docs: document session metadata helpers

This commit is contained in:
Peter Steinberger
2026-06-04 13:23:36 -04:00
parent c87c1569d5
commit fff04af46d
7 changed files with 31 additions and 0 deletions

View File

@@ -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<string>();
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;

View File

@@ -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;

View File

@@ -1,3 +1,4 @@
// Explicit session keys are normalized by the channel that owns their opaque id shape.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,

View File

@@ -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<SessionGoalSnapshot> {
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,

View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -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(