diff --git a/README.md b/README.md index 9dba094..00731f5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ 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) +- [Maintenance](#maintenance) +- [Acknowledgements](#acknowledgements) +- [License](#license) + ## Installation ### For Humans @@ -177,6 +193,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 +280,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 +326,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, } ``` @@ -321,6 +455,8 @@ Add to `~/.config/opencode/oh-my-openagent.json`: } ``` +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 ```bash @@ -345,6 +481,37 @@ 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. + +## 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 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" } } } 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", diff --git a/scripts/purge-injected.ts b/scripts/purge-injected.ts new file mode 100644 index 0000000..1f92545 --- /dev/null +++ b/scripts/purge-injected.ts @@ -0,0 +1,290 @@ +/** + * 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 = [ + // 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 { + 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/src/capture/incremental.ts b/src/capture/incremental.ts new file mode 100644 index 0000000..ca31bc7 --- /dev/null +++ b/src/capture/incremental.ts @@ -0,0 +1,70 @@ +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 { + 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 allParts = info.parts ?? []; + const boundary = createPromptBoundary(allParts, { sessionID, role: "assistant" }); + if (boundary.isPolluted || !boundary.userText) return; + + const cleanPart: MessagePart = { type: "text", text: boundary.userText }; + const message: Message = { + id: messageID, + sessionID, + role: "assistant", + parts: [cleanPart], + }; + + 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 ?? 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", 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 new file mode 100644 index 0000000..521eaf0 --- /dev/null +++ b/src/capture/session-end.ts @@ -0,0 +1,127 @@ +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: { + 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"; source?: MemorySource }): 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", source: "summary" }); + 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/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/src/chat/handler.ts b/src/chat/handler.ts index 0e460cd..9b4737b 100644 --- a/src/chat/handler.ts +++ b/src/chat/handler.ts @@ -20,7 +20,11 @@ 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 { 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; @@ -52,19 +56,32 @@ 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" + | "reinjectEveryN" + > & + Partial>; tags: { user: string; project: string }; injectedSessions: Pick; + pendingReinjectSessions?: Pick, "delete" | "has">; log: (message: string, data?: unknown) => void; 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 @@ -79,35 +96,31 @@ 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 { - const textParts = output.parts.filter((p): p is Part & { type: "text"; text: string } => p.type === "text"); - - if (textParts.length === 0) { - deps.log("chat.message: no text parts found"); - return; - } + if (!deps.isConfigured()) return; - const userMessage = textParts.map((p) => p.text).join("\n"); + const boundary = createPromptBoundary(output.parts, { sessionID: input.sessionID, role: "user" }); + const userMessage = boundary.userText; - if (!userMessage.trim()) { - deps.log("chat.message: empty message, skipping"); + if (!userMessage) { + 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, - textPartsCount: textParts.length, + isPolluted: boundary.isPolluted, }); 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", @@ -118,6 +131,10 @@ 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; + let injectedThisTurn = false; if (isFirstMessage) { deps.injectedSessions.markInjected(input.sessionID); @@ -147,12 +164,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: `prt_supermemory-context-${Date.now()}`, + id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", - text: memoryContext, + text: `\n${safeMemoryContext}\n`, synthetic: true, }; @@ -163,9 +181,24 @@ export async function handleChatMessage(input: ChatHandlerInput, output: ChatHan duration, contextLength: memoryContext.length, }); + injectedThisTurn = true; + } + } 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/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..6eef92a 100644 --- a/src/compaction/index.ts +++ b/src/compaction/index.ts @@ -1,12 +1,9 @@ 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"; +import { isPolluted } from "@/shared/user-prompt"; const logger = getLogger(["supermemory", "compaction"]); @@ -21,15 +18,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 +103,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 +126,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 +145,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 +153,22 @@ 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 }); + 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" }); + 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 +192,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 +221,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 bbf9475..0000000 --- a/src/compaction/message-store.ts +++ /dev/null @@ -1,116 +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"; - -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 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 }; - 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/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/compaction/pre-save.ts b/src/compaction/pre-save.ts new file mode 100644 index 0000000..ccf521f --- /dev/null +++ b/src/compaction/pre-save.ts @@ -0,0 +1,79 @@ +import { getLogger } from "@logtape/logtape"; + +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 } 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[] }>; + }; + /** + * 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( + 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, deps); + + if (content.length > 0) { + const projectTag = getProjectTag(process.cwd(), deps.config); + 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 }); + } + } + } 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[], 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 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/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/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/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/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/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/src/events/handler.ts b/src/events/handler.ts index 49d64b4..fb15295 100644 --- a/src/events/handler.ts +++ b/src/events/handler.ts @@ -5,16 +5,55 @@ * stays in `src/compaction/` where it can be unit-tested in isolation. */ +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"; + export interface EventInput { event: { type: string; properties?: unknown }; } export interface EventDeps { compactionHook: { event(input: EventInput): Promise } | null; + config?: SupermemoryConfig; + incrementalCapture?: IncrementalCaptureDeps; + sessionEnd?: SessionEndDeps; } 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 ((input.event.type === "session.deleted" || input.event.type === "session.idle") && deps.sessionEnd) { + void handleSessionEnd(input as Parameters[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); } } + +function createDefaultIncrementalCaptureDeps(): IncrementalCaptureDeps { + return { + config: getConfig(), + client: resultSupermemoryClient, + tracker, + signalExtract: extractSignalContent, + dataDir: process.cwd(), + }; +} diff --git a/src/index.ts b/src/index.ts index 1e3e4a7..b4d906f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +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 { 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 = @@ -23,7 +33,54 @@ 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 } }) => + deps.isConfigured() + ? handleEvent(input, { + compactionHook, + config: deps.config, + incrementalCapture: { + config: deps.config, + client: deps.resultClient, + tracker, + signalExtract: extractSignalContent, + dataDir: ctx.directory, + }, + 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 } }), + ), + }), + }, + signalExtract: extractSignalContent, + }, + ) + : Promise.resolve(), }; }; @@ -34,11 +91,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, 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 }; + }, + }; } 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/src/memory/client.ts b/src/memory/client.ts index 81837bc..b18ad14 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 type { ConversationIngestResponse, ConversationMessage, MemoryType } from "@/types/index"; +import { withTimeout } from "@/shared/timeout"; +import type { ConversationIngestResponse, ConversationMessage, MemorySource, 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; source?: MemorySource; [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 = @@ -87,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 () => { @@ -100,6 +144,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,27 +160,43 @@ export class SupermemoryClient { q: query, }), TIMEOUT_MS, + "supermemory.getProfile", ); logger.info("getProfile: success", { hasProfile: !!result?.profile }); return { success: true as const, ...result }; }); } - async addMemory( - content: string, - containerTag: string, - metadata?: { type?: MemoryType; tool?: string; [key: string]: unknown }, - ): 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(); + 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 +205,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 +223,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 +268,18 @@ 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; } } @@ -253,7 +322,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", @@ -270,7 +339,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/src/memory/context.ts b/src/memory/context.ts index 27d8ed6..ba0eb97 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,18 @@ 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/src/memory/dedup.ts b/src/memory/dedup.ts new file mode 100644 index 0000000..511c240 --- /dev/null +++ b/src/memory/dedup.ts @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto"; +import { existsSync, promises as fs, readFileSync } 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/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/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/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/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/src/recall/every-message.ts b/src/recall/every-message.ts new file mode 100644 index 0000000..ad496de --- /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"; +import { createPromptBoundary, sanitizeMemoryContextForInjection } from "@/shared/user-prompt"; + +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[], sessionID: string): string { + return createPromptBoundary(parts, { sessionID, role: "user" }).userText; +} + +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, input.sessionID)); + 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; + + const safeMemoryContext = sanitizeMemoryContextForInjection(memoryContext); + + output.parts.unshift({ + id: generatePartId(), + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: `\n${safeMemoryContext}\n`, + synthetic: true, + }); + } catch (error) { + deps.log("chat.message: recall ERROR", { error: String(error) }); + } +} 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/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/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/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/src/shared/user-prompt.ts b/src/shared/user-prompt.ts new file mode 100644 index 0000000..c2bc787 --- /dev/null +++ b/src/shared/user-prompt.ts @@ -0,0 +1,174 @@ +/** + * 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. + * + * 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. + * + * 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. + * + * `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. + */ + +export type MessageRole = "user" | "assistant"; + +export interface TextPartLike { + type: string; + text?: unknown; + synthetic?: boolean; + ignored?: boolean; +} + +export interface PromptBoundary { + /** Concatenated text of eligible (non-synthetic, non-ignored) parts. */ + rawText: string; + /** + * 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. */ + role?: MessageRole; +} + +export interface PromptBoundaryContext { + sessionID?: string; + role?: MessageRole; +} + +/** + * 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 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 USER_WRAPPER_PATTERN = /<(user-request|user-task)>([\s\S]*?)<\/\1>/gi; + +/** + * True iff `text` contains any pollution marker. + * + * Cheap (compiled regex tests, no allocations). Safe on empty / undefined. + */ +export function isPolluted(text: string | undefined | null): boolean { + if (!text) return false; + return POLLUTION_PATTERNS.some((pattern) => pattern.test(text)); +} + +/** + * 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); + } + } + if (wrapped.length > 0) return wrapped.join("\n").trim(); + return rawText.trim(); +} + +/** + * 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 userText = extractUserContent(rawText); + return Object.freeze({ + rawText, + text: userText, + userText, + isPolluted: isPolluted(rawText), + sessionID: context.sessionID, + role: context.role, + }); +} + +/** + * 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). + * + * Clean text passes through unchanged. + */ +export function sanitizeMemoryContextForInjection(memoryContext: string): string { + if (!memoryContext) return ""; + return isPolluted(memoryContext) ? "" : memoryContext; +} diff --git a/src/signal/extract.ts b/src/signal/extract.ts new file mode 100644 index 0000000..2714732 --- /dev/null +++ b/src/signal/extract.ts @@ -0,0 +1,113 @@ +import { createPromptBoundary } from "@/shared/user-prompt"; + +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; + /** Session this turn belongs to, propagated through `PromptBoundary`. */ + sessionID?: string; + /** + * 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 { + signalKeywords: string[]; + signalTurnsBefore: number; +} + +export function groupIntoTurns(messages: Message[]): Turn[] { + return messages.map((message) => { + const boundary = createPromptBoundary(message.parts, { sessionID: message.sessionID, role: message.role }); + return { + role: message.role, + text: boundary.isPolluted ? "" : boundary.userText, + messageId: message.id, + sessionID: boundary.sessionID, + polluted: boundary.isPolluted, + }; + }); +} + +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))) { + indices.push(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/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 ({ 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/contract/tool-modes.test.ts b/tests/contract/tool-modes.test.ts index 53307dd..b7f9848 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)); @@ -308,7 +308,7 @@ describe("tool.execute mode=add", () => { // 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 () => { @@ -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/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" } } 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); + }); +}); diff --git a/tests/integration/incremental-capture-hook.test.ts b/tests/integration/incremental-capture-hook.test.ts new file mode 100644 index 0000000..1dff90b --- /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; source?: 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/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"); + }); +}); diff --git a/tests/unit/chat-handler.test.ts b/tests/unit/chat-handler.test.ts index 9fbea19..e43ce84 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]>; } @@ -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/client.test.ts b/tests/unit/client.test.ts index c984b67..ff44bf1 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { DedupCache } from "@/memory/dedup"; import type { AppError } from "@/shared/errors"; // ===================================================================== @@ -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"); diff --git a/tests/unit/collection.test.ts b/tests/unit/collection.test.ts new file mode 100644 index 0000000..0720edb --- /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"]); + }); +}); 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"); + }); +}); diff --git a/tests/unit/config-new-keys.test.ts b/tests/unit/config-new-keys.test.ts new file mode 100644 index 0000000..77628a2 --- /dev/null +++ b/tests/unit/config-new-keys.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "bun:test"; + +// ===================================================================== +// 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("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("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("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("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("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..f720835 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. + 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. + 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. + everyMessageRecall: false, + reinjectEveryN: 0, + autoCategoryTagging: false, }); }); }); 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); + }); }); diff --git a/tests/unit/dedup.test.ts b/tests/unit/dedup.test.ts new file mode 100644 index 0000000..935d769 --- /dev/null +++ b/tests/unit/dedup.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +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"); + }); +}); diff --git a/tests/unit/entity-context.test.ts b/tests/unit/entity-context.test.ts new file mode 100644 index 0000000..c5f0d75 --- /dev/null +++ b/tests/unit/entity-context.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; + +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", () => { + 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); + }); +}); diff --git a/tests/unit/every-message-recall.test.ts b/tests/unit/every-message-recall.test.ts new file mode 100644 index 0000000..4336bba --- /dev/null +++ b/tests/unit/every-message-recall.test.ts @@ -0,0 +1,203 @@ +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, + reinjectEveryN: 0, + ...((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"); + }); +}); 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]"); + }); +}); 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); + }); +}); diff --git a/tests/unit/incremental-capture.test.ts b/tests/unit/incremental-capture.test.ts new file mode 100644 index 0000000..e442943 --- /dev/null +++ b/tests/unit/incremental-capture.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { type EventMessageUpdated, handleMessageUpdatedForCapture, type IncrementalCaptureDeps } from "@/capture/incremental"; +import { DEFAULTS } from "@/config/defaults"; +import { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; + +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; source?: 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", source: "assistant" }, + ]); + }); + + 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; source?: 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); + }); + + 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/metadata-strip.test.ts b/tests/unit/metadata-strip.test.ts new file mode 100644 index 0000000..658948b --- /dev/null +++ b/tests/unit/metadata-strip.test.ts @@ -0,0 +1,65 @@ +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(""); + }); +}); diff --git a/tests/unit/periodic-reinject.test.ts b/tests/unit/periodic-reinject.test.ts new file mode 100644 index 0000000..15dc9d1 --- /dev/null +++ b/tests/unit/periodic-reinject.test.ts @@ -0,0 +1,173 @@ +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 { 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] ?? { 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"]], + ]); + }); + + 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"]], + ]); + }); +}); diff --git a/tests/unit/post-compaction-reinject.test.ts b/tests/unit/post-compaction-reinject.test.ts new file mode 100644 index 0000000..922aa86 --- /dev/null +++ b/tests/unit/post-compaction-reinject.test.ts @@ -0,0 +1,148 @@ +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 { handleSessionCompacted, pendingReinjectSessions } from "@/compaction/post-reinject"; +import type { SupermemoryConfig } from "@/config/schema"; +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); + }); +}); diff --git a/tests/unit/pre-compaction-save.test.ts b/tests/unit/pre-compaction-save.test.ts new file mode 100644 index 0000000..7e9d6f7 --- /dev/null +++ b/tests/unit/pre-compaction-save.test.ts @@ -0,0 +1,167 @@ +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 { 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, + }); +} + +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; source?: 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 }, + signalExtract: extractSignalContent, + ...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", source: "summary" }); + 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"); + }); + + 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"); + }); + + 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/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); + }); +}); 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(""); + }); +}); diff --git a/tests/unit/session-end.test.ts b/tests/unit/session-end.test.ts new file mode 100644 index 0000000..2623937 --- /dev/null +++ b/tests/unit/session-end.test.ts @@ -0,0 +1,120 @@ +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 { type SupermemoryConfig, SupermemoryConfigSchema } from "@/config/schema"; +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; source?: 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", source: "summary" }); + }); + + 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"); + }); +}); diff --git a/tests/unit/signal-extract.test.ts b/tests/unit/signal-extract.test.ts new file mode 100644 index 0000000..536e3df --- /dev/null +++ b/tests/unit/signal-extract.test.ts @@ -0,0 +1,171 @@ +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", 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 marks polluted slash-command messages", () => { + const message: Message = { + id: "u_omo", + role: "user", + sessionID: "ses_flow", + parts: [{ type: "text", text: "xreal ask" }], + }; + + const [turn] = groupIntoTurns([message]); + + // 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"); + }); + + 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(); + }); +}); + +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/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); }); 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); + }); +}); diff --git a/tests/unit/tracker.test.ts b/tests/unit/tracker.test.ts new file mode 100644 index 0000000..b1eb81e --- /dev/null +++ b/tests/unit/tracker.test.ts @@ -0,0 +1,86 @@ +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); + } + }); +}); diff --git a/tests/unit/user-prompt.test.ts b/tests/unit/user-prompt.test.ts new file mode 100644 index 0000000..695337b --- /dev/null +++ b/tests/unit/user-prompt.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "bun:test"; + +import { createPromptBoundary, isPolluted, sanitizeMemoryContextForInjection, type TextPartLike } from "@/shared/user-prompt"; + +const OMO_START_WORK_MARKER = "You are starting a Sisyphus work session."; + +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 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("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("flags Sisyphus wrapper", () => { + expect(isPolluted("x")).toBe(true); + }); + + 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("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("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("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("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("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", () => { + 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 b = createPromptBoundary([{ type: "text", text: "x", synthetic: true }]); + expect(b.rawText).toBe(""); + expect(b.userText).toBe(""); + expect(b.isPolluted).toBe(false); + }); + + 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: "ignored fragment", ignored: true }, + { type: "text", text: "real user input" }, + ]; + const boundary = createPromptBoundary(parts); + expect(boundary.rawText).toBe("real user input"); + expect(boundary.userText).toBe("real user input"); + expect(boundary.isPolluted).toBe(false); + }); + + 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("extracts body and marks polluted (slash-command path)", () => { + const expansion = `${OMO_START_WORK_MARKER}\nrefactor auth.ts`; + const boundary = createPromptBoundary([{ type: "text", text: expansion }]); + + 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("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); + }); + + 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("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("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("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"); + }); +}); + +describe("sanitizeMemoryContextForInjection", () => { + it("returns empty string for empty input", () => { + expect(sanitizeMemoryContextForInjection("")).toBe(""); + }); + + 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); + }); + + 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(""); + }); +});