From fff04af46db4ea936e12440fa137405954343cd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 4 Jun 2026 13:23:36 -0400 Subject: [PATCH] docs: document session metadata helpers --- src/config/sessions/delivery-info.ts | 5 +++++ src/config/sessions/disk-budget.ts | 12 ++++++++++++ .../sessions/explicit-session-key-normalization.ts | 1 + src/config/sessions/goals.ts | 5 +++++ src/config/sessions/group.ts | 4 ++++ src/config/sessions/lifecycle.ts | 1 + src/config/sessions/metadata.ts | 3 +++ 7 files changed, 31 insertions(+) diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index 1a76132bb637..9cead0c21187 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -1,3 +1,4 @@ +// Delivery lookup recovers routable channel context from persisted session stores. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { resolveSessionStoreAgentId, @@ -83,6 +84,8 @@ export function extractDeliveryInfo( function resolveDeliveryStorePaths(cfg: OpenClawConfig, agentId: string): string[] { const paths = new Set(); paths.add(resolveStorePath(cfg.session?.store, { agentId })); + // Delivery can be restored from any resolved agent target; store order keeps the configured + // primary path first while still covering per-agent stores. for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) { if (target.agentId === agentId) { paths.add(target.storePath); @@ -205,6 +208,8 @@ function buildFreshestSessionEntryIndex( ) { index.set(normalized, entry); } + // Lowercase aliases are only indexed when case folding is not proof-sensitive; Matrix-style + // opaque ids must keep exact-case delivery evidence. const foldedLegacyKey = normalizeLowercaseStringOrEmpty(normalized); if (foldedLegacyKey === normalized || requiresFoldedSessionKeyAliasProof(normalized)) { continue; diff --git a/src/config/sessions/disk-budget.ts b/src/config/sessions/disk-budget.ts index 186ef6901553..28d61c270e64 100644 --- a/src/config/sessions/disk-budget.ts +++ b/src/config/sessions/disk-budget.ts @@ -1,3 +1,4 @@ +// Session disk-budget enforcement prunes orphaned artifacts before deleting store entries. import fs from "node:fs"; import path from "node:path"; import { @@ -144,6 +145,8 @@ function resolveSessionTranscriptPathForEntry(params: { const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir); const resolvedPath = canonicalizePathForComparison(resolved); const relative = path.relative(resolvedSessionsDir, resolvedPath); + // Cleanup only owns artifacts under the sessions directory; absolute/parent escapes are + // ignored even if a stale entry points there. if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { return null; } @@ -373,6 +376,8 @@ async function removeFileForBudget(params: { const resolvedPath = path.resolve(params.filePath); const canonicalPath = params.canonicalPath ?? canonicalizePathForComparison(resolvedPath); if (params.dryRun) { + // Dry-run deletion is path-deduped so a transcript and pointer alias cannot count the same + // artifact twice against the simulated budget. if (params.simulatedRemovedPaths.has(canonicalPath)) { return 0; } @@ -453,6 +458,8 @@ export async function pruneUnreferencedSessionArtifacts(params: { sessionsDir, store: params.store, }); + // Prompt refs are projected through the persistence layer so inline snapshots and externalized + // prompt blobs are judged against the bytes that would actually hit disk. const projectedPromptBlobRefCounts = buildProjectedPromptBlobRefCounts( projectSessionStoreForPersistence({ storePath: params.storePath, @@ -583,6 +590,8 @@ export async function enforceSessionDiskBudget(params: { (sum, bytes) => sum + bytes, 0, ); + // Budget starts from current files, then swaps in the projected store/prompt bytes that the next + // persistence pass will write. let total = [...files, ...promptBlobFiles].reduce((sum, file) => sum + file.size, 0) - (storeFile?.size ?? 0) + @@ -642,6 +651,7 @@ export async function enforceSessionDiskBudget(params: { ); }) .toSorted((a, b) => a.mtimeMs - b.mtimeMs); + // Cheapest cleanup first: orphaned prompt blobs can relieve pressure without losing sessions. for (const file of unreferencedPromptBlobQueue) { if (total <= highWaterBytes) { break; @@ -669,6 +679,7 @@ export async function enforceSessionDiskBudget(params: { isDiskBudgetRemovableSessionFile(file, referencedPaths, tempStaleCutoffMs, storeBasename), ) .toSorted((a, b) => a.mtimeMs - b.mtimeMs); + // Then remove stale artifacts already detached from live entries. for (const file of removableFileQueue) { if (total <= highWaterBytes) { break; @@ -698,6 +709,7 @@ export async function enforceSessionDiskBudget(params: { const bTime = getEntryUpdatedAt(params.store[b]); return aTime - bTime; }); + // Last resort: delete oldest non-preserved sessions, then their now-unreferenced artifacts. for (const key of keys) { if (total <= highWaterBytes) { break; diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 9dae2d9f06d1..1da8dbd40239 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,3 +1,4 @@ +// Explicit session keys are normalized by the channel that owns their opaque id shape. import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, diff --git a/src/config/sessions/goals.ts b/src/config/sessions/goals.ts index 3cd11f1d988f..5d905e4b2dea 100644 --- a/src/config/sessions/goals.ts +++ b/src/config/sessions/goals.ts @@ -1,3 +1,4 @@ +// Session goal state tracks objective progress and token budgets in the session store. import crypto from "node:crypto"; import { getSessionEntry, patchSessionEntry } from "./store.js"; import { resolveFreshSessionTotalTokens } from "./types.js"; @@ -101,6 +102,8 @@ function accountGoalUsage( } const totalTokens = resolveEntryFreshTotalTokens(entry); const hasFreshStart = goal.tokenStartFresh !== false; + // Old entries may have a stale token baseline; display-only reads can hold it, while persisted + // reads adopt the fresh total so future budget checks use current accounting. const shouldHoldStaleStart = !hasFreshStart && options?.adoptFreshBaseline === false; const shouldAdoptFreshStart = !shouldHoldStaleStart && totalTokens !== undefined && !hasFreshStart; @@ -175,6 +178,7 @@ export async function getSessionGoal( ): Promise { const now = nowMs(options.now); if (options.persist === false) { + // Status rendering should not write incidental budget/baseline adoption unless callers opt in. const entry = getSessionEntry({ sessionKey: options.sessionKey, storePath: options.storePath }) ?? options.fallbackEntry; @@ -265,6 +269,7 @@ export async function updateSessionGoalStatus( (accounted.status === "budget_limited" || accounted.status === "usage_limited" || (accounted.tokenBudget !== undefined && accounted.tokensUsed >= accounted.tokenBudget)); + // Resuming from a limited state starts a new budget window at the current fresh token count. const freshTokenStart = resetsBudgetWindow ? resolveEntryFreshTotalTokens(entry) : undefined; const next: SessionGoal = { ...accounted, diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 7bd4e9d80c91..9c8915cba601 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -1,3 +1,4 @@ +// Group session keys convert channel-specific group metadata into stable store ids. import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -17,6 +18,8 @@ type LegacyGroupSessionSurface = { }; function resolveLegacyGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { + // Legacy plugin resolvers stay first-class because some channels still expose native group ids + // only through channel-owned context parsing. for (const plugin of listChannelPlugins()) { const resolved = ( plugin.messaging as LegacyGroupSessionSurface | undefined @@ -93,6 +96,7 @@ export function buildGroupDisplayName(params: { const fallbackId = normalizeOptionalString(params.id) ?? params.key; const rawLabel = detail || fallbackId; let token = normalizeGroupLabel(rawLabel); + // Very long opaque ids become a readable stable token instead of leaking full route ids into UI. if (!token) { token = normalizeGroupLabel(shortenGroupId(rawLabel)); } diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 8a6c7284ee24..d528d759471c 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -1,3 +1,4 @@ +// Session lifecycle timestamps prefer store metadata and fall back to transcript headers. import fs from "node:fs"; import { asDateTimestampMs } from "../../shared/number-coercion.js"; import { diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index c20b3dd48063..37ed188bf5cf 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -1,3 +1,4 @@ +// Session metadata derives stable origin, group, and display fields from message context. import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -137,6 +138,8 @@ export function deriveGroupSessionPatch(params: { const space = params.ctx.GroupSpace?.trim(); const explicitChannel = params.ctx.GroupChannel?.trim(); const subjectLooksChannel = Boolean(subject?.startsWith("#")); + // Channel-looking subjects become `groupChannel` only for channel-capable providers; ordinary + // group chats keep the subject as human-readable metadata. const normalizedChannel = subjectLooksChannel && resolution.chatType !== "channel" ? normalizeChannelId(channel) : null; const isChannelProvider = Boolean(