Files
openclaw/src/plugin-sdk/session-transcript-runtime.ts
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* refactor(sessions): migrate runtime storage to sqlite

* test(sessions): fix sqlite CI regressions

* test(sessions): align remaining sqlite fixtures

* fix(codex): require sqlite trajectory recorder

* test(sessions): align orphan recovery sqlite fixture

* test(sessions): align sqlite rebase fixtures

* fix(sessions): finish current-main integration of the sqlite flip

Resolve the whole-store SDK removal across its owner boundary: drop the
loadSessionStore re-export and the registry whole-store wrappers, wire
hasTrackedActiveSessionRun into gateway chat, complete the
preserveLockedHarnessIds cleanup contract, flip the codex thread-history
import to storePath targets, and port remaining main-side tests from
file-store helpers to session accessor reads.

* chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs

Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore
the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain
bump gets its own review, and regenerate docs_map, the plugin SDK API baseline,
and the export-surface ratchet for the merged tree.

* feat(sessions): keep archived transcripts by default with zstd cold storage

Codex-style retention: deleting or resetting a session archives its
transcript as a zstd-compressed JSONL artifact (plain when the runtime
lacks node:zlib zstd) and keeps it until the disk budget evicts oldest
first. resetArchiveRetention now governs both deleted and reset archives
and defaults to keep; maxDiskBytes defaults to 2gb so retention stays
bounded, with archives evicted before live sessions. The cron reaper
follows the same knob instead of deleting archives on its own timer.

* fix(state): converge agent DB migration lineages and bound database growth

Merge coherence: run both structure-gated legacy memory-schema repairs
(flip-lineage drop, main-lineage identity rebuild) before the flip
migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all
converge, and hoist foreign_keys=OFF outside the schema transaction
where the pragma was silently ignored and the v1 sessions rebuild
cascade-deleted session_entries.

Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL
maintenance releases freed pages in bounded passes (never a blocking
full VACUUM), and doctor reports state/agent DB bloat from freelist
stats.

* fix(codex): resolve the store path for thread-history import via the SDK

The supervision catalog passed the legacy sessionFile locator to the
storePath-targeted transcript mirror; resolve the agent store path with
the session-store SDK helper instead of a runtime-object seam so test
fakes and headless callers need no extra surface. Drop the obsolete
missing-session-id preprocessing case: sessions rows are NOT NULL on
session_id and upsert repairs id-less patches at write time.

* fix(sessions): fail safe on malformed disk-budget config and doctor stat errors

A malformed explicit maxDiskBytes disables the budget instead of
falling back to the destructive 2gb default the user never chose, and
the doctor bloat check skips databases whose paths stat-fail instead of
aborting doctor.

* fix(sessions): complete sqlite conflict translations

* test(sqlite): align hardening checks with maintenance

* test(sessions): inspect compressed transcript archives

* fix(tests): await session seeds and drop unused helpers flagged by CI lint

The five unawaited writeSessionStoreSeed calls raced their SQLite seeds
against the assertions, failing compact shards; the bloat probe drops a
useless initializer and the merged tests drop now-unused helpers.

* test(sessions): type legacy proof events directly

* test(sessions): align hardening contracts

* perf(sessions): read usage transcript sizes from SQL aggregates

Usage/cost scans walked every session and materialized every transcript
event just to re-stringify it for a byte estimate — the #86718 stall
class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes
in SQLite without loading a single row.

* fix(sessions): re-root foreign-root transcript paths onto the current sessions dir

Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry
absolute sessionFile paths from the old root; the containment fallback
kept those foreign paths, so migration read (and would archive) files in
the original root and reported local copies missing. Re-root the
canonical agents/<id>/sessions suffix onto the current dir when the file
exists there; genuine cross-root layouts still fall through unchanged.

* test(agents): seed harness admission through sqlite

* fix(sqlite): close agent db on pragma setup failure

* fix(doctor): compact and retrofit incremental auto-vacuum after session import

The migration is the sanctioned offline window: post-import compact
reclaims import churn and applies auto_vacuum=INCREMENTAL to databases
created before the fresh-DB pragma existed, so runtime maintenance can
release pages in bounded passes on every install.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 14:50:37 -07:00

453 lines
15 KiB
TypeScript

import { redactTranscriptMessage } from "../agents/transcript-redact.js";
import {
appendTranscriptMessage,
loadSessionEntry,
loadTranscriptEvents,
publishTranscriptUpdate,
readLatestTranscriptAssistantText,
resolveSessionTranscriptRuntimeReadTarget,
resolveSessionTranscriptRuntimeTarget,
withTranscriptWriteLock,
type TranscriptMessageAppendOptions,
type TranscriptMessageAppendResult,
type TranscriptUpdatePayload,
} from "../config/sessions/session-accessor.js";
import { resolveMirroredTranscriptText } from "../config/sessions/transcript-mirror.js";
import {
selectVisibleTranscriptEventEntries,
selectVisibleTranscriptEvents,
} from "../config/sessions/transcript-visible-events.js";
import type {
LatestAssistantTranscriptText,
SessionTranscriptAppendResult,
SessionTranscriptAssistantMessage,
SessionTranscriptDeliveryMirror,
SessionTranscriptUpdateMode,
} from "../config/sessions/transcript.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
import type { AgentMessage } from "./agent-core.js";
import {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
type ResolveSessionTranscriptMemoryHitKeyParams,
type SessionTranscriptIdentity,
type SessionTranscriptMemoryHitIdentity,
type SessionTranscriptMemoryHitKey,
type SessionTranscriptMemoryHitKeyParams,
type SessionTranscriptReadParams,
} from "./session-transcript-memory-hit.js";
export {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
};
export type {
ResolveSessionTranscriptMemoryHitKeyParams,
SessionTranscriptIdentity,
SessionTranscriptMemoryHitIdentity,
SessionTranscriptMemoryHitKey,
SessionTranscriptMemoryHitKeyParams,
SessionTranscriptReadParams,
};
export type SessionTranscriptEvent = unknown;
export type SessionTranscriptTargetParams = SessionTranscriptReadParams;
export type SessionTranscriptMessageEntry = {
/** Stable transcript event id for this message entry. */
entryId: string;
/** Parent id after active-branch normalization; null when this is a visible root. */
parentId: string | null;
/** Ordered read metadata for this full transcript read, not a resumable cursor. */
seq: number;
/** Redacted agent message payload as persisted by the runtime. */
message: AgentMessage;
/** Convenience mirror of message.role. */
role: AgentMessage["role"];
/** Entry timestamp recorded by the transcript store, when present. */
createdAt?: string;
/** Message idempotency key, when the persisted message has one. */
idempotencyKey?: string;
};
export type SessionTranscriptTarget = SessionTranscriptIdentity & {
targetKind: "runtime-session";
};
export type SessionTranscriptAppendMessageParams<TMessage> = SessionTranscriptTargetParams &
TranscriptMessageAppendOptions<TMessage>;
export type SessionTranscriptAssistantMirrorAppendParams = SessionTranscriptReadParams & {
config?: OpenClawConfig;
deliveryMirror?: SessionTranscriptDeliveryMirror;
idempotencyKey?: string;
mediaUrls?: string[];
text?: string;
updateMode?: SessionTranscriptUpdateMode;
};
export type SessionTranscriptWriteLockParams = SessionTranscriptTargetParams & {
config?: TranscriptMessageAppendOptions<unknown>["config"];
};
export type SessionTranscriptWriteLockContext = {
appendMessage: <TMessage>(
options: Omit<TranscriptMessageAppendOptions<TMessage>, "config">,
) => Promise<TranscriptMessageAppendResult<TMessage> | undefined>;
publishUpdate: (update?: TranscriptUpdatePayload) => Promise<void>;
readEvents: () => Promise<SessionTranscriptEvent[]>;
target: SessionTranscriptTarget;
};
type SessionTranscriptMirrorAppendResult =
| { ok: true; messageId: string }
| Extract<SessionTranscriptAppendResult, { ok: false }>;
/**
* Resolves the public identity for a transcript without returning its file path.
*/
export async function resolveSessionTranscriptIdentity(
params: SessionTranscriptReadParams,
): Promise<SessionTranscriptIdentity> {
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
const agentId = normalizeAgentId(target.agentId);
return {
agentId,
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
sessionId: target.sessionId,
sessionKey: target.sessionKey,
};
}
/**
* Resolves the public target for transcript operations without exposing the
* current storage path as identity.
*/
export async function resolveSessionTranscriptTarget(
params: SessionTranscriptTargetParams,
): Promise<SessionTranscriptTarget> {
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
return projectPublicTarget({
...target,
targetKind: "runtime-session",
});
}
/**
* Reads transcript events by public session identity instead of file path.
*/
export async function readSessionTranscriptEvents(
params: SessionTranscriptTargetParams,
): Promise<SessionTranscriptEvent[]> {
return await loadTranscriptEvents(params);
}
/**
* Reads visible transcript message entries by scoped identity.
*
* This is a branch-safe message projection over the current full transcript
* read. `seq` is ordered read metadata, not a resumable cursor.
*/
export async function readVisibleSessionTranscriptMessageEntries(
params: SessionTranscriptTargetParams,
): Promise<SessionTranscriptMessageEntry[]> {
return selectVisibleTranscriptEventEntries(await loadTranscriptEvents(params)).flatMap(
projectVisibleMessageEntry,
);
}
/**
* Reads the latest visible assistant text by scoped identity.
*/
export async function readLatestAssistantTextByIdentity(
params: SessionTranscriptTargetParams,
): Promise<LatestAssistantTranscriptText | undefined> {
return readLatestTranscriptAssistantText(params);
}
/**
* Appends a delivery-mirror assistant message through the SQLite transcript accessor.
*/
export async function appendAssistantMirrorMessageByIdentity(
params: SessionTranscriptAssistantMirrorAppendParams,
): Promise<SessionTranscriptMirrorAppendResult> {
const text = resolveMirroredTranscriptText({
...(params.mediaUrls !== undefined ? { mediaUrls: params.mediaUrls } : {}),
...(params.text !== undefined ? { text: params.text } : {}),
});
if (!text) {
return { ok: false, reason: "empty message" };
}
const message = createAssistantMirrorMessage({
...(params.deliveryMirror !== undefined ? { deliveryMirror: params.deliveryMirror } : {}),
...(params.idempotencyKey !== undefined ? { idempotencyKey: params.idempotencyKey } : {}),
text,
});
return await withTranscriptWriteLock(params, async (locked) => {
const currentEntry = loadSessionEntry(params);
if (!currentEntry?.sessionId) {
return { ok: false, reason: "missing active session", code: "blocked" };
}
if (params.sessionId && currentEntry.sessionId !== params.sessionId) {
return { ok: false, reason: "session changed", code: "session-rebound" };
}
const scope = {
...params,
sessionId: currentEntry.sessionId,
};
const target = await resolveSessionTranscriptRuntimeReadTarget(scope);
const latestEquivalentAssistantId =
!params.idempotencyKey && isDeliveryMirrorAssistantMessage(message)
? findLatestEquivalentAssistantMessageId(
selectVisibleTranscriptEvents(await locked.readEvents()),
message,
params.config,
)
: undefined;
if (latestEquivalentAssistantId) {
return {
ok: true,
messageId: latestEquivalentAssistantId,
};
}
const appendResult = await locked.appendMessage({
...(params.config !== undefined ? { config: params.config } : {}),
...(params.idempotencyKey ? { idempotencyLookup: "scan" as const } : {}),
message,
});
if (!appendResult) {
return { ok: false, reason: "message skipped", code: "blocked" };
}
if (params.updateMode !== "none" && appendResult.appended) {
await publishTranscriptUpdate(scope, {
agentId: target.agentId,
messageId: appendResult.messageId,
sessionKey: target.sessionKey,
target: {
agentId: target.agentId,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
},
});
}
return {
ok: true,
messageId: appendResult.messageId,
};
});
}
/**
* Appends a transcript message by scoped transcript target.
*/
export async function appendSessionTranscriptMessageByIdentity<TMessage>(
params: SessionTranscriptAppendMessageParams<TMessage>,
): Promise<TranscriptMessageAppendResult<TMessage> | undefined> {
return await appendTranscriptMessage(params, params);
}
/**
* Publishes a transcript update by scoped transcript target.
*/
export async function publishSessionTranscriptUpdateByIdentity(
params: SessionTranscriptTargetParams & { update?: TranscriptUpdatePayload },
): Promise<void> {
const target = await resolveSessionTranscriptRuntimeTarget(params);
await publishTranscriptUpdate(
{
...params,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
},
{
...params.update,
agentId: target.agentId,
sessionKey: target.sessionKey,
target: {
agentId: target.agentId,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
},
},
);
}
/**
* Runs transcript work under the write lock for the resolved scoped target.
*/
export async function withSessionTranscriptWriteLock<T>(
params: SessionTranscriptWriteLockParams,
run: (context: SessionTranscriptWriteLockContext) => Promise<T> | T,
): Promise<T> {
const storageTarget = await resolveSessionTranscriptRuntimeTarget(params);
const target = projectPublicTarget({
...storageTarget,
targetKind: "runtime-session",
});
const boundScope = {
...params,
sessionId: storageTarget.sessionId,
sessionKey: storageTarget.sessionKey,
};
// Treat publishUpdate as a post-commit callback: future transactional stores
// must not expose updates when the scoped write callback fails.
const queuedUpdates: Array<TranscriptUpdatePayload | undefined> = [];
const result = await withTranscriptWriteLock(
boundScope,
async (locked) =>
await run({
target,
readEvents: locked.readEvents,
appendMessage: (options) =>
locked.appendMessage({
...options,
...(params.config !== undefined ? { config: params.config } : {}),
}),
publishUpdate: async (update) => {
queuedUpdates.push(update ? { ...update } : undefined);
},
}),
);
for (const update of queuedUpdates) {
await publishSessionTranscriptUpdateByIdentity({
...boundScope,
update,
});
}
return result;
}
function createAssistantMirrorMessage(params: {
deliveryMirror?: SessionTranscriptDeliveryMirror;
idempotencyKey?: string;
text: string;
}): SessionTranscriptAssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: params.text }],
api: "openai-responses",
provider: "openclaw",
model: "delivery-mirror",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
...(params.deliveryMirror ? { openclawDeliveryMirror: params.deliveryMirror } : {}),
};
}
function findLatestEquivalentAssistantMessageId(
events: readonly SessionTranscriptEvent[],
message: SessionTranscriptAssistantMessage,
config: OpenClawConfig | undefined,
): string | undefined {
const expectedText = extractAssistantMirrorComparableText(message, config);
if (!expectedText) {
return undefined;
}
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || typeof event !== "object") {
continue;
}
const record = event as { id?: unknown; message?: unknown };
const candidate = record.message as SessionTranscriptAssistantMessage | undefined;
if (!candidate) {
continue;
}
if (candidate.role !== "assistant") {
return undefined;
}
return extractAssistantMirrorComparableText(candidate, config) === expectedText &&
typeof record.id === "string" &&
record.id
? record.id
: undefined;
}
return undefined;
}
function extractAssistantMirrorComparableText(
message: SessionTranscriptAssistantMessage,
config: OpenClawConfig | undefined,
): string | undefined {
const redacted = redactTranscriptMessage(
message as Parameters<typeof redactTranscriptMessage>[0],
config,
) as SessionTranscriptAssistantMessage;
return extractAssistantVisibleText(redacted)?.trim() || undefined;
}
function isDeliveryMirrorAssistantMessage(message: SessionTranscriptAssistantMessage): boolean {
return message.provider === "openclaw" && message.model === "delivery-mirror";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function isAgentMessageRecord(value: unknown): value is AgentMessage & Record<string, unknown> {
return isRecord(value) && readNonEmptyString(value.role) !== undefined;
}
function projectVisibleMessageEntry(entry: {
event: SessionTranscriptEvent;
parentId: string | null;
seq: number;
}): SessionTranscriptMessageEntry[] {
const event = entry.event;
if (!isRecord(event) || event.type !== "message") {
return [];
}
const entryId = readNonEmptyString(event.id);
const message = event.message;
if (!entryId || !isAgentMessageRecord(message)) {
return [];
}
const createdAt = readNonEmptyString(event.timestamp);
const idempotencyKey = readNonEmptyString(message.idempotencyKey);
return [
{
entryId,
parentId: entry.parentId,
seq: entry.seq,
message,
role: message.role,
...(createdAt ? { createdAt } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
},
];
}
function projectPublicTarget(target: {
agentId: string;
sessionId: string;
sessionKey: string;
targetKind: SessionTranscriptTarget["targetKind"];
}): SessionTranscriptTarget {
const agentId = normalizeAgentId(target.agentId);
return {
agentId,
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
sessionId: target.sessionId,
sessionKey: target.sessionKey,
targetKind: target.targetKind,
};
}