diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 5ab5c1a8071d..a981e7655cd2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index cf430c759e19..7701f18feac1 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -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. + Default model and provider constants: diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index cdeb5ce73835..a4507c0818df 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | diff --git a/package.json b/package.json index fb70792e62e8..eea3f085cacb 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 6356a6fc2cca..5b42328040d6 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -104,6 +104,9 @@ export const pluginSdkDocMetadata = { "runtime-store": { category: "runtime", }, + "session-transcript-runtime": { + category: "runtime", + }, "qa-live-transport-scenarios": { category: "utilities", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 4c99bc66a681..e0d8005b9661 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -216,6 +216,7 @@ "session-key-runtime", "session-store-runtime", "session-transcript-hit", + "session-transcript-runtime", "session-visibility", "ssrf-dispatcher", "string-coerce-runtime", diff --git a/src/plugin-sdk/session-transcript-hit.test.ts b/src/plugin-sdk/session-transcript-hit.test.ts index 3e575950dfa6..b2444ff02983 100644 --- a/src/plugin-sdk/session-transcript-hit.test.ts +++ b/src/plugin-sdk/session-transcript-hit.test.ts @@ -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 = { + "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", + ]); + }); +}); diff --git a/src/plugin-sdk/session-transcript-hit.ts b/src/plugin-sdk/session-transcript-hit.ts index a4a9025a592d..852ef658569e 100644 --- a/src/plugin-sdk/session-transcript-hit.ts +++ b/src/plugin-sdk/session-transcript-hit.ts @@ -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"; diff --git a/src/plugin-sdk/session-transcript-runtime.test.ts b/src/plugin-sdk/session-transcript-runtime.test.ts new file mode 100644 index 000000000000..f3942bc9e4fe --- /dev/null +++ b/src/plugin-sdk/session-transcript-runtime.test.ts @@ -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([]); + }); +}); diff --git a/src/plugin-sdk/session-transcript-runtime.ts b/src/plugin-sdk/session-transcript-runtime.ts new file mode 100644 index 000000000000..b7a5a7e4d0af --- /dev/null +++ b/src/plugin-sdk/session-transcript-runtime.ts @@ -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; +}; + +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 { + 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 { + 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)]; +}