From fb8ee0d475d3a9dd8514f3fc8c7799b9beb3d52b Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Wed, 13 May 2026 23:54:18 +0800 Subject: [PATCH 01/37] feat(config): add memory feature configuration keys --- src/config/defaults.ts | 96 ++++++++- src/config/schema.ts | 28 +++ tests/unit/config-new-keys.test.ts | 301 +++++++++++++++++++++++++++++ tests/unit/config.test.ts | 57 ++++++ 4 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 tests/unit/config-new-keys.test.ts diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 3ed8a5d..b05fe2a 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,15 +1,20 @@ /** * Static defaults for the Supermemory plugin configuration. * - * Two exports: - * - `DEFAULT_KEYWORD_PATTERNS`: 16 baseline regex strings that always seed the - * merged `keywordPatterns` list. User-supplied patterns are appended (and - * filtered for valid regex syntax) by the loader. + * Two layers: + * - Module-level constants (`DEFAULT_KEYWORD_PATTERNS`, `DEFAULT_SIGNAL_KEYWORDS`, + * `DEFAULT_RECALL_KEYWORD_PATTERNS`, `DEFAULT_ENTITY_CONTEXT`) — the canonical + * data tables consumed both by the loader (merge logic) and by the schema + * (`.catch(...)` fallbacks). * - `DEFAULTS`: per-field fallback values used when the file config omits a key * or the value fails schema validation. Mirrors the shape of * `SupermemoryConfig` minus `apiKey` / `userContainerTag` / * `projectContainerTag` (those are intentionally optional and have no * default). + * + * Policy (decisions.md): "safe-on, costly-off" — every new memory-feature + * key defaults to a non-trivial enabled value except the three flagged as + * costly (`everyMessageRecall`, `reinjectEveryN`, `autoCategoryTagging`). */ export const DEFAULT_KEYWORD_PATTERNS: readonly string[] = [ @@ -31,7 +36,68 @@ export const DEFAULT_KEYWORD_PATTERNS: readonly string[] = [ "always\\s+remember", ]; +/** + * Trigger phrases for the heuristic signal-extraction pass. Each entry is a + * lowercase substring matched against incoming chat messages — no regex + * compilation. Sourced from the claude-supermemory baseline plus a small set + * of identity-anchoring phrases ("my name", "I work", "my company") that + * surface lasting personal facts. + */ +export const DEFAULT_SIGNAL_KEYWORDS: readonly string[] = [ + "remember", + "important", + "save this", + "note this", + "don't forget", + "key decision", + "project info", + "my name", + "I work", + "I prefer", + "I use", + "I like", + "always", + "never", + "my team", + "my email", + "my company", +]; + +/** + * Phrases that, when present in a user message, indicate an explicit recall + * intent — the assistant should pull prior memories into scope before + * answering. Lowercase substrings; no regex. + */ +export const DEFAULT_RECALL_KEYWORD_PATTERNS: readonly string[] = [ + "what did we", + "remind me", + "earlier you said", + "what was the", + "do you remember", +]; + +/** + * Verbatim system-prompt fragment from openclaw-supermemory (memory.ts — + * `DEFAULT_ENTITY_CONTEXT`). Length is well under the 1500-character clamp + * documented there (`MAX_ENTITY_CONTEXT_LENGTH`). Used as the context window + * passed to the entity-extraction pipeline. + */ +export const DEFAULT_ENTITY_CONTEXT: string = `User-assistant conversation. Format: [role: user]...[user:end] and [role: assistant]...[assistant:end]. + +Only extract things useful in FUTURE conversations. Most messages are not worth remembering. + +REMEMBER: lasting personal facts — dietary restrictions, preferences, personal details, workplace, location, tools, ongoing projects, routines, explicit "remember this" requests. + +DO NOT REMEMBER: temporary intents, one-time tasks, assistant actions (searching, writing files, generating code), assistant suggestions, implementation details, in-progress task status. + +RULES: +- Assistant output is CONTEXT ONLY — never attribute assistant actions to the user +- "find X" or "do Y" = one-time request, NOT a memory +- Only store preferences explicitly stated ("I like...", "I prefer...", "I always...") +- When in doubt, do NOT create a memory. Less is more.`; + export const DEFAULTS = { + // Existing keys (DO NOT change values — regression-guarded by tests/unit/config.test.ts). similarityThreshold: 0.6, maxMemories: 5, maxProjectMemories: 10, @@ -43,4 +109,26 @@ export const DEFAULTS = { "You are a stateful coding agent. Remember all the information, including but not limited to user's coding preferences, tech stack, behaviours, workflows, and any other relevant details.", keywordPatterns: [] as string[], compactionThreshold: 0.8, + + // Safe-on memory features (default enabled / non-zero). + incrementalCapture: true, + maxCaptureChars: 5000, + postCompactionReinject: true, + sessionEndSave: true, + signalExtraction: true, + signalKeywords: DEFAULT_SIGNAL_KEYWORDS, + signalTurnsBefore: 3, + recallKeywordPatterns: DEFAULT_RECALL_KEYWORD_PATTERNS, + dedupEnabled: true, + dedupCacheSize: 500, + entityContext: DEFAULT_ENTITY_CONTEXT, + metadataStripping: true, + relativeTimeDisplay: true, + memoUsageFooter: true, + profileCrossArrayDedup: true, + + // Costly-off memory features (default disabled / zero). + everyMessageRecall: false, + reinjectEveryN: 0, + autoCategoryTagging: false, } as const; diff --git a/src/config/schema.ts b/src/config/schema.ts index bc283d8..6a685ae 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -18,8 +18,14 @@ import { DEFAULTS } from "@/config/defaults"; * `apiKey`, `userContainerTag`, `projectContainerTag` stay `.optional()` — * they have no semantic default and the loader / consumers branch on * `undefined` explicitly. + * + * Sections below mirror the layering in `src/config/defaults.ts`: + * 1. Core knobs that predate the memory-feature work. + * 2. Safe-on memory-feature knobs (default enabled / non-zero). + * 3. Costly-off memory-feature knobs (default disabled / zero). */ export const SupermemoryConfigSchema = z.object({ + // Core knobs. apiKey: z.string().startsWith("sm_").optional(), similarityThreshold: z.number().gt(0).lte(1).catch(DEFAULTS.similarityThreshold), maxMemories: z.number().positive().catch(DEFAULTS.maxMemories), @@ -33,6 +39,28 @@ export const SupermemoryConfigSchema = z.object({ filterPrompt: z.string().catch(DEFAULTS.filterPrompt), keywordPatterns: z.array(z.string()).catch(DEFAULTS.keywordPatterns), compactionThreshold: z.number().gt(0).lte(1).catch(DEFAULTS.compactionThreshold), + + // Safe-on memory-feature knobs. + incrementalCapture: z.boolean().catch(DEFAULTS.incrementalCapture), + maxCaptureChars: z.number().positive().catch(DEFAULTS.maxCaptureChars), + postCompactionReinject: z.boolean().catch(DEFAULTS.postCompactionReinject), + sessionEndSave: z.boolean().catch(DEFAULTS.sessionEndSave), + signalExtraction: z.boolean().catch(DEFAULTS.signalExtraction), + signalKeywords: z.array(z.string()).catch([...DEFAULTS.signalKeywords]), + signalTurnsBefore: z.number().int().nonnegative().catch(DEFAULTS.signalTurnsBefore), + recallKeywordPatterns: z.array(z.string()).catch([...DEFAULTS.recallKeywordPatterns]), + dedupEnabled: z.boolean().catch(DEFAULTS.dedupEnabled), + dedupCacheSize: z.number().int().positive().catch(DEFAULTS.dedupCacheSize), + entityContext: z.string().catch(DEFAULTS.entityContext), + metadataStripping: z.boolean().catch(DEFAULTS.metadataStripping), + relativeTimeDisplay: z.boolean().catch(DEFAULTS.relativeTimeDisplay), + memoUsageFooter: z.boolean().catch(DEFAULTS.memoUsageFooter), + profileCrossArrayDedup: z.boolean().catch(DEFAULTS.profileCrossArrayDedup), + + // Costly-off memory-feature knobs. + everyMessageRecall: z.boolean().catch(DEFAULTS.everyMessageRecall), + reinjectEveryN: z.number().int().nonnegative().catch(DEFAULTS.reinjectEveryN), + autoCategoryTagging: z.boolean().catch(DEFAULTS.autoCategoryTagging), }); export type SupermemoryConfig = z.infer; diff --git a/tests/unit/config-new-keys.test.ts b/tests/unit/config-new-keys.test.ts new file mode 100644 index 0000000..4d0b27b --- /dev/null +++ b/tests/unit/config-new-keys.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "bun:test"; + +// ===================================================================== +// T1 — Config Schema + Defaults: 18 new keys +// +// Locks the contract that every new memory-feature key: +// 1. Has a default value in src/config/defaults.ts (DEFAULTS table) +// 2. Has a Zod validator in src/config/schema.ts that uses .catch() +// for silent-recovery on malformed input (never throws). +// 3. Surfaces through SupermemoryConfigSchema.parse({}) at the +// documented default. +// +// Policy (decisions.md): "safe-on, costly-off" — the three costly +// features default to false/0, every other new key defaults to a +// non-trivial enabled value. +// ===================================================================== + +describe("T1: new constants — DEFAULT_SIGNAL_KEYWORDS / DEFAULT_RECALL_KEYWORD_PATTERNS / DEFAULT_ENTITY_CONTEXT", () => { + it("DEFAULT_SIGNAL_KEYWORDS exports an array of exactly 17 English terms", async () => { + const { DEFAULT_SIGNAL_KEYWORDS } = await import("@/config/defaults"); + expect(Array.isArray(DEFAULT_SIGNAL_KEYWORDS)).toBe(true); + expect(DEFAULT_SIGNAL_KEYWORDS).toHaveLength(17); + // Spot-check the documented terms; the loader merges this with + // user-supplied keywords downstream. + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("remember"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("important"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("save this"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("note this"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("don't forget"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("key decision"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("project info"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("my name"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("I work"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("I prefer"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("I use"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("I like"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("always"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("never"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("my team"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("my email"); + expect(DEFAULT_SIGNAL_KEYWORDS).toContain("my company"); + }); + + it("DEFAULT_RECALL_KEYWORD_PATTERNS exports a non-empty array of recall trigger phrases", async () => { + const { DEFAULT_RECALL_KEYWORD_PATTERNS } = await import("@/config/defaults"); + expect(Array.isArray(DEFAULT_RECALL_KEYWORD_PATTERNS)).toBe(true); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS.length).toBeGreaterThanOrEqual(5); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS).toContain("what did we"); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS).toContain("remind me"); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS).toContain("earlier you said"); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS).toContain("what was the"); + expect(DEFAULT_RECALL_KEYWORD_PATTERNS).toContain("do you remember"); + }); + + it("DEFAULT_ENTITY_CONTEXT exports a non-empty string clamped to <= 1500 characters", async () => { + const { DEFAULT_ENTITY_CONTEXT } = await import("@/config/defaults"); + expect(typeof DEFAULT_ENTITY_CONTEXT).toBe("string"); + expect(DEFAULT_ENTITY_CONTEXT.length).toBeGreaterThan(0); + expect(DEFAULT_ENTITY_CONTEXT.length).toBeLessThanOrEqual(1500); + // Anchor: the verbatim openclaw-supermemory text starts with this prefix. + expect(DEFAULT_ENTITY_CONTEXT).toContain("User-assistant conversation"); + }); +}); + +describe("T1: SupermemoryConfigSchema — 15 safe-on defaults applied on empty input", () => { + it("incrementalCapture defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).incrementalCapture).toBe(true); + }); + + it("maxCaptureChars defaults to 5000", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).maxCaptureChars).toBe(5000); + }); + + it("postCompactionReinject defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).postCompactionReinject).toBe(true); + }); + + it("sessionEndSave defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).sessionEndSave).toBe(true); + }); + + it("signalExtraction defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).signalExtraction).toBe(true); + }); + + it("signalKeywords defaults to DEFAULT_SIGNAL_KEYWORDS (17 terms)", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const { DEFAULT_SIGNAL_KEYWORDS } = await import("@/config/defaults"); + const parsed = SupermemoryConfigSchema.parse({}); + expect(parsed.signalKeywords).toEqual([...DEFAULT_SIGNAL_KEYWORDS]); + }); + + it("signalTurnsBefore defaults to 3", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).signalTurnsBefore).toBe(3); + }); + + it("recallKeywordPatterns defaults to DEFAULT_RECALL_KEYWORD_PATTERNS", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const { DEFAULT_RECALL_KEYWORD_PATTERNS } = await import("@/config/defaults"); + const parsed = SupermemoryConfigSchema.parse({}); + expect(parsed.recallKeywordPatterns).toEqual([...DEFAULT_RECALL_KEYWORD_PATTERNS]); + }); + + it("dedupEnabled defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).dedupEnabled).toBe(true); + }); + + it("dedupCacheSize defaults to 500", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).dedupCacheSize).toBe(500); + }); + + it("entityContext defaults to DEFAULT_ENTITY_CONTEXT", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const { DEFAULT_ENTITY_CONTEXT } = await import("@/config/defaults"); + expect(SupermemoryConfigSchema.parse({}).entityContext).toBe(DEFAULT_ENTITY_CONTEXT); + }); + + it("metadataStripping defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).metadataStripping).toBe(true); + }); + + it("relativeTimeDisplay defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).relativeTimeDisplay).toBe(true); + }); + + it("memoUsageFooter defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).memoUsageFooter).toBe(true); + }); + + it("profileCrossArrayDedup defaults to true", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).profileCrossArrayDedup).toBe(true); + }); +}); + +describe("T1: SupermemoryConfigSchema — 3 costly-off defaults applied on empty input", () => { + it("everyMessageRecall defaults to false (costly)", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).everyMessageRecall).toBe(false); + }); + + it("reinjectEveryN defaults to 0 (disabled)", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).reinjectEveryN).toBe(0); + }); + + it("autoCategoryTagging defaults to false (costly)", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + expect(SupermemoryConfigSchema.parse({}).autoCategoryTagging).toBe(false); + }); +}); + +describe("T1: silent-recovery — malformed values for every new key fall back to default", () => { + it("every new key recovers from a wrong-type value via .catch()", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const { DEFAULT_SIGNAL_KEYWORDS, DEFAULT_RECALL_KEYWORD_PATTERNS, DEFAULT_ENTITY_CONTEXT } = await import("@/config/defaults"); + + // Each field receives a value of the wrong shape. .catch() must + // absorb the failure and return the documented default. + const result = SupermemoryConfigSchema.parse({ + incrementalCapture: "yes", + maxCaptureChars: "five thousand", + postCompactionReinject: 0, + sessionEndSave: null, + signalExtraction: 1, + signalKeywords: "not-an-array", + signalTurnsBefore: "three", + recallKeywordPatterns: 42, + dedupEnabled: "true", + dedupCacheSize: { nested: true }, + entityContext: 9001, + metadataStripping: [], + relativeTimeDisplay: 0, + memoUsageFooter: "no", + profileCrossArrayDedup: undefined, + everyMessageRecall: "true", + reinjectEveryN: "five", + autoCategoryTagging: "yes", + }); + + expect(result.incrementalCapture).toBe(true); + expect(result.maxCaptureChars).toBe(5000); + expect(result.postCompactionReinject).toBe(true); + expect(result.sessionEndSave).toBe(true); + expect(result.signalExtraction).toBe(true); + expect(result.signalKeywords).toEqual([...DEFAULT_SIGNAL_KEYWORDS]); + expect(result.signalTurnsBefore).toBe(3); + expect(result.recallKeywordPatterns).toEqual([...DEFAULT_RECALL_KEYWORD_PATTERNS]); + expect(result.dedupEnabled).toBe(true); + expect(result.dedupCacheSize).toBe(500); + expect(result.entityContext).toBe(DEFAULT_ENTITY_CONTEXT); + expect(result.metadataStripping).toBe(true); + expect(result.relativeTimeDisplay).toBe(true); + expect(result.memoUsageFooter).toBe(true); + expect(result.profileCrossArrayDedup).toBe(true); + expect(result.everyMessageRecall).toBe(false); + expect(result.reinjectEveryN).toBe(0); + expect(result.autoCategoryTagging).toBe(false); + }); + + it("valid user-supplied values flow through verbatim (no override)", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const result = SupermemoryConfigSchema.parse({ + incrementalCapture: false, + maxCaptureChars: 8000, + postCompactionReinject: false, + sessionEndSave: false, + signalExtraction: false, + signalKeywords: ["custom", "keywords"], + signalTurnsBefore: 5, + recallKeywordPatterns: ["recall this"], + dedupEnabled: false, + dedupCacheSize: 100, + entityContext: "Custom override context", + metadataStripping: false, + relativeTimeDisplay: false, + memoUsageFooter: false, + profileCrossArrayDedup: false, + everyMessageRecall: true, + reinjectEveryN: 10, + autoCategoryTagging: true, + }); + + expect(result.incrementalCapture).toBe(false); + expect(result.maxCaptureChars).toBe(8000); + expect(result.postCompactionReinject).toBe(false); + expect(result.sessionEndSave).toBe(false); + expect(result.signalExtraction).toBe(false); + expect(result.signalKeywords).toEqual(["custom", "keywords"]); + expect(result.signalTurnsBefore).toBe(5); + expect(result.recallKeywordPatterns).toEqual(["recall this"]); + expect(result.dedupEnabled).toBe(false); + expect(result.dedupCacheSize).toBe(100); + expect(result.entityContext).toBe("Custom override context"); + expect(result.metadataStripping).toBe(false); + expect(result.relativeTimeDisplay).toBe(false); + expect(result.memoUsageFooter).toBe(false); + expect(result.profileCrossArrayDedup).toBe(false); + expect(result.everyMessageRecall).toBe(true); + expect(result.reinjectEveryN).toBe(10); + expect(result.autoCategoryTagging).toBe(true); + }); +}); + +describe("T1: SupermemoryConfig type — 13 existing keys preserved alongside 18 new keys", () => { + it("parse({}) returns the union of all 13 existing defaults plus all 18 new defaults", async () => { + const { SupermemoryConfigSchema } = await import("@/config/schema"); + const result = SupermemoryConfigSchema.parse({}); + + // Existing 13 keys preserved (regression guard). + expect(result.apiKey).toBeUndefined(); + expect(result.similarityThreshold).toBe(0.6); + expect(result.maxMemories).toBe(5); + expect(result.maxProjectMemories).toBe(10); + expect(result.maxProfileItems).toBe(5); + expect(result.injectProfile).toBe(true); + expect(result.containerTagPrefix).toBe("opencode"); + expect(result.projectTagStrategy).toBe("hashGitRepoName"); + expect(result.userContainerTag).toBeUndefined(); + expect(result.projectContainerTag).toBeUndefined(); + expect(result.filterPrompt).toContain("You are a stateful coding agent"); + expect(result.keywordPatterns).toEqual([]); + expect(result.compactionThreshold).toBe(0.8); + + // 18 new keys present on the resolved type. + const newKeys = [ + "incrementalCapture", + "maxCaptureChars", + "postCompactionReinject", + "sessionEndSave", + "signalExtraction", + "signalKeywords", + "signalTurnsBefore", + "recallKeywordPatterns", + "dedupEnabled", + "dedupCacheSize", + "entityContext", + "metadataStripping", + "relativeTimeDisplay", + "memoUsageFooter", + "profileCrossArrayDedup", + "everyMessageRecall", + "reinjectEveryN", + "autoCategoryTagging", + ] as const; + for (const key of newKeys) { + expect(result).toHaveProperty(key); + } + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index faa82b0..9518dc8 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -36,6 +36,25 @@ interface ConfigEvalResult { filterPrompt: string; keywordPatterns: string[]; compactionThreshold: number; + // Memory-feature knobs (added in T1). + incrementalCapture: boolean; + maxCaptureChars: number; + postCompactionReinject: boolean; + sessionEndSave: boolean; + signalExtraction: boolean; + signalKeywords: string[]; + signalTurnsBefore: number; + recallKeywordPatterns: string[]; + dedupEnabled: boolean; + dedupCacheSize: number; + entityContext: string; + metadataStripping: boolean; + relativeTimeDisplay: boolean; + memoUsageFooter: boolean; + profileCrossArrayDedup: boolean; + everyMessageRecall: boolean; + reinjectEveryN: number; + autoCategoryTagging: boolean; }; isConfigured: boolean; } @@ -244,6 +263,44 @@ describe("loadConfig — fixture round-trips", () => { "always\\s+remember", ], compactionThreshold: 0.8, + // Safe-on memory-feature knobs (added in T1). + incrementalCapture: true, + maxCaptureChars: 5000, + postCompactionReinject: true, + sessionEndSave: true, + signalExtraction: true, + signalKeywords: [ + "remember", + "important", + "save this", + "note this", + "don't forget", + "key decision", + "project info", + "my name", + "I work", + "I prefer", + "I use", + "I like", + "always", + "never", + "my team", + "my email", + "my company", + ], + signalTurnsBefore: 3, + recallKeywordPatterns: ["what did we", "remind me", "earlier you said", "what was the", "do you remember"], + dedupEnabled: true, + dedupCacheSize: 500, + entityContext: expect.stringContaining("User-assistant conversation"), + metadataStripping: true, + relativeTimeDisplay: true, + memoUsageFooter: true, + profileCrossArrayDedup: true, + // Costly-off memory-feature knobs (added in T1). + everyMessageRecall: false, + reinjectEveryN: 0, + autoCategoryTagging: false, }); }); }); From b5e8a37993dfc2b89eaa9e931c0819ba7dc93922 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:00:03 +0800 Subject: [PATCH 02/37] feat(shared): add withTimeout utility --- src/shared/timeout.ts | 12 +++++++ tests/unit/timeout.test.ts | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/shared/timeout.ts create mode 100644 tests/unit/timeout.test.ts diff --git a/src/shared/timeout.ts b/src/shared/timeout.ts new file mode 100644 index 0000000..e00c184 --- /dev/null +++ b/src/shared/timeout.ts @@ -0,0 +1,12 @@ +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout after ${ms}ms${label ? ` (${label})` : ""}`)); + }, ms); + + promise.finally(() => clearTimeout(timer)); + }), + ]); +} diff --git a/tests/unit/timeout.test.ts b/tests/unit/timeout.test.ts new file mode 100644 index 0000000..9955dcf --- /dev/null +++ b/tests/unit/timeout.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; + +import { advanceTime, useFakeTimers, useRealTimers } from "../helpers/mock-time"; + +describe("withTimeout", () => { + beforeEach(() => { + useFakeTimers(); + }); + + afterEach(() => { + useRealTimers(); + }); + + it("resolves with the wrapped value before the timeout", async () => { + const { withTimeout } = await import("../../src/shared/timeout"); + + const result = await withTimeout(Promise.resolve("ok"), 1000); + advanceTime(0); + + expect(result).toBe("ok"); + }); + + it("rejects after the timeout with the backward-compatible message", async () => { + const { withTimeout } = await import("../../src/shared/timeout"); + + const result = withTimeout(new Promise(() => {}), 50); + advanceTime(50); + + try { + await result; + throw new Error("expected timeout error"); + } catch (error) { + expect(error instanceof Error).toBe(true); + if (!(error instanceof Error)) throw error; + expect(error.message).toBe("Timeout after 50ms"); + } + }); + + it("includes the label in the timeout message when provided", async () => { + const { withTimeout } = await import("../../src/shared/timeout"); + + const result = withTimeout(new Promise(() => {}), 25, "fetch"); + advanceTime(25); + + try { + await result; + throw new Error("expected timeout error"); + } catch (error) { + expect(error instanceof Error).toBe(true); + if (!(error instanceof Error)) throw error; + expect(error.message).toBe("Timeout after 25ms (fetch)"); + } + }); + + it("clears the timeout handle after resolve", async () => { + const { withTimeout } = await import("../../src/shared/timeout"); + const clearTimeoutSpy = spyOn(globalThis, "clearTimeout"); + + const result = await withTimeout(Promise.resolve(123), 1000); + advanceTime(0); + + expect(result).toBe(123); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + }); +}); From af54cf2b197d58a115b4bdee11820999337525e7 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:04:03 +0800 Subject: [PATCH 03/37] feat(shared): add generic dedupe helper --- src/shared/collection.ts | 21 +++++++++++++++++++++ tests/unit/collection.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/shared/collection.ts create mode 100644 tests/unit/collection.test.ts diff --git a/src/shared/collection.ts b/src/shared/collection.ts new file mode 100644 index 0000000..46716f4 --- /dev/null +++ b/src/shared/collection.ts @@ -0,0 +1,21 @@ +export function dedupe(items: T[], getKey: (item: T) => string = (item) => String(item)): T[] { + const seen = new Set(); + const result: T[] = []; + + for (const item of items) { + const normalizedKey = getKey(item).toLowerCase().trim(); + + if (normalizedKey === "") { + continue; + } + + if (seen.has(normalizedKey)) { + continue; + } + + seen.add(normalizedKey); + result.push(item); + } + + return result; +} diff --git a/tests/unit/collection.test.ts b/tests/unit/collection.test.ts new file mode 100644 index 0000000..5cf6023 --- /dev/null +++ b/tests/unit/collection.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; + +import { dedupe } from "../../src/shared/collection"; + +describe("dedupe", () => { + it("returns the same items for unique values", () => { + expect(dedupe(["a", "b", "c"])).toEqual(["a", "b", "c"]); + }); + + it("deduplicates values using a case-insensitive normalized key", () => { + expect(dedupe(["Alpha", "alpha", "BETA", "beta"])) .toEqual(["Alpha", "BETA"]); + }); + + it("skips empty and whitespace-only keys", () => { + expect(dedupe(["", " ", "alpha", " ", "beta"])).toEqual(["alpha", "beta"]); + }); + + it("supports a custom key function", () => { + const items = [{ id: "A1" }, { id: "a1" }, { id: "B2" }]; + + expect(dedupe(items, (item: { id: string }) => item.id)).toEqual([{ id: "A1" }, { id: "B2" }]); + }); + + it("preserves the first seen order", () => { + expect(dedupe(["b", "A", "a", "B", "c"])).toEqual(["b", "A", "c"]); + }); +}); From d0dc9f467be84bc3bd064f0ec703742b4ac97db4 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:12:18 +0800 Subject: [PATCH 04/37] refactor(shared): consolidate ID generation --- src/chat/handler.ts | 5 +-- src/compaction/message-store.ts | 13 +------- src/shared/ids.ts | 28 ++++++++++++++++ tests/unit/ids.test.ts | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 src/shared/ids.ts create mode 100644 tests/unit/ids.test.ts diff --git a/src/chat/handler.ts b/src/chat/handler.ts index 0e460cd..b27c7cc 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -21,6 +21,7 @@ import { MEMORY_NUDGE_MESSAGE } from "@/chat/nudge"; import type { SupermemoryConfig } from "@/config/schema"; import { formatContextForPrompt } from "@/memory/context"; import type { SessionState } from "@/session/state"; +import { generatePartId } from "@/shared/ids"; export interface ChatHandlerInput { sessionID: string; @@ -107,7 +108,7 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan if (detectMemoryKeyword(userMessage, deps.config)) { deps.log("chat.message: memory keyword detected"); const nudgePart: Part = { - id: `prt_supermemory-nudge-${Date.now()}`, + id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", @@ -148,7 +149,7 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan if (memoryContext) { const contextPart: Part = { - id: `prt_supermemory-context-${Date.now()}`, + id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", diff --git a/src/compaction/message-store.ts b/src/compaction/message-store.ts index bbf9475..d34b5e1 100644 --- a/src/compaction/message-store.ts +++ b/src/compaction/message-store.ts @@ -6,6 +6,7 @@ import { getLogger } from "@logtape/logtape"; import { ensureDirSync } from "fs-extra"; import { findNearestMessageWithFields } from "@/compaction/finder"; +import { generateMessageId, generatePartId } from "@/shared/ids"; const logger = getLogger(["supermemory", "compaction", "message-store"]); @@ -38,18 +39,6 @@ export function getOrCreateMessageDir(sessionID: string): string { return directPath; } -export function generateMessageId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 14); - return `msg_${timestamp}${random}`; -} - -export function generatePartId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 10); - return `prt_${timestamp}${random}`; -} - export interface InjectOriginalMessage { agent?: string; model?: { providerID?: string; modelID?: string }; diff --git a/src/shared/ids.ts b/src/shared/ids.ts new file mode 100644 index 0000000..e71b41a --- /dev/null +++ b/src/shared/ids.ts @@ -0,0 +1,28 @@ +/** + * Stateless ID generators for OpenCode messages and message parts. + * + * Both producers follow the canonical format: + * `${prefix}_${hexTimestamp}${base36Random}` + * + * - prefix: `msg` for messages, `prt` for parts + * - hexTimestamp: `Date.now().toString(16)` — monotonic-ish, sortable + * - base36Random: 8 chars from `Math.random().toString(36).substring(2, 10)` + * + * The shape mirrors OpenCode's on-disk identifiers; do not change the prefix + * or trim the random tail without coordinating with the message-store reader. + */ + +const RANDOM_TAIL_START = 2; +const RANDOM_TAIL_END = 10; + +function randomTail(): string { + return Math.random().toString(36).substring(RANDOM_TAIL_START, RANDOM_TAIL_END); +} + +export function generateMessageId(): string { + return `msg_${Date.now().toString(16)}${randomTail()}`; +} + +export function generatePartId(): string { + return `prt_${Date.now().toString(16)}${randomTail()}`; +} diff --git a/tests/unit/ids.test.ts b/tests/unit/ids.test.ts new file mode 100644 index 0000000..b4385f7 --- /dev/null +++ b/tests/unit/ids.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; + +import { generateMessageId, generatePartId } from "../../src/shared/ids"; + +/** + * Canonical ID format: + * prefix `prt_` or `msg_` + hex timestamp + 8 chars of base36 randomness. + * + * The hex timestamp from `Date.now().toString(16)` is currently 11 chars at + * millisecond resolution; do not hard-code the length, only its character class. + * The random tail comes from `Math.random().toString(36).substring(2, 10)` and is + * exactly 8 chars of `[0-9a-z]`. + */ +const PART_ID_PATTERN = /^prt_[0-9a-f]+[0-9a-z]{8}$/; +const MESSAGE_ID_PATTERN = /^msg_[0-9a-f]+[0-9a-z]{8}$/; + +describe("generatePartId", () => { + it("returns a string matching the canonical part ID format", () => { + const id = generatePartId(); + + expect(typeof id).toBe("string"); + expect(id.startsWith("prt_")).toBe(true); + expect(PART_ID_PATTERN.test(id)).toBe(true); + }); + + it("returns a different value on each call", () => { + const first = generatePartId(); + const second = generatePartId(); + + expect(first).not.toBe(second); + }); +}); + +describe("generateMessageId", () => { + it("returns a string matching the canonical message ID format", () => { + const id = generateMessageId(); + + expect(typeof id).toBe("string"); + expect(id.startsWith("msg_")).toBe(true); + expect(MESSAGE_ID_PATTERN.test(id)).toBe(true); + }); + + it("returns a different value on each call", () => { + const first = generateMessageId(); + const second = generateMessageId(); + + expect(first).not.toBe(second); + }); + + it("never collides with part IDs (different prefix)", () => { + const messageId = generateMessageId(); + const partId = generatePartId(); + + expect(messageId.startsWith("msg_")).toBe(true); + expect(partId.startsWith("prt_")).toBe(true); + expect(messageId).not.toBe(partId); + }); +}); From 0d7f6e556e078c4436084042d7828630233f2511 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:19:02 +0800 Subject: [PATCH 05/37] feat(memory): strip inbound metadata from queries --- src/memory/metadata-strip.ts | 21 ++++++++++ tests/unit/metadata-strip.test.ts | 66 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/memory/metadata-strip.ts create mode 100644 tests/unit/metadata-strip.test.ts diff --git a/src/memory/metadata-strip.ts b/src/memory/metadata-strip.ts new file mode 100644 index 0000000..cf900a4 --- /dev/null +++ b/src/memory/metadata-strip.ts @@ -0,0 +1,21 @@ +const LEADING_ISO_TIMESTAMP_PATTERN = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?Z?\]?\s*/; + +const SYSTEM_REMINDER_BLOCK_PATTERN = /]*>[\s\S]*?<\/system-reminder>/gi; +const SUPERMEMORY_CONTEXT_BLOCK_PATTERN = /]*>[\s\S]*?<\/supermemory-context>/gi; +const SUPERMEMORY_CONTAINERS_BLOCK_PATTERN = /]*>[\s\S]*?<\/supermemory-containers>/gi; + +const SENTINEL_LINE_PATTERN = + /^(?:Conversation info \(untrusted metadata\):|Sender \(untrusted metadata\):|Thread starter \(untrusted, for context\):|Replied message \(untrusted, for context\):|Forwarded message context \(untrusted metadata\):|Chat history since last reply \(untrusted, for context\):)[ \t]*(?:\r?\n[ \t]*```json\r?\n[\s\S]*?\r?\n```[ \t]*)?(?:\r?\n)?/gim; + +export function stripInboundMetadata(text: string): string { + if (!text) return ""; + + return text + .replace(LEADING_ISO_TIMESTAMP_PATTERN, "") + .replace(SYSTEM_REMINDER_BLOCK_PATTERN, "") + .replace(SUPERMEMORY_CONTEXT_BLOCK_PATTERN, "") + .replace(SUPERMEMORY_CONTAINERS_BLOCK_PATTERN, "") + .replace(SENTINEL_LINE_PATTERN, "") + .replace(LEADING_ISO_TIMESTAMP_PATTERN, "") + .trim(); +} diff --git a/tests/unit/metadata-strip.test.ts b/tests/unit/metadata-strip.test.ts new file mode 100644 index 0000000..2570e83 --- /dev/null +++ b/tests/unit/metadata-strip.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; + +import { stripInboundMetadata } from "@/memory/metadata-strip"; + +describe("stripInboundMetadata", () => { + it("strips leading ISO timestamp", () => { + expect(stripInboundMetadata("[2024-01-01T00:00:00.123Z] hello")).toBe("hello"); + expect(stripInboundMetadata("2024-01-01 00:00:00 hello")).toBe("hello"); + }); + + it("strips single-line system reminder tag", () => { + expect(stripInboundMetadata("noise\nhello")).toBe("hello"); + }); + + it("strips multi-line supermemory context tag", () => { + const input = "before\n\none\ntwo\n\nafter"; + + expect(stripInboundMetadata(input)).toBe("before\n\nafter"); + }); + + it("strips supermemory containers tag", () => { + const input = "{\"id\":\"1\"}\nhello"; + + expect(stripInboundMetadata(input)).toBe("hello"); + }); + + it("strips sentinel line and following fenced JSON block", () => { + const input = [ + "Conversation info (untrusted metadata):", + "```json", + '{"id":"abc","createdAt":"2024-01-01T00:00:00Z"}', + "```", + "real content", + ].join("\n"); + + expect(stripInboundMetadata(input)).toBe("real content"); + }); + + it("keeps only real content from mixed metadata", () => { + const input = + "2024-01-01T00:00:00Z\nX\nY\nhello"; + + expect(stripInboundMetadata(input)).toBe("hello"); + }); + + it("is idempotent", () => { + const input = [ + "[2024-01-01T00:00:00Z]", + "Sender (untrusted metadata):", + "```json", + '{"name":"bot"}', + "```", + "ignore", + "hello", + ].join("\n"); + const once = stripInboundMetadata(input); + + expect(stripInboundMetadata(once)).toBe(once); + }); + + it("returns empty string for empty, null, or undefined input", () => { + expect(stripInboundMetadata("")).toBe(""); + expect(stripInboundMetadata(null as unknown as string)).toBe(""); + expect(stripInboundMetadata(undefined as unknown as string)).toBe(""); + }); +}); From 303c57db89d49886f2120a08cc1cca0602be2303 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:23:39 +0800 Subject: [PATCH 06/37] feat(memory): format timestamps as relative time --- src/memory/relative-time.ts | 54 ++++++++++++++++++++++++++++++++ tests/unit/relative-time.test.ts | 39 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/memory/relative-time.ts create mode 100644 tests/unit/relative-time.test.ts diff --git a/src/memory/relative-time.ts b/src/memory/relative-time.ts new file mode 100644 index 0000000..d56d0fb --- /dev/null +++ b/src/memory/relative-time.ts @@ -0,0 +1,54 @@ +const MONTH_SHORT_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; + +function toValidDate(value: string | Date): Date | null { + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatAbsoluteDate(date: Date, now: Date): string { + const day = date.getUTCDate(); + const month = MONTH_SHORT_NAMES[date.getUTCMonth()]; + + if (date.getUTCFullYear() === now.getUTCFullYear()) { + return `${day} ${month}`; + } + + return `${day} ${month}, ${date.getUTCFullYear()}`; +} + +export function formatRelativeTime(isoTimestamp: string | Date, now: Date = new Date()): string { + const input = toValidDate(isoTimestamp); + const current = toValidDate(now); + + if (!input || !current) return ""; + + const diffMs = current.getTime() - input.getTime(); + + if (diffMs < 0) { + return formatAbsoluteDate(input, current); + } + + const minutes = Math.floor(diffMs / 60_000); + + if (minutes < 30) { + return "just now"; + } + + if (minutes < 60) { + return minutes === 1 ? "1min ago" : `${minutes}mins ago`; + } + + const hours = Math.floor(minutes / 60); + + if (hours < 24) { + return hours === 1 ? "1 hr ago" : `${hours} hrs ago`; + } + + const days = Math.floor(hours / 24); + + if (days < 7) { + return `${days}d ago`; + } + + return formatAbsoluteDate(input, current); +} diff --git a/tests/unit/relative-time.test.ts b/tests/unit/relative-time.test.ts new file mode 100644 index 0000000..4a97176 --- /dev/null +++ b/tests/unit/relative-time.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; + +import { formatRelativeTime } from "../../src/memory/relative-time"; + +describe("formatRelativeTime", () => { + const now = new Date("2026-05-15T12:00:00.000Z"); + + it("returns just now for times under 30 minutes", () => { + expect(formatRelativeTime("2026-05-15T11:31:00.000Z", now)).toBe("just now"); + }); + + it("formats 45 minutes as mins ago", () => { + expect(formatRelativeTime("2026-05-15T11:15:00.000Z", now)).toBe("45mins ago"); + }); + + it("formats one hour as singular hr ago", () => { + expect(formatRelativeTime("2026-05-15T11:00:00.000Z", now)).toBe("1 hr ago"); + }); + + it("formats six hours as hrs ago", () => { + expect(formatRelativeTime("2026-05-15T06:00:00.000Z", now)).toBe("6 hrs ago"); + }); + + it("formats three days as d ago", () => { + expect(formatRelativeTime("2026-05-12T12:00:00.000Z", now)).toBe("3d ago"); + }); + + it("formats same-year dates with month abbreviation", () => { + expect(formatRelativeTime("2026-08-15T00:00:00.000Z", now)).toBe("15 Aug"); + }); + + it("formats different-year dates with year suffix", () => { + expect(formatRelativeTime("2023-08-22T00:00:00.000Z", now)).toBe("22 Aug, 2023"); + }); + + it("returns an empty string for invalid input", () => { + expect(formatRelativeTime("not-a-date", now)).toBe(""); + }); +}); From ca31010a543ccc88e014e15fae8283c9e2405bb3 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:28:35 +0800 Subject: [PATCH 07/37] feat(memory): add entity context guidance --- src/memory/entity-context.ts | 13 +++++++++++++ tests/unit/entity-context.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/memory/entity-context.ts create mode 100644 tests/unit/entity-context.test.ts diff --git a/src/memory/entity-context.ts b/src/memory/entity-context.ts new file mode 100644 index 0000000..84ce1a2 --- /dev/null +++ b/src/memory/entity-context.ts @@ -0,0 +1,13 @@ +import { DEFAULT_ENTITY_CONTEXT as BASE_DEFAULT_ENTITY_CONTEXT } from "@/config/defaults"; + +export const MAX_ENTITY_CONTEXT_LENGTH = 1500; +export const DEFAULT_ENTITY_CONTEXT: string = BASE_DEFAULT_ENTITY_CONTEXT; + +export function clampEntityContext(text: string): string { + if (text.length <= MAX_ENTITY_CONTEXT_LENGTH) return text; + + const truncated = text.slice(0, MAX_ENTITY_CONTEXT_LENGTH); + const lastSpace = truncated.lastIndexOf(" "); + + return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; +} diff --git a/tests/unit/entity-context.test.ts b/tests/unit/entity-context.test.ts new file mode 100644 index 0000000..12abf90 --- /dev/null +++ b/tests/unit/entity-context.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; + +import { + DEFAULT_ENTITY_CONTEXT, + MAX_ENTITY_CONTEXT_LENGTH, + clampEntityContext, +} from "../../src/memory/entity-context"; + +describe("entity-context", () => { + it("keeps DEFAULT_ENTITY_CONTEXT within the configured limit", () => { + expect(DEFAULT_ENTITY_CONTEXT.length <= MAX_ENTITY_CONTEXT_LENGTH).toBe(true); + }); + + it("clamps long text at a word boundary", () => { + const word = "memory"; + const text = Array.from({ length: 228 }, () => word).join(" "); + + const clamped = clampEntityContext(text); + + expect(clamped.length <= MAX_ENTITY_CONTEXT_LENGTH).toBe(true); + expect(clamped.length).toBeLessThan(text.length); + expect(clamped.endsWith(" ")).toBe(false); + expect(clamped.split(" ").at(-1)).toBe(word); + }); + + it("returns short text unchanged", () => { + const text = "short entity context"; + + expect(clampEntityContext(text)).toBe(text); + }); +}); From 6863229fe0ec9ea0d375fff5a92abff576ecec40 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:35:15 +0800 Subject: [PATCH 08/37] chore: remove internal identifiers from comments and test labels --- src/compaction/threshold.ts | 4 ++-- tests/contract/tool-modes.test.ts | 8 ++++---- tests/unit/config-new-keys.test.ts | 12 ++++++------ tests/unit/config.test.ts | 6 +++--- tests/unit/tags.test.ts | 10 +++++----- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/compaction/threshold.ts b/src/compaction/threshold.ts index 02564a8..9d8f622 100644 --- a/src/compaction/threshold.ts +++ b/src/compaction/threshold.ts @@ -1,6 +1,6 @@ /** * Compaction threshold constants and the pure decision function. The shipped - * hook in src/services/compaction.ts still inlines the gate today; T17 will + * hook in src/services/compaction.ts still inlines the gate today; a future * collapse that onto computeShouldCompact(). Until then, this module is the * single source of truth for the constants so tests, future call sites and * config audits all agree on one value. @@ -53,7 +53,7 @@ export interface ShouldCompactResult { * Pure decision: should we trigger compaction for this session right now? * Mirrors the gate ordering in createCompactionHook → checkAndTriggerCompaction * (in-progress / cooldown / summary / min-tokens / threshold) so behaviour stays - * identical when T17 wires this in. totalUsed and usageRatio are always + * identical once fully wired in. totalUsed and usageRatio are always * computed so the caller can log them regardless of the decision. */ export function computeShouldCompact(input: ShouldCompactInput): ShouldCompactResult { diff --git a/tests/contract/tool-modes.test.ts b/tests/contract/tool-modes.test.ts index 53307dd..78cefc1 100644 --- a/tests/contract/tool-modes.test.ts +++ b/tests/contract/tool-modes.test.ts @@ -18,10 +18,10 @@ import { fileURLToPath } from "node:url"; // // These tests pin the JSON shapes returned for every branch + the // pre-call validation / privacy / scope-defaulting behaviour. They are -// the "behavior contract" that the planned tool-mode refactor (T13) +// the "behavior contract" that any future tool-mode refactor // must preserve. // -// Mocking strategy (informed by T6 notepad — "own-property shadow over +// Mocking strategy (own-property shadow over prototype): // prototype"): // // - Mock the `supermemory` SDK module so the real SupermemoryClient @@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url"; // own-properties intercept calls before the prototype method runs. // // This avoids `mock.module("src/services/client.ts", ...)`, which -// leaks across files in parallel test mode (per T6 learnings). +// leaks across files in parallel test mode. // ===================================================================== const HERE = dirname(fileURLToPath(import.meta.url)); @@ -497,7 +497,7 @@ describe("tool.execute mode=profile", () => { }); it("profile missing on success: empty arrays are substituted for missing static/dynamic slots", async () => { - // T13 may unify this — pin the current "lenient defaulting" behaviour. + // Pin the current "lenient defaulting" behaviour. clientImpl.getProfile = () => ({ success: true }); const out = (await callTool({ mode: "profile" })) as { profile: { static: unknown[]; dynamic: unknown[] } }; diff --git a/tests/unit/config-new-keys.test.ts b/tests/unit/config-new-keys.test.ts index 4d0b27b..77628a2 100644 --- a/tests/unit/config-new-keys.test.ts +++ b/tests/unit/config-new-keys.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; // ===================================================================== -// T1 — Config Schema + Defaults: 18 new keys +// Config Schema + Defaults: 18 new keys // // Locks the contract that every new memory-feature key: // 1. Has a default value in src/config/defaults.ts (DEFAULTS table) @@ -15,7 +15,7 @@ import { describe, expect, it } from "bun:test"; // non-trivial enabled value. // ===================================================================== -describe("T1: new constants — DEFAULT_SIGNAL_KEYWORDS / DEFAULT_RECALL_KEYWORD_PATTERNS / DEFAULT_ENTITY_CONTEXT", () => { +describe("new constants — DEFAULT_SIGNAL_KEYWORDS / DEFAULT_RECALL_KEYWORD_PATTERNS / DEFAULT_ENTITY_CONTEXT", () => { it("DEFAULT_SIGNAL_KEYWORDS exports an array of exactly 17 English terms", async () => { const { DEFAULT_SIGNAL_KEYWORDS } = await import("@/config/defaults"); expect(Array.isArray(DEFAULT_SIGNAL_KEYWORDS)).toBe(true); @@ -62,7 +62,7 @@ describe("T1: new constants — DEFAULT_SIGNAL_KEYWORDS / DEFAULT_RECALL_KEYWORD }); }); -describe("T1: SupermemoryConfigSchema — 15 safe-on defaults applied on empty input", () => { +describe("SupermemoryConfigSchema — 15 safe-on defaults applied on empty input", () => { it("incrementalCapture defaults to true", async () => { const { SupermemoryConfigSchema } = await import("@/config/schema"); expect(SupermemoryConfigSchema.parse({}).incrementalCapture).toBe(true); @@ -144,7 +144,7 @@ describe("T1: SupermemoryConfigSchema — 15 safe-on defaults applied on empty i }); }); -describe("T1: SupermemoryConfigSchema — 3 costly-off defaults applied on empty input", () => { +describe("SupermemoryConfigSchema — 3 costly-off defaults applied on empty input", () => { it("everyMessageRecall defaults to false (costly)", async () => { const { SupermemoryConfigSchema } = await import("@/config/schema"); expect(SupermemoryConfigSchema.parse({}).everyMessageRecall).toBe(false); @@ -161,7 +161,7 @@ describe("T1: SupermemoryConfigSchema — 3 costly-off defaults applied on empty }); }); -describe("T1: silent-recovery — malformed values for every new key fall back to default", () => { +describe("silent-recovery — malformed values for every new key fall back to default", () => { it("every new key recovers from a wrong-type value via .catch()", async () => { const { SupermemoryConfigSchema } = await import("@/config/schema"); const { DEFAULT_SIGNAL_KEYWORDS, DEFAULT_RECALL_KEYWORD_PATTERNS, DEFAULT_ENTITY_CONTEXT } = await import("@/config/defaults"); @@ -253,7 +253,7 @@ describe("T1: silent-recovery — malformed values for every new key fall back t }); }); -describe("T1: SupermemoryConfig type — 13 existing keys preserved alongside 18 new keys", () => { +describe("SupermemoryConfig type — 13 existing keys preserved alongside 18 new keys", () => { it("parse({}) returns the union of all 13 existing defaults plus all 18 new defaults", async () => { const { SupermemoryConfigSchema } = await import("@/config/schema"); const result = SupermemoryConfigSchema.parse({}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 9518dc8..f720835 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -36,7 +36,7 @@ interface ConfigEvalResult { filterPrompt: string; keywordPatterns: string[]; compactionThreshold: number; - // Memory-feature knobs (added in T1). + // Memory-feature knobs. incrementalCapture: boolean; maxCaptureChars: number; postCompactionReinject: boolean; @@ -263,7 +263,7 @@ describe("loadConfig — fixture round-trips", () => { "always\\s+remember", ], compactionThreshold: 0.8, - // Safe-on memory-feature knobs (added in T1). + // Safe-on memory-feature knobs. incrementalCapture: true, maxCaptureChars: 5000, postCompactionReinject: true, @@ -297,7 +297,7 @@ describe("loadConfig — fixture round-trips", () => { relativeTimeDisplay: true, memoUsageFooter: true, profileCrossArrayDedup: true, - // Costly-off memory-feature knobs (added in T1). + // Costly-off memory-feature knobs. everyMessageRecall: false, reinjectEveryN: 0, autoCategoryTagging: false, diff --git a/tests/unit/tags.test.ts b/tests/unit/tags.test.ts index 90ce72a..dce68c9 100644 --- a/tests/unit/tags.test.ts +++ b/tests/unit/tags.test.ts @@ -92,7 +92,7 @@ beforeEach(() => { gitEmailCallCount = 0; gitRemoteResponder = new Error("not a git repo"); gitRemoteCallCount = 0; - // T14: reset the in-process git-email cache between tests so each case + // Reset the in-process git-email cache between tests so each case // sees a fresh execSync call path. Without this, cached results from // earlier tests would mask the mocked responder. tags.resetTagsCache(); @@ -137,7 +137,7 @@ describe("getUserTag", () => { it("auto-generates a user tag from `{prefix}_user_{sha256(email).slice(0,16)}` (byte-identical)", () => { gitEmailResponder = "test@example.com\n"; // sha256("test@example.com").slice(0,16) == 973dfe463ec85785 - // This exact byte sequence MUST survive T14 (getGitEmail caching) and + // This exact byte sequence must survive getGitEmail caching and // any future refactor of tags.ts. Snapshot locks the full output string. expect(tags.getUserTag(mockConfig)).toMatchInlineSnapshot(`"opencode_user_973dfe463ec85785"`); }); @@ -326,7 +326,7 @@ describe("getTags", () => { }); }); -describe("in-process cache for git email (T14)", () => { +describe("in-process cache for git email", () => { it("cache hit: two getUserTag() calls trigger execSync exactly once", () => { gitEmailResponder = "test@example.com\n"; @@ -334,8 +334,8 @@ describe("in-process cache for git email (T14)", () => { tags.getUserTag(mockConfig); tags.getUserTag(mockConfig); - // T14 caching: second call returns the cached email and skips execSync. - // Before T14 this assertion locked toBe(2); the change to toBe(1) is the + // Caching: second call returns the cached email and skips execSync. + // Before caching this assertion was toBe(2); the change to toBe(1) is the // observable proof that caching is wired into both call sites. expect(gitEmailCallCount).toBe(1); }); From 8fc208be73b601464825b678ce69a1998be69f93 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:35:32 +0800 Subject: [PATCH 09/37] docs(config): correct loader docstring for two-tier lookup --- src/config/loader.ts | 3 ++- tests/unit/config-loader-docstring.test.ts | 27 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/unit/config-loader-docstring.test.ts diff --git a/src/config/loader.ts b/src/config/loader.ts index 394345d..ee31ec6 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -80,7 +80,8 @@ function readFileConfig(homeDir: string): Record { * * Resolution order for `apiKey` (highest priority first): * 1. `SUPERMEMORY_API_KEY` env var. - * 2. `apiKey` field in `~/.config/opencode/supermemory.{jsonc,json}`. + * 2. `apiKey` field in `~/.config/opencode/supermemory-p.{jsonc,json}` + * (`.jsonc` preferred when both exist). * 3. `apiKey` from `~/.local/share/opencode-supermemory-p/credentials.json` (OAuth flow). * * `keywordPatterns` is the merge of `DEFAULT_KEYWORD_PATTERNS` (always first) diff --git a/tests/unit/config-loader-docstring.test.ts b/tests/unit/config-loader-docstring.test.ts new file mode 100644 index 0000000..3126439 --- /dev/null +++ b/tests/unit/config-loader-docstring.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = resolve(fileURLToPath(import.meta.url), ".."); +const REPO_ROOT = join(HERE, "..", ".."); +const LOADER_PATH = join(REPO_ROOT, "src", "config", "loader.ts"); + +function getLoadConfigDocstring(source: string): string { + const match = source.match(/\/\*\*[\s\S]*?Loads, validates, and returns the resolved Supermemory configuration\.[\s\S]*?\*\//); + if (!match) throw new Error("loadConfig docstring not found"); + return match[0]; +} + +describe("loadConfig docstring", () => { + it("documents the actual two-tier lookup without legacy bare-name wording", () => { + const source = readFileSync(LOADER_PATH, "utf-8"); + const docstring = getLoadConfigDocstring(source); + + expect(docstring).toContain("SUPERMEMORY_API_KEY"); + expect(docstring).toContain("~/.config/opencode/supermemory-p.{jsonc,json}"); + expect(docstring).toContain("~/.local/share/opencode-supermemory-p/credentials.json"); + expect(docstring).not.toContain("supermemory.{jsonc,json}"); + expect(docstring).not.toContain("supermemory.jsonc"); + }); +}); From 42ac254380f7da50d0981f9035ec4cfeb75d124b Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:47:50 +0800 Subject: [PATCH 10/37] feat(memory): dedupe memories by content hash --- src/memory/dedup.ts | 171 +++++++++++++++++++++++++++++++++++++++ tests/unit/dedup.test.ts | 91 +++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/memory/dedup.ts create mode 100644 tests/unit/dedup.test.ts diff --git a/src/memory/dedup.ts b/src/memory/dedup.ts new file mode 100644 index 0000000..b3b953f --- /dev/null +++ b/src/memory/dedup.ts @@ -0,0 +1,171 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; + +export interface DedupCacheConfig { + dedupEnabled: boolean; + dedupCacheSize: number; + dataDir: string; +} + +interface PersistedDedupCache { + hashes: string[]; + updatedAt: string; +} + +export interface DedupCache { + has(content: string): boolean; + add(content: string): void; + flush(): Promise; + load(): Promise; +} + +const CACHE_FILE = "dedup-cache.json"; + +function normalizeContent(content: string): string { + return content.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function hashContent(content: string): string { + return createHash("sha256").update(normalizeContent(content)).digest("hex"); +} + +function parsePersistedCache(raw: string): string[] { + const parsed = JSON.parse(raw) as Partial; + + if (!Array.isArray(parsed.hashes)) { + return []; + } + + return parsed.hashes.filter((hash): hash is string => typeof hash === "string"); +} + +function trimToCapacity(hashes: string[], capacity: number): string[] { + if (capacity <= 0) { + return []; + } + + return hashes.slice(-capacity); +} + +export function createDedupCache(config: DedupCacheConfig): DedupCache { + const cachePath = join(config.dataDir, CACHE_FILE); + const hashes = new Set(); + let loaded = false; + + function replaceHashes(nextHashes: string[]): void { + hashes.clear(); + for (const hash of trimToCapacity(nextHashes, config.dedupCacheSize)) { + hashes.add(hash); + } + } + + function loadSync(): void { + if (loaded || !config.dedupEnabled) { + loaded = true; + return; + } + + try { + if (!existsSync(cachePath)) { + replaceHashes([]); + return; + } + + replaceHashes(parsePersistedCache(readFileSync(cachePath, "utf-8"))); + } catch { + replaceHashes([]); + } finally { + loaded = true; + } + } + + return { + has(content: string): boolean { + loadSync(); + + if (!config.dedupEnabled) { + return false; + } + + const hash = hashContent(content); + if (!hashes.has(hash)) { + return false; + } + + hashes.delete(hash); + hashes.add(hash); + return true; + }, + + add(content: string): void { + if (!config.dedupEnabled || config.dedupCacheSize <= 0) { + return; + } + + loadSync(); + + const hash = hashContent(content); + if (hashes.has(hash)) { + hashes.delete(hash); + } else if (hashes.size >= config.dedupCacheSize) { + const oldestHash = hashes.values().next().value; + if (oldestHash) { + hashes.delete(oldestHash); + } + } + + hashes.add(hash); + }, + + async flush(): Promise { + if (!config.dedupEnabled) { + return; + } + + await fs.mkdir(config.dataDir, { recursive: true, mode: 0o700 }); + + const payload: PersistedDedupCache = { + hashes: [...hashes], + updatedAt: new Date().toISOString(), + }; + const json = JSON.stringify(payload, null, 2); + const tmpPath = `${cachePath}.tmp.${process.pid}`; + + await fs.writeFile(tmpPath, json, { mode: 0o600 }); + await fs.rename(tmpPath, cachePath); + }, + + async load(): Promise { + if (!config.dedupEnabled) { + loaded = true; + replaceHashes([]); + return; + } + + try { + const raw = await fs.readFile(cachePath, "utf-8"); + replaceHashes(parsePersistedCache(raw)); + } catch { + replaceHashes([]); + } finally { + loaded = true; + } + }, + }; +} + +export async function withDedup( + content: string, + fn: () => Promise, + cache: ReturnType, +): Promise { + if (cache.has(content)) { + return { skipped: true }; + } + + const result = await fn(); + cache.add(content); + return result; +} diff --git a/tests/unit/dedup.test.ts b/tests/unit/dedup.test.ts new file mode 100644 index 0000000..90ae9d9 --- /dev/null +++ b/tests/unit/dedup.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { afterEach, describe, expect, it } from "bun:test"; + +import { createDedupCache, withDedup } from "@/memory/dedup"; + +const tempDirs: string[] = []; + +async function makeDataDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "dedup-cache-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +describe("createDedupCache", () => { + it("reports a hit after adding identical content", async () => { + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir: await makeDataDir() }); + + cache.add("Same memory"); + + expect(cache.has("Same memory")).toBe(true); + expect(await withDedup("Same memory", async () => "stored", cache)).toEqual({ skipped: true }); + }); + + it("reports a miss for new content", async () => { + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir: await makeDataDir() }); + + cache.add("Known memory"); + + expect(cache.has("Fresh memory")).toBe(false); + }); + + it("normalizes whitespace and case before hashing", async () => { + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir: await makeDataDir() }); + + cache.add(" Alpha\n\tBeta "); + + expect(cache.has("alpha beta")).toBe(true); + }); + + it("evicts the oldest hash when capacity is reached", async () => { + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 2, dataDir: await makeDataDir() }); + + cache.add("first"); + cache.add("second"); + cache.add("third"); + + expect(cache.has("first")).toBe(false); + expect(cache.has("second")).toBe(true); + expect(cache.has("third")).toBe(true); + }); + + it("loads persisted hashes after a restart and ignores corrupt cache files", async () => { + const dataDir = await makeDataDir(); + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir }); + cache.add("persisted memory"); + await cache.flush(); + + const restarted = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir }); + await restarted.load(); + + expect(restarted.has("persisted memory")).toBe(true); + + await writeFile(join(dataDir, "dedup-cache.json"), "{bad json", "utf-8"); + const recovered = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir }); + + await recovered.load(); + expect(recovered.has("persisted memory")).toBe(false); + }); + + it("flushes valid JSON with only hashes and timestamp", async () => { + const dataDir = await makeDataDir(); + const cache = createDedupCache({ dedupEnabled: true, dedupCacheSize: 10, dataDir }); + + cache.add("private content stays hashed"); + await cache.flush(); + + const payload = JSON.parse(await readFile(join(dataDir, "dedup-cache.json"), "utf-8")); + + expect(payload.hashes).toHaveLength(1); + expect(payload.hashes[0]).toMatch(/^[a-f0-9]{64}$/); + expect(payload.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(JSON.stringify(payload)).not.toContain("private content stays hashed"); + }); +}); From 3135fe2a22cefd8f0ada501703b225e5189361b8 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 00:59:50 +0800 Subject: [PATCH 11/37] feat(signal): heuristic signal extraction pipeline --- src/signal/extract.ts | 104 ++++++++++++++++++++++++ tests/unit/signal-extract.test.ts | 131 ++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/signal/extract.ts create mode 100644 tests/unit/signal-extract.test.ts diff --git a/src/signal/extract.ts b/src/signal/extract.ts new file mode 100644 index 0000000..8fe8cd6 --- /dev/null +++ b/src/signal/extract.ts @@ -0,0 +1,104 @@ +const MAX_SIGNAL_TURN_CHARS = 50 * 1024; +const COMMAND_PATTERN = /^[/\\][a-z-]+/i; + +export interface MessagePart { + type: string; + text?: string; + synthetic?: boolean; + ignored?: boolean; +} + +export interface Message { + id: string; + role: "user" | "assistant"; + parts: MessagePart[]; + sessionID?: string; + finish?: string; +} + +export interface Turn { + role: "user" | "assistant"; + text: string; + messageId: string; +} + +export interface SignalExtractionConfig { + signalKeywords: string[]; + signalTurnsBefore: number; +} + +export function groupIntoTurns(messages: Message[]): Turn[] { + return messages.map((message) => ({ + role: message.role, + text: message.parts + .filter((part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored) + .map((part) => part.text) + .join("\n") + .trim(), + messageId: message.id, + })); +} + +export function findSignalTurns(turns: Turn[], keywords: string[]): number[] { + const normalizedKeywords = keywords.map((keyword) => keyword.toLowerCase()).filter((keyword) => keyword.length > 0); + + if (normalizedKeywords.length === 0) { + return []; + } + + return turns.reduce((indices, turn, index) => { + if (!isDetectableUserTurn(turn)) { + return indices; + } + + const normalizedText = turn.text.toLowerCase(); + + if (normalizedKeywords.some((keyword) => normalizedText.includes(keyword))) { + return [...indices, index]; + } + + return indices; + }, []); +} + +export function getContextualTurns(turns: Turn[], signalIndices: number[], turnsBefore: number): Turn[] { + const collectedIndices = new Set(); + const safeTurnsBefore = Math.max(0, Math.floor(turnsBefore)); + + for (const signalIndex of signalIndices) { + const startIndex = Math.max(0, signalIndex - safeTurnsBefore); + const endIndex = Math.min(turns.length - 1, signalIndex); + + for (let index = startIndex; index <= endIndex; index += 1) { + collectedIndices.add(index); + } + } + + return [...collectedIndices] + .sort((left, right) => left - right) + .map((index) => turns[index]) + .filter((turn): turn is Turn => Boolean(turn)); +} + +export function extractSignalContent(messages: Message[], config: SignalExtractionConfig): string | null { + const turns = groupIntoTurns(messages); + const signalIndices = findSignalTurns(turns, config.signalKeywords); + + if (signalIndices.length === 0) { + return null; + } + + const contextualTurns = getContextualTurns(turns, signalIndices, config.signalTurnsBefore); + const content = contextualTurns.map((turn) => `[${turn.role}] ${turn.text}`).join("\n").trim(); + + return content.length > 0 ? content : null; +} + +function isDetectableUserTurn(turn: Turn): boolean { + return ( + turn.role === "user" && + turn.text.length > 0 && + turn.text.length <= MAX_SIGNAL_TURN_CHARS && + !COMMAND_PATTERN.test(turn.text) + ); +} diff --git a/tests/unit/signal-extract.test.ts b/tests/unit/signal-extract.test.ts new file mode 100644 index 0000000..c0dfeea --- /dev/null +++ b/tests/unit/signal-extract.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test"; +import { + extractSignalContent, + findSignalTurns, + getContextualTurns, + groupIntoTurns, + type Message, + type Turn, +} from "@/signal/extract"; + +function textMessage(id: string, role: Message["role"], text: string): Message { + return { + id, + role, + parts: [{ type: "text", text }], + }; +} + +describe("signal extraction", () => { + it("groups interleaved messages into turns", () => { + const turns = groupIntoTurns([ + textMessage("u1", "user", "First request"), + textMessage("a1", "assistant", "First reply"), + textMessage("u2", "user", "Second request"), + ]); + + expect(turns).toEqual([ + { role: "user", text: "First request", messageId: "u1" }, + { role: "assistant", text: "First reply", messageId: "a1" }, + { role: "user", text: "Second request", messageId: "u2" }, + ]); + }); + + it("returns matching user turn indices", () => { + const turns: Turn[] = [ + { role: "user", text: "This is important", messageId: "u1" }, + { role: "assistant", text: "important assistant context", messageId: "a1" }, + { role: "user", text: "Please remember the API shape", messageId: "u2" }, + ]; + + expect(findSignalTurns(turns, ["remember", "important"])).toEqual([0, 2]); + }); + + it("returns empty indices when no keyword matches", () => { + const turns: Turn[] = [{ role: "user", text: "Please run the test", messageId: "u1" }]; + + expect(findSignalTurns(turns, ["remember"])).toEqual([]); + }); + + it("collects signal turns with prior context without duplicate overlap", () => { + const turns: Turn[] = [ + { role: "user", text: "Setup", messageId: "u1" }, + { role: "assistant", text: "Reply", messageId: "a1" }, + { role: "user", text: "remember alpha", messageId: "u2" }, + { role: "assistant", text: "Ack", messageId: "a2" }, + { role: "user", text: "remember beta", messageId: "u3" }, + ]; + + expect(getContextualTurns(turns, [2, 4], 2)).toEqual(turns); + }); + + it("handles a signal at the first turn", () => { + const turns: Turn[] = [ + { role: "user", text: "remember root", messageId: "u1" }, + { role: "assistant", text: "Ack", messageId: "a1" }, + ]; + + expect(getContextualTurns(turns, [0], 3)).toEqual([{ role: "user", text: "remember root", messageId: "u1" }]); + }); + + it("returns null when keywords are absent", () => { + const messages = [textMessage("u1", "user", "Please inspect this file")]; + + expect(extractSignalContent(messages, { signalKeywords: ["remember"], signalTurnsBefore: 1 })).toBeNull(); + }); + + it("returns concatenated content for matched turns and context", () => { + const messages = [ + textMessage("u1", "user", "Earlier context"), + textMessage("a1", "assistant", "Assistant context"), + textMessage("u2", "user", "Remember the release uses Bun"), + ]; + + expect(extractSignalContent(messages, { signalKeywords: ["remember"], signalTurnsBefore: 1 })).toBe( + "[assistant] Assistant context\n[user] Remember the release uses Bun", + ); + }); + + it("skips long user turns during signal detection", () => { + const longPaste = `${"x".repeat(50 * 1024 + 1)} remember this`; + const turns: Turn[] = [{ role: "user", text: longPaste, messageId: "u1" }]; + + expect(findSignalTurns(turns, ["remember"])).toEqual([]); + }); + + it("skips command-like user turns during signal detection", () => { + const turns: Turn[] = [ + { role: "user", text: "/remember this", messageId: "u1" }, + { role: "user", text: "\\note this", messageId: "u2" }, + ]; + + expect(findSignalTurns(turns, ["remember", "note"])).toEqual([]); + }); + + it("matches signal keywords without case sensitivity", () => { + const turns: Turn[] = [{ role: "user", text: "This is IMPORTANT", messageId: "u1" }]; + + expect(findSignalTurns(turns, ["important"])).toEqual([0]); + }); + + it("deduplicates overlapping context windows for two signals", () => { + const turns: Turn[] = [ + { role: "user", text: "One", messageId: "u1" }, + { role: "assistant", text: "Two", messageId: "a1" }, + { role: "user", text: "remember three", messageId: "u2" }, + { role: "assistant", text: "Four", messageId: "a2" }, + { role: "user", text: "remember five", messageId: "u3" }, + ]; + + expect(getContextualTurns(turns, [2, 4], 1)).toEqual([ + { role: "assistant", text: "Two", messageId: "a1" }, + { role: "user", text: "remember three", messageId: "u2" }, + { role: "assistant", text: "Four", messageId: "a2" }, + { role: "user", text: "remember five", messageId: "u3" }, + ]); + }); + + it("returns null for an empty message list", () => { + expect(extractSignalContent([], { signalKeywords: ["remember"], signalTurnsBefore: 2 })).toBeNull(); + }); +}); From 6d6c71d5d2af7c2ccc7903b3c35905b7c7038213 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:07:32 +0800 Subject: [PATCH 12/37] feat(capture): persistent per-session capture tracker --- src/capture/tracker.ts | 51 +++++++++++++++++++++++ tests/unit/tracker.test.ts | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/capture/tracker.ts create mode 100644 tests/unit/tracker.test.ts diff --git a/src/capture/tracker.ts b/src/capture/tracker.ts new file mode 100644 index 0000000..0012ed1 --- /dev/null +++ b/src/capture/tracker.ts @@ -0,0 +1,51 @@ +import { appendFile, mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; + +const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +function trackerPath(sessionId: string, trackersDir: string): string { + const sanitizedSessionId = sessionId.replace(/[^a-z0-9_-]/gi, "_"); + return join(trackersDir, `${sanitizedSessionId}.txt`); +} + +async function ensureTrackersDir(trackersDir: string): Promise { + await mkdir(trackersDir, { recursive: true, mode: 0o700 }); +} + +export async function getLastCaptured(sessionId: string, trackersDir: string): Promise { + try { + const content = await readFile(trackerPath(sessionId, trackersDir), "utf-8"); + const capturedIds = content.split(/\r?\n/).filter((line) => line.length > 0); + return capturedIds.at(-1) ?? null; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function appendCaptured(sessionId: string, messageId: string, trackersDir: string): Promise { + await ensureTrackersDir(trackersDir); + await appendFile(trackerPath(sessionId, trackersDir), `${messageId}\n`, "utf-8"); +} + +export async function pruneOldTrackers(trackersDir: string, maxAgeMs = DEFAULT_MAX_AGE_MS): Promise { + await ensureTrackersDir(trackersDir); + const entries = await readdir(trackersDir, { withFileTypes: true }); + const cutoff = Date.now() - maxAgeMs; + let deletedCount = 0; + + for (const entry of entries) { + if (!entry.isFile()) continue; + + const filePath = join(trackersDir, entry.name); + const fileStat = await stat(filePath); + if (fileStat.mtimeMs >= cutoff) continue; + + await rm(filePath); + deletedCount += 1; + } + + return deletedCount; +} diff --git a/tests/unit/tracker.test.ts b/tests/unit/tracker.test.ts new file mode 100644 index 0000000..6b3b2c2 --- /dev/null +++ b/tests/unit/tracker.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import { chmod, mkdir, readFile, stat, utimes } from "node:fs/promises"; +import { join } from "node:path"; + +import { appendCaptured, getLastCaptured, pruneOldTrackers } from "@/capture/tracker"; +import { cleanupTmpDir, createTmpDir } from "../helpers/tmpdir"; + +async function expectMode(path: string, expectedMode: number): Promise { + const actualMode = (await stat(path)).mode & 0o777; + expect(actualMode).toBe(expectedMode); +} + +describe("capture tracker", () => { + it("appends captured message IDs and reads the last non-empty line", async () => { + const root = createTmpDir("tracker-tail"); + try { + const trackersDir = join(root, "trackers"); + + await appendCaptured("session-a", "msg-1", trackersDir); + await appendCaptured("session-a", "msg-2", trackersDir); + + expect(await readFile(join(trackersDir, "session-a.txt"), "utf-8")).toBe("msg-1\nmsg-2\n"); + expect(await getLastCaptured("session-a", trackersDir)).toBe("msg-2"); + } finally { + cleanupTmpDir(root); + } + }); + + it("sanitizes session IDs before building tracker paths", async () => { + const root = createTmpDir("tracker-sanitize"); + try { + const trackersDir = join(root, "trackers"); + const sessionId = "../bad/session:id"; + + await appendCaptured(sessionId, "msg-safe", trackersDir); + + expect(await getLastCaptured(sessionId, trackersDir)).toBe("msg-safe"); + expect(await readFile(join(trackersDir, "___bad_session_id.txt"), "utf-8")).toBe("msg-safe\n"); + } finally { + cleanupTmpDir(root); + } + }); + + it("prunes old tracker files and keeps new tracker files", async () => { + const root = createTmpDir("tracker-prune"); + try { + const trackersDir = join(root, "trackers"); + await mkdir(trackersDir, { recursive: true }); + await appendCaptured("old", "old-msg", trackersDir); + await appendCaptured("new", "new-msg", trackersDir); + + const oldTime = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + await utimes(join(trackersDir, "old.txt"), oldTime, oldTime); + + expect(await pruneOldTrackers(trackersDir)).toBe(1); + expect(await getLastCaptured("old", trackersDir)).toBeNull(); + expect(await getLastCaptured("new", trackersDir)).toBe("new-msg"); + } finally { + cleanupTmpDir(root); + } + }); + + it("returns null when the tracker file is missing", async () => { + const root = createTmpDir("tracker-missing"); + try { + expect(await getLastCaptured("missing-session", join(root, "trackers"))).toBeNull(); + } finally { + cleanupTmpDir(root); + } + }); + + it("creates the tracker directory on first use with owner-only permissions", async () => { + const root = createTmpDir("tracker-create-dir"); + try { + const trackersDir = join(root, "nested", "trackers"); + + await appendCaptured("session-a", "msg-1", trackersDir); + + await expectMode(trackersDir, 0o700); + } finally { + await chmod(root, 0o700); + cleanupTmpDir(root); + } + }); +}); From 2dbac57fa4a6269856d51ce2af237da3ef386054 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:11:47 +0800 Subject: [PATCH 13/37] feat(memory): auto-categorize stored memories --- src/memory/category.ts | 23 ++++++++++++++++++++++ tests/unit/category.test.ts | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/memory/category.ts create mode 100644 tests/unit/category.test.ts diff --git a/src/memory/category.ts b/src/memory/category.ts new file mode 100644 index 0000000..7ffd7f7 --- /dev/null +++ b/src/memory/category.ts @@ -0,0 +1,23 @@ +export type Category = "preference" | "fact" | "decision" | "entity" | "other"; + +const CODE_BLOCK_RE = /```[\s\S]*?```/g; + +// Order below mirrors the priority ranking in detectCategory. +const PREFERENCE_RE = /\b(i (prefer|like|always|never|use|hate)|prefer to|like to|always|never)\b/i; +const DECISION_RE = /\b(decided|decision|chose|picked|going to use|will use|switched to)\b/i; +const ENTITY_RE = /\b(my (name|team|company|project|email|phone)|i work at|i am at)\b/i; +const FACT_RE = /\b(is|are|was|were|has|have|works|works at)\b/i; + +export function detectCategory(content: string | null | undefined): Category { + if (typeof content !== "string" || content.length === 0) return "other"; + + const stripped = content.replace(CODE_BLOCK_RE, ""); + if (stripped.trim().length === 0) return "other"; + + if (PREFERENCE_RE.test(stripped)) return "preference"; + if (DECISION_RE.test(stripped)) return "decision"; + if (ENTITY_RE.test(stripped)) return "entity"; + if (FACT_RE.test(stripped)) return "fact"; + + return "other"; +} diff --git a/tests/unit/category.test.ts b/tests/unit/category.test.ts new file mode 100644 index 0000000..4e7c6e0 --- /dev/null +++ b/tests/unit/category.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; + +import { type Category, detectCategory } from "@/memory/category"; + +describe("detectCategory", () => { + it("classifies a preference phrase as 'preference'", () => { + expect(detectCategory("I prefer dark mode for the editor")).toBe("preference"); + }); + + it("classifies a decision phrase as 'decision'", () => { + expect(detectCategory("We decided to switch the build runner")).toBe("decision"); + }); + + it("classifies an entity phrase as 'entity'", () => { + expect(detectCategory("My team is Platform Infra")).toBe("entity"); + }); + + it("classifies a plain factual phrase as 'fact'", () => { + expect(detectCategory("The sky is blue today")).toBe("fact"); + }); + + it("returns 'other' for empty input", () => { + expect(detectCategory("")).toBe("other"); + }); + + it("strips fenced code blocks before matching so code does not trigger preference", () => { + const input = "```ts\nI prefer dark mode\n```\nshipping update"; + expect(detectCategory(input)).toBe("other"); + }); + + it("returns 'other' for null or undefined input without throwing", () => { + expect(detectCategory(null as unknown as string)).toBe("other"); + expect(detectCategory(undefined as unknown as string)).toBe("other"); + }); + + it("prefers 'preference' over 'fact' when both signals are present", () => { + expect(detectCategory("I prefer the way the API is structured")).toBe("preference"); + }); +}); From f141f0b9a06853657177a5867f0c4e25ead415d1 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:11:47 +0800 Subject: [PATCH 14/37] feat(memory): show memory usage footer --- src/memory/footer.ts | 12 ++++++++++++ tests/unit/footer.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/memory/footer.ts create mode 100644 tests/unit/footer.test.ts diff --git a/src/memory/footer.ts b/src/memory/footer.ts new file mode 100644 index 0000000..b4bae72 --- /dev/null +++ b/src/memory/footer.ts @@ -0,0 +1,12 @@ +export interface MemoryCounts { + profile: number; + projectMemories: number; + relevantMemories: number; +} + +export function formatMemoFooter(counts: MemoryCounts): string { + const total = counts.profile + counts.projectMemories + counts.relevantMemories; + if (total === 0) return ""; + + return `[Supermemory: ${total} memories loaded]`; +} diff --git a/tests/unit/footer.test.ts b/tests/unit/footer.test.ts new file mode 100644 index 0000000..65db21a --- /dev/null +++ b/tests/unit/footer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { formatMemoFooter } from "@/memory/footer"; + +describe("formatMemoFooter", () => { + it("renders the total across profile, project, and relevant counts", () => { + expect( + formatMemoFooter({ + profile: 1, + projectMemories: 2, + relevantMemories: 2, + }), + ).toBe("[Supermemory: 5 memories loaded]"); + }); + + it("returns an empty string when no memories are loaded", () => { + expect( + formatMemoFooter({ + profile: 0, + projectMemories: 0, + relevantMemories: 0, + }), + ).toBe(""); + }); + + it("handles mixed non-zero counts with one bucket empty", () => { + expect( + formatMemoFooter({ + profile: 3, + projectMemories: 0, + relevantMemories: 7, + }), + ).toBe("[Supermemory: 10 memories loaded]"); + }); +}); From bb03e0e413773329beae9728de5f923307dc1e46 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:24:34 +0800 Subject: [PATCH 15/37] feat(memory): wrap client calls with timeout and dedupe --- src/memory/client.ts | 78 ++++++++++++++++++++++++++++------ tests/unit/client.test.ts | 89 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/src/memory/client.ts b/src/memory/client.ts index 81837bc..972c99c 100644 --- a/src/memory/client.ts +++ b/src/memory/client.ts @@ -1,19 +1,24 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + import { getLogger } from "@logtape/logtape"; import Supermemory from "supermemory"; import { getConfig } from "@/config/loader"; +import { detectCategory } from "@/memory/category"; +import { createDedupCache, type DedupCache } from "@/memory/dedup"; +import { clampEntityContext } from "@/memory/entity-context"; import type { AppError } from "@/shared/errors"; import { err, ok, type Result } from "@/shared/result"; +import { withTimeout } from "@/shared/timeout"; import type { ConversationIngestResponse, ConversationMessage, MemoryType } from "@/types/index"; const logger = getLogger(["supermemory", "memory", "client"]); -const TIMEOUT_MS = 30000; +const TIMEOUT_MS = 5000; +const INGEST_TIMEOUT_MS = 30000; const MAX_CONVERSATION_CHARS = 100_000; - -function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms))]); -} +const DEDUP_DATA_DIR = join(homedir(), ".local", "share", "opencode-supermemory-p"); function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -47,13 +52,33 @@ async function withResult(label: string, fn: () => Promise): Promise> & { success: true }; export type ProfileResult = Awaited> & { success: true }; -export type AddMemoryResult = Awaited> & { success: true }; +export type AddMemoryResult = Partial>> & { success: true; id?: string; deduped?: true }; export type DeleteMemoryResult = { success: true }; export type ListMemoriesResult = Awaited> & { success: true }; export type IngestConversationResult = ConversationIngestResponse & { success: true; storedMemoryIds: string[] }; +type AddMemoryMetadata = { type?: MemoryType; tool?: string; [key: string]: unknown }; + +interface SupermemoryClientOptions { + dedupCache?: DedupCache; +} + export class SupermemoryClient { private client: Supermemory | null = null; + private dedupCache?: DedupCache; + + constructor(options: SupermemoryClientOptions = {}) { + this.dedupCache = options.dedupCache; + } + + private getDedupCache(config: ReturnType): DedupCache { + this.dedupCache ??= createDedupCache({ + dedupEnabled: config.dedupEnabled, + dedupCacheSize: config.dedupCacheSize, + dataDir: DEDUP_DATA_DIR, + }); + return this.dedupCache; + } private formatConversationMessage(message: ConversationMessage): string { const content = @@ -100,6 +125,7 @@ export class SupermemoryClient { searchMode: "hybrid", }), TIMEOUT_MS, + "supermemory.searchMemories", ); logger.info("searchMemories: success", { count: result.results?.length || 0 }); return { success: true as const, ...result }; @@ -115,6 +141,7 @@ export class SupermemoryClient { q: query, }), TIMEOUT_MS, + "supermemory.getProfile", ); logger.info("getProfile: success", { hasProfile: !!result?.profile }); return { success: true as const, ...result }; @@ -124,18 +151,37 @@ export class SupermemoryClient { async addMemory( content: string, containerTag: string, - metadata?: { type?: MemoryType; tool?: string; [key: string]: unknown }, + metadata?: AddMemoryMetadata, ): Promise> { logger.info("addMemory: start", { containerTag, contentLength: content.length }); return withResult("addMemory", async () => { + const config = getConfig(); + const nextMetadata: Record & { type?: string } = { ...(metadata ?? {}) }; + if (config.autoCategoryTagging === true && nextMetadata.type === undefined) { + nextMetadata.type = detectCategory(content); + } + + if (config.dedupEnabled && this.getDedupCache(config).has(content)) { + logger.info("addMemory: deduped", { containerTag }); + return { success: true as const, deduped: true as const }; + } + + const metadataWithContext = { + ...nextMetadata, + entityContext: clampEntityContext(config.entityContext), + }; const result = await withTimeout( this.getClient().memories.add({ content, containerTag, - metadata: metadata as Record, + metadata: metadataWithContext as Record, }), TIMEOUT_MS, + "supermemory.addMemory", ); + if (config.dedupEnabled) { + this.getDedupCache(config).add(content); + } logger.info("addMemory: success", { id: result.id }); return { success: true as const, ...result }; }); @@ -144,7 +190,7 @@ export class SupermemoryClient { async deleteMemory(memoryId: string): Promise> { logger.info("deleteMemory: start", { memoryId }); return withResult("deleteMemory", async () => { - await withTimeout(this.getClient().memories.delete(memoryId), TIMEOUT_MS); + await withTimeout(this.getClient().memories.delete(memoryId), TIMEOUT_MS, "supermemory.deleteMemory"); logger.info("deleteMemory: success", { memoryId }); return { success: true }; }); @@ -162,6 +208,7 @@ export class SupermemoryClient { includeContent: true, }), TIMEOUT_MS, + "supermemory.listMemories", ); logger.info("listMemories: success", { count: result.memories?.length || 0 }); return { success: true as const, ...result }; @@ -206,11 +253,16 @@ export class SupermemoryClient { let firstError: AppError | null = null; for (const tag of uniqueTags) { - const result = await this.addMemory(content, tag, ingestMetadata); - if (result.ok) { + let result: Result; + try { + result = await withTimeout(this.addMemory(content, tag, ingestMetadata), INGEST_TIMEOUT_MS, "supermemory.ingestConversation"); + } catch (error) { + result = err(toAppError(error)); + } + if (result.ok && typeof result.value.id === "string") { savedIds.push(result.value.id); } else if (!firstError) { - firstError = result.error; + firstError = result.ok ? ({ kind: "NetworkError", message: "Memory was deduped before ingest storage" } satisfies AppError) : result.error; } } @@ -270,7 +322,7 @@ export const supermemoryClient = { profile: null, }); }, - async addMemory(content: string, containerTag: string, metadata?: { type?: MemoryType; tool?: string; [key: string]: unknown }) { + async addMemory(content: string, containerTag: string, metadata?: AddMemoryMetadata) { return unwrapOrLegacyShape(await resultSupermemoryClient.addMemory(content, containerTag, metadata), { success: false as const, error: "Failed to add memory", diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index c984b67..979fc6d 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; import type { AppError } from "@/shared/errors"; +import type { DedupCache } from "@/memory/dedup"; // ===================================================================== // Background — what we are pinning @@ -45,6 +46,24 @@ const sdkState: { settingsUpdate: { calls: [] }, }; +const timeoutState: { + calls: Array<{ ms: number; label?: string }>; + rejectLabels: Set; +} = { + calls: [], + rejectLabels: new Set(), +}; + +mock.module("@/shared/timeout", () => ({ + withTimeout: (promise: Promise, ms: number, label?: string): Promise => { + timeoutState.calls.push({ ms, label }); + if (label && timeoutState.rejectLabels.has(label)) { + return Promise.reject(new Error(`Timeout after ${ms}ms (${label})`)); + } + return promise; + }, +})); + // Mock the `supermemory` SDK module. Returns a class whose constructor // shape matches `new Supermemory({ apiKey })` and exposes the same // method tree client.ts navigates into. @@ -94,6 +113,8 @@ mock.module("supermemory", () => { // Dynamic import so both mocks are in effect before client.ts loads. let SupermemoryClient: typeof import("@/memory/client").SupermemoryClient; +let getConfig: typeof import("@/config/loader").getConfig; +let resetConfigCache: typeof import("@/config/loader").resetConfigCache; let previousApiKey: string | undefined; function expectErrorKind(error: AppError, kind: AppError["kind"], message: string): void { @@ -107,7 +128,7 @@ beforeAll(async () => { // Bust any cached config left over from other test files (e.g. tags.test.ts // calls getConfig() without an apiKey set) so getClient() picks up the env // we just set instead of returning a poisoned singleton. - const { resetConfigCache } = await import("@/config/loader"); + ({ getConfig, resetConfigCache } = await import("@/config/loader")); resetConfigCache(); const mod = await import("@/memory/client"); SupermemoryClient = mod.SupermemoryClient; @@ -133,8 +154,18 @@ function resetSdkState(): void { sdkState.settingsUpdate.impl = undefined; } +function resetTimeoutState(): void { + timeoutState.calls.length = 0; + timeoutState.rejectLabels.clear(); +} + beforeEach(() => { + resetConfigCache(); + const config = getConfig(); + config.dedupEnabled = false; + config.autoCategoryTagging = false; resetSdkState(); + resetTimeoutState(); }); // ===================================================================== @@ -168,6 +199,17 @@ describe("SupermemoryClient.searchMemories", () => { }); }); + it("error: timeout failure includes the shared timeout label", async () => { + timeoutState.rejectLabels.add("supermemory.searchMemories"); + const client = new SupermemoryClient(); + const out = await client.searchMemories("q", "tag_a"); + + expect(out.ok).toBe(false); + if (out.ok) throw new Error("expected error result"); + expectErrorKind(out.error, "NetworkError", "Timeout after 5000ms (supermemory.searchMemories)"); + expect(timeoutState.calls[0]).toEqual({ ms: 5000, label: "supermemory.searchMemories" }); + }); + it("error: SDK throw → returns { success: false, error, results: [], total: 0, timing: 0 } envelope", async () => { sdkState.searchMemories.impl = () => { throw new Error("boom: 503 service unavailable"); @@ -227,10 +269,53 @@ describe("SupermemoryClient.addMemory", () => { expect(sdkState.addMemory.calls[0]?.args[0]).toEqual({ content: "hello world", containerTag: "tag_p", - metadata: { type: "preference", tool: "test" }, + metadata: { type: "preference", tool: "test", entityContext: expect.any(String) }, }); }); + it("success: dedup cache intercepts duplicate content before the SDK call", async () => { + const config = getConfig(); + config.dedupEnabled = true; + const dedupCache: DedupCache = { + has: mock((content: string) => content === "duplicate memory"), + add: mock(() => undefined), + flush: mock(async () => undefined), + load: mock(async () => undefined), + }; + const client = new SupermemoryClient({ dedupCache }); + const out = await client.addMemory("duplicate memory", "tag_p"); + + expect(out.ok).toBe(true); + if (!out.ok) throw new Error("expected ok result"); + expect(out.value).toEqual({ success: true, deduped: true }); + expect(sdkState.addMemory.calls).toHaveLength(0); + expect(dedupCache.add).toHaveBeenCalledTimes(0); + }); + + it("success: entity context is clamped into metadata before adding memory", async () => { + const config = getConfig(); + config.entityContext = "entity ".repeat(400); + sdkState.addMemory.impl = () => ({ id: "mem_entity" }); + const client = new SupermemoryClient(); + await client.addMemory("remember my company is Acme", "tag_p", { tool: "test" }); + + const addCall = sdkState.addMemory.calls[0]?.args[0] as { metadata: Record }; + expect(typeof addCall.metadata.entityContext).toBe("string"); + expect((addCall.metadata.entityContext as string).length).toBeLessThanOrEqual(1500); + expect(addCall.metadata.entityContext).not.toBe(config.entityContext); + }); + + it("success: category is detected when auto tagging is enabled and metadata has no type", async () => { + const config = getConfig(); + config.autoCategoryTagging = true; + sdkState.addMemory.impl = () => ({ id: "mem_category" }); + const client = new SupermemoryClient(); + await client.addMemory("I prefer Bun for project scripts", "tag_p", { tool: "test" }); + + const addCall = sdkState.addMemory.calls[0]?.args[0] as { metadata: Record }; + expect(addCall.metadata.type).toBe("preference"); + }); + it("error: SDK throw → returns { success: false, error } (minimal shape — no fallback `id`)", async () => { sdkState.addMemory.impl = () => { throw new Error("rate limited"); From 68bcc5264477975eda4d008d423298af2a3729b6 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:38:32 +0800 Subject: [PATCH 16/37] feat(memory): dedupe profile facts across sources --- src/memory/context.ts | 74 ++++++++++++++++++++++++++++++-------- tests/unit/context.test.ts | 62 +++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/memory/context.ts b/src/memory/context.ts index 27d8ed6..150f231 100644 --- a/src/memory/context.ts +++ b/src/memory/context.ts @@ -2,11 +2,15 @@ import type { ProfileResponse } from "supermemory/resources"; import { getConfig } from "@/config/loader"; import type { SupermemoryConfig } from "@/config/schema"; +import { formatMemoFooter } from "@/memory/footer"; +import { formatRelativeTime } from "@/memory/relative-time"; +import { dedupe } from "@/shared/collection"; interface MemoryResultMinimal { similarity?: number; memory?: string; chunk?: string; + createdAt?: string | Date; } interface MemoriesResponseMinimal { @@ -23,51 +27,79 @@ function extractFactText(fact: unknown): string { return String(fact ?? ""); } +function getMemoryContent(memory: MemoryResultMinimal): string { + return memory.memory || memory.chunk || ""; +} + +function normalizeFactText(text: string): string { + return text.toLowerCase().trim(); +} + +function formatMemoryLine(memory: MemoryResultMinimal, useRelativeTime: boolean): string { + const similarity = Math.round((memory.similarity ?? 0) * 100); + const content = getMemoryContent(memory); + const relativeTime = useRelativeTime && memory.createdAt != null ? formatRelativeTime(memory.createdAt) : ""; + const timestampSuffix = relativeTime ? ` (${relativeTime})` : ""; + + return `- [${similarity}%] ${content}${timestampSuffix}`; +} + export function formatContextForPrompt( profile: ProfileResponse | null, userMemories: MemoriesResponseMinimal, projectMemories: MemoriesResponseMinimal, - config: Pick = getConfig(), + config: Pick & + Partial> = getConfig(), ): string { const parts: string[] = ["[SUPERMEMORY]"]; + const useRelativeTime = config.relativeTimeDisplay === true; + let staticFactTexts: string[] = []; + let dynamicFactTexts: string[] = []; + const projectResults = projectMemories.results || []; + let userResults = userMemories.results || []; if (config.injectProfile && profile?.profile) { const { static: staticFacts, dynamic: dynamicFacts } = profile.profile; + staticFactTexts = staticFacts.slice(0, config.maxProfileItems).map(extractFactText); + dynamicFactTexts = dynamicFacts.slice(0, config.maxProfileItems).map(extractFactText); + + if (config.profileCrossArrayDedup === true) { + staticFactTexts = dedupe(staticFactTexts); + const staticFactKeys = new Set(staticFactTexts.map(normalizeFactText)); + dynamicFactTexts = dedupe(dynamicFactTexts.filter((fact) => !staticFactKeys.has(normalizeFactText(fact)))); + const profileFactKeys = new Set([...staticFactTexts, ...dynamicFactTexts].map(normalizeFactText)); + userResults = dedupe( + userResults.filter((memory) => !profileFactKeys.has(normalizeFactText(getMemoryContent(memory)))), + getMemoryContent, + ); + } - if (staticFacts.length > 0) { + if (staticFactTexts.length > 0) { parts.push("\nUser Profile:"); - staticFacts.slice(0, config.maxProfileItems).forEach((fact) => { - const text = extractFactText(fact); + staticFactTexts.forEach((text) => { parts.push(`- ${text}`); }); } - if (dynamicFacts.length > 0) { + if (dynamicFactTexts.length > 0) { parts.push("\nRecent Context:"); - dynamicFacts.slice(0, config.maxProfileItems).forEach((fact) => { - const text = extractFactText(fact); + dynamicFactTexts.forEach((text) => { parts.push(`- ${text}`); }); } } - const projectResults = projectMemories.results || []; if (projectResults.length > 0) { parts.push("\nProject Knowledge:"); projectResults.forEach((mem) => { - const similarity = Math.round((mem.similarity ?? 0) * 100); - const content = mem.memory || mem.chunk || ""; - parts.push(`- [${similarity}%] ${content}`); + parts.push(formatMemoryLine(mem, useRelativeTime)); }); } - const userResults = userMemories.results || []; if (userResults.length > 0) { parts.push("\nRelevant Memories:"); userResults.forEach((mem) => { - const similarity = Math.round((mem.similarity ?? 0) * 100); - const content = mem.memory || mem.chunk || ""; - parts.push(`- [${similarity}%] ${content}`); + parts.push(formatMemoryLine(mem, useRelativeTime)); }); } @@ -75,5 +107,17 @@ export function formatContextForPrompt( return ""; } + const footer = config.memoUsageFooter === true + ? formatMemoFooter({ + profile: staticFactTexts.length + dynamicFactTexts.length, + projectMemories: projectResults.length, + relevantMemories: userResults.length, + }) + : ""; + + if (footer) { + parts.push(`\n${footer}`); + } + return parts.join("\n"); } diff --git a/tests/unit/context.test.ts b/tests/unit/context.test.ts index f8d6a19..e50cb52 100644 --- a/tests/unit/context.test.ts +++ b/tests/unit/context.test.ts @@ -1,4 +1,6 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; + +import { resetTime, setFakeTime } from "../helpers/mock-time"; // Dynamic import so the mock above is in effect. let formatContextForPrompt: typeof import("@/memory/context").formatContextForPrompt; @@ -9,6 +11,10 @@ beforeAll(async () => { formatContextForPrompt = mod.formatContextForPrompt; }); +afterEach(() => { + resetTime(); +}); + // Minimal stand-in for ProfileResponse — only the shape that // formatContextForPrompt actually reads. Cast at the call site. interface ProfileLike { @@ -161,4 +167,58 @@ Relevant Memories: - [91%] U1" `); }); + + it("dedupes lower-priority search results when the same fact appears in static profile facts", () => { + const repeatedFact = "Uses Bun for project scripts"; + const profile = { + profile: { + static: [repeatedFact], + dynamic: [], + }, + } satisfies ProfileLike; + + const out = formatContextForPrompt( + profile as unknown as Parameters[0], + { results: [{ similarity: 0.92, memory: repeatedFact }] }, + {}, + { ...testConfig, profileCrossArrayDedup: true }, + ); + + expect(out.match(new RegExp(repeatedFact, "g"))?.length).toBe(1); + expect(out).toContain("User Profile:"); + expect(out).not.toContain("Relevant Memories:"); + }); + + it("renders relative time for memory createdAt values without exposing the ISO timestamp", () => { + setFakeTime("2026-01-15T12:00:00.000Z"); + const isoTimestamp = "2026-01-15T09:00:00.000Z"; + + const out = formatContextForPrompt( + null, + { results: [{ similarity: 0.82, memory: "Prefers concise updates", createdAt: isoTimestamp }] }, + {}, + { ...testConfig, relativeTimeDisplay: true }, + ); + + expect(out).toContain("3 hrs ago"); + expect(out).not.toContain(isoTimestamp); + }); + + it("appends the memo usage footer at the end when enabled", () => { + const profile = { + profile: { + static: ["S1"], + dynamic: ["D1"], + }, + } satisfies ProfileLike; + + const out = formatContextForPrompt( + profile as unknown as Parameters[0], + { results: [{ similarity: 0.91, memory: "U1" }] }, + { results: [{ similarity: 0.77, memory: "P1" }] }, + { ...testConfig, memoUsageFooter: true }, + ); + + expect(out.endsWith("[Supermemory: 4 memories loaded]")).toBe(true); + }); }); From e368ed6adaf3ec08887e2540db74752e55050825 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 01:51:11 +0800 Subject: [PATCH 17/37] feat(capture): incremental capture on assistant finish --- src/capture/incremental.ts | 72 ++++++++++ src/events/handler.ts | 23 +++ .../incremental-capture-hook.test.ts | 71 +++++++++ tests/unit/incremental-capture.test.ts | 136 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/capture/incremental.ts create mode 100644 tests/integration/incremental-capture-hook.test.ts create mode 100644 tests/unit/incremental-capture.test.ts diff --git a/src/capture/incremental.ts b/src/capture/incremental.ts new file mode 100644 index 0000000..0162c89 --- /dev/null +++ b/src/capture/incremental.ts @@ -0,0 +1,72 @@ +import { join } from "node:path"; + +import type { SupermemoryConfig } from "@/config/schema"; +import type { SupermemoryClient } from "@/memory/client"; +import { getProjectTag } from "@/memory/tags"; +import { type Message, type MessagePart, extractSignalContent } from "@/signal/extract"; + +export interface EventMessageUpdated { + event: { + type: string; + properties?: { + info?: { + id?: string; + sessionID?: string; + role?: string; + finish?: unknown; + parts?: MessagePart[]; + }; + }; + }; +} + +export interface IncrementalCaptureDeps { + config: SupermemoryConfig; + client: Pick; + tracker: typeof import("@/capture/tracker"); + signalExtract: typeof extractSignalContent; + dataDir: string; +} + +export async function handleMessageUpdatedForCapture( + input: EventMessageUpdated, + deps: IncrementalCaptureDeps, +): Promise { + try { + if (deps.config.incrementalCapture === false) return; + + const info = input.event.properties?.info; + const sessionID = info?.sessionID; + const messageID = info?.id; + if (!sessionID || !messageID || info.role !== "assistant" || !info.finish) return; + + const textParts = (info.parts ?? []).filter( + (part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored, + ); + if (textParts.length === 0) return; + + const message: Message = { + id: messageID, + sessionID, + role: "assistant", + parts: textParts, + }; + + const extractedContent = deps.config.signalExtraction ? deps.signalExtract([message], deps.config) : null; + if (deps.config.signalExtraction && extractedContent === null) return; + + const trackersDir = join(deps.dataDir, "capture-trackers"); + const lastCaptured = await deps.tracker.getLastCaptured(sessionID, trackersDir); + if (lastCaptured === messageID) return; + + const rawContent = extractedContent ?? textParts.map((part) => part.text).join("\n").trim(); + if (rawContent.length === 0) return; + + const content = rawContent.slice(0, deps.config.maxCaptureChars); + const projectTag = getProjectTag(deps.dataDir, deps.config); + await deps.client.addMemory(content, projectTag, { type: "conversation" }); + await deps.tracker.appendCaptured(sessionID, messageID, trackersDir); + } catch { + // Event hooks should never interrupt OpenCode's main event flow. + } +} diff --git a/src/events/handler.ts b/src/events/handler.ts index 49d64b4..24813d9 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -5,16 +5,39 @@ * stays in `src/compaction/` where it can be unit-tested in isolation. */ +import { handleMessageUpdatedForCapture, type IncrementalCaptureDeps } from "@/capture/incremental"; +import * as tracker from "@/capture/tracker"; +import { getConfig } from "@/config/loader"; +import { resultSupermemoryClient } from "@/memory/client"; +import { extractSignalContent } from "@/signal/extract"; + export interface EventInput { event: { type: string; properties?: unknown }; } export interface EventDeps { compactionHook: { event(input: EventInput): Promise } | null; + incrementalCapture?: IncrementalCaptureDeps; } export async function handleEvent(input: EventInput, deps: EventDeps): Promise { + const props = input.event.properties as Record | undefined; + const info = props?.info as { role?: string; finish?: unknown } | undefined; + if (input.event.type === "message.updated" && info?.role === "assistant" && info.finish) { + void handleMessageUpdatedForCapture(input as Parameters[0], deps.incrementalCapture ?? createDefaultIncrementalCaptureDeps()); + } + if (deps.compactionHook) { await deps.compactionHook.event(input); } } + +function createDefaultIncrementalCaptureDeps(): IncrementalCaptureDeps { + return { + config: getConfig(), + client: resultSupermemoryClient, + tracker, + signalExtract: extractSignalContent, + dataDir: process.cwd(), + }; +} diff --git a/tests/integration/incremental-capture-hook.test.ts b/tests/integration/incremental-capture-hook.test.ts new file mode 100644 index 0000000..79e2842 --- /dev/null +++ b/tests/integration/incremental-capture-hook.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { IncrementalCaptureDeps } from "@/capture/incremental"; +import { DEFAULTS } from "@/config/defaults"; +import { SupermemoryConfigSchema } from "@/config/schema"; +import { handleEvent } from "@/events/handler"; + +async function flushCaptureQueue(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("incremental capture event hook", () => { + it("dispatches only completed assistant message.updated events", async () => { + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + success: true as const, + id: "mem_1", + })); + const deps = { + config: SupermemoryConfigSchema.parse({ ...DEFAULTS, projectContainerTag: "project-tag", signalExtraction: false }), + client: { addMemory } as unknown as IncrementalCaptureDeps["client"], + tracker: { + getLastCaptured: mock(async () => null), + appendCaptured: mock(async () => undefined), + pruneOldTrackers: mock(async () => 0), + }, + signalExtract: mock(() => "captured"), + dataDir: "/tmp/project", + } satisfies IncrementalCaptureDeps; + + await handleEvent( + { + event: { + type: "message.updated", + properties: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + finish: true, + parts: [{ type: "text", text: "capture this" }], + }, + }, + }, + }, + { compactionHook: null, incrementalCapture: deps }, + ); + await flushCaptureQueue(); + + await handleEvent( + { + event: { + type: "message.part.updated", + properties: { + info: { + id: "msg_2", + sessionID: "ses_1", + role: "assistant", + finish: true, + parts: [{ type: "text", text: "stream delta" }], + }, + }, + }, + }, + { compactionHook: null, incrementalCapture: deps }, + ); + await flushCaptureQueue(); + + expect(addMemory).toHaveBeenCalledTimes(1); + expect(addMemory.mock.calls[0]?.[0]).toBe("capture this"); + }); +}); diff --git a/tests/unit/incremental-capture.test.ts b/tests/unit/incremental-capture.test.ts new file mode 100644 index 0000000..4cae703 --- /dev/null +++ b/tests/unit/incremental-capture.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; +import { DEFAULTS } from "@/config/defaults"; +import { handleMessageUpdatedForCapture, type EventMessageUpdated, type IncrementalCaptureDeps } from "@/capture/incremental"; + +function makeConfig(overrides: Partial = {}): SupermemoryConfig { + return SupermemoryConfigSchema.parse({ + ...DEFAULTS, + ...overrides, + }); +} + +type MessageUpdatedInfo = NonNullable["info"]>; + +function makeEvent(overrides: Partial = {}): EventMessageUpdated { + return { + event: { + type: "message.updated", + properties: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + finish: true, + parts: [{ type: "text", text: "Remember this stable project fact." }], + ...overrides, + }, + }, + }, + }; +} + +function makeDeps(overrides: Partial = {}) { + const getLastCaptured = mock(async () => null); + const appendCaptured = mock(async () => undefined); + const pruneOldTrackers = mock(async () => 0); + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + success: true as const, + id: "mem_1", + })); + const signalExtract = mock(() => "[assistant] Remember this stable project fact."); + const deps: IncrementalCaptureDeps = { + config: makeConfig({ projectContainerTag: "project-tag", signalExtraction: false }), + client: { addMemory } as unknown as IncrementalCaptureDeps["client"], + tracker: { getLastCaptured, appendCaptured, pruneOldTrackers }, + signalExtract, + dataDir: "/tmp/project", + ...overrides, + }; + + return { deps, getLastCaptured, appendCaptured, addMemory, signalExtract }; +} + +describe("handleMessageUpdatedForCapture", () => { + it("captures assistant text to project memory", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture(makeEvent(), deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + expect(addMemory.mock.calls[0]).toEqual(["Remember this stable project fact.", "project-tag", { type: "conversation" }]); + }); + + it("skips messages with no usable text parts", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture(makeEvent({ parts: [{ type: "tool", text: "hidden" }] }), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("filters synthetic and ignored text parts", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture( + makeEvent({ + parts: [ + { type: "text", text: "synthetic", synthetic: true }, + { type: "text", text: "ignored", ignored: true }, + { type: "text", text: "visible" }, + ], + }), + deps, + ); + + expect(addMemory.mock.calls[0]?.[0]).toBe("visible"); + }); + + it("skips when signal extraction finds no content", async () => { + const signalExtract = mock(() => null); + const { deps, addMemory } = makeDeps({ config: makeConfig({ projectContainerTag: "project-tag", signalExtraction: true }), signalExtract }); + + await handleMessageUpdatedForCapture(makeEvent(), deps); + + expect(signalExtract).toHaveBeenCalledTimes(1); + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("skips when tracker already captured the message", async () => { + const getLastCaptured = mock(async () => "msg_1"); + const { deps, addMemory } = makeDeps({ + tracker: { getLastCaptured, appendCaptured: mock(async () => undefined), pruneOldTrackers: mock(async () => 0) }, + }); + + await handleMessageUpdatedForCapture(makeEvent(), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("swallows tracker and client errors", async () => { + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => { + throw new Error("network down"); + }); + const { deps } = makeDeps({ client: { addMemory } as unknown as IncrementalCaptureDeps["client"] }); + + await handleMessageUpdatedForCapture(makeEvent(), deps); + expect(addMemory).toHaveBeenCalledTimes(1); + }); + + it("appends tracker entry after successful capture", async () => { + const { deps, appendCaptured } = makeDeps(); + + await handleMessageUpdatedForCapture(makeEvent(), deps); + + expect(appendCaptured).toHaveBeenCalledWith("ses_1", "msg_1", "/tmp/project/capture-trackers"); + }); + + it("returns early when finish is missing", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture(makeEvent({ finish: false }), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); +}); From 3c35e3b8f5a9a5ab89aa6dda57aaa83ec4a8811e Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 02:04:57 +0800 Subject: [PATCH 18/37] feat(capture): save summary on session end --- src/capture/session-end.ts | 123 +++++++++++++++++++++++++++++++++ src/events/handler.ts | 6 ++ src/index.ts | 22 +++++- tests/unit/session-end.test.ts | 120 ++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/capture/session-end.ts create mode 100644 tests/unit/session-end.test.ts diff --git a/src/capture/session-end.ts b/src/capture/session-end.ts new file mode 100644 index 0000000..6db0225 --- /dev/null +++ b/src/capture/session-end.ts @@ -0,0 +1,123 @@ +import { join } from "node:path"; + +import type { SupermemoryConfig } from "@/config/schema"; +import { type Message, type MessagePart, type Turn, extractSignalContent, groupIntoTurns } from "@/signal/extract"; + +export interface EventSessionDeleted { + event: { + type: "session.deleted"; + properties?: { + info?: { + id?: string; + }; + }; + }; +} + +export interface EventSessionIdle { + event: { + type: "session.idle"; + properties?: { + sessionID?: string; + }; + }; +} + +interface SessionMessageInfo { + id?: string; + role?: string; + sessionID?: string; +} + +interface SessionMessageRecord { + info?: SessionMessageInfo; + parts?: MessagePart[]; +} + +type SessionMessagesResponse = SessionMessageRecord[] | { data?: SessionMessageRecord[] }; + +export interface SessionEndDeps { + config: SupermemoryConfig; + client: { + addMemory(content: string, containerTag: string, metadata?: { type: "conversation" }): Promise; + }; + sdkClient: { + session: { + messages(input: { sessionId: string }): Promise; + }; + }; + tracker: Pick; + signalExtract: typeof extractSignalContent; + dataDir: string; + projectTag: string; +} + +const savedSessions = new Set(); + +export async function handleSessionEnd(input: EventSessionDeleted | EventSessionIdle, deps: SessionEndDeps): Promise { + try { + if (deps.config.sessionEndSave === false) return; + + const sessionID = getSessionID(input); + if (!sessionID || savedSessions.has(sessionID)) return; + + const trackersDir = join(deps.dataDir, "capture-trackers"); + const lastCaptured = await deps.tracker.getLastCaptured(sessionID, trackersDir); + const response = await deps.sdkClient.session.messages({ sessionId: sessionID }); + const messages = normalizeMessages(response); + if (messages.length === 0) return; + + const uncapturedMessages = sliceAfterLastCaptured(messages, lastCaptured); + const turns = groupIntoTurns(uncapturedMessages).filter((turn) => turn.text.length > 0); + if (turns.length === 0) return; + + const extractedContent = deps.config.signalExtraction ? deps.signalExtract(uncapturedMessages, deps.config) : null; + if (deps.config.signalExtraction && extractedContent === null) return; + + const rawContent = extractedContent ?? formatTurns(turns); + const content = rawContent.slice(0, deps.config.maxCaptureChars * 4).trim(); + if (content.length === 0) return; + + await deps.client.addMemory(content, deps.projectTag, { type: "conversation" }); + savedSessions.add(sessionID); + + if (input.event.type === "session.deleted") { + await deps.tracker.pruneOldTrackers(trackersDir); + } + } catch { + // Event hooks should never interrupt OpenCode's main event flow. + } +} + +function getSessionID(input: EventSessionDeleted | EventSessionIdle): string | undefined { + if (input.event.type === "session.deleted") { + return input.event.properties?.info?.id; + } + return input.event.properties?.sessionID; +} + +function normalizeMessages(response: SessionMessagesResponse): Message[] { + const records = Array.isArray(response) ? response : (response.data ?? []); + return records.flatMap((record): Message[] => { + const info = record.info; + if (!info?.id || (info.role !== "user" && info.role !== "assistant")) return []; + return [ + { + id: info.id, + role: info.role, + sessionID: info.sessionID, + parts: record.parts ?? [], + }, + ]; + }); +} + +function sliceAfterLastCaptured(messages: Message[], lastCaptured: string | null): Message[] { + if (!lastCaptured) return messages; + const lastCapturedIndex = messages.findIndex((message) => message.id === lastCaptured); + return lastCapturedIndex >= 0 ? messages.slice(lastCapturedIndex + 1) : messages; +} + +function formatTurns(turns: Turn[]): string { + return turns.map((turn) => `[${turn.role}] ${turn.text}`).join("\n").trim(); +} diff --git a/src/events/handler.ts b/src/events/handler.ts index 24813d9..4624cea 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -6,6 +6,7 @@ */ import { handleMessageUpdatedForCapture, type IncrementalCaptureDeps } from "@/capture/incremental"; +import { handleSessionEnd, type SessionEndDeps } from "@/capture/session-end"; import * as tracker from "@/capture/tracker"; import { getConfig } from "@/config/loader"; import { resultSupermemoryClient } from "@/memory/client"; @@ -18,6 +19,7 @@ export interface EventInput { export interface EventDeps { compactionHook: { event(input: EventInput): Promise } | null; incrementalCapture?: IncrementalCaptureDeps; + sessionEnd?: SessionEndDeps; } export async function handleEvent(input: EventInput, deps: EventDeps): Promise { @@ -27,6 +29,10 @@ export async function handleEvent(input: EventInput, deps: EventDeps): Promise[0], deps.incrementalCapture ?? createDefaultIncrementalCaptureDeps()); } + if ((input.event.type === "session.deleted" || input.event.type === "session.idle") && deps.sessionEnd) { + void handleSessionEnd(input as Parameters[0], deps.sessionEnd); + } + if (deps.compactionHook) { await deps.compactionHook.event(input); } diff --git a/src/index.ts b/src/index.ts index 1e3e4a7..65171b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { handleChatMessage } from "@/chat/handler"; import { type CompactionContext, createCompactionHook } from "@/compaction/index"; import { createModelLimitLookup } from "@/compaction/model-limits"; import { getConfig } from "@/config/loader"; +import { extractSignalContent } from "@/signal/extract"; +import * as tracker from "@/capture/tracker"; import { handleEvent } from "@/events/handler"; import { supermemoryClient } from "@/memory/client"; import { getTags } from "@/memory/tags"; @@ -23,7 +25,25 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { return { "chat.message": (input, output) => handleChatMessage(input, output, deps), tool: { supermemory: createSupermemoryTool({ tags: deps.tags, client: supermemoryClient }) }, - event: (input: { event: { type: string; properties?: unknown } }) => handleEvent(input, { compactionHook }), + event: (input: { event: { type: string; properties?: unknown } }) => + handleEvent(input, { + compactionHook, + sessionEnd: ctx.client + ? { + config: deps.config, + client: deps.client, + sdkClient: { + session: { + messages: ({ sessionId }) => ctx.client.session.messages({ path: { id: sessionId }, query: { directory: ctx.directory } }), + }, + }, + tracker, + signalExtract: extractSignalContent, + dataDir: ctx.directory, + projectTag: deps.tags.project, + } + : undefined, + }), }; }; diff --git a/tests/unit/session-end.test.ts b/tests/unit/session-end.test.ts new file mode 100644 index 0000000..d1cc41c --- /dev/null +++ b/tests/unit/session-end.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { DEFAULTS } from "@/config/defaults"; +import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; +import { handleSessionEnd, type EventSessionDeleted, type EventSessionIdle, type SessionEndDeps } from "@/capture/session-end"; +import { extractSignalContent } from "@/signal/extract"; + +function makeConfig(overrides: Partial = {}): SupermemoryConfig { + return SupermemoryConfigSchema.parse({ + ...DEFAULTS, + projectContainerTag: "project-tag", + signalExtraction: false, + ...overrides, + }); +} + +function makeDeletedEvent(sessionId: string): EventSessionDeleted { + return { event: { type: "session.deleted", properties: { info: { id: sessionId } } } }; +} + +function makeIdleEvent(sessionId: string): EventSessionIdle { + return { event: { type: "session.idle", properties: { sessionID: sessionId } } }; +} + +function makeMessage(id: string, role: "user" | "assistant", text: string) { + return { + info: { id, role, sessionID: "ses_test" }, + parts: [{ type: "text", text }], + }; +} + +function makeDeps(overrides: Partial = {}) { + const getLastCaptured = mock(async () => null); + const pruneOldTrackers = mock(async () => 0); + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + success: true as const, + id: "mem_1", + })); + const messages = mock(async () => ({ + data: [makeMessage("msg_1", "user", "Remember this project decision."), makeMessage("msg_2", "assistant", "Saved.")], + })); + const deps: SessionEndDeps = { + config: makeConfig(), + client: { addMemory } as unknown as SessionEndDeps["client"], + sdkClient: { session: { messages } }, + tracker: { getLastCaptured, pruneOldTrackers }, + signalExtract: extractSignalContent, + dataDir: "/tmp/project", + projectTag: "project-tag", + ...overrides, + }; + + return { deps, getLastCaptured, pruneOldTrackers, addMemory, messages }; +} + +describe("handleSessionEnd", () => { + it("saves a deleted session once", async () => { + const { deps, addMemory, messages } = makeDeps(); + + await handleSessionEnd(makeDeletedEvent("ses_deleted_once"), deps); + + expect(messages).toHaveBeenCalledWith({ sessionId: "ses_deleted_once" }); + expect(addMemory).toHaveBeenCalledTimes(1); + expect(addMemory.mock.calls[0]?.[0]).toContain("[user] Remember this project decision."); + expect(addMemory.mock.calls[0]?.[1]).toBe("project-tag"); + expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation" }); + }); + + it("deduplicates idle and deleted events for the same session", async () => { + const { deps, addMemory } = makeDeps(); + + await handleSessionEnd(makeIdleEvent("ses_dedup_pair"), deps); + await handleSessionEnd(makeDeletedEvent("ses_dedup_pair"), deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + }); + + it("skips empty sessions", async () => { + const messages = mock(async () => ({ data: [] })); + const { deps, addMemory } = makeDeps({ sdkClient: { session: { messages } } }); + + await handleSessionEnd(makeIdleEvent("ses_empty"), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("skips when signal extraction finds no keyword match", async () => { + const { deps, addMemory } = makeDeps({ + config: makeConfig({ signalExtraction: true, signalKeywords: ["durable-signal"] }), + }); + + await handleSessionEnd(makeIdleEvent("ses_no_signal"), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("saves only messages after the tracker watermark", async () => { + const getLastCaptured = mock(async () => "msg_1"); + const messages = mock(async () => ({ + data: [makeMessage("msg_1", "user", "Already captured."), makeMessage("msg_2", "assistant", "New uncaptured turn.")], + })); + const { deps, addMemory } = makeDeps({ + tracker: { getLastCaptured, pruneOldTrackers: mock(async () => 0) }, + sdkClient: { session: { messages } }, + }); + + await handleSessionEnd(makeIdleEvent("ses_tracker_delta"), deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + expect(addMemory.mock.calls[0]?.[0]).toBe("[assistant] New uncaptured turn."); + }); + + it("prunes old trackers after deleted sessions", async () => { + const { deps, pruneOldTrackers } = makeDeps(); + + await handleSessionEnd(makeDeletedEvent("ses_prune_deleted"), deps); + + expect(pruneOldTrackers).toHaveBeenCalledWith("/tmp/project/capture-trackers"); + }); +}); From c2353eafd1ab1cc7c36cb731f71a4f48e82100dc Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 02:19:50 +0800 Subject: [PATCH 19/37] feat(recall): query memories on every message --- src/chat/handler.ts | 17 +- src/memory/client.ts | 23 ++- src/recall/every-message.ts | 90 +++++++++++ tests/unit/chat-handler.test.ts | 2 +- tests/unit/every-message-recall.test.ts | 202 ++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 src/recall/every-message.ts create mode 100644 tests/unit/every-message-recall.test.ts diff --git a/src/chat/handler.ts b/src/chat/handler.ts index b27c7cc..b83d143 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -20,6 +20,7 @@ import { detectMemoryKeyword } from "@/chat/keywords"; import { MEMORY_NUDGE_MESSAGE } from "@/chat/nudge"; import type { SupermemoryConfig } from "@/config/schema"; import { formatContextForPrompt } from "@/memory/context"; +import { detectRecallKeyword, runEveryMessageRecall } from "@/recall/every-message"; import type { SessionState } from "@/session/state"; import { generatePartId } from "@/shared/ids"; @@ -53,15 +54,19 @@ type ListResult = export interface ChatClientLike { getProfile: (containerTag: string, query?: string) => Promise; - searchMemories: (query: string, containerTag: string) => Promise; + searchMemories: (query: string, containerTag: string | string[]) => Promise; listMemories: (containerTag: string, limit?: number) => Promise; } export interface ChatHandlerDeps { client: ChatClientLike; - config: Pick; + config: Pick< + SupermemoryConfig, + "keywordPatterns" | "maxProjectMemories" | "injectProfile" | "maxProfileItems" | "recallKeywordPatterns" | "everyMessageRecall" + > & Partial>; tags: { user: string; project: string }; injectedSessions: Pick; + pendingReinjectSessions?: Pick, "delete" | "has">; log: (message: string, data?: unknown) => void; isConfigured: () => boolean; } @@ -119,6 +124,9 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan } const isFirstMessage = !deps.injectedSessions.wasInjected(input.sessionID); + const hasRecallKeyword = detectRecallKeyword(userMessage, deps.config); + const hasPendingReinject = deps.pendingReinjectSessions?.has(input.sessionID) === true; + const shouldRecallAfterFirstMessage = deps.config.everyMessageRecall === true || hasRecallKeyword || hasPendingReinject; if (isFirstMessage) { deps.injectedSessions.markInjected(input.sessionID); @@ -165,6 +173,11 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan contextLength: memoryContext.length, }); } + } else if (shouldRecallAfterFirstMessage) { + await runEveryMessageRecall(input, output, deps); + if (hasPendingReinject) { + deps.pendingReinjectSessions?.delete(input.sessionID); + } } } catch (error) { deps.log("chat.message: ERROR", { error: String(error) }); diff --git a/src/memory/client.ts b/src/memory/client.ts index 972c99c..d40a2dc 100644 --- a/src/memory/client.ts +++ b/src/memory/client.ts @@ -112,7 +112,26 @@ export class SupermemoryClient { return this.client; } - async searchMemories(query: string, containerTag: string): Promise> { + async searchMemories(query: string, containerTag: string | string[]): Promise> { + if (Array.isArray(containerTag)) { + return withResult("searchMemories", async () => { + const uniqueTags = [...new Set(containerTag)].filter((tag) => tag.length > 0); + const results = await Promise.all(uniqueTags.map((tag) => this.searchMemories(query, tag))); + const firstFailure = results.find((result) => !result.ok); + if (firstFailure && !firstFailure.ok) { + throw new Error(firstFailure.error.message); + } + + const successful = results.filter((result): result is { ok: true; value: SearchMemoriesResult } => result.ok); + return { + success: true as const, + results: successful.flatMap((result) => result.value.results ?? []), + total: successful.reduce((total, result) => total + (result.value.results?.length ?? 0), 0), + timing: 0, + }; + }); + } + const config = getConfig(); logger.info("searchMemories: start", { containerTag }); return withResult("searchMemories", async () => { @@ -305,7 +324,7 @@ function unwrapOrLegacyShape(result: Result = { success: false; error: string } & T; export const supermemoryClient = { - async searchMemories(query: string, containerTag: string) { + async searchMemories(query: string, containerTag: string | string[]) { const fallback: LegacyFailure<{ results: never[]; total: number; timing: number }> = { success: false, error: "Failed to search memories", diff --git a/src/recall/every-message.ts b/src/recall/every-message.ts new file mode 100644 index 0000000..1fd45e1 --- /dev/null +++ b/src/recall/every-message.ts @@ -0,0 +1,90 @@ +import type { Part } from "@opencode-ai/sdk"; + +import type { SupermemoryConfig } from "@/config/schema"; +import { formatContextForPrompt } from "@/memory/context"; +import { stripInboundMetadata } from "@/memory/metadata-strip"; +import { generatePartId } from "@/shared/ids"; +import { withTimeout } from "@/shared/timeout"; + +const RECALL_THROTTLE_MS = 2_000; +const RECALL_TIMEOUT_MS = 2_000; + +const lastRecallAt = new Map(); + +type SearchResult = + | { success: true; results?: Array<{ similarity?: number; memory?: string; chunk?: string; createdAt?: string | Date }> } + | { success: false; error?: string }; + +export interface EveryMessageRecallInput { + sessionID: string; +} + +export interface EveryMessageRecallOutput { + message: { id: string }; + parts: Part[]; +} + +export interface EveryMessageRecallDeps { + client: { + searchMemories: (query: string, containerTags: string | string[]) => Promise; + }; + config: Pick & + Partial>; + tags: { user: string; project: string }; + log: (message: string, data?: unknown) => void; +} + +export function detectRecallKeyword(userMessage: string, config: Partial>): boolean { + const normalized = userMessage.toLowerCase(); + return (config.recallKeywordPatterns ?? []).some((pattern) => pattern.trim().length > 0 && normalized.includes(pattern.toLowerCase())); +} + +function getUserMessage(parts: Part[]): string { + return parts + .filter((part): part is Part & { type: "text"; text: string } => part.type === "text" && part.synthetic !== true) + .map((part) => part.text) + .join("\n"); +} + +export async function runEveryMessageRecall( + input: EveryMessageRecallInput, + output: EveryMessageRecallOutput, + deps: EveryMessageRecallDeps, +): Promise { + const now = Date.now(); + const previous = lastRecallAt.get(input.sessionID); + if (previous !== undefined && now - previous < RECALL_THROTTLE_MS) { + deps.log("chat.message: recall throttled", { sessionID: input.sessionID }); + return; + } + lastRecallAt.set(input.sessionID, now); + + const strippedQuery = stripInboundMetadata(getUserMessage(output.parts)); + if (!strippedQuery) { + deps.log("chat.message: recall query empty after metadata stripping"); + return; + } + + try { + const result = await withTimeout( + deps.client.searchMemories(strippedQuery, [deps.tags.user, deps.tags.project]), + RECALL_TIMEOUT_MS, + "every-message recall", + ); + const userMemories = result.success ? result : { results: [] }; + const memoryContext = formatContextForPrompt(null, userMemories, { results: [] }, deps.config); + + if (!memoryContext) return; + + output.parts.unshift({ + id: generatePartId(), + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: memoryContext, + synthetic: true, + }); + } catch (error) { + deps.log("chat.message: recall ERROR", { error: String(error) }); + } +} diff --git a/tests/unit/chat-handler.test.ts b/tests/unit/chat-handler.test.ts index 9fbea19..ce7859c 100644 --- a/tests/unit/chat-handler.test.ts +++ b/tests/unit/chat-handler.test.ts @@ -14,7 +14,7 @@ import { createSessionState } from "@/session/state"; interface CallLog { getProfile: Array<[string, string | undefined]>; - searchMemories: Array<[string, string]>; + searchMemories: Array<[string, string | string[]]>; listMemories: Array<[string, number | undefined]>; } diff --git a/tests/unit/every-message-recall.test.ts b/tests/unit/every-message-recall.test.ts new file mode 100644 index 0000000..cf69d40 --- /dev/null +++ b/tests/unit/every-message-recall.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; + +import type { Part } from "@opencode-ai/sdk"; + +import type { ChatClientLike, ChatHandlerDeps } from "@/chat/handler"; +import { handleChatMessage } from "@/chat/handler"; +import { runEveryMessageRecall } from "@/recall/every-message"; +import { createSessionState } from "@/session/state"; + +interface CallLog { + getProfile: Array<[string, string | undefined]>; + searchMemories: Array<[string, string | string[]]>; + listMemories: Array<[string, number | undefined]>; +} + +interface FakeClient extends ChatClientLike { + calls: CallLog; +} + +function createFakeClient(overrides: Partial = {}): FakeClient { + const calls: CallLog = { getProfile: [], searchMemories: [], listMemories: [] }; + return { + calls, + async getProfile(tag, query) { + calls.getProfile.push([tag, query]); + if (overrides.getProfile) return overrides.getProfile(tag, query); + return { success: true, profile: { static: [], dynamic: [] } }; + }, + async searchMemories(query, tag) { + calls.searchMemories.push([query, tag]); + if (overrides.searchMemories) return overrides.searchMemories(query, tag); + return { success: true, results: [{ memory: "Uses Bun for project tasks", similarity: 0.91 }] }; + }, + async listMemories(tag, limit) { + calls.listMemories.push([tag, limit]); + if (overrides.listMemories) return overrides.listMemories(tag, limit); + return { success: true, memories: [{ id: "mem_project", summary: "Build with bun run typecheck" }] }; + }, + }; +} + +function createDeps(overrides: Partial = {}): ChatHandlerDeps & { + logs: Array<{ msg: string; data?: unknown }>; + client: FakeClient; +} { + const logs: Array<{ msg: string; data?: unknown }> = []; + const client = (overrides.client as FakeClient) ?? createFakeClient(); + return { + client, + config: { + keywordPatterns: [], + recallKeywordPatterns: ["remind me", "what did we"], + maxProjectMemories: 10, + injectProfile: true, + maxProfileItems: 5, + memoUsageFooter: true, + everyMessageRecall: false, + ...((overrides.config as object) ?? {}), + } as ChatHandlerDeps["config"], + tags: overrides.tags ?? { user: "u-tag", project: "p-tag" }, + injectedSessions: overrides.injectedSessions ?? createSessionState(), + pendingReinjectSessions: overrides.pendingReinjectSessions ?? new Set(), + log: (msg, data) => logs.push({ msg, data }), + isConfigured: overrides.isConfigured ?? (() => true), + logs, + }; +} + +function textPart(text: string, id = "prt_user_1"): Part { + return { + id, + sessionID: "ses_1", + messageID: "msg_1", + type: "text", + text, + synthetic: false, + } as Part; +} + +function emptyOutput(parts: Part[] = []) { + return { message: { id: "msg_1" }, parts }; +} + +function contextParts(output: { parts: Part[] }) { + return output.parts.filter((part) => part.type === "text" && "text" in part && part.text.includes("[SUPERMEMORY]")); +} + +afterEach(() => { + setSystemTime(); +}); + +describe("every-message recall", () => { + it("keeps first-message context injection working", async () => { + const deps = createDeps(); + const output = emptyOutput([textPart("hi")]); + + await handleChatMessage({ sessionID: "first-message" }, output, deps); + + expect(deps.injectedSessions.wasInjected("first-message")).toBe(true); + expect(contextParts(output)).toHaveLength(1); + expect(deps.client.calls.searchMemories).toEqual([["hi", "u-tag"]]); + }); + + it("runs recall on a later message when everyMessageRecall is true", async () => { + const deps = createDeps({ config: { everyMessageRecall: true } as ChatHandlerDeps["config"] }); + deps.injectedSessions.markInjected("third-message"); + const output = emptyOutput([textPart("third message asks for context")]); + + await handleChatMessage({ sessionID: "third-message" }, output, deps); + + expect(contextParts(output)).toHaveLength(1); + expect(deps.client.calls.searchMemories).toEqual([["third message asks for context", ["u-tag", "p-tag"]]]); + }); + + it("keeps everyMessageRecall false as first-message-only baseline", async () => { + const deps = createDeps(); + const first = emptyOutput([textPart("first")]); + const second = emptyOutput([textPart("second")]); + + await handleChatMessage({ sessionID: "baseline" }, first, deps); + await handleChatMessage({ sessionID: "baseline" }, second, deps); + + expect(contextParts(first)).toHaveLength(1); + expect(contextParts(second)).toHaveLength(0); + expect(deps.client.calls.searchMemories).toEqual([["first", "u-tag"]]); + }); + + it("runs recall when a recall keyword is present", async () => { + const deps = createDeps(); + deps.injectedSessions.markInjected("keyword-session"); + const output = emptyOutput([textPart("Can you remind me what library we chose?")]); + + await handleChatMessage({ sessionID: "keyword-session" }, output, deps); + + expect(contextParts(output)).toHaveLength(1); + expect(deps.client.calls.searchMemories).toEqual([["Can you remind me what library we chose?", ["u-tag", "p-tag"]]]); + }); + + it("skips recall within the two-second session throttle", async () => { + const deps = createDeps(); + setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const first = emptyOutput([textPart("first recall", "prt_user_1")]); + const second = emptyOutput([textPart("second recall", "prt_user_2")]); + + await runEveryMessageRecall({ sessionID: "throttle-session" }, first, deps); + setSystemTime(new Date("2026-01-01T00:00:01.000Z")); + await runEveryMessageRecall({ sessionID: "throttle-session" }, second, deps); + + expect(contextParts(first)).toHaveLength(1); + expect(contextParts(second)).toHaveLength(0); + expect(deps.client.calls.searchMemories).toHaveLength(1); + }); + + it("strips inbound metadata before searching", async () => { + const deps = createDeps(); + const output = emptyOutput([textPart("[2026-01-01T00:00:00Z] hiddenactual query")]); + + await runEveryMessageRecall({ sessionID: "strip-session" }, output, deps); + + expect(deps.client.calls.searchMemories[0]).toEqual(["actual query", ["u-tag", "p-tag"]]); + }); + + it("does not inject context twice in one chat message", async () => { + const deps = createDeps({ config: { everyMessageRecall: true } as ChatHandlerDeps["config"] }); + const output = emptyOutput([textPart("first turn with every-message recall enabled")]); + + await handleChatMessage({ sessionID: "single-inject" }, output, deps); + + expect(contextParts(output)).toHaveLength(1); + }); + + it("marks the injected recall part as synthetic", async () => { + const deps = createDeps(); + const output = emptyOutput([textPart("synthetic check")]); + + await runEveryMessageRecall({ sessionID: "synthetic-session" }, output, deps); + + const [context] = contextParts(output); + expect(context && "synthetic" in context && context.synthetic).toBe(true); + }); + + it("includes the memo usage footer from the context formatter", async () => { + const deps = createDeps({ config: { memoUsageFooter: true } as ChatHandlerDeps["config"] }); + const output = emptyOutput([textPart("footer check")]); + + await runEveryMessageRecall({ sessionID: "footer-session" }, output, deps); + + const [context] = contextParts(output); + expect(context && "text" in context && context.text).toContain("[Supermemory: 1 memories loaded]"); + }); + + it("uses generated part identifiers for recall context", async () => { + const deps = createDeps(); + const output = emptyOutput([textPart("id check")]); + + await runEveryMessageRecall({ sessionID: "id-session" }, output, deps); + + const [context] = contextParts(output); + expect(context?.id).toMatch(/^prt_[a-f0-9]+[a-z0-9]{8}$/); + expect(context?.id).not.toBe("prt_user_1"); + }); +}); From 450a305edecd1846549c9817cfc34ec0fa7778cb Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 02:28:43 +0800 Subject: [PATCH 20/37] feat(recall): re-inject context on a configurable cadence --- src/chat/handler.ts | 34 ++++- src/recall/periodic.ts | 4 + tests/unit/every-message-recall.test.ts | 1 + tests/unit/periodic-reinject.test.ts | 173 ++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 src/recall/periodic.ts create mode 100644 tests/unit/periodic-reinject.test.ts diff --git a/src/chat/handler.ts b/src/chat/handler.ts index b83d143..52ade2b 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -21,6 +21,7 @@ import { MEMORY_NUDGE_MESSAGE } from "@/chat/nudge"; import type { SupermemoryConfig } from "@/config/schema"; import { formatContextForPrompt } from "@/memory/context"; import { detectRecallKeyword, runEveryMessageRecall } from "@/recall/every-message"; +import { shouldPeriodicReinject } from "@/recall/periodic"; import type { SessionState } from "@/session/state"; import { generatePartId } from "@/shared/ids"; @@ -62,7 +63,13 @@ export interface ChatHandlerDeps { client: ChatClientLike; config: Pick< SupermemoryConfig, - "keywordPatterns" | "maxProjectMemories" | "injectProfile" | "maxProfileItems" | "recallKeywordPatterns" | "everyMessageRecall" + | "keywordPatterns" + | "maxProjectMemories" + | "injectProfile" + | "maxProfileItems" + | "recallKeywordPatterns" + | "everyMessageRecall" + | "reinjectEveryN" > & Partial>; tags: { user: string; project: string }; injectedSessions: Pick; @@ -71,6 +78,8 @@ export interface ChatHandlerDeps { isConfigured: () => boolean; } +const msgCounter = new Map(); + /** * Runs once per assistant message. Responsibilities: * 1. Bail out fast when the plugin is unconfigured or the message has no @@ -85,11 +94,13 @@ export interface ChatHandlerDeps { * OpenCode will surface the failure to the user. */ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHandlerOutput, deps: ChatHandlerDeps): Promise { - if (!deps.isConfigured()) return; + const completedMessages = msgCounter.get(input.sessionID) ?? 0; const start = Date.now(); try { + if (!deps.isConfigured()) return; + const textParts = output.parts.filter((p): p is Part & { type: "text"; text: string } => p.type === "text"); if (textParts.length === 0) { @@ -127,6 +138,7 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan const hasRecallKeyword = detectRecallKeyword(userMessage, deps.config); const hasPendingReinject = deps.pendingReinjectSessions?.has(input.sessionID) === true; const shouldRecallAfterFirstMessage = deps.config.everyMessageRecall === true || hasRecallKeyword || hasPendingReinject; + let injectedThisTurn = false; if (isFirstMessage) { deps.injectedSessions.markInjected(input.sessionID); @@ -172,14 +184,24 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan duration, contextLength: memoryContext.length, }); + injectedThisTurn = true; } - } else if (shouldRecallAfterFirstMessage) { - await runEveryMessageRecall(input, output, deps); - if (hasPendingReinject) { - deps.pendingReinjectSessions?.delete(input.sessionID); + } else { + if (shouldRecallAfterFirstMessage) { + await runEveryMessageRecall(input, output, deps); + injectedThisTurn = true; + if (hasPendingReinject) { + deps.pendingReinjectSessions?.delete(input.sessionID); + } + } + + if (!injectedThisTurn && shouldPeriodicReinject(input.sessionID, deps.config.reinjectEveryN, msgCounter)) { + await runEveryMessageRecall(input, output, deps); } } } catch (error) { deps.log("chat.message: ERROR", { error: String(error) }); + } finally { + msgCounter.set(input.sessionID, completedMessages + 1); } } diff --git a/src/recall/periodic.ts b/src/recall/periodic.ts new file mode 100644 index 0000000..c33aabf --- /dev/null +++ b/src/recall/periodic.ts @@ -0,0 +1,4 @@ +export function shouldPeriodicReinject(sessionID: string, reinjectEveryN: number, counter: Map): boolean { + const completedMessages = counter.get(sessionID); + return reinjectEveryN > 0 && completedMessages !== undefined && completedMessages > 0 && completedMessages % reinjectEveryN === 0; +} diff --git a/tests/unit/every-message-recall.test.ts b/tests/unit/every-message-recall.test.ts index cf69d40..4336bba 100644 --- a/tests/unit/every-message-recall.test.ts +++ b/tests/unit/every-message-recall.test.ts @@ -55,6 +55,7 @@ function createDeps(overrides: Partial = {}): ChatHandlerDeps & maxProfileItems: 5, memoUsageFooter: true, everyMessageRecall: false, + reinjectEveryN: 0, ...((overrides.config as object) ?? {}), } as ChatHandlerDeps["config"], tags: overrides.tags ?? { user: "u-tag", project: "p-tag" }, diff --git a/tests/unit/periodic-reinject.test.ts b/tests/unit/periodic-reinject.test.ts new file mode 100644 index 0000000..3af87a1 --- /dev/null +++ b/tests/unit/periodic-reinject.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, setSystemTime, afterEach } from "bun:test"; + +import type { Part } from "@opencode-ai/sdk"; + +import type { ChatClientLike, ChatHandlerDeps } from "@/chat/handler"; +import { handleChatMessage } from "@/chat/handler"; +import { shouldPeriodicReinject } from "@/recall/periodic"; +import { createSessionState } from "@/session/state"; + +interface CallLog { + getProfile: Array<[string, string | undefined]>; + searchMemories: Array<[string, string | string[]]>; + listMemories: Array<[string, number | undefined]>; +} + +interface FakeClient extends ChatClientLike { + calls: CallLog; +} + +function createFakeClient(): FakeClient { + const calls: CallLog = { getProfile: [], searchMemories: [], listMemories: [] }; + return { + calls, + async getProfile(tag, query) { + calls.getProfile.push([tag, query]); + return { success: true, profile: { static: [], dynamic: [] } }; + }, + async searchMemories(query, tag) { + calls.searchMemories.push([query, tag]); + return { success: true, results: [{ memory: `Memory for ${query}`, similarity: 0.91 }] }; + }, + async listMemories(tag, limit) { + calls.listMemories.push([tag, limit]); + return { success: true, memories: [{ id: "mem_project", summary: "Project memory" }] }; + }, + }; +} + +function createDeps(config: Partial = {}): ChatHandlerDeps & { + client: FakeClient; + logs: Array<{ msg: string; data?: unknown }>; +} { + const logs: Array<{ msg: string; data?: unknown }> = []; + const client = createFakeClient(); + return { + client, + config: { + keywordPatterns: [], + recallKeywordPatterns: ["remind me"], + maxProjectMemories: 10, + injectProfile: true, + maxProfileItems: 5, + memoUsageFooter: true, + everyMessageRecall: false, + reinjectEveryN: 0, + ...config, + } as ChatHandlerDeps["config"], + tags: { user: "u-tag", project: "p-tag" }, + injectedSessions: createSessionState(), + pendingReinjectSessions: new Set(), + log: (msg, data) => logs.push({ msg, data }), + isConfigured: () => true, + logs, + }; +} + +function textPart(text: string, sessionID: string, messageID: string): Part { + return { + id: `prt_${messageID}`, + sessionID, + messageID, + type: "text", + text, + synthetic: false, + } as Part; +} + +function outputFor(text: string, sessionID: string, turn: number) { + const messageID = `msg_${sessionID}_${turn}`; + return { message: { id: messageID }, parts: [textPart(text, sessionID, messageID)] }; +} + +function contextParts(output: { parts: Part[] }) { + return output.parts.filter((part) => part.type === "text" && "text" in part && part.text.includes("[SUPERMEMORY]")); +} + +afterEach(() => { + setSystemTime(); +}); + +describe("periodic recall re-injection", () => { + it("keeps reinjectEveryN=0 disabled", () => { + const counter = new Map([["ses_1", 3]]); + + expect(shouldPeriodicReinject("ses_1", 0, counter)).toBe(false); + }); + + it("fires at turn 4 when reinjectEveryN is 3", async () => { + const deps = createDeps({ reinjectEveryN: 3 }); + const outputs = []; + + for (let turn = 1; turn <= 4; turn += 1) { + setSystemTime(new Date(Date.UTC(2026, 0, 1, 0, 0, turn * 3))); + const output = outputFor(`turn ${turn}`, "cadence-session", turn); + outputs.push(output); + await handleChatMessage({ sessionID: "cadence-session" }, output, deps); + } + + expect(contextParts(outputs[0]!)).toHaveLength(1); + expect(contextParts(outputs[1]!)).toHaveLength(0); + expect(contextParts(outputs[2]!)).toHaveLength(0); + expect(contextParts(outputs[3]!)).toHaveLength(1); + expect(deps.client.calls.searchMemories).toEqual([ + ["turn 1", "u-tag"], + ["turn 4", ["u-tag", "p-tag"]], + ]); + }); + + it("tracks periodic counters per session", async () => { + const deps = createDeps({ reinjectEveryN: 3 }); + + for (let turn = 1; turn <= 4; turn += 1) { + setSystemTime(new Date(Date.UTC(2026, 0, 1, 0, 1, turn * 3))); + await handleChatMessage({ sessionID: "ready-session" }, outputFor(`ready ${turn}`, "ready-session", turn), deps); + } + + const otherOutput = outputFor("other 1", "other-session", 1); + await handleChatMessage({ sessionID: "other-session" }, otherOutput, deps); + + expect(deps.client.calls.searchMemories).toEqual([ + ["ready 1", "u-tag"], + ["ready 4", ["u-tag", "p-tag"]], + ["other 1", "u-tag"], + ]); + expect(contextParts(otherOutput)).toHaveLength(1); + }); + + it("avoids double injection when every-message recall already ran", async () => { + const deps = createDeps({ everyMessageRecall: true, reinjectEveryN: 3 }); + + for (let turn = 1; turn <= 4; turn += 1) { + setSystemTime(new Date(Date.UTC(2026, 0, 1, 0, 2, turn * 3))); + const output = outputFor(`double ${turn}`, "double-session", turn); + await handleChatMessage({ sessionID: "double-session" }, output, deps); + + expect(contextParts(output)).toHaveLength(1); + } + + expect(deps.client.calls.searchMemories).toEqual([ + ["double 1", "u-tag"], + ["double 2", ["u-tag", "p-tag"]], + ["double 3", ["u-tag", "p-tag"]], + ["double 4", ["u-tag", "p-tag"]], + ]); + }); + + it("keeps independent sessions on separate cadences", async () => { + const deps = createDeps({ reinjectEveryN: 3 }); + + for (let turn = 1; turn <= 4; turn += 1) { + setSystemTime(new Date(Date.UTC(2026, 0, 1, 0, 3, turn * 4))); + await handleChatMessage({ sessionID: "alpha" }, outputFor(`alpha ${turn}`, "alpha", turn), deps); + await handleChatMessage({ sessionID: "beta" }, outputFor(`beta ${turn}`, "beta", turn), deps); + } + + expect(deps.client.calls.searchMemories).toEqual([ + ["alpha 1", "u-tag"], + ["beta 1", "u-tag"], + ["alpha 4", ["u-tag", "p-tag"]], + ["beta 4", ["u-tag", "p-tag"]], + ]); + }); +}); From b11da760109ad64eb86924e4b1b40a5c8ed0e294 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 02:36:01 +0800 Subject: [PATCH 21/37] feat(compaction): full save before summarization --- src/compaction/pre-save.ts | 69 +++++++++++++++ tests/unit/pre-compaction-save.test.ts | 113 +++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/compaction/pre-save.ts create mode 100644 tests/unit/pre-compaction-save.test.ts diff --git a/src/compaction/pre-save.ts b/src/compaction/pre-save.ts new file mode 100644 index 0000000..6e2bc94 --- /dev/null +++ b/src/compaction/pre-save.ts @@ -0,0 +1,69 @@ +import { getLogger } from "@logtape/logtape"; + +import type { SupermemoryConfig } from "@/config/schema"; +import type { SupermemoryClient } from "@/memory/client"; +import { getProjectTag } from "@/memory/tags"; +import type { Message } from "@/signal/extract"; + +const logger = getLogger(["supermemory", "compaction", "pre-save"]); + +const PRESERVED_CONTEXT_MESSAGE = "Memories preserved in Supermemory."; +const MAX_TURNS = 20; +const MAX_CONTENT_CHARS = 50_000; + +export interface PreSaveDeps { + config: SupermemoryConfig; + client: SupermemoryClient; + sdkSession: { + messages(args: { sessionId: string }): Promise<{ messages: Message[] }>; + }; +} + +export async function handlePreCompactionSave( + input: { sessionID: string; output: { context: string[] } }, + deps: PreSaveDeps, +): Promise { + try { + if (deps.config.postCompactionReinject !== true && deps.config.sessionEndSave !== true) return; + + try { + const response = await deps.sdkSession.messages({ sessionId: input.sessionID }); + const content = buildSessionContent(response.messages); + + if (content.length > 0) { + const projectTag = getProjectTag(process.cwd(), deps.config); + const result = await deps.client.addMemory(content, projectTag, { type: "conversation" }); + if (!result.ok) { + logger.warn("[compaction] pre-save failed", { sessionID: input.sessionID, error: result.error.message }); + } + } + } catch (error) { + logger.warn("[compaction] pre-save failed", { sessionID: input.sessionID, error: String(error) }); + } finally { + input.output.context.push(PRESERVED_CONTEXT_MESSAGE); + } + } catch (error) { + logger.warn("[compaction] pre-save hook failed", { sessionID: input.sessionID, error: String(error) }); + } +} + +function buildSessionContent(messages: Message[]): string { + return messages + .flatMap(formatMessageTurn) + .slice(-MAX_TURNS) + .join("\n") + .slice(0, MAX_CONTENT_CHARS) + .trim(); +} + +function formatMessageTurn(message: Message): string[] { + if (message.role !== "user" && message.role !== "assistant") return []; + + const text = message.parts + .filter((part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored) + .map((part) => part.text) + .join("\n") + .trim(); + + return text.length > 0 ? [`[${message.role}] ${text}`] : []; +} diff --git a/tests/unit/pre-compaction-save.test.ts b/tests/unit/pre-compaction-save.test.ts new file mode 100644 index 0000000..796213b --- /dev/null +++ b/tests/unit/pre-compaction-save.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; + +import { DEFAULTS } from "@/config/defaults"; +import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; +import { handlePreCompactionSave, type PreSaveDeps } from "@/compaction/pre-save"; +import type { Message } from "@/signal/extract"; + +function makeConfig(overrides: Partial = {}): SupermemoryConfig { + return SupermemoryConfigSchema.parse({ + ...DEFAULTS, + projectContainerTag: "project-tag", + ...overrides, + }); +} + +function makeMessage(id: string, role: "user" | "assistant", text: string, parts: Message["parts"] = [{ type: "text", text }]): Message { + return { id, role, sessionID: "ses_pre_save", parts }; +} + +function makeDeps(messagesList: Message[], overrides: Partial = {}) { + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + ok: true as const, + value: { success: true as const, id: "mem_1" }, + })); + const messages = mock(async (_args: { sessionId: string }) => ({ messages: messagesList })); + const deps: PreSaveDeps = { + config: makeConfig(), + client: { addMemory } as unknown as PreSaveDeps["client"], + sdkSession: { messages }, + ...overrides, + }; + + return { deps, addMemory, messages }; +} + +describe("handlePreCompactionSave", () => { + it("saves the last user and assistant turns and mutates context", async () => { + const input = { sessionID: "ses_pre_save", output: { context: [] as string[] } }; + const { deps, addMemory, messages } = makeDeps([ + makeMessage("msg_1", "user", "Remember this decision."), + makeMessage("msg_2", "assistant", "Saved."), + ]); + + await handlePreCompactionSave(input, deps); + + expect(messages).toHaveBeenCalledWith({ sessionId: "ses_pre_save" }); + expect(addMemory).toHaveBeenCalledTimes(1); + expect(addMemory.mock.calls[0]?.[0]).toBe("[user] Remember this decision.\n[assistant] Saved."); + expect(addMemory.mock.calls[0]?.[1]).toBe("project-tag"); + expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation" }); + expect(input.output.context).toEqual(["Memories preserved in Supermemory."]); + }); + + it("filters synthetic ignored and compaction parts before saving", async () => { + const input = { sessionID: "ses_filter", output: { context: [] as string[] } }; + const { deps, addMemory } = makeDeps([ + makeMessage("msg_1", "user", "", [ + { type: "text", text: "kept user text" }, + { type: "text", text: "synthetic text", synthetic: true }, + { type: "text", text: "ignored text", ignored: true }, + { type: "compaction", text: "summary text" }, + ]), + makeMessage("msg_2", "assistant", "kept assistant text"), + ]); + + await handlePreCompactionSave(input, deps); + + expect(addMemory.mock.calls[0]?.[0]).toBe("[user] kept user text\n[assistant] kept assistant text"); + }); + + it("treats deduped addMemory results as preserved", async () => { + const input = { sessionID: "ses_dedup", output: { context: [] as string[] } }; + const addMemory = mock(async () => ({ ok: true as const, value: { success: true as const, deduped: true as const } })); + const { deps } = makeDeps([makeMessage("msg_1", "user", "Already stored.")], { + client: { addMemory } as unknown as PreSaveDeps["client"], + }); + + await handlePreCompactionSave(input, deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + expect(input.output.context).toContain("Memories preserved in Supermemory."); + }); + + it("does not block compaction when the API fails", async () => { + const input = { sessionID: "ses_failure", output: { context: [] as string[] } }; + const addMemory = mock(async () => { + throw new Error("api down"); + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const { deps } = makeDeps([makeMessage("msg_1", "user", "Keep this despite failure.")], { + client: { addMemory } as unknown as PreSaveDeps["client"], + }); + + const result = await handlePreCompactionSave(input, deps); + + expect(result).toBeUndefined(); + expect(input.output.context).toContain("Memories preserved in Supermemory."); + warnSpy.mockRestore(); + }); + + it("mutates only output.context", async () => { + const input = { + sessionID: "ses_context_only", + output: { context: [] as string[], prompt: "original prompt" } as { context: string[]; prompt: string }, + }; + const { deps } = makeDeps([makeMessage("msg_1", "assistant", "Preserve this turn.")]); + + await handlePreCompactionSave(input, deps); + + expect(input.output.context).toEqual(["Memories preserved in Supermemory."]); + expect(input.output.prompt).toBe("original prompt"); + }); +}); From 2c54e6fbbb46a7910797ad1ff25a94186b3fc34d Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 02:44:30 +0800 Subject: [PATCH 22/37] feat(compaction): rehydrate context after summarization --- src/compaction/post-reinject.ts | 20 +++ src/events/handler.ts | 7 + src/index.ts | 4 +- tests/unit/post-compaction-reinject.test.ts | 145 ++++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/compaction/post-reinject.ts create mode 100644 tests/unit/post-compaction-reinject.test.ts diff --git a/src/compaction/post-reinject.ts b/src/compaction/post-reinject.ts new file mode 100644 index 0000000..1a7a389 --- /dev/null +++ b/src/compaction/post-reinject.ts @@ -0,0 +1,20 @@ +import type { SupermemoryConfig } from "@/config/schema"; + +export interface EventSessionCompacted { + event: { + properties?: { + sessionID?: string; + }; + }; +} + +export const pendingReinjectSessions = new Set(); + +export function handleSessionCompacted(input: EventSessionCompacted, config: SupermemoryConfig): void { + if (config.postCompactionReinject !== true) return; + + const sessionID = input.event.properties?.sessionID; + if (!sessionID) return; + + pendingReinjectSessions.add(sessionID); +} diff --git a/src/events/handler.ts b/src/events/handler.ts index 4624cea..f6d0e6a 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -8,7 +8,9 @@ import { handleMessageUpdatedForCapture, type IncrementalCaptureDeps } from "@/capture/incremental"; import { handleSessionEnd, type SessionEndDeps } from "@/capture/session-end"; import * as tracker from "@/capture/tracker"; +import { handleSessionCompacted } from "@/compaction/post-reinject"; import { getConfig } from "@/config/loader"; +import type { SupermemoryConfig } from "@/config/schema"; import { resultSupermemoryClient } from "@/memory/client"; import { extractSignalContent } from "@/signal/extract"; @@ -18,6 +20,7 @@ export interface EventInput { export interface EventDeps { compactionHook: { event(input: EventInput): Promise } | null; + config?: SupermemoryConfig; incrementalCapture?: IncrementalCaptureDeps; sessionEnd?: SessionEndDeps; } @@ -33,6 +36,10 @@ export async function handleEvent(input: EventInput, deps: EventDeps): Promise[0], deps.sessionEnd); } + if (input.event.type === "session.compacted") { + handleSessionCompacted(input as Parameters[0], deps.config ?? getConfig()); + } + if (deps.compactionHook) { await deps.compactionHook.event(input); } diff --git a/src/index.ts b/src/index.ts index 65171b8..4922137 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import { handleChatMessage } from "@/chat/handler"; import { type CompactionContext, createCompactionHook } from "@/compaction/index"; import { createModelLimitLookup } from "@/compaction/model-limits"; +import { pendingReinjectSessions } from "@/compaction/post-reinject"; import { getConfig } from "@/config/loader"; import { extractSignalContent } from "@/signal/extract"; import * as tracker from "@/capture/tracker"; @@ -28,6 +29,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { event: (input: { event: { type: string; properties?: unknown } }) => handleEvent(input, { compactionHook, + config: deps.config, sessionEnd: ctx.client ? { config: deps.config, @@ -60,5 +62,5 @@ function createDeps(ctx: PluginInput) { }; log("Plugin init", { directory: ctx.directory, tags, configured: isConfigured() }); if (!isConfigured()) log("Plugin disabled - SUPERMEMORY_API_KEY not set"); - return { client: supermemoryClient, config, tags, injectedSessions: sessionState, log, isConfigured }; + return { client: supermemoryClient, config, tags, injectedSessions: sessionState, pendingReinjectSessions, log, isConfigured }; } diff --git a/tests/unit/post-compaction-reinject.test.ts b/tests/unit/post-compaction-reinject.test.ts new file mode 100644 index 0000000..3fe8349 --- /dev/null +++ b/tests/unit/post-compaction-reinject.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, setSystemTime } from "bun:test"; + +import type { Part } from "@opencode-ai/sdk"; + +import type { ChatClientLike, ChatHandlerDeps } from "@/chat/handler"; +import { handleChatMessage } from "@/chat/handler"; +import type { SupermemoryConfig } from "@/config/schema"; +import { handleSessionCompacted, pendingReinjectSessions } from "@/compaction/post-reinject"; +import { handleEvent } from "@/events/handler"; +import { createSessionState } from "@/session/state"; + +const enabledConfig = { postCompactionReinject: true } as SupermemoryConfig; +const disabledConfig = { postCompactionReinject: false } as SupermemoryConfig; + +interface CallLog { + getProfile: Array<[string, string | undefined]>; + searchMemories: Array<[string, string | string[]]>; + listMemories: Array<[string, number | undefined]>; +} + +interface FakeClient extends ChatClientLike { + calls: CallLog; +} + +function createFakeClient(): FakeClient { + const calls: CallLog = { getProfile: [], searchMemories: [], listMemories: [] }; + return { + calls, + async getProfile(tag, query) { + calls.getProfile.push([tag, query]); + return { success: true, profile: { static: [], dynamic: [] } }; + }, + async searchMemories(query, tag) { + calls.searchMemories.push([query, tag]); + return { success: true, results: [{ memory: `Memory for ${query}`, similarity: 0.91 }] }; + }, + async listMemories(tag, limit) { + calls.listMemories.push([tag, limit]); + return { success: true, memories: [{ id: "mem_project", summary: "Project memory" }] }; + }, + }; +} + +function createDeps(): ChatHandlerDeps & { client: FakeClient; logs: Array<{ msg: string; data?: unknown }> } { + const logs: Array<{ msg: string; data?: unknown }> = []; + const injectedSessions = createSessionState(); + const client = createFakeClient(); + return { + client, + config: { + keywordPatterns: [], + recallKeywordPatterns: ["remind me"], + maxProjectMemories: 10, + injectProfile: true, + maxProfileItems: 5, + memoUsageFooter: true, + everyMessageRecall: false, + reinjectEveryN: 0, + } as ChatHandlerDeps["config"], + tags: { user: "u-tag", project: "p-tag" }, + injectedSessions, + pendingReinjectSessions, + log: (msg, data) => logs.push({ msg, data }), + isConfigured: () => true, + logs, + }; +} + +function outputFor(text: string, sessionID: string, turn: number) { + const messageID = `msg_${sessionID}_${turn}`; + const part: Part = { + id: `prt_${messageID}`, + sessionID, + messageID, + type: "text", + text, + synthetic: false, + } as Part; + return { message: { id: messageID }, parts: [part] }; +} + +function contextParts(output: { parts: Part[] }) { + return output.parts.filter((part) => part.type === "text" && "text" in part && part.text.includes("[SUPERMEMORY]")); +} + +beforeEach(() => { + pendingReinjectSessions.clear(); +}); + +afterEach(() => { + setSystemTime(); +}); + +describe("post-compaction recall re-injection", () => { + it("queues compacted sessions when post-compaction re-injection is enabled", async () => { + await handleEvent({ event: { type: "session.compacted", properties: { sessionID: "ses_compacted" } } }, { compactionHook: null, config: enabledConfig }); + + expect(pendingReinjectSessions.has("ses_compacted")).toBe(true); + }); + + it("runs recall on the next chat message for a pending session", async () => { + const deps = createDeps(); + deps.injectedSessions.markInjected("ses_pending"); + pendingReinjectSessions.add("ses_pending"); + const output = outputFor("restore my context", "ses_pending", 1); + + await handleChatMessage({ sessionID: "ses_pending" }, output, deps); + + expect(contextParts(output)).toHaveLength(1); + expect(deps.client.calls.searchMemories).toEqual([["restore my context", ["u-tag", "p-tag"]]]); + }); + + it("does not inject twice after the pending session is consumed", async () => { + const deps = createDeps(); + deps.injectedSessions.markInjected("ses_once"); + pendingReinjectSessions.add("ses_once"); + const first = outputFor("restore once", "ses_once", 1); + const second = outputFor("plain follow-up", "ses_once", 2); + + await handleChatMessage({ sessionID: "ses_once" }, first, deps); + setSystemTime(new Date(Date.UTC(2026, 0, 1, 0, 0, 3))); + await handleChatMessage({ sessionID: "ses_once" }, second, deps); + + expect(contextParts(first)).toHaveLength(1); + expect(contextParts(second)).toHaveLength(0); + expect(deps.client.calls.searchMemories).toHaveLength(1); + }); + + it("skips queueing when post-compaction re-injection is disabled", () => { + handleSessionCompacted({ event: { properties: { sessionID: "ses_disabled" } } }, disabledConfig); + + expect(pendingReinjectSessions.has("ses_disabled")).toBe(false); + }); + + it("clears the pending session after recall is consumed", async () => { + const deps = createDeps(); + deps.injectedSessions.markInjected("ses_clear"); + pendingReinjectSessions.add("ses_clear"); + const output = outputFor("clear pending", "ses_clear", 1); + + await handleChatMessage({ sessionID: "ses_clear" }, output, deps); + + expect(pendingReinjectSessions.has("ses_clear")).toBe(false); + }); +}); From 4ada1e30ab0e107f7e379e9805e68c3ddace4887 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 03:01:15 +0800 Subject: [PATCH 23/37] feat(plugin): wire memory feature hooks into entry point --- src/index.ts | 133 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4922137..7b080f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,26 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import * as tracker from "@/capture/tracker"; import { handleChatMessage } from "@/chat/handler"; import { type CompactionContext, createCompactionHook } from "@/compaction/index"; import { createModelLimitLookup } from "@/compaction/model-limits"; import { pendingReinjectSessions } from "@/compaction/post-reinject"; +import { handlePreCompactionSave } from "@/compaction/pre-save"; import { getConfig } from "@/config/loader"; -import { extractSignalContent } from "@/signal/extract"; -import * as tracker from "@/capture/tracker"; import { handleEvent } from "@/events/handler"; -import { supermemoryClient } from "@/memory/client"; +import { SupermemoryClient, supermemoryClient } from "@/memory/client"; +import { createDedupCache } from "@/memory/dedup"; import { getTags } from "@/memory/tags"; import { createSessionState } from "@/session/state"; import { initLogger, rootLogger } from "@/shared/logger"; +import { extractSignalContent, type Message, type MessagePart } from "@/signal/extract"; import { createSupermemoryTool } from "@/tool/index"; +const DEDUP_DATA_DIR = join(homedir(), ".local", "share", "opencode-supermemory-p"); + export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const deps = createDeps(ctx); const compactionHook = @@ -27,25 +34,52 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { "chat.message": (input, output) => handleChatMessage(input, output, deps), tool: { supermemory: createSupermemoryTool({ tags: deps.tags, client: supermemoryClient }) }, event: (input: { event: { type: string; properties?: unknown } }) => - handleEvent(input, { - compactionHook, - config: deps.config, - sessionEnd: ctx.client - ? { + deps.isConfigured() + ? handleEvent(input, { + compactionHook, + config: deps.config, + incrementalCapture: { config: deps.config, - client: deps.client, - sdkClient: { - session: { - messages: ({ sessionId }) => ctx.client.session.messages({ path: { id: sessionId }, query: { directory: ctx.directory } }), - }, - }, + client: deps.resultClient, tracker, signalExtract: extractSignalContent, dataDir: ctx.directory, - projectTag: deps.tags.project, - } - : undefined, - }), + }, + sessionEnd: ctx.client + ? { + config: deps.config, + client: deps.resultClient, + sdkClient: { + session: { + messages: ({ sessionId }) => + ctx.client.session.messages({ path: { id: sessionId }, query: { directory: ctx.directory } }), + }, + }, + tracker, + signalExtract: extractSignalContent, + dataDir: ctx.directory, + projectTag: deps.tags.project, + } + : undefined, + }) + : Promise.resolve(), + "experimental.session.compacting": (input, output) => + deps.isConfigured() && ctx.client + ? handlePreCompactionSave( + { sessionID: input.sessionID, output }, + { + config: deps.config, + client: deps.resultClient, + sdkSession: { + messages: async ({ sessionId }) => ({ + messages: normalizeSdkMessages( + await ctx.client.session.messages({ path: { id: sessionId }, query: { directory: ctx.directory } }), + ), + }), + }, + }, + ) + : Promise.resolve(), }; }; @@ -56,11 +90,72 @@ function createDeps(ctx: PluginInput) { const config = getConfig(); const tags = getTags(ctx.directory); const sessionState = createSessionState(); + const dedupCache = createDedupCache({ + dedupEnabled: config.dedupEnabled, + dedupCacheSize: config.dedupCacheSize, + dataDir: DEDUP_DATA_DIR, + }); + const resultClient = new SupermemoryClient({ dedupCache }); + const client = createLegacyClient(resultClient); const isConfigured = () => !!config.apiKey; const log = (message: string, data?: unknown) => { rootLogger.info(message, data as Record | undefined); }; + process.on("beforeExit", () => { + void dedupCache.flush().catch((error) => log("dedup cache flush failed", { error: String(error) })); + }); log("Plugin init", { directory: ctx.directory, tags, configured: isConfigured() }); if (!isConfigured()) log("Plugin disabled - SUPERMEMORY_API_KEY not set"); - return { client: supermemoryClient, config, tags, injectedSessions: sessionState, pendingReinjectSessions, log, isConfigured }; + return { client, resultClient, config, tags, injectedSessions: sessionState, pendingReinjectSessions, log, isConfigured }; +} + +function normalizeSdkMessages(response: { + data?: Array<{ info?: { id?: string; role?: string; sessionID?: string }; parts?: MessagePart[] }>; +}): Message[] { + return (response.data ?? []).flatMap((record): Message[] => { + const info = record.info; + if (!info?.id || (info.role !== "user" && info.role !== "assistant")) return []; + return [{ id: info.id, role: info.role, sessionID: info.sessionID, parts: record.parts ?? [] }]; + }); +} + +function createLegacyClient(client: SupermemoryClient) { + return { + async searchMemories(query: string, containerTag: string | string[]) { + const result = await client.searchMemories(query, containerTag); + return result.ok ? result.value : { success: false as const, error: result.error.message, results: [], total: 0, timing: 0 }; + }, + async getProfile(containerTag: string, query?: string) { + const result = await client.getProfile(containerTag, query); + return result.ok ? result.value : { success: false as const, error: result.error.message, profile: null }; + }, + async addMemory(content: string, containerTag: string, metadata?: Parameters[2]) { + const result = await client.addMemory(content, containerTag, metadata); + return result.ok ? result.value : { success: false as const, error: result.error.message }; + }, + async deleteMemory(memoryId: string) { + const result = await client.deleteMemory(memoryId); + return result.ok ? result.value : { success: false as const, error: result.error.message }; + }, + async listMemories(containerTag: string, limit = 20) { + const result = await client.listMemories(containerTag, limit); + return result.ok + ? result.value + : { + success: false as const, + error: result.error.message, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }; + }, + async ingestConversation( + conversationId: string, + messages: Parameters[1], + containerTags: string[], + metadata?: Record, + ) { + const result = await client.ingestConversation(conversationId, messages, containerTags, metadata); + return result.ok ? result.value : { success: false as const, error: result.error.message }; + }, + }; } From 7abd3805447776a47efd5f33edf98a7c730c3e64 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 03:02:53 +0800 Subject: [PATCH 24/37] chore(config): regenerate JSON schema --- assets/config.schema.json | 113 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/assets/config.schema.json b/assets/config.schema.json index 38a050e..99f2e06 100644 --- a/assets/config.schema.json +++ b/assets/config.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/TheOrdinaryWow/opencode-supermemory-p/refs/heads/main/assets/config.schema.json", "title": "opencode-supermemory-p config", - "description": "opencode-supermemory-p's configuration schema.", + "description": "Configuration for opencode-supermemory-p.", "type": "object", "required": [], "additionalProperties": false, "properties": { "$schema": { "type": "string", - "description": "opencode-supermemory-p's JSON Schema reference." + "description": "JSON Schema reference for editor validation." }, "apiKey": { "type": "string", @@ -75,6 +75,115 @@ "type": "number", "exclusiveMinimum": 0, "maximum": 1 + }, + "incrementalCapture": { + "default": true, + "type": "boolean" + }, + "maxCaptureChars": { + "default": 5000, + "type": "number", + "exclusiveMinimum": 0 + }, + "postCompactionReinject": { + "default": true, + "type": "boolean" + }, + "sessionEndSave": { + "default": true, + "type": "boolean" + }, + "signalExtraction": { + "default": true, + "type": "boolean" + }, + "signalKeywords": { + "default": [ + "remember", + "important", + "save this", + "note this", + "don't forget", + "key decision", + "project info", + "my name", + "I work", + "I prefer", + "I use", + "I like", + "always", + "never", + "my team", + "my email", + "my company" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "signalTurnsBefore": { + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "recallKeywordPatterns": { + "default": [ + "what did we", + "remind me", + "earlier you said", + "what was the", + "do you remember" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "dedupEnabled": { + "default": true, + "type": "boolean" + }, + "dedupCacheSize": { + "default": 500, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "entityContext": { + "default": "User-assistant conversation. Format: [role: user]...[user:end] and [role: assistant]...[assistant:end].\n\nOnly extract things useful in FUTURE conversations. Most messages are not worth remembering.\n\nREMEMBER: lasting personal facts — dietary restrictions, preferences, personal details, workplace, location, tools, ongoing projects, routines, explicit \"remember this\" requests.\n\nDO NOT REMEMBER: temporary intents, one-time tasks, assistant actions (searching, writing files, generating code), assistant suggestions, implementation details, in-progress task status.\n\nRULES:\n- Assistant output is CONTEXT ONLY — never attribute assistant actions to the user\n- \"find X\" or \"do Y\" = one-time request, NOT a memory\n- Only store preferences explicitly stated (\"I like...\", \"I prefer...\", \"I always...\")\n- When in doubt, do NOT create a memory. Less is more.", + "type": "string" + }, + "metadataStripping": { + "default": true, + "type": "boolean" + }, + "relativeTimeDisplay": { + "default": true, + "type": "boolean" + }, + "memoUsageFooter": { + "default": true, + "type": "boolean" + }, + "profileCrossArrayDedup": { + "default": true, + "type": "boolean" + }, + "everyMessageRecall": { + "default": false, + "type": "boolean" + }, + "reinjectEveryN": { + "default": 0, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "autoCategoryTagging": { + "default": false, + "type": "boolean" } } } From dbc6ba92ead9a719abf31b6b95d1754023df1fb7 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 04:15:17 +0800 Subject: [PATCH 25/37] test(contract): cover new plugin hooks --- tests/contract/plugin-shape.test.ts | 70 ++++++++++++++++++++--- tests/fixtures/baseline/plugin-shape.json | 6 +- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/tests/contract/plugin-shape.test.ts b/tests/contract/plugin-shape.test.ts index d73086d..e5c6180 100644 --- a/tests/contract/plugin-shape.test.ts +++ b/tests/contract/plugin-shape.test.ts @@ -7,9 +7,23 @@ import { cleanupTmpDir, createTmpDir } from "../helpers/tmpdir"; const ROOT = process.cwd(); const BASELINE_DIR = join(ROOT, "tests", "fixtures", "baseline"); +// Stub OpenCode plugin context — the minimum surface the factory touches +// during init (directory + a no-op provider list call). +function makeCtx() { + return { + directory: "/tmp/test", + client: { + provider: { + list: async () => ({ data: { all: [] } }), + }, + }, + } as never; +} + describe("plugin shape baseline", () => { let tmpHome: string; let previousLog: string | undefined; + let previousApiKey: string | undefined; beforeAll(() => { // Bun's os.homedir() ignores process.env.HOME, so route the plugin log @@ -17,25 +31,24 @@ describe("plugin shape baseline", () => { tmpHome = createTmpDir("plugin-shape-home"); previousLog = process.env.OPENCODE_SUPERMEMORY_LOG; process.env.OPENCODE_SUPERMEMORY_LOG = join(tmpHome, "log", "main.log"); + // These tests pin the "no apiKey configured" shape. Ensure any + // ambient env var from the developer's shell does not leak in. + previousApiKey = process.env.SUPERMEMORY_API_KEY; + delete process.env.SUPERMEMORY_API_KEY; }); afterAll(() => { if (previousLog === undefined) delete process.env.OPENCODE_SUPERMEMORY_LOG; else process.env.OPENCODE_SUPERMEMORY_LOG = previousLog; + if (previousApiKey === undefined) delete process.env.SUPERMEMORY_API_KEY; + else process.env.SUPERMEMORY_API_KEY = previousApiKey; cleanupTmpDir(tmpHome); }); it("loads dist/index.js and the captured baseline", async () => { const baseline = JSON.parse(readFileSync(join(BASELINE_DIR, "plugin-shape.json"), "utf-8")); const mod = await import(join(ROOT, "dist", "index.js")); - const plugin = await mod.SupermemoryPlugin({ - directory: "/tmp/test", - client: { - provider: { - list: async () => ({ data: { all: [] } }), - }, - }, - } as never); + const plugin = await mod.SupermemoryPlugin(makeCtx()); const defaultKeys = Object.keys(plugin); const hookTypes = Object.fromEntries(defaultKeys.map((key) => [key, typeof plugin[key as keyof typeof plugin]])); @@ -44,4 +57,45 @@ describe("plugin shape baseline", () => { expect(defaultKeys).toEqual(baseline.defaultKeys); expect(hookTypes).toEqual(baseline.hookTypes); }); + + it("exposes experimental.session.compacting as a function", async () => { + const mod = await import(join(ROOT, "dist", "index.js")); + const plugin = (await mod.SupermemoryPlugin(makeCtx())) as Record; + + expect(Object.keys(plugin)).toContain("experimental.session.compacting"); + expect(typeof plugin["experimental.session.compacting"]).toBe("function"); + }); + + it("returns every hook key even when SUPERMEMORY_API_KEY is unset", async () => { + // beforeAll deletes SUPERMEMORY_API_KEY — verify the disabled plugin + // still exposes the full hook surface so OpenCode can call into it + // without checking shape variants. + expect(process.env.SUPERMEMORY_API_KEY).toBeUndefined(); + + const mod = await import(join(ROOT, "dist", "index.js")); + const plugin = (await mod.SupermemoryPlugin(makeCtx())) as Record; + const keys = Object.keys(plugin); + + expect(keys).toContain("chat.message"); + expect(keys).toContain("tool"); + expect(keys).toContain("event"); + expect(keys).toContain("experimental.session.compacting"); + }); + + it("hooks are safe no-ops when apiKey is missing (event + experimental.session.compacting do not throw)", async () => { + expect(process.env.SUPERMEMORY_API_KEY).toBeUndefined(); + + const mod = await import(join(ROOT, "dist", "index.js")); + const plugin = (await mod.SupermemoryPlugin(makeCtx())) as Record; + + const eventHook = plugin.event as (input: unknown) => Promise; + const compactingHook = plugin["experimental.session.compacting"] as (input: unknown, output: unknown) => Promise; + + // Both gated hooks must resolve to undefined (Promise.resolve()) without + // ever calling the real Supermemory client. + const eventResult = await eventHook({ event: { type: "session.idle", properties: {} } }); + const compactingResult = await compactingHook({ sessionID: "ses_shape_test" }, []); + expect(eventResult).toBeUndefined(); + expect(compactingResult).toBeUndefined(); + }); }); diff --git a/tests/fixtures/baseline/plugin-shape.json b/tests/fixtures/baseline/plugin-shape.json index 468a18d..58efd53 100644 --- a/tests/fixtures/baseline/plugin-shape.json +++ b/tests/fixtures/baseline/plugin-shape.json @@ -2,12 +2,14 @@ "defaultKeys": [ "chat.message", "tool", - "event" + "event", + "experimental.session.compacting" ], "defaultType": "object", "hookTypes": { "chat.message": "function", "tool": "object", - "event": "function" + "event": "function", + "experimental.session.compacting": "function" } } From 517c5c656736517b759b39dbe394507aa218172b Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 04:19:50 +0800 Subject: [PATCH 26/37] style: apply biome formatting fixes --- src/capture/incremental.ts | 14 ++++++++------ src/capture/session-end.ts | 7 +++++-- src/chat/handler.ts | 3 ++- src/compaction/pre-save.ts | 7 +------ src/events/handler.ts | 5 ++++- src/memory/client.ts | 10 ++++------ src/memory/context.ts | 15 ++++++++------- src/memory/dedup.ts | 3 +-- src/signal/extract.ts | 12 +++++------- tests/unit/client.test.ts | 2 +- tests/unit/collection.test.ts | 2 +- tests/unit/dedup.test.ts | 5 ++--- tests/unit/entity-context.test.ts | 6 +----- tests/unit/incremental-capture.test.ts | 9 ++++++--- tests/unit/metadata-strip.test.ts | 5 ++--- tests/unit/periodic-reinject.test.ts | 2 +- tests/unit/post-compaction-reinject.test.ts | 7 +++++-- tests/unit/pre-compaction-save.test.ts | 4 ++-- tests/unit/session-end.test.ts | 4 ++-- tests/unit/signal-extract.test.ts | 10 ++-------- tests/unit/tracker.test.ts | 1 + 21 files changed, 64 insertions(+), 69 deletions(-) diff --git a/src/capture/incremental.ts b/src/capture/incremental.ts index 0162c89..8aec30a 100644 --- a/src/capture/incremental.ts +++ b/src/capture/incremental.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { SupermemoryConfig } from "@/config/schema"; import type { SupermemoryClient } from "@/memory/client"; import { getProjectTag } from "@/memory/tags"; -import { type Message, type MessagePart, extractSignalContent } from "@/signal/extract"; +import type { extractSignalContent, Message, MessagePart } from "@/signal/extract"; export interface EventMessageUpdated { event: { @@ -28,10 +28,7 @@ export interface IncrementalCaptureDeps { dataDir: string; } -export async function handleMessageUpdatedForCapture( - input: EventMessageUpdated, - deps: IncrementalCaptureDeps, -): Promise { +export async function handleMessageUpdatedForCapture(input: EventMessageUpdated, deps: IncrementalCaptureDeps): Promise { try { if (deps.config.incrementalCapture === false) return; @@ -59,7 +56,12 @@ export async function handleMessageUpdatedForCapture( const lastCaptured = await deps.tracker.getLastCaptured(sessionID, trackersDir); if (lastCaptured === messageID) return; - const rawContent = extractedContent ?? textParts.map((part) => part.text).join("\n").trim(); + const rawContent = + extractedContent ?? + textParts + .map((part) => part.text) + .join("\n") + .trim(); if (rawContent.length === 0) return; const content = rawContent.slice(0, deps.config.maxCaptureChars); diff --git a/src/capture/session-end.ts b/src/capture/session-end.ts index 6db0225..bce7c3c 100644 --- a/src/capture/session-end.ts +++ b/src/capture/session-end.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import type { SupermemoryConfig } from "@/config/schema"; -import { type Message, type MessagePart, type Turn, extractSignalContent, groupIntoTurns } from "@/signal/extract"; +import { type extractSignalContent, groupIntoTurns, type Message, type MessagePart, type Turn } from "@/signal/extract"; export interface EventSessionDeleted { event: { @@ -119,5 +119,8 @@ function sliceAfterLastCaptured(messages: Message[], lastCaptured: string | null } function formatTurns(turns: Turn[]): string { - return turns.map((turn) => `[${turn.role}] ${turn.text}`).join("\n").trim(); + return turns + .map((turn) => `[${turn.role}] ${turn.text}`) + .join("\n") + .trim(); } diff --git a/src/chat/handler.ts b/src/chat/handler.ts index 52ade2b..86a4ba2 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -70,7 +70,8 @@ export interface ChatHandlerDeps { | "recallKeywordPatterns" | "everyMessageRecall" | "reinjectEveryN" - > & Partial>; + > & + Partial>; tags: { user: string; project: string }; injectedSessions: Pick; pendingReinjectSessions?: Pick, "delete" | "has">; diff --git a/src/compaction/pre-save.ts b/src/compaction/pre-save.ts index 6e2bc94..c56115b 100644 --- a/src/compaction/pre-save.ts +++ b/src/compaction/pre-save.ts @@ -48,12 +48,7 @@ export async function handlePreCompactionSave( } function buildSessionContent(messages: Message[]): string { - return messages - .flatMap(formatMessageTurn) - .slice(-MAX_TURNS) - .join("\n") - .slice(0, MAX_CONTENT_CHARS) - .trim(); + return messages.flatMap(formatMessageTurn).slice(-MAX_TURNS).join("\n").slice(0, MAX_CONTENT_CHARS).trim(); } function formatMessageTurn(message: Message): string[] { diff --git a/src/events/handler.ts b/src/events/handler.ts index f6d0e6a..fb15295 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -29,7 +29,10 @@ export async function handleEvent(input: EventInput, deps: EventDeps): Promise | undefined; const info = props?.info as { role?: string; finish?: unknown } | undefined; if (input.event.type === "message.updated" && info?.role === "assistant" && info.finish) { - void handleMessageUpdatedForCapture(input as Parameters[0], deps.incrementalCapture ?? createDefaultIncrementalCaptureDeps()); + void handleMessageUpdatedForCapture( + input as Parameters[0], + deps.incrementalCapture ?? createDefaultIncrementalCaptureDeps(), + ); } if ((input.event.type === "session.deleted" || input.event.type === "session.idle") && deps.sessionEnd) { diff --git a/src/memory/client.ts b/src/memory/client.ts index d40a2dc..bf79b11 100644 --- a/src/memory/client.ts +++ b/src/memory/client.ts @@ -167,11 +167,7 @@ export class SupermemoryClient { }); } - async addMemory( - content: string, - containerTag: string, - metadata?: AddMemoryMetadata, - ): Promise> { + async addMemory(content: string, containerTag: string, metadata?: AddMemoryMetadata): Promise> { logger.info("addMemory: start", { containerTag, contentLength: content.length }); return withResult("addMemory", async () => { const config = getConfig(); @@ -281,7 +277,9 @@ export class SupermemoryClient { if (result.ok && typeof result.value.id === "string") { savedIds.push(result.value.id); } else if (!firstError) { - firstError = result.ok ? ({ kind: "NetworkError", message: "Memory was deduped before ingest storage" } satisfies AppError) : result.error; + firstError = result.ok + ? ({ kind: "NetworkError", message: "Memory was deduped before ingest storage" } satisfies AppError) + : result.error; } } diff --git a/src/memory/context.ts b/src/memory/context.ts index 150f231..ba0eb97 100644 --- a/src/memory/context.ts +++ b/src/memory/context.ts @@ -107,13 +107,14 @@ export function formatContextForPrompt( return ""; } - const footer = config.memoUsageFooter === true - ? formatMemoFooter({ - profile: staticFactTexts.length + dynamicFactTexts.length, - projectMemories: projectResults.length, - relevantMemories: userResults.length, - }) - : ""; + const footer = + config.memoUsageFooter === true + ? formatMemoFooter({ + profile: staticFactTexts.length + dynamicFactTexts.length, + projectMemories: projectResults.length, + relevantMemories: userResults.length, + }) + : ""; if (footer) { parts.push(`\n${footer}`); diff --git a/src/memory/dedup.ts b/src/memory/dedup.ts index b3b953f..511c240 100644 --- a/src/memory/dedup.ts +++ b/src/memory/dedup.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { promises as fs } from "node:fs"; +import { existsSync, promises as fs, readFileSync } from "node:fs"; import { join } from "node:path"; export interface DedupCacheConfig { diff --git a/src/signal/extract.ts b/src/signal/extract.ts index 8fe8cd6..6d013b6 100644 --- a/src/signal/extract.ts +++ b/src/signal/extract.ts @@ -89,16 +89,14 @@ export function extractSignalContent(messages: Message[], config: SignalExtracti } const contextualTurns = getContextualTurns(turns, signalIndices, config.signalTurnsBefore); - const content = contextualTurns.map((turn) => `[${turn.role}] ${turn.text}`).join("\n").trim(); + const content = contextualTurns + .map((turn) => `[${turn.role}] ${turn.text}`) + .join("\n") + .trim(); return content.length > 0 ? content : null; } function isDetectableUserTurn(turn: Turn): boolean { - return ( - turn.role === "user" && - turn.text.length > 0 && - turn.text.length <= MAX_SIGNAL_TURN_CHARS && - !COMMAND_PATTERN.test(turn.text) - ); + return turn.role === "user" && turn.text.length > 0 && turn.text.length <= MAX_SIGNAL_TURN_CHARS && !COMMAND_PATTERN.test(turn.text); } diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 979fc6d..ff44bf1 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; -import type { AppError } from "@/shared/errors"; import type { DedupCache } from "@/memory/dedup"; +import type { AppError } from "@/shared/errors"; // ===================================================================== // Background — what we are pinning diff --git a/tests/unit/collection.test.ts b/tests/unit/collection.test.ts index 5cf6023..0720edb 100644 --- a/tests/unit/collection.test.ts +++ b/tests/unit/collection.test.ts @@ -8,7 +8,7 @@ describe("dedupe", () => { }); it("deduplicates values using a case-insensitive normalized key", () => { - expect(dedupe(["Alpha", "alpha", "BETA", "beta"])) .toEqual(["Alpha", "BETA"]); + expect(dedupe(["Alpha", "alpha", "BETA", "beta"])).toEqual(["Alpha", "BETA"]); }); it("skips empty and whitespace-only keys", () => { diff --git a/tests/unit/dedup.test.ts b/tests/unit/dedup.test.ts index 90ae9d9..935d769 100644 --- a/tests/unit/dedup.test.ts +++ b/tests/unit/dedup.test.ts @@ -1,8 +1,7 @@ +import { afterEach, describe, expect, it } from "bun:test"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { tmpdir } from "node:os"; - -import { afterEach, describe, expect, it } from "bun:test"; +import { join } from "node:path"; import { createDedupCache, withDedup } from "@/memory/dedup"; diff --git a/tests/unit/entity-context.test.ts b/tests/unit/entity-context.test.ts index 12abf90..c5f0d75 100644 --- a/tests/unit/entity-context.test.ts +++ b/tests/unit/entity-context.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { - DEFAULT_ENTITY_CONTEXT, - MAX_ENTITY_CONTEXT_LENGTH, - clampEntityContext, -} from "../../src/memory/entity-context"; +import { clampEntityContext, DEFAULT_ENTITY_CONTEXT, MAX_ENTITY_CONTEXT_LENGTH } from "../../src/memory/entity-context"; describe("entity-context", () => { it("keeps DEFAULT_ENTITY_CONTEXT within the configured limit", () => { diff --git a/tests/unit/incremental-capture.test.ts b/tests/unit/incremental-capture.test.ts index 4cae703..998cdaa 100644 --- a/tests/unit/incremental-capture.test.ts +++ b/tests/unit/incremental-capture.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, mock } from "bun:test"; -import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; +import { type EventMessageUpdated, handleMessageUpdatedForCapture, type IncrementalCaptureDeps } from "@/capture/incremental"; import { DEFAULTS } from "@/config/defaults"; -import { handleMessageUpdatedForCapture, type EventMessageUpdated, type IncrementalCaptureDeps } from "@/capture/incremental"; +import { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; function makeConfig(overrides: Partial = {}): SupermemoryConfig { return SupermemoryConfigSchema.parse({ @@ -89,7 +89,10 @@ describe("handleMessageUpdatedForCapture", () => { it("skips when signal extraction finds no content", async () => { const signalExtract = mock(() => null); - const { deps, addMemory } = makeDeps({ config: makeConfig({ projectContainerTag: "project-tag", signalExtraction: true }), signalExtract }); + const { deps, addMemory } = makeDeps({ + config: makeConfig({ projectContainerTag: "project-tag", signalExtraction: true }), + signalExtract, + }); await handleMessageUpdatedForCapture(makeEvent(), deps); diff --git a/tests/unit/metadata-strip.test.ts b/tests/unit/metadata-strip.test.ts index 2570e83..658948b 100644 --- a/tests/unit/metadata-strip.test.ts +++ b/tests/unit/metadata-strip.test.ts @@ -19,7 +19,7 @@ describe("stripInboundMetadata", () => { }); it("strips supermemory containers tag", () => { - const input = "{\"id\":\"1\"}\nhello"; + const input = '{"id":"1"}\nhello'; expect(stripInboundMetadata(input)).toBe("hello"); }); @@ -37,8 +37,7 @@ describe("stripInboundMetadata", () => { }); it("keeps only real content from mixed metadata", () => { - const input = - "2024-01-01T00:00:00Z\nX\nY\nhello"; + const input = "2024-01-01T00:00:00Z\nX\nY\nhello"; expect(stripInboundMetadata(input)).toBe("hello"); }); diff --git a/tests/unit/periodic-reinject.test.ts b/tests/unit/periodic-reinject.test.ts index 3af87a1..d92bddc 100644 --- a/tests/unit/periodic-reinject.test.ts +++ b/tests/unit/periodic-reinject.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, setSystemTime, afterEach } from "bun:test"; +import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; import type { Part } from "@opencode-ai/sdk"; diff --git a/tests/unit/post-compaction-reinject.test.ts b/tests/unit/post-compaction-reinject.test.ts index 3fe8349..922aa86 100644 --- a/tests/unit/post-compaction-reinject.test.ts +++ b/tests/unit/post-compaction-reinject.test.ts @@ -4,8 +4,8 @@ import type { Part } from "@opencode-ai/sdk"; import type { ChatClientLike, ChatHandlerDeps } from "@/chat/handler"; import { handleChatMessage } from "@/chat/handler"; -import type { SupermemoryConfig } from "@/config/schema"; import { handleSessionCompacted, pendingReinjectSessions } from "@/compaction/post-reinject"; +import type { SupermemoryConfig } from "@/config/schema"; import { handleEvent } from "@/events/handler"; import { createSessionState } from "@/session/state"; @@ -93,7 +93,10 @@ afterEach(() => { describe("post-compaction recall re-injection", () => { it("queues compacted sessions when post-compaction re-injection is enabled", async () => { - await handleEvent({ event: { type: "session.compacted", properties: { sessionID: "ses_compacted" } } }, { compactionHook: null, config: enabledConfig }); + await handleEvent( + { event: { type: "session.compacted", properties: { sessionID: "ses_compacted" } } }, + { compactionHook: null, config: enabledConfig }, + ); expect(pendingReinjectSessions.has("ses_compacted")).toBe(true); }); diff --git a/tests/unit/pre-compaction-save.test.ts b/tests/unit/pre-compaction-save.test.ts index 796213b..28742a5 100644 --- a/tests/unit/pre-compaction-save.test.ts +++ b/tests/unit/pre-compaction-save.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; -import { DEFAULTS } from "@/config/defaults"; -import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; import { handlePreCompactionSave, type PreSaveDeps } from "@/compaction/pre-save"; +import { DEFAULTS } from "@/config/defaults"; +import { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; import type { Message } from "@/signal/extract"; function makeConfig(overrides: Partial = {}): SupermemoryConfig { diff --git a/tests/unit/session-end.test.ts b/tests/unit/session-end.test.ts index d1cc41c..11178bc 100644 --- a/tests/unit/session-end.test.ts +++ b/tests/unit/session-end.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, mock } from "bun:test"; +import { type EventSessionDeleted, type EventSessionIdle, handleSessionEnd, type SessionEndDeps } from "@/capture/session-end"; import { DEFAULTS } from "@/config/defaults"; -import { SupermemoryConfigSchema, type SupermemoryConfig } from "@/config/schema"; -import { handleSessionEnd, type EventSessionDeleted, type EventSessionIdle, type SessionEndDeps } from "@/capture/session-end"; +import { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; import { extractSignalContent } from "@/signal/extract"; function makeConfig(overrides: Partial = {}): SupermemoryConfig { diff --git a/tests/unit/signal-extract.test.ts b/tests/unit/signal-extract.test.ts index c0dfeea..dcf8c52 100644 --- a/tests/unit/signal-extract.test.ts +++ b/tests/unit/signal-extract.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { - extractSignalContent, - findSignalTurns, - getContextualTurns, - groupIntoTurns, - type Message, - type Turn, -} from "@/signal/extract"; + +import { extractSignalContent, findSignalTurns, getContextualTurns, groupIntoTurns, type Message, type Turn } from "@/signal/extract"; function textMessage(id: string, role: Message["role"], text: string): Message { return { diff --git a/tests/unit/tracker.test.ts b/tests/unit/tracker.test.ts index 6b3b2c2..b1eb81e 100644 --- a/tests/unit/tracker.test.ts +++ b/tests/unit/tracker.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, readFile, stat, utimes } from "node:fs/promises"; import { join } from "node:path"; import { appendCaptured, getLastCaptured, pruneOldTrackers } from "@/capture/tracker"; + import { cleanupTmpDir, createTmpDir } from "../helpers/tmpdir"; async function expectMode(path: string, expectedMode: number): Promise { From ea8647a33f2b5ccb7cb8d9b50bcae8493a59ed55 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 04:23:13 +0800 Subject: [PATCH 27/37] fix: eliminate biome warnings in signal and test files --- src/signal/extract.ts | 2 +- tests/unit/periodic-reinject.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/signal/extract.ts b/src/signal/extract.ts index 6d013b6..9e5685e 100644 --- a/src/signal/extract.ts +++ b/src/signal/extract.ts @@ -54,7 +54,7 @@ export function findSignalTurns(turns: Turn[], keywords: string[]): number[] { const normalizedText = turn.text.toLowerCase(); if (normalizedKeywords.some((keyword) => normalizedText.includes(keyword))) { - return [...indices, index]; + indices.push(index); } return indices; diff --git a/tests/unit/periodic-reinject.test.ts b/tests/unit/periodic-reinject.test.ts index d92bddc..15dc9d1 100644 --- a/tests/unit/periodic-reinject.test.ts +++ b/tests/unit/periodic-reinject.test.ts @@ -106,10 +106,10 @@ describe("periodic recall re-injection", () => { await handleChatMessage({ sessionID: "cadence-session" }, output, deps); } - expect(contextParts(outputs[0]!)).toHaveLength(1); - expect(contextParts(outputs[1]!)).toHaveLength(0); - expect(contextParts(outputs[2]!)).toHaveLength(0); - expect(contextParts(outputs[3]!)).toHaveLength(1); + expect(contextParts(outputs[0] ?? { parts: [] })).toHaveLength(1); + expect(contextParts(outputs[1] ?? { parts: [] })).toHaveLength(0); + expect(contextParts(outputs[2] ?? { parts: [] })).toHaveLength(0); + expect(contextParts(outputs[3] ?? { parts: [] })).toHaveLength(1); expect(deps.client.calls.searchMemories).toEqual([ ["turn 1", "u-tag"], ["turn 4", ["u-tag", "p-tag"]], From 5bea18c77a1778d3c22c31151ce50e1957b98fa2 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 04:26:15 +0800 Subject: [PATCH 28/37] docs: document memory feature suite --- README.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/README.md b/README.md index 9dba094..5a50933 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,20 @@ Your agent remembers what you tell it — across sessions, across projects. +## Table of Contents + +- [Installation](#installation) +- [Features](#features) +- [Tool Usage](#tool-usage) +- [Memory Scoping](#memory-scoping) +- [Configuration](#configuration) +- [Container Tag Selection](#container-tag-selection) +- [Project Tag Strategy](#project-tag-strategy) +- [Usage with Oh My OpenAgent](#usage-with-oh-my-openagent) +- [Development](#development) +- [Logs](#logs) +- [License](#license) + ## Installation ### For Humans @@ -177,6 +191,66 @@ API key is sk-abc123 Content in `` tags is never stored. +### Incremental Capture + +Assistant turns can be saved as they finish, which keeps long sessions from losing useful context. The capture limit is controlled by `maxCaptureChars`, so very large turns stay bounded. + +### Every-Message Recall + +This option runs memory search on every user message and re-injects the best matches when they matter. It is off by default because it adds retrieval cost on every turn. + +### Signal Extraction + +The plugin can watch for signal keywords and keep only the turns that look worth preserving. It stays heuristic-only, so no extra LLM call is needed for capture decisions. + +### Content Dedup + +Duplicate memories are skipped by content hash so the same fact does not get stored over and over. The cache is bounded and only stores hashes, which keeps the disk footprint small. + +### Metadata Stripping + +Injected timestamps, tags, and other wrapper metadata are removed before search. Private tags remain untouched, so the plugin still respects explicit private boundaries. + +### Relative Time Display + +Memory timestamps can render as relative text such as `2 hrs ago` instead of raw ISO strings. That keeps the injected context easier to scan during active sessions. + +### Entity Context + +Entity guidance gives memory extraction a short, focused hint about the current subject. It helps the plugin keep person, project, and product references consistent without adding extra API calls. + +### Auto-Category Tagging + +Memories can be classified into categories such as preference, decision, fact, or other. The feature is off by default so categorization only happens when you opt in. + +### Memo Usage Footer + +A compact footer can show how many memories were injected into the current context. That makes it easier to see when memory usage is high without opening logs. + +### Profile Cross-Array Dedup + +Profile facts can be deduped across the profile, project, and relevant-memory arrays before injection. That keeps repeated facts from crowding out fresher context. + +### Recall Keywords + +Extra recall phrases can trigger a search even when the message does not look like a normal memory request. This is useful for project-specific prompts that should always pull context back in. + +### Periodic Re-Injection + +The plugin can re-inject memory every N completed turns to keep long chats anchored. Set the interval to `0` to disable the cadence entirely. + +### Session-End Save + +When a session idles or ends, the plugin can write a final memory snapshot. That gives the conversation one last save point even if earlier capture was missed. + +### Pre-Compaction Save + +Before OpenCode compacts the conversation, the plugin can preserve the current state in full. This keeps the memory timeline intact before summarization trims the active context. + +### Post-Compaction Re-Injection + +After compaction, the plugin can queue the session for a fresh memory pull on the next chat turn. That restores useful context after the summary pass finishes. + ## Tool Usage The `supermemory` tool is available to the agent: @@ -204,6 +278,10 @@ The project tag formula is configurable via [`projectTagStrategy`](#project-tag- ## Configuration +### Migration Notes (v2) + +All new features default to safe behavior (zero additional API cost). Three options require explicit opt-in: `everyMessageRecall`, `reinjectEveryN`, and `autoCategoryTagging`. + Create `~/.config/opencode/supermemory-p.jsonc`: ```jsonc @@ -246,6 +324,60 @@ Create `~/.config/opencode/supermemory-p.jsonc`: // Context usage ratio that triggers compaction (0-1) "compactionThreshold": 0.8, + + // Save assistant turns progressively as they finish + "incrementalCapture": true, + + // Maximum characters kept from any captured turn + "maxCaptureChars": 5000, + + // Run recall search on every message (costly; default off) + "everyMessageRecall": false, + + // Re-inject memory every N completed turns (0 disables it) + "reinjectEveryN": 0, + + // Re-inject memory after context compaction + "postCompactionReinject": true, + + // Save a final snapshot when the session ends or idles + "sessionEndSave": true, + + // Keep only turns that match signal keywords + "signalExtraction": true, + + // Keywords that trigger capture for high-signal turns + "signalKeywords": ["remember", "save this", "important"], + + // Include this many earlier turns before a signal turn + "signalTurnsBefore": 3, + + // Extra recall phrases that trigger a memory search + "recallKeywordPatterns": ["bring that back", "look that up"], + + // Skip duplicate memory saves by normalized content hash + "dedupEnabled": true, + + // Maximum number of entries kept in the dedup cache + "dedupCacheSize": 500, + + // Short guidance for entity extraction during capture + "entityContext": "Focus on people, projects, tools, and decisions that matter to this repository.", + + // Strip injected metadata from memory search queries + "metadataStripping": true, + + // Show relative timestamps like '2 hrs ago' in memory context + "relativeTimeDisplay": true, + + // Append a compact memory-count footer to injected context + "memoUsageFooter": true, + + // Classify memories into categories before storing them + "autoCategoryTagging": false, + + // Deduplicate profile facts across profile, project, and relevant memories + "profileCrossArrayDedup": true, } ``` From eb0811cdfe0fd8d0127733b4302228a0ef3cf00c Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:11:25 +0800 Subject: [PATCH 29/37] fix: extract user prompt before persisting and re-injecting memory --- src/capture/incremental.ts | 20 +- src/capture/session-end.ts | 5 +- src/chat/handler.ts | 21 +- src/compaction/finder.ts | 56 --- src/compaction/index.ts | 87 +--- src/compaction/message-store.ts | 105 ----- src/compaction/pre-save.ts | 36 +- src/compaction/prompt.ts | 52 --- src/index.ts | 1 + src/memory/client.ts | 4 +- src/recall/every-message.ts | 14 +- src/shared/user-prompt.ts | 337 +++++++++++++++ src/signal/extract.ts | 25 +- src/tool/add.ts | 2 +- src/types/index.ts | 13 + tests/contract/tool-modes.test.ts | 2 +- .../incremental-capture-hook.test.ts | 2 +- tests/unit/chat-handler.test.ts | 2 +- tests/unit/incremental-capture.test.ts | 10 +- tests/unit/pre-compaction-save.test.ts | 38 +- tests/unit/session-end.test.ts | 4 +- tests/unit/signal-extract.test.ts | 21 +- tests/unit/user-prompt.test.ts | 395 ++++++++++++++++++ 23 files changed, 888 insertions(+), 364 deletions(-) delete mode 100644 src/compaction/finder.ts delete mode 100644 src/compaction/message-store.ts delete mode 100644 src/compaction/prompt.ts create mode 100644 src/shared/user-prompt.ts create mode 100644 tests/unit/user-prompt.test.ts diff --git a/src/capture/incremental.ts b/src/capture/incremental.ts index 8aec30a..a50b825 100644 --- a/src/capture/incremental.ts +++ b/src/capture/incremental.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { SupermemoryConfig } from "@/config/schema"; import type { SupermemoryClient } from "@/memory/client"; import { getProjectTag } from "@/memory/tags"; +import { createPromptBoundary } from "@/shared/user-prompt"; import type { extractSignalContent, Message, MessagePart } from "@/signal/extract"; export interface EventMessageUpdated { @@ -37,16 +38,16 @@ export async function handleMessageUpdatedForCapture(input: EventMessageUpdated, const messageID = info?.id; if (!sessionID || !messageID || info.role !== "assistant" || !info.finish) return; - const textParts = (info.parts ?? []).filter( - (part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored, - ); - if (textParts.length === 0) return; + const allParts = info.parts ?? []; + const boundary = createPromptBoundary(allParts, { sessionID, role: "assistant" }); + if (!boundary.userText) return; + const cleanPart: MessagePart = { type: "text", text: boundary.userText }; const message: Message = { id: messageID, sessionID, role: "assistant", - parts: textParts, + parts: [cleanPart], }; const extractedContent = deps.config.signalExtraction ? deps.signalExtract([message], deps.config) : null; @@ -56,17 +57,12 @@ export async function handleMessageUpdatedForCapture(input: EventMessageUpdated, const lastCaptured = await deps.tracker.getLastCaptured(sessionID, trackersDir); if (lastCaptured === messageID) return; - const rawContent = - extractedContent ?? - textParts - .map((part) => part.text) - .join("\n") - .trim(); + const rawContent = extractedContent ?? boundary.userText; if (rawContent.length === 0) return; const content = rawContent.slice(0, deps.config.maxCaptureChars); const projectTag = getProjectTag(deps.dataDir, deps.config); - await deps.client.addMemory(content, projectTag, { type: "conversation" }); + await deps.client.addMemory(content, projectTag, { type: "conversation", source: "assistant" }); await deps.tracker.appendCaptured(sessionID, messageID, trackersDir); } catch { // Event hooks should never interrupt OpenCode's main event flow. diff --git a/src/capture/session-end.ts b/src/capture/session-end.ts index bce7c3c..521eaf0 100644 --- a/src/capture/session-end.ts +++ b/src/capture/session-end.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import type { SupermemoryConfig } from "@/config/schema"; import { type extractSignalContent, groupIntoTurns, type Message, type MessagePart, type Turn } from "@/signal/extract"; +import type { MemorySource } from "@/types/index"; export interface EventSessionDeleted { event: { @@ -39,7 +40,7 @@ type SessionMessagesResponse = SessionMessageRecord[] | { data?: SessionMessageR export interface SessionEndDeps { config: SupermemoryConfig; client: { - addMemory(content: string, containerTag: string, metadata?: { type: "conversation" }): Promise; + addMemory(content: string, containerTag: string, metadata?: { type: "conversation"; source?: MemorySource }): Promise; }; sdkClient: { session: { @@ -78,7 +79,7 @@ export async function handleSessionEnd(input: EventSessionDeleted | EventSession const content = rawContent.slice(0, deps.config.maxCaptureChars * 4).trim(); if (content.length === 0) return; - await deps.client.addMemory(content, deps.projectTag, { type: "conversation" }); + await deps.client.addMemory(content, deps.projectTag, { type: "conversation", source: "summary" }); savedSessions.add(sessionID); if (input.event.type === "session.deleted") { diff --git a/src/chat/handler.ts b/src/chat/handler.ts index 86a4ba2..90c3511 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -24,6 +24,7 @@ import { detectRecallKeyword, runEveryMessageRecall } from "@/recall/every-messa import { shouldPeriodicReinject } from "@/recall/periodic"; import type { SessionState } from "@/session/state"; import { generatePartId } from "@/shared/ids"; +import { createPromptBoundary, sanitizeMemoryContextForInjection } from "@/shared/user-prompt"; export interface ChatHandlerInput { sessionID: string; @@ -102,24 +103,19 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan try { if (!deps.isConfigured()) return; - const textParts = output.parts.filter((p): p is Part & { type: "text"; text: string } => p.type === "text"); + const boundary = createPromptBoundary(output.parts, { sessionID: input.sessionID, role: "user" }); + const userMessage = boundary.userText; - if (textParts.length === 0) { - deps.log("chat.message: no text parts found"); - return; - } - - const userMessage = textParts.map((p) => p.text).join("\n"); - - if (!userMessage.trim()) { - deps.log("chat.message: empty message, skipping"); + if (!userMessage) { + deps.log("chat.message: empty message, skipping", { source: boundary.source }); return; } deps.log("chat.message: processing", { messagePreview: userMessage.slice(0, 100), partsCount: output.parts.length, - textPartsCount: textParts.length, + source: boundary.source, + strippedMarkers: boundary.strippedMarkers, }); if (detectMemoryKeyword(userMessage, deps.config)) { @@ -169,12 +165,13 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan const memoryContext = formatContextForPrompt(profile, userMemories, projectMemories); if (memoryContext) { + const safeMemoryContext = sanitizeMemoryContextForInjection(memoryContext); const contextPart: Part = { id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", - text: memoryContext, + text: `\n${safeMemoryContext}\n`, synthetic: true, }; diff --git a/src/compaction/finder.ts b/src/compaction/finder.ts deleted file mode 100644 index ec60201..0000000 --- a/src/compaction/finder.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { getLogger } from "@logtape/logtape"; - -const logger = getLogger(["supermemory", "compaction", "finder"]); - -/** - * Minimal subset of an OpenCode stored message that compaction logic cares - * about. Used to recover agent + provider/model identity when the live event - * payload is missing those fields. - */ -export interface StoredMessage { - agent?: string; - model?: { providerID?: string; modelID?: string }; -} - -/** - * Walks `messageDir` (newest first by lexicographic filename — IDs are - * timestamp-prefixed) and returns the first stored message that has both an - * agent string and a complete provider+model pair. Returns null when the - * directory cannot be read or no qualifying message exists. - * - * Per-file parse / read errors are silently swallowed: corrupt JSON in one - * message must not abort the search through the rest. This is intentional — - * the function is a best-effort fallback used by the compaction hook before - * it gives up on a session, not a strict consistency check. - * - * Single source of truth: this is the canonical implementation. The hook in - * src/services/compaction.ts and any future caller MUST import it from here - * rather than reimplementing. - */ -export function findNearestMessageWithFields(messageDir: string): StoredMessage | null { - try { - const files = readdirSync(messageDir) - .filter((f) => f.endsWith(".json")) - .sort() - .reverse(); - - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8"); - const msg = JSON.parse(content) as StoredMessage; - if (msg.agent && msg.model?.providerID && msg.model?.modelID) { - return msg; - } - } catch (err) { - logger.warn("[compaction] failed to read stored message", { file, error: String(err) }); - } - } - } catch (err) { - logger.warn("[compaction] failed to scan stored messages", { messageDir, error: String(err) }); - return null; - } - return null; -} diff --git a/src/compaction/index.ts b/src/compaction/index.ts index 94fc12b..8af654a 100644 --- a/src/compaction/index.ts +++ b/src/compaction/index.ts @@ -1,11 +1,7 @@ import { getLogger } from "@logtape/logtape"; -import { findNearestMessageWithFields } from "@/compaction/finder"; -import { getMessageDir, injectHookMessage } from "@/compaction/message-store"; -import { createCompactionPrompt } from "@/compaction/prompt"; import { type CompactionState, createCompactionState } from "@/compaction/state"; import { computeShouldCompact, DEFAULT_CONTEXT_LIMIT, DEFAULT_THRESHOLD, type TokenInfo } from "@/compaction/threshold"; -import { getConfig } from "@/config/loader"; import { supermemoryClient } from "@/memory/client"; const logger = getLogger(["supermemory", "compaction"]); @@ -21,15 +17,6 @@ export interface MessageInfo { finish?: boolean; } -export interface SummarizeContext { - sessionID: string; - providerID: string; - modelID: string; - usageRatio: number; - directory: string; - agent?: string; -} - export interface CompactionOptions { threshold?: number; getModelLimit?: (providerID: string, modelID: string) => number | undefined; @@ -115,7 +102,6 @@ export async function handleSessionIdle(deps: HookDeps, props?: Record m.info.role === "assistant").map((m) => m.info); const lastAssistant = assistants.at(-1); if (!lastAssistant) return; - fillMissingModelFromStorage(sessionID, lastAssistant); await performCompaction(deps, sessionID, lastAssistant); } catch (err) { logger.warn("[compaction] failed to process idle session", { sessionID, error: String(err) }); @@ -139,9 +125,8 @@ export function shouldCompact(deps: HookDeps, sessionID: string, lastAssistant: } export async function performCompaction(deps: HookDeps, sessionID: string, lastAssistant: MessageInfo): Promise { - const storedMessage = resolveStoredMessage(sessionID); - const providerID = lastAssistant.providerID || storedMessage?.model?.providerID || ""; - const modelID = lastAssistant.modelID || storedMessage?.model?.modelID || ""; + const providerID = lastAssistant.providerID || ""; + const modelID = lastAssistant.modelID || ""; const decision = shouldCompact(deps, sessionID, { ...lastAssistant, providerID, modelID }); if (!decision?.shouldCompact) return; logger.info("[compaction] checking", { @@ -159,14 +144,6 @@ export async function performCompaction(deps: HookDeps, sessionID: string, lastA await warnToast(deps, decision.usageRatio); logger.info("[compaction] triggering compaction", { sessionID, usageRatio: decision.usageRatio }); try { - await injectMemoryContext(deps, { - sessionID, - providerID, - modelID, - usageRatio: decision.usageRatio, - directory: deps.ctx.directory, - agent: storedMessage?.agent, - }); deps.state.summarizedSessions.add(sessionID); await deps.ctx.client.session.summarize({ path: { id: sessionID }, @@ -175,44 +152,19 @@ export async function performCompaction(deps: HookDeps, sessionID: string, lastA }); await successToast(deps); deps.state.compactionInProgress.delete(sessionID); - scheduleContinuePrompt(deps, sessionID); } catch (err) { logger.warn("[compaction] compaction failed", { sessionID, error: String(err) }); deps.state.compactionInProgress.delete(sessionID); } } - -export async function injectMemoryContext(deps: HookDeps, summarizeCtx: SummarizeContext): Promise { - logger.info("[compaction] injecting context", { sessionID: summarizeCtx.sessionID }); - const projectMemories = await fetchProjectMemoriesForCompaction(deps.tags.project); - const prompt = createCompactionPrompt(projectMemories); - const success = injectHookMessage(summarizeCtx.sessionID, prompt, { - agent: summarizeCtx.agent, - model: { providerID: summarizeCtx.providerID, modelID: summarizeCtx.modelID }, - path: { cwd: summarizeCtx.directory }, - }); - if (success) - logger.info("[compaction] context injected with project memories", { - sessionID: summarizeCtx.sessionID, - memoriesCount: projectMemories.length, - }); -} - -async function fetchProjectMemoriesForCompaction(projectTag: string): Promise { - try { - const result = await supermemoryClient.listMemories(projectTag, getConfig().maxProjectMemories); - return (result.memories || []).map((m) => m.summary || m.content || "").filter(Boolean); - } catch (err) { - logger.warn("[compaction] failed to fetch project memories", { error: String(err) }); - return []; - } -} - async function saveSummaryAsMemory(deps: HookDeps, sessionID: string, summaryContent: string): Promise { if (!summaryContent || summaryContent.length < 100) return logger.info("[compaction] summary too short to save", { sessionID, length: summaryContent.length }); try { - const result = await supermemoryClient.addMemory(`[Session Summary]\n${summaryContent}`, deps.tags.project, { type: "conversation" }); + const result = await supermemoryClient.addMemory(`[Session Summary]\n${summaryContent}`, deps.tags.project, { + type: "conversation", + source: "summary", + }); if (result.success) logger.info("[compaction] summary saved as memory", { sessionID, memoryId: result.id }); else logger.warn("[compaction] failed to save summary", { error: result.error }); } catch (err) { @@ -236,18 +188,6 @@ async function handleSummaryMessage(deps: HookDeps, sessionID: string): Promise< } } -function resolveStoredMessage(sessionID: string) { - const messageDir = getMessageDir(sessionID); - return messageDir ? findNearestMessageWithFields(messageDir) : null; -} - -function fillMissingModelFromStorage(sessionID: string, message: MessageInfo): void { - if (message.providerID && message.modelID) return; - const storedMessage = resolveStoredMessage(sessionID); - if (storedMessage?.model?.providerID) message.providerID = storedMessage.model.providerID; - if (storedMessage?.model?.modelID) message.modelID = storedMessage.model.modelID; -} - async function warnToast(deps: HookDeps, usageRatio: number): Promise { try { await deps.ctx.client.tui.showToast({ @@ -277,18 +217,3 @@ async function successToast(deps: HookDeps): Promise { logger.warn("[compaction] failed to show success toast", { error: String(err) }); } } - -function scheduleContinuePrompt(deps: HookDeps, sessionID: string): void { - setTimeout(async () => { - try { - const storedMessage = resolveStoredMessage(sessionID); - await deps.ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { agent: storedMessage?.agent, parts: [{ type: "text", text: "Continue" }] }, - query: { directory: deps.ctx.directory }, - }); - } catch (err) { - logger.warn("[compaction] failed to continue after compaction", { sessionID, error: String(err) }); - } - }, 500); -} diff --git a/src/compaction/message-store.ts b/src/compaction/message-store.ts deleted file mode 100644 index d34b5e1..0000000 --- a/src/compaction/message-store.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync, readdirSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -import { getLogger } from "@logtape/logtape"; -import { ensureDirSync } from "fs-extra"; - -import { findNearestMessageWithFields } from "@/compaction/finder"; -import { generateMessageId, generatePartId } from "@/shared/ids"; - -const logger = getLogger(["supermemory", "compaction", "message-store"]); - -/** On-disk roots captured at module-load time so tests can override $HOME first. */ -export const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); -export const PART_STORAGE = join(homedir(), ".opencode", "parts"); - -/** Locates a session's message dir; OpenCode stores them either at the root or one level deeper. Returns null if missing. */ -export function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null; - - const directPath = join(MESSAGE_STORAGE, sessionID); - if (existsSync(directPath)) return directPath; - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); - if (existsSync(sessionPath)) return sessionPath; - } - return null; -} - -/** Resolves the message dir, creating it if missing. Reuses getMessageDir so the lookup logic stays single-source. */ -export function getOrCreateMessageDir(sessionID: string): string { - ensureDirSync(MESSAGE_STORAGE); - const found = getMessageDir(sessionID); - if (found) return found; - - const directPath = join(MESSAGE_STORAGE, sessionID); - ensureDirSync(directPath); - return directPath; -} - -export interface InjectOriginalMessage { - agent?: string; - model?: { providerID?: string; modelID?: string }; - path?: { cwd?: string; root?: string }; -} - -/** - * Writes a synthetic user message + text part so the next assistant turn sees `hookContent` as if a user sent it. - * Agent + model resolve from the live event first, then a nearest-stored-message fallback. Returns false on empty - * content or write failure. The on-disk format is OpenCode's contract — do not change without coordinating upstream. - */ -export function injectHookMessage(sessionID: string, hookContent: string, originalMessage: InjectOriginalMessage): boolean { - if (!hookContent || hookContent.trim().length === 0) { - logger.info("[compaction] attempted to inject empty content, skipping"); - return false; - } - - const messageDir = getOrCreateMessageDir(sessionID); - const fallback = findNearestMessageWithFields(messageDir); - - const now = Date.now(); - const messageID = generateMessageId(); - const partID = generatePartId(); - - const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"; - const resolvedModel = - originalMessage.model?.providerID && originalMessage.model?.modelID - ? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID } - : fallback?.model?.providerID && fallback?.model?.modelID - ? { providerID: fallback.model.providerID, modelID: fallback.model.modelID } - : undefined; - - const messageMeta = { - id: messageID, - sessionID, - role: "user", - time: { created: now }, - agent: resolvedAgent, - model: resolvedModel, - path: originalMessage.path?.cwd ? { cwd: originalMessage.path.cwd, root: originalMessage.path.root ?? "/" } : undefined, - }; - - const textPart = { - id: partID, - type: "text", - text: hookContent, - synthetic: true, - time: { start: now, end: now }, - messageID, - sessionID, - }; - - try { - writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2)); - const partDir = join(PART_STORAGE, messageID); - ensureDirSync(partDir); - writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2)); - logger.info("[compaction] hook message injected", { sessionID, messageID }); - return true; - } catch (err) { - logger.info("[compaction] failed to inject hook message", { error: String(err) }); - return false; - } -} diff --git a/src/compaction/pre-save.ts b/src/compaction/pre-save.ts index c56115b..0d5042a 100644 --- a/src/compaction/pre-save.ts +++ b/src/compaction/pre-save.ts @@ -3,7 +3,8 @@ import { getLogger } from "@logtape/logtape"; import type { SupermemoryConfig } from "@/config/schema"; import type { SupermemoryClient } from "@/memory/client"; import { getProjectTag } from "@/memory/tags"; -import type { Message } from "@/signal/extract"; +import { createPromptBoundary } from "@/shared/user-prompt"; +import type { extractSignalContent, Message } from "@/signal/extract"; const logger = getLogger(["supermemory", "compaction", "pre-save"]); @@ -17,6 +18,14 @@ export interface PreSaveDeps { sdkSession: { messages(args: { sessionId: string }): Promise<{ messages: Message[] }>; }; + /** + * Heuristic signal extractor. When `config.signalExtraction === true` and + * this function returns a non-null string, it is preferred over the + * last-N-turns fallback. When it returns null (no signal keyword matched), + * we fall back to dumping the last N turns so the compaction summary still + * has SOMETHING to anchor on — better than losing the entire session. + */ + signalExtract: typeof extractSignalContent; } export async function handlePreCompactionSave( @@ -28,11 +37,11 @@ export async function handlePreCompactionSave( try { const response = await deps.sdkSession.messages({ sessionId: input.sessionID }); - const content = buildSessionContent(response.messages); + const content = buildSessionContent(response.messages, deps); if (content.length > 0) { const projectTag = getProjectTag(process.cwd(), deps.config); - const result = await deps.client.addMemory(content, projectTag, { type: "conversation" }); + const result = await deps.client.addMemory(content, projectTag, { type: "conversation", source: "summary" }); if (!result.ok) { logger.warn("[compaction] pre-save failed", { sessionID: input.sessionID, error: result.error.message }); } @@ -47,18 +56,23 @@ export async function handlePreCompactionSave( } } -function buildSessionContent(messages: Message[]): string { +function buildSessionContent(messages: Message[], deps: PreSaveDeps): string { + if (deps.config.signalExtraction === true) { + // Signal extraction here is used as a TRIGGER, not a selector: if the session + // contains no signal keywords we save nothing (cuts pre-compaction noise). + // When a signal IS present we still dump the last N turns — the agent needs + // the conversational context around the signal turn (especially the + // assistant response that follows) for the post-compaction summary to be + // useful, and `signalExtract` only walks BACKWARDS from the trigger. + const triggered = deps.signalExtract(messages, deps.config); + if (triggered === null) return ""; + } return messages.flatMap(formatMessageTurn).slice(-MAX_TURNS).join("\n").slice(0, MAX_CONTENT_CHARS).trim(); } function formatMessageTurn(message: Message): string[] { if (message.role !== "user" && message.role !== "assistant") return []; - const text = message.parts - .filter((part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored) - .map((part) => part.text) - .join("\n") - .trim(); - - return text.length > 0 ? [`[${message.role}] ${text}`] : []; + const boundary = createPromptBoundary(message.parts, { sessionID: message.sessionID, role: message.role }); + return boundary.userText.length > 0 ? [`[${message.role}] ${boundary.userText}`] : []; } diff --git a/src/compaction/prompt.ts b/src/compaction/prompt.ts deleted file mode 100644 index 4b56bc9..0000000 --- a/src/compaction/prompt.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Builds the compaction context-injection prompt that gets prepended (as a - * synthetic user message) to a session right before OpenCode summarises it. - * - * The five-section structure (User Requests / Final Goal / Work Completed / - * Remaining Tasks / MUST NOT Do) is part of the contract OpenCode's - * summarisation expects. Do not rewrite the body without updating downstream - * consumers — the existing wording is byte-identical to the pre-extraction - * version in src/services/compaction.ts. - */ -export function createCompactionPrompt(projectMemories: string[]): string { - const memoriesSection = - projectMemories.length > 0 - ? ` -## Project Knowledge (from Supermemory) -The following project-specific knowledge should be preserved and referenced in the summary: -${projectMemories.map((m) => `- ${m}`).join("\n")} -` - : ""; - - return `[COMPACTION CONTEXT INJECTION] - -When summarizing this session, you MUST include the following sections in your summary: - -## 1. User Requests (As-Is) -- List all original user requests exactly as they were stated -- Preserve the user's exact wording and intent - -## 2. Final Goal -- What the user ultimately wanted to achieve -- The end result or deliverable expected - -## 3. Work Completed -- What has been done so far -- Files created/modified -- Features implemented -- Problems solved - -## 4. Remaining Tasks -- What still needs to be done -- Pending items from the original request -- Follow-up tasks identified during the work - -## 5. MUST NOT Do (Critical Constraints) -- Things that were explicitly forbidden -- Approaches that failed and should not be retried -- User's explicit restrictions or preferences -- Anti-patterns identified during the session -${memoriesSection} -This context is critical for maintaining continuity after compaction. -`; -} diff --git a/src/index.ts b/src/index.ts index 7b080f5..b4d906f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { ), }), }, + signalExtract: extractSignalContent, }, ) : Promise.resolve(), diff --git a/src/memory/client.ts b/src/memory/client.ts index bf79b11..b18ad14 100644 --- a/src/memory/client.ts +++ b/src/memory/client.ts @@ -11,7 +11,7 @@ import { clampEntityContext } from "@/memory/entity-context"; import type { AppError } from "@/shared/errors"; import { err, ok, type Result } from "@/shared/result"; import { withTimeout } from "@/shared/timeout"; -import type { ConversationIngestResponse, ConversationMessage, MemoryType } from "@/types/index"; +import type { ConversationIngestResponse, ConversationMessage, MemorySource, MemoryType } from "@/types/index"; const logger = getLogger(["supermemory", "memory", "client"]); @@ -57,7 +57,7 @@ export type DeleteMemoryResult = { success: true }; export type ListMemoriesResult = Awaited> & { success: true }; export type IngestConversationResult = ConversationIngestResponse & { success: true; storedMemoryIds: string[] }; -type AddMemoryMetadata = { type?: MemoryType; tool?: string; [key: string]: unknown }; +type AddMemoryMetadata = { type?: MemoryType; tool?: string; source?: MemorySource; [key: string]: unknown }; interface SupermemoryClientOptions { dedupCache?: DedupCache; diff --git a/src/recall/every-message.ts b/src/recall/every-message.ts index 1fd45e1..ad496de 100644 --- a/src/recall/every-message.ts +++ b/src/recall/every-message.ts @@ -5,6 +5,7 @@ import { formatContextForPrompt } from "@/memory/context"; import { stripInboundMetadata } from "@/memory/metadata-strip"; import { generatePartId } from "@/shared/ids"; import { withTimeout } from "@/shared/timeout"; +import { createPromptBoundary, sanitizeMemoryContextForInjection } from "@/shared/user-prompt"; const RECALL_THROTTLE_MS = 2_000; const RECALL_TIMEOUT_MS = 2_000; @@ -39,11 +40,8 @@ export function detectRecallKeyword(userMessage: string, config: Partial pattern.trim().length > 0 && normalized.includes(pattern.toLowerCase())); } -function getUserMessage(parts: Part[]): string { - return parts - .filter((part): part is Part & { type: "text"; text: string } => part.type === "text" && part.synthetic !== true) - .map((part) => part.text) - .join("\n"); +function getUserMessage(parts: Part[], sessionID: string): string { + return createPromptBoundary(parts, { sessionID, role: "user" }).userText; } export async function runEveryMessageRecall( @@ -59,7 +57,7 @@ export async function runEveryMessageRecall( } lastRecallAt.set(input.sessionID, now); - const strippedQuery = stripInboundMetadata(getUserMessage(output.parts)); + const strippedQuery = stripInboundMetadata(getUserMessage(output.parts, input.sessionID)); if (!strippedQuery) { deps.log("chat.message: recall query empty after metadata stripping"); return; @@ -76,12 +74,14 @@ export async function runEveryMessageRecall( if (!memoryContext) return; + const safeMemoryContext = sanitizeMemoryContextForInjection(memoryContext); + output.parts.unshift({ id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", - text: memoryContext, + text: `\n${safeMemoryContext}\n`, synthetic: true, }); } catch (error) { diff --git a/src/shared/user-prompt.ts b/src/shared/user-prompt.ts new file mode 100644 index 0000000..4dc746d --- /dev/null +++ b/src/shared/user-prompt.ts @@ -0,0 +1,337 @@ +/** + * User prompt boundary: a single source of truth for "what did the user + * actually type" in a chat message. + * + * OpenCode's `output.parts` is a shared, mutable prompt bus: it carries the + * user's input, slash-command expansions, system reminders, recovery prompts, + * memory injections, and more. Many of those entries are produced by other + * plugins (notably oh-my-openagent / OMO) and intentionally LOOK like user + * input because they overwrite the user's part text directly. They are not + * marked `synthetic: true`. + * + * If supermemory treats every text part as user input, three things break: + * 1. Searches/profile queries get polluted by plugin templates. + * 2. Long-term memory ingests OMO marker strings (e.g. the start-work + * template) — turning every future session into a context-injection + * trojan that re-triggers OMO's start-work-hook into forcing the + * `atlas` agent. + * 3. Keyword detection fires on plugin reminders, not user intent. + * + * This module is the boundary. Every place that consumes "user text" from + * `parts` MUST route through `extractUserPromptFromParts` (or the lower-level + * `extractUserPrompt`). Every place that injects supermemory's own context + * back into a chat message MUST sanitize it through + * `sanitizeMemoryContextForInjection` first. + * + * Strategy (per Oracle review): + * 1. Honor OpenCode part flags: skip `synthetic === true` and + * `ignored === true` parts. + * 2. Code fences (triple-backtick blocks) are sacrosanct — we never alter + * content inside them. This lets users paste source code (including + * OMO templates we are debugging right now) without lossy filtering. + * 3. In prose, prefer explicit user-content wrappers: `` and + * `` are how OMO wraps the actual user arguments inside a + * slash-command expansion. If present, extract them. + * 4. Fall back to a denylist removal of known plugin-injected block tags + * (``, ``, ``, + * ``, ``) plus line-prefix + * directives (`[SYSTEM DIRECTIVE: ...]`, the OMO checkpoint restore + * prompt, the OMO_INTERNAL_INITIATOR marker). + * 5. Conservative on malformed input — leave user content intact rather + * than risk catastrophic deletion. Orphan opening/closing tags are not + * stripped from `extractUserPrompt`; we trust that the user's text is + * more important than perfect cleanup. + * + * `sanitizeMemoryContextForInjection` is more aggressive: when we are about + * to inject memory text BACK into a chat message, we cannot afford any + * leftover wrapper or trigger phrase, because OMO and friends will read the + * combined parts and react to anything that looks like their own marker. + * Memory is structured data we produce — there is no user-typed code fence + * to preserve, so we strip everything known to be dangerous, including bare + * trigger phrases like the start-work-hook canary string. + */ + +const USER_WRAPPER_PATTERN = /<(user-request|user-task)>([\s\S]*?)<\/\1>/gi; + +// Block-level wrappers OMO and friends inject around their own content. The +// supermemory-context entry exists so future supermemory injections can be +// stripped cleanly if they re-enter the parts stream (e.g. via conversation +// history fed back through SDK calls). +const BLOCK_TAGS_TO_STRIP = [ + "auto-slash-command", + "command-instruction", + "session-context", + "system-reminder", + "supermemory-context", +] as const; + +// Tags that may appear in memory text as an orphan opener/closer after +// chunking. Includes the user wrappers — memory has no business carrying an +// active tag; if it does, the memory was captured from a +// previously polluted session. +const ORPHAN_TAG_PATTERN = + /<\/?(?:auto-slash-command|command-instruction|session-context|system-reminder|supermemory-context|user-request|user-task)[^>]*>/gi; + +const LINE_PREFIXES_TO_STRIP: RegExp[] = [ + /^[\t ]*\[SYSTEM DIRECTIVE:[^\]]*\].*$/gm, + /^[\t ]*\[restore checkpointed session agent configuration after compaction\][\t ]*$/gm, + /^[\t ]*[\t ]*$/gm, +]; + +// Standalone trigger phrases that OMO hooks key off without any surrounding +// tag. Currently the start-work-hook checks for this canary string. +const STANDALONE_TRIGGER_PHRASES: RegExp[] = [/You are starting a Sisyphus work session\./g]; + +const CODE_FENCE_PATTERN = /```[\s\S]*?```/g; + +export type SourceKind = "wrapped-user-content" | "sanitized-text" | "raw-text" | "empty"; + +export type MessageRole = "user" | "assistant"; + +export interface TextPartLike { + type: string; + text?: unknown; + synthetic?: boolean; + ignored?: boolean; +} + +/** + * Result of extracting the user-typed portion of one or more text parts. + * Kept as a structured value (vs. a bare string) so downstream callers can + * carry provenance information (source, stripped markers) into logs, memory + * records, and tests without re-running the extractor. + */ +export interface ExtractedUserPrompt { + /** The extracted user text, or "" if nothing remained after sanitization. */ + text: string; + /** How the text was obtained — useful for logging and tests. */ + source: SourceKind; + /** Names of markers that were detected/removed; informational only. */ + strippedMarkers: string[]; +} + +/** + * The contract between OpenCode's mutable `parts` bus and supermemory-p's + * domain logic. Every hook that needs to know "what did the user actually + * say" should consume a `PromptBoundary` rather than parsing parts directly. + * + * - `rawText` preserves the concatenated text of all eligible (non-synthetic, + * non-ignored) parts before any extraction. Useful for debugging when + * extraction is unexpectedly empty. + * - `userText` is the post-extraction value, alias of `text` for clarity at + * call sites. + * - `sessionID` / `role` are optional metadata the caller can attach so logs + * and memory provenance carry through without re-deriving them. + */ +export interface PromptBoundary extends ExtractedUserPrompt { + /** Concatenated text of eligible parts before extraction. */ + rawText: string; + /** Alias of `text`. */ + userText: string; + /** OpenCode session this boundary belongs to, if known. */ + sessionID?: string; + /** Role of the message this boundary was extracted from, if known. */ + role?: MessageRole; +} + +export interface PromptBoundaryContext { + sessionID?: string; + role?: MessageRole; +} + +interface Segment { + type: "code" | "prose"; + content: string; +} + +function splitFencedCode(text: string): Segment[] { + const segments: Segment[] = []; + let cursor = 0; + for (const match of text.matchAll(CODE_FENCE_PATTERN)) { + const idx = match.index ?? 0; + if (idx > cursor) { + segments.push({ type: "prose", content: text.slice(cursor, idx) }); + } + segments.push({ type: "code", content: match[0] }); + cursor = idx + match[0].length; + } + if (cursor < text.length) { + segments.push({ type: "prose", content: text.slice(cursor) }); + } + return segments; +} + +function extractWrappedUserContent(prose: string): string[] { + const contents: string[] = []; + for (const match of prose.matchAll(USER_WRAPPER_PATTERN)) { + const body = match[2]; + if (typeof body === "string") { + contents.push(body.trim()); + } + } + return contents; +} + +function stripBlockTagsFromProse(prose: string): string { + let result = prose; + for (const tag of BLOCK_TAGS_TO_STRIP) { + result = result.replace(new RegExp(`<${tag}>[\\s\\S]*?`, "gi"), ""); + } + for (const pattern of LINE_PREFIXES_TO_STRIP) { + result = result.replace(pattern, ""); + } + return result; +} + +function collectStrippedMarkers(raw: string): string[] { + const markers: string[] = []; + if (//i.test(raw)) markers.push("auto-slash-command"); + if (//i.test(raw)) markers.push("command-instruction"); + if (//i.test(raw)) markers.push("session-context"); + if (//i.test(raw)) markers.push("system-reminder"); + if (//i.test(raw)) markers.push("supermemory-context"); + if (/\[SYSTEM DIRECTIVE:/i.test(raw)) markers.push("system-directive"); + if (/\[restore checkpointed session/i.test(raw)) markers.push("checkpoint-restore"); + if (//i.test(raw)) markers.push("omo-internal-initiator"); + return markers; +} + +function collapseBlankLines(text: string): string { + return text.replace(/\n{3,}/g, "\n\n"); +} + +/** + * Extract the user-typed portion of a raw chat text. See module header for + * the full strategy. Safe for empty/whitespace input. + */ +export function extractUserPrompt(rawText: string): ExtractedUserPrompt { + if (!rawText || rawText.trim().length === 0) { + return { text: "", source: "empty", strippedMarkers: [] }; + } + + const segments = splitFencedCode(rawText); + const proseSegments = segments.filter((s) => s.type === "prose"); + + // Step 1: explicit user wrappers win when both present and non-empty. + const wrapped = proseSegments.flatMap((s) => extractWrappedUserContent(s.content)); + const wrappedNonEmpty = wrapped.filter((c) => c.length > 0); + + if (wrappedNonEmpty.length > 0) { + return { + text: wrappedNonEmpty.join("\n").trim(), + source: "wrapped-user-content", + strippedMarkers: ["user-wrapper"], + }; + } + + if (wrapped.length > 0) { + // Wrapper present but empty — caller said "no user content"; respect it. + return { text: "", source: "empty", strippedMarkers: ["user-wrapper-empty"] }; + } + + // Step 2: denylist-strip prose; code fences pass through untouched. + const sanitizedSegments = segments.map((segment) => + segment.type === "code" ? segment.content : stripBlockTagsFromProse(segment.content), + ); + const sanitized = collapseBlankLines(sanitizedSegments.join("")).trim(); + + if (sanitized.length === 0) { + return { text: "", source: "empty", strippedMarkers: collectStrippedMarkers(rawText) }; + } + + const changed = sanitized !== rawText.trim(); + return { + text: sanitized, + source: changed ? "sanitized-text" : "raw-text", + strippedMarkers: changed ? collectStrippedMarkers(rawText) : [], + }; +} + +/** + * Convenience wrapper: filter out synthetic/ignored/non-text parts, join the + * eligible texts, then run `extractUserPrompt` over the combined string. + * + * Pass `parts === undefined` and you get an empty result back — safe for + * cases where the hook payload may omit the parts list. + */ +export function extractUserPromptFromParts(parts: readonly TextPartLike[] | undefined): ExtractedUserPrompt { + if (!parts || parts.length === 0) { + return { text: "", source: "empty", strippedMarkers: [] }; + } + + const eligible = parts.filter( + (p): p is TextPartLike & { text: string } => p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored, + ); + + if (eligible.length === 0) { + return { text: "", source: "empty", strippedMarkers: [] }; + } + + const joined = eligible.map((p) => p.text).join("\n"); + return extractUserPrompt(joined); +} + +/** + * Build a `PromptBoundary` from raw parts + optional metadata. This is the + * preferred entry point for hooks — the returned object carries the raw + * text, the extracted user text, provenance information, and any caller + * context (sessionID, role) all in one immutable bundle. + * + * For places that only need `string`, the lower-level `extractUserPrompt*` + * functions remain available. + */ +export function createPromptBoundary(parts: readonly TextPartLike[] | undefined, context: PromptBoundaryContext = {}): PromptBoundary { + const eligible = (parts ?? []).filter( + (p): p is TextPartLike & { text: string } => p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored, + ); + const rawText = eligible.map((p) => p.text).join("\n"); + const extracted = extractUserPrompt(rawText); + + return Object.freeze({ + rawText, + text: extracted.text, + userText: extracted.text, + source: extracted.source, + strippedMarkers: extracted.strippedMarkers, + sessionID: context.sessionID, + role: context.role, + }); +} + +/** + * Sanitize memory context BEFORE injecting it back into a user message's + * parts. More aggressive than `extractUserPrompt` because: + * - We control the input (formatted memory, not user text), so there is no + * code fence to preserve. + * - Any leftover OMO trigger phrase or wrapper here will be processed by + * downstream plugins as if the user typed it. + * + * Strips block tags, orphan tag fragments, line-prefix directives, and + * standalone trigger phrases (the start-work-hook canary string). + */ +export function sanitizeMemoryContextForInjection(memoryContext: string): string { + if (!memoryContext) return ""; + + let cleaned = memoryContext; + + // Strip complete block tags (including user-wrappers — memory should never + // carry an active tag). + for (const tag of [...BLOCK_TAGS_TO_STRIP, "user-request", "user-task"]) { + cleaned = cleaned.replace(new RegExp(`<${tag}>[\\s\\S]*?`, "gi"), ""); + } + + // Strip orphan opener/closer fragments left behind by chunked memories. + cleaned = cleaned.replace(ORPHAN_TAG_PATTERN, ""); + + // Strip line-prefix directives. + for (const pattern of LINE_PREFIXES_TO_STRIP) { + cleaned = cleaned.replace(pattern, ""); + } + + // Neutralize standalone trigger phrases. + for (const pattern of STANDALONE_TRIGGER_PHRASES) { + cleaned = cleaned.replace(pattern, ""); + } + + return collapseBlankLines(cleaned).trim(); +} diff --git a/src/signal/extract.ts b/src/signal/extract.ts index 9e5685e..2edc3c0 100644 --- a/src/signal/extract.ts +++ b/src/signal/extract.ts @@ -1,3 +1,5 @@ +import { createPromptBoundary, type SourceKind } from "@/shared/user-prompt"; + const MAX_SIGNAL_TURN_CHARS = 50 * 1024; const COMMAND_PATTERN = /^[/\\][a-z-]+/i; @@ -20,6 +22,10 @@ export interface Turn { role: "user" | "assistant"; text: string; messageId: string; + /** Session this turn belongs to, propagated through `PromptBoundary`. */ + sessionID?: string; + /** How the turn's text was extracted from the original parts. */ + source?: SourceKind; } export interface SignalExtractionConfig { @@ -28,15 +34,16 @@ export interface SignalExtractionConfig { } export function groupIntoTurns(messages: Message[]): Turn[] { - return messages.map((message) => ({ - role: message.role, - text: message.parts - .filter((part) => part.type === "text" && typeof part.text === "string" && !part.synthetic && !part.ignored) - .map((part) => part.text) - .join("\n") - .trim(), - messageId: message.id, - })); + return messages.map((message) => { + const boundary = createPromptBoundary(message.parts, { sessionID: message.sessionID, role: message.role }); + return { + role: message.role, + text: boundary.userText, + messageId: message.id, + sessionID: boundary.sessionID, + source: boundary.source, + }; + }); } export function findSignalTurns(turns: Turn[], keywords: string[]): number[] { diff --git a/src/tool/add.ts b/src/tool/add.ts index eed4a65..a8df6e9 100644 --- a/src/tool/add.ts +++ b/src/tool/add.ts @@ -20,7 +20,7 @@ export async function executeAdd(args: ToolArgs, deps: ToolDeps): Promise { // Note: content is passed through stripPrivateContent → unchanged here, plain text. expect(clientCalls.addMemory[0]?.args[0]).toBe("hello world"); expect(clientCalls.addMemory[0]?.args[1]).toBe(MOCK_PROJECT_TAG); - expect(clientCalls.addMemory[0]?.args[2]).toEqual({ type: "preference" }); + expect(clientCalls.addMemory[0]?.args[2]).toEqual({ type: "preference", source: "user" }); }); it("scope=user routes to the user tag, scope=project routes to the project tag", async () => { diff --git a/tests/integration/incremental-capture-hook.test.ts b/tests/integration/incremental-capture-hook.test.ts index 79e2842..1dff90b 100644 --- a/tests/integration/incremental-capture-hook.test.ts +++ b/tests/integration/incremental-capture-hook.test.ts @@ -11,7 +11,7 @@ async function flushCaptureQueue(): Promise { describe("incremental capture event hook", () => { it("dispatches only completed assistant message.updated events", async () => { - const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string; source?: string }) => ({ success: true as const, id: "mem_1", })); diff --git a/tests/unit/chat-handler.test.ts b/tests/unit/chat-handler.test.ts index ce7859c..e43ce84 100644 --- a/tests/unit/chat-handler.test.ts +++ b/tests/unit/chat-handler.test.ts @@ -192,7 +192,7 @@ describe("handleChatMessage", () => { expect(output.parts).toHaveLength(0); expect(deps.client.calls.searchMemories).toHaveLength(0); - expect(deps.logs.some((l) => l.msg.includes("no text parts"))).toBe(true); + expect(deps.logs.some((l) => l.msg.includes("empty message"))).toBe(true); }); it("swallows client errors and logs them without throwing", async () => { diff --git a/tests/unit/incremental-capture.test.ts b/tests/unit/incremental-capture.test.ts index 998cdaa..524b447 100644 --- a/tests/unit/incremental-capture.test.ts +++ b/tests/unit/incremental-capture.test.ts @@ -35,7 +35,7 @@ function makeDeps(overrides: Partial = {}) { const getLastCaptured = mock(async () => null); const appendCaptured = mock(async () => undefined); const pruneOldTrackers = mock(async () => 0); - const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string; source?: string }) => ({ success: true as const, id: "mem_1", })); @@ -59,7 +59,11 @@ describe("handleMessageUpdatedForCapture", () => { await handleMessageUpdatedForCapture(makeEvent(), deps); expect(addMemory).toHaveBeenCalledTimes(1); - expect(addMemory.mock.calls[0]).toEqual(["Remember this stable project fact.", "project-tag", { type: "conversation" }]); + expect(addMemory.mock.calls[0]).toEqual([ + "Remember this stable project fact.", + "project-tag", + { type: "conversation", source: "assistant" }, + ]); }); it("skips messages with no usable text parts", async () => { @@ -112,7 +116,7 @@ describe("handleMessageUpdatedForCapture", () => { }); it("swallows tracker and client errors", async () => { - const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => { + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string; source?: string }) => { throw new Error("network down"); }); const { deps } = makeDeps({ client: { addMemory } as unknown as IncrementalCaptureDeps["client"] }); diff --git a/tests/unit/pre-compaction-save.test.ts b/tests/unit/pre-compaction-save.test.ts index 28742a5..c78458a 100644 --- a/tests/unit/pre-compaction-save.test.ts +++ b/tests/unit/pre-compaction-save.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; import { handlePreCompactionSave, type PreSaveDeps } from "@/compaction/pre-save"; import { DEFAULTS } from "@/config/defaults"; import { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; -import type { Message } from "@/signal/extract"; +import { extractSignalContent, type Message } from "@/signal/extract"; function makeConfig(overrides: Partial = {}): SupermemoryConfig { return SupermemoryConfigSchema.parse({ ...DEFAULTS, projectContainerTag: "project-tag", + // pre-save uses signalExtraction as a save-or-skip trigger; tests that aren't + // exercising that trigger opt out so the dump path is exercised directly. + signalExtraction: false, ...overrides, }); } @@ -18,7 +21,7 @@ function makeMessage(id: string, role: "user" | "assistant", text: string, parts } function makeDeps(messagesList: Message[], overrides: Partial = {}) { - const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string; source?: string }) => ({ ok: true as const, value: { success: true as const, id: "mem_1" }, })); @@ -27,6 +30,7 @@ function makeDeps(messagesList: Message[], overrides: Partial = {}) config: makeConfig(), client: { addMemory } as unknown as PreSaveDeps["client"], sdkSession: { messages }, + signalExtract: extractSignalContent, ...overrides, }; @@ -47,7 +51,7 @@ describe("handlePreCompactionSave", () => { expect(addMemory).toHaveBeenCalledTimes(1); expect(addMemory.mock.calls[0]?.[0]).toBe("[user] Remember this decision.\n[assistant] Saved."); expect(addMemory.mock.calls[0]?.[1]).toBe("project-tag"); - expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation" }); + expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation", source: "summary" }); expect(input.output.context).toEqual(["Memories preserved in Supermemory."]); }); @@ -110,4 +114,32 @@ describe("handlePreCompactionSave", () => { expect(input.output.context).toEqual(["Memories preserved in Supermemory."]); expect(input.output.prompt).toBe("original prompt"); }); + + describe("handlePreCompactionSave signal triggering", () => { + it("skips save when signalExtraction is on and no signal keyword present", async () => { + const input = { sessionID: "ses_no_signal", output: { context: [] as string[] } }; + const { deps, addMemory } = makeDeps([makeMessage("msg_1", "user", "nothing notable here")], { + config: makeConfig({ signalExtraction: true, signalKeywords: ["durable-signal"] }), + }); + + await handlePreCompactionSave(input, deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + expect(input.output.context).toContain("Memories preserved in Supermemory."); + }); + + it("saves last-N turns when signalExtraction triggers (signal present)", async () => { + const input = { sessionID: "ses_with_signal", output: { context: [] as string[] } }; + const { deps, addMemory } = makeDeps( + [makeMessage("msg_1", "user", "remember this fact"), makeMessage("msg_2", "assistant", "noted")], + { config: makeConfig({ signalExtraction: true }) }, + ); + + await handlePreCompactionSave(input, deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + // Full last-N dump including the assistant turn that followed the signal. + expect(addMemory.mock.calls[0]?.[0]).toBe("[user] remember this fact\n[assistant] noted"); + }); + }); }); diff --git a/tests/unit/session-end.test.ts b/tests/unit/session-end.test.ts index 11178bc..2623937 100644 --- a/tests/unit/session-end.test.ts +++ b/tests/unit/session-end.test.ts @@ -32,7 +32,7 @@ function makeMessage(id: string, role: "user" | "assistant", text: string) { function makeDeps(overrides: Partial = {}) { const getLastCaptured = mock(async () => null); const pruneOldTrackers = mock(async () => 0); - const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string }) => ({ + const addMemory = mock(async (_content: string, _containerTag: string, _metadata?: { type: string; source?: string }) => ({ success: true as const, id: "mem_1", })); @@ -63,7 +63,7 @@ describe("handleSessionEnd", () => { expect(addMemory).toHaveBeenCalledTimes(1); expect(addMemory.mock.calls[0]?.[0]).toContain("[user] Remember this project decision."); expect(addMemory.mock.calls[0]?.[1]).toBe("project-tag"); - expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation" }); + expect(addMemory.mock.calls[0]?.[2]).toEqual({ type: "conversation", source: "summary" }); }); it("deduplicates idle and deleted events for the same session", async () => { diff --git a/tests/unit/signal-extract.test.ts b/tests/unit/signal-extract.test.ts index dcf8c52..29bc359 100644 --- a/tests/unit/signal-extract.test.ts +++ b/tests/unit/signal-extract.test.ts @@ -19,12 +19,27 @@ describe("signal extraction", () => { ]); expect(turns).toEqual([ - { role: "user", text: "First request", messageId: "u1" }, - { role: "assistant", text: "First reply", messageId: "a1" }, - { role: "user", text: "Second request", messageId: "u2" }, + { role: "user", text: "First request", messageId: "u1", sessionID: undefined, source: "raw-text" }, + { role: "assistant", text: "First reply", messageId: "a1", sessionID: undefined, source: "raw-text" }, + { role: "user", text: "Second request", messageId: "u2", sessionID: undefined, source: "raw-text" }, ]); }); + it("propagates sessionID and source through Turn metadata", () => { + const message: Message = { + id: "u_omo", + role: "user", + sessionID: "ses_flow", + parts: [{ type: "text", text: "xreal ask" }], + }; + + const [turn] = groupIntoTurns([message]); + + expect(turn?.text).toBe("real ask"); + expect(turn?.sessionID).toBe("ses_flow"); + expect(turn?.source).toBe("wrapped-user-content"); + }); + it("returns matching user turn indices", () => { const turns: Turn[] = [ { role: "user", text: "This is important", messageId: "u1" }, diff --git a/tests/unit/user-prompt.test.ts b/tests/unit/user-prompt.test.ts new file mode 100644 index 0000000..62d6523 --- /dev/null +++ b/tests/unit/user-prompt.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from "bun:test"; + +import { + createPromptBoundary, + extractUserPrompt, + extractUserPromptFromParts, + sanitizeMemoryContextForInjection, + type TextPartLike, +} from "@/shared/user-prompt"; + +const OMO_START_WORK_MARKER = "You are starting a Sisyphus work session."; + +describe("createPromptBoundary", () => { + it("carries rawText (joined eligible parts) alongside userText", () => { + const parts: TextPartLike[] = [{ type: "text", text: "hello world" }]; + const boundary = createPromptBoundary(parts); + expect(boundary.rawText).toBe("hello world"); + expect(boundary.userText).toBe("hello world"); + expect(boundary.source).toBe("raw-text"); + }); + + it("attaches sessionID and role from context", () => { + const boundary = createPromptBoundary([{ type: "text", text: "x" }], { + sessionID: "ses_1", + role: "user", + }); + expect(boundary.sessionID).toBe("ses_1"); + expect(boundary.role).toBe("user"); + }); + + it("rawText preserves OMO wrapper while userText extracts inner content", () => { + const omoExpansion = `${OMO_START_WORK_MARKER}\nreal ask`; + const boundary = createPromptBoundary([{ type: "text", text: omoExpansion }]); + expect(boundary.rawText).toBe(omoExpansion); + expect(boundary.userText).toBe("real ask"); + expect(boundary.source).toBe("wrapped-user-content"); + }); + + it("returns empty boundary for empty parts", () => { + const boundary = createPromptBoundary([]); + expect(boundary.userText).toBe(""); + expect(boundary.rawText).toBe(""); + expect(boundary.source).toBe("empty"); + }); + + it("excludes synthetic parts from rawText (those are NOT user input)", () => { + const parts: TextPartLike[] = [ + { type: "text", text: "memory", synthetic: true }, + { type: "text", text: "real input" }, + ]; + const boundary = createPromptBoundary(parts); + expect(boundary.rawText).toBe("real input"); + expect(boundary.userText).toBe("real input"); + }); + + it("is frozen (immutable)", () => { + const boundary = createPromptBoundary([{ type: "text", text: "x" }]); + expect(Object.isFrozen(boundary)).toBe(true); + }); + + it("undefined parts produces empty boundary with context still attached", () => { + const boundary = createPromptBoundary(undefined, { sessionID: "ses_x", role: "assistant" }); + expect(boundary.userText).toBe(""); + expect(boundary.rawText).toBe(""); + expect(boundary.sessionID).toBe("ses_x"); + expect(boundary.role).toBe("assistant"); + }); +}); + +describe("extractUserPrompt", () => { + it("returns wrapped content from ", () => { + const raw = ` +${OMO_START_WORK_MARKER} +Do work. + + + +Session ID: ses_abc +Timestamp: 2026-05-14T00:00:00Z + + + +help me refactor auth.ts +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe("help me refactor auth.ts"); + expect(result.source).toBe("wrapped-user-content"); + }); + + it("returns wrapped content from ", () => { + const raw = ` +Do something + + + +fix the failing test +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe("fix the failing test"); + expect(result.source).toBe("wrapped-user-content"); + }); + + it("joins multiple user wrappers in order", () => { + const raw = ` +first request + + +mid noise + + +second request +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe("first request\nsecond request"); + expect(result.source).toBe("wrapped-user-content"); + }); + + it("returns empty when wrapper is empty", () => { + const raw = `stuff +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe(""); + expect(result.source).toBe("empty"); + }); + + it("returns plain user prompt unchanged", () => { + const raw = "just a normal question about typescript generics"; + const result = extractUserPrompt(raw); + expect(result.text).toBe(raw); + expect(result.source).toBe("raw-text"); + }); + + it("strips when no user wrapper present", () => { + const raw = ` +[SYSTEM DIRECTIVE: OH-MY-OPENCODE - SINGLE TASK ONLY] +some reminder body +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe(""); + expect(result.source).toBe("empty"); + expect(result.strippedMarkers).toContain("system-reminder"); + }); + + it("strips [SYSTEM DIRECTIVE: ...] prefix lines", () => { + const raw = `[SYSTEM DIRECTIVE: OH-MY-OPENCODE - BOULDER CONTINUATION] + +You have an active work plan with incomplete tasks. +keep working.`; + + const result = extractUserPrompt(raw); + expect(result.text).toContain("You have an active work plan"); + expect(result.text).not.toContain("[SYSTEM DIRECTIVE"); + expect(result.source).toBe("sanitized-text"); + }); + + it("strips OMO checkpoint restore prompt", () => { + const raw = `[restore checkpointed session agent configuration after compaction] +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe(""); + expect(result.source).toBe("empty"); + }); + + it("prefers over ", () => { + const raw = ` +${OMO_START_WORK_MARKER} + + + +the actual prompt +`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe("the actual prompt"); + expect(result.source).toBe("wrapped-user-content"); + }); + + it("preserves inside code fence", () => { + const raw = `here is the OMO template: + +\`\`\`xml + +Session ID: $SESSION_ID + +\`\`\` + +please explain how this works`; + + const result = extractUserPrompt(raw); + expect(result.text).toContain(""); + expect(result.text).toContain("please explain how this works"); + }); + + it("preserves prose + code fence, strips real injected block", () => { + const raw = ` +ignore me + + +here is some code: + +\`\`\`ts +const x = "not real"; +\`\`\` + +end of message`; + + const result = extractUserPrompt(raw); + expect(result.text).toContain("here is some code"); + expect(result.text).toContain("end of message"); + expect(result.text).toContain('const x = "not real"'); + expect(result.text).not.toContain("ignore me"); + }); + + it("strips wrappers from prior injections", () => { + const raw = ` +[SUPERMEMORY] +User Profile: +- Likes brevity + + +actual user message`; + + const result = extractUserPrompt(raw); + expect(result.text).toBe("actual user message"); + expect(result.source).toBe("sanitized-text"); + }); + + it("handles malformed tags conservatively (no catastrophic deletion)", () => { + const raw = ` +no closing tag, this should still work + +normal text after`; + + const result = extractUserPrompt(raw); + // unclosed wrapper falls back to denylist sanitize; user content preserved + expect(result.text).toContain("no closing tag"); + expect(result.text).toContain("normal text after"); + }); + + it("processes ~70KB input quickly and bounds output", () => { + const filler = "lorem ipsum ".repeat(7000); // ~84KB + const raw = `${filler} + + +real ask +`; + + const start = Date.now(); + const result = extractUserPrompt(raw); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(500); + expect(result.text).toBe("real ask"); + expect(result.source).toBe("wrapped-user-content"); + }); +}); + +describe("extractUserPromptFromParts", () => { + it("skips synthetic parts", () => { + const parts: TextPartLike[] = [ + { type: "text", text: "from supermemory injection", synthetic: true }, + { type: "text", text: "actual user input" }, + ]; + + const result = extractUserPromptFromParts(parts); + expect(result.text).toBe("actual user input"); + }); + + it("skips ignored parts", () => { + const parts: TextPartLike[] = [ + { type: "text", text: "irrelevant", ignored: true }, + { type: "text", text: "this is the real one" }, + ]; + + const result = extractUserPromptFromParts(parts); + expect(result.text).toBe("this is the real one"); + }); + + it("ignores non-text parts", () => { + const parts: TextPartLike[] = [ + { type: "tool", text: "tool output" }, + { type: "text", text: "user text" }, + ]; + + const result = extractUserPromptFromParts(parts); + expect(result.text).toBe("user text"); + }); + + it("joins multiple eligible parts and extracts wrappers across them", () => { + const parts: TextPartLike[] = [ + { type: "text", text: "cmd" }, + { type: "text", text: "real prompt" }, + ]; + + const result = extractUserPromptFromParts(parts); + expect(result.text).toBe("real prompt"); + }); + + it("returns empty when all parts filtered or empty", () => { + const parts: TextPartLike[] = [ + { type: "text", text: "synthetic data", synthetic: true }, + { type: "text", text: "", synthetic: false }, + ]; + + const result = extractUserPromptFromParts(parts); + expect(result.text).toBe(""); + expect(result.source).toBe("empty"); + }); + + it("handles undefined parts list", () => { + const result = extractUserPromptFromParts(undefined); + expect(result.text).toBe(""); + expect(result.source).toBe("empty"); + }); +}); + +describe("sanitizeMemoryContextForInjection", () => { + it("removes both OMO start-work-hook trigger markers", () => { + const polluted = `[SUPERMEMORY] +Project Knowledge: +- [100%] Past session contained: + ${OMO_START_WORK_MARKER} + Session ID: foo`; + + const safe = sanitizeMemoryContextForInjection(polluted); + + expect(safe.includes("") && safe.includes(OMO_START_WORK_MARKER)).toBe(false); + }); + + it("removes blocks", () => { + const polluted = `User Profile: +- pref: be concise + + +You are starting a Sisyphus work session. +`; + + const safe = sanitizeMemoryContextForInjection(polluted); + + expect(safe).not.toContain(""); + expect(safe).not.toContain(""); + expect(safe).toContain("be concise"); + }); + + it("removes blocks", () => { + const polluted = ` +# /start-work Command +${OMO_START_WORK_MARKER} + + +normal memo`; + + const safe = sanitizeMemoryContextForInjection(polluted); + + expect(safe).not.toContain(""); + expect(safe).not.toContain(OMO_START_WORK_MARKER); + expect(safe).toContain("normal memo"); + }); + + it("preserves legitimate content outside injection markers", () => { + const polluted = `User Profile: +- Likes typescript +- Project: opencode-supermemory-p +- Build: bun run build`; + + const safe = sanitizeMemoryContextForInjection(polluted); + + expect(safe).toContain("Likes typescript"); + expect(safe).toContain("Project: opencode-supermemory-p"); + expect(safe).toContain("Build: bun run build"); + }); + + it("removes SYSTEM DIRECTIVE prefix lines from memory text", () => { + const polluted = `Project memo: +[SYSTEM DIRECTIVE: OH-MY-OPENCODE - BOULDER CONTINUATION] +- task remaining: x`; + + const safe = sanitizeMemoryContextForInjection(polluted); + expect(safe).not.toContain("[SYSTEM DIRECTIVE"); + expect(safe).toContain("task remaining: x"); + }); + + it("neutralizes lone marker strings even outside tags", () => { + const polluted = `Past summary: ${OMO_START_WORK_MARKER} ran successfully`; + + const safe = sanitizeMemoryContextForInjection(polluted); + expect(safe).not.toContain(OMO_START_WORK_MARKER); + }); +}); From b4c1ba8f2175a61a2fa9e28fcfc1e8770b285800 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:11:32 +0800 Subject: [PATCH 30/37] chore: add purge script for legacy injected memories --- scripts/purge-injected.ts | 280 ++++++++++++++++++++++++++++++ tests/unit/purge-injected.test.ts | 163 +++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 scripts/purge-injected.ts create mode 100644 tests/unit/purge-injected.test.ts diff --git a/scripts/purge-injected.ts b/scripts/purge-injected.ts new file mode 100644 index 0000000..0957f1b --- /dev/null +++ b/scripts/purge-injected.ts @@ -0,0 +1,280 @@ +/** + * Purge memories that were poisoned by other-plugin injections (typically + * oh-my-openagent's ``, ``, + * ``, `[SYSTEM DIRECTIVE: ...]`, or the OMO_INTERNAL_INITIATOR + * marker) before this plugin learned to filter them at capture time. + * + * Run modes: + * bun run scripts/purge-injected.ts # dry-run (default) + * bun run scripts/purge-injected.ts --confirm # actually delete + * bun run scripts/purge-injected.ts --api-key ... # override env + * bun run scripts/purge-injected.ts --base-url ... # override default + * + * The script re-uses the same pattern set as + * `sanitizeMemoryContextForInjection` so the dry-run preview matches what the + * runtime sanitizer would strip. + * + * Pure functions (`findPolluted`, `listAllMemories`, `deleteMemories`) are + * exported and unit-tested independently of HTTP and stdout. The `runPurge` + * function and the entry guard at the bottom orchestrate them. + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { stripJsoncComments } from "@/shared/jsonc"; + +export const POLLUTION_PATTERNS: ReadonlyArray = [ + //i, + //i, + //i, + //i, + //i, + //i, + /\[SYSTEM DIRECTIVE:/i, + /\[restore checkpointed session/i, + //i, + /You are starting a Sisyphus work session\./, +]; + +export interface MemoryRecord { + id: string; + summary?: string | null; + content?: string | null; + title?: string | null; + containerTags?: string[]; + createdAt?: string; + metadata?: Record; +} + +export interface PollutionMatch { + id: string; + pattern: string; + preview: string; + containerTags: string[]; + createdAt?: string; +} + +export interface PurgeApi { + listPage(page: number, limit: number): Promise<{ memories: MemoryRecord[]; pagination?: { currentPage: number; totalPages: number } }>; + deleteMemory(id: string): Promise<{ ok: boolean; status: number }>; +} + +export interface DeleteResult { + deleted: string[]; + failed: Array<{ id: string; status: number }>; +} + +/** + * Walk every memory and return matches against `POLLUTION_PATTERNS`. + * Stops at the FIRST pattern hit per memory so the count reflects unique + * polluted records, not pattern occurrences. + */ +export function findPolluted(memories: readonly MemoryRecord[], patterns: ReadonlyArray = POLLUTION_PATTERNS): PollutionMatch[] { + const matches: PollutionMatch[] = []; + for (const memory of memories) { + const text = [memory.summary, memory.content, memory.title].filter(Boolean).join("\n"); + if (!text) continue; + for (const pattern of patterns) { + if (pattern.test(text)) { + matches.push({ + id: memory.id, + pattern: pattern.source, + preview: text.slice(0, 200), + containerTags: memory.containerTags ?? [], + createdAt: memory.createdAt, + }); + break; + } + } + } + return matches; +} + +/** + * Paginate `api.listPage` until exhausted. Defensive against: + * - APIs that ignore the `page` parameter (returns same data; we detect + * by counting added entries and stop when none are new). + * - Runaway pagination metadata (we cap at `safetyLimit`). + * + * Dedups by `id` across pages. + */ +export async function listAllMemories(api: PurgeApi, options: { pageSize?: number; safetyLimit?: number } = {}): Promise { + const pageSize = options.pageSize ?? 100; + const safetyLimit = options.safetyLimit ?? 200; + const all = new Map(); + let page = 1; + + while (page <= safetyLimit) { + const response = await api.listPage(page, pageSize); + if (!response.memories || response.memories.length === 0) break; + + let added = 0; + for (const memory of response.memories) { + if (!all.has(memory.id)) { + all.set(memory.id, memory); + added += 1; + } + } + + if (added === 0) break; + + const pagination = response.pagination; + if (!pagination || pagination.currentPage >= pagination.totalPages) break; + + page += 1; + } + + return [...all.values()]; +} + +/** + * Sequentially delete each id and partition by HTTP success. + * Sequential (not parallel) to keep API rate-limit pressure predictable. + */ +export async function deleteMemories(api: PurgeApi, ids: readonly string[]): Promise { + const result: DeleteResult = { deleted: [], failed: [] }; + for (const id of ids) { + const response = await api.deleteMemory(id); + if (response.ok) { + result.deleted.push(id); + } else { + result.failed.push({ id, status: response.status }); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// CLI orchestration (not unit-tested directly; runs against real Supermemory) +// --------------------------------------------------------------------------- + +const DEFAULT_BASE_URL = "https://api.supermemory.ai/v3"; + +interface CliOptions { + apiKey: string; + baseUrl: string; + confirm: boolean; +} + +function parseArgs(argv: readonly string[]): CliOptions { + let apiKey = process.env.SUPERMEMORY_API_KEY ?? ""; + let baseUrl = DEFAULT_BASE_URL; + let confirm = false; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--api-key") apiKey = argv[++i] ?? ""; + else if (arg === "--base-url") baseUrl = argv[++i] ?? baseUrl; + else if (arg === "--confirm") confirm = true; + } + + if (!apiKey) { + apiKey = readApiKeyFromConfig() ?? ""; + } + + return { apiKey, baseUrl, confirm }; +} + +function readApiKeyFromConfig(): string | null { + const candidates = [ + join(homedir(), ".config", "opencode", "supermemory-p.jsonc"), + join(homedir(), ".config", "opencode", "supermemory.jsonc"), + ]; + for (const path of candidates) { + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(stripJsoncComments(raw)) as { apiKey?: string }; + if (parsed.apiKey) return parsed.apiKey; + } catch { + // ignore — try next candidate + } + } + return null; +} + +function makeHttpApi(baseUrl: string, apiKey: string): PurgeApi { + const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }; + + return { + async listPage(page, limit) { + const response = await fetch(`${baseUrl}/memories/list`, { + method: "POST", + headers, + body: JSON.stringify({ page, limit }), + }); + if (!response.ok) { + throw new Error(`list page=${page} failed: ${response.status} ${await response.text()}`); + } + return (await response.json()) as Awaited>; + }, + + async deleteMemory(id) { + const response = await fetch(`${baseUrl}/memories/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${apiKey}` }, + }); + return { ok: response.ok, status: response.status }; + }, + }; +} + +export async function runPurge( + api: PurgeApi, + options: { confirm: boolean }, + log: (line: string) => void = console.log, +): Promise { + log("Listing memories…"); + const memories = await listAllMemories(api); + log(`Total: ${memories.length}`); + + const polluted = findPolluted(memories); + log(`Polluted: ${polluted.length} (${memories.length === 0 ? 0 : ((polluted.length / memories.length) * 100).toFixed(1)}%)`); + + if (polluted.length === 0) { + log("Nothing to purge."); + return null; + } + + log(""); + log("Sample (up to 5):"); + const sampled = new Set(); + for (const match of polluted) { + const sig = match.preview.slice(0, 60); + if (sampled.has(sig)) continue; + sampled.add(sig); + log(` [${match.id}] (${match.pattern}) ${match.preview.replace(/\s+/g, " ")}…`); + if (sampled.size >= 5) break; + } + + if (!options.confirm) { + log(""); + log("Dry-run only. Re-run with --confirm to delete."); + return null; + } + + log(""); + log(`Deleting ${polluted.length} memories…`); + const result = await deleteMemories( + api, + polluted.map((p) => p.id), + ); + log(`Deleted: ${result.deleted.length}`); + if (result.failed.length > 0) log(`Failed: ${result.failed.length}`); + return result; +} + +const isCliEntry = import.meta.url === `file://${process.argv[1]}`; +if (isCliEntry) { + const options = parseArgs(process.argv.slice(2)); + if (!options.apiKey) { + console.error("ERROR: SUPERMEMORY_API_KEY not set and no apiKey found in ~/.config/opencode/supermemory-p.jsonc."); + process.exit(1); + } + const api = makeHttpApi(options.baseUrl, options.apiKey); + runPurge(api, { confirm: options.confirm }).catch((error) => { + console.error(`purge failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/tests/unit/purge-injected.test.ts b/tests/unit/purge-injected.test.ts new file mode 100644 index 0000000..b31e61d --- /dev/null +++ b/tests/unit/purge-injected.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { + deleteMemories, + findPolluted, + listAllMemories, + type MemoryRecord, + POLLUTION_PATTERNS, + type PurgeApi, +} from "../../scripts/purge-injected"; + +describe("findPolluted", () => { + it("detects every OMO marker pattern", () => { + const samples: MemoryRecord[] = [ + { id: "a", content: "x" }, + { id: "b", summary: "y" }, + { id: "c", content: "[user] z" }, + { id: "d", content: "..." }, + { id: "e", content: "real" }, + { id: "f", content: "do x" }, + { id: "g", content: "[SYSTEM DIRECTIVE: OH-MY-OPENCODE - X]" }, + { id: "h", content: "[restore checkpointed session agent configuration after compaction]" }, + { id: "i", content: "" }, + { id: "j", content: "You are starting a Sisyphus work session." }, + ]; + + const matches = findPolluted(samples); + + expect(matches).toHaveLength(samples.length); + expect(matches.map((m) => m.id).sort()).toEqual(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]); + }); + + it("ignores memories with no markers", () => { + const clean: MemoryRecord[] = [ + { id: "x", content: "User prefers concise responses" }, + { id: "y", summary: "Project uses Bun for testing" }, + { id: "z", title: "API reference" }, + ]; + + expect(findPolluted(clean)).toHaveLength(0); + }); + + it("handles memories missing all text fields", () => { + const empty: MemoryRecord[] = [{ id: "blank" }, { id: "null", content: null, summary: null, title: null }]; + + expect(findPolluted(empty)).toHaveLength(0); + }); + + it("captures preview, tags, createdAt for surfacing in dry-run", () => { + const memory: MemoryRecord = { + id: "preview_one", + content: "[user] SINGLE TASK ONLY", + containerTags: ["opencode_project_xxx"], + createdAt: "2026-05-13T20:31:00Z", + }; + + const [match] = findPolluted([memory]); + expect(match?.id).toBe("preview_one"); + expect(match?.pattern).toBe(POLLUTION_PATTERNS.find((p) => p.source.includes("system-reminder"))?.source); + expect(match?.preview).toContain("SINGLE TASK ONLY"); + expect(match?.containerTags).toEqual(["opencode_project_xxx"]); + expect(match?.createdAt).toBe("2026-05-13T20:31:00Z"); + }); + + it("stops at first matching pattern (no double-count)", () => { + const multi: MemoryRecord = { + id: "multi", + content: "xy", + }; + + const matches = findPolluted([multi]); + expect(matches).toHaveLength(1); + }); +}); + +describe("listAllMemories", () => { + function makeApi(pages: Array<{ memories: MemoryRecord[]; pagination?: { currentPage: number; totalPages: number } }>): PurgeApi { + let callCount = 0; + return { + listPage: mock(async (_page: number) => { + const result = pages[callCount] ?? { memories: [] }; + callCount += 1; + return result; + }), + deleteMemory: mock(async () => ({ ok: true, status: 204 })), + }; + } + + it("walks all pages and dedups by id", async () => { + const api = makeApi([ + { memories: [{ id: "1" }, { id: "2" }], pagination: { currentPage: 1, totalPages: 2 } }, + { memories: [{ id: "3" }], pagination: { currentPage: 2, totalPages: 2 } }, + ]); + + const all = await listAllMemories(api); + expect(all.map((m) => m.id).sort()).toEqual(["1", "2", "3"]); + }); + + it("stops when a page returns zero new entries (defensive against broken pagination)", async () => { + const api = makeApi([ + { memories: [{ id: "1" }], pagination: { currentPage: 1, totalPages: 99 } }, + { memories: [{ id: "1" }], pagination: { currentPage: 1, totalPages: 99 } }, + ]); + + const all = await listAllMemories(api, { safetyLimit: 10 }); + expect(all).toHaveLength(1); + }); + + it("respects safetyLimit", async () => { + const api = makeApi( + Array.from({ length: 50 }, (_, i) => ({ memories: [{ id: String(i) }], pagination: { currentPage: i + 1, totalPages: 100 } })), + ); + + const all = await listAllMemories(api, { safetyLimit: 3 }); + expect(all.length).toBeLessThanOrEqual(3); + }); + + it("returns empty when first page is empty", async () => { + const api = makeApi([{ memories: [] }]); + + expect(await listAllMemories(api)).toEqual([]); + }); +}); + +describe("deleteMemories", () => { + it("partitions deleted vs failed by status", async () => { + const api: PurgeApi = { + listPage: mock(async () => ({ memories: [] })), + deleteMemory: mock(async (id: string) => { + if (id === "fail") return { ok: false, status: 500 }; + return { ok: true, status: 204 }; + }), + }; + + const result = await deleteMemories(api, ["a", "fail", "b"]); + expect(result.deleted).toEqual(["a", "b"]); + expect(result.failed).toEqual([{ id: "fail", status: 500 }]); + }); + + it("calls deleteMemory once per id", async () => { + const deleteMemory = mock(async () => ({ ok: true, status: 204 })); + const api: PurgeApi = { + listPage: mock(async () => ({ memories: [] })), + deleteMemory, + }; + + await deleteMemories(api, ["a", "b", "c"]); + expect(deleteMemory).toHaveBeenCalledTimes(3); + }); + + it("returns empty result for empty input", async () => { + const deleteMemory = mock(async () => ({ ok: true, status: 204 })); + const api: PurgeApi = { + listPage: mock(async () => ({ memories: [] })), + deleteMemory, + }; + + const result = await deleteMemories(api, []); + expect(result.deleted).toEqual([]); + expect(result.failed).toEqual([]); + expect(deleteMemory).toHaveBeenCalledTimes(0); + }); +}); From 10c5bbe75be13f2e25bb2d2bc46a03e65210f460 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:11:39 +0800 Subject: [PATCH 31/37] test: cover regression where injected markers force agent switch --- tests/integration/atlas-regression.test.ts | 226 +++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/integration/atlas-regression.test.ts diff --git a/tests/integration/atlas-regression.test.ts b/tests/integration/atlas-regression.test.ts new file mode 100644 index 0000000..a10b5fb --- /dev/null +++ b/tests/integration/atlas-regression.test.ts @@ -0,0 +1,226 @@ +/** + * Regression: prove that supermemory-p cannot trigger oh-my-openagent's + * `start-work-hook` into forcing the Atlas agent. + * + * That hook fires when ANY text part in a `chat.message` contains BOTH: + * - `` (any opening tag) + * - `You are starting a Sisyphus work session.` (the literal canary string) + * + * Pre-fix, supermemory-p would: + * 1. Store the user's slash-command-expanded text (containing both markers) + * into long-term memory as if it were user input. + * 2. Re-inject that polluted memory into future sessions' `chat.message` + * parts, re-triggering the OMO hook on every message. + * + * This test wires the real `handleChatMessage` against a fake client that + * returns polluted memory and asserts: + * - The extracted user prompt is the actual `` content, not + * the OMO template body. + * - Search queries / keyword detection see ONLY user text. + * - The injected supermemory part never contains both OMO trigger markers + * simultaneously. + * - The injected part is wrapped in `` and marked + * `synthetic: true` so other plugins (and we ourselves) can identify it. + */ + +import { describe, expect, it, mock } from "bun:test"; + +import type { Part } from "@opencode-ai/sdk"; + +import { handleChatMessage } from "@/chat/handler"; +import { DEFAULTS } from "@/config/defaults"; +import { SupermemoryConfigSchema } from "@/config/schema"; +import { createSessionState } from "@/session/state"; + +const OMO_START_WORK_MARKER = "You are starting a Sisyphus work session."; + +const OMO_EXPANDED_PROMPT = ` +${OMO_START_WORK_MARKER} + +## ARGUMENTS +- \`/start-work [plan-name] [--worktree ]\` + +## WHAT TO DO +1. Find available plans... + + + +Session ID: ses_regression +Timestamp: 2026-05-14T00:00:00Z + + + +implement the OAuth callback +`; + +const POLLUTED_MEMORY = `[user] +[SYSTEM DIRECTIVE: OH-MY-OPENCODE - SINGLE TASK ONLY] + +You are starting a Sisyphus work session. + +Session ID: ses_old +`; + +function makeConfig() { + return SupermemoryConfigSchema.parse({ + ...DEFAULTS, + projectContainerTag: "project-tag", + }); +} + +function makeFakeClient(opts: { profileFacts?: string[]; userMemoryText?: string; projectMemoryText?: string } = {}) { + const calls = { + getProfile: [] as Array<{ tag: string; query?: string }>, + searchMemories: [] as Array<{ query: string; tag: string | string[] }>, + listMemories: [] as Array<{ tag: string; limit?: number }>, + }; + + return { + calls, + client: { + getProfile: mock(async (containerTag: string, query?: string) => { + calls.getProfile.push({ tag: containerTag, query }); + return { + success: true as const, + profile: { + static: opts.profileFacts ?? ["Prefers concise responses."], + dynamic: [], + }, + }; + }), + searchMemories: mock(async (query: string, tag: string | string[]) => { + calls.searchMemories.push({ query, tag }); + return { + success: true as const, + results: [ + { + similarity: 0.95, + memory: opts.userMemoryText ?? POLLUTED_MEMORY, + chunk: opts.userMemoryText ?? POLLUTED_MEMORY, + }, + ], + }; + }), + listMemories: mock(async (containerTag: string, limit?: number) => { + calls.listMemories.push({ tag: containerTag, limit }); + return { + success: true as const, + memories: [ + { + id: "mem_polluted_project", + summary: opts.projectMemoryText ?? POLLUTED_MEMORY, + content: opts.projectMemoryText ?? POLLUTED_MEMORY, + }, + ], + }; + }), + }, + }; +} + +function makeDeps(client: ReturnType["client"]) { + return { + client, + config: makeConfig(), + tags: { user: "user-tag", project: "project-tag" }, + injectedSessions: createSessionState(), + log: () => undefined, + isConfigured: () => true, + }; +} + +describe("atlas regression: supermemory-p + oh-my-openagent coexistence", () => { + it("extracts only the body, not the OMO template", async () => { + const { client, calls } = makeFakeClient(); + const deps = makeDeps(client); + const output = { + message: { id: "msg_user" }, + parts: [{ id: "p_user", sessionID: "ses_regression", messageID: "msg_user", type: "text", text: OMO_EXPANDED_PROMPT } as Part], + }; + + await handleChatMessage({ sessionID: "ses_regression" }, output, deps); + + expect(calls.searchMemories[0]?.query).toBe("implement the OAuth callback"); + expect(calls.getProfile[0]?.query).toBe("implement the OAuth callback"); + expect(calls.searchMemories[0]?.query.includes(OMO_START_WORK_MARKER)).toBe(false); + expect(calls.searchMemories[0]?.query.includes("")).toBe(false); + }); + + it("never injects a part that contains BOTH OMO start-work-hook markers simultaneously", async () => { + const { client } = makeFakeClient({ + userMemoryText: POLLUTED_MEMORY, + projectMemoryText: POLLUTED_MEMORY, + profileFacts: [`Past session: ${OMO_START_WORK_MARKER}`], + }); + const deps = makeDeps(client); + const output = { + message: { id: "msg_user" }, + parts: [{ id: "p_user", sessionID: "ses_regression", messageID: "msg_user", type: "text", text: OMO_EXPANDED_PROMPT } as Part], + }; + + await handleChatMessage({ sessionID: "ses_regression" }, output, deps); + + // We don't sanitize the user's own input — if they invoked `/start-work`, + // OMO is supposed to route them to atlas. What we MUST guarantee is that + // supermemory's OWN injected parts (synthetic) never carry both markers. + const supermemoryParts = output.parts.filter((p) => p.type === "text" && p.synthetic === true); + expect(supermemoryParts.length).toBeGreaterThan(0); + for (const part of supermemoryParts) { + if (part.type !== "text") continue; + const text = part.text; + const hasSessionContext = //i.test(text); + const hasMarker = text.includes(OMO_START_WORK_MARKER); + expect(hasSessionContext && hasMarker).toBe(false); + } + }); + + it("wraps every supermemory injection in and marks it synthetic", async () => { + const { client } = makeFakeClient(); + const deps = makeDeps(client); + const output = { + message: { id: "msg_user" }, + parts: [{ id: "p_user", sessionID: "ses_regression", messageID: "msg_user", type: "text", text: OMO_EXPANDED_PROMPT } as Part], + }; + + await handleChatMessage({ sessionID: "ses_regression" }, output, deps); + + const injected = output.parts.find((p) => p.type === "text" && p.synthetic === true); + expect(injected).toBeDefined(); + expect(injected?.type === "text" && injected.text.startsWith("")).toBe(true); + expect(injected?.type === "text" && injected.text.endsWith("")).toBe(true); + }); + + it("supermemory injection is filtered out by extractor on the NEXT turn (no self-trigger loop)", async () => { + const { client } = makeFakeClient(); + const deps = makeDeps(client); + + // First turn: user sends OMO-expanded prompt, supermemory injects its memory. + const firstOutput = { + message: { id: "msg_first" }, + parts: [{ id: "p_first", sessionID: "ses_regression", messageID: "msg_first", type: "text", text: OMO_EXPANDED_PROMPT } as Part], + }; + await handleChatMessage({ sessionID: "ses_regression" }, firstOutput, deps); + + const injected = firstOutput.parts.find((p) => p.type === "text" && p.synthetic === true); + expect(injected).toBeDefined(); + + // Second turn: imagine the next message includes the same supermemory-context + // synthetic part (carried forward as conversation history) plus a new user + // turn. The extractor MUST ignore the synthetic part and treat only the new + // user message as input. + const secondOutput = { + message: { id: "msg_second" }, + parts: [ + injected as Part, + { id: "p_second", sessionID: "ses_regression", messageID: "msg_second", type: "text", text: "follow-up question" } as Part, + ], + }; + + const { client: client2, calls } = makeFakeClient(); + const deps2 = makeDeps(client2); + await handleChatMessage({ sessionID: "ses_second" }, secondOutput, deps2); + + expect(calls.searchMemories[0]?.query).toBe("follow-up question"); + expect(calls.searchMemories[0]?.query.includes(OMO_START_WORK_MARKER)).toBe(false); + }); +}); From 0e1f221380285061032d5842d7198bc6bc566ced Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:19:20 +0800 Subject: [PATCH 32/37] docs: note injection filtering and purge script --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 5a50933..51f60ae 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Your agent remembers what you tell it — across sessions, across projects. - [Usage with Oh My OpenAgent](#usage-with-oh-my-openagent) - [Development](#development) - [Logs](#logs) +- [Maintenance](#maintenance) - [License](#license) ## Installation @@ -453,6 +454,8 @@ Add to `~/.config/opencode/oh-my-openagent.json`: } ``` +Plugin-injected content (slash-command expansions, system reminders, session-context blocks) is filtered out at capture time. Memories ingested with this plugin enabled will not contain other plugins' markers, so no manual cleanup is needed for new sessions. To clean up memories captured before this filtering existed, see [Maintenance](#maintenance). + ## Development ```bash @@ -477,6 +480,26 @@ Local install: tail -f ~/.local/share/opencode-supermemory-p/log/main.log ``` +## Maintenance + +### Purge legacy plugin-injected memories + +Memories captured by earlier versions of this plugin may contain content injected by other plugins (slash-command expansions, system reminders, etc.). The `purge-injected` script scans the Supermemory backend and removes them. + +Dry-run (default — lists matches without deleting): + +```bash +bun run scripts/purge-injected.ts +``` + +Confirm deletion: + +```bash +bun run scripts/purge-injected.ts --confirm +``` + +The script reads `SUPERMEMORY_API_KEY` from the environment or `~/.config/opencode/supermemory-p.jsonc`. Override with `--api-key sm_...` or `--base-url https://...` if needed. + ## License MIT From 6deb6b9a85bf784fa610f0a187d07eaba133ea70 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:22:25 +0800 Subject: [PATCH 33/37] docs: credit upstream and community plugins --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 51f60ae..f7c05fa 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Your agent remembers what you tell it — across sessions, across projects. - [Development](#development) - [Logs](#logs) - [Maintenance](#maintenance) +- [Acknowledgements](#acknowledgements) - [License](#license) ## Installation @@ -500,6 +501,17 @@ bun run scripts/purge-injected.ts --confirm The script reads `SUPERMEMORY_API_KEY` from the environment or `~/.config/opencode/supermemory-p.jsonc`. Override with `--api-key sm_...` or `--base-url https://...` if needed. +## Acknowledgements + +This plugin builds on the work of the Supermemory team and the wider community of plugin authors. In particular: + +- [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) — the upstream OpenCode plugin this fork is based on. +- [claude-supermemory](https://github.com/supermemoryai/claude-supermemory) — source of the entity-context, dedup, and signal-extraction patterns. +- [openclaw-supermemory](https://github.com/supermemoryai/openclaw-supermemory) — source of the incremental-capture, metadata-stripping, and compaction-hook designs. +- [opencode-supermemory-max](https://github.com/kandotrun/opencode-supermemory-max) — community fork that informed several of the feature consolidations here. + +Thanks to everyone who shipped these projects. + ## License MIT From 2661c474c5855377ebe18bc357c3cc910f0823eb Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 06:52:03 +0800 Subject: [PATCH 34/37] refactor: skip whole message when scaffolding markers are present --- scripts/purge-injected.ts | 19 +- src/capture/incremental.ts | 2 +- src/chat/handler.ts | 5 +- src/compaction/index.ts | 4 + src/compaction/pre-save.ts | 1 + src/shared/user-prompt.ts | 389 ++++++--------------- src/signal/extract.ts | 14 +- tests/unit/incremental-capture.test.ts | 30 ++ tests/unit/pre-compaction-save.test.ts | 22 ++ tests/unit/signal-extract.test.ts | 43 ++- tests/unit/user-prompt.test.ts | 453 ++++++------------------- 11 files changed, 342 insertions(+), 640 deletions(-) diff --git a/scripts/purge-injected.ts b/scripts/purge-injected.ts index 0957f1b..24e54ba 100644 --- a/scripts/purge-injected.ts +++ b/scripts/purge-injected.ts @@ -26,16 +26,23 @@ import { join } from "node:path"; import { stripJsoncComments } from "@/shared/jsonc"; export const POLLUTION_PATTERNS: ReadonlyArray = [ - //i, - //i, - //i, - //i, - //i, - //i, + // Plugin block wrappers (OMO and similar) + /<(?:auto-slash-command|command-instruction|session-context|system-reminder|supermemory-context|user-request|user-task)\b/i, + // Sisyphus orchestrator scaffold wrapper + //i, + // Sisyphus task briefs / plan management / mode indicators + /^[\t ]*## F\d+:/m, + /^[\t ]*## Auto-Selected Plan[\t ]*$/m, + /^[\t ]*## Plan Not Found\b/m, + /^[\t ]*boulder\.json has been created\./m, + /^[\t ]*\[(?:analyze|search|deep|ultrawork|ultrabrain|artistry|writing|quick|visual-engineering|unspecified-(?:low|high))-mode\][\t ]*$/m, + // Standalone scaffolding signature strings /You are starting a Sisyphus work session\./, + /MANDATORY delegate_task params:/, ]; export interface MemoryRecord { diff --git a/src/capture/incremental.ts b/src/capture/incremental.ts index a50b825..ca31bc7 100644 --- a/src/capture/incremental.ts +++ b/src/capture/incremental.ts @@ -40,7 +40,7 @@ export async function handleMessageUpdatedForCapture(input: EventMessageUpdated, const allParts = info.parts ?? []; const boundary = createPromptBoundary(allParts, { sessionID, role: "assistant" }); - if (!boundary.userText) return; + if (boundary.isPolluted || !boundary.userText) return; const cleanPart: MessagePart = { type: "text", text: boundary.userText }; const message: Message = { diff --git a/src/chat/handler.ts b/src/chat/handler.ts index 90c3511..9b4737b 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -107,15 +107,14 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan const userMessage = boundary.userText; if (!userMessage) { - deps.log("chat.message: empty message, skipping", { source: boundary.source }); + deps.log("chat.message: empty message, skipping", { isPolluted: boundary.isPolluted }); return; } deps.log("chat.message: processing", { messagePreview: userMessage.slice(0, 100), partsCount: output.parts.length, - source: boundary.source, - strippedMarkers: boundary.strippedMarkers, + isPolluted: boundary.isPolluted, }); if (detectMemoryKeyword(userMessage, deps.config)) { diff --git a/src/compaction/index.ts b/src/compaction/index.ts index 8af654a..6eef92a 100644 --- a/src/compaction/index.ts +++ b/src/compaction/index.ts @@ -3,6 +3,7 @@ import { getLogger } from "@logtape/logtape"; import { type CompactionState, createCompactionState } from "@/compaction/state"; import { computeShouldCompact, DEFAULT_CONTEXT_LIMIT, DEFAULT_THRESHOLD, type TokenInfo } from "@/compaction/threshold"; import { supermemoryClient } from "@/memory/client"; +import { isPolluted } from "@/shared/user-prompt"; const logger = getLogger(["supermemory", "compaction"]); @@ -160,6 +161,9 @@ export async function performCompaction(deps: HookDeps, sessionID: string, lastA async function saveSummaryAsMemory(deps: HookDeps, sessionID: string, summaryContent: string): Promise { if (!summaryContent || summaryContent.length < 100) return logger.info("[compaction] summary too short to save", { sessionID, length: summaryContent.length }); + if (isPolluted(summaryContent)) { + return logger.info("[compaction] summary contains plugin scaffolding, skipping save", { sessionID }); + } try { const result = await supermemoryClient.addMemory(`[Session Summary]\n${summaryContent}`, deps.tags.project, { type: "conversation", diff --git a/src/compaction/pre-save.ts b/src/compaction/pre-save.ts index 0d5042a..ccf521f 100644 --- a/src/compaction/pre-save.ts +++ b/src/compaction/pre-save.ts @@ -74,5 +74,6 @@ function formatMessageTurn(message: Message): string[] { if (message.role !== "user" && message.role !== "assistant") return []; const boundary = createPromptBoundary(message.parts, { sessionID: message.sessionID, role: message.role }); + if (boundary.isPolluted) return []; return boundary.userText.length > 0 ? [`[${message.role}] ${boundary.userText}`] : []; } diff --git a/src/shared/user-prompt.ts b/src/shared/user-prompt.ts index 4dc746d..c074e42 100644 --- a/src/shared/user-prompt.ts +++ b/src/shared/user-prompt.ts @@ -1,91 +1,30 @@ /** - * User prompt boundary: a single source of truth for "what did the user - * actually type" in a chat message. + * Prompt boundary: the contract between OpenCode's mutable `parts` bus and + * supermemory-p's domain logic. Every hook that needs to know "what did the + * user actually say" consumes a `PromptBoundary` rather than parsing parts + * directly. * - * OpenCode's `output.parts` is a shared, mutable prompt bus: it carries the - * user's input, slash-command expansions, system reminders, recovery prompts, - * memory injections, and more. Many of those entries are produced by other - * plugins (notably oh-my-openagent / OMO) and intentionally LOOK like user - * input because they overwrite the user's part text directly. They are not - * marked `synthetic: true`. + * Design principle — `polluted-skip`: + * Either a message is clean and we treat it as user input verbatim, or it + * contains ANY plugin/orchestrator scaffolding marker and we skip it + * ENTIRELY from the capture stream. We do not try to surgically strip out + * parts of a message — that path leads to fragile regex chains chasing + * every new marker some upstream framework introduces. * - * If supermemory treats every text part as user input, three things break: - * 1. Searches/profile queries get polluted by plugin templates. - * 2. Long-term memory ingests OMO marker strings (e.g. the start-work - * template) — turning every future session into a context-injection - * trojan that re-triggers OMO's start-work-hook into forcing the - * `atlas` agent. - * 3. Keyword detection fires on plugin reminders, not user intent. + * The boundary surface is two pieces of information: + * - `userText`: best-effort extraction of the user's typed input. For + * messages wrapped in `` or `` (slash-command + * expansion), we pull the inner body. Otherwise we return the raw + * concatenated text. Use this for SEARCH and KEYWORD DETECTION. + * - `isPolluted`: true when the raw text matches ANY pollution pattern. + * Use this to GATE CAPTURE. Polluted messages must never enter + * long-term memory. * - * This module is the boundary. Every place that consumes "user text" from - * `parts` MUST route through `extractUserPromptFromParts` (or the lower-level - * `extractUserPrompt`). Every place that injects supermemory's own context - * back into a chat message MUST sanitize it through - * `sanitizeMemoryContextForInjection` first. - * - * Strategy (per Oracle review): - * 1. Honor OpenCode part flags: skip `synthetic === true` and - * `ignored === true` parts. - * 2. Code fences (triple-backtick blocks) are sacrosanct — we never alter - * content inside them. This lets users paste source code (including - * OMO templates we are debugging right now) without lossy filtering. - * 3. In prose, prefer explicit user-content wrappers: `` and - * `` are how OMO wraps the actual user arguments inside a - * slash-command expansion. If present, extract them. - * 4. Fall back to a denylist removal of known plugin-injected block tags - * (``, ``, ``, - * ``, ``) plus line-prefix - * directives (`[SYSTEM DIRECTIVE: ...]`, the OMO checkpoint restore - * prompt, the OMO_INTERNAL_INITIATOR marker). - * 5. Conservative on malformed input — leave user content intact rather - * than risk catastrophic deletion. Orphan opening/closing tags are not - * stripped from `extractUserPrompt`; we trust that the user's text is - * more important than perfect cleanup. - * - * `sanitizeMemoryContextForInjection` is more aggressive: when we are about - * to inject memory text BACK into a chat message, we cannot afford any - * leftover wrapper or trigger phrase, because OMO and friends will read the - * combined parts and react to anything that looks like their own marker. - * Memory is structured data we produce — there is no user-typed code fence - * to preserve, so we strip everything known to be dangerous, including bare - * trigger phrases like the start-work-hook canary string. + * `POLLUTION_PATTERNS` is the single source of truth used by both the + * runtime hooks and the standalone `scripts/purge-injected.ts` script. + * Add new markers here. */ -const USER_WRAPPER_PATTERN = /<(user-request|user-task)>([\s\S]*?)<\/\1>/gi; - -// Block-level wrappers OMO and friends inject around their own content. The -// supermemory-context entry exists so future supermemory injections can be -// stripped cleanly if they re-enter the parts stream (e.g. via conversation -// history fed back through SDK calls). -const BLOCK_TAGS_TO_STRIP = [ - "auto-slash-command", - "command-instruction", - "session-context", - "system-reminder", - "supermemory-context", -] as const; - -// Tags that may appear in memory text as an orphan opener/closer after -// chunking. Includes the user wrappers — memory has no business carrying an -// active tag; if it does, the memory was captured from a -// previously polluted session. -const ORPHAN_TAG_PATTERN = - /<\/?(?:auto-slash-command|command-instruction|session-context|system-reminder|supermemory-context|user-request|user-task)[^>]*>/gi; - -const LINE_PREFIXES_TO_STRIP: RegExp[] = [ - /^[\t ]*\[SYSTEM DIRECTIVE:[^\]]*\].*$/gm, - /^[\t ]*\[restore checkpointed session agent configuration after compaction\][\t ]*$/gm, - /^[\t ]*[\t ]*$/gm, -]; - -// Standalone trigger phrases that OMO hooks key off without any surrounding -// tag. Currently the start-work-hook checks for this canary string. -const STANDALONE_TRIGGER_PHRASES: RegExp[] = [/You are starting a Sisyphus work session\./g]; - -const CODE_FENCE_PATTERN = /```[\s\S]*?```/g; - -export type SourceKind = "wrapped-user-content" | "sanitized-text" | "raw-text" | "empty"; - export type MessageRole = "user" | "assistant"; export interface TextPartLike { @@ -95,39 +34,30 @@ export interface TextPartLike { ignored?: boolean; } -/** - * Result of extracting the user-typed portion of one or more text parts. - * Kept as a structured value (vs. a bare string) so downstream callers can - * carry provenance information (source, stripped markers) into logs, memory - * records, and tests without re-running the extractor. - */ -export interface ExtractedUserPrompt { - /** The extracted user text, or "" if nothing remained after sanitization. */ - text: string; - /** How the text was obtained — useful for logging and tests. */ - source: SourceKind; - /** Names of markers that were detected/removed; informational only. */ - strippedMarkers: string[]; -} - -/** - * The contract between OpenCode's mutable `parts` bus and supermemory-p's - * domain logic. Every hook that needs to know "what did the user actually - * say" should consume a `PromptBoundary` rather than parsing parts directly. - * - * - `rawText` preserves the concatenated text of all eligible (non-synthetic, - * non-ignored) parts before any extraction. Useful for debugging when - * extraction is unexpectedly empty. - * - `userText` is the post-extraction value, alias of `text` for clarity at - * call sites. - * - `sessionID` / `role` are optional metadata the caller can attach so logs - * and memory provenance carry through without re-deriving them. - */ -export interface PromptBoundary extends ExtractedUserPrompt { - /** Concatenated text of eligible parts before extraction. */ +export interface PromptBoundary { + /** Concatenated text of eligible (non-synthetic, non-ignored) parts. */ rawText: string; - /** Alias of `text`. */ + /** + * Best-effort user-typed content extracted from `rawText`. For + * slash-command expansions wrapped in `` / ``, + * this is the inner body; otherwise it is `rawText` trimmed. + * + * Always safe to use as a search query — does NOT imply the message is + * clean enough to capture. Check `isPolluted` for that. + */ userText: string; + /** Alias of `userText`. */ + text: string; + /** + * True when the raw message contains plugin/orchestrator scaffolding + * (OMO injections, Sisyphus orchestrator scaffolds, supermemory's own + * injections, slash-command wrappers, system directives, etc.). + * + * Capture-path callers MUST skip polluted messages entirely. Query-path + * callers can still use `userText` — it represents what the user + * meaningfully typed, even when wrapped. + */ + isPolluted: boolean; /** OpenCode session this boundary belongs to, if known. */ sessionID?: string; /** Role of the message this boundary was extracted from, if known. */ @@ -139,199 +69,102 @@ export interface PromptBoundaryContext { role?: MessageRole; } -interface Segment { - type: "code" | "prose"; - content: string; -} - -function splitFencedCode(text: string): Segment[] { - const segments: Segment[] = []; - let cursor = 0; - for (const match of text.matchAll(CODE_FENCE_PATTERN)) { - const idx = match.index ?? 0; - if (idx > cursor) { - segments.push({ type: "prose", content: text.slice(cursor, idx) }); - } - segments.push({ type: "code", content: match[0] }); - cursor = idx + match[0].length; - } - if (cursor < text.length) { - segments.push({ type: "prose", content: text.slice(cursor) }); - } - return segments; -} - -function extractWrappedUserContent(prose: string): string[] { - const contents: string[] = []; - for (const match of prose.matchAll(USER_WRAPPER_PATTERN)) { - const body = match[2]; - if (typeof body === "string") { - contents.push(body.trim()); - } - } - return contents; -} - -function stripBlockTagsFromProse(prose: string): string { - let result = prose; - for (const tag of BLOCK_TAGS_TO_STRIP) { - result = result.replace(new RegExp(`<${tag}>[\\s\\S]*?`, "gi"), ""); - } - for (const pattern of LINE_PREFIXES_TO_STRIP) { - result = result.replace(pattern, ""); - } - return result; -} - -function collectStrippedMarkers(raw: string): string[] { - const markers: string[] = []; - if (//i.test(raw)) markers.push("auto-slash-command"); - if (//i.test(raw)) markers.push("command-instruction"); - if (//i.test(raw)) markers.push("session-context"); - if (//i.test(raw)) markers.push("system-reminder"); - if (//i.test(raw)) markers.push("supermemory-context"); - if (/\[SYSTEM DIRECTIVE:/i.test(raw)) markers.push("system-directive"); - if (/\[restore checkpointed session/i.test(raw)) markers.push("checkpoint-restore"); - if (//i.test(raw)) markers.push("omo-internal-initiator"); - return markers; -} - -function collapseBlankLines(text: string): string { - return text.replace(/\n{3,}/g, "\n\n"); -} - /** - * Extract the user-typed portion of a raw chat text. See module header for - * the full strategy. Safe for empty/whitespace input. + * Markers that flag a message as containing plugin/orchestrator scaffolding. + * Matching ANY pattern flips `isPolluted` to true. + * + * Keep this list in sync with `scripts/purge-injected.ts` (the standalone + * cleanup script uses its own copy so it can run without depending on the + * full module graph). */ -export function extractUserPrompt(rawText: string): ExtractedUserPrompt { - if (!rawText || rawText.trim().length === 0) { - return { text: "", source: "empty", strippedMarkers: [] }; - } - - const segments = splitFencedCode(rawText); - const proseSegments = segments.filter((s) => s.type === "prose"); - - // Step 1: explicit user wrappers win when both present and non-empty. - const wrapped = proseSegments.flatMap((s) => extractWrappedUserContent(s.content)); - const wrappedNonEmpty = wrapped.filter((c) => c.length > 0); - - if (wrappedNonEmpty.length > 0) { - return { - text: wrappedNonEmpty.join("\n").trim(), - source: "wrapped-user-content", - strippedMarkers: ["user-wrapper"], - }; - } - - if (wrapped.length > 0) { - // Wrapper present but empty — caller said "no user content"; respect it. - return { text: "", source: "empty", strippedMarkers: ["user-wrapper-empty"] }; - } - - // Step 2: denylist-strip prose; code fences pass through untouched. - const sanitizedSegments = segments.map((segment) => - segment.type === "code" ? segment.content : stripBlockTagsFromProse(segment.content), - ); - const sanitized = collapseBlankLines(sanitizedSegments.join("")).trim(); - - if (sanitized.length === 0) { - return { text: "", source: "empty", strippedMarkers: collectStrippedMarkers(rawText) }; - } +export const POLLUTION_PATTERNS: ReadonlyArray = [ + // Plugin block wrappers (OMO and similar). Match the opening tag only — a + // bare `/i, + // Sisyphus task briefs (`## F1:` through `## F9:`). + /^[\t ]*## F\d+:/m, + // Sisyphus plan-management headers and announcements. + /^[\t ]*## Auto-Selected Plan[\t ]*$/m, + /^[\t ]*## Plan Not Found\b/m, + /^[\t ]*boulder\.json has been created\./m, + // Sisyphus mode indicators on their own line. + /^[\t ]*\[(?:analyze|search|deep|ultrawork|ultrabrain|artistry|writing|quick|visual-engineering|unspecified-(?:low|high))-mode\][\t ]*$/m, + // Standalone scaffolding signature strings. + /You are starting a Sisyphus work session\./, + /MANDATORY delegate_task params:/, +]; - const changed = sanitized !== rawText.trim(); - return { - text: sanitized, - source: changed ? "sanitized-text" : "raw-text", - strippedMarkers: changed ? collectStrippedMarkers(rawText) : [], - }; -} +const USER_WRAPPER_PATTERN = /<(user-request|user-task)>([\s\S]*?)<\/\1>/gi; /** - * Convenience wrapper: filter out synthetic/ignored/non-text parts, join the - * eligible texts, then run `extractUserPrompt` over the combined string. + * True iff `text` contains any pollution marker. * - * Pass `parts === undefined` and you get an empty result back — safe for - * cases where the hook payload may omit the parts list. + * Cheap (compiled regex tests, no allocations). Safe on empty / undefined. */ -export function extractUserPromptFromParts(parts: readonly TextPartLike[] | undefined): ExtractedUserPrompt { - if (!parts || parts.length === 0) { - return { text: "", source: "empty", strippedMarkers: [] }; - } - - const eligible = parts.filter( - (p): p is TextPartLike & { text: string } => p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored, - ); +export function isPolluted(text: string | undefined | null): boolean { + if (!text) return false; + return POLLUTION_PATTERNS.some((pattern) => pattern.test(text)); +} - if (eligible.length === 0) { - return { text: "", source: "empty", strippedMarkers: [] }; +/** + * Pull the user's actual typed content out of a raw message body. If the + * body is wrapped in `` / `` (OMO slash-command + * expansion), we extract the inner body. Otherwise we return the trimmed + * raw text — the caller can decide whether to use it based on + * `isPolluted`. + */ +function extractUserContent(rawText: string): string { + if (!rawText) return ""; + const wrapped: string[] = []; + for (const match of rawText.matchAll(USER_WRAPPER_PATTERN)) { + const body = match[2]; + if (typeof body === "string") { + const trimmed = body.trim(); + if (trimmed.length > 0) wrapped.push(trimmed); + } } - - const joined = eligible.map((p) => p.text).join("\n"); - return extractUserPrompt(joined); + if (wrapped.length > 0) return wrapped.join("\n").trim(); + return rawText.trim(); } /** - * Build a `PromptBoundary` from raw parts + optional metadata. This is the - * preferred entry point for hooks — the returned object carries the raw - * text, the extracted user text, provenance information, and any caller - * context (sessionID, role) all in one immutable bundle. - * - * For places that only need `string`, the lower-level `extractUserPrompt*` - * functions remain available. + * Build a `PromptBoundary` from raw parts + optional caller context. Always + * the preferred entry point for hooks. Returns a frozen object so callers + * can pass it around without worrying about mutation. */ export function createPromptBoundary(parts: readonly TextPartLike[] | undefined, context: PromptBoundaryContext = {}): PromptBoundary { const eligible = (parts ?? []).filter( (p): p is TextPartLike & { text: string } => p.type === "text" && typeof p.text === "string" && !p.synthetic && !p.ignored, ); const rawText = eligible.map((p) => p.text).join("\n"); - const extracted = extractUserPrompt(rawText); - + const userText = extractUserContent(rawText); return Object.freeze({ rawText, - text: extracted.text, - userText: extracted.text, - source: extracted.source, - strippedMarkers: extracted.strippedMarkers, + text: userText, + userText, + isPolluted: isPolluted(rawText), sessionID: context.sessionID, role: context.role, }); } /** - * Sanitize memory context BEFORE injecting it back into a user message's - * parts. More aggressive than `extractUserPrompt` because: - * - We control the input (formatted memory, not user text), so there is no - * code fence to preserve. - * - Any leftover OMO trigger phrase or wrapper here will be processed by - * downstream plugins as if the user typed it. + * Sanitize a memory-context string before injecting it back into a chat + * message. Polluted text never makes it through — better to inject nothing + * than to leak markers that re-trigger downstream plugins (this was the + * root cause of the original Atlas-bug regression). * - * Strips block tags, orphan tag fragments, line-prefix directives, and - * standalone trigger phrases (the start-work-hook canary string). + * Clean text passes through unchanged. */ export function sanitizeMemoryContextForInjection(memoryContext: string): string { if (!memoryContext) return ""; - - let cleaned = memoryContext; - - // Strip complete block tags (including user-wrappers — memory should never - // carry an active tag). - for (const tag of [...BLOCK_TAGS_TO_STRIP, "user-request", "user-task"]) { - cleaned = cleaned.replace(new RegExp(`<${tag}>[\\s\\S]*?`, "gi"), ""); - } - - // Strip orphan opener/closer fragments left behind by chunked memories. - cleaned = cleaned.replace(ORPHAN_TAG_PATTERN, ""); - - // Strip line-prefix directives. - for (const pattern of LINE_PREFIXES_TO_STRIP) { - cleaned = cleaned.replace(pattern, ""); - } - - // Neutralize standalone trigger phrases. - for (const pattern of STANDALONE_TRIGGER_PHRASES) { - cleaned = cleaned.replace(pattern, ""); - } - - return collapseBlankLines(cleaned).trim(); + return isPolluted(memoryContext) ? "" : memoryContext; } diff --git a/src/signal/extract.ts b/src/signal/extract.ts index 2edc3c0..2714732 100644 --- a/src/signal/extract.ts +++ b/src/signal/extract.ts @@ -1,4 +1,4 @@ -import { createPromptBoundary, type SourceKind } from "@/shared/user-prompt"; +import { createPromptBoundary } from "@/shared/user-prompt"; const MAX_SIGNAL_TURN_CHARS = 50 * 1024; const COMMAND_PATTERN = /^[/\\][a-z-]+/i; @@ -24,8 +24,12 @@ export interface Turn { messageId: string; /** Session this turn belongs to, propagated through `PromptBoundary`. */ sessionID?: string; - /** How the turn's text was extracted from the original parts. */ - source?: SourceKind; + /** + * True when the underlying message contained plugin/orchestrator + * scaffolding. Polluted turns get an empty `text` so downstream filters + * that look at `text.length > 0` will skip them automatically. + */ + polluted?: boolean; } export interface SignalExtractionConfig { @@ -38,10 +42,10 @@ export function groupIntoTurns(messages: Message[]): Turn[] { const boundary = createPromptBoundary(message.parts, { sessionID: message.sessionID, role: message.role }); return { role: message.role, - text: boundary.userText, + text: boundary.isPolluted ? "" : boundary.userText, messageId: message.id, sessionID: boundary.sessionID, - source: boundary.source, + polluted: boundary.isPolluted, }; }); } diff --git a/tests/unit/incremental-capture.test.ts b/tests/unit/incremental-capture.test.ts index 524b447..e442943 100644 --- a/tests/unit/incremental-capture.test.ts +++ b/tests/unit/incremental-capture.test.ts @@ -140,4 +140,34 @@ describe("handleMessageUpdatedForCapture", () => { expect(addMemory).toHaveBeenCalledTimes(0); }); + + it("skips a message that contains orchestrator scaffolding (Work_Context)", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture( + makeEvent({ parts: [{ type: "text", text: "policy\nstuff" }] }), + deps, + ); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("skips a message that contains a system reminder", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture(makeEvent({ parts: [{ type: "text", text: "do x" }] }), deps); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); + + it("skips a message that opens with a Sisyphus mode indicator", async () => { + const { deps, addMemory } = makeDeps(); + + await handleMessageUpdatedForCapture( + makeEvent({ parts: [{ type: "text", text: "[analyze-mode]\nANALYSIS MODE...\n\n---\n\nreal stuff" }] }), + deps, + ); + + expect(addMemory).toHaveBeenCalledTimes(0); + }); }); diff --git a/tests/unit/pre-compaction-save.test.ts b/tests/unit/pre-compaction-save.test.ts index c78458a..7e9d6f7 100644 --- a/tests/unit/pre-compaction-save.test.ts +++ b/tests/unit/pre-compaction-save.test.ts @@ -141,5 +141,27 @@ describe("handlePreCompactionSave", () => { // Full last-N dump including the assistant turn that followed the signal. expect(addMemory.mock.calls[0]?.[0]).toBe("[user] remember this fact\n[assistant] noted"); }); + + it("drops polluted messages (Work_Context/system-reminder/mode) from the dump", async () => { + const input = { sessionID: "ses_mixed", output: { context: [] as string[] } }; + const { deps, addMemory } = makeDeps( + [ + makeMessage("m1", "user", "policy"), + makeMessage("m2", "user", "please remember this real ask"), + makeMessage("m3", "assistant", "acknowledged"), + makeMessage("m4", "user", "do this"), + ], + { config: makeConfig({ signalExtraction: true }) }, + ); + + await handlePreCompactionSave(input, deps); + + expect(addMemory).toHaveBeenCalledTimes(1); + const dumped = addMemory.mock.calls[0]?.[0] ?? ""; + expect(dumped).not.toContain("Work_Context"); + expect(dumped).not.toContain("system-reminder"); + expect(dumped).toContain("please remember this real ask"); + expect(dumped).toContain("acknowledged"); + }); }); }); diff --git a/tests/unit/signal-extract.test.ts b/tests/unit/signal-extract.test.ts index 29bc359..536e3df 100644 --- a/tests/unit/signal-extract.test.ts +++ b/tests/unit/signal-extract.test.ts @@ -19,13 +19,13 @@ describe("signal extraction", () => { ]); expect(turns).toEqual([ - { role: "user", text: "First request", messageId: "u1", sessionID: undefined, source: "raw-text" }, - { role: "assistant", text: "First reply", messageId: "a1", sessionID: undefined, source: "raw-text" }, - { role: "user", text: "Second request", messageId: "u2", sessionID: undefined, source: "raw-text" }, + { role: "user", text: "First request", messageId: "u1", sessionID: undefined, polluted: false }, + { role: "assistant", text: "First reply", messageId: "a1", sessionID: undefined, polluted: false }, + { role: "user", text: "Second request", messageId: "u2", sessionID: undefined, polluted: false }, ]); }); - it("propagates sessionID and source through Turn metadata", () => { + it("propagates sessionID and marks polluted slash-command messages", () => { const message: Message = { id: "u_omo", role: "user", @@ -35,9 +35,12 @@ describe("signal extraction", () => { const [turn] = groupIntoTurns([message]); - expect(turn?.text).toBe("real ask"); + // Polluted message — capture-path consumers will skip it. Text is + // emptied so downstream filters that key off `text.length > 0` exclude + // it from signal extraction. + expect(turn?.text).toBe(""); + expect(turn?.polluted).toBe(true); expect(turn?.sessionID).toBe("ses_flow"); - expect(turn?.source).toBe("wrapped-user-content"); }); it("returns matching user turn indices", () => { @@ -138,3 +141,31 @@ describe("signal extraction", () => { expect(extractSignalContent([], { signalKeywords: ["remember"], signalTurnsBefore: 2 })).toBeNull(); }); }); + +describe("signal extraction polluted handling", () => { + it("marks turns from polluted messages with empty text + polluted flag", () => { + const messages: Message[] = [ + { id: "m_polluted", role: "user", parts: [{ type: "text", text: "policy" }] }, + { id: "m_clean", role: "user", parts: [{ type: "text", text: "hello" }] }, + ]; + + const turns = groupIntoTurns(messages); + expect(turns[0]?.polluted).toBe(true); + expect(turns[0]?.text).toBe(""); + expect(turns[1]?.polluted).toBe(false); + expect(turns[1]?.text).toBe("hello"); + }); + + it("extractSignalContent omits polluted turns from the joined output", () => { + const messages: Message[] = [ + { id: "u_pol", role: "user", parts: [{ type: "text", text: "x" }] }, + { id: "u_sig", role: "user", parts: [{ type: "text", text: "please remember this fact" }] }, + { id: "a_rep", role: "assistant", parts: [{ type: "text", text: "OK noted" }] }, + ]; + + const content = extractSignalContent(messages, { signalKeywords: ["remember"], signalTurnsBefore: 5 }); + expect(content).not.toBeNull(); + expect(content).not.toContain("system-reminder"); + expect(content).toContain("please remember this fact"); + }); +}); diff --git a/tests/unit/user-prompt.test.ts b/tests/unit/user-prompt.test.ts index 62d6523..cc4f39f 100644 --- a/tests/unit/user-prompt.test.ts +++ b/tests/unit/user-prompt.test.ts @@ -1,395 +1,166 @@ import { describe, expect, it } from "bun:test"; -import { - createPromptBoundary, - extractUserPrompt, - extractUserPromptFromParts, - sanitizeMemoryContextForInjection, - type TextPartLike, -} from "@/shared/user-prompt"; +import { createPromptBoundary, isPolluted, sanitizeMemoryContextForInjection, type TextPartLike } from "@/shared/user-prompt"; const OMO_START_WORK_MARKER = "You are starting a Sisyphus work session."; -describe("createPromptBoundary", () => { - it("carries rawText (joined eligible parts) alongside userText", () => { - const parts: TextPartLike[] = [{ type: "text", text: "hello world" }]; - const boundary = createPromptBoundary(parts); - expect(boundary.rawText).toBe("hello world"); - expect(boundary.userText).toBe("hello world"); - expect(boundary.source).toBe("raw-text"); - }); - - it("attaches sessionID and role from context", () => { - const boundary = createPromptBoundary([{ type: "text", text: "x" }], { - sessionID: "ses_1", - role: "user", - }); - expect(boundary.sessionID).toBe("ses_1"); - expect(boundary.role).toBe("user"); - }); - - it("rawText preserves OMO wrapper while userText extracts inner content", () => { - const omoExpansion = `${OMO_START_WORK_MARKER}\nreal ask`; - const boundary = createPromptBoundary([{ type: "text", text: omoExpansion }]); - expect(boundary.rawText).toBe(omoExpansion); - expect(boundary.userText).toBe("real ask"); - expect(boundary.source).toBe("wrapped-user-content"); - }); - - it("returns empty boundary for empty parts", () => { - const boundary = createPromptBoundary([]); - expect(boundary.userText).toBe(""); - expect(boundary.rawText).toBe(""); - expect(boundary.source).toBe("empty"); - }); - - it("excludes synthetic parts from rawText (those are NOT user input)", () => { - const parts: TextPartLike[] = [ - { type: "text", text: "memory", synthetic: true }, - { type: "text", text: "real input" }, - ]; - const boundary = createPromptBoundary(parts); - expect(boundary.rawText).toBe("real input"); - expect(boundary.userText).toBe("real input"); - }); - - it("is frozen (immutable)", () => { - const boundary = createPromptBoundary([{ type: "text", text: "x" }]); - expect(Object.isFrozen(boundary)).toBe(true); - }); - - it("undefined parts produces empty boundary with context still attached", () => { - const boundary = createPromptBoundary(undefined, { sessionID: "ses_x", role: "assistant" }); - expect(boundary.userText).toBe(""); - expect(boundary.rawText).toBe(""); - expect(boundary.sessionID).toBe("ses_x"); - expect(boundary.role).toBe("assistant"); - }); -}); - -describe("extractUserPrompt", () => { - it("returns wrapped content from ", () => { - const raw = ` -${OMO_START_WORK_MARKER} -Do work. - - - -Session ID: ses_abc -Timestamp: 2026-05-14T00:00:00Z - - - -help me refactor auth.ts -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe("help me refactor auth.ts"); - expect(result.source).toBe("wrapped-user-content"); +describe("isPolluted", () => { + it("returns false for empty / undefined input", () => { + expect(isPolluted("")).toBe(false); + expect(isPolluted(undefined)).toBe(false); + expect(isPolluted(null)).toBe(false); }); - it("returns wrapped content from ", () => { - const raw = ` -Do something - - - -fix the failing test -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe("fix the failing test"); - expect(result.source).toBe("wrapped-user-content"); + it("returns false for plain user text", () => { + expect(isPolluted("hello world, please refactor auth.ts")).toBe(false); + expect(isPolluted("we use TanStack Query for data fetching")).toBe(false); }); - it("joins multiple user wrappers in order", () => { - const raw = ` -first request - - -mid noise - - -second request -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe("first request\nsecond request"); - expect(result.source).toBe("wrapped-user-content"); - }); - - it("returns empty when wrapper is empty", () => { - const raw = `stuff -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe(""); - expect(result.source).toBe("empty"); + it("flags every OMO / plugin block-wrapper opening tag", () => { + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); + expect(isPolluted("x")).toBe(true); }); - it("returns plain user prompt unchanged", () => { - const raw = "just a normal question about typescript generics"; - const result = extractUserPrompt(raw); - expect(result.text).toBe(raw); - expect(result.source).toBe("raw-text"); + it("flags Sisyphus wrapper", () => { + expect(isPolluted("x")).toBe(true); }); - it("strips when no user wrapper present", () => { - const raw = ` -[SYSTEM DIRECTIVE: OH-MY-OPENCODE - SINGLE TASK ONLY] -some reminder body -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe(""); - expect(result.source).toBe("empty"); - expect(result.strippedMarkers).toContain("system-reminder"); + it("flags Sisyphus task brief headers (## F1: through ## F9:)", () => { + expect(isPolluted("## F1: Plan Compliance Audit")).toBe(true); + expect(isPolluted("## F2: Code Quality Review")).toBe(true); + expect(isPolluted(" ## F9: edge case")).toBe(true); }); - it("strips [SYSTEM DIRECTIVE: ...] prefix lines", () => { - const raw = `[SYSTEM DIRECTIVE: OH-MY-OPENCODE - BOULDER CONTINUATION] - -You have an active work plan with incomplete tasks. -keep working.`; - - const result = extractUserPrompt(raw); - expect(result.text).toContain("You have an active work plan"); - expect(result.text).not.toContain("[SYSTEM DIRECTIVE"); - expect(result.source).toBe("sanitized-text"); - }); - - it("strips OMO checkpoint restore prompt", () => { - const raw = `[restore checkpointed session agent configuration after compaction] -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe(""); - expect(result.source).toBe("empty"); - }); - - it("prefers over ", () => { - const raw = ` -${OMO_START_WORK_MARKER} - - - -the actual prompt -`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe("the actual prompt"); - expect(result.source).toBe("wrapped-user-content"); - }); - - it("preserves inside code fence", () => { - const raw = `here is the OMO template: - -\`\`\`xml - -Session ID: $SESSION_ID - -\`\`\` - -please explain how this works`; - - const result = extractUserPrompt(raw); - expect(result.text).toContain(""); - expect(result.text).toContain("please explain how this works"); + it("flags Sisyphus plan-management markers", () => { + expect(isPolluted("## Auto-Selected Plan")).toBe(true); + expect(isPolluted("## Plan Not Found")).toBe(true); + expect(isPolluted("boulder.json has been created. Read the plan.")).toBe(true); }); - it("preserves prose + code fence, strips real injected block", () => { - const raw = ` -ignore me - - -here is some code: - -\`\`\`ts -const x = "not real"; -\`\`\` - -end of message`; - - const result = extractUserPrompt(raw); - expect(result.text).toContain("here is some code"); - expect(result.text).toContain("end of message"); - expect(result.text).toContain('const x = "not real"'); - expect(result.text).not.toContain("ignore me"); + it("flags Sisyphus mode indicators on their own line", () => { + expect(isPolluted("[analyze-mode]")).toBe(true); + expect(isPolluted("[search-mode]")).toBe(true); + expect(isPolluted("[deep-mode]")).toBe(true); + expect(isPolluted("[ultrawork-mode]")).toBe(true); + expect(isPolluted("[visual-engineering-mode]")).toBe(true); }); - it("strips wrappers from prior injections", () => { - const raw = ` -[SUPERMEMORY] -User Profile: -- Likes brevity - - -actual user message`; - - const result = extractUserPrompt(raw); - expect(result.text).toBe("actual user message"); - expect(result.source).toBe("sanitized-text"); + it("flags system directives and recovery prompts", () => { + expect(isPolluted("[SYSTEM DIRECTIVE: OH-MY-OPENCODE - X]")).toBe(true); + expect(isPolluted("[restore checkpointed session agent configuration after compaction]")).toBe(true); + expect(isPolluted("")).toBe(true); }); - it("handles malformed tags conservatively (no catastrophic deletion)", () => { - const raw = ` -no closing tag, this should still work - -normal text after`; - - const result = extractUserPrompt(raw); - // unclosed wrapper falls back to denylist sanitize; user content preserved - expect(result.text).toContain("no closing tag"); - expect(result.text).toContain("normal text after"); + it("flags standalone scaffolding signature strings", () => { + expect(isPolluted(OMO_START_WORK_MARKER)).toBe(true); + expect(isPolluted("MANDATORY delegate_task params: ALWAYS include load_skills=[]")).toBe(true); }); - it("processes ~70KB input quickly and bounds output", () => { - const filler = "lorem ipsum ".repeat(7000); // ~84KB - const raw = `${filler} - - -real ask -`; - - const start = Date.now(); - const result = extractUserPrompt(raw); - const elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(500); - expect(result.text).toBe("real ask"); - expect(result.source).toBe("wrapped-user-content"); + it("flags polluted message even when buried mid-text", () => { + expect(isPolluted("user said: hi\nnoise\nmore text")).toBe(true); }); }); -describe("extractUserPromptFromParts", () => { - it("skips synthetic parts", () => { - const parts: TextPartLike[] = [ - { type: "text", text: "from supermemory injection", synthetic: true }, - { type: "text", text: "actual user input" }, - ]; - - const result = extractUserPromptFromParts(parts); - expect(result.text).toBe("actual user input"); - }); - - it("skips ignored parts", () => { - const parts: TextPartLike[] = [ - { type: "text", text: "irrelevant", ignored: true }, - { type: "text", text: "this is the real one" }, - ]; +describe("createPromptBoundary", () => { + it("returns frozen, empty boundary for missing or all-filtered parts", () => { + const a = createPromptBoundary(undefined); + expect(a.rawText).toBe(""); + expect(a.userText).toBe(""); + expect(a.isPolluted).toBe(false); + expect(Object.isFrozen(a)).toBe(true); - const result = extractUserPromptFromParts(parts); - expect(result.text).toBe("this is the real one"); + const b = createPromptBoundary([{ type: "text", text: "x", synthetic: true }]); + expect(b.rawText).toBe(""); + expect(b.userText).toBe(""); + expect(b.isPolluted).toBe(false); }); - it("ignores non-text parts", () => { + it("filters synthetic + ignored + non-text parts before joining", () => { const parts: TextPartLike[] = [ + { type: "text", text: "from supermemory injection", synthetic: true }, { type: "tool", text: "tool output" }, - { type: "text", text: "user text" }, + { type: "text", text: "ignored fragment", ignored: true }, + { type: "text", text: "real user input" }, ]; - - const result = extractUserPromptFromParts(parts); - expect(result.text).toBe("user text"); + const boundary = createPromptBoundary(parts); + expect(boundary.rawText).toBe("real user input"); + expect(boundary.userText).toBe("real user input"); + expect(boundary.isPolluted).toBe(false); }); - it("joins multiple eligible parts and extracts wrappers across them", () => { - const parts: TextPartLike[] = [ - { type: "text", text: "cmd" }, - { type: "text", text: "real prompt" }, - ]; - - const result = extractUserPromptFromParts(parts); - expect(result.text).toBe("real prompt"); + it("returns raw text trimmed when no wrappers are present", () => { + const boundary = createPromptBoundary([{ type: "text", text: " hello world " }]); + expect(boundary.userText).toBe("hello world"); }); - it("returns empty when all parts filtered or empty", () => { - const parts: TextPartLike[] = [ - { type: "text", text: "synthetic data", synthetic: true }, - { type: "text", text: "", synthetic: false }, - ]; + it("extracts body and marks polluted (slash-command path)", () => { + const expansion = `${OMO_START_WORK_MARKER}\nrefactor auth.ts`; + const boundary = createPromptBoundary([{ type: "text", text: expansion }]); - const result = extractUserPromptFromParts(parts); - expect(result.text).toBe(""); - expect(result.source).toBe("empty"); + expect(boundary.userText).toBe("refactor auth.ts"); + // Even though we extract the body for query use, the message itself is + // still polluted — capture-path callers must skip it. + expect(boundary.isPolluted).toBe(true); }); - it("handles undefined parts list", () => { - const result = extractUserPromptFromParts(undefined); - expect(result.text).toBe(""); - expect(result.source).toBe("empty"); + it("extracts body and marks polluted", () => { + const expansion = "x\nfix the failing test"; + const boundary = createPromptBoundary([{ type: "text", text: expansion }]); + expect(boundary.userText).toBe("fix the failing test"); + expect(boundary.isPolluted).toBe(true); }); -}); -describe("sanitizeMemoryContextForInjection", () => { - it("removes both OMO start-work-hook trigger markers", () => { - const polluted = `[SUPERMEMORY] -Project Knowledge: -- [100%] Past session contained: - ${OMO_START_WORK_MARKER} - Session ID: foo`; - - const safe = sanitizeMemoryContextForInjection(polluted); - - expect(safe.includes("") && safe.includes(OMO_START_WORK_MARKER)).toBe(false); + it("joins multiple user wrappers in order", () => { + const raw = "one\nmid noise\ntwo"; + const boundary = createPromptBoundary([{ type: "text", text: raw }]); + expect(boundary.userText).toBe("one\ntwo"); }); - it("removes blocks", () => { - const polluted = `User Profile: -- pref: be concise - - -You are starting a Sisyphus work session. -`; - - const safe = sanitizeMemoryContextForInjection(polluted); - - expect(safe).not.toContain(""); - expect(safe).not.toContain(""); - expect(safe).toContain("be concise"); + it("attaches sessionID and role from context", () => { + const boundary = createPromptBoundary([{ type: "text", text: "x" }], { sessionID: "ses_1", role: "user" }); + expect(boundary.sessionID).toBe("ses_1"); + expect(boundary.role).toBe("user"); }); - it("removes blocks", () => { - const polluted = ` -# /start-work Command -${OMO_START_WORK_MARKER} - - -normal memo`; - - const safe = sanitizeMemoryContextForInjection(polluted); - - expect(safe).not.toContain(""); - expect(safe).not.toContain(OMO_START_WORK_MARKER); - expect(safe).toContain("normal memo"); + it("plain user prompt is non-polluted (capture allowed)", () => { + const boundary = createPromptBoundary([{ type: "text", text: "remember that we use Bun" }]); + expect(boundary.isPolluted).toBe(false); }); - it("preserves legitimate content outside injection markers", () => { - const polluted = `User Profile: -- Likes typescript -- Project: opencode-supermemory-p -- Build: bun run build`; - - const safe = sanitizeMemoryContextForInjection(polluted); - - expect(safe).toContain("Likes typescript"); - expect(safe).toContain("Project: opencode-supermemory-p"); - expect(safe).toContain("Build: bun run build"); + it("polluted messages with non-wrapper content keep userText = raw text (debug/log usable)", () => { + const polluted = "do x\nfollow-up"; + const boundary = createPromptBoundary([{ type: "text", text: polluted }]); + expect(boundary.isPolluted).toBe(true); + // No user-wrapper present, so userText is the raw trimmed text. The + // important guarantee is `isPolluted: true` — capture callers will skip. + expect(boundary.userText).toContain("follow-up"); }); +}); - it("removes SYSTEM DIRECTIVE prefix lines from memory text", () => { - const polluted = `Project memo: -[SYSTEM DIRECTIVE: OH-MY-OPENCODE - BOULDER CONTINUATION] -- task remaining: x`; - - const safe = sanitizeMemoryContextForInjection(polluted); - expect(safe).not.toContain("[SYSTEM DIRECTIVE"); - expect(safe).toContain("task remaining: x"); +describe("sanitizeMemoryContextForInjection", () => { + it("returns empty string for empty input", () => { + expect(sanitizeMemoryContextForInjection("")).toBe(""); }); - it("neutralizes lone marker strings even outside tags", () => { - const polluted = `Past summary: ${OMO_START_WORK_MARKER} ran successfully`; + it("passes clean memory text through unchanged", () => { + const clean = "[SUPERMEMORY]\n\nUser Profile:\n- Prefers Bun\n\nProject Knowledge:\n- Uses TanStack Query"; + expect(sanitizeMemoryContextForInjection(clean)).toBe(clean); + }); - const safe = sanitizeMemoryContextForInjection(polluted); - expect(safe).not.toContain(OMO_START_WORK_MARKER); + it("returns empty string when memory text contains ANY pollution marker", () => { + // The point: legacy memories captured before pollution filtering may + // contain markers. Rather than partially strip them (fragile), we drop + // the whole injection. Better no context than markers that re-trigger + // downstream plugins. + expect(sanitizeMemoryContextForInjection(`prefix ${OMO_START_WORK_MARKER} suffix`)).toBe(""); + expect(sanitizeMemoryContextForInjection("user pref\nx")).toBe(""); + expect(sanitizeMemoryContextForInjection("## F1: audit body")).toBe(""); + expect(sanitizeMemoryContextForInjection("[analyze-mode]\nbody")).toBe(""); + expect(sanitizeMemoryContextForInjection("policy")).toBe(""); }); }); From b4314a8989f9d2922b99bfabbb21d8b5e59745d5 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 07:10:11 +0800 Subject: [PATCH 35/37] fix: treat skill invocation bodies as scaffolding --- scripts/purge-injected.ts | 3 +++ src/shared/user-prompt.ts | 4 ++++ tests/unit/user-prompt.test.ts | 10 ++++++++++ 3 files changed, 17 insertions(+) diff --git a/scripts/purge-injected.ts b/scripts/purge-injected.ts index 24e54ba..1f92545 100644 --- a/scripts/purge-injected.ts +++ b/scripts/purge-injected.ts @@ -30,6 +30,9 @@ export const POLLUTION_PATTERNS: ReadonlyArray = [ /<(?:auto-slash-command|command-instruction|session-context|system-reminder|supermemory-context|user-request|user-task)\b/i, // Sisyphus orchestrator scaffold wrapper / = [ /<(?:auto-slash-command|command-instruction|session-context|system-reminder|supermemory-context|user-request|user-task)\b/i, // Sisyphus orchestrator scaffold wrapper. / { it("flags polluted message even when buried mid-text", () => { expect(isPolluted("user said: hi\nnoise\nmore text")).toBe(true); }); + + it("flags skill invocation content blocks (skill body should never be captured)", () => { + expect(isPolluted('a long skill body')).toBe(true); + expect(isPolluted("\n- foo\n- bar\n")).toBe(true); + }); + + it("flags skill invocation even when buried inside other content", () => { + const text = 'first line\nthen huge content\nend'; + expect(isPolluted(text)).toBe(true); + }); }); describe("createPromptBoundary", () => { From 217a7c384da0badab59c8c1a6bb25ab4b6183907 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 07:11:43 +0800 Subject: [PATCH 36/37] docs: clarify scaffolding filter scope --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f7c05fa..00731f5 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,7 @@ Add to `~/.config/opencode/oh-my-openagent.json`: } ``` -Plugin-injected content (slash-command expansions, system reminders, session-context blocks) is filtered out at capture time. Memories ingested with this plugin enabled will not contain other plugins' markers, so no manual cleanup is needed for new sessions. To clean up memories captured before this filtering existed, see [Maintenance](#maintenance). +Messages containing plugin or orchestrator scaffolding (slash-command expansions, system reminders, `` blocks, skill bodies, session-context wrappers, mode indicators, etc.) are skipped entirely from the capture stream. Memories ingested with this plugin enabled will not contain other plugins' markers, so no manual cleanup is needed for new sessions. To clean up memories captured before this filtering existed, see [Maintenance](#maintenance). ## Development From f83c02b042dc00ddb835a381263a0e4facdd01b4 Mon Sep 17 00:00:00 2001 From: TheOrdinaryWow <25114850+TheOrdinaryWow@users.noreply.github.com> Date: Thu, 14 May 2026 07:12:37 +0800 Subject: [PATCH 37/37] chore: bump version to 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5bddcc..bc9ba07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory-p", - "version": "1.0.0", + "version": "1.1.0", "description": "The powered Supermemory plugin for OpenCode — persistent memory for coding agents", "type": "module", "main": "./dist/index.js",