Skip to content

Commit 7a0d36f

Browse files
authored
refactor: add SDK transcript identity target API (#95030)
1 parent 0a707af commit 7a0d36f

14 files changed

Lines changed: 867 additions & 8 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
2-
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
1+
9edb033535fe1325c18b431190672dc3a826dba312e376c13c98fcf9043060dd plugin-sdk-api-baseline.json
2+
78f26963fe2e6d7903ce2e1067699200d825f391c0010df46f48d9abd2915e65 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-runtime.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,9 @@ 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.
168168

169-
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
169+
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
170+
171+
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
170172

171173
</Accordion>
172174
<Accordion title="api.runtime.agent.defaults">

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
248248
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
249249
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
250250
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
251+
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
251252
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
252253
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
253254
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,10 @@
975975
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
976976
"default": "./dist/plugin-sdk/session-store-runtime.js"
977977
},
978+
"./plugin-sdk/session-transcript-runtime": {
979+
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
980+
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
981+
},
978982
"./plugin-sdk/sqlite-runtime": {
979983
"types": "./dist/plugin-sdk/sqlite-runtime.d.ts",
980984
"default": "./dist/plugin-sdk/sqlite-runtime.js"

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export const pluginSdkDocMetadata = {
114114
"runtime-store": {
115115
category: "runtime",
116116
},
117+
"session-transcript-runtime": {
118+
category: "runtime",
119+
},
117120
"sqlite-runtime": {
118121
category: "runtime",
119122
},

scripts/lib/plugin-sdk-entrypoints.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@
217217
"session-binding-runtime",
218218
"session-key-runtime",
219219
"session-store-runtime",
220+
"session-transcript-runtime",
220221
"sqlite-runtime",
221222
"sqlite-runtime-testing",
222223
"session-transcript-hit",

scripts/plugin-sdk-surface-report.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
125125
"channel-policy": 8,
126126
"channel-route": 5,
127127
"session-store-runtime": 1,
128+
"session-transcript-runtime": 1,
128129
"group-access": 13,
129130
"media-generation-runtime-shared": 3,
130131
"music-generation-core": 20,
@@ -161,9 +162,9 @@ let budgets;
161162
let publicDeprecatedExportsByEntrypointBudget;
162163
try {
163164
budgets = {
164-
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 320),
165-
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10301),
166-
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5171),
165+
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
166+
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10331),
167+
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5183),
167168
publicDeprecatedExports: readBudgetEnv(
168169
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
169170
3245,

src/config/sessions/session-accessor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,11 @@ export async function loadTranscriptEvents(
866866
const transcript = await resolveTranscriptReadAccess(scope);
867867
const events: TranscriptEvent[] = [];
868868
for await (const line of streamSessionTranscriptLines(transcript.sessionFile)) {
869-
events.push(JSON.parse(line) as TranscriptEvent);
869+
try {
870+
events.push(JSON.parse(line) as TranscriptEvent);
871+
} catch {
872+
continue;
873+
}
870874
}
871875
return events;
872876
}

src/config/sessions/transcript-append.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
2626
streamSessionTranscriptLinesReverse,
2727
} from "./transcript-stream.js";
2828
import { isCanonicalSessionTranscriptEntry } from "./transcript-tree.js";
29-
import { resolveOwnedSessionTranscriptWriteLockRunner } from "./transcript-write-context.js";
29+
import {
30+
resolveOwnedSessionTranscriptWriteLockRunner,
31+
type OwnedSessionTranscriptPublishedEntry,
32+
} from "./transcript-write-context.js";
3033
import { CURRENT_SESSION_VERSION } from "./version.js";
3134

3235
const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024;
@@ -385,6 +388,13 @@ export type AppendSessionTranscriptMessageResult<TMessage> = {
385388
appended: boolean;
386389
};
387390

391+
export type SessionTranscriptAppendTransactionContext = {
392+
appendEvent: (event: unknown) => Promise<void>;
393+
appendMessage: <TMessage>(
394+
params: Omit<AppendSessionTranscriptMessageParams<TMessage>, "config" | "transcriptPath">,
395+
) => Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined>;
396+
};
397+
388398
function isTranscriptAgentMessage(value: unknown): value is AgentMessage {
389399
return (
390400
typeof value === "object" &&
@@ -464,6 +474,57 @@ export async function appendSessionTranscriptMessageWithOwnedWriteLock<TMessage>
464474
return await activeLockRunner(() => appendSessionTranscriptMessageLocked(params));
465475
}
466476

477+
/**
478+
* Runs a group of transcript appends through one append queue and write lock.
479+
*/
480+
export async function runSessionTranscriptAppendTransaction<T>(
481+
params: Pick<AppendSessionTranscriptMessageParams, "config" | "transcriptPath">,
482+
run: (context: SessionTranscriptAppendTransactionContext) => Promise<T> | T,
483+
): Promise<T> {
484+
const publishedEntries: OwnedSessionTranscriptPublishedEntry[] = [];
485+
const runTransaction = async (): Promise<T> =>
486+
await run({
487+
appendEvent: async (event) => {
488+
const result = await appendSessionTranscriptEventLocked({
489+
config: params.config,
490+
event,
491+
transcriptPath: params.transcriptPath,
492+
});
493+
publishedEntries.push({ kind: "serialized", serialized: result.serializedEntry });
494+
},
495+
appendMessage: async (messageParams) => {
496+
const result = await appendSessionTranscriptMessageLocked({
497+
...messageParams,
498+
config: params.config,
499+
onHeaderCreated: (header) => {
500+
publishedEntries.push({ kind: "header", serialized: header });
501+
},
502+
transcriptPath: params.transcriptPath,
503+
});
504+
if (result?.appended === true) {
505+
publishedEntries.push({ kind: "id", id: result.messageId });
506+
}
507+
return result;
508+
},
509+
});
510+
const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({
511+
sessionFile: params.transcriptPath,
512+
});
513+
if (activeLockRunner) {
514+
return await activeLockRunner(
515+
() => withSessionTranscriptAppendQueue(params.transcriptPath, runTransaction),
516+
{
517+
publishOwnedWrite: true,
518+
resolvePublishedEntries: () => publishedEntries,
519+
resolvePublishedEntriesAfterFailure: () => publishedEntries,
520+
},
521+
);
522+
}
523+
return await withSessionTranscriptAppendQueue(params.transcriptPath, () =>
524+
withSessionTranscriptWriteLock(params, runTransaction),
525+
);
526+
}
527+
467528
type AppendSessionTranscriptEventParams = {
468529
config?: OpenClawConfig;
469530
event: unknown;

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
// Session transcript hit tests cover transcript match formatting and path resolution.
2+
import fs from "node:fs";
23
import { describe, expect, it } from "vitest";
34
import type { SessionEntry } from "../config/sessions/types.js";
45
import {
56
extractTranscriptIdentityFromSessionsMemoryHit,
67
extractTranscriptStemFromSessionsMemoryHit,
8+
formatSessionTranscriptMemoryHitKey,
9+
parseSessionTranscriptMemoryHitKey,
10+
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
711
resolveTranscriptStemToSessionKeys,
812
} from "./session-transcript-hit.js";
913

@@ -273,3 +277,34 @@ describe("resolveTranscriptStemToSessionKeys", () => {
273277
).toEqual([]);
274278
});
275279
});
280+
281+
describe("session transcript memory hit key compatibility exports", () => {
282+
it("keeps hit-subpath memory helpers off the runtime writer import path", () => {
283+
const source = fs.readFileSync(new URL("./session-transcript-hit.ts", import.meta.url), "utf8");
284+
285+
expect(source).not.toContain("session-transcript-runtime.js");
286+
});
287+
288+
it("exports storage-neutral memory hit key helpers from the legacy hit subpath", () => {
289+
const key = formatSessionTranscriptMemoryHitKey({
290+
agentId: "main",
291+
sessionId: "session:legacy",
292+
});
293+
const store: Record<string, SessionEntry> = {
294+
"agent:main:discord:direct:42": {
295+
sessionFile: "/tmp/not-the-identity.jsonl",
296+
sessionId: "session:legacy",
297+
updatedAt: 10,
298+
},
299+
};
300+
301+
expect(key).toBe("transcript:main:session%3Alegacy");
302+
expect(parseSessionTranscriptMemoryHitKey(key)).toMatchObject({
303+
agentId: "main",
304+
sessionId: "session:legacy",
305+
});
306+
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store })).toEqual([
307+
"agent:main:discord:direct:42",
308+
]);
309+
});
310+
});

0 commit comments

Comments
 (0)