Skip to content

Commit f6d5949

Browse files
committed
clawdbot-d02.1.9.1.20: add public SDK transcript identity API
1 parent a5012a3 commit f6d5949

10 files changed

Lines changed: 309 additions & 2 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
2-
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
1+
15ceed8879fabeecd3e7c87726f121b64ee490b5c92135ecb3915490dc71934a plugin-sdk-api-baseline.json
2+
d2be9469fa4952b4861c1a98dc0dee0327057afe98e2ac6b35a90ce5b0091f57 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-runtime.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ two-party event loops that do not go through the shared inbound reply runner.
166166

167167
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.
168168

169+
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.
170+
169171
</Accordion>
170172
<Accordion title="api.runtime.agent.defaults">
171173
Default model and provider constants:

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ usage endpoint failed or returned no usable usage data.
245245
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
246246
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
247247
| `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 |
248+
| `plugin-sdk/session-transcript-runtime` | Transcript identity, read-events, and storage-neutral memory hit key helpers that do not expose `sessionFile` as identity |
248249
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
249250
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
250251
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,10 @@
949949
"types": "./dist/plugin-sdk/session-transcript-hit.d.ts",
950950
"default": "./dist/plugin-sdk/session-transcript-hit.js"
951951
},
952+
"./plugin-sdk/session-transcript-runtime": {
953+
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
954+
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
955+
},
952956
"./plugin-sdk/session-visibility": {
953957
"types": "./dist/plugin-sdk/session-visibility.d.ts",
954958
"default": "./dist/plugin-sdk/session-visibility.js"

scripts/lib/plugin-sdk-doc-metadata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export const pluginSdkDocMetadata = {
104104
"runtime-store": {
105105
category: "runtime",
106106
},
107+
"session-transcript-runtime": {
108+
category: "runtime",
109+
},
107110
"qa-live-transport-scenarios": {
108111
category: "utilities",
109112
},

scripts/lib/plugin-sdk-entrypoints.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
"session-key-runtime",
217217
"session-store-runtime",
218218
"session-transcript-hit",
219+
"session-transcript-runtime",
219220
"session-visibility",
220221
"ssrf-dispatcher",
221222
"string-coerce-runtime",

src/plugin-sdk/session-transcript-hit.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import type { SessionEntry } from "../config/sessions/types.js";
33
import {
44
extractTranscriptIdentityFromSessionsMemoryHit,
55
extractTranscriptStemFromSessionsMemoryHit,
6+
formatSessionTranscriptMemoryHitKey,
7+
parseSessionTranscriptMemoryHitKey,
8+
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
69
resolveTranscriptStemToSessionKeys,
710
} from "./session-transcript-hit.js";
811

@@ -272,3 +275,28 @@ describe("resolveTranscriptStemToSessionKeys", () => {
272275
).toEqual([]);
273276
});
274277
});
278+
279+
describe("session transcript memory hit key compatibility exports", () => {
280+
it("exports storage-neutral memory hit key helpers from the legacy hit subpath", () => {
281+
const key = formatSessionTranscriptMemoryHitKey({
282+
agentId: "main",
283+
sessionId: "session:legacy",
284+
});
285+
const store: Record<string, SessionEntry> = {
286+
"agent:main:discord:direct:42": {
287+
sessionFile: "/tmp/not-the-identity.jsonl",
288+
sessionId: "session:legacy",
289+
updatedAt: 10,
290+
},
291+
};
292+
293+
expect(key).toBe("transcript:main:session%3Alegacy");
294+
expect(parseSessionTranscriptMemoryHitKey(key)).toMatchObject({
295+
agentId: "main",
296+
sessionId: "session:legacy",
297+
});
298+
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store })).toEqual([
299+
"agent:main:discord:direct:42",
300+
]);
301+
});
302+
});

src/plugin-sdk/session-transcript-hit.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import { uniqueStrings } from "../../packages/normalization-core/src/string-norm
44
import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js";
55
import type { SessionEntry } from "../config/sessions/types.js";
66
import { normalizeAgentId } from "../routing/session-key.js";
7+
export {
8+
formatSessionTranscriptMemoryHitKey,
9+
parseSessionTranscriptMemoryHitKey,
10+
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
11+
} from "./session-transcript-runtime.js";
12+
export type {
13+
ResolveSessionTranscriptMemoryHitKeyParams,
14+
SessionTranscriptIdentity,
15+
SessionTranscriptMemoryHitIdentity,
16+
SessionTranscriptMemoryHitKey,
17+
SessionTranscriptMemoryHitKeyParams,
18+
SessionTranscriptReadParams,
19+
} from "./session-transcript-runtime.js";
720

821
export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js";
922

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { appendTranscriptEvent, upsertSessionEntry } from "../config/sessions/session-accessor.js";
6+
import { loadSessionStore } from "../config/sessions/store.js";
7+
import {
8+
formatSessionTranscriptMemoryHitKey,
9+
parseSessionTranscriptMemoryHitKey,
10+
readSessionTranscriptEvents,
11+
resolveSessionTranscriptIdentity,
12+
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
13+
} from "./session-transcript-runtime.js";
14+
15+
describe("session transcript runtime SDK", () => {
16+
let tempDir: string;
17+
let storePath: string;
18+
19+
beforeEach(() => {
20+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-transcript-"));
21+
storePath = path.join(tempDir, "sessions.json");
22+
});
23+
24+
afterEach(() => {
25+
fs.rmSync(tempDir, { force: true, recursive: true });
26+
});
27+
28+
it("resolves transcript identity and reads events without returning sessionFile", async () => {
29+
const scope = {
30+
agentId: "Main",
31+
sessionId: "session-with-colon",
32+
sessionKey: "agent:main:main",
33+
storePath,
34+
};
35+
const event = { id: "event-1", type: "message" };
36+
37+
await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 });
38+
await appendTranscriptEvent(scope, event);
39+
40+
const identity = await resolveSessionTranscriptIdentity(scope);
41+
42+
expect(identity).toEqual({
43+
agentId: "main",
44+
memoryKey: "transcript:main:session-with-colon",
45+
sessionId: scope.sessionId,
46+
sessionKey: "agent:main:main",
47+
});
48+
expect(identity).not.toHaveProperty("sessionFile");
49+
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([event]);
50+
});
51+
52+
it("round-trips encoded memory hit keys with opaque session ids", () => {
53+
const key = formatSessionTranscriptMemoryHitKey({
54+
agentId: "SECONDARY",
55+
sessionId: "my-plugin:task/1",
56+
});
57+
58+
expect(key).toBe("transcript:secondary:my-plugin%3Atask%2F1");
59+
expect(parseSessionTranscriptMemoryHitKey(key)).toEqual({
60+
agentId: "secondary",
61+
key,
62+
sessionId: "my-plugin:task/1",
63+
});
64+
});
65+
66+
it("resolves memory hit keys by agent and session id instead of transcript basename", async () => {
67+
const scope = {
68+
agentId: "main",
69+
sessionId: "session-id",
70+
sessionKey: "agent:main:telegram:direct:123",
71+
storePath,
72+
};
73+
await upsertSessionEntry(scope, {
74+
sessionFile: path.join(tempDir, "legacy-file-name.jsonl"),
75+
sessionId: scope.sessionId,
76+
updatedAt: 10,
77+
});
78+
79+
const keys = resolveSessionTranscriptMemoryHitKeyToSessionKeys({
80+
key: formatSessionTranscriptMemoryHitKey(scope),
81+
store: loadSessionStore(storePath),
82+
});
83+
84+
expect(keys).toEqual(["agent:main:telegram:direct:123"]);
85+
});
86+
87+
it("can avoid synthetic fallback keys for strict live-store checks", () => {
88+
const key = formatSessionTranscriptMemoryHitKey({
89+
agentId: "main",
90+
sessionId: "deleted-session",
91+
});
92+
93+
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store: {} })).toEqual([
94+
"agent:main:deleted-session",
95+
]);
96+
expect(
97+
resolveSessionTranscriptMemoryHitKeyToSessionKeys({
98+
includeSyntheticFallback: false,
99+
key,
100+
store: {},
101+
}),
102+
).toEqual([]);
103+
});
104+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
2+
import { uniqueStrings } from "../../packages/normalization-core/src/string-normalization.js";
3+
import {
4+
loadTranscriptEvents,
5+
resolveSessionTranscriptRuntimeTarget,
6+
} from "../config/sessions/session-accessor.js";
7+
import type { SessionEntry } from "../config/sessions/types.js";
8+
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
9+
10+
const SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX = "transcript";
11+
12+
export type SessionTranscriptEvent = unknown;
13+
14+
export type SessionTranscriptIdentity = {
15+
agentId: string;
16+
memoryKey: SessionTranscriptMemoryHitKey;
17+
sessionId: string;
18+
sessionKey: string;
19+
};
20+
21+
export type SessionTranscriptMemoryHitIdentity = {
22+
agentId: string;
23+
key: SessionTranscriptMemoryHitKey;
24+
sessionId: string;
25+
};
26+
27+
export type SessionTranscriptMemoryHitKey = `transcript:${string}:${string}`;
28+
29+
export type SessionTranscriptReadParams = {
30+
agentId?: string;
31+
env?: NodeJS.ProcessEnv;
32+
hydrateSkillPromptRefs?: boolean;
33+
sessionId: string;
34+
sessionKey: string;
35+
storePath?: string;
36+
threadId?: string | number;
37+
};
38+
39+
export type SessionTranscriptMemoryHitKeyParams = {
40+
agentId: string;
41+
sessionId: string;
42+
};
43+
44+
export type ResolveSessionTranscriptMemoryHitKeyParams = {
45+
includeSyntheticFallback?: boolean;
46+
key: string;
47+
store: Record<string, SessionEntry>;
48+
};
49+
50+
function requireMemoryKeySegment(value: string, label: string): string {
51+
const normalized = normalizeOptionalString(value);
52+
if (!normalized) {
53+
throw new Error(`Cannot build session transcript memory hit key without ${label}.`);
54+
}
55+
return encodeURIComponent(normalized);
56+
}
57+
58+
function decodeMemoryKeySegment(value: string): string | null {
59+
try {
60+
return normalizeOptionalString(decodeURIComponent(value)) ?? null;
61+
} catch {
62+
return null;
63+
}
64+
}
65+
66+
function syntheticSessionKey(identity: SessionTranscriptMemoryHitIdentity): string {
67+
return `agent:${identity.agentId}:${identity.sessionId}`;
68+
}
69+
70+
/**
71+
* Builds the storage-neutral memory hit key for one session transcript.
72+
*/
73+
export function formatSessionTranscriptMemoryHitKey(
74+
params: SessionTranscriptMemoryHitKeyParams,
75+
): SessionTranscriptMemoryHitKey {
76+
const agentId = requireMemoryKeySegment(normalizeAgentId(params.agentId), "agentId");
77+
const sessionId = requireMemoryKeySegment(params.sessionId, "sessionId");
78+
return `${SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX}:${agentId}:${sessionId}`;
79+
}
80+
81+
/**
82+
* Parses a storage-neutral session transcript memory hit key.
83+
*/
84+
export function parseSessionTranscriptMemoryHitKey(
85+
key: string,
86+
): SessionTranscriptMemoryHitIdentity | null {
87+
const parts = key.split(":");
88+
if (parts.length !== 3 || parts[0] !== SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX) {
89+
return null;
90+
}
91+
const agentId = decodeMemoryKeySegment(parts[1] ?? "");
92+
const sessionId = decodeMemoryKeySegment(parts[2] ?? "");
93+
if (!agentId || !sessionId) {
94+
return null;
95+
}
96+
return {
97+
agentId: normalizeAgentId(agentId),
98+
key: formatSessionTranscriptMemoryHitKey({ agentId, sessionId }),
99+
sessionId,
100+
};
101+
}
102+
103+
/**
104+
* Resolves the public identity for a transcript without returning its file path.
105+
*/
106+
export async function resolveSessionTranscriptIdentity(
107+
params: SessionTranscriptReadParams,
108+
): Promise<SessionTranscriptIdentity> {
109+
const target = await resolveSessionTranscriptRuntimeTarget(params);
110+
const agentId = normalizeAgentId(target.agentId);
111+
return {
112+
agentId,
113+
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
114+
sessionId: target.sessionId,
115+
sessionKey: target.sessionKey,
116+
};
117+
}
118+
119+
/**
120+
* Reads transcript events by public session identity instead of file path.
121+
*/
122+
export async function readSessionTranscriptEvents(
123+
params: SessionTranscriptReadParams,
124+
): Promise<SessionTranscriptEvent[]> {
125+
return await loadTranscriptEvents(params);
126+
}
127+
128+
/**
129+
* Maps a storage-neutral memory hit key back to visible session store keys.
130+
*/
131+
export function resolveSessionTranscriptMemoryHitKeyToSessionKeys(
132+
params: ResolveSessionTranscriptMemoryHitKeyParams,
133+
): string[] {
134+
const identity = parseSessionTranscriptMemoryHitKey(params.key);
135+
if (!identity) {
136+
return [];
137+
}
138+
const matches = Object.entries(params.store)
139+
.filter(([sessionKey, entry]) => {
140+
return (
141+
entry.sessionId === identity.sessionId &&
142+
normalizeAgentId(resolveAgentIdFromSessionKey(sessionKey)) === identity.agentId
143+
);
144+
})
145+
.map(([sessionKey]) => sessionKey);
146+
const deduped = uniqueStrings(matches);
147+
if (deduped.length > 0) {
148+
return deduped;
149+
}
150+
return params.includeSyntheticFallback === false ? [] : [syntheticSessionKey(identity)];
151+
}

0 commit comments

Comments
 (0)