clawdbot-d02.1.9.1.20: add public SDK transcript identity API

This commit is contained in:
Josh Lehman
2026-06-01 12:51:44 -07:00
parent a5012a331e
commit f6d5949599
10 changed files with 309 additions and 2 deletions

View File

@@ -1,2 +1,2 @@
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
15ceed8879fabeecd3e7c87726f121b64ee490b5c92135ecb3915490dc71934a plugin-sdk-api-baseline.json
d2be9469fa4952b4861c1a98dc0dee0327057afe98e2ac6b35a90ce5b0091f57 plugin-sdk-api-baseline.jsonl

View File

@@ -166,6 +166,8 @@ two-party event loops that do not go through the shared inbound reply runner.
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
For transcript reads, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)` or `readSessionTranscriptEvents(...)` with `{ agentId, sessionKey, sessionId }`. That API returns stable transcript identity and storage-neutral memory hit keys without exposing `sessionFile` as the identity. File-path helpers such as `resolveSessionFilePath(...)`, `resolveAndPersistSessionFile(...)`, and `readLatestAssistantTextFromSessionTranscript(...)` are legacy compatibility exports for plugins that still own file-backed behavior.
</Accordion>
<Accordion title="api.runtime.agent.defaults">
Default model and provider constants:

View File

@@ -245,6 +245,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, read-events, and storage-neutral memory hit key helpers that do not expose `sessionFile` as identity |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |

View File

@@ -949,6 +949,10 @@
"types": "./dist/plugin-sdk/session-transcript-hit.d.ts",
"default": "./dist/plugin-sdk/session-transcript-hit.js"
},
"./plugin-sdk/session-transcript-runtime": {
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
},
"./plugin-sdk/session-visibility": {
"types": "./dist/plugin-sdk/session-visibility.d.ts",
"default": "./dist/plugin-sdk/session-visibility.js"

View File

@@ -104,6 +104,9 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"session-transcript-runtime": {
category: "runtime",
},
"qa-live-transport-scenarios": {
category: "utilities",
},

View File

@@ -216,6 +216,7 @@
"session-key-runtime",
"session-store-runtime",
"session-transcript-hit",
"session-transcript-runtime",
"session-visibility",
"ssrf-dispatcher",
"string-coerce-runtime",

View File

@@ -3,6 +3,9 @@ import type { SessionEntry } from "../config/sessions/types.js";
import {
extractTranscriptIdentityFromSessionsMemoryHit,
extractTranscriptStemFromSessionsMemoryHit,
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
resolveTranscriptStemToSessionKeys,
} from "./session-transcript-hit.js";
@@ -272,3 +275,28 @@ describe("resolveTranscriptStemToSessionKeys", () => {
).toEqual([]);
});
});
describe("session transcript memory hit key compatibility exports", () => {
it("exports storage-neutral memory hit key helpers from the legacy hit subpath", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "main",
sessionId: "session:legacy",
});
const store: Record<string, SessionEntry> = {
"agent:main:discord:direct:42": {
sessionFile: "/tmp/not-the-identity.jsonl",
sessionId: "session:legacy",
updatedAt: 10,
},
};
expect(key).toBe("transcript:main:session%3Alegacy");
expect(parseSessionTranscriptMemoryHitKey(key)).toMatchObject({
agentId: "main",
sessionId: "session:legacy",
});
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store })).toEqual([
"agent:main:discord:direct:42",
]);
});
});

View File

@@ -4,6 +4,19 @@ import { uniqueStrings } from "../../packages/normalization-core/src/string-norm
import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
export {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
} from "./session-transcript-runtime.js";
export type {
ResolveSessionTranscriptMemoryHitKeyParams,
SessionTranscriptIdentity,
SessionTranscriptMemoryHitIdentity,
SessionTranscriptMemoryHitKey,
SessionTranscriptMemoryHitKeyParams,
SessionTranscriptReadParams,
} from "./session-transcript-runtime.js";
export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js";

View File

@@ -0,0 +1,104 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { appendTranscriptEvent, upsertSessionEntry } from "../config/sessions/session-accessor.js";
import { loadSessionStore } from "../config/sessions/store.js";
import {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
readSessionTranscriptEvents,
resolveSessionTranscriptIdentity,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
} from "./session-transcript-runtime.js";
describe("session transcript runtime SDK", () => {
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-transcript-"));
storePath = path.join(tempDir, "sessions.json");
});
afterEach(() => {
fs.rmSync(tempDir, { force: true, recursive: true });
});
it("resolves transcript identity and reads events without returning sessionFile", async () => {
const scope = {
agentId: "Main",
sessionId: "session-with-colon",
sessionKey: "agent:main:main",
storePath,
};
const event = { id: "event-1", type: "message" };
await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 });
await appendTranscriptEvent(scope, event);
const identity = await resolveSessionTranscriptIdentity(scope);
expect(identity).toEqual({
agentId: "main",
memoryKey: "transcript:main:session-with-colon",
sessionId: scope.sessionId,
sessionKey: "agent:main:main",
});
expect(identity).not.toHaveProperty("sessionFile");
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([event]);
});
it("round-trips encoded memory hit keys with opaque session ids", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "SECONDARY",
sessionId: "my-plugin:task/1",
});
expect(key).toBe("transcript:secondary:my-plugin%3Atask%2F1");
expect(parseSessionTranscriptMemoryHitKey(key)).toEqual({
agentId: "secondary",
key,
sessionId: "my-plugin:task/1",
});
});
it("resolves memory hit keys by agent and session id instead of transcript basename", async () => {
const scope = {
agentId: "main",
sessionId: "session-id",
sessionKey: "agent:main:telegram:direct:123",
storePath,
};
await upsertSessionEntry(scope, {
sessionFile: path.join(tempDir, "legacy-file-name.jsonl"),
sessionId: scope.sessionId,
updatedAt: 10,
});
const keys = resolveSessionTranscriptMemoryHitKeyToSessionKeys({
key: formatSessionTranscriptMemoryHitKey(scope),
store: loadSessionStore(storePath),
});
expect(keys).toEqual(["agent:main:telegram:direct:123"]);
});
it("can avoid synthetic fallback keys for strict live-store checks", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "main",
sessionId: "deleted-session",
});
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store: {} })).toEqual([
"agent:main:deleted-session",
]);
expect(
resolveSessionTranscriptMemoryHitKeyToSessionKeys({
includeSyntheticFallback: false,
key,
store: {},
}),
).toEqual([]);
});
});

View File

@@ -0,0 +1,151 @@
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
import { uniqueStrings } from "../../packages/normalization-core/src/string-normalization.js";
import {
loadTranscriptEvents,
resolveSessionTranscriptRuntimeTarget,
} from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX = "transcript";
export type SessionTranscriptEvent = unknown;
export type SessionTranscriptIdentity = {
agentId: string;
memoryKey: SessionTranscriptMemoryHitKey;
sessionId: string;
sessionKey: string;
};
export type SessionTranscriptMemoryHitIdentity = {
agentId: string;
key: SessionTranscriptMemoryHitKey;
sessionId: string;
};
export type SessionTranscriptMemoryHitKey = `transcript:${string}:${string}`;
export type SessionTranscriptReadParams = {
agentId?: string;
env?: NodeJS.ProcessEnv;
hydrateSkillPromptRefs?: boolean;
sessionId: string;
sessionKey: string;
storePath?: string;
threadId?: string | number;
};
export type SessionTranscriptMemoryHitKeyParams = {
agentId: string;
sessionId: string;
};
export type ResolveSessionTranscriptMemoryHitKeyParams = {
includeSyntheticFallback?: boolean;
key: string;
store: Record<string, SessionEntry>;
};
function requireMemoryKeySegment(value: string, label: string): string {
const normalized = normalizeOptionalString(value);
if (!normalized) {
throw new Error(`Cannot build session transcript memory hit key without ${label}.`);
}
return encodeURIComponent(normalized);
}
function decodeMemoryKeySegment(value: string): string | null {
try {
return normalizeOptionalString(decodeURIComponent(value)) ?? null;
} catch {
return null;
}
}
function syntheticSessionKey(identity: SessionTranscriptMemoryHitIdentity): string {
return `agent:${identity.agentId}:${identity.sessionId}`;
}
/**
* Builds the storage-neutral memory hit key for one session transcript.
*/
export function formatSessionTranscriptMemoryHitKey(
params: SessionTranscriptMemoryHitKeyParams,
): SessionTranscriptMemoryHitKey {
const agentId = requireMemoryKeySegment(normalizeAgentId(params.agentId), "agentId");
const sessionId = requireMemoryKeySegment(params.sessionId, "sessionId");
return `${SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX}:${agentId}:${sessionId}`;
}
/**
* Parses a storage-neutral session transcript memory hit key.
*/
export function parseSessionTranscriptMemoryHitKey(
key: string,
): SessionTranscriptMemoryHitIdentity | null {
const parts = key.split(":");
if (parts.length !== 3 || parts[0] !== SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX) {
return null;
}
const agentId = decodeMemoryKeySegment(parts[1] ?? "");
const sessionId = decodeMemoryKeySegment(parts[2] ?? "");
if (!agentId || !sessionId) {
return null;
}
return {
agentId: normalizeAgentId(agentId),
key: formatSessionTranscriptMemoryHitKey({ agentId, sessionId }),
sessionId,
};
}
/**
* Resolves the public identity for a transcript without returning its file path.
*/
export async function resolveSessionTranscriptIdentity(
params: SessionTranscriptReadParams,
): Promise<SessionTranscriptIdentity> {
const target = await resolveSessionTranscriptRuntimeTarget(params);
const agentId = normalizeAgentId(target.agentId);
return {
agentId,
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
sessionId: target.sessionId,
sessionKey: target.sessionKey,
};
}
/**
* Reads transcript events by public session identity instead of file path.
*/
export async function readSessionTranscriptEvents(
params: SessionTranscriptReadParams,
): Promise<SessionTranscriptEvent[]> {
return await loadTranscriptEvents(params);
}
/**
* Maps a storage-neutral memory hit key back to visible session store keys.
*/
export function resolveSessionTranscriptMemoryHitKeyToSessionKeys(
params: ResolveSessionTranscriptMemoryHitKeyParams,
): string[] {
const identity = parseSessionTranscriptMemoryHitKey(params.key);
if (!identity) {
return [];
}
const matches = Object.entries(params.store)
.filter(([sessionKey, entry]) => {
return (
entry.sessionId === identity.sessionId &&
normalizeAgentId(resolveAgentIdFromSessionKey(sessionKey)) === identity.agentId
);
})
.map(([sessionKey]) => sessionKey);
const deduped = uniqueStrings(matches);
if (deduped.length > 0) {
return deduped;
}
return params.includeSyntheticFallback === false ? [] : [syntheticSessionKey(identity)];
}