From cd37fc89c8743f4d7999378734961baf76695f5d Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:26:09 -0400 Subject: [PATCH] Move session summaries off blocking /new --- index.ts | 227 +++++++++++---------- package.json | 2 +- test/plugin-manifest-regression.mjs | 9 +- test/session-summary-before-reset.test.mjs | 166 +++++++++++++++ 4 files changed, 293 insertions(+), 111 deletions(-) create mode 100644 test/session-summary-before-reset.test.mjs diff --git a/index.ts b/index.ts index 0b67869..2c2e3e4 100644 --- a/index.ts +++ b/index.ts @@ -268,6 +268,13 @@ function resolveHookAgentId( : parseAgentIdFromSessionKey(sessionKey)) || "main"; } +function resolveSourceFromSessionKey(sessionKey: string | undefined): string { + const trimmed = sessionKey?.trim() ?? ""; + const match = /^agent:[^:]+:([^:]+)/.exec(trimmed); + const source = match?.[1]?.trim(); + return source || "unknown"; +} + function summarizeAgentEndMessages(messages: unknown[]): string { const roleCounts = new Map(); let textBlocks = 0; @@ -938,31 +945,48 @@ function extractTextFromToolResult(result: unknown): string { } } +function summarizeRecentConversationMessages( + messages: readonly unknown[], + messageCount: number, +): string | null { + if (!Array.isArray(messages) || messages.length === 0) return null; + + const recent: string[] = []; + for (let index = messages.length - 1; index >= 0 && recent.length < messageCount; index--) { + const raw = messages[index]; + if (!raw || typeof raw !== "object") continue; + + const msg = raw as Record; + const role = typeof msg.role === "string" ? msg.role : ""; + if (role !== "user" && role !== "assistant") continue; + + const text = extractTextContent(msg.content); + if (!text || shouldSkipReflectionMessage(role, text)) continue; + + recent.push(`${role}: ${redactSecrets(text)}`); + } + + if (recent.length === 0) return null; + recent.reverse(); + return recent.join("\n"); +} + async function readSessionConversationForReflection(filePath: string, messageCount: number): Promise { try { const lines = (await readFile(filePath, "utf-8")).trim().split("\n"); - const messages: string[] = []; + const messages: unknown[] = []; for (const line of lines) { try { const entry = JSON.parse(line); if (entry?.type !== "message" || !entry?.message) continue; - - const msg = entry.message as Record; - const role = typeof msg.role === "string" ? msg.role : ""; - if (role !== "user" && role !== "assistant") continue; - - const text = extractTextContent(msg.content); - if (!text || shouldSkipReflectionMessage(role, text)) continue; - - messages.push(`${role}: ${redactSecrets(text)}`); + messages.push(entry.message); } catch { // ignore JSON parse errors } } - if (messages.length === 0) return null; - return messages.slice(-messageCount).join("\n"); + return summarizeRecentConversationMessages(messages, messageCount); } catch { return null; } @@ -3256,121 +3280,108 @@ const memoryLanceDBProPlugin = { if (config.sessionStrategy === "systemSessionMemory") { const sessionMessageCount = config.sessionMemory?.messageCount ?? 15; - api.registerHook("command:new", async (event) => { - try { - api.logger.debug("session-memory: hook triggered for /new command"); + const storeSystemSessionSummary = async (params: { + agentId: string; + defaultScope: string; + sessionKey: string; + sessionId: string; + source: string; + sessionContent: string; + timestampMs?: number; + }) => { + const now = new Date(params.timestampMs ?? Date.now()); + const dateStr = now.toISOString().split("T")[0]; + const timeStr = now.toISOString().split("T")[1].split(".")[0]; + const memoryText = [ + `Session: ${dateStr} ${timeStr} UTC`, + `Session Key: ${params.sessionKey}`, + `Session ID: ${params.sessionId}`, + `Source: ${params.source}`, + "", + "Conversation Summary:", + params.sessionContent, + ].join("\n"); + + const vector = await embedder.embedPassage(memoryText); + await store.store({ + text: memoryText, + vector, + category: "fact", + scope: params.defaultScope, + importance: 0.5, + metadata: stringifySmartMetadata( + buildSmartMetadata( + { + text: `Session summary for ${dateStr}`, + category: "fact", + importance: 0.5, + timestamp: Date.now(), + }, + { + l0_abstract: `Session summary for ${dateStr}`, + l1_overview: `- Session summary saved for ${params.sessionId}`, + l2_content: memoryText, + memory_category: "patterns", + tier: "peripheral", + confidence: 0.5, + type: "session-summary", + sessionKey: params.sessionKey, + sessionId: params.sessionId, + date: dateStr, + agentId: params.agentId, + scope: params.defaultScope, + }, + ), + ), + }); - const context = (event.context || {}) as Record; - const sessionKey = typeof event.sessionKey === "string" ? event.sessionKey : ""; + api.logger.info( + `session-memory: stored session summary for ${params.sessionId} (agent: ${params.agentId}, scope: ${params.defaultScope})` + ); + }; + + api.on("before_reset", async (event, ctx) => { + if (event.reason !== "new") return; + + try { + const sessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : ""; const agentId = resolveHookAgentId( - (event.agentId as string) || (context.agentId as string) || undefined, - sessionKey || (context.sessionKey as string) || undefined, + typeof ctx.agentId === "string" ? ctx.agentId : undefined, + sessionKey, ); const defaultScope = isSystemBypassId(agentId) ? config.scopes?.default ?? "global" : scopeManager.getDefaultScope(agentId); - const workspaceDir = resolveWorkspaceDirFromContext(context); - const cfg = context.cfg; - const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record; - const currentSessionId = typeof sessionEntry.sessionId === "string" ? sessionEntry.sessionId : "unknown"; - let currentSessionFile = typeof sessionEntry.sessionFile === "string" ? sessionEntry.sessionFile : undefined; - const source = typeof context.commandSource === "string" ? context.commandSource : "unknown"; - - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const searchDirs = resolveReflectionSessionSearchDirs({ - context, - cfg, - workspaceDir, - currentSessionFile, - sourceAgentId: agentId, - }); + const currentSessionId = + typeof ctx.sessionId === "string" && ctx.sessionId.trim().length > 0 + ? ctx.sessionId + : "unknown"; + const source = resolveSourceFromSessionKey(sessionKey); + const sessionContent = + summarizeRecentConversationMessages(event.messages ?? [], sessionMessageCount) ?? + (typeof event.sessionFile === "string" + ? await readSessionConversationWithResetFallback(event.sessionFile, sessionMessageCount) + : null); - for (const sessionsDir of searchDirs) { - const recovered = await findPreviousSessionFile( - sessionsDir, - currentSessionFile, - currentSessionId, - ); - if (recovered) { - currentSessionFile = recovered; - api.logger.debug(`session-memory: recovered session file: ${recovered}`); - break; - } - } - } - - if (!currentSessionFile) { - api.logger.debug("session-memory: no session file found, skipping"); - return; - } - - const sessionContent = await readSessionConversationWithResetFallback( - currentSessionFile, - sessionMessageCount, - ); if (!sessionContent) { api.logger.debug("session-memory: no session content found, skipping"); return; } - const now = new Date(typeof event.timestamp === "number" ? event.timestamp : Date.now()); - const dateStr = now.toISOString().split("T")[0]; - const timeStr = now.toISOString().split("T")[1].split(".")[0]; - const memoryText = [ - `Session: ${dateStr} ${timeStr} UTC`, - `Session Key: ${sessionKey}`, - `Session ID: ${currentSessionId}`, - `Source: ${source}`, - "", - "Conversation Summary:", + await storeSystemSessionSummary({ + agentId, + defaultScope, + sessionKey, + sessionId: currentSessionId, + source, sessionContent, - ].join("\n"); - - const vector = await embedder.embedPassage(memoryText); - await store.store({ - text: memoryText, - vector, - category: "fact", - scope: defaultScope, - importance: 0.5, - metadata: stringifySmartMetadata( - buildSmartMetadata( - { - text: `Session summary for ${dateStr}`, - category: "fact", - importance: 0.5, - timestamp: Date.now(), - }, - { - l0_abstract: `Session summary for ${dateStr}`, - l1_overview: `- Session summary saved for ${currentSessionId}`, - l2_content: memoryText, - memory_category: "patterns", - tier: "peripheral", - confidence: 0.5, - type: "session-summary", - sessionKey, - sessionId: currentSessionId, - date: dateStr, - agentId, - scope: defaultScope, - }, - ), - ), }); - - api.logger.info( - `session-memory: stored session summary for ${currentSessionId} (agent: ${agentId}, scope: ${defaultScope})` - ); } catch (err) { api.logger.warn(`session-memory: failed to save: ${String(err)}`); } - }, { - name: "memory-lancedb-pro-session-memory", - description: "Store /new session summaries in LanceDB memory", }); - api.logger.info("session-memory: hook registered for command:new as memory-lancedb-pro-session-memory"); + api.logger.info("session-memory: typed before_reset hook registered for /new session summaries"); } if (config.sessionStrategy === "none") { api.logger.info("session-strategy: using none (plugin memory-reflection hooks disabled)"); diff --git a/package.json b/package.json index 52d25c2..cfd47cd 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ ] }, "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs", "test:openclaw-host": "node test/openclaw-host-functional.mjs", "version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json" }, diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index d021b39..4ea48bc 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -187,9 +187,14 @@ try { }); plugin.register(sessionEnabledApi); assert.equal( - typeof sessionEnabledApi.hooks["command:new"], + typeof sessionEnabledApi.hooks.before_reset, "function", - "sessionMemory.enabled=true should register the /new hook", + "sessionMemory.enabled=true should register the async before_reset hook", + ); + assert.equal( + sessionEnabledApi.hooks["command:new"], + undefined, + "sessionMemory.enabled=true should not register the blocking command:new hook", ); const longText = `${"Long embedding payload. ".repeat(420)}tail`; diff --git a/test/session-summary-before-reset.test.mjs b/test/session-summary-before-reset.test.mjs new file mode 100644 index 0000000..d3c7a1a --- /dev/null +++ b/test/session-summary-before-reset.test.mjs @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const { MemoryStore } = jiti("../src/store.ts"); + +const EMBEDDING_DIMENSIONS = 4; +const FIXED_VECTOR = [0.5, 0.5, 0.5, 0.5]; + +function createEmbeddingServer() { + return http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const inputs = Array.isArray(payload.input) ? payload.input : [payload.input]; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + object: "list", + data: inputs.map((_, index) => ({ + object: "embedding", + index, + embedding: FIXED_VECTOR, + })), + model: payload.model || "mock-embedding-model", + usage: { + prompt_tokens: 0, + total_tokens: 0, + }, + })); + }); +} + +function createApiHarness({ dbPath, embeddingBaseURL }) { + return { + pluginConfig: { + dbPath, + autoCapture: false, + autoRecall: false, + sessionStrategy: "systemSessionMemory", + embedding: { + provider: "openai-compatible", + apiKey: "dummy", + model: "text-embedding-3-small", + baseURL: embeddingBaseURL, + dimensions: EMBEDDING_DIMENSIONS, + }, + }, + hooks: {}, + toolFactories: {}, + services: [], + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + resolvePath(value) { + return value; + }, + registerTool() {}, + registerCli() {}, + registerService(service) { + this.services.push(service); + }, + on(name, handler) { + this.hooks[name] = handler; + }, + registerHook(name, handler) { + this.hooks[name] = handler; + }, + }; +} + +describe("systemSessionMemory before_reset", () => { + let workDir; + let embeddingServer; + let embeddingBaseURL; + + beforeEach(async () => { + workDir = mkdtempSync(path.join(tmpdir(), "memory-session-summary-")); + embeddingServer = createEmbeddingServer(); + await new Promise((resolve) => embeddingServer.listen(0, "127.0.0.1", resolve)); + const port = embeddingServer.address().port; + embeddingBaseURL = `http://127.0.0.1:${port}/v1`; + }); + + afterEach(async () => { + await new Promise((resolve) => embeddingServer.close(resolve)); + rmSync(workDir, { recursive: true, force: true }); + }); + + it("stores a session-summary row for /new using before_reset messages", async () => { + const dbPath = path.join(workDir, "db"); + const api = createApiHarness({ dbPath, embeddingBaseURL }); + + memoryLanceDBProPlugin.register(api); + + assert.equal(typeof api.hooks.before_reset, "function"); + assert.equal(api.hooks["command:new"], undefined); + + await api.hooks.before_reset( + { + reason: "new", + messages: [ + { role: "user", content: "Need to fix the OAuth endpoint." }, + { role: "assistant", content: "Patched the endpoint and verified the login flow." }, + ], + }, + { + agentId: "main", + sessionKey: "agent:main:telegram:group:-100123:topic:42", + sessionId: "session-42", + workspaceDir: workDir, + }, + ); + + const store = new MemoryStore({ dbPath, vectorDim: EMBEDDING_DIMENSIONS }); + const entries = await store.list(undefined, undefined, 10, 0); + assert.equal(entries.length, 1); + + const [entry] = entries; + const metadata = JSON.parse(entry.metadata || "{}"); + assert.equal(metadata.type, "session-summary"); + assert.equal(metadata.sessionId, "session-42"); + assert.equal(metadata.sessionKey, "agent:main:telegram:group:-100123:topic:42"); + assert.match(entry.text, /Source: telegram/); + assert.match(entry.text, /Conversation Summary:/); + assert.match(entry.text, /user: Need to fix the OAuth endpoint\./); + assert.match(entry.text, /assistant: Patched the endpoint and verified the login flow\./); + }); + + it("skips writes for /reset", async () => { + const dbPath = path.join(workDir, "db-reset"); + const api = createApiHarness({ dbPath, embeddingBaseURL }); + + memoryLanceDBProPlugin.register(api); + + await api.hooks.before_reset( + { + reason: "reset", + messages: [ + { role: "user", content: "This should not be stored." }, + { role: "assistant", content: "Correct, reset should skip session-summary writes." }, + ], + }, + { + agentId: "main", + sessionKey: "agent:main:discord:dm:99", + sessionId: "session-reset", + workspaceDir: workDir, + }, + ); + + const store = new MemoryStore({ dbPath, vectorDim: EMBEDDING_DIMENSIONS }); + const entries = await store.list(undefined, undefined, 10, 0); + assert.equal(entries.length, 0); + }); +});