Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 119 additions & 108 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
let textBlocks = 0;
Expand Down Expand Up @@ -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<string, unknown>;
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<string | null> {
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<string, unknown>;
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;
}
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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)");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
9 changes: 7 additions & 2 deletions test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Loading