From 96783d8d106b2804d619f09e19835d4f00a49a3d Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 12:18:42 +0100 Subject: [PATCH 01/36] fix: prompt dedup, double summarize, docker-mode stop, hermetic tests - observe: hash the hook payload when tool_input is absent so prompt_submit, notification, and lifecycle events dedup on content instead of collapsing onto one shared key that silently dropped every prompt after the first in a TTL window (#1173) - stop hook: drop the direct /agentmemory/summarize POST; /session/end already fans out event::session::stopped which runs mem::summarize, so every Stop dispatched two full summarizes (#1203) - cli: refuse to adopt or signal Docker/VM port holders (com.docker.backend, vpnkit, colima, ...) as the native engine unless --force; scope Docker-mode teardown to agentmemory's own compose services via rm -s -f instead of an unscoped down; reap the native worker before Docker teardown instead of deleting worker.pid with the process still running (#1151) - tests: isolate HOME/USERPROFILE for the whole vitest run so suites stop reading the developer's real ~/.agentmemory/.env (#1178) --- plugin/scripts/stop.mjs | 6 -- src/cli.ts | 97 +++++++++++++++++++- src/functions/observe.ts | 7 +- src/hooks/stop.ts | 11 +-- test/observe-dedup-prompt.test.ts | 144 ++++++++++++++++++++++++++++++ vitest.config.ts | 20 +++++ 6 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 test/observe-dedup-prompt.test.ts create mode 100644 vitest.config.ts diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs index 0b1c43b0b..41fda645d 100755 --- a/plugin/scripts/stop.mjs +++ b/plugin/scripts/stop.mjs @@ -24,12 +24,6 @@ async function main() { if (!data || typeof data !== "object") return; if (isSdkChildContext(data)) return; const sessionId = data.session_id || data.sessionId || "unknown"; - fetch(`${REST_URL}/agentmemory/summarize`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(12e4) - }).catch(() => {}); fetch(`${REST_URL}/agentmemory/session/end`, { method: "POST", headers: authHeaders(), diff --git a/src/cli.ts b/src/cli.ts index 918e011cd..8e26ee51d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -830,6 +830,18 @@ function adoptRunningEngine(): void { const pids = findEnginePidsByPort(getRestPort()); const enginePid = pids[0]; + if (enginePid) { + // A Docker-forwarded port is held by the VM/proxy process + // (com.docker.backend, vpnkit, ...), not the engine. Adopting it + // as kind:"native" would make a later `stop` SIGTERM that process. + const comm = pidCommand(enginePid); + if (isForeignPortHolder(comm)) { + vlog( + `adoptRunningEngine: refusing to adopt pid ${enginePid} (${comm}) — Docker/VM port holder, not a native engine`, + ); + return; + } + } if (enginePid && !existingPid) { writeEnginePidfile(enginePid); } @@ -2542,6 +2554,25 @@ async function signalAndWait( return !pidAlive(pid); } +function pidCommand(pid: number): string { + if (IS_WINDOWS) return ""; + try { + return execFileSync("ps", ["-p", String(pid), "-o", "comm="], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function isForeignPortHolder(comm: string): boolean { + if (!comm) return false; + return /docker|vpnkit|qemu|virtualization|colima|lima|podman|orbstack/i.test( + comm, + ); +} + function findEnginePidsByPort(port: number): number[] { if (IS_WINDOWS) return []; const lsof = whichBinary("lsof"); @@ -2581,15 +2612,55 @@ async function stopDockerEngine(composeFile: string, port: number): Promise + new RegExp(`^\\s{2}${svc}:`, "m").test(composeText), + ); + if (ownServices.length === 0) { + p.log.error( + `${composeFile} does not define the agentmemory services (iii-engine/iii-init). Refusing to run an unscoped \`docker compose down\` against it — that would tear down every service in the file.\n\nStop the engine service manually:\n docker compose -f ${composeFile} stop `, + ); + process.exit(1); + } + const ok = runCommand( + dockerBin, + ["compose", "-f", composeFile, "rm", "-s", "-f", ...ownServices], + { + label: `docker compose -f ${composeFile} rm -s -f ${ownServices.join(" ")}`, + }, + ); clearEnginePidfile(); clearEngineState(); clearWorkerPidfile(); if (!ok) { p.log.error( - `docker compose down failed. The engine may still be running on :${port}. Inspect with:\n docker compose -f ${composeFile} ps`, + `docker compose rm failed. The engine may still be running on :${port}. Inspect with:\n docker compose -f ${composeFile} ps`, ); process.exit(1); } @@ -2710,8 +2781,17 @@ async function runStop(): Promise { s.stop(ok ? `Stopped worker pid ${pid}` : `Failed to stop worker pid ${pid}`); if (!ok) allStopped = false; } + const skippedForeign: Array<{ pid: number; comm: string }> = []; for (const pid of candidates) { if (workerCandidates.has(pid)) continue; + // Last-line guard against a stale/poisoned pidfile or a Docker + // port-forward holding :port — signaling com.docker.backend kills + // Docker Desktop's whole backend. + const comm = pidCommand(pid); + if (!force && isForeignPortHolder(comm)) { + skippedForeign.push({ pid, comm }); + continue; + } const s = p.spinner(); s.start(`Stopping iii-engine (pid ${pid})...`); const ok = await signalAndWait(pid, "SIGTERM", 3000); @@ -2722,6 +2802,15 @@ async function runStop(): Promise { clearEnginePidfile(); clearEngineState(); clearWorkerPidfile(); + if (skippedForeign.length > 0) { + const list = skippedForeign + .map((sf) => ` pid ${sf.pid} ${sf.comm}`) + .join("\n"); + p.log.error( + `Refused to signal Docker/VM process(es) holding :${port} — they are not the iii engine:\n${list}\n\nIf the engine runs in Docker, stop it there:\n docker compose ps && docker compose rm -s -f \n\nOr re-run with --force to signal them anyway.`, + ); + process.exit(1); + } if (!allStopped) { p.log.error("One or more processes survived SIGKILL. Investigate with `ps`."); process.exit(1); diff --git a/src/functions/observe.ts b/src/functions/observe.ts index 8ad4ba0ff..cf94edc38 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -68,10 +68,15 @@ export function registerObserveFunction( ? (payload.data as Record) : {}; const toolName = (d["tool_name"] as string) || payload.hookType; + // Hooks without tool_input (prompt_submit, notifications, lifecycle) + // must hash their actual payload — hashing the shared undefined would + // collapse every event of that hook type into one dedup key and + // silently drop all but the first within the TTL window. + const dedupInput = d["tool_input"] !== undefined ? d["tool_input"] : d; dedupHash = dedupMap.computeHash( payload.sessionId, toolName, - d["tool_input"], + dedupInput, ); if (dedupMap.isDuplicate(dedupHash)) { return { deduplicated: true, sessionId: payload.sessionId }; diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index 5eacd12e1..e5671432a 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -40,13 +40,10 @@ async function main() { const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; - fetch(`${REST_URL}/agentmemory/summarize`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(120000), - }).catch(() => {}); - + // session/end fans out event::session::stopped, whose handler already + // runs mem::summarize (plus slot-reflect and graph-extract). A separate + // /agentmemory/summarize POST here dispatched a second full summarize + // for every Stop hook. fetch(`${REST_URL}/agentmemory/session/end`, { method: "POST", headers: authHeaders(), diff --git a/test/observe-dedup-prompt.test.ts b/test/observe-dedup-prompt.test.ts new file mode 100644 index 000000000..711bb7ccc --- /dev/null +++ b/test/observe-dedup-prompt.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { + const m = store.get(scope); + if (!m) return; + const v = (m.get(key) as Record) ?? {}; + for (const u of updates) v[u.path] = u.value; + m.set(key, v); + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: ( + idOrOpts: string | { id: string }, + fn: Function, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +function observePayload(hookType: string, data: unknown) { + return { + sessionId: "ses_dedup_test", + project: "/home/user/myrepo", + cwd: "/home/user/myrepo", + hookType, + timestamp: new Date().toISOString(), + data, + }; +} + +describe("observe dedup for hooks without tool_input (#1173)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("records consecutive prompt_submit observations with different prompts", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const { DedupMap } = await import("../src/functions/dedup.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never, new DedupMap()); + + const first = (await sdk.trigger( + "mem::observe", + observePayload("prompt_submit", { prompt: "ship the helm chart" }), + )) as { observationId?: string; deduplicated?: boolean }; + const second = (await sdk.trigger( + "mem::observe", + observePayload("prompt_submit", { prompt: "now fix the failing test" }), + )) as { observationId?: string; deduplicated?: boolean }; + + expect(first.observationId).toBeTruthy(); + expect(second.deduplicated).toBeUndefined(); + expect(second.observationId).toBeTruthy(); + }); + + it("still dedups an identical prompt_submit within the TTL window", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const { DedupMap } = await import("../src/functions/dedup.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never, new DedupMap()); + + const payload = { prompt: "ship the helm chart" }; + const first = (await sdk.trigger( + "mem::observe", + observePayload("prompt_submit", payload), + )) as { observationId?: string }; + const second = (await sdk.trigger( + "mem::observe", + observePayload("prompt_submit", payload), + )) as { deduplicated?: boolean }; + + expect(first.observationId).toBeTruthy(); + expect(second.deduplicated).toBe(true); + }); + + it("keeps tool_input as the dedup key for tool hooks (response changes still dedup)", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const { DedupMap } = await import("../src/functions/dedup.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never, new DedupMap()); + + const first = (await sdk.trigger( + "mem::observe", + observePayload("post_tool_use", { + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_response: "a.txt", + }), + )) as { observationId?: string }; + const second = (await sdk.trigger( + "mem::observe", + observePayload("post_tool_use", { + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_response: "b.txt", + }), + )) as { deduplicated?: boolean }; + + expect(first.observationId).toBeTruthy(); + expect(second.deduplicated).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..a391bdff1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Tests must never see the developer's real $HOME: config.ts reads +// ~/.agentmemory/.env underneath process.env, so asserted defaults would +// silently become whatever the local install happens to set — red on clean +// checkouts, green on machines whose .env masks a broken default. Point HOME +// at a throwaway directory for the whole run; tests that need home-dir state +// create their own sandbox and reset HOME themselves. +const testHome = join(tmpdir(), "agentmemory-test-home"); + +export default defineConfig({ + test: { + env: { + HOME: testHome, + USERPROFILE: testHome, + }, + }, +}); From 5bb4863c7f28a400c671d94f7e64adf4565a0e41 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 12:42:00 +0100 Subject: [PATCH 02/36] fix(viewer): live stream port discovery, fresh tab data, honest states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve the stream WebSocket target from /agentmemory/livez (new streamsPort field) instead of viewerPort-1 arithmetic, which pointed at the wrong server whenever the viewer bound a fallback port and silently degraded live updates to 10s polling — verified reaching 'live' on the fallback-port case - refetch tab data on every tab entry; the loaded-once cache meant a memory saved by the agent never appeared until a hard browser reload (loading placeholders now render only on first load, so background refreshes don't flash) - memories: rows expand on click/Enter to the full stored record — content, id, project, created, supersedes, files — plus a collapsible raw JSON view - graph: a 503 with the structured disabled body renders 'Knowledge graph is off' with the enableHow text and docs link instead of a 'query failed / Retry' error that sends users to server logs - sessions: cards get role=button, tabindex, Enter/Space activation, and the detail panel scrolls into view on select; session ids truncate head…tail so the distinguishing suffix stays visible - style search inputs and toolbar buttons on lessons/actions/crystals/ replay (previously bare native controls); horizontal scroll containment for narrow viewports - demo: only print the semantic-recall success notice when the search actually hit; on 0 hits explain the missing embedding key instead --- src/cli.ts | 21 +++++- src/triggers/api.ts | 12 ++- src/viewer/index.html | 170 +++++++++++++++++++++++++++++++++++------- 3 files changed, 172 insertions(+), 31 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 8e26ee51d..c9ee130ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2385,8 +2385,25 @@ async function runDemoBody(base: string) { ` ${c.dim("→")} ${c.ok(`${r.hits} hit(s)`)}, top: ${r.topTitle.slice(0, 60)}`, ]), "", - c.accent(`Notice: searching "database performance optimization"`), - c.accent(`found the N+1 query fix — keyword matching can't do that.`), + // Only claim the semantic-recall win when the search actually hit. + // Without an embedding key this query returns 0 hits, and asserting + // success over a visibly failed search reads as a lie. + ...(() => { + const semantic = results.find( + (r) => r.query === "database performance optimization", + ); + if (semantic && semantic.hits > 0) { + return [ + c.accent(`Notice: searching "database performance optimization"`), + c.accent(`found the N+1 query fix — keyword matching can't do that.`), + ]; + } + return [ + c.dim(`Note: "database performance optimization" found nothing —`), + c.dim(`semantic recall needs an embedding provider key (e.g.`), + c.dim(`OPENAI_API_KEY or GEMINI_API_KEY in ~/.agentmemory/.env).`), + ]; + })(), "", `Viewer: ${c.url(getViewerUrl())}`, `Clean up with: ${c.dim(`curl -X DELETE "${base}/agentmemory/sessions?project=${demoProject}"`)}`, diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7560e873d..e296f99b7 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -23,6 +23,7 @@ import { detectLlmProviderKind, getAgentId, isAgentScopeIsolated, + loadConfig, } from "../config.js"; type Response = { @@ -167,7 +168,16 @@ export function registerApiTriggers( sdk.registerFunction("api::liveness", async (): Promise => ({ status_code: 200, - body: { status: "ok", service: "agentmemory", viewerPort: getBoundViewerPort(), viewerSkipped: getViewerSkipped() }, + body: { + status: "ok", + service: "agentmemory", + viewerPort: getBoundViewerPort(), + viewerSkipped: getViewerSkipped(), + // The viewer derives its stream WebSocket target from this instead + // of port arithmetic: when the viewer binds a fallback port, + // viewerPort-1 points at the wrong server and live updates die. + streamsPort: loadConfig().streamsPort, + }, }), ); sdk.registerTrigger({ diff --git a/src/viewer/index.html b/src/viewer/index.html index 3efe43425..de7831a2b 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -333,7 +333,7 @@ align-items: center; flex-wrap: wrap; } - .toolbar input, .toolbar select { + .toolbar input, .toolbar select, .search-input { background: var(--bg); border: 1px solid var(--border); color: var(--ink); @@ -342,11 +342,29 @@ outline: none; font-family: var(--font-ui); } - .toolbar input:focus, .toolbar select:focus { + .toolbar input:focus, .toolbar select:focus, .search-input:focus { border-color: var(--ink); box-shadow: 2px 2px 0px 0px var(--border); } .toolbar input { flex: 1; min-width: 200px; } + .toolbar button:not(.btn) { + background: var(--bg); + border: 1px solid var(--border); + color: var(--ink); + padding: 7px 16px; + font-size: 11px; + cursor: pointer; + transition: box-shadow 0.1s, transform 0.1s; + font-family: var(--font-ui); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + } + .toolbar button:not(.btn):hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); } + .toolbar button:not(.btn):active { box-shadow: none; transform: translate(0, 0); } + @media (max-width: 720px) { + .view { overflow-x: auto; } + } .btn { background: var(--bg); @@ -1112,7 +1130,7 @@

agentmemory

activeTab: 'dashboard', dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] }, graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null, queryError: null, truncated: false, totalNodes: 0, totalEdges: 0 }, - memories: { loaded: false, items: [], search: '', typeFilter: '' }, + memories: { loaded: false, items: [], search: '', typeFilter: '', selectedId: null }, timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 }, sessions: { loaded: false, items: [], selectedId: null }, audit: { loaded: false, entries: [], opFilter: '' }, @@ -1150,7 +1168,15 @@

agentmemory

} function shortSessionId(s, n) { var id = sessionId(s); - return id ? id.slice(0, n || 8) : ''; + if (!id) return ''; + var max = n || 8; + if (id.length <= max) return id; + // Session ids share a long common prefix (demo_msuaboq7_...); the + // distinguishing part is the tail. Keep head + tail so truncated + // ids stay tellable apart in lists and dropdowns. + var tail = 6; + var head = Math.max(2, max - tail - 1); + return id.slice(0, head) + '…' + id.slice(-tail); } function sessionDisplayName(s) { var project = s && s.project ? String(s.project).split('/').pop() : ''; @@ -1336,25 +1362,29 @@

agentmemory

} async function loadTab(tab) { + // Refetch on every tab entry. The loaded-once model went stale the + // moment anything wrote through the API after first visit: a memory + // saved by the agent never appeared until a hard browser reload, + // and the 10s poll only refreshes the dashboard. switch(tab) { - case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break; - case 'graph': if (!state.graph.loaded) await loadGraph(); break; - case 'memories': if (!state.memories.loaded) await loadMemories(); break; - case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break; - case 'sessions': if (!state.sessions.loaded) await loadSessions(); break; - case 'lessons': if (!state.lessons.loaded) await loadLessons(); break; - case 'actions': if (!state.actions.loaded) await loadActions(); break; - case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break; - case 'audit': if (!state.audit.loaded) await loadAudit(); break; - case 'activity': if (!state.activity.loaded) await loadActivity(); break; - case 'profile': if (!state.profile.loaded) await loadProfile(); break; + case 'dashboard': await loadDashboard(); break; + case 'graph': await loadGraph(); break; + case 'memories': await loadMemories(); break; + case 'timeline': await loadTimeline(); break; + case 'sessions': await loadSessions(); break; + case 'lessons': await loadLessons(); break; + case 'actions': await loadActions(); break; + case 'crystals': await loadCrystals(); break; + case 'audit': await loadAudit(); break; + case 'activity': await loadActivity(); break; + case 'profile': await loadProfile(); break; case 'replay': if (!state.replay.loaded) await loadReplay(); break; } } async function loadDashboard() { var el = document.getElementById('view-dashboard'); - el.innerHTML = '
Loading dashboard...
'; + if (!state.dashboard.loaded) el.innerHTML = '
Loading dashboard...
'; try { var results = await Promise.all([ api('health', { readErrorBody: true }), @@ -1695,9 +1725,24 @@

agentmemory

var results = await Promise.all([ apiPost('graph/query', { limit: GRAPH_INITIAL_LIMIT }), - apiGet('graph/stats') + api('graph/stats', { readErrorBody: true }) ]); var queryResult = results[0]; + var statsResult = results[1]; + if (statsResult && statsResult.error && statsResult.flag) { + // 503 with a structured body = the feature is off, not broken. + // Rendering this as "query failed / Retry" sends users hunting + // through server logs for an error that isn't one. + state.graph.disabledInfo = statsResult; + state.graph.queryError = null; + state.graph.nodes = []; + state.graph.edges = []; + state.graph.stats = {}; + state.graph.loaded = true; + renderGraphSidebar(); + return; + } + state.graph.disabledInfo = null; if (queryResult === null) { // api() returns null only on non-2xx or a transport error; an // empty graph would come back as { nodes: [], edges: [] }. @@ -1764,6 +1809,17 @@

agentmemory

var html = ''; + if (state.graph.disabledInfo) { + html += '
'; + html += '
Knowledge graph is off
'; + html += '
' + esc(state.graph.disabledInfo.enableHow || 'Set ' + (state.graph.disabledInfo.flag || 'GRAPH_EXTRACTION_ENABLED') + '=true and restart.') + '
'; + if (state.graph.disabledInfo.docsHref) { + html += 'docs →'; + } + html += '
'; + sb.innerHTML = html; + return; + } // #753: error banner stays above the search box so a failed // graph/query doesn't read as "0 nodes". if (state.graph.queryError) { @@ -2401,7 +2457,7 @@

agentmemory

async function loadMemories() { var el = document.getElementById('view-memories'); - el.innerHTML = '
Loading memories...
'; + if (!state.memories.loaded) el.innerHTML = '
Loading memories...
'; // cap at 2000 so the viewer remains responsive on large // corpora. Older endpoints returned the full unbounded list which // hit the iii invocation timeout and the UI fell through to 0. @@ -2484,7 +2540,8 @@

agentmemory

var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10); if (strength > 100) strength = 100; var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)'; - html += ''; + var expanded = state.memories.selectedId === m.id; + html += ''; var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim(); var previewHtml = esc(truncate(preview, 150)); if (search && search.length > 2) { @@ -2505,6 +2562,21 @@

agentmemory

html += '' + esc(formatTime(m.updatedAt)) + ''; html += ''; html += ''; + if (expanded) { + html += ''; + html += '
' + esc(m.content || '') + '
'; + html += '
'; + html += 'id: ' + esc(m.id) + ''; + if (m.project) html += 'project: ' + esc(m.project) + ''; + if (m.createdAt) html += 'created: ' + esc(formatTime(m.createdAt)) + ''; + if (m.supersedes && m.supersedes.length > 0) html += 'supersedes: ' + esc(m.supersedes.join(', ')) + ''; + if (m.files && m.files.length > 0) html += 'files: ' + esc(m.files.join(', ')) + ''; + if (m.sessionIds && m.sessionIds.length > 0) html += 'sessions: ' + m.sessionIds.length + ''; + html += '
'; + html += '
raw record'; + html += '
' + esc(JSON.stringify(m, null, 2)) + '
'; + html += ''; + } }); html += ''; } @@ -2546,7 +2618,7 @@

agentmemory

async function loadTimeline() { var el = document.getElementById('view-timeline'); - el.innerHTML = '
Loading timeline...
'; + if (!state.timeline.loaded) el.innerHTML = '
Loading timeline...
'; var sessResult = await apiGet('sessions'); var sessions = (sessResult && sessResult.sessions) || []; state.timeline.loaded = true; @@ -2772,7 +2844,7 @@

agentmemory

async function loadActivity() { var el = document.getElementById('view-activity'); - el.innerHTML = '
Loading activity...
'; + if (!state.activity.loaded) el.innerHTML = '
Loading activity...
'; var results = await Promise.all([ apiGet('sessions'), apiGet('audit?limit=200') @@ -2909,7 +2981,7 @@

agentmemory

async function loadSessions() { var el = document.getElementById('view-sessions'); - el.innerHTML = '
Loading sessions...
'; + if (!state.sessions.loaded) el.innerHTML = '
Loading sessions...
'; var result = await apiGet('sessions'); state.sessions.items = (result && result.sessions) || []; state.sessions.loaded = true; @@ -2930,7 +3002,7 @@

agentmemory

var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted'; var id = sessionId(s); var selected = id && state.sessions.selectedId === id; - html += '
'; + html += '
'; html += '
' + esc(sessionDisplayName(s)) + ''; html += '' + esc(s.status) + '
'; var preview = s.firstPrompt || s.summary || ''; @@ -2953,6 +3025,15 @@

agentmemory

function selectSession(id) { state.sessions.selectedId = state.sessions.selectedId === id ? null : id; renderSessions(); + // The detail panel renders below the full session list — off-screen + // for any list longer than a few rows. Bring it into view so + // selecting a session visibly does something. + if (state.sessions.selectedId) { + var panel = document.getElementById('session-detail'); + if (panel && panel.scrollIntoView) { + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } } async function renderSessionDetail() { @@ -3076,7 +3157,7 @@

agentmemory

async function loadLessons() { var el = document.getElementById('view-lessons'); - el.innerHTML = '
Loading lessons...
'; + if (!state.lessons.loaded) el.innerHTML = '
Loading lessons...
'; var result = await apiGet('lessons'); state.lessons.items = (result && result.lessons) || []; state.lessons.loaded = true; @@ -3138,7 +3219,7 @@

agentmemory

async function loadActions() { var el = document.getElementById('view-actions'); - el.innerHTML = '
Loading actions...
'; + if (!state.actions.loaded) el.innerHTML = '
Loading actions...
'; var results = await Promise.all([apiGet('actions'), apiGet('frontier')]); state.actions.items = (results[0] && results[0].actions) || []; state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; @@ -3213,7 +3294,7 @@

agentmemory

async function loadCrystals() { var el = document.getElementById('view-crystals'); - el.innerHTML = '
Loading crystals...
'; + if (!state.crystals.loaded) el.innerHTML = '
Loading crystals...
'; var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]); state.crystals.items = (results[0] && results[0].crystals) || []; var lessonMap = {}; @@ -3326,7 +3407,7 @@

agentmemory

async function loadAudit() { var el = document.getElementById('view-audit'); - el.innerHTML = '
Loading audit log...
'; + if (!state.audit.loaded) el.innerHTML = '
Loading audit log...
'; var result = await apiGet('audit?limit=100'); state.audit.entries = (result && result.entries) || []; state.audit.loaded = true; @@ -3859,6 +3940,17 @@

agentmemory

} }); fetchFlags(); + // Keyboard activation for role="button" cards (session items). Click + // delegation alone leaves them unreachable for keyboard and AT users. + document.addEventListener('keydown', function(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + if (!(e.target instanceof Element)) return; + var card = e.target.closest('[role="button"][data-action="select-session"], [role="button"][data-action="select-memory"]'); + if (!card) return; + e.preventDefault(); + card.click(); + }); + document.addEventListener('click', function(e) { if (!(e.target instanceof Element)) return; var target = e.target.closest('[data-action]'); @@ -3939,6 +4031,14 @@

agentmemory

if (sessionId) selectSession(sessionId); return; } + if (action === 'select-memory') { + var memId = target.getAttribute('data-memory-id'); + if (memId) { + state.memories.selectedId = state.memories.selectedId === memId ? null : memId; + renderMemories(); + } + return; + } if (action === 'end-session') { var endSessionId = target.getAttribute('data-session-id'); if (endSessionId) endSession(endSessionId); @@ -4188,7 +4288,21 @@

agentmemory

}); switchTab(tabFromRoute(), { replaceRoute: true }); - connectWs(); + // Resolve the stream WebSocket target from the server before the first + // connect. The old viewerPort-1 arithmetic breaks whenever the viewer + // binds a fallback port (3113 taken → viewer on 3114 → 3113 is another + // HTTP server, not the streams endpoint) — every retry then fails and + // the viewer silently degrades to 10s polling. + (async function initWs() { + try { + var live = await api('livez'); + if (live && typeof live.streamsPort === 'number' && live.streamsPort > 0) { + WS_URL = wsProto + '//' + window.location.hostname + ':' + live.streamsPort; + WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer'; + } + } catch (_) {} + connectWs(); + })(); startDashboardAutoRefresh(); From 943ffff0b46baeb23e645c3dd4258560e0f855e2 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 12:48:13 +0100 Subject: [PATCH 03/36] fix: thread agentId/project through save paths, per-session OpenCode scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REST /agentmemory/remember accepts and forwards agentId to mem::remember; it previously dropped the field so per-request multi-agent scoping was impossible over REST (#1159) - memoryToObservation() carries the memory's agentId into the search-index shape; dropping it made every memory invisible to agent-scoped search (#1160) - MCP memory_save path: the tool schema now exposes agentId, the in-worker MCP server forwards it, and the standalone stdio package parses and forwards both agentId and project — the stdio pipeline previously dropped project even though its schema advertised it (#1197) - opencode plugin: project/cwd attribution is per-session (resolved from the session's own directory at session.created, pruned on session end) instead of module-level state that recorded every session in a multi-directory OpenCode process under whichever repo loaded the plugin first (#1188) Live-verified: memory saved with agentId=agent-alpha is returned by smart-search for agent-alpha and hidden from agent-beta. --- plugin/opencode/agentmemory-capture.ts | 52 +++++++++++++++++++------- src/mcp/server.ts | 5 +++ src/mcp/standalone.ts | 13 +++++++ src/mcp/tools-registry.ts | 6 +++ src/state/memory-utils.ts | 4 ++ src/triggers/api.ts | 4 ++ 6 files changed, 70 insertions(+), 14 deletions(-) diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index 1a1d04268..0162fde60 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -52,11 +52,12 @@ async function observe( hookType: string, data: Record, ): Promise { + const proj = projectFor(sessionId); await post("/observe", { hookType, sessionId, - project: projectName, - cwd: projectCwd, + project: proj.name, + cwd: proj.cwd, timestamp: new Date().toISOString(), data, }); @@ -64,12 +65,20 @@ async function observe( let activeSessionId: string | null = null; let pendingConfig: Record | null = null; -// projectName is the canonical scope (same resolution order as the hooks' -// resolveProject: env override, git toplevel basename, cwd basename) so -// OpenCode sessions land in the same project bucket as every other agent on -// the repo. projectCwd keeps the full path for the cwd field. -let projectName: string | null = null; -let projectCwd: string | null = null; +// Default scope resolved at plugin init (same resolution order as the hooks' +// resolveProject: env override, git toplevel basename, cwd basename). In a +// long-lived OpenCode process serving multiple directories these defaults are +// only a fallback — attribution is per-session via sessionProjects, resolved +// from each session's own directory at session.created. Module-level-only +// state recorded home-directory sessions under whatever repo loaded first. +let defaultProjectName: string | null = null; +let defaultProjectCwd: string | null = null; +const sessionProjects = new Map(); + +function projectFor(sessionId: string): { name: string | null; cwd: string | null } { + const p = sessionProjects.get(sessionId); + return p ?? { name: defaultProjectName, cwd: defaultProjectCwd }; +} function resolveProjectName(dir: string): string { const explicit = process.env.AGENTMEMORY_PROJECT_NAME?.trim(); @@ -119,6 +128,7 @@ function pruneSessionMaps(sid: string): void { stashedFiles.delete(sid); seenSubtaskIds.delete(sid); seenToolCallIds.delete(sid); + sessionProjects.delete(sid); } function safeSlice(v: unknown, max: number): string { @@ -194,8 +204,8 @@ function extractErrorMessage(err: unknown): string { } export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { - projectCwd = ctx.worktree || ctx.project?.id || process.cwd(); - projectName = resolveProjectName(projectCwd); + defaultProjectCwd = ctx.worktree || ctx.project?.id || process.cwd(); + defaultProjectName = resolveProjectName(defaultProjectCwd); return { event: async ({ event }) => { @@ -215,13 +225,27 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { // and another `session.created` event during the await could // rebind it, causing context to be cached against the wrong key. const sessionId = activeSessionId; + // Attribute this session to its own directory when the event + // carries one; a multi-directory OpenCode process otherwise + // records every session under whichever repo loaded the plugin. + const sessionDir = + typeof info?.directory === "string" && info.directory + ? info.directory + : defaultProjectCwd; + if (sessionDir) { + sessionProjects.set(sessionId, { + cwd: sessionDir, + name: resolveProjectName(sessionDir), + }); + } + const proj = projectFor(sessionId); const startResult = await postJson("/session/start", { sessionId, title: info?.title ?? null, parentID: info?.parentID ?? null, version: info?.version ?? null, - project: projectName, - cwd: projectCwd, + project: proj.name, + cwd: proj.cwd, }); // cache the context returned at session/start so the // chat.system.transform hook injects it without a second fetch. @@ -639,7 +663,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { if (typeof ctx !== "string" || ctx.length === 0) { const result = await postJson("/context", { sessionId: sid, - project: projectName, + project: projectFor(sid).name, }); ctx = (result as any)?.context; } else { @@ -677,7 +701,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { const result = await postJson("/context", { sessionId: sid, - project: projectName, + project: projectFor(sid).name, }); const ctx = (result as any)?.context; if (typeof ctx === "string" && ctx.length > 0) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 13240003b..ef26427aa 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -186,6 +186,10 @@ export function registerMcpEndpoints( typeof args.project === "string" && args.project.trim().length > 0 ? args.project.trim() : undefined; + const saveAgentId = + typeof args.agentId === "string" && args.agentId.trim().length > 0 + ? (args.agentId as string).trim() + : undefined; const result = await sdk.trigger({ function_id: "mem::remember", payload: { content: args.content, @@ -193,6 +197,7 @@ export function registerMcpEndpoints( concepts, files, ...(project !== undefined && { project }), + ...(saveAgentId !== undefined && { agentId: saveAgentId }), } }); return { status_code: 200, diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index 1ace150b1..4a8967246 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -99,6 +99,8 @@ interface Validated { type?: string; concepts?: string[]; files?: string[]; + project?: string; + agentId?: string; query?: string; limit?: number; format?: string; @@ -122,6 +124,15 @@ function validate(toolName: string, args: Record): Validated { v.type = (args["type"] as string) || "fact"; v.concepts = normalizeList(args["concepts"]); v.files = normalizeList(args["files"]); + // The tool schema exposes project (and now agentId); dropping them + // here silently broke project/agent scoping through the stdio + // package specifically. + if (typeof args["project"] === "string" && args["project"].trim()) { + v.project = args["project"].trim(); + } + if (typeof args["agentId"] === "string" && args["agentId"].trim()) { + v.agentId = args["agentId"].trim(); + } return v; } case "memory_recall": @@ -180,6 +191,8 @@ async function handleProxy( type: v.type, concepts: v.concepts, files: v.files, + ...(v.project !== undefined && { project: v.project }), + ...(v.agentId !== undefined && { agentId: v.agentId }), }), }); return textResponse(result); diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 464cb3b0c..1225b4ce7 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -83,6 +83,12 @@ export const CORE_TOOLS: McpToolDef[] = [ "started. Do not use filesystem paths or ad-hoc display names — those " + "change across machines and will silently break project scoping.", }, + agentId: { + type: "string", + description: + "Agent identity to scope this memory to. When set, agent-scoped recall " + + "and search only surface it for the same agentId. Omit for shared memory.", + }, }, required: ["content"], }, diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts index aa0bcc5b8..8b8f8d520 100644 --- a/src/state/memory-utils.ts +++ b/src/state/memory-utils.ts @@ -20,5 +20,9 @@ export function memoryToObservation(memory: Memory): CompressedObservation { concepts: memory.concepts, files: memory.files, importance: memory.strength, + // Carry the owning agent through so agent-scoped search filters see + // memories, not just raw observations. Dropping it made every memory + // invisible to any agentId-scoped query. + ...(memory.agentId ? { agentId: memory.agentId } : {}), }; } diff --git a/src/triggers/api.ts b/src/triggers/api.ts index e296f99b7..d52625bb0 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1012,6 +1012,7 @@ export function registerApiTriggers( ttlDays?: number; sourceObservationIds?: string[]; project?: string; + agentId?: string; }>, ): Promise => { const authErr = checkAuth(req, secret); @@ -1039,6 +1040,9 @@ export function registerApiTriggers( ...(req.body.ttlDays !== undefined && { ttlDays: req.body.ttlDays }), ...(req.body.sourceObservationIds !== undefined && { sourceObservationIds: req.body.sourceObservationIds }), ...(req.body.project !== undefined && { project: req.body.project }), + ...(typeof req.body.agentId === "string" && req.body.agentId.trim() + ? { agentId: req.body.agentId.trim() } + : {}), }, }); return { status_code: 201, body: result }; From 7230ec08428891fb856f75194062e90271de767a Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 13:11:24 +0100 Subject: [PATCH 04/36] feat: hybrid recall everywhere, indexed lessons, provenance, recall hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mem::search ranks through the full BM25+vector+graph fusion when the vector index is populated (injected post-boot via setHybridRanker); the primary recall surface was keyword-only while only smart-search got hybrid ranking - fusion weights normalize per item over the streams that actually ranked it, with a small explicit cross-stream agreement bonus; the old every-enabled-stream denominator permanently penalized single-stream hits (the graph stream is empty on default installs). Result order is now deterministic (score, best rank, id) - lessons get a dedicated in-memory BM25 index built lazily from one KV list and maintained incrementally on save/delete/decay; recall previously listed and substring-scanned the whole corpus per query. Confidence x recency composite scoring is unchanged - mem::remember finds supersession candidates through the search index (top-50) instead of walking every memory per save, with a full-scan fallback while the index is cold; near-miss similarity (0.4-0.7) is reported back as an advisory similarTo hint - superseded memory versions leave the BM25 and vector indexes; the version chain stays in KV for history, but recall no longer returns an outdated fact as if current - every observation and memory now carries an immutable origin block (channel: user|agent|tool|import|shared, detail, capturedAt) stamped at capture, save, and import, and inherited through both compression paths — the base for trust-aware retrieval and ingest screening - regression tests: supersede index removal, similarTo hint, index-backed candidate discovery, lesson index recall/lazy rebuild/delete --- src/functions/compress-synthetic.ts | 1 + src/functions/compress.ts | 1 + src/functions/export-import.ts | 9 ++ src/functions/lessons.ts | 111 +++++++++++++++------ src/functions/observe.ts | 17 ++++ src/functions/remember.ts | 56 ++++++++++- src/functions/replay.ts | 5 + src/functions/search.ts | 28 +++++- src/index.ts | 2 + src/state/hybrid-search.ts | 71 ++++++++------ src/types.ts | 17 ++++ test/lesson-index-recall.test.ts | 129 +++++++++++++++++++++++++ test/remember-supersede-recall.test.ts | 128 ++++++++++++++++++++++++ 13 files changed, 516 insertions(+), 59 deletions(-) create mode 100644 test/lesson-index-recall.test.ts create mode 100644 test/remember-supersede-recall.test.ts diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts index 28d17e979..14f757ce1 100644 --- a/src/functions/compress-synthetic.ts +++ b/src/functions/compress-synthetic.ts @@ -102,5 +102,6 @@ export function buildSyntheticCompression( if (raw.modality) result.modality = raw.modality; if (raw.imageData) result.imageData = raw.imageData; if (raw.agentId) result.agentId = raw.agentId; + if (raw.origin) result.origin = raw.origin; return result; } diff --git a/src/functions/compress.ts b/src/functions/compress.ts index 0569555e0..c2019e7d6 100644 --- a/src/functions/compress.ts +++ b/src/functions/compress.ts @@ -166,6 +166,7 @@ export function registerCompressFunction( ...(imageDescription ? { imageDescription } : {}), ...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}), ...(data.raw.agentId ? { agentId: data.raw.agentId } : {}), + ...(data.raw.origin ? { origin: data.raw.origin } : {}), }; await kv.set( diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index 23854a97f..e3a961eeb 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -428,6 +428,12 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { return; } } + // Imported records enter through a different trust boundary than + // live capture; keep the source's own origin when the export + // carried one, otherwise mark the import channel. + if (!o.origin) { + o.origin = { channel: "import", capturedAt: o.timestamp }; + } await kv.set(KV.observations(sessionId), o.id, o); stats.observations++; indexObs.push(o); @@ -448,6 +454,9 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { if (!Array.isArray(memory.sessionIds)) { memory.sessionIds = []; } + if (!memory.origin) { + memory.origin = { channel: "import", capturedAt: memory.createdAt }; + } await kv.set(KV.memories, memory.id, memory); stats.memories++; indexMems.push(memory); diff --git a/src/functions/lessons.ts b/src/functions/lessons.ts index 0314298ce..4eb3f0ce3 100644 --- a/src/functions/lessons.ts +++ b/src/functions/lessons.ts @@ -1,9 +1,56 @@ import type { ISdk } from "iii-sdk"; import type { StateKV } from "../state/kv.js"; import { KV, fingerprintId } from "../state/schema.js"; -import type { Lesson } from "../types.js"; +import type { CompressedObservation, Lesson } from "../types.js"; +import { SearchIndex } from "../state/search-index.js"; import { recordAudit } from "./audit.js"; +// Dedicated BM25 index for lessons. Recall previously listed every +// lesson from KV and substring-matched per query — O(corpus) per call +// with no term weighting. The index is in-memory and built lazily from +// one KV list (the same cost a single recall used to pay), then kept +// current incrementally on save/delete. Confidence x recency reranking +// stays exactly as before — the index only replaces the relevance term. +let lessonIndex: SearchIndex | null = null; +let lessonIndexBuild: Promise | null = null; + +function lessonToIndexDoc(l: Lesson): CompressedObservation { + return { + id: l.id, + sessionId: "lesson", + timestamp: l.createdAt, + type: "decision", + title: l.content.slice(0, 120), + facts: [l.content], + narrative: l.context || "", + concepts: l.tags, + files: [], + importance: l.confidence, + }; +} + +async function ensureLessonIndex(kv: StateKV): Promise { + if (lessonIndex) return lessonIndex; + if (!lessonIndexBuild) { + lessonIndexBuild = (async () => { + const idx = new SearchIndex(); + const all = await kv.list(KV.lessons); + for (const l of all) { + if (!l.deleted) idx.add(lessonToIndexDoc(l)); + } + lessonIndex = idx; + })().finally(() => { + lessonIndexBuild = null; + }); + } + await lessonIndexBuild; + return lessonIndex!; +} + +export function __resetLessonIndex(): void { + lessonIndex = null; +} + function reinforceLesson(lesson: Lesson): void { const now = new Date().toISOString(); lesson.reinforcements++; @@ -77,6 +124,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { }; await kv.set(KV.lessons, lesson.id, lesson); + if (lessonIndex) lessonIndex.add(lessonToIndexDoc(lesson)); try { await recordAudit(kv, "lesson_save", "mem::lesson-save", [lesson.id]); @@ -97,41 +145,42 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { return { success: false, error: "query is required" }; } - const query = data.query.toLowerCase(); const minConfidence = data.minConfidence ?? 0.1; const limit = data.limit ?? 10; - let lessons = await kv.list(KV.lessons); + // BM25 over the lesson index picks candidates; the composite score + // (confidence x relevance x recency) is unchanged — only the + // relevance term moved from per-call substring counting to a + // weighted index lookup. + const idx = await ensureLessonIndex(kv); + const hits = idx.search(data.query, Math.max(limit * 5, 50)); + const maxHit = hits.length > 0 ? hits[0].score : 0; - lessons = lessons.filter( - (l) => !l.deleted && l.confidence >= minConfidence, + const loaded = await Promise.all( + hits.map((h) => kv.get(KV.lessons, h.obsId).catch(() => null)), ); - if (data.project) { - lessons = lessons.filter((l) => l.project === data.project); + const scored: Array<{ lesson: Lesson; score: number }> = []; + for (let i = 0; i < hits.length; i++) { + const l = loaded[i]; + if (!l || l.deleted || l.confidence < minConfidence) continue; + if (data.project && l.project !== data.project) continue; + + const relevance = maxHit > 0 ? hits[i].score / maxHit : 0; + const daysSinceReinforced = l.lastReinforcedAt + ? (Date.now() - new Date(l.lastReinforcedAt).getTime()) / + (1000 * 60 * 60 * 24) + : (Date.now() - new Date(l.createdAt).getTime()) / + (1000 * 60 * 60 * 24); + const recencyBoost = 1 / (1 + daysSinceReinforced * 0.01); + scored.push({ lesson: l, score: l.confidence * relevance * recencyBoost }); } - const scored = lessons - .map((l) => { - const text = `${l.content} ${l.context} ${l.tags.join(" ")}`.toLowerCase(); - const terms = query.split(/\s+/).filter((t) => t.length > 1); - const matchCount = terms.filter((t) => text.includes(t)).length; - if (matchCount === 0) return null; - - const relevance = matchCount / terms.length; - const daysSinceReinforced = l.lastReinforcedAt - ? (Date.now() - new Date(l.lastReinforcedAt).getTime()) / - (1000 * 60 * 60 * 24) - : (Date.now() - new Date(l.createdAt).getTime()) / - (1000 * 60 * 60 * 24); - const recencyBoost = 1 / (1 + daysSinceReinforced * 0.01); - const score = l.confidence * relevance * recencyBoost; - - return { lesson: l, score }; - }) - .filter(Boolean) as Array<{ lesson: Lesson; score: number }>; - - scored.sort((a, b) => b.score - a.score); + scored.sort( + (a, b) => + b.score - a.score || + (a.lesson.id < b.lesson.id ? -1 : a.lesson.id > b.lesson.id ? 1 : 0), + ); try { await recordAudit(kv, "lesson_recall", "mem::lesson-recall", [], { @@ -218,6 +267,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { lesson.updatedAt = new Date().toISOString(); await kv.set(KV.lessons, lesson.id, lesson); + if (lessonIndex) lessonIndex.remove(lesson.id); try { await recordAudit(kv, "lesson_delete", "mem::lesson-delete", [ @@ -285,6 +335,11 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { } await Promise.all(dirty.map((l) => kv.set(KV.lessons, l.id, l))); + if (lessonIndex) { + for (const l of dirty) { + if (l.deleted) lessonIndex.remove(l.id); + } + } await Promise.all( auditEvents.map((event) => recordAudit(kv, "lesson_strengthen", "mem::lesson-decay-sweep", [event.id], { diff --git a/src/functions/observe.ts b/src/functions/observe.ts index cf94edc38..2b4e67d7f 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -92,12 +92,28 @@ export function registerObserveFunction( sanitizedRaw = stripPrivateData(String(payload.data)); } + // Stamp which trust boundary this content crossed. Tool hooks carry + // whatever the tool returned (file contents, command output, web + // pages) — content the user never wrote; prompt_submit is the user's + // own words; everything else originates from the agent runtime. + const originChannel = + payload.hookType === "prompt_submit" + ? ("user" as const) + : payload.hookType === "pre_tool_use" || + payload.hookType === "post_tool_use" || + payload.hookType === "post_tool_failure" + ? ("tool" as const) + : ("agent" as const); const raw: RawObservation = { id: obsId, sessionId: payload.sessionId, timestamp: payload.timestamp, hookType: payload.hookType, raw: sanitizedRaw, + origin: { + channel: originChannel, + capturedAt: payload.timestamp, + }, }; let extractedImage: string | undefined; @@ -111,6 +127,7 @@ export function registerObserveFunction( raw.toolName = d["tool_name"] as string | undefined; raw.toolInput = d["tool_input"]; raw.toolOutput = d["tool_output"] || d["error"]; + if (raw.origin && raw.toolName) raw.origin.detail = raw.toolName; } if (payload.hookType === "prompt_submit") { raw.userPrompt = d["prompt"] as string | undefined; diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 759fddb5f..7372352dc 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -69,12 +69,37 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { : undefined; return withKeyedLock("mem:remember", async () => { - const existingMemories = await kv.list(KV.memories); + // Candidate generation: query the BM25 index with the new content + // and Jaccard-compare only the top hits, instead of walking the + // full memory corpus on every save. The index receives every + // memory at save time and is rebuilt at boot, so it covers the + // corpus whenever it is non-empty; a cold, never-queried index + // falls back to the full scan so supersession never silently + // stops working. + const idx = getSearchIndex(); + let candidateMemories: Memory[]; + if (idx.size > 0) { + // 50 hits, not 20: the shared index also holds observations, + // which occupy slots but never resolve to memories below. A + // >0.7-Jaccard duplicate shares most tokens with the query so + // it ranks near the top regardless. + const hits = idx.search(data.content, 50); + const loaded = await Promise.all( + hits.map((h) => kv.get(KV.memories, h.obsId).catch(() => null)), + ); + candidateMemories = loaded.filter((m): m is Memory => m !== null); + } else { + candidateMemories = await kv.list(KV.memories); + } let supersededId: string | undefined; let supersededVersion = 1; let supersededMemory: Memory | undefined; + // Track the closest sub-threshold match: not similar enough to + // supersede, but similar enough that the caller may want to + // consolidate. Reported back as a hint; never acted on here. + let nearMatch: { id: string; title: string; similarity: number } | undefined; const lowerContent = data.content.toLowerCase(); - for (const existing of existingMemories) { + for (const existing of candidateMemories) { if (existing.isLatest === false) continue; // Never supersede a memory that belongs to a different project. // Both sides must have an explicit project for the guard to engage; @@ -93,6 +118,16 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { supersededMemory = existing; break; } + if ( + similarity > 0.4 && + (!nearMatch || similarity > nearMatch.similarity) + ) { + nearMatch = { + id: existing.id, + title: existing.title, + similarity: Math.round(similarity * 100) / 100, + }; + } } // stamp the agent role on the memory so future recall can @@ -122,6 +157,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { (id): id is string => typeof id === "string" && id.length > 0, ), isLatest: true, + origin: { channel: "agent", capturedAt: now }, ...(callAgentId ? { agentId: callAgentId } : {}), ...(project !== undefined && { project }), }; @@ -133,6 +169,14 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { if (supersededMemory) { supersededMemory.isLatest = false; await kv.set(KV.memories, supersededMemory.id, supersededMemory); + // The superseded version stays in KV (the viewer's version + // chain reads it there) but leaves both search indexes: + // recall returning an outdated fact as if current is worse + // than returning nothing. + try { + getSearchIndex().remove(supersededMemory.id); + } catch {} + vectorIndexRemove(supersededMemory.id); } await kv.set(KV.memories, memory.id, memory); @@ -171,7 +215,13 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { type: memory.type, project: memory.project, }); - return { success: true, memory }; + // similarTo is advisory only: a close-but-not-superseding match + // the caller may want to consolidate via memory_update/forget. + return { + success: true, + memory, + ...(nearMatch && !supersededId ? { similarTo: nearMatch } : {}), + }; }); }, ); diff --git a/src/functions/replay.ts b/src/functions/replay.ts index e91850503..8e3f40833 100644 --- a/src/functions/replay.ts +++ b/src/functions/replay.ts @@ -436,6 +436,11 @@ export function registerReplayFunctions(sdk: ISdk, kv: StateKV): void { await Promise.all( parsed.observations.map(async (obs) => { const synthetic = buildSyntheticCompression(obs); + synthetic.origin = { + channel: "import", + detail: "jsonl", + capturedAt: synthetic.timestamp, + }; compressed.push(synthetic); await kv.set(KV.observations(parsed.sessionId), obs.id, synthetic); }), diff --git a/src/functions/search.ts b/src/functions/search.ts index 9bcda6ae0..0944663fd 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -14,6 +14,22 @@ let index: SearchIndex | null = null let vectorIndex: VectorIndex | null = null let currentEmbeddingProvider: EmbeddingProvider | null = null +// Hybrid ranking hook for mem::search. Wired by index.ts once the +// hybrid searcher exists (it is constructed after this module's +// registration runs). When set and the vector index has entries, +// mem::search ranks candidates through the full BM25+vector+graph +// fusion instead of BM25 alone — previously only mem::smart-search got +// hybrid ranking while the primary recall surface stayed keyword-only. +type HybridRanker = ( + query: string, + limit: number, +) => Promise> +let hybridRanker: HybridRanker | null = null + +export function setHybridRanker(fn: HybridRanker | null): void { + hybridRanker = fn +} + // Dedupes the lazy cold-start rebuild kicked off from the mem::search // request path. A full rebuildIndex walks every observation across every // session, so N concurrent queries against an empty index would each @@ -448,7 +464,17 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // rank lower than cross-agent ones in the hybrid score. const filtering = !!(projectFilter || cwdFilter || filterAgentId) const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit - const results = idx.search(query, fetchLimit) + let results: Array<{ obsId: string; sessionId: string; score: number }> + if (hybridRanker && vectorIndex && vectorIndex.size > 0) { + const hybrid = await hybridRanker(query, fetchLimit) + results = hybrid.map((r) => ({ + obsId: r.observation.id, + sessionId: r.sessionId, + score: r.combinedScore, + })) + } else { + results = idx.search(query, fetchLimit) + } // Resolve session -> project/cwd once per sessionId we touch. const sessionCache = new Map() diff --git a/src/index.ts b/src/index.ts index 198a6dc3d..70de7cb83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import { setVectorIndex, setEmbeddingProvider, setIndexPersistence, + setHybridRanker, } from "./functions/search.js"; import { registerContextFunction } from "./functions/context.js"; import { registerSummarizeFunction } from "./functions/summarize.js"; @@ -389,6 +390,7 @@ async function main() { registerSmartSearchFunction(sdk, kv, (query, limit) => hybridSearch.search(query, limit), ); + setHybridRanker((query, limit) => hybridSearch.search(query, limit)); registerRecentSearchesSweepFunction(sdk, kv); registerApiTriggers(sdk, kv, secret, metricsStore, provider); diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts index d234a3efc..910595e31 100644 --- a/src/state/hybrid-search.ts +++ b/src/state/hybrid-search.ts @@ -191,34 +191,51 @@ export class HybridSearch { } }); - const hasVector = vectorResults.length > 0; - const hasGraph = graphResults.length > 0; - - let effectiveBm25W = this.bm25Weight; - let effectiveVectorW = hasVector ? this.vectorWeight : 0; - let effectiveGraphW = hasGraph ? this.graphWeight : 0; - - const totalW = effectiveBm25W + effectiveVectorW + effectiveGraphW; - if (totalW > 0) { - effectiveBm25W /= totalW; - effectiveVectorW /= totalW; - effectiveGraphW /= totalW; - } - - const combined = Array.from(scores.entries()).map(([obsId, s]) => ({ - obsId, - sessionId: s.sessionId, - bm25Score: s.bm25Score, - vectorScore: s.vectorScore, - graphScore: s.graphScore, - graphContext: s.graphContext, - combinedScore: - effectiveBm25W * (1 / (RRF_K + s.bm25Rank)) + - effectiveVectorW * (1 / (RRF_K + s.vectorRank)) + - effectiveGraphW * (1 / (RRF_K + s.graphRank)), - })); + // Weight fusion per item over the streams that actually ranked it. + // Normalizing over every enabled stream caps a single-stream hit at + // weight/(RRF_K+1) no matter how strong it is — with the graph stream + // empty on default installs, a #1 BM25 result carried a permanent + // penalty against anything two streams agreed on. Per-item + // normalization puts single-stream and multi-stream hits on the same + // scale; cross-stream agreement earns a small explicit bonus instead + // of an implicit one baked into the denominator. + const AGREEMENT_BONUS = 0.05; + const combined = Array.from(scores.entries()).map(([obsId, s]) => { + const wB = Number.isFinite(s.bm25Rank) ? this.bm25Weight : 0; + const wV = Number.isFinite(s.vectorRank) ? this.vectorWeight : 0; + const wG = Number.isFinite(s.graphRank) ? this.graphWeight : 0; + const wSum = wB + wV + wG; + const matchedStreams = + (wB > 0 ? 1 : 0) + (wV > 0 ? 1 : 0) + (wG > 0 ? 1 : 0); + const rrf = + wSum > 0 + ? (wB * (1 / (RRF_K + s.bm25Rank)) + + wV * (1 / (RRF_K + s.vectorRank)) + + wG * (1 / (RRF_K + s.graphRank))) / + wSum + : 0; + const minRank = Math.min(s.bm25Rank, s.vectorRank, s.graphRank); + return { + obsId, + sessionId: s.sessionId, + bm25Score: s.bm25Score, + vectorScore: s.vectorScore, + graphScore: s.graphScore, + graphContext: s.graphContext, + combinedScore: rrf * (1 + AGREEMENT_BONUS * (matchedStreams - 1)), + minRank, + }; + }); - combined.sort((a, b) => b.combinedScore - a.combinedScore); + // Deterministic order: score, then best single-stream rank, then id — + // equal-scored results previously came back in Map-insertion order, + // which varies with which stream answered first. + combined.sort( + (a, b) => + b.combinedScore - a.combinedScore || + a.minRank - b.minRank || + (a.obsId < b.obsId ? -1 : a.obsId > b.obsId ? 1 : 0), + ); const retrievalDepth = Math.max(limit, 20); const rerankWindow = 20; diff --git a/src/types.ts b/src/types.ts index 2f3f0285f..fe77407d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,20 @@ export interface CommitLink { linkedAt: string; } +// Immutable write-time provenance. `channel` records which trust +// boundary the content crossed to get here: text a user typed, the +// agent's own reasoning, output returned by a tool, an imported +// transcript, or a record shared in from another agent. Derived +// records (compressed observations, memories distilled from them) +// carry their source's origin forward so downstream consumers can +// always answer "where did this come from" — the precondition for any +// future trust-tiered retrieval or ingest screening. +export interface Origin { + channel: "user" | "agent" | "tool" | "import" | "shared"; + detail?: string; + capturedAt: string; +} + export interface RawObservation { id: string; sessionId: string; @@ -41,6 +55,7 @@ export interface RawObservation { modality?: "text" | "image" | "mixed"; imageData?: string; agentId?: string; + origin?: Origin; } export interface CompressedObservation { @@ -61,6 +76,7 @@ export interface CompressedObservation { imageDescription?: string; modality?: "text" | "image" | "mixed"; agentId?: string; + origin?: Origin; } export type ObservationType = @@ -102,6 +118,7 @@ export interface Memory { imageData?: string; agentId?: string; project?: string; + origin?: Origin; } export interface SessionSummary { diff --git a/test/lesson-index-recall.test.ts b/test/lesson-index-recall.test.ts new file mode 100644 index 000000000..543544cb2 --- /dev/null +++ b/test/lesson-index-recall.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async () => {}, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +async function setup() { + vi.resetModules(); + const { registerLessonsFunctions } = await import("../src/functions/lessons.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerLessonsFunctions(sdk as never, kv as never); + return { sdk, kv }; +} + +describe("lesson recall through the lesson index", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("recalls a saved lesson by keyword and preserves confidence ordering", async () => { + const { sdk } = await setup(); + await sdk.trigger("mem::lesson-save", { + content: "always run migrations inside a transaction", + confidence: 0.9, + tags: ["database"], + }); + await sdk.trigger("mem::lesson-save", { + content: "database migrations need a rollback script committed alongside", + confidence: 0.3, + tags: ["database"], + }); + + const res = (await sdk.trigger("mem::lesson-recall", { + query: "database migrations", + })) as { success: boolean; lessons: Array<{ content: string; score: number }> }; + + expect(res.success).toBe(true); + expect(res.lessons.length).toBe(2); + expect(res.lessons[0].content).toContain("transaction"); + expect(res.lessons[0].score).toBeGreaterThan(res.lessons[1].score); + }); + + it("recalls lessons that existed before the index was built (lazy rebuild)", async () => { + const { sdk, kv } = await setup(); + await kv.set("mem:lessons", "lsn_pre", { + id: "lsn_pre", + content: "verify wire payloads at the boundary before trusting them", + context: "", + confidence: 0.8, + reinforcements: 2, + source: "manual", + sourceIds: [], + tags: ["verification"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + decayRate: 0.05, + }); + + const res = (await sdk.trigger("mem::lesson-recall", { + query: "wire payloads boundary", + })) as { lessons: Array<{ id: string }> }; + + expect(res.lessons.map((l) => l.id)).toContain("lsn_pre"); + }); + + it("stops returning deleted lessons", async () => { + const { sdk } = await setup(); + const saved = (await sdk.trigger("mem::lesson-save", { + content: "prefer streaming responses over polling loops", + confidence: 0.7, + })) as { lesson: { id: string } }; + + let res = (await sdk.trigger("mem::lesson-recall", { + query: "streaming polling", + })) as { lessons: Array<{ id: string }> }; + expect(res.lessons.map((l) => l.id)).toContain(saved.lesson.id); + + await sdk.trigger("mem::lesson-delete", { lessonId: saved.lesson.id }); + + res = (await sdk.trigger("mem::lesson-recall", { + query: "streaming polling", + })) as { lessons: Array<{ id: string }> }; + expect(res.lessons.map((l) => l.id)).not.toContain(saved.lesson.id); + }); +}); diff --git a/test/remember-supersede-recall.test.ts b/test/remember-supersede-recall.test.ts new file mode 100644 index 000000000..f01916854 --- /dev/null +++ b/test/remember-supersede-recall.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async () => {}, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +async function setup() { + vi.resetModules(); + const search = await import("../src/functions/search.js"); + const { registerRememberFunction } = await import("../src/functions/remember.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + return { sdk, kv, search }; +} + +describe("mem::remember supersession and recall hygiene", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("removes the superseded version from the search index", async () => { + const { sdk, search } = await setup(); + const first = (await sdk.trigger("mem::remember", { + content: "the deploy pipeline uses blue green rollout with health gates", + type: "architecture", + })) as { memory: { id: string } }; + const idx = search.getSearchIndex(); + expect(idx.has(first.memory.id)).toBe(true); + + const second = (await sdk.trigger("mem::remember", { + content: "the deploy pipeline uses blue green rollout with health gates always", + type: "architecture", + })) as { memory: { id: string; supersedes: string[] } }; + + expect(second.memory.supersedes).toContain(first.memory.id); + expect(idx.has(first.memory.id)).toBe(false); + expect(idx.has(second.memory.id)).toBe(true); + }); + + it("reports a close-but-below-threshold match as similarTo without superseding", async () => { + const { sdk } = await setup(); + const first = (await sdk.trigger("mem::remember", { + content: "redis cache layer fronting the primary database for hot reads", + type: "architecture", + })) as { memory: { id: string } }; + + const second = (await sdk.trigger("mem::remember", { + content: "redis cache layer fronting the primary database misses cold writes entirely", + type: "architecture", + })) as { + memory: { id: string; version: number }; + similarTo?: { id: string; similarity: number }; + }; + + expect(second.memory.version).toBe(1); + if (second.similarTo) { + expect(second.similarTo.id).toBe(first.memory.id); + expect(second.similarTo.similarity).toBeGreaterThan(0.4); + expect(second.similarTo.similarity).toBeLessThanOrEqual(0.7); + } + }); + + it("still finds the supersession target through index-backed candidates", async () => { + const { sdk } = await setup(); + for (let i = 0; i < 30; i++) { + await sdk.trigger("mem::remember", { + content: `unrelated filler memory number ${i} about topic-${i} with words w${i}a w${i}b`, + type: "fact", + }); + } + const target = (await sdk.trigger("mem::remember", { + content: "session tokens rotate every fifteen minutes via the auth broker", + type: "workflow", + })) as { memory: { id: string } }; + + const update = (await sdk.trigger("mem::remember", { + content: "session tokens rotate every fifteen minutes via the auth broker service", + type: "workflow", + })) as { memory: { supersedes: string[]; version: number } }; + + expect(update.memory.supersedes).toContain(target.memory.id); + expect(update.memory.version).toBe(2); + }); +}); From e81361622ebb1dbb5cbe93e7a37a7b381fa20bc8 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 13:18:05 +0100 Subject: [PATCH 05/36] feat(viewer): two-pane sessions, navigable dashboard, motion and copy polish - sessions: list + sticky detail panel side by side above 1100px (the detail previously rendered below the whole list, off-screen on any real corpus); selected/hover/active states with reserved left border so selection doesn't shift layout - dashboard stat cards for sessions/memories/lessons/crystals/graph navigate to their tabs (click or Enter), with hover affordance - observation subtitles that are raw serialized tool input now display the meaningful field (file path, command, pattern, url) instead of a JSON blob - expanded memory rows show the new origin provenance (channel + detail) - motion: 160ms view entrance, live-badge pulse, both gated behind prefers-reduced-motion; tabular numerals in tables - mobile: header stops wrapping the dateline into the badge row - lessons/crystals empty-state copy aligned with the header definitions (each concept was described two conflicting ways) --- src/viewer/index.html | 102 +++++++++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index de7831a2b..3e84d977b 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -139,6 +139,10 @@ align-items: center; gap: 12px; } + @media (max-width: 720px) { + .app-header { flex-wrap: wrap; row-gap: 6px; padding: 10px 16px; } + .app-header .dateline { display: none; } + } .ws-status { font-size: 10px; padding: 3px 10px; @@ -158,7 +162,11 @@ display: inline-block; } .ws-status.connected { border-color: var(--green); color: var(--green); } - .ws-status.connected::before { background: var(--green); } + .ws-status.connected::before { background: var(--green); animation: live-pulse 2.4s ease-in-out infinite; } + @keyframes live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + } .ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); } .ws-status.disconnected::before { background: var(--ink-faint); } @@ -195,7 +203,15 @@ } .view { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 24px; } - .view.active { display: block; } + .view.active { display: block; animation: view-in 160ms ease-out; } + @keyframes view-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + @media (prefers-reduced-motion: reduce) { + .view.active { animation: none; } + .ws-status.connected::before { animation: none; } + } .stats-grid { display: grid; @@ -211,6 +227,14 @@ border-bottom: 1px solid var(--border-light); } .stat-card:last-child { border-right: none; } + .stat-card[data-action] { + cursor: pointer; + transition: background 0.15s ease-out; + } + .stat-card[data-action]:hover { background: var(--bg-alt); } + .stat-card[data-action]:hover .label { color: var(--accent); } + .stat-card[data-action]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + .stat-card[data-action]:active { background: var(--bg-inset); } .stat-card .label { font-size: 9px; color: var(--ink-muted); @@ -294,6 +318,7 @@ border-collapse: collapse; font-size: 13px; font-family: var(--font-body); + font-variant-numeric: tabular-nums; } th { text-align: left; @@ -541,18 +566,36 @@ } .tag.file-tag { border-color: var(--green); color: var(--green); } + /* Two-pane sessions: list left, detail pinned right on wide screens. + The detail panel previously rendered below the full list — selecting + a session on any real corpus put the response off-screen. */ + .sessions-layout { + display: grid; + grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); + gap: 20px; + align-items: start; + } + .sessions-layout #session-detail { position: sticky; top: 0; min-width: 0; } + .sessions-layout #session-detail .detail-panel { margin-top: 0; } + @media (max-width: 1100px) { + .sessions-layout { grid-template-columns: 1fr; } + .sessions-layout #session-detail { position: static; } + } .session-list { display: flex; flex-direction: column; gap: 0; } .session-item { background: var(--bg); border: 1px solid var(--border-light); border-bottom: none; + border-left: 3px solid transparent; padding: 14px 20px; cursor: pointer; - transition: background 0.1s; + transition: background 0.15s ease-out, border-color 0.15s ease-out; } .session-item:last-child { border-bottom: 1px solid var(--border-light); } - .session-item:hover { background: var(--bg-alt); } - .session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); } + .session-item:hover { background: var(--bg-alt); border-left-color: var(--border-light); } + .session-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + .session-item:active { background: var(--bg-inset); } + .session-item.selected { background: var(--bg-alt); border-left-color: var(--accent); } .session-item .session-top { display: flex; justify-content: space-between; @@ -1159,6 +1202,25 @@

agentmemory

if (!ts) return ''; try { return new Date(ts).toLocaleTimeString(); } catch { return ts; } } + // Observation subtitles are often the raw tool input serialized as + // JSON ('{"file_path":"src/x.ts"}'). Pull the human-meaningful field + // out for display; anything unparseable renders as-is. + function humanizeSubtitle(s) { + if (typeof s !== 'string') return ''; + var t = s.trim(); + if (!t.startsWith('{')) return s; + try { + var o = JSON.parse(t); + if (o && typeof o === 'object') { + var keys = ['file_path', 'filePath', 'path', 'command', 'pattern', 'url', 'query', 'prompt']; + for (var i = 0; i < keys.length; i++) { + if (typeof o[keys[i]] === 'string' && o[keys[i]].length > 0) return o[keys[i]]; + } + } + } catch (_) {} + return s; + } + function truncate(s, n) { if (!s) return ''; return s.length > n ? s.slice(0, n) + '...' : s; @@ -1458,13 +1520,13 @@

agentmemory

'
'; } html += '
'; - html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
'; - html += '
Memories
' + d.memories.length + '
latest versions
'; + html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
'; + html += '
Memories
' + d.memories.length + '
latest versions
'; var lessonCount = (d.lessons || []).length; var crystalCount = (d.crystals || []).length; - html += '
Lessons
' + lessonCount + '
confidence-scored
'; - html += '
Crystals
' + crystalCount + '
action digests
'; - html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
'; + html += '
Lessons
' + lessonCount + '
confidence-scored
'; + html += '
Crystals
' + crystalCount + '
action digests
'; + html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
'; html += '
Health
' + esc(healthStatus) + '
'; html += '
' + esc(snap.connectionState || 'unknown') + '
'; var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0); @@ -2567,6 +2629,9 @@

agentmemory

html += '
' + esc(m.content || '') + '
'; html += '
'; html += 'id: ' + esc(m.id) + ''; + if (m.origin && m.origin.channel) { + html += 'origin: ' + esc(m.origin.channel) + (m.origin.detail ? ' (' + esc(m.origin.detail) + ')' : '') + ''; + } if (m.project) html += 'project: ' + esc(m.project) + ''; if (m.createdAt) html += 'created: ' + esc(formatTime(m.createdAt)) + ''; if (m.supersedes && m.supersedes.length > 0) html += 'supersedes: ' + esc(m.supersedes.join(', ')) + ''; @@ -2764,7 +2829,7 @@

agentmemory

html += '' + esc(shortTime(o.timestamp)) + ''; html += '
'; - if (o.subtitle) html += '
' + esc(o.subtitle) + '
'; + if (o.subtitle) html += '
' + esc(humanizeSubtitle(o.subtitle)) + '
'; html += '
'; html += '' + esc(type.replace(/_/g, ' ')) + ''; @@ -2994,7 +3059,7 @@

agentmemory

return (b.startedAt || '').localeCompare(a.startedAt || ''); }); - var html = '
'; + var html = '
'; if (items.length === 0) { html += '
🗒

No sessions

'; } else { @@ -3016,7 +3081,7 @@

agentmemory

}); } html += '
'; - html += '
'; + html += '
'; el.innerHTML = html; if (state.sessions.selectedId) renderSessionDetail(); @@ -3189,7 +3254,7 @@

agentmemory

html += '
' + '
💡
' + '
No lessons yet
' + - '
Lessons are confidence-scored pattern observations — things you corrected once that the agent should never do again. They persist across projects.
' + + '
Lessons are short imperative rules (always/never/prefer/avoid) learned from past work — things you corrected once that the agent should never repeat. Confidence grows when they hold and decays when unused.
' + '
# Save a lesson explicitly\nmemory_lesson_save { rule, reason, confidence }\n\n# Or: Replay tab → Import JSONL auto-extracts lessons\n# from your past Claude Code sessions
' + '' + '
'; @@ -3345,7 +3410,7 @@

agentmemory

html += '
' + '
💎
' + '
No crystals yet
' + - '
Crystals are compressed action digests — the 3-line summary of what happened in a session. Generated from long conversations to give the next session fast context without re-reading everything.
' + + '
Crystals are frozen snapshots of completed work — one session’s narrative, key outcomes, files touched, and lessons surfaced, kept after raw observations are pruned so the next session gets fast context.
' + '
# Auto: import a JSONL transcript\n#   Replay tab → Import JSONL\n\n# Manual: crystallize a specific session\nmemory_crystallize { sessionId }
' + '' + '
'; @@ -3945,7 +4010,7 @@

agentmemory

document.addEventListener('keydown', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; if (!(e.target instanceof Element)) return; - var card = e.target.closest('[role="button"][data-action="select-session"], [role="button"][data-action="select-memory"]'); + var card = e.target.closest('[role="button"][data-action="select-session"], [role="button"][data-action="select-memory"], [role="link"][data-action="goto-tab"]'); if (!card) return; e.preventDefault(); card.click(); @@ -4031,6 +4096,11 @@

agentmemory

if (sessionId) selectSession(sessionId); return; } + if (action === 'goto-tab') { + var gotoTab = target.getAttribute('data-tab'); + if (gotoTab) switchTab(gotoTab); + return; + } if (action === 'select-memory') { var memId = target.getAttribute('data-memory-id'); if (memId) { From d1adc913eb7eee85b8210be3a8a0eb42c7187558 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 13:34:24 +0100 Subject: [PATCH 06/36] refactor: cleanup pass over the branch diff - shared test mocks: the three new test files use test/helpers/mocks (extended with update, store access, and an opt-in loose trigger) instead of three diverging inline copies - lessons: record cache beside the index takes recall to zero KV round-trips (was up to 50 gets per call); the observation adapter moved next to memoryToObservation so both record kinds thread new fields in one place; dead reset export removed - mem::search hybrid path carries the observations the ranker already loaded instead of refetching every result (halves KV I/O on the primary recall path); remember's candidate lookup skips ids that cannot resolve as memories and fails open to a full scan - fusion: derived tiebreak field no longer rides along past the sort; comment trimmed to the non-obvious history - cli: engine identity is a positive check (only the iii binary may be adopted or signaled; unknown port holders are refused, not just known VM names); worker reap extracted to one helper; demo notice picks its branch from a hoisted count - api: livez and health share one instanceInfo source (health now reports streamsPort too) computed once at boot instead of rebuilding the merged env per request - provenance: one importOrigin factory encodes the keep-or-mark rule at all three import sites - opencode plugin: project resolution memoized per directory (was a blocking git subprocess per session event); session.created uses the entry it just built - observe: origin channel derived from a named hook set, no nested ternary - viewer: toolbar buttons merged into the .btn rules, one 720px media block, generic keyboard activation for role-carrying cards, scroll-into-view only on the stacked layout, 5s freshness gate on tab refetch (replay stays fetch-once, reason documented), subtitle humanizer covers the capture-side key variants --- plugin/opencode/agentmemory-capture.ts | 25 ++++++--- src/cli.ts | 71 +++++++++++++------------- src/functions/export-import.ts | 14 ++--- src/functions/lessons.ts | 63 ++++++++++------------- src/functions/observe.ts | 21 ++++---- src/functions/remember.ts | 51 ++++++++++++------ src/functions/replay.ts | 11 ++-- src/functions/search.ts | 12 ++++- src/index.ts | 8 +-- src/state/hybrid-search.ts | 16 +++--- src/state/memory-utils.ts | 20 +++++++- src/triggers/api.ts | 27 +++++----- src/types.ts | 12 +++++ src/viewer/index.html | 59 ++++++++++----------- test/helpers/mocks.ts | 24 ++++++++- test/lesson-index-recall.test.ts | 46 +---------------- test/observe-dedup-prompt.test.ts | 59 ++------------------- test/remember-supersede-recall.test.ts | 46 +---------------- 18 files changed, 260 insertions(+), 325 deletions(-) diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts index 0162fde60..034a1faf7 100644 --- a/plugin/opencode/agentmemory-capture.ts +++ b/plugin/opencode/agentmemory-capture.ts @@ -80,20 +80,30 @@ function projectFor(sessionId: string): { name: string | null; cwd: string | nul return p ?? { name: defaultProjectName, cwd: defaultProjectCwd }; } +const projectNameCache = new Map(); + function resolveProjectName(dir: string): string { const explicit = process.env.AGENTMEMORY_PROJECT_NAME?.trim(); if (explicit) return explicit; + const cached = projectNameCache.get(dir); + if (cached !== undefined) return cached; try { const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", }).trim(); - if (top) return basename(top); + if (top) { + const name = basename(top); + projectNameCache.set(dir, name); + return name; + } } catch { // not a git repo, fall through } - return basename(dir) || dir; + const fallback = basename(dir) || dir; + projectNameCache.set(dir, fallback); + return fallback; } const stashedFiles = new Map>(); const seenSubtaskIds = new Map>(); @@ -232,13 +242,14 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { typeof info?.directory === "string" && info.directory ? info.directory : defaultProjectCwd; + let proj: { name: string | null; cwd: string | null }; if (sessionDir) { - sessionProjects.set(sessionId, { - cwd: sessionDir, - name: resolveProjectName(sessionDir), - }); + const entry = { cwd: sessionDir, name: resolveProjectName(sessionDir) }; + sessionProjects.set(sessionId, entry); + proj = entry; + } else { + proj = projectFor(sessionId); } - const proj = projectFor(sessionId); const startResult = await postJson("/session/start", { sessionId, title: info?.title ?? null, diff --git a/src/cli.ts b/src/cli.ts index c9ee130ec..9278ebec6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -837,7 +837,7 @@ function adoptRunningEngine(): void { const comm = pidCommand(enginePid); if (isForeignPortHolder(comm)) { vlog( - `adoptRunningEngine: refusing to adopt pid ${enginePid} (${comm}) — Docker/VM port holder, not a native engine`, + `adoptRunningEngine: refusing to adopt pid ${enginePid} (${comm}) — not the iii engine binary`, ); return; } @@ -2375,6 +2375,12 @@ async function runDemoBody(base: string) { sQuery.stop("Search complete"); + // Only claim the semantic-recall win when the search actually hit. + // Without an embedding key this query returns 0 hits, and asserting + // success over a visibly failed search reads as a lie. + const semanticHits = + results.find((r) => r.query === "database performance optimization") + ?.hits ?? 0; const lines = [ `Project: ${demoProject}`, `Sessions: ${sessions.length} seeded (${totalObs} observations)`, @@ -2385,25 +2391,16 @@ async function runDemoBody(base: string) { ` ${c.dim("→")} ${c.ok(`${r.hits} hit(s)`)}, top: ${r.topTitle.slice(0, 60)}`, ]), "", - // Only claim the semantic-recall win when the search actually hit. - // Without an embedding key this query returns 0 hits, and asserting - // success over a visibly failed search reads as a lie. - ...(() => { - const semantic = results.find( - (r) => r.query === "database performance optimization", - ); - if (semantic && semantic.hits > 0) { - return [ + ...(semanticHits > 0 + ? [ c.accent(`Notice: searching "database performance optimization"`), c.accent(`found the N+1 query fix — keyword matching can't do that.`), - ]; - } - return [ - c.dim(`Note: "database performance optimization" found nothing —`), - c.dim(`semantic recall needs an embedding provider key (e.g.`), - c.dim(`OPENAI_API_KEY or GEMINI_API_KEY in ~/.agentmemory/.env).`), - ]; - })(), + ] + : [ + c.dim(`Note: "database performance optimization" found nothing —`), + c.dim(`semantic recall needs an embedding provider key (e.g.`), + c.dim(`OPENAI_API_KEY or GEMINI_API_KEY in ~/.agentmemory/.env).`), + ]), "", `Viewer: ${c.url(getViewerUrl())}`, `Clean up with: ${c.dim(`curl -X DELETE "${base}/agentmemory/sessions?project=${demoProject}"`)}`, @@ -2571,6 +2568,17 @@ async function signalAndWait( return !pidAlive(pid); } +// Shared worker-reap: SIGTERM with a grace window sized for the worker's +// shutdown flush (index snapshots land via the engine, so the worker must +// die before the engine does, with time to commit). +async function stopWorkerPid(pid: number, graceMs: number): Promise { + const s = p.spinner(); + s.start(`Stopping agentmemory worker (pid ${pid})... [flushing state]`); + const ok = await signalAndWait(pid, "SIGTERM", graceMs); + s.stop(ok ? `Stopped worker pid ${pid}` : `Failed to stop worker pid ${pid}`); + return ok; +} + function pidCommand(pid: number): string { if (IS_WINDOWS) return ""; try { @@ -2583,11 +2591,15 @@ function pidCommand(pid: number): string { } } +// Positive identity beats a denylist: the engine is always the `iii` +// binary (spawned from PATH or ~/.agentmemory/bin), so anything else +// holding the port — Docker's proxy, an ssh forward, a stray dev +// server — must not be adopted or signaled. A denylist of known VM +// stacks failed open for every name it didn't know. function isForeignPortHolder(comm: string): boolean { if (!comm) return false; - return /docker|vpnkit|qemu|virtualization|colima|lima|podman|orbstack/i.test( - comm, - ); + const base = comm.split("/").pop() || comm; + return base !== "iii" && !base.startsWith("iii-"); } function findEnginePidsByPort(port: number): number[] { @@ -2637,14 +2649,7 @@ async function stopDockerEngine(composeFile: string, port: number): Promise { // persists. Worker SIGTERM grace bumped 3s -> 5s to give a large // index a real chance to commit before the engine goes away. for (const pid of workerCandidates) { - const s = p.spinner(); - s.start(`Stopping agentmemory worker (pid ${pid})... [flushing state]`); - const ok = await signalAndWait(pid, "SIGTERM", 5000); - s.stop(ok ? `Stopped worker pid ${pid}` : `Failed to stop worker pid ${pid}`); - if (!ok) allStopped = false; + if (!(await stopWorkerPid(pid, 5000))) allStopped = false; } const skippedForeign: Array<{ pid: number; comm: string }> = []; for (const pid of candidates) { @@ -2824,7 +2825,7 @@ async function runStop(): Promise { .map((sf) => ` pid ${sf.pid} ${sf.comm}`) .join("\n"); p.log.error( - `Refused to signal Docker/VM process(es) holding :${port} — they are not the iii engine:\n${list}\n\nIf the engine runs in Docker, stop it there:\n docker compose ps && docker compose rm -s -f \n\nOr re-run with --force to signal them anyway.`, + `Refused to signal process(es) holding :${port} that are not the iii engine:\n${list}\n\nIf the engine runs in Docker, stop it there:\n docker compose ps && docker compose rm -s -f \n\nOr re-run with --force to signal them anyway.`, ); process.exit(1); } diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index e3a961eeb..83e5cc0f1 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -24,6 +24,7 @@ import type { ExportPagination, AccessLogExport, } from "../types.js"; +import { importOrigin } from "../types.js"; import { normalizeAccessLog } from "./access-tracker.js"; import { KV } from "../state/schema.js"; import { checkPayloadFrameSize } from "../state/frame-guard.js"; @@ -428,12 +429,9 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { return; } } - // Imported records enter through a different trust boundary than - // live capture; keep the source's own origin when the export - // carried one, otherwise mark the import channel. - if (!o.origin) { - o.origin = { channel: "import", capturedAt: o.timestamp }; - } + // Imported records enter through a different trust boundary + // than live capture. + o.origin = importOrigin(o.origin, o.timestamp); await kv.set(KV.observations(sessionId), o.id, o); stats.observations++; indexObs.push(o); @@ -454,9 +452,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { if (!Array.isArray(memory.sessionIds)) { memory.sessionIds = []; } - if (!memory.origin) { - memory.origin = { channel: "import", capturedAt: memory.createdAt }; - } + memory.origin = importOrigin(memory.origin, memory.createdAt); await kv.set(KV.memories, memory.id, memory); stats.memories++; indexMems.push(memory); diff --git a/src/functions/lessons.ts b/src/functions/lessons.ts index 4eb3f0ce3..7b8a4c4ae 100644 --- a/src/functions/lessons.ts +++ b/src/functions/lessons.ts @@ -1,34 +1,23 @@ import type { ISdk } from "iii-sdk"; import type { StateKV } from "../state/kv.js"; import { KV, fingerprintId } from "../state/schema.js"; -import type { CompressedObservation, Lesson } from "../types.js"; +import type { Lesson } from "../types.js"; import { SearchIndex } from "../state/search-index.js"; +import { lessonToObservation } from "../state/memory-utils.js"; import { recordAudit } from "./audit.js"; -// Dedicated BM25 index for lessons. Recall previously listed every -// lesson from KV and substring-matched per query — O(corpus) per call -// with no term weighting. The index is in-memory and built lazily from -// one KV list (the same cost a single recall used to pay), then kept -// current incrementally on save/delete. Confidence x recency reranking -// stays exactly as before — the index only replaces the relevance term. +// Dedicated BM25 index for lessons, with the full records cached +// alongside it. Recall previously listed every lesson from KV and +// substring-matched per query — O(corpus) per call with no term +// weighting. Index and record cache are built lazily from one KV list +// (the same cost a single recall used to pay) and kept current +// incrementally on save/delete/decay. Confidence x recency reranking +// stays exactly as before — the index only replaces the relevance term, +// and the record cache keeps recall at zero KV round-trips. let lessonIndex: SearchIndex | null = null; +const lessonRecords = new Map(); let lessonIndexBuild: Promise | null = null; -function lessonToIndexDoc(l: Lesson): CompressedObservation { - return { - id: l.id, - sessionId: "lesson", - timestamp: l.createdAt, - type: "decision", - title: l.content.slice(0, 120), - facts: [l.content], - narrative: l.context || "", - concepts: l.tags, - files: [], - importance: l.confidence, - }; -} - async function ensureLessonIndex(kv: StateKV): Promise { if (lessonIndex) return lessonIndex; if (!lessonIndexBuild) { @@ -36,7 +25,10 @@ async function ensureLessonIndex(kv: StateKV): Promise { const idx = new SearchIndex(); const all = await kv.list(KV.lessons); for (const l of all) { - if (!l.deleted) idx.add(lessonToIndexDoc(l)); + if (!l.deleted) { + idx.add(lessonToObservation(l)); + lessonRecords.set(l.id, l); + } } lessonIndex = idx; })().finally(() => { @@ -47,10 +39,6 @@ async function ensureLessonIndex(kv: StateKV): Promise { return lessonIndex!; } -export function __resetLessonIndex(): void { - lessonIndex = null; -} - function reinforceLesson(lesson: Lesson): void { const now = new Date().toISOString(); lesson.reinforcements++; @@ -86,6 +74,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { existing.context = data.context; } await kv.set(KV.lessons, existing.id, existing); + lessonRecords.set(existing.id, existing); try { await recordAudit(kv, "lesson_strengthen", "mem::lesson-save", [ @@ -124,7 +113,8 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { }; await kv.set(KV.lessons, lesson.id, lesson); - if (lessonIndex) lessonIndex.add(lessonToIndexDoc(lesson)); + lessonRecords.set(lesson.id, lesson); + if (lessonIndex) lessonIndex.add(lessonToObservation(lesson)); try { await recordAudit(kv, "lesson_save", "mem::lesson-save", [lesson.id]); @@ -156,13 +146,9 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { const hits = idx.search(data.query, Math.max(limit * 5, 50)); const maxHit = hits.length > 0 ? hits[0].score : 0; - const loaded = await Promise.all( - hits.map((h) => kv.get(KV.lessons, h.obsId).catch(() => null)), - ); - const scored: Array<{ lesson: Lesson; score: number }> = []; for (let i = 0; i < hits.length; i++) { - const l = loaded[i]; + const l = lessonRecords.get(hits[i].obsId); if (!l || l.deleted || l.confidence < minConfidence) continue; if (data.project && l.project !== data.project) continue; @@ -241,6 +227,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { reinforceLesson(lesson); await kv.set(KV.lessons, lesson.id, lesson); + lessonRecords.set(lesson.id, lesson); try { await recordAudit(kv, "lesson_strengthen", "mem::lesson-strengthen", [ @@ -267,6 +254,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { lesson.updatedAt = new Date().toISOString(); await kv.set(KV.lessons, lesson.id, lesson); + lessonRecords.delete(lesson.id); if (lessonIndex) lessonIndex.remove(lesson.id); try { @@ -335,9 +323,12 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { } await Promise.all(dirty.map((l) => kv.set(KV.lessons, l.id, l))); - if (lessonIndex) { - for (const l of dirty) { - if (l.deleted) lessonIndex.remove(l.id); + for (const l of dirty) { + if (l.deleted) { + lessonRecords.delete(l.id); + if (lessonIndex) lessonIndex.remove(l.id); + } else { + lessonRecords.set(l.id, l); } } await Promise.all( diff --git a/src/functions/observe.ts b/src/functions/observe.ts index 2b4e67d7f..8c2e0bed5 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -1,5 +1,7 @@ import { TriggerAction, type ISdk } from "iii-sdk"; -import type { RawObservation, HookPayload } from "../types.js"; +import type { RawObservation, HookPayload, Origin } from "../types.js"; + +const TOOL_HOOKS = new Set(["pre_tool_use", "post_tool_use", "post_tool_failure"]); import { KV, STREAM, generateId } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { stripPrivateData } from "./privacy.js"; @@ -93,17 +95,12 @@ export function registerObserveFunction( } // Stamp which trust boundary this content crossed. Tool hooks carry - // whatever the tool returned (file contents, command output, web - // pages) — content the user never wrote; prompt_submit is the user's - // own words; everything else originates from the agent runtime. - const originChannel = - payload.hookType === "prompt_submit" - ? ("user" as const) - : payload.hookType === "pre_tool_use" || - payload.hookType === "post_tool_use" || - payload.hookType === "post_tool_failure" - ? ("tool" as const) - : ("agent" as const); + // whatever the tool returned — content the user never wrote; + // prompt_submit is the user's own words; everything else originates + // from the agent runtime. + let originChannel: Origin["channel"] = "agent"; + if (payload.hookType === "prompt_submit") originChannel = "user"; + else if (TOOL_HOOKS.has(payload.hookType)) originChannel = "tool"; const raw: RawObservation = { id: obsId, sessionId: payload.sessionId, diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 7372352dc..db9a6b00a 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -78,17 +78,31 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { // stops working. const idx = getSearchIndex(); let candidateMemories: Memory[]; - if (idx.size > 0) { - // 50 hits, not 20: the shared index also holds observations, - // which occupy slots but never resolve to memories below. A - // >0.7-Jaccard duplicate shares most tokens with the query so - // it ranks near the top regardless. - const hits = idx.search(data.content, 50); - const loaded = await Promise.all( - hits.map((h) => kv.get(KV.memories, h.obsId).catch(() => null)), - ); - candidateMemories = loaded.filter((m): m is Memory => m !== null); - } else { + try { + if (idx.size > 0) { + // 50 hits, not 20: the shared index also holds observations, + // which occupy slots but never resolve to memories below. A + // >0.7-Jaccard duplicate shares most tokens with the query so + // it ranks near the top regardless. Only mem_-prefixed ids can + // resolve in KV.memories, so skip the guaranteed-miss lookups. + const hits = idx + .search(data.content, 50) + .filter((h) => h.obsId.startsWith("mem_")); + const loaded = await Promise.all( + hits.map((h) => + kv.get(KV.memories, h.obsId).catch(() => null), + ), + ); + candidateMemories = loaded.filter((m): m is Memory => m !== null); + } else { + candidateMemories = await kv.list(KV.memories); + } + } catch (err) { + // Candidate generation is an optimization; a failure here must + // never block the save itself. + logger.warn("supersession candidate lookup failed, using full scan", { + error: err instanceof Error ? err.message : JSON.stringify(err), + }); candidateMemories = await kv.list(KV.memories); } let supersededId: string | undefined; @@ -122,11 +136,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { similarity > 0.4 && (!nearMatch || similarity > nearMatch.similarity) ) { - nearMatch = { - id: existing.id, - title: existing.title, - similarity: Math.round(similarity * 100) / 100, - }; + nearMatch = { id: existing.id, title: existing.title, similarity }; } } @@ -220,7 +230,14 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { return { success: true, memory, - ...(nearMatch && !supersededId ? { similarTo: nearMatch } : {}), + ...(nearMatch && !supersededId + ? { + similarTo: { + ...nearMatch, + similarity: Math.round(nearMatch.similarity * 100) / 100, + }, + } + : {}), }; }); }, diff --git a/src/functions/replay.ts b/src/functions/replay.ts index 8e3f40833..ee1e6a6ec 100644 --- a/src/functions/replay.ts +++ b/src/functions/replay.ts @@ -9,6 +9,7 @@ import type { RawObservation, Session, } from "../types.js"; +import { importOrigin } from "../types.js"; import type { StateKV } from "../state/kv.js"; import { KV, generateId, fingerprintId } from "../state/schema.js"; import { parseJsonlText } from "../replay/jsonl-parser.js"; @@ -436,11 +437,11 @@ export function registerReplayFunctions(sdk: ISdk, kv: StateKV): void { await Promise.all( parsed.observations.map(async (obs) => { const synthetic = buildSyntheticCompression(obs); - synthetic.origin = { - channel: "import", - detail: "jsonl", - capturedAt: synthetic.timestamp, - }; + synthetic.origin = importOrigin( + synthetic.origin, + synthetic.timestamp, + "jsonl", + ); compressed.push(synthetic); await kv.set(KV.observations(parsed.sessionId), obs.id, synthetic); }), diff --git a/src/functions/search.ts b/src/functions/search.ts index 0944663fd..ae03d8237 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -464,13 +464,22 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // rank lower than cross-agent ones in the hybrid score. const filtering = !!(projectFilter || cwdFilter || filterAgentId) const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit - let results: Array<{ obsId: string; sessionId: string; score: number }> + // Hybrid results carry the observation the ranker already loaded, + // so the load pass below doesn't refetch every record it just + // enriched. + let results: Array<{ + obsId: string + sessionId: string + score: number + observation?: CompressedObservation + }> if (hybridRanker && vectorIndex && vectorIndex.size > 0) { const hybrid = await hybridRanker(query, fetchLimit) results = hybrid.map((r) => ({ obsId: r.observation.id, sessionId: r.sessionId, score: r.combinedScore, + observation: r.observation, })) } else { results = idx.search(query, fetchLimit) @@ -548,6 +557,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // sessionId, so the observation key never exists (#265). const obsResults = await Promise.all( candidates.map(async (r) => { + if (r.observation) return r.observation const obs = await kv .get(KV.observations(r.sessionId), r.obsId) .catch(() => null) diff --git a/src/index.ts b/src/index.ts index 70de7cb83..45c927167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -387,10 +387,10 @@ async function main() { graphWeight, ); - registerSmartSearchFunction(sdk, kv, (query, limit) => - hybridSearch.search(query, limit), - ); - setHybridRanker((query, limit) => hybridSearch.search(query, limit)); + const hybridRanker = (query: string, limit: number) => + hybridSearch.search(query, limit); + registerSmartSearchFunction(sdk, kv, hybridRanker); + setHybridRanker(hybridRanker); registerRecentSearchesSweepFunction(sdk, kv); registerApiTriggers(sdk, kv, secret, metricsStore, provider); diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts index 910595e31..5b5c67da8 100644 --- a/src/state/hybrid-search.ts +++ b/src/state/hybrid-search.ts @@ -191,14 +191,10 @@ export class HybridSearch { } }); - // Weight fusion per item over the streams that actually ranked it. - // Normalizing over every enabled stream caps a single-stream hit at - // weight/(RRF_K+1) no matter how strong it is — with the graph stream - // empty on default installs, a #1 BM25 result carried a permanent - // penalty against anything two streams agreed on. Per-item - // normalization puts single-stream and multi-stream hits on the same - // scale; cross-stream agreement earns a small explicit bonus instead - // of an implicit one baked into the denominator. + // Weights are normalized per item over the streams that ranked it. + // Normalizing over every enabled stream capped single-stream hits at + // weight/(RRF_K+1) — with the graph stream empty on default installs, + // a #1 BM25 result carried a permanent penalty. const AGREEMENT_BONUS = 0.05; const combined = Array.from(scores.entries()).map(([obsId, s]) => { const wB = Number.isFinite(s.bm25Rank) ? this.bm25Weight : 0; @@ -214,7 +210,6 @@ export class HybridSearch { wG * (1 / (RRF_K + s.graphRank))) / wSum : 0; - const minRank = Math.min(s.bm25Rank, s.vectorRank, s.graphRank); return { obsId, sessionId: s.sessionId, @@ -223,7 +218,7 @@ export class HybridSearch { graphScore: s.graphScore, graphContext: s.graphContext, combinedScore: rrf * (1 + AGREEMENT_BONUS * (matchedStreams - 1)), - minRank, + minRank: Math.min(s.bm25Rank, s.vectorRank, s.graphRank), }; }); @@ -236,6 +231,7 @@ export class HybridSearch { a.minRank - b.minRank || (a.obsId < b.obsId ? -1 : a.obsId > b.obsId ? 1 : 0), ); + for (const c of combined) delete (c as { minRank?: number }).minRank; const retrievalDepth = Math.max(limit, 20); const rerankWindow = 20; diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts index 8b8f8d520..9bc5b16af 100644 --- a/src/state/memory-utils.ts +++ b/src/state/memory-utils.ts @@ -1,4 +1,4 @@ -import type { CompressedObservation, Memory } from "../types.js"; +import type { CompressedObservation, Lesson, Memory } from "../types.js"; // Wraps a Memory record in the CompressedObservation shape that // SearchIndex / VectorIndex / enrichment paths consume. Memories share @@ -26,3 +26,21 @@ export function memoryToObservation(memory: Memory): CompressedObservation { ...(memory.agentId ? { agentId: memory.agentId } : {}), }; } + +// Same adapter for lessons, kept beside memoryToObservation so a new +// CompressedObservation field has one obvious place to be threaded +// through both record kinds. +export function lessonToObservation(l: Lesson): CompressedObservation { + return { + id: l.id, + sessionId: "lesson", + timestamp: l.createdAt, + type: "decision", + title: l.content.slice(0, 120), + facts: [l.content], + narrative: l.context || "", + concepts: l.tags, + files: [], + importance: l.confidence, + }; +} diff --git a/src/triggers/api.ts b/src/triggers/api.ts index d52625bb0..56fad4f0d 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -165,19 +165,23 @@ export function registerApiTriggers( }, ); + // Shared instance metadata for livez and health so the two never + // drift. streamsPort lets the viewer resolve its stream WebSocket + // target from the server instead of port arithmetic, which broke + // whenever the viewer bound a fallback port. Config is boot-static, + // so read it once instead of rebuilding the merged env per request. + const bootStreamsPort = loadConfig().streamsPort; + const instanceInfo = () => ({ + service: "agentmemory", + viewerPort: getBoundViewerPort(), + viewerSkipped: getViewerSkipped(), + streamsPort: bootStreamsPort, + }); + sdk.registerFunction("api::liveness", async (): Promise => ({ status_code: 200, - body: { - status: "ok", - service: "agentmemory", - viewerPort: getBoundViewerPort(), - viewerSkipped: getViewerSkipped(), - // The viewer derives its stream WebSocket target from this instead - // of port arithmetic: when the viewer binds a fallback port, - // viewerPort-1 points at the wrong server and live updates die. - streamsPort: loadConfig().streamsPort, - }, + body: { status: "ok", ...instanceInfo() }, }), ); sdk.registerTrigger({ @@ -278,8 +282,7 @@ export function registerApiTriggers( health: health || null, functionMetrics, circuitBreaker, - viewerPort: getBoundViewerPort(), - viewerSkipped: getViewerSkipped(), + ...instanceInfo(), }, }; }, diff --git a/src/types.ts b/src/types.ts index fe77407d2..89a37d2cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,18 @@ export interface Origin { capturedAt: string; } +// One precedence rule for records entering through an import path: +// a source that already carries provenance keeps it; anything else is +// marked as having crossed the import boundary. +export function importOrigin( + existing: Origin | undefined, + capturedAt: string, + detail?: string, +): Origin { + if (existing) return existing; + return { channel: "import", capturedAt, ...(detail ? { detail } : {}) }; +} + export interface RawObservation { id: string; sessionId: string; diff --git a/src/viewer/index.html b/src/viewer/index.html index 3e84d977b..34948d6cd 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -142,6 +142,7 @@ @media (max-width: 720px) { .app-header { flex-wrap: wrap; row-gap: 6px; padding: 10px 16px; } .app-header .dateline { display: none; } + .view { overflow-x: auto; } } .ws-status { font-size: 10px; @@ -372,26 +373,8 @@ box-shadow: 2px 2px 0px 0px var(--border); } .toolbar input { flex: 1; min-width: 200px; } - .toolbar button:not(.btn) { - background: var(--bg); - border: 1px solid var(--border); - color: var(--ink); - padding: 7px 16px; - font-size: 11px; - cursor: pointer; - transition: box-shadow 0.1s, transform 0.1s; - font-family: var(--font-ui); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - } - .toolbar button:not(.btn):hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); } - .toolbar button:not(.btn):active { box-shadow: none; transform: translate(0, 0); } - @media (max-width: 720px) { - .view { overflow-x: auto; } - } - .btn { + .btn, .toolbar button { background: var(--bg); border: 1px solid var(--border); color: var(--ink); @@ -404,8 +387,8 @@ text-transform: uppercase; letter-spacing: 0.06em; } - .btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); } - .btn:active { box-shadow: none; transform: translate(0, 0); } + .btn:hover, .toolbar button:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); } + .btn:active, .toolbar button:active { box-shadow: none; transform: translate(0, 0); } .btn-danger { border-color: var(--accent); color: var(--accent); } .btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); } .btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); } @@ -1212,7 +1195,7 @@

agentmemory

try { var o = JSON.parse(t); if (o && typeof o === 'object') { - var keys = ['file_path', 'filePath', 'path', 'command', 'pattern', 'url', 'query', 'prompt']; + var keys = ['file_path', 'filepath', 'filePath', 'path', 'file', 'command', 'pattern', 'url', 'query', 'prompt']; for (var i = 0; i < keys.length; i++) { if (typeof o[keys[i]] === 'string' && o[keys[i]].length > 0) return o[keys[i]]; } @@ -1423,11 +1406,19 @@

agentmemory

loadTab(tab); } + // Per-tab freshness stamps. Tabs refetch on entry (the loaded-once + // model went stale the moment anything wrote through the API), but a + // short window stops rapid tab flipping from re-issuing the full + // fan-out (dashboard alone is ~10 requests) on every click. + var tabFetchedAt = {}; + var TAB_FRESH_MS = 5000; + async function loadTab(tab) { - // Refetch on every tab entry. The loaded-once model went stale the - // moment anything wrote through the API after first visit: a memory - // saved by the agent never appeared until a hard browser reload, - // and the 10s poll only refreshes the dashboard. + var now = Date.now(); + if (tab !== 'replay' && tabFetchedAt[tab] && now - tabFetchedAt[tab] < TAB_FRESH_MS) { + return; + } + tabFetchedAt[tab] = now; switch(tab) { case 'dashboard': await loadDashboard(); break; case 'graph': await loadGraph(); break; @@ -1440,6 +1431,9 @@

agentmemory

case 'audit': await loadAudit(); break; case 'activity': await loadActivity(); break; case 'profile': await loadProfile(); break; + // Replay stays fetch-once: reloading it would reset playback + // timer and cursor state mid-session; its toolbar has an explicit + // Refresh button instead. case 'replay': if (!state.replay.loaded) await loadReplay(); break; } } @@ -3090,10 +3084,13 @@

agentmemory

function selectSession(id) { state.sessions.selectedId = state.sessions.selectedId === id ? null : id; renderSessions(); - // The detail panel renders below the full session list — off-screen - // for any list longer than a few rows. Bring it into view so - // selecting a session visibly does something. - if (state.sessions.selectedId) { + // On the stacked layout (narrow screens) the detail renders below + // the list; bring it into view. The wide two-pane layout keeps the + // panel sticky beside the list, so no scroll is needed there. + if ( + state.sessions.selectedId && + window.matchMedia('(max-width: 1100px)').matches + ) { var panel = document.getElementById('session-detail'); if (panel && panel.scrollIntoView) { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); @@ -4010,7 +4007,7 @@

agentmemory

document.addEventListener('keydown', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; if (!(e.target instanceof Element)) return; - var card = e.target.closest('[role="button"][data-action="select-session"], [role="button"][data-action="select-memory"], [role="link"][data-action="goto-tab"]'); + var card = e.target.closest('[data-action][role="button"], [data-action][role="link"]'); if (!card) return; e.preventDefault(); card.click(); diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index a382e2ee8..bc829ed5f 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -5,9 +5,21 @@ type Handler = (data: unknown) => Promise; export function mockKV() { const store = new Map>(); return { + store, get: async (scope: string, key: string): Promise => { return (store.get(scope)?.get(key) as T) ?? null; }, + update: async ( + scope: string, + key: string, + updates: Array<{ path: string; value: unknown }>, + ): Promise => { + const entries = store.get(scope); + if (!entries) return; + const value = (entries.get(key) as Record) ?? {}; + for (const u of updates) value[u.path] = u.value; + entries.set(key, value); + }, set: async (scope: string, key: string, data: T): Promise => { if (!store.has(scope)) store.set(scope, new Map()); store.get(scope)!.set(key, data); @@ -23,9 +35,11 @@ export function mockKV() { }; } -export function mockSdk() { +export function mockSdk(opts?: { looseTrigger?: boolean }) { const functions = new Map(); + const looseTrigger = opts?.looseTrigger ?? false; return { + fns: functions, registerFunction: ( idOrOpts: string | { id: string }, handler: Handler, @@ -46,7 +60,13 @@ export function mockSdk() { const payload = typeof idOrInput === "string" ? data : (idOrInput.payload as unknown); const fn = functions.get(id); - if (!fn) throw new Error(`No function: ${id}`); + if (!fn) { + // looseTrigger mirrors production fan-out where side-effect + // triggers (cascade, events) may target functions another + // module registers; tests exercising one module opt in. + if (looseTrigger) return null; + throw new Error(`No function: ${id}`); + } return fn(payload); }, }; diff --git a/test/lesson-index-recall.test.ts b/test/lesson-index-recall.test.ts index 543544cb2..4539a9fdb 100644 --- a/test/lesson-index-recall.test.ts +++ b/test/lesson-index-recall.test.ts @@ -1,56 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -function mockKV() { - const store = new Map>(); - return { - store, - get: async (scope: string, key: string): Promise => - (store.get(scope)?.get(key) as T) ?? null, - set: async (scope: string, key: string, data: T): Promise => { - if (!store.has(scope)) store.set(scope, new Map()); - store.get(scope)!.set(key, data); - return data; - }, - update: async () => {}, - delete: async (scope: string, key: string) => { - store.get(scope)?.delete(key); - }, - list: async (scope: string): Promise => { - const m = store.get(scope); - return m ? (Array.from(m.values()) as T[]) : []; - }, - }; -} - -function mockSdk() { - const fns = new Map(); - return { - fns, - registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { - const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; - fns.set(id, fn); - }, - trigger: async ( - idOrInput: string | { function_id: string; payload: unknown }, - data?: unknown, - ) => { - const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; - const payload = typeof idOrInput === "string" ? data : idOrInput.payload; - const fn = fns.get(id); - if (fn) return fn(payload); - return null; - }, - }; -} - async function setup() { vi.resetModules(); const { registerLessonsFunctions } = await import("../src/functions/lessons.js"); - const sdk = mockSdk(); + const sdk = mockSdk({ looseTrigger: true }); const kv = mockKV(); registerLessonsFunctions(sdk as never, kv as never); return { sdk, kv }; diff --git a/test/observe-dedup-prompt.test.ts b/test/observe-dedup-prompt.test.ts index 711bb7ccc..29438ea69 100644 --- a/test/observe-dedup-prompt.test.ts +++ b/test/observe-dedup-prompt.test.ts @@ -1,61 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -function mockKV() { - const store = new Map>(); - return { - store, - get: async (scope: string, key: string): Promise => - (store.get(scope)?.get(key) as T) ?? null, - set: async (scope: string, key: string, data: T): Promise => { - if (!store.has(scope)) store.set(scope, new Map()); - store.get(scope)!.set(key, data); - return data; - }, - update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { - const m = store.get(scope); - if (!m) return; - const v = (m.get(key) as Record) ?? {}; - for (const u of updates) v[u.path] = u.value; - m.set(key, v); - }, - delete: async (scope: string, key: string) => { - store.get(scope)?.delete(key); - }, - list: async (scope: string): Promise => { - const m = store.get(scope); - return m ? (Array.from(m.values()) as T[]) : []; - }, - }; -} - -function mockSdk() { - const fns = new Map(); - return { - fns, - registerFunction: ( - idOrOpts: string | { id: string }, - fn: Function, - ) => { - const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; - fns.set(id, fn); - }, - trigger: async ( - idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, - data?: unknown, - ) => { - const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; - const payload = typeof idOrInput === "string" ? data : idOrInput.payload; - const fn = fns.get(id); - if (fn) return fn(payload); - return null; - }, - }; -} - function observePayload(hookType: string, data: unknown) { return { sessionId: "ses_dedup_test", @@ -75,7 +24,7 @@ describe("observe dedup for hooks without tool_input (#1173)", () => { it("records consecutive prompt_submit observations with different prompts", async () => { const { registerObserveFunction } = await import("../src/functions/observe.js"); const { DedupMap } = await import("../src/functions/dedup.js"); - const sdk = mockSdk(); + const sdk = mockSdk({ looseTrigger: true }); const kv = mockKV(); registerObserveFunction(sdk as never, kv as never, new DedupMap()); @@ -96,7 +45,7 @@ describe("observe dedup for hooks without tool_input (#1173)", () => { it("still dedups an identical prompt_submit within the TTL window", async () => { const { registerObserveFunction } = await import("../src/functions/observe.js"); const { DedupMap } = await import("../src/functions/dedup.js"); - const sdk = mockSdk(); + const sdk = mockSdk({ looseTrigger: true }); const kv = mockKV(); registerObserveFunction(sdk as never, kv as never, new DedupMap()); @@ -117,7 +66,7 @@ describe("observe dedup for hooks without tool_input (#1173)", () => { it("keeps tool_input as the dedup key for tool hooks (response changes still dedup)", async () => { const { registerObserveFunction } = await import("../src/functions/observe.js"); const { DedupMap } = await import("../src/functions/dedup.js"); - const sdk = mockSdk(); + const sdk = mockSdk({ looseTrigger: true }); const kv = mockKV(); registerObserveFunction(sdk as never, kv as never, new DedupMap()); diff --git a/test/remember-supersede-recall.test.ts b/test/remember-supersede-recall.test.ts index f01916854..e3648dc6d 100644 --- a/test/remember-supersede-recall.test.ts +++ b/test/remember-supersede-recall.test.ts @@ -1,57 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -function mockKV() { - const store = new Map>(); - return { - store, - get: async (scope: string, key: string): Promise => - (store.get(scope)?.get(key) as T) ?? null, - set: async (scope: string, key: string, data: T): Promise => { - if (!store.has(scope)) store.set(scope, new Map()); - store.get(scope)!.set(key, data); - return data; - }, - update: async () => {}, - delete: async (scope: string, key: string) => { - store.get(scope)?.delete(key); - }, - list: async (scope: string): Promise => { - const m = store.get(scope); - return m ? (Array.from(m.values()) as T[]) : []; - }, - }; -} - -function mockSdk() { - const fns = new Map(); - return { - fns, - registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { - const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; - fns.set(id, fn); - }, - trigger: async ( - idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, - data?: unknown, - ) => { - const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; - const payload = typeof idOrInput === "string" ? data : idOrInput.payload; - const fn = fns.get(id); - if (fn) return fn(payload); - return null; - }, - }; -} - async function setup() { vi.resetModules(); const search = await import("../src/functions/search.js"); const { registerRememberFunction } = await import("../src/functions/remember.js"); - const sdk = mockSdk(); + const sdk = mockSdk({ looseTrigger: true }); const kv = mockKV(); registerRememberFunction(sdk as never, kv as never); return { sdk, kv, search }; From f94367cc277710a612682d314454bfb767072ee7 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 15 Aug 2026 15:08:13 +0100 Subject: [PATCH 07/36] feat(viewer): clarity pass and ambient refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health notes/alerts translate their machine slugs into sentences (memory_heap_tight_93%_rss111mb reads as heap usage with context) - lessons rows expand to full detail: rule, why-learned context, tags, learned/last-confirmed times, source sessions, raw record; column headers carry title hints for confidence and uses - actions tab gets the same intro card as the other tabs (status flow and frontier explained on the populated view, not just when empty) - timeline defaults to the session with the most observations instead of the newest, which was often a sparse just-started session - consolidation status and top-concepts zero states explain what fills them and which flags gate it - ambient background: the static dot grid becomes a slowly drifting ordered-dither field (quarter-res canvas, ~12fps, static frame under prefers-reduced-motion, theme-aware) - dark theme: layered near-black surfaces, hairline borders, softened accent — replaces the flat gray borders --- src/viewer/index.html | 176 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 152 insertions(+), 24 deletions(-) diff --git a/src/viewer/index.html b/src/viewer/index.html index 34948d6cd..35d7588df 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -50,22 +50,21 @@ --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; } html[data-theme="dark"] { - --bg: #1a1a1e; - --bg-alt: #232328; - --bg-subtle: #1f1f24; - --bg-inset: #2a2a30; - --border: #444; - --border-light: #3a3a42; - --border-heavy: #ccc; - --ink: #eee; - --ink-secondary: #ccc; - --ink-muted: #999; - --ink-faint: #777; + --bg: #121316; + --bg-alt: #1a1c20; + --bg-subtle: #17181b; + --bg-inset: #222428; + --border: #33363b; + --border-light: #26282c; + --border-heavy: #c9cbd1; + --ink: #eef0f3; + --ink-secondary: #c6c9ce; + --ink-muted: #94979d; + --ink-faint: #6d7076; + --accent: #f2555a; + --accent-light: #ff7a70; --cream: #2a2520; } - html[data-theme="dark"] body { - background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px); - } html[data-theme="dark"] .graph-tooltip { background: rgba(30,30,35,0.92); border-color: rgba(255,255,255,0.1); @@ -80,6 +79,16 @@ color: var(--bg); } * { margin: 0; padding: 0; box-sizing: border-box; } + #bg-dither { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + opacity: 0.5; + } + .app-header, .tab-bar, .view, .flags-banner, footer, .app-footer { position: relative; z-index: 1; } body { font-family: var(--font-body); background: var(--bg); @@ -89,8 +98,6 @@ height: 100vh; display: flex; flex-direction: column; - background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px); - background-size: 16px 16px; } ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--bg); } @@ -1019,6 +1026,7 @@ +

agentmemory

@@ -1161,7 +1169,7 @@

agentmemory

sessions: { loaded: false, items: [], selectedId: null }, audit: { loaded: false, entries: [], opFilter: '' }, activity: { loaded: false, observations: [], sessions: [], typeFilter: '' }, - lessons: { loaded: false, items: [], search: '' }, + lessons: { loaded: false, items: [], search: '', selectedId: null }, actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' }, crystals: { loaded: false, items: [], search: '', lessonMap: {} }, profile: { loaded: false, projects: [], selectedProject: '', data: null }, @@ -1188,6 +1196,26 @@

agentmemory

// Observation subtitles are often the raw tool input serialized as // JSON ('{"file_path":"src/x.ts"}'). Pull the human-meaningful field // out for display; anything unparseable renders as-is. + // Health alerts/notes arrive as compact machine slugs + // (memory_heap_tight_93%_rss111mb). Translate the known families + // into sentences; unknown slugs render as-is. + function humanizeHealthFlag(f) { + var m; + if ((m = /^memory_heap_tight_(\d+)%_rss(\d+)mb$/.exec(f))) + return 'Heap is running tight: ' + m[1] + '% of allocated heap in use (process memory ' + m[2] + ' MB). Informational — Node grows the heap on demand.'; + if ((m = /^memory_(warn|critical)_(\d+)%_rss(\d+)mb$/.exec(f))) + return 'Memory ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '% of heap in use, process memory ' + m[3] + ' MB.'; + if ((m = /^cpu_(warn|critical)_(\d+)%$/.exec(f))) + return 'CPU ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '%.'; + if ((m = /^event_loop_lag_(warn|critical)_(\d+)ms$/.exec(f))) + return 'Event loop ' + (m[1] === 'critical' ? 'severely delayed' : 'delayed') + ': ' + m[2] + ' ms behind. The worker is busy or blocked.'; + if (f === 'connection_reconnecting') + return 'Engine connection lost — reconnecting.'; + if ((m = /^connection_(.+)$/.exec(f))) + return 'Engine connection state: ' + m[1] + '.'; + return f; + } + function humanizeSubtitle(s) { if (typeof s !== 'string') return ''; var t = s.trim(); @@ -1581,7 +1609,8 @@

agentmemory

if (snap.alerts && snap.alerts.length > 0) { html += '
Alerts (' + snap.alerts.length + ')
'; - snap.alerts.forEach(function(al) { + snap.alerts.forEach(function(alRaw) { + var al = humanizeHealthFlag(alRaw); html += '
' + esc(al) + '
'; }); html += '
'; @@ -1590,7 +1619,7 @@

agentmemory

if (snap.notes && snap.notes.length > 0) { html += '
Notes (' + snap.notes.length + ')
'; snap.notes.forEach(function(n) { - html += '
' + esc(n) + '
'; + html += '
' + esc(humanizeHealthFlag(n)) + '
'; }); html += '
'; } @@ -1724,6 +1753,9 @@

agentmemory

html += '
Semantic facts' + semFacts.length + '
'; html += '
Procedures' + procItems.length + '
'; html += '
Relations' + relItems.length + '
'; + if (semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) { + html += '
Consolidation distills session observations into durable facts and repeatable procedures. It runs on a schedule when CONSOLIDATION_ENABLED=true and an LLM provider key are set, or on demand via memory_consolidate.
'; + } html += '
'; if (relItems.length > 0) { @@ -2683,7 +2715,13 @@

agentmemory

state.timeline.loaded = true; if (sessions.length > 0 && !state.timeline.sessionId) { - var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }); + // Default to the session with the most observations — the newest + // one is often a sparse just-started session, which made the tab + // look empty on first open. + var sorted = sessions.slice().sort(function(a, b) { + return (b.observationCount || 0) - (a.observationCount || 0) || + (b.startedAt || '').localeCompare(a.startedAt || ''); + }); var firstSelectable = sorted.find(function(s) { return sessionId(s); }); state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : ''; } @@ -3256,11 +3294,12 @@

agentmemory

'
' + '
'; } else { - html += ''; + html += '
LessonConfidenceReinforcementsSourceProjectUpdated
'; items.forEach(function(l) { var confPct = Math.round(l.confidence * 100); var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)'; - html += ''; + var expanded = state.lessons.selectedId === l.id; + html += ''; html += ''; html += ''; html += ''; @@ -3268,6 +3307,21 @@

agentmemory

html += ''; html += ''; html += ''; + if (expanded) { + html += ''; + } }); html += '
LessonConfidenceUsesSourceProjectUpdated
' + esc(truncate(l.content, 120)) + (l.context ? '
' + esc(truncate(l.context, 80)) + '
' : '') + '
' + confPct + '%
' + (l.reinforcements || 0) + '' + esc(l.project || '-') + '' + shortTime(l.updatedAt) + '
'; + html += '
' + esc(l.content) + '
'; + if (l.context) html += '
Why learned
' + esc(l.context) + '
'; + html += '
'; + html += 'id: ' + esc(l.id) + ''; + if (l.tags && l.tags.length) html += 'tags: ' + esc(l.tags.join(', ')) + ''; + if (l.createdAt) html += 'learned: ' + esc(formatTime(l.createdAt)) + ''; + if (l.lastReinforcedAt) html += 'last confirmed: ' + esc(formatTime(l.lastReinforcedAt)) + ''; + if (l.sourceIds && l.sourceIds.length) html += 'from ' + l.sourceIds.length + ' session(s)'; + html += '
'; + html += '
raw record'; + html += '
' + esc(JSON.stringify(l, null, 2)) + '
'; + html += '
'; } @@ -3292,6 +3346,10 @@

agentmemory

function renderActions() { var el = document.getElementById('view-actions'); var items = state.actions.items; + var introCard = '
' + + '
' + + 'Actions are follow-ups the agent surfaced during sessions — decisions to revisit, files to inspect, tasks blocked on input. Status flows pending → active → done/blocked; the frontier marks what is unblocked and ready to pick up next.' + + '
'; var search = state.actions.search.toLowerCase(); var statusFilter = state.actions.statusFilter; var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); @@ -3305,7 +3363,8 @@

agentmemory

items = items.filter(function(a) { return a.status === statusFilter; }); } - var html = '
'; + var html = introCard; + html += '
'; html += ''; html += '' + esc(type) + ''; }); - html += '

Legend

'; + if (state.graph.nodes.length > 0 && state.graph.edges.length === 0) { + html += '
Entities extracted, no relations between them yet. Nodes are grouped by kind; edges appear as extraction sees entities acting on each other across more sessions (larger models find them faster).
'; + } + html += '