diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index a083c9f127f..2d498f6b67c 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -961,6 +961,30 @@ describe("active-memory plugin", () => { ); }); + it("keeps legacy dm session keys eligible for direct recall", async () => { + api.pluginConfig = { + agents: ["main"], + allowedChatTypes: ["direct"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + const result = await hooks.before_prompt_build( + { prompt: "what did we discuss?", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:dm:peer-123", + messageProvider: "telegram", + channelId: "telegram", + }, + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + prependContext: expect.stringContaining(""), + }); + }); + it("uses messageProvider not Google Chat space id for embedded recall (#78918)", async () => { seedSessionEntry("agent:main:googlechat:default:direct:spaces/khfx4yaaaae", { chatType: "direct", @@ -1020,6 +1044,30 @@ describe("active-memory plugin", () => { }); }); + it("keeps exact explicit session keys eligible when no session row exists yet", async () => { + api.pluginConfig = { + agents: ["main"], + allowedChatTypes: ["explicit"], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + const result = await hooks.before_prompt_build( + { prompt: "what should i work on next?", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:explicit", + messageProvider: "custom", + channelId: "custom", + }, + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + prependContext: expect.stringContaining(""), + }); + }); + it("keeps explicit session classification when the opaque session id contains chat-type tokens", async () => { seedSessionEntry("agent:main:explicit:portal-123:group:shadow", { chatType: "explicit" }); api.pluginConfig = { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index ad940306907..241d8b55eba 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { loadSqliteSessionTranscriptEvents } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { loadSqliteSessionTranscriptBoundedEvents } from "openclaw/plugin-sdk/agent-harness-runtime"; import { DEFAULT_PROVIDER, parseModelRef, @@ -1105,6 +1105,9 @@ function resolveChatType(ctx: { if (sessionKey.includes(":direct:")) { return "direct"; } + if (sessionKey.includes(":dm:")) { + return "direct"; + } if (sessionKey.includes(":group:")) { return "group"; } @@ -1114,6 +1117,9 @@ function resolveChatType(ctx: { if (sessionKey.includes(":explicit:")) { return "explicit"; } + if (/^agent:[^:]+:explicit$/.test(sessionKey)) { + return "explicit"; + } if (/^agent:[^:]+:main:thread:/.test(sessionKey)) { return "direct"; } @@ -1537,11 +1543,12 @@ async function streamBoundedTranscriptEvents(params: { }): Promise { const limits = resolveTranscriptReadLimits(params.limits); try { - const events = loadSqliteSessionTranscriptEvents(params.transcriptScope); - if (JSON.stringify(events.map((entry) => entry.event)).length > limits.maxBytes) { - return; - } - for (const { event } of events.slice(0, limits.maxLines)) { + const events = loadSqliteSessionTranscriptBoundedEvents({ + ...params.transcriptScope, + maxBytes: limits.maxBytes, + maxEvents: limits.maxLines, + }); + for (const { event } of events) { if (params.onRecord(event)) { break; } diff --git a/src/commands/doctor/legacy/session-transcript-health.test.ts b/src/commands/doctor/legacy/session-transcript-health.test.ts index fef6aeac033..aa1844fb0c0 100644 --- a/src/commands/doctor/legacy/session-transcript-health.test.ts +++ b/src/commands/doctor/legacy/session-transcript-health.test.ts @@ -203,6 +203,20 @@ describe("doctor session transcript repair", () => { threadId: "thread-123", cwd: root, model: "gpt-5.5", + userMcpServersFingerprint: "user-mcp-v1", + mcpServersFingerprint: "mcp-v1", + pluginAppsFingerprint: "plugin-apps-v1", + pluginAppsInputFingerprint: "plugin-app-input-v1", + pluginAppPolicyContext: { + fingerprint: "policy-v1", + apps: {}, + pluginAppIds: {}, + }, + contextEngine: { + schemaVersion: 1, + engineId: "context-engine", + policyFingerprint: "context-policy-v1", + }, }), ); @@ -220,6 +234,20 @@ describe("doctor session transcript repair", () => { sessionId: "session-1", cwd: root, model: "gpt-5.5", + userMcpServersFingerprint: "user-mcp-v1", + mcpServersFingerprint: "mcp-v1", + pluginAppsFingerprint: "plugin-apps-v1", + pluginAppsInputFingerprint: "plugin-app-input-v1", + pluginAppPolicyContext: { + fingerprint: "policy-v1", + apps: {}, + pluginAppIds: {}, + }, + contextEngine: { + schemaVersion: 1, + engineId: "context-engine", + policyFingerprint: "context-policy-v1", + }, }); const [message, title] = note.mock.calls[0] as [string, string]; expect(title).toBe("Session transcripts"); diff --git a/src/commands/doctor/legacy/session-transcript-health.ts b/src/commands/doctor/legacy/session-transcript-health.ts index 83f780ec359..a8429c4a62d 100644 --- a/src/commands/doctor/legacy/session-transcript-health.ts +++ b/src/commands/doctor/legacy/session-transcript-health.ts @@ -332,7 +332,9 @@ function normalizeCodexAppServerBindingPayload( ) { return undefined; } + const payload = JSON.parse(JSON.stringify(parsed)) as Record; return { + ...payload, schemaVersion: 1, sessionId, threadId: parsed.threadId, diff --git a/src/config/sessions/transcript-store.sqlite.test.ts b/src/config/sessions/transcript-store.sqlite.test.ts index cbfea15e076..f465e869ba6 100644 --- a/src/config/sessions/transcript-store.sqlite.test.ts +++ b/src/config/sessions/transcript-store.sqlite.test.ts @@ -19,6 +19,7 @@ import { countSqliteSessionTranscriptDisplayMessages, deleteSqliteSessionTranscript, listSqliteSessionTranscripts, + loadSqliteSessionTranscriptBoundedEvents, loadSqliteSessionTranscriptEvents, loadSqliteSessionTranscriptTailEvents, recordSqliteSessionTranscriptSnapshot, @@ -290,6 +291,45 @@ describe("SQLite session transcript store", () => { ).toBe(8); }); + it("reads bounded transcript heads without materializing rows beyond caps", () => { + const stateDir = createTempDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + replaceSqliteSessionTranscriptEvents({ + env, + agentId: "main", + sessionId: "session-1", + events: [ + { type: "session", id: "session-1" }, + { type: "message", id: "m1", message: { role: "assistant", content: "short" } }, + { + type: "message", + id: "m2", + message: { role: "assistant", content: "this row should not be parsed" }, + }, + ], + now: () => 100, + }); + + expect( + loadSqliteSessionTranscriptBoundedEvents({ + env, + agentId: "main", + sessionId: "session-1", + maxEvents: 2, + maxBytes: 120, + }).map((entry) => (entry.event as { id?: string }).id), + ).toEqual(["session-1", "m1"]); + expect( + loadSqliteSessionTranscriptBoundedEvents({ + env, + agentId: "main", + sessionId: "session-1", + maxEvents: 3, + maxBytes: 8, + }), + ).toEqual([]); + }); + it("preserves event timestamps when replacing transcript rows", () => { const stateDir = createTempDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/config/sessions/transcript-store.sqlite.ts b/src/config/sessions/transcript-store.sqlite.ts index 90e15475c02..705521d1b47 100644 --- a/src/config/sessions/transcript-store.sqlite.ts +++ b/src/config/sessions/transcript-store.sqlite.ts @@ -51,6 +51,12 @@ export type LoadSqliteSessionTranscriptTailEventsOptions = SqliteSessionTranscri maxEvents: number; }; +export type LoadSqliteSessionTranscriptBoundedEventsOptions = + SqliteSessionTranscriptStoreOptions & { + maxBytes?: number; + maxEvents: number; + }; + export type SqliteSessionTranscriptScope = { agentId: string; path?: string; @@ -738,6 +744,41 @@ export function loadSqliteSessionTranscriptTailEvents( return selected.toReversed().map(parseTranscriptEventRow); } +export function loadSqliteSessionTranscriptBoundedEvents( + options: LoadSqliteSessionTranscriptBoundedEventsOptions, +): SqliteSessionTranscriptEvent[] { + const { sessionId } = normalizeTranscriptScope(options); + const database = openTranscriptAgentDatabase(options); + const maxEvents = normalizePositiveInteger(options.maxEvents, 1); + const maxBytes = + typeof options.maxBytes === "number" && Number.isFinite(options.maxBytes) + ? Math.max(1, Math.floor(options.maxBytes)) + : undefined; + const rows = executeSqliteQuerySync( + database.db, + getAgentTranscriptKysely(database.db) + .selectFrom("transcript_events") + .select(["seq", "event_json", "created_at"]) + .where("session_id", "=", sessionId) + .orderBy("seq", "asc") + .limit(maxEvents), + ).rows; + const selected: typeof rows = []; + let bytes = 0; + for (const row of rows) { + const eventBytes = Buffer.byteLength(row.event_json, "utf8") + 1; + if (maxBytes !== undefined && selected.length > 0 && bytes + eventBytes > maxBytes) { + break; + } + if (maxBytes !== undefined && selected.length === 0 && eventBytes > maxBytes) { + return []; + } + selected.push(row); + bytes += eventBytes; + } + return selected.map(parseTranscriptEventRow); +} + export function countSqliteSessionTranscriptDisplayMessages( options: SqliteSessionTranscriptStoreOptions, ): number { diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 6f5a6c6f1d9..1ccb631070b 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -36,6 +36,7 @@ export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js export { appendSqliteSessionTranscriptEvent, hasSqliteSessionTranscriptEvents, + loadSqliteSessionTranscriptBoundedEvents, loadSqliteSessionTranscriptEvents, replaceSqliteSessionTranscriptEvents, } from "../config/sessions/transcript-store.sqlite.js";