From 146f132afe83a8eb5769a58df14f8c1c8f9f3852 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:20:25 +0000 Subject: [PATCH 01/25] Skip session-pending markers that vanish before they are stat-ed (#36) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSnY9UQPcd6a4DCMpXD --- bin/memories.ts | 8 +++++++- tests/bun/session.spec.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/bin/memories.ts b/bin/memories.ts index 3792c2a..8395d2b 100644 --- a/bin/memories.ts +++ b/bin/memories.ts @@ -492,7 +492,13 @@ export function listSessionPending(root: string, now: number = Date.now()): Pend } const files = readdirSync(folder) .filter(name => name.endsWith('.json')) - .map(name => ({ name, mtime: statSync(join(folder, name)).mtimeMs })) + .flatMap(name => { + try { + return [{ name, mtime: statSync(join(folder, name)).mtimeMs }] + } catch { + return [] // gone or claimed since the readdir + } + }) .sort((a, b) => b.mtime - a.mtime || (a.name < b.name ? -1 : 1)) const out: PendingSession[] = [] for (const { name } of files) { diff --git a/tests/bun/session.spec.ts b/tests/bun/session.spec.ts index 2afa43d..30f0019 100644 --- a/tests/bun/session.spec.ts +++ b/tests/bun/session.spec.ts @@ -4,7 +4,8 @@ // under the temp HOME, `--store` and `--root` always name it, and the model is a stand-in. import { Database } from 'bun:sqlite' import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { engineCall, isolatedEnv, ROOT, toolCall } from './helpers' import { hooks, makeHost, until } from './knowledge-host' @@ -508,6 +509,31 @@ describe('SESSION.1: session-pending markers', () => { }) }) +describe('SESSION.1: a session-pending marker that vanishes before it is stat-ed is skipped (#36)', () => { + const entry = (id: string) => ({ sessionId: id, owner: 'global', transcript: `/home/u/.claude/projects/-work/${id}.jsonl` }) + + test('a dangling .json (listed by readdir, gone for stat) is skipped; the rest is listed newest first, in-process and via the CLI', async () => { + const { listSessionPending } = await import('../../bin/memories') + const root = mkdtempSync(join(tmpdir(), 'mnemo-pending-')) + try { + const folder = join(root, 'session-pending') + mkdirSync(folder, { recursive: true }) + for (const [i, id] of ['older', 'newer'].entries()) { + writeFileSync(join(folder, `${id}.json`), JSON.stringify(entry(id))) + const when = new Date(Date.now() - (2 - i) * 60_000) + utimesSync(join(folder, `${id}.json`), when, when) + } + // readdir lists it, statSync throws ENOENT: the same as a marker claimed or removed between the two calls + symlinkSync(join(folder, 'missing-target'), join(folder, 'vanished.json')) + expect(listSessionPending(root).map(item => item.sessionId)).toEqual(['newer', 'older']) + const done = ok(await memories(['session-pending', 'list', root])) + expect((JSON.parse(done.stdout) as { sessionId: string }[]).map(item => item.sessionId)).toEqual(['newer', 'older']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + describe('SESSION.1: every owner the plugin builds gets a pending marker (#28)', () => { test('a per-project owner from a ~200-character folder name is accepted; characters outside the class are still refused', async () => { const { pendingSession } = await import('../../hooks/session') From 292839e526fbc4e60be45df3209db2342306f61f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:20:22 +0000 Subject: [PATCH 02/25] Put back a fresh sweep lock found during stale takeover instead of deleting it (#37) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSnY9UQPcd6a4DCMpXD --- bin/memories.ts | 23 +++++++++++++++++++---- tests/bun/session.spec.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/bin/memories.ts b/bin/memories.ts index 8395d2b..f4897bc 100644 --- a/bin/memories.ts +++ b/bin/memories.ts @@ -513,7 +513,7 @@ export function listSessionPending(root: string, now: number = Date.now()): Pend } /** Makes the lock with an exclusive create, so one caller gets it; a lock left by a claimant that died is taken over. */ -function takeSweepLock(lock: string): boolean { +export function takeSweepLock(lock: string, beforeTakeover?: () => void): boolean { for (let attempt = 0; attempt < 3; attempt++) { try { closeSync(openSync(lock, 'wx', 0o600)) @@ -530,14 +530,29 @@ function takeSweepLock(lock: string): boolean { if (age < SWEEP_LOCK_STALE_MS) return false // a live claimant is checking the marker right now // Stale: moved aside by a rename (one caller's rename wins), and only a lock that is still old is thrown away. const aside = `${lock}.${process.pid}.stale` + beforeTakeover?.() // test seam: another claimant may replace the stale lock here try { renameSync(lock, aside) } catch { continue } - const fresh = Date.now() - statSync(aside).mtimeMs < SWEEP_LOCK_STALE_MS - rmSync(aside, { force: true }) - if (fresh) return false // that was a live claimant's new lock + let fresh: boolean + try { + fresh = Date.now() - statSync(aside).mtimeMs < SWEEP_LOCK_STALE_MS + } catch { + continue // it went in the meantime: try the create again + } + if (!fresh) { + rmSync(aside, { force: true }) + continue + } + // That was a live claimant's new lock: put it back for them; if that fails, drop the copy, but never claim the lock. + try { + renameSync(aside, lock) + } catch { + rmSync(aside, { force: true }) + } + return false } return false } diff --git a/tests/bun/session.spec.ts b/tests/bun/session.spec.ts index 30f0019..198f79d 100644 --- a/tests/bun/session.spec.ts +++ b/tests/bun/session.spec.ts @@ -345,6 +345,25 @@ describe('SESSION.4: the daily marker', () => { expect(existsSync(marker)).toBe(true) }) + test('a stale lock replaced by a live claimant just before the takeover is put back, not deleted (#37)', async () => { + const { takeSweepLock } = await import('../../bin/memories') + const host = await open() + mkdirSync(host.root, { recursive: true }) + const lock = join(host.root, 'expire-sweep.lock') + writeFileSync(lock, '') + const long = new Date(Date.now() - 10 * 60_000) + utimesSync(lock, long, long) + // between our look at the old lock and our rename, another claimant replaces it with its own fresh lock + const taken = takeSweepLock(lock, () => { + const now = new Date() + utimesSync(lock, now, now) + }) + expect(taken).toBe(false) + expect(existsSync(lock)).toBe(true) + expect(Date.now() - statSync(lock).mtimeMs).toBeLessThan(60_000) + expect(readdirSync(host.root).filter(name => name.endsWith('.stale'))).toEqual([]) + }) + test('sweep-claim refuses --store and a bad folder argument', async () => { const host = await open() expect((await memories(['--store', host.store, 'sweep-claim', host.root])).exitCode).not.toBe(0) From 9b595c66b2aa5a69c7da16749d931ad51bdecb8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:39:34 +0000 Subject: [PATCH 03/25] Back off and give up on session summaries whose model call keeps failing (#38) A failed or unanswered summary model call now releases the marker with attempts+1 and notBefore = now + 1h doubling per attempt (cap 24h); session-pending list skips markers still waiting, and the fifth failed attempt drops the marker with one activity line. Old markers parse as attempts 0; session-pending write starts again at 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSnY9UQPcd6a4DCMpXD --- bin/memories.ts | 42 +++++++++++++++-- hooks/register.ts | 12 ++++- hooks/session.ts | 21 +++++++-- tests/bun/session.spec.ts | 94 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 10 deletions(-) diff --git a/bin/memories.ts b/bin/memories.ts index f4897bc..f73a6dc 100644 --- a/bin/memories.ts +++ b/bin/memories.ts @@ -30,7 +30,7 @@ import { basename, dirname, join, resolve } from 'node:path' import { BACKFILL_TEXT_LIMIT, isProposalId } from '../hooks/backfill' import { isKind } from '../hooks/kinds' import { buildRequests, engineEnv, parseReplies } from '../hooks/engine' -import { BLOCK_PINNED_MAX, EXPIRE_DEFAULT_DAYS, expireRequest, expireSetting, parseExpireReply, parsePending, SESSION_CLAIM_STALE_MS, SESSION_CLAIM_SUFFIX, SESSION_ID, SESSION_PENDING_DIR, SWEEP_LIMIT, SWEEP_LOCK, SWEEP_LOCK_STALE_MS, SWEEP_MARKER, sweepDue, sweepMarkerText } from '../hooks/session' +import { BLOCK_PINNED_MAX, EXPIRE_DEFAULT_DAYS, expireRequest, expireSetting, parseExpireReply, parsePending, SESSION_CLAIM_STALE_MS, SESSION_CLAIM_SUFFIX, SESSION_ID, SESSION_MAX_ATTEMPTS, SESSION_PENDING_DIR, sessionBackoffMs, SWEEP_LIMIT, SWEEP_LOCK, SWEEP_LOCK_STALE_MS, SWEEP_MARKER, sweepDue, sweepMarkerText } from '../hooks/session' import type { BlockRead, ExpiredRow, PendingSession } from '../hooks/session' import { cleanTags, MAX_TAGS, normalizeTag } from '../hooks/tags' import { readConfig } from './settings' @@ -476,6 +476,38 @@ export function releaseSessionPending(root: string, sessionId: string) { } } +/** + * Issue #38: puts a claimed marker back after a failed summary model call, with one more failed attempt and a time before + * which it is not tried again; the fifth failed attempt drops it instead. The new marker is a 0600 file renamed over the + * claim, then the claim is put back as releaseSessionPending does. Nothing claimed is nothing to do (attempts 0). + */ +export function failSessionPending(root: string, sessionId: string, now: number = Date.now()): { attempts: number; gaveUp: boolean } { + if (!SESSION_ID.test(sessionId)) throw new Error('a session marker needs a plain session id') + const claim = claimFileOf(root, sessionId) + let entry: PendingSession | undefined + try { + entry = parsePending(readFileSync(claim, 'utf8')) + } catch { + return { attempts: 0, gaveUp: false } + } + if (entry === undefined || entry.sessionId !== sessionId) { + releaseSessionPending(root, sessionId) + return { attempts: 0, gaveUp: false } + } + const attempts = (entry.attempts ?? 0) + 1 + if (attempts >= SESSION_MAX_ATTEMPTS) { + rmSync(claim, { force: true }) + return { attempts, gaveUp: true } + } + const temp = `${claim}.${process.pid}.tmp` + rmSync(temp, { force: true }) + writeFileSync(temp, `${JSON.stringify({ sessionId: entry.sessionId, owner: entry.owner, transcript: entry.transcript, attempts, notBefore: now + sessionBackoffMs(attempts) })}\n`, { mode: 0o600, flag: 'wx' }) + if (!IS_WINDOWS) chmodSync(temp, 0o600) + renameSync(temp, claim) + releaseSessionPending(root, sessionId) + return { attempts, gaveUp: false } +} + /** Every session marker, newest file first (the latest sessions are summarised first); a torn or foreign file is skipped. */ export function listSessionPending(root: string, now: number = Date.now()): PendingSession[] { const folder = sessionPendingDir(root) @@ -504,7 +536,8 @@ export function listSessionPending(root: string, now: number = Date.now()): Pend for (const { name } of files) { try { const entry = parsePending(readFileSync(join(folder, name), 'utf8')) - if (entry !== undefined && `${entry.sessionId}.json` === name) out.push(entry) + // Issue #38: a marker still waiting after a failed attempt is not listed until its time comes. + if (entry !== undefined && `${entry.sessionId}.json` === name && !(entry.notBefore !== undefined && entry.notBefore > now)) out.push(entry) } catch { // unreadable: not an entry } @@ -782,8 +815,9 @@ async function main(argv: readonly string[]) { if (more.length !== 1) throw new Error('usage: session-pending claim ') process.stdout.write(`${JSON.stringify({ claimed: claimSessionPending(root, more[0]!) })}\n`) } else if (action === 'release') { - if (more.length !== 1) throw new Error('usage: session-pending release ') - releaseSessionPending(root, more[0]!) + if (more.length === 2 && more[1] === '--failed') process.stdout.write(`${JSON.stringify(failSessionPending(root, more[0]!))}\n`) + else if (more.length !== 1) throw new Error('usage: session-pending release [--failed]') + else releaseSessionPending(root, more[0]!) } else { throw new Error('usage: session-pending write|list|claim|release|remove ...') } diff --git a/hooks/register.ts b/hooks/register.ts index 7e58ab0..2e16e1a 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -83,6 +83,7 @@ import { parsePending, parseSummary, pendingSession, + SESSION_MAX_ATTEMPTS, SUMMARY_CALL_TIMEOUT_MS, SUMMARY_MAX_PER_RUN, SUMMARY_MAX_TOKENS, @@ -1805,7 +1806,16 @@ async function finishSessionSummaries($: EngineInterface, options: PluginOptions () => $.model.complete({ model, system: summarySystemPrompt(), prompt: summaryPrompt(read.text), maxTokens: SUMMARY_MAX_TOKENS, timeoutMs: SUMMARY_CALL_TIMEOUT_MS }, { signal: never }), ms => $.clock.sleep(ms, { signal: never }), ).catch(() => undefined) - if (reply === undefined || !reply.isAnswered) continue + if (reply === undefined || !reply.isAnswered) { + // Issue #38: a failed or unanswered call counts: the marker waits longer each time, and the fifth failure drops it. + finished = true + const gaveUp = await memoriesCli($, options, ['session-pending', 'release', root, entry.sessionId, '--failed'], undefined, SESSION_CLI_TIMEOUT_MS).then( + out => (JSON.parse(out) as { gaveUp?: unknown }).gaveUp === true, + async () => (await release(entry), false), + ).catch(() => false) + if (gaveUp) await record($, options, [{ action: 'remember', origin: 'session', owner: entry.owner, client: 'mnemosyne-session', text: 'session summary', ids: [], count: 0, ok: false, error: `the summary was given up after ${SESSION_MAX_ATTEMPTS} failed attempts` }]) + continue + } const summary = parseSummary(reply.text) const guard = summary === undefined ? undefined : admit({ content: summary, kind: 'event', origin: 'import' }, guardConfigOf(settings)) // Nothing to say, a block, or a warning with nobody to confirm it (strict mode): the marker goes and nothing is saved. diff --git a/hooks/session.ts b/hooks/session.ts index aba19b0..307f233 100644 --- a/hooks/session.ts +++ b/hooks/session.ts @@ -119,8 +119,17 @@ export const SESSION_ID = /^[A-Za-z0-9_-]{1,100}$/ // The same limit as OWNER in hooks/projects.ts ('project-' plus up to OWNER_NAME_MAX): ownerId() does not cap the folder name. const OWNER_ID = new RegExp(`^[A-Za-z0-9_-]{1,${'project-'.length + OWNER_NAME_MAX}}$`) -/** What a marker holds: the ended session's id, its owner and its transcript path. No conversation text, no memory text. */ -export type PendingSession = { sessionId: string; owner: string; transcript: string } +/** + * What a marker holds: the ended session's id, its owner and its transcript path. No conversation text, no memory text. + * Issue #38: after a failed summary model call it also holds the failed attempts and the time (ms since the epoch) before + * which it is not tried again. An old marker without them is attempts 0 with no wait. + */ +export type PendingSession = { sessionId: string; owner: string; transcript: string; attempts?: number; notBefore?: number } + +/** Issue #38: the failed summary attempt that drops a marker for good. */ +export const SESSION_MAX_ATTEMPTS = 5 +/** Issue #38: the wait after a marker's nth failed attempt: 1 hour, doubling per attempt, at most 24 hours. */ +export const sessionBackoffMs = (attempts: number) => Math.min(3_600_000 * 2 ** Math.max(0, attempts - 1), 86_400_000) /** Claude Code's folder name for a project: the working folder with every character outside A-Z, a-z, 0-9 turned into '-'. */ export const projectSlug = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-') @@ -138,7 +147,13 @@ export function pendingSession(sessionId: unknown, owner: unknown, transcript: u export function parsePending(text: string): PendingSession | undefined { try { const value = JSON.parse(text) as Record - return value === null || typeof value !== 'object' ? undefined : pendingSession(value.sessionId, value.owner, value.transcript) + if (value === null || typeof value !== 'object') return undefined + const entry = pendingSession(value.sessionId, value.owner, value.transcript) + if (entry === undefined) return undefined + const { attempts, notBefore } = value + if (typeof attempts === 'number' && Number.isSafeInteger(attempts) && attempts > 0) entry.attempts = attempts + if (typeof notBefore === 'number' && Number.isFinite(notBefore)) entry.notBefore = notBefore + return entry } catch { return undefined } diff --git a/tests/bun/session.spec.ts b/tests/bun/session.spec.ts index 198f79d..da30edc 100644 --- a/tests/bun/session.spec.ts +++ b/tests/bun/session.spec.ts @@ -526,6 +526,46 @@ describe('SESSION.1: session-pending markers', () => { writeFileSync(join(host.root, 'session-pending', 'mismatch.json'), JSON.stringify(entry('other'))) expect(JSON.parse((await memories(['session-pending', 'list', host.root])).stdout)).toEqual([]) }) + + test('issue #38: an old marker without attempts or notBefore parses as before; bad values are ignored', async () => { + const { parsePending } = await import('../../hooks/session') + expect(parsePending(JSON.stringify(entry('a')))).toEqual(entry('a')) + expect(parsePending(JSON.stringify({ ...entry('a'), attempts: 2, notBefore: 1234 }))).toEqual({ ...entry('a'), attempts: 2, notBefore: 1234 }) + expect(parsePending(JSON.stringify({ ...entry('a'), attempts: -1, notBefore: 'soon' }))).toEqual(entry('a')) + expect(parsePending(JSON.stringify({ ...entry('a'), attempts: 1.5 }))).toEqual(entry('a')) + }) + + test('issue #38: list skips a marker whose notBefore is in the future; release --failed counts and backs off; write starts again at 0', async () => { + const host = await open() + mkdirSync(host.root, { recursive: true }) + const folder = join(host.root, 'session-pending') + const listed = async () => JSON.parse(ok(await memories(['session-pending', 'list', host.root])).stdout) as { sessionId: string; attempts?: number; notBefore?: number }[] + ok(await memories(['session-pending', 'write', host.root], JSON.stringify(entry('one')))) + expect((JSON.parse(ok(await memories(['session-pending', 'claim', host.root, 'one'])).stdout) as { claimed: boolean }).claimed).toBe(true) + const failed = JSON.parse(ok(await memories(['session-pending', 'release', host.root, 'one', '--failed'])).stdout) as { attempts: number; gaveUp: boolean } + expect(failed).toEqual({ attempts: 1, gaveUp: false }) + expect(readdirSync(folder)).toEqual(['one.json']) + expect((statSync(join(folder, 'one.json')).mode & 0o777).toString(8)).toBe('600') + const stored = JSON.parse(readFileSync(join(folder, 'one.json'), 'utf8')) as { attempts: number; notBefore: number } + expect(stored.attempts).toBe(1) + expect(stored.notBefore).toBeGreaterThan(Date.now() + 59 * 60_000) + expect(await listed()).toEqual([]) + writeFileSync(join(folder, 'one.json'), JSON.stringify({ ...stored, notBefore: Date.now() - 1000 })) + const back = await listed() + expect(back.map(item => [item.sessionId, item.attempts])).toEqual([['one', 1]]) + // a marker rewritten by write starts again at 0 + ok(await memories(['session-pending', 'write', host.root], JSON.stringify({ ...entry('one'), attempts: 4, notBefore: Date.now() + DAY }))) + expect(Object.keys(JSON.parse(readFileSync(join(folder, 'one.json'), 'utf8'))).sort()).toEqual(['owner', 'sessionId', 'transcript']) + expect(await listed()).toEqual([entry('one')]) + // the fifth failed attempt drops the marker + writeFileSync(join(folder, 'one.json'), JSON.stringify({ ...entry('one'), attempts: 4 })) + expect((JSON.parse(ok(await memories(['session-pending', 'claim', host.root, 'one'])).stdout) as { claimed: boolean }).claimed).toBe(true) + expect(JSON.parse(ok(await memories(['session-pending', 'release', host.root, 'one', '--failed'])).stdout)).toEqual({ attempts: 5, gaveUp: true }) + expect(readdirSync(folder)).toEqual([]) + // nothing claimed: nothing to count + expect(JSON.parse(ok(await memories(['session-pending', 'release', host.root, 'one', '--failed'])).stdout)).toEqual({ attempts: 0, gaveUp: false }) + expect((await memories(['session-pending', 'release', host.root, 'one', '--other'])).exitCode).not.toBe(0) + }) }) describe('SESSION.1: a session-pending marker that vanishes before it is stat-ed is skipped (#36)', () => { @@ -829,13 +869,63 @@ describe('SESSION.1 and SESSION.2: the summary of the last session and the first await host.settled() expect(host.model).toHaveLength(1) expect(summaryRows(host)).toHaveLength(0) - expect(existsSync(join(host.root, 'session-pending', 'old-1.json'))).toBe(true) + const marker = join(host.root, 'session-pending', 'old-1.json') + expect(existsSync(marker)).toBe(true) + // issue #38: the failed attempt is counted and the marker waits an hour before the next try + const after = JSON.parse(readFileSync(marker, 'utf8')) as { attempts?: number; notBefore?: number } + expect(after.attempts).toBe(1) + expect(after.notBefore! - Date.now()).toBeGreaterThan(59 * 60_000) + expect(after.notBefore! - Date.now()).toBeLessThanOrEqual(60 * 60_000) + expect((statSync(marker).mode & 0o777).toString(8)).toBe('600') host.setSession('new-3') await host.prompt('First prompt of the third session, please help.') await host.turn() await host.settled() + expect(host.model).toHaveLength(1) + expect(existsSync(marker)).toBe(true) + // the clock past notBefore: the next first prompt tries again and finishes it + writeFileSync(marker, JSON.stringify({ ...after, notBefore: Date.now() - 1000 })) + host.setSession('new-4') + await host.prompt('First prompt of the fourth session, please help.') + await host.turn() + await host.settled() + expect(host.model).toHaveLength(2) expect(summaryRows(host)).toHaveLength(1) - expect(existsSync(join(host.root, 'session-pending', 'old-1.json'))).toBe(false) + expect(existsSync(marker)).toBe(false) + }) + + test('a model that always fails: the back-off doubles per attempt and after five failed attempts the marker is dropped with one activity line', async () => { + const host = await open({ options: { sessionSummary: 'on' }, model: () => ({ isAnswered: false, reason: 'api-error', status: 400, error: 'bad model id' }) }) + writeTranscript(host, 'old-1') + await host.sessionEnd('old-1') + const marker = join(host.root, 'session-pending', 'old-1.json') + for (let n = 1; n <= 5; n++) { + host.setSession(`new-${n}`) + await host.prompt(`First prompt of session number ${n}, please help.`) + await host.turn() + await host.settled() + expect(host.model).toHaveLength(n) + if (n < 5) { + const state = JSON.parse(readFileSync(marker, 'utf8')) as { attempts?: number; notBefore?: number } + expect(state.attempts).toBe(n) + const wait = state.notBefore! - Date.now() + expect(wait).toBeGreaterThan(2 ** (n - 1) * 3_600_000 - 60_000) + expect(wait).toBeLessThanOrEqual(2 ** (n - 1) * 3_600_000) + // the clock moves past notBefore + writeFileSync(marker, JSON.stringify({ ...state, notBefore: Date.now() - 1000 })) + } + } + expect(readdirSync(join(host.root, 'session-pending'))).toEqual([]) + expect(summaryRows(host)).toHaveLength(0) + const lines = host.activity().filter(entry => entry.origin === 'session' && entry.client === 'mnemosyne-session') + expect(lines).toHaveLength(1) + expect(lines[0]).toMatchObject({ ok: false, count: 0 }) + expect(String(lines[0]!.error)).toContain('given up') + host.setSession('new-6') + await host.prompt('First prompt of session number six, please help.') + await host.turn() + await host.settled() + expect(host.model).toHaveLength(5) }) test('a session with no transcript is dropped without a model call', async () => { From 8f2a6a96549d5bbd71f75e2e3c2e5da61c1080a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:23:21 +0000 Subject: [PATCH 04/25] docs(plans): Status lines say what shipped; the sweep marker is written first by design (#39) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSnY9UQPcd6a4DCMpXD --- docs/plans/mapx-solar-map.md | 2 ++ docs/plans/mnemosyne-settings.md | 2 ++ docs/plans/mnemosyne-v3.md | 4 +++- docs/plans/standalone-install.md | 1 + docs/plans/ui-polish-proposal.md | 2 ++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/plans/mapx-solar-map.md b/docs/plans/mapx-solar-map.md index 77ebd6d..a7bbdd6 100644 --- a/docs/plans/mapx-solar-map.md +++ b/docs/plans/mapx-solar-map.md @@ -2,6 +2,8 @@ Status: decided, ready to build (Rohit's decisions 2026-10-25, see Decisions). Created 2026-10-25. Baseline: `main` at `ba6f97d` (plugin 0.8.2). Delivery: each task group on a short branch off `main`, merged straight into `main` after a separate review; nothing is pushed. Release: 0.8.3 (Rohit, confirmed 2026-10-25). +Status 2026-11-08: shipped in 0.8.3 (2026-10-25), MAPX.0 to MAPX.8, MAPX.10 and MAPX.11; 0.8.4 (2026-10-25) then placed the Galaxy page as in the mockup. Not built: MAPX.9 (Edit, Pin and Retire in the Details panel); the panel is still read-only and points to the Review tab for Restore. + This plan was split out of phase MAPX of `docs/plans/mnemosyne-v3.md` on 2026-10-25, because Rohit wants the map built first, ahead of the rest of 0.9.0 (the dashboard writes, the importers, session summaries and the expiry sweep). Everything the old MAPX text said is carried here; what changed, and why, is listed in "Notes for the reviewer". A later step removes MAPX from the 0.9.0 plan; "What the 0.9.0 plan must drop or point to" is the list for that step. ## Summary diff --git a/docs/plans/mnemosyne-settings.md b/docs/plans/mnemosyne-settings.md index 4bb688a..7d6a471 100644 --- a/docs/plans/mnemosyne-settings.md +++ b/docs/plans/mnemosyne-settings.md @@ -2,6 +2,8 @@ Status: decided, ready to build (Rohit accepted all 13 proposed answers, 2026-10-25; see Decisions). Created 2026-10-25. Baseline: `main` at `59d1f3e` (0.8.4). Release: 0.8.5, before the 0.9.0 half of `docs/plans/mnemosyne-v3.md` (waves 4-7) starts, so the 0.9.0 lanes branch from a `main` that already has this. Delivery: each phase on a short branch off `main`, merged into `main` after its review; nothing is pushed until the release task. +Status 2026-11-08: shipped in 0.8.5 (2026-10-26), phases CFG, WIRE, SRV, UI and REL85. UI.4 is Rohit's manual check on his installed plugin and is not recorded here. + Research reference: `research/settings-research.md` (next to this plan; read-only homework, 2026-10-25). Section numbers below ("R1.3") point into it. ## Summary diff --git a/docs/plans/mnemosyne-v3.md b/docs/plans/mnemosyne-v3.md index 83efb8d..23638d4 100644 --- a/docs/plans/mnemosyne-v3.md +++ b/docs/plans/mnemosyne-v3.md @@ -4,6 +4,8 @@ Status: decided, ready to build (Rohit's decisions 2026-10-24, see Decisions). C Status 2026-10-25: waves 1-3b are shipped (0.8.0, then fixes 0.8.1 and 0.8.2; `main` at `2f1a104`). The 0.9.0 half (waves 4-7) was reviewed against the code on 2026-10-25 and corrected in place; see "0.9.0 review (2026-10-25)" before Wave 4. Not yet started; Rohit is still adding points before execution is planned. The solar-system map (the old phase MAPX) and the project registry (the old SESSION.5) moved to `docs/plans/mapx-solar-map.md` on 2026-10-25; the map plan ships first and `main` moves on, so lanes branch from the new `main`. +Status 2026-11-08: the 0.9.0 half shipped in 0.9.0 (2026-10-26): CRUDX.0 to CRUDX.5, IMPORT.0 to IMPORT.3, CRUD.1 to CRUD.4, SESSION.1, SESSION.2, SESSION.4 and HARDEN.3. Not built by an agent: HARDEN.4 and HARDEN.5 are Rohit's manual runs on his store and are not recorded here. Wave 8 (LATER.1, LATER.2, LATER.3, LATER.5) is still parked. + ## Summary Version 3 of the Mnemosyne plugin: control what goes into memory, and organise what is there. Every save, from Claude, outside agents, the dashboard or an importer, passes one guard that blocks secrets and oversize text and warns about near-duplicates and task chatter. Saves then record honest provenance and a kind instead of 'imported' and 'unknown'. Tags come from a small shared vocabulary with entity, agent and asset namespaces. The dashboard gains create, edit, retire and a review queue, and documents and session logs can be imported through the same guard. It matters because the store today holds 447 tags for 140 memories (407 used once), five rows marked SUPERSEDED that are still recalled, and no usable provenance at all. @@ -806,7 +808,7 @@ When a session ends (exit, `/clear` or resume), the plugin notes it in a small p 1. Goal: the plugin side of auto-expire. Needs MODEL.5 and IMPORT.3 (the pinned flag). 2. Acceptance: 1. a plugin option `expireUnusedDays`: 90 (default), or 0 to turn the sweep off; values 1-29 are refused; - 2. the sweep runs at most once per 24 hours (a marker file, mode 0600), triggered from `session.start` and from a session's first prompt (so a long-lived process that only sees `/clear` still sweeps), after the reply, in the background: the C9 backup first (stop if it fails), then `mnemopi_expire_unused` with `dry_run` false and `validator: 'expire-sweep'` (C10), for the owner's own rows only; the activity log line (action 'expire', origin 'session') holds counts and ids, never memory text; + 2. the sweep runs at most once per 24 hours (a marker file, mode 0600), triggered from `session.start` and from a session's first prompt (so a long-lived process that only sees `/clear` still sweeps), after the reply, in the background: the C9 backup first (stop if it fails), then `mnemopi_expire_unused` with `dry_run` false and `validator: 'expire-sweep'` (C10), for the owner's own rows only; the activity log line (action 'expire', origin 'session') holds counts and ids, never memory text. The 24-hour marker is written before the sweep runs, by design (see `claimSweep` in `bin/memories.ts`), so a failed sweep is tried again only after 24 hours; 3. `bun bin/memories.ts expire` is a dry run that prints the candidates (id, kind, last used, first 80 characters) and `bun bin/memories.ts expire --apply` runs it for real after a backup, so Rohit can preview the first run on his store; 4. rows with a pinned flag, other kinds or an existing expiry are never touched (the engine enforces this; a spec checks the plugin does not pass wider kinds than the option allows); 5. `tests/session.test.ts` and `tests/bun/` specs cover the daily marker, both triggers, the off setting, a failed backup stopping the sweep, and the dry run, all on a temp store. diff --git a/docs/plans/standalone-install.md b/docs/plans/standalone-install.md index c32a3ed..2168669 100644 --- a/docs/plans/standalone-install.md +++ b/docs/plans/standalone-install.md @@ -1,5 +1,6 @@ # standalone-install: install the engine and proxy outside Claude Code (for Switch), issue #43 Status: revision 2, ready to build (2026-11-01). Revised after the OMP DeepSeek v4.1 flash review in `docs/plans/research/standalone-install-review.md` (17 findings F1 to F17, answers Q1 to Q6); every finding is handled below, marked `[F#]`. Baseline: `main` at `8dfb382` (plugin 0.9.0, `ENGINE_BUILD` `18.12.3-mnemosyne.10`). Delivery: one branch in an Orca worktree, one commit per task, merged into `main` only after a separate review; nothing is pushed or tagged without Rohit. Release: 0.10.0 (next minor). Source: GitHub issue #43 (from Interstellar-code/switch-agent #5, decision SD12) and the Switch docs read on 2026-10-31. +Status 2026-11-08: shipped in 0.10.0 (2026-11-01), SA.1 to SA.5; install fixes followed in 0.10.1 (2026-11-01). ## Summary diff --git a/docs/plans/ui-polish-proposal.md b/docs/plans/ui-polish-proposal.md index 1c88cdd..d3ef445 100644 --- a/docs/plans/ui-polish-proposal.md +++ b/docs/plans/ui-polish-proposal.md @@ -4,6 +4,8 @@ Date: 2026-10-24. Status: proposal, nothing built. Source: a read-only review of `ui/` (four parallel reviews plus the screenshots). No browser run was done, so the line references are from reading the code, not from a live check. +Status 2026-11-08: partly done, mostly through other plans and in other forms. Already in the first commit of this repository (0.8.1, 2026-10-25): X1 (the Map loads first), X2 (the shared bottom sheet, `ui/sheet.ts`) and Search's Keyword / Keyword + meaning control (Search 1). Section 1 (Map) was superseded by the solar-system map (`docs/plans/mapx-solar-map.md`, 0.8.3, 2026-10-25). The Review tab and `GET /api/review` (section 6, items 8 and 9) shipped in 0.9.0 (2026-10-26). The tab polish of 0.17.0 to 0.25.0 (2026-11-07) followed a later design proposal, not this list, and covers much of sections 2 to 6 in its own way, for example X3 (Health's System panel, 0.23.0), why a hit matched (0.19.0), Activity grouping (0.21.0), Knowledge grouping and filters (0.20.0) and a Keep in Review (0.22.0); see those CHANGELOG entries. Items not named here are open or dropped. + Effort: S = under an hour, M = a few hours, L = a day or more. ## 0. Cross-page changes (do first, every page gains) From 07fadc3817edb06ec7538ed0a47dd057941e2815 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:23:53 +0000 Subject: [PATCH 05/25] fix(engine): resolve 'day before yesterday' to two days back (#9) The yesterday check ran first and matched the phrase; check the two-day form first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/temporal-parser.ts | 10 ++++----- .../temporal-day-before-yesterday.spec.ts | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 engine/test/temporal-day-before-yesterday.spec.ts diff --git a/engine/src/core/temporal-parser.ts b/engine/src/core/temporal-parser.ts index 327283b..01a3741 100644 --- a/engine/src/core/temporal-parser.ts +++ b/engine/src/core/temporal-parser.ts @@ -210,6 +210,11 @@ export function parseNlDate(text: string, reference?: QueryTime): ParsedNaturalD return [d, "day", [isoDate(d), dayName(d)]]; } + if (/\bday before yesterday\b/.test(textLower) || /\bday\s+before\s+yesterday\b/.test(textLower)) { + const d = addDays(ref, -2); + return [d, "day", [isoDate(d)]]; + } + if (/\byesterday\b/.test(textLower)) { const d = addDays(ref, -1); return [d, "day", [isoDate(d), dayName(d), "yesterday"]]; @@ -220,11 +225,6 @@ export function parseNlDate(text: string, reference?: QueryTime): ParsedNaturalD return [d, "day", [isoDate(d), dayName(d), "tomorrow"]]; } - if (/\bday before yesterday\b/.test(textLower) || /\bday\s+before\s+yesterday\b/.test(textLower)) { - const d = addDays(ref, -2); - return [d, "day", [isoDate(d)]]; - } - m = /\b(last|this|next)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)\b/.exec( textLower, diff --git a/engine/test/temporal-day-before-yesterday.spec.ts b/engine/test/temporal-day-before-yesterday.spec.ts new file mode 100644 index 0000000..a60c3ed --- /dev/null +++ b/engine/test/temporal-day-before-yesterday.spec.ts @@ -0,0 +1,21 @@ +// Issue #9: "the day before yesterday" must resolve two days back, not one (the yesterday check +// used to match it first, leaving the two-day branch dead). +import { describe, expect, test } from "bun:test"; +import { extractDateFromText, parseNlDate } from "../src/core/temporal-parser"; + +const REF = "2026-03-11T12:00:00Z"; // a Wednesday + +describe("parseNlDate relative days", () => { + test("'the day before yesterday' is two days back", () => { + const parsed = parseNlDate("we shipped it the day before yesterday", REF); + expect(parsed?.[0].toISOString().slice(0, 10)).toBe("2026-03-09"); + expect(parsed?.[1]).toBe("day"); + expect(extractDateFromText("the day before yesterday", REF)).toBe("2026-03-09"); + }); + + test("'yesterday' is still one day back", () => { + const parsed = parseNlDate("we shipped it yesterday", REF); + expect(parsed?.[0].toISOString().slice(0, 10)).toBe("2026-03-10"); + expect(parsed?.[2]).toContain("yesterday"); + }); +}); From 31fd22479d1e636d1446877fcf3a7effaf4c23d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:27:50 +0000 Subject: [PATCH 06/25] Match confidence boosters as whole words, not substrings (#23) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/typed-memory.ts | 3 ++- engine/test/confidence-boosters.spec.ts | 27 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 engine/test/confidence-boosters.spec.ts diff --git a/engine/src/core/typed-memory.ts b/engine/src/core/typed-memory.ts index 5bc7720..4ad935f 100644 --- a/engine/src/core/typed-memory.ts +++ b/engine/src/core/typed-memory.ts @@ -305,7 +305,8 @@ export function classifyMemory(content: string): TypeMatch { } for (const booster of CONFIDENCE_BOOSTERS[memoryType]) { - if (contentLower.includes(booster.toLowerCase())) { + const escaped = booster.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`\\b${escaped}\\b`).test(contentLower)) { confidence += 0.05; } } diff --git a/engine/test/confidence-boosters.spec.ts b/engine/test/confidence-boosters.spec.ts new file mode 100644 index 0000000..06ffb25 --- /dev/null +++ b/engine/test/confidence-boosters.spec.ts @@ -0,0 +1,27 @@ +// Issue #23: confidence boosters must match as whole words/phrases, not raw substrings. +import { describe, expect, test } from "bun:test"; +import { classifyMemory, MemoryType } from "../src/core/typed-memory"; + +describe("confidence boosters match whole words (#23)", () => { + test("'at'/'on' inside other words give no event boost", () => { + // "incident 42" -> EVENT 0.8 base, +0.05 for an 11-char match; no real booster words. + const plain = classifyMemory("incident 42 logged"); + const inside = classifyMemory("incident 42 logged database configuration"); + expect(plain.memoryType).toBe(MemoryType.EVENT); + expect(plain.confidence).toBeCloseTo(0.85, 5); + expect(inside.memoryType).toBe(MemoryType.EVENT); + expect(inside.confidence).toBeCloseTo(0.85, 5); + }); + + test("'at'/'on' as whole words still boost", () => { + const real = classifyMemory("incident 42 logged at 3pm on friday"); + expect(real.memoryType).toBe(MemoryType.EVENT); + expect(real.confidence).toBeCloseTo(0.95, 5); + }); + + test("a false event boost no longer beats a better type", () => { + // LEARNING "turns out" 0.7 * 1.9 = 1.33 beats EVENT 0.85 * 1.5 = 1.275, but a false +0.10 made EVENT 1.425. + const m = classifyMemory("incident 42 turns out the database configuration"); + expect(m.memoryType).toBe(MemoryType.LEARNING); + }); +}); From d84b59f82101ecac7aed8157b140f9c5a9b01f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:20:09 +0000 Subject: [PATCH 07/25] fix(engine): match temporal trigger keywords on word boundaries (#17) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/polyphonic-recall.ts | 5 +++-- engine/test/temporal-trigger.spec.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 engine/test/temporal-trigger.spec.ts diff --git a/engine/src/core/polyphonic-recall.ts b/engine/src/core/polyphonic-recall.ts index ebd8fcd..7340a8c 100644 --- a/engine/src/core/polyphonic-recall.ts +++ b/engine/src/core/polyphonic-recall.ts @@ -268,10 +268,11 @@ function anchorTime(queryTime: string | Date | null | undefined): number | null return Number.isFinite(parsed) ? parsed : null; } -function looksTemporal(query: string): boolean { +export function looksTemporal(query: string): boolean { const lower = query.toLowerCase(); + // Whole words only, so "Chicago", "plastic" or "beforehand" do not switch the voice on (#17). return ["yesterday", "today", "recent", "last", "latest", "this week", "this month", "ago", "before"].some(keyword => - lower.includes(keyword), + new RegExp(`\\b${keyword}\\b`).test(lower), ); } diff --git a/engine/test/temporal-trigger.spec.ts b/engine/test/temporal-trigger.spec.ts new file mode 100644 index 0000000..694279d --- /dev/null +++ b/engine/test/temporal-trigger.spec.ts @@ -0,0 +1,24 @@ +// Issue #17: the temporal voice's trigger matches its keywords on word boundaries, so words that +// merely contain one (Chicago, Elasticsearch, plastic, beforehand) do not switch it on. +import { expect, test } from "bun:test"; +import { looksTemporal } from "../src/core/polyphonic-recall"; + +test("looksTemporal ignores keywords embedded in other words", () => { + for (const q of ["Chicago office", "Elasticsearch plastic", "beforehand", "blast radius", "Atlas lasting"]) { + expect(looksTemporal(q)).toBe(false); + } +}); + +test("looksTemporal still fires on whole keywords and phrases", () => { + for (const q of [ + "2 days ago", + "last week", + "before the release", + "today", + "what changed this week?", + "Latest deploy", + "Yesterday's notes", + ]) { + expect(looksTemporal(q)).toBe(true); + } +}); From 0220502d27de74e05fadc340fbff1012d24e6efb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:16:34 +0000 Subject: [PATCH 08/25] Forward useSynonyms, useIntent, useMmr and mmrLambda through the memory facade (#7) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/memory.ts | 4 ++ engine/test/recall-facade-options.spec.ts | 51 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 engine/test/recall-facade-options.spec.ts diff --git a/engine/src/core/memory.ts b/engine/src/core/memory.ts index df88faa..ac15995 100644 --- a/engine/src/core/memory.ts +++ b/engine/src/core/memory.ts @@ -333,6 +333,10 @@ function toRecallOptions(options: RecallFacadeOptions): BeamRecallFacadeOptions ftsWeight: options.ftsWeight ?? options.fts_weight ?? undefined, importanceWeight: options.importanceWeight ?? options.importance_weight ?? undefined, contentPreviewChars: options.contentPreviewChars, + useSynonyms: options.useSynonyms, + useIntent: options.useIntent, + useMmr: options.useMmr, + mmrLambda: options.mmrLambda, }; // Preserve the three-state semantics (`undefined` = auto-derive, `null` = explicitly // FTS-only, `number[]` = caller-supplied) so callers can opt out of `recall()`'s diff --git a/engine/test/recall-facade-options.spec.ts b/engine/test/recall-facade-options.spec.ts new file mode 100644 index 0000000..3f2fb37 --- /dev/null +++ b/engine/test/recall-facade-options.spec.ts @@ -0,0 +1,51 @@ +// Issue #7: the memory facade's recall()/recallEnhanced() forwarded a fresh +// options object to beam recall that dropped useSynonyms, useIntent, useMmr +// and mmrLambda. Real temporary store, no embeddings. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { Mnemopi } from "../src/core/memory"; +import { tempStore } from "./helpers"; + +process.env.MNEMOPI_NO_EMBEDDINGS = "1"; + +let store: ReturnType; +let memory: Mnemopi; +beforeEach(() => { + store = tempStore(); + memory = new Mnemopi({ dbPath: join(store.dir, "m.db"), noEmbeddings: true }); +}); +afterEach(() => { + memory.close(); + store.dispose(); +}); + +test("useSynonyms:false through the facade disables a synonym-only match", async () => { + memory.remember("the team prefers tabs over spaces"); + const byDefault = await memory.recall("preference", 5); + expect(byDefault.map(r => r.content)).toContain("the team prefers tabs over spaces"); + const noSynonyms = await memory.recall("preference", 5, { useSynonyms: false }); + expect(noSynonyms.map(r => r.content)).not.toContain("the team prefers tabs over spaces"); +}); + +test("recall and recallEnhanced forward useSynonyms, useIntent, useMmr and mmrLambda; unset stays undefined", async () => { + const seen: { name: string; options: Record }[] = []; + const beam = memory.beam as unknown as Record unknown>; + for (const name of ["recall", "recallEnhanced"]) { + const original = beam[name]!.bind(memory.beam); + beam[name] = (query: unknown, topK: unknown, options: unknown) => { + seen.push({ name, options: options as Record }); + return original(query, topK, options); + }; + } + const flags = { useSynonyms: false, useIntent: true, useMmr: true, mmrLambda: 0.3 }; + await memory.recall("anything", 5, flags); + expect(seen[0]).toMatchObject({ name: "recall", options: flags }); + + seen.length = 0; + await memory.recallEnhanced("anything", 5, flags); + expect(seen[0]).toMatchObject({ name: "recallEnhanced", options: flags }); + + seen.length = 0; + await memory.recall("anything", 5); + for (const key of Object.keys(flags)) expect(seen[0]!.options[key]).toBeUndefined(); +}); From 0d282f8c6d2ad5a7df947de9d5911424d958ed27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:16:38 +0000 Subject: [PATCH 09/25] fix(recall): vector similarity covers every visible embedded row, not the 500 newest (#19) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/beam/recall.ts | 20 +++++++- engine/test/vector-recall-coverage.spec.ts | 53 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 engine/test/vector-recall-coverage.spec.ts diff --git a/engine/src/core/beam/recall.ts b/engine/src/core/beam/recall.ts index a04a021..35fa9d0 100644 --- a/engine/src/core/beam/recall.ts +++ b/engine/src/core/beam/recall.ts @@ -711,6 +711,22 @@ function allVisibleIds( return rows.map(row => asString(row.id)).filter(Boolean); } +/** Every visible row with a stored embedding, not only the newest DEFAULT_LIMIT (issue #19). */ +function embeddedVisibleIds( + beam: BeamMemoryState, + table: "working_memory" | "episodic_memory", + options: RecallOptionsInternal, +): string[] { + if (!tableExists(beam, "memory_embeddings")) return []; + const { where, params } = buildWhere(beam, "", options); + const rows = queryAll( + beam, + `SELECT id FROM ${table} WHERE ${where} AND id IN (SELECT memory_id FROM memory_embeddings)`, + params, + ); + return rows.map(row => asString(row.id)).filter(Boolean); +} + function fetchCandidates( beam: BeamMemoryState, tierLabel: TierLabel, @@ -981,8 +997,8 @@ function collectMemoryCandidates( let wmVec = new Map(); let emVec = new Map(); if (queryEmbedding !== null && queryEmbedding !== undefined) { - const allWmIds = options.includeWorking === false ? [] : allVisibleIds(beam, "working_memory", options); - const allEmIds = allVisibleIds(beam, "episodic_memory", options); + const allWmIds = options.includeWorking === false ? [] : embeddedVisibleIds(beam, "working_memory", options); + const allEmIds = embeddedVisibleIds(beam, "episodic_memory", options); wmVec = vectorSimilarities(beam, allWmIds, queryEmbedding); emVec = vectorSimilarities(beam, allEmIds, queryEmbedding); wmIds = [ diff --git a/engine/test/vector-recall-coverage.spec.ts b/engine/test/vector-recall-coverage.spec.ts new file mode 100644 index 0000000..b354ecb --- /dev/null +++ b/engine/test/vector-recall-coverage.spec.ts @@ -0,0 +1,53 @@ +// Issue #19: with a query embedding, vector similarity covers every visible row with a stored +// embedding, not only the 500 newest per tier. Real temporary store; embeddings written directly. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { BeamMemory } from "../src/core/beam"; +import { tempStore } from "./helpers"; + +process.env.MNEMOPI_NO_EMBEDDINGS = "1"; + +let store: ReturnType; +beforeEach(() => { + store = tempStore(); +}); +afterEach(() => store.dispose()); + +const DIMS = 8; +function axes(weights: Record): number[] { + const v = new Array(DIMS).fill(0); + for (const [i, w] of Object.entries(weights)) v[Number(i)] = w; + return v; +} + +test("the oldest memory, the only close embedding match with no shared keywords, is recalled past 500 rows", async () => { + const beam = new BeamMemory({ sessionId: "o1", dbPath: join(store.dir, "vec.db") }); + try { + const insertEmbedding = beam.db.query( + "INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model) VALUES (?, ?, 'test')", + ); + const setTime = beam.db.query("UPDATE working_memory SET timestamp = ? WHERE id = ?"); + const base = Date.now() - 1000 * 86_400_000; + const oldest = beam.remember("the kestrel hovers over the quiet meadow at dusk", { + source: "imported", + scope: "global", + }); + setTime.run(new Date(base).toISOString(), oldest); + insertEmbedding.run(oldest, JSON.stringify(axes({ 0: 1 }))); + for (let i = 0; i < 520; i += 1) { + const id = beam.remember(`filler note number ${i} about invoice ledger item ${i * 7}`, { + source: "imported", + scope: "global", + }); + setTime.run(new Date(base + (i + 1) * 60_000).toISOString(), id); + insertEmbedding.run(id, JSON.stringify(axes({ 1: 1 }))); + } + const results = await beam.recall("zebra xylophone", 10, { + queryEmbedding: axes({ 0: 1 }), + updateRecallCounts: false, + } as never); + expect(results.map(r => r.id)).toContain(oldest); + } finally { + beam.close(); + } +}); From 0b751e7af8de6ead9d7dc66cd3a5d5cbd2b6a2e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:24:27 +0000 Subject: [PATCH 10/25] Keep every query word in the FTS query; synonyms fill the remaining room (#26) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/beam/recall.ts | 5 ++++- engine/test/fts-query-terms.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 engine/test/fts-query-terms.spec.ts diff --git a/engine/src/core/beam/recall.ts b/engine/src/core/beam/recall.ts index 35fa9d0..baac058 100644 --- a/engine/src/core/beam/recall.ts +++ b/engine/src/core/beam/recall.ts @@ -483,7 +483,10 @@ function ftsPhrase(token: string): string { } function ftsQuery(query: string, useSynonyms = true): string { - const tokens = expandedTokens(query, useSynonyms).slice(0, 12); + // Every distinct query word first, then synonyms fill whatever room is left under the cap (#26). + const words = [...new Set(tokenize(query))]; + const synonyms = expandedTokens(query, useSynonyms).filter(token => !words.includes(token)); + const tokens = [...words, ...synonyms.slice(0, Math.max(0, 12 - words.length))]; if (tokens.length === 0) return ftsPhrase(query.trim()); return tokens.map(ftsPhrase).join(" OR "); } diff --git a/engine/test/fts-query-terms.spec.ts b/engine/test/fts-query-terms.spec.ts new file mode 100644 index 0000000..6a9e3f0 --- /dev/null +++ b/engine/test/fts-query-terms.spec.ts @@ -0,0 +1,23 @@ +// Issue #26: the FTS query keeps every distinct query word; synonyms only fill +// the room left under the 12-term cap. Real temporary store, real engine process. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mcp, remember, tempStore } from "./helpers"; + +process.env.MNEMOPI_NO_EMBEDDINGS = "1"; + +let store: ReturnType; +beforeEach(() => { + store = tempStore(); +}); +afterEach(() => store.dispose()); + +const ids = (body: Record) => ((body.results ?? []) as { id: string }[]).map(r => r.id); + +test("a later query word is still matched when earlier words have many synonyms", async () => { + // "error" and "fix" expand to 8 + 7 terms, which used to push "heron" past the 12-term cap. + // The "error" memory gives the FTS query a hit, so the recall does not fall back to scanning every row. + await remember(store.dir, "o1", "an error showed up in the nightly build"); + const id = await remember(store.dir, "o1", "the heron nests by the quiet lake"); + const [reply] = await mcp(store.dir, "o1", [["mnemopi_recall", { query: "error fix heron" }]]); + expect(ids(reply!.body)).toContain(id); +}); From 8194264c7f59856c7bae837a2f7600fdc44eba7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:20:17 +0000 Subject: [PATCH 11/25] fix(engine): expand words in several synonym groups to every group (#12) getSynonyms now returns the canonicals and synonyms of every group a word belongs to, deduped in group order; normalizeQuery keeps a single canonical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/synonyms.ts | 12 +++++++++--- engine/test/synonyms.spec.ts | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 engine/test/synonyms.spec.ts diff --git a/engine/src/core/synonyms.ts b/engine/src/core/synonyms.ts index 5780065..9afdf97 100644 --- a/engine/src/core/synonyms.ts +++ b/engine/src/core/synonyms.ts @@ -191,7 +191,13 @@ export function expandQuery(query: string): string { } export function getSynonyms(word: string): string[] { const lowered = word.toLowerCase(); - const canonical = WORD_TO_CANONICAL.get(lowered); - if (canonical === undefined) return [lowered]; - return [canonical, ...SYNONYM_GROUPS[canonical]]; + const words = new Set(); + for (const canonical in SYNONYM_GROUPS) { + const group: readonly string[] = SYNONYM_GROUPS[canonical as Canonical]; + if (canonical !== lowered && !group.includes(lowered)) continue; + words.add(canonical); + for (const synonym of group) words.add(synonym); + } + if (words.size === 0) return [lowered]; + return [...words]; } diff --git a/engine/test/synonyms.spec.ts b/engine/test/synonyms.spec.ts new file mode 100644 index 0000000..f7ed7f0 --- /dev/null +++ b/engine/test/synonyms.spec.ts @@ -0,0 +1,38 @@ +// Issue #12: a word listed in several synonym groups expands to every group it belongs to, not only the last. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { getSynonyms, normalizeQuery } from "../src/core/synonyms"; +import { mcp, remember, tempStore } from "./helpers"; + +process.env.MNEMOPI_NO_EMBEDDINGS = "1"; + +let store: ReturnType; +beforeEach(() => { + store = tempStore(); +}); +afterEach(() => store.dispose()); + +test("getSynonyms('token') includes the password group and the key group, deduped, in group order", () => { + const words = getSynonyms("token"); + expect(words).toContain("password"); + expect(words).toContain("pwd"); + expect(words).toContain("key"); + expect(words).toContain("api_key"); + expect(new Set(words).size).toBe(words.length); + expect(words.indexOf("password")).toBeLessThan(words.indexOf("key")); + expect(getSynonyms("push")).toEqual(expect.arrayContaining(["deploy", "rollout", "git", "commit"])); +}); + +test("a single-group word and an unknown word keep their expansion", () => { + expect(getSynonyms("DB")).toEqual(["database", "db", "datastore", "data_store"]); + expect(getSynonyms("heron")).toEqual(["heron"]); +}); + +test("normalizeQuery still maps to one canonical", () => { + expect(normalizeQuery("token")).toBe("key"); +}); + +test("a recall for 'token' finds a memory that uses only a password-group word", async () => { + const id = await remember(store.dir, "o1", "the staging pwd lives in the vault"); + const [reply] = await mcp(store.dir, "o1", [["mnemopi_recall", { query: "token", update_recall_counts: false }]]); + expect((reply!.body.results as { id: string }[]).map(r => r.id)).toContain(id); +}); From b3a159cac49fc67f1ee7e24bebaf057dd427a98a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:52:55 +0000 Subject: [PATCH 12/25] fix(engine): every query word and every synonym group fits inside the recall token cap (#12) A word in several synonym groups (push: deploy + git) now expands to 12+ tokens, so a flat expansion let it crowd later query words and its own second meaning out of the 12-term FTS cap and the 6-term fact fallback. expandedTokens now lists the literal query words first and then takes one variant from each synonym group of each word in turn (new getSynonymGroups in synonyms.ts). The token set is unchanged; only the order differs. The FTS cap stays 12, as decided for #26. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSpuvDs1ukDqG5dKSZu --- engine/src/core/beam/recall.ts | 20 ++++++++++++++++---- engine/src/core/synonyms.ts | 16 ++++++++++------ engine/test/synonyms.spec.ts | 9 +++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/engine/src/core/beam/recall.ts b/engine/src/core/beam/recall.ts index baac058..f98ba85 100644 --- a/engine/src/core/beam/recall.ts +++ b/engine/src/core/beam/recall.ts @@ -19,7 +19,7 @@ import { type OrchestratedRecallResult, orchestrateRecall } from "../orchestrato import { POLYPHONIC_MAX_COMBINED_SCORE } from "../polyphonic-recall"; import { isQueryCacheEnabled, QueryCache } from "../query-cache"; import { adjustWeights, classifyIntent, type QueryIntentCategory } from "../query-intent"; -import { getSynonyms, STOP_WORDS as QUERY_STOP_WORDS } from "../synonyms"; +import { getSynonymGroups, getSynonyms, STOP_WORDS as QUERY_STOP_WORDS } from "../synonyms"; import { extractTemporal } from "../temporal-parser"; import { cosineSimilarity } from "../vector-math"; import type { BeamMemoryState, RecallEnhancedOptions, RecallOptions, RecallResult } from "./types"; @@ -308,13 +308,25 @@ function recallSynonyms(token: string, useSynonyms: boolean): string[] { } } +// Literal query words first, then one variant from each synonym group of each word in turn, so a capped +// prefix (ftsQuery, the fact fallback) reaches every query word and every meaning of a word that sits in +// several synonym groups (issues #12, #26). The token set is the same as a flat expansion; only the order differs. function expandedTokens(query: string, useSynonyms = true): string[] { - const seen = new Set(); + const seen = new Set(tokenize(query)); + const lanes: string[][] = []; for (const token of tokenize(query)) { - for (const variant of recallSynonyms(token, useSynonyms)) { - for (const part of tokenize(variant)) seen.add(part); + const meanings = useSynonyms ? getSynonymGroups(token) : []; + const grouped = new Set(meanings.flat()); + const rest = recallSynonyms(token, useSynonyms).filter(variant => !grouped.has(variant)); + for (const lane of [...meanings, rest]) { + const parts = lane.flatMap(variant => tokenize(variant)); + if (parts.length > 0) lanes.push(parts); } } + const longest = Math.max(0, ...lanes.map(lane => lane.length)); + for (let i = 0; i < longest; i++) { + for (const lane of lanes) if (i < lane.length) seen.add(lane[i] as string); + } return [...seen]; } diff --git a/engine/src/core/synonyms.ts b/engine/src/core/synonyms.ts index 9afdf97..8612d18 100644 --- a/engine/src/core/synonyms.ts +++ b/engine/src/core/synonyms.ts @@ -189,15 +189,19 @@ export function expandQuery(query: string): string { } return expandedParts.join(" "); } -export function getSynonyms(word: string): string[] { +// One list per synonym group the word belongs to ([canonical, ...synonyms]), in group order (issue #12). +export function getSynonymGroups(word: string): string[][] { const lowered = word.toLowerCase(); - const words = new Set(); + const groups: string[][] = []; for (const canonical in SYNONYM_GROUPS) { const group: readonly string[] = SYNONYM_GROUPS[canonical as Canonical]; - if (canonical !== lowered && !group.includes(lowered)) continue; - words.add(canonical); - for (const synonym of group) words.add(synonym); + if (canonical === lowered || group.includes(lowered)) groups.push([canonical, ...group]); } - if (words.size === 0) return [lowered]; + return groups; +} + +export function getSynonyms(word: string): string[] { + const words = new Set(getSynonymGroups(word).flat()); + if (words.size === 0) return [word.toLowerCase()]; return [...words]; } diff --git a/engine/test/synonyms.spec.ts b/engine/test/synonyms.spec.ts index f7ed7f0..5269cf2 100644 --- a/engine/test/synonyms.spec.ts +++ b/engine/test/synonyms.spec.ts @@ -36,3 +36,12 @@ test("a recall for 'token' finds a memory that uses only a password-group word", const [reply] = await mcp(store.dir, "o1", [["mnemopi_recall", { query: "token", update_recall_counts: false }]]); expect((reply!.body.results as { id: string }[]).map(r => r.id)).toContain(id); }); + +test("a two-word recall whose first word is in several groups still searches the second word", async () => { + const git = await remember(store.dir, "o1", "the git branch naming rules"); + const setup = await remember(store.dir, "o1", "the setup guide for the cluster"); + const [reply] = await mcp(store.dir, "o1", [["mnemopi_recall", { query: "push setup", update_recall_counts: false }]]); + const ids = (reply!.body.results as { id: string }[]).map(r => r.id); + expect(ids).toContain(git); + expect(ids).toContain(setup); +}); From ed2c4a285440127aa0dca96b6fe4aae6140205cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:54:53 +0000 Subject: [PATCH 13/25] Session markers: a notBefore past the longest back-off is not trusted; comments name the back-off (#38) Follow-up to the review of the #38 fix: a marker whose notBefore is more than 24 hours ahead (a clock that jumped back after a failed attempt, or a hand edit) was hidden from listSessionPending for as long as it said, so it was never retried or dropped. Such a wait is now treated as due. The finishSessionSummaries comment and the bin/memories.ts usage line describe the back-off and release --failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSnY9UQPcd6a4DCMpXD --- bin/memories.ts | 10 ++++++---- hooks/register.ts | 3 ++- hooks/session.ts | 4 +++- tests/bun/session.spec.ts | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/bin/memories.ts b/bin/memories.ts index f73a6dc..6cab86e 100644 --- a/bin/memories.ts +++ b/bin/memories.ts @@ -8,7 +8,7 @@ // and, for the session work (plan phase SESSION): // bun bin/memories.ts --store first-prompt [] the newest session summary and the pinned memories, as JSON (read-only) // bun bin/memories.ts --store summarised ... which of those sessions already have a summary, as JSON (read-only) -// bun bin/memories.ts session-pending write|list|claim|release|remove ... the markers session.end leaves (an id, an owner and a path; never text); claim takes one for a summary, release puts it back +// bun bin/memories.ts session-pending write|list|claim|release|remove ... the markers session.end leaves (an id, an owner and a path; never text); claim takes one for a summary, release puts it back (release --failed counts a failed model call: back-off, dropped at the fifth) // bun bin/memories.ts sweep-claim whether the daily unused-expiry sweep is due; if so, marks it taken // bun bin/memories.ts --store expire [--apply] [--days N] [--owner O] [--root R] [--limit N] [--json] // the unused-expiry sweep: a dry run unless --apply, which backs the store up first @@ -30,7 +30,7 @@ import { basename, dirname, join, resolve } from 'node:path' import { BACKFILL_TEXT_LIMIT, isProposalId } from '../hooks/backfill' import { isKind } from '../hooks/kinds' import { buildRequests, engineEnv, parseReplies } from '../hooks/engine' -import { BLOCK_PINNED_MAX, EXPIRE_DEFAULT_DAYS, expireRequest, expireSetting, parseExpireReply, parsePending, SESSION_CLAIM_STALE_MS, SESSION_CLAIM_SUFFIX, SESSION_ID, SESSION_MAX_ATTEMPTS, SESSION_PENDING_DIR, sessionBackoffMs, SWEEP_LIMIT, SWEEP_LOCK, SWEEP_LOCK_STALE_MS, SWEEP_MARKER, sweepDue, sweepMarkerText } from '../hooks/session' +import { BLOCK_PINNED_MAX, EXPIRE_DEFAULT_DAYS, expireRequest, expireSetting, parseExpireReply, parsePending, SESSION_CLAIM_STALE_MS, SESSION_CLAIM_SUFFIX, SESSION_BACKOFF_MAX_MS, SESSION_ID, SESSION_MAX_ATTEMPTS, SESSION_PENDING_DIR, sessionBackoffMs, SWEEP_LIMIT, SWEEP_LOCK, SWEEP_LOCK_STALE_MS, SWEEP_MARKER, sweepDue, sweepMarkerText } from '../hooks/session' import type { BlockRead, ExpiredRow, PendingSession } from '../hooks/session' import { cleanTags, MAX_TAGS, normalizeTag } from '../hooks/tags' import { readConfig } from './settings' @@ -536,8 +536,10 @@ export function listSessionPending(root: string, now: number = Date.now()): Pend for (const { name } of files) { try { const entry = parsePending(readFileSync(join(folder, name), 'utf8')) - // Issue #38: a marker still waiting after a failed attempt is not listed until its time comes. - if (entry !== undefined && `${entry.sessionId}.json` === name && !(entry.notBefore !== undefined && entry.notBefore > now)) out.push(entry) + // Issue #38: a marker still waiting after a failed attempt is not listed until its time comes. A wait longer than the + // longest back-off (a clock that jumped back, a hand edit) is not trusted, so such a marker is never hidden for good. + const waiting = entry?.notBefore !== undefined && entry.notBefore > now && entry.notBefore - now <= SESSION_BACKOFF_MAX_MS + if (entry !== undefined && `${entry.sessionId}.json` === name && !waiting) out.push(entry) } catch { // unreadable: not an entry } diff --git a/hooks/register.ts b/hooks/register.ts index 2e16e1a..3ae4b06 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -1747,7 +1747,8 @@ async function firstPromptBlock($: EngineInterface, options: PluginOptions, sett * 3 a run, never the running session): a session already summarised, with no transcript or with too little said is dropped; * otherwise one Haiku call (the extractionModel) turns the redacted conversation into a note of at most 600 characters, which * goes through admit() and is saved as kind 'event', source 'import:session', expiring in 30 days, with metadata.session. A lost - * call or save leaves the marker for the next first prompt. Memory text is never logged. Never throws. + * save leaves the marker for the next first prompt; a failed or unanswered model call puts it back to wait 1, 2, 4 then 8 hours + * (issue #38), and the fifth failure drops it with one activity line. Memory text is never logged. Never throws. */ async function finishSessionSummaries($: EngineInterface, options: PluginOptions) { const mine = runningOf(options) diff --git a/hooks/session.ts b/hooks/session.ts index 307f233..0863c38 100644 --- a/hooks/session.ts +++ b/hooks/session.ts @@ -128,8 +128,10 @@ export type PendingSession = { sessionId: string; owner: string; transcript: str /** Issue #38: the failed summary attempt that drops a marker for good. */ export const SESSION_MAX_ATTEMPTS = 5 +/** Issue #38: the longest wait after a failed attempt; a marker that says it waits longer is not trusted and is due now. */ +export const SESSION_BACKOFF_MAX_MS = 86_400_000 /** Issue #38: the wait after a marker's nth failed attempt: 1 hour, doubling per attempt, at most 24 hours. */ -export const sessionBackoffMs = (attempts: number) => Math.min(3_600_000 * 2 ** Math.max(0, attempts - 1), 86_400_000) +export const sessionBackoffMs = (attempts: number) => Math.min(3_600_000 * 2 ** Math.max(0, attempts - 1), SESSION_BACKOFF_MAX_MS) /** Claude Code's folder name for a project: the working folder with every character outside A-Z, a-z, 0-9 turned into '-'. */ export const projectSlug = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-') diff --git a/tests/bun/session.spec.ts b/tests/bun/session.spec.ts index da30edc..a9332f0 100644 --- a/tests/bun/session.spec.ts +++ b/tests/bun/session.spec.ts @@ -566,6 +566,21 @@ describe('SESSION.1: session-pending markers', () => { expect(JSON.parse(ok(await memories(['session-pending', 'release', host.root, 'one', '--failed'])).stdout)).toEqual({ attempts: 0, gaveUp: false }) expect((await memories(['session-pending', 'release', host.root, 'one', '--other'])).exitCode).not.toBe(0) }) + + test('issue #38: a notBefore more than 24 hours ahead (a clock that jumped back, a hand edit) does not hide the marker', async () => { + const { listSessionPending } = await import('../../bin/memories') + const root = mkdtempSync(join(tmpdir(), 'mnemo-pending-')) + try { + const folder = join(root, 'session-pending') + mkdirSync(folder, { recursive: true }) + const now = Date.now() + writeFileSync(join(folder, 'far.json'), JSON.stringify({ ...entry('far'), attempts: 2, notBefore: now + 365 * DAY })) + writeFileSync(join(folder, 'near.json'), JSON.stringify({ ...entry('near'), attempts: 4, notBefore: now + DAY - 60_000 })) + expect(listSessionPending(root, now).map(item => item.sessionId)).toEqual(['far']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) describe('SESSION.1: a session-pending marker that vanishes before it is stat-ed is skipped (#36)', () => { From d701544adf19adf6275a1233f26aa0edf628823a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:15:03 +0000 Subject: [PATCH 14/25] Memories list: only the reply to the latest load writes the rows (#20) A filter change that overlapped an earlier /api/memories request could let the older reply land last and overwrite the table with the older filter's rows; pickTagFromSheet always raced (untagged first-open load, then the tagged one). loadMemories now numbers each request and drops stale replies and stale errors silently. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/ui-views.spec.ts | 53 ++++++++++++++++++++++++++++++++++++++ ui/app.ts | 7 ++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/bun/ui-views.spec.ts b/tests/bun/ui-views.spec.ts index 1cb3d8f..e729a51 100644 --- a/tests/bun/ui-views.spec.ts +++ b/tests/bun/ui-views.spec.ts @@ -727,6 +727,8 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { let mode: 'real' | 'fail500' | 'throw' = 'real' const calls: { path: string; method: string; body: string }[] = [] const focused: AppNode[] = [] + // When set, /api/memories requests wait here until the test lets each one through (out of order). + let held: { path: string; release: () => void }[] | null = null class AppText { constructor(public data: string) {} @@ -831,6 +833,7 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { g.document = { createElement: (tag: string) => new AppNode(tag.toLowerCase()), createTextNode: (data: string) => new AppText(data), getElementById: (id: string) => page.get(id) ?? null } g.fetch = async (path: string, init: RequestInit = {}) => { calls.push({ path, method: init.method ?? 'GET', body: typeof init.body === 'string' ? init.body : '' }) + if (held && path.startsWith('/api/memories?')) await new Promise(release => held!.push({ path, release })) if (mode === 'fail500') return new Response(JSON.stringify({ error: 'boom' }), { status: 500 }) // The server itself still answers /api/health (the page's check on a failed request), so these are tab failures, not a lost server. if (mode === 'throw' && path !== '/api/health') throw new TypeError('network down') @@ -1253,6 +1256,56 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { expect(app.revealNeeded('style', '', true)).toBe(false) expect(app.revealNeeded('', 'accessibility', false)).toBe(false) }) + + describe('issue #20: only the reply to the latest Memories request writes the rows', () => { + const shown = () => (byId('mem-rows').children as AppNode[]).filter(row => row.className.startsWith('row')).map(row => row.dataset.id) + const settle = async () => { + for (let i = 0; i < 20; i++) await Bun.sleep(5) + } + + test('two filters typed in turn, the older reply arriving last: the rows are the newer filter\'s', async () => { + held = [] + try { + byId('mem-filter').value = 'first' + const older = app.loadMemories() + byId('mem-filter').value = 'second' + const newer = app.loadMemories() + await until(() => held!.length === 2, 'both requests') + expect(held[0]!.path).toContain('q=first') + expect(held[1]!.path).toContain('q=second') + held[1]!.release() + await newer + expect(shown()).toEqual([ids[1]]) + held[0]!.release() + await older + expect(shown()).toEqual([ids[1]]) + } finally { + held = null + byId('mem-filter').value = '' + } + }) + + test('a tag picked in a memory panel from another tab: the first open of Memories shows the tagged rows, not the untagged load', async () => { + const made = body((await engineCall(CLI, store, 'project-glue-1a2b', [toolCall(1, 'mnemopi_remember', { content: 'tagged for the race test', metadata: { tags: ['race-tag'] } })])).get(1)) as { memory_id: string } + app.show('search') + held = [] + try { + app.pickTagFromSheet('race-tag') + await until(() => held!.length === 2, 'both requests') + expect(held[0]!.path).not.toContain('tag=') + expect(held[1]!.path).toContain('tag=race-tag') + held[1]!.release() + await settle() + expect(shown()).toEqual([made.memory_id]) + held[0]!.release() + await settle() + expect(shown()).toEqual([made.memory_id]) + expect(byId('mem-tag').textContent).toBe('tag: race-tag ×') + } finally { + held = null + } + }) + }) }) // QAPAGE R1: the page itself, without a browser: the served markup read as a tree (Bun's HTMLRewriter), diff --git a/ui/app.ts b/ui/app.ts index 05b4464..ba29e5d 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -2520,6 +2520,8 @@ const memories = { reach: '' as '' | Reach, kind: '', facets: null as Facets | null, + // Counts loads, so only the reply to the latest one writes the rows (an older reply can answer last). + request: 0, } // The state view is a per-viewer setting: this browser's own storage, and the page works without it. @@ -2565,9 +2567,11 @@ export async function loadMemories() { if (memories.tag) params.set('tag', memories.tag) if (memories.reach) params.set('reach', memories.reach) if (memories.kind) params.set('kind', memories.kind) + const request = ++memories.request try { const response = await api(`/api/memories?${params}`) const body = await readJson<{ total?: number; count?: number; memories?: Memory[]; facets?: Facets; error?: string }>(response) + if (request !== memories.request) return if (!response.ok) throw new Error(body.error ?? `the server answered ${response.status}`) memories.rows = body.memories ?? [] memories.total = body.total ?? memories.rows.length @@ -2576,6 +2580,7 @@ export async function loadMemories() { drawFacets() drawRows() } catch (err) { + if (request !== memories.request) return if (!(err instanceof Expired)) setStatus(`Could not load memories: ${failure(err)}`, 'error') } } @@ -2834,7 +2839,7 @@ function openForm(memory: Memory | undefined, sheet: { title: string; returnFocu export const forgetMemory = (memory: Memory, done: (error?: string) => void) => forgetById(memory.id, done) /** A tag in the sheet: the Memories list filtered by it, from whichever tab the sheet is on. */ -function pickTagFromSheet(tag: string) { +export function pickTagFromSheet(tag: string) { if (current !== 'memories') show('memories') setTagFilter(tag) } From 7d595c4e12ae845feff7eb6f905d78861fff95fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:15:55 +0000 Subject: [PATCH 15/25] Run a search typed after Escape while a request is in flight (#22) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/map-state.spec.ts | 35 +++++++++++++++++++++++++++++++++++ ui/map-state.ts | 3 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/bun/map-state.spec.ts b/tests/bun/map-state.spec.ts index cf40888..d1caf81 100644 --- a/tests/bun/map-state.spec.ts +++ b/tests/bun/map-state.spec.ts @@ -3795,4 +3795,39 @@ describe('the solar page state (MAPX.11)', () => { await Bun.sleep(5) expect(done.some(([, hits]) => JSON.stringify(hits).includes('late'))).toBe(false) }) + + test('#22: a search typed after Escape while a request is in flight still runs, once, and the cancelled answer is never shown', async () => { + const sent: string[] = [] + const answers: ((response: Response) => void)[] = [] + const done: [string, unknown][] = [] + const search = createMemorySearch({ + post: (_path, body) => { + sent.push((body as { query: string }).query) + return new Promise(resolve => answers.push(resolve)) + }, + mode: () => 'keyword', + done: (query, hits) => void done.push([query, hits]), + }) + search.enter('aaa') + search.cancel() + search.enter('bbb') + answers[0]!(new Response('{"results":[{"id":"a-hit"}]}', { status: 200 })) + await Bun.sleep(5) + expect(sent).toEqual(['aaa', 'bbb']) + answers[1]!(new Response('{"results":[{"id":"b-hit"}]}', { status: 200 })) + await Bun.sleep(5) + expect(sent).toEqual(['aaa', 'bbb']) + expect(done).toEqual([['bbb', [{ id: 'b-hit' }]]]) + // The same words again after Escape: the cancelled answer is dropped, and the search runs again. + search.enter('ccc') + search.cancel() + search.enter('ccc') + answers[2]!(new Response('{"results":[{"id":"c-old"}]}', { status: 200 })) + await Bun.sleep(5) + expect(sent).toEqual(['aaa', 'bbb', 'ccc', 'ccc']) + answers[3]!(new Response('{"results":[{"id":"c-new"}]}', { status: 200 })) + await Bun.sleep(5) + expect(done.at(-1)).toEqual(['ccc', [{ id: 'c-new' }]]) + expect(done.some(([, hits]) => JSON.stringify(hits).includes('c-old') || JSON.stringify(hits).includes('a-hit'))).toBe(false) + }) }) diff --git a/ui/map-state.ts b/ui/map-state.ts index f572b75..fdd94ee 100644 --- a/ui/map-state.ts +++ b/ui/map-state.ts @@ -1999,7 +1999,8 @@ export function createMemorySearch(deps: MemorySearchDeps) { if (mine === generation && query === latest) deps.done(query, hits) const next = queued queued = null - if (next !== null && next !== query && next === latest && mine === generation) await run(next) + // Escape clears queued, so a query queued now was asked after it: run it (the same words too, since the old answer was dropped). + if (next !== null && next === latest && (next !== query || mine !== generation)) await run(next) } return { /** A keystroke: starts the pause; under 3 characters it clears the memory results. */ From 6d8f220c4f038b3b60f8ad4205dedb4f08c0e9be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:20:36 +0000 Subject: [PATCH 16/25] Keep the knowledge note's Clear button across re-renders (#29) renderKnowledgeControls rebuilt the note on every render, replacing the Clear button and dropping keyboard focus on each live event. Rebuild only when the note's text or Clear presence changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/map-state.spec.ts | 6 ++++++ ui/map.ts | 12 ++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/bun/map-state.spec.ts b/tests/bun/map-state.spec.ts index d1caf81..1cc30de 100644 --- a/tests/bun/map-state.spec.ts +++ b/tests/bun/map-state.spec.ts @@ -2049,6 +2049,12 @@ describe('MOON: moons on a clicked star and the knowledge filter', () => { // QAMAP R2: the note carries its own Clear button, made by the map; it clears the chips. const clearButton = () => node('map-know-note').all().find(n => n.tagName === 'button' && n.textContent === 'Clear') expect(clearButton()).toBeDefined() + // #29: a re-render with the filter still on keeps the same Clear node, so keyboard focus on it survives live events. + const firstClear = clearButton() + node('map-spokes').fire('change') + node('map-spokes').fire('change') + expect(clearButton()).toBe(firstClear) + expect(node('map-know-note').textContent).toBe('Showing only memories with the chosen knowledge; the rest are dimmed. Clear') clearButton()!.fire('click') expect(chips.map(chip => chip.attributes.get('aria-pressed'))).toEqual(['false', 'false', 'false', 'false', 'false']) expect(node('map-know-note').textContent).toBe('Pick a kind to light up only the memories it was mined from.') diff --git a/ui/map.ts b/ui/map.ts index b7d07fd..a30dc3f 100644 --- a/ui/map.ts +++ b/ui/map.ts @@ -1265,6 +1265,8 @@ export function createMap(host: MapHost): MapView { let state: MapState = initialState({ reducedMotion, layout: initialLayout(readLayout(storage), true) }) let galaxy: Galaxy | null = null let found: Found | null = null + /** What the knowledge note last showed, so it is rebuilt (and its Clear replaced) only when that changes. */ + let knowNoteKey = '' let graph: ForceGraph3DInstance | null = null let lib: MapLib | null = null let three: Three | null = null @@ -1811,9 +1813,15 @@ export function createMap(host: MapHost): MapView { if (number) number.textContent = String(counts[kind]) } const note = byId('map-know-note') - note.textContent = any ? knowledgeNote(state) : 'No knowledge extracted yet. Run /mnemosyne-knowledge, then reload.' + const text = any ? knowledgeNote(state) : 'No knowledge extracted yet. Run /mnemosyne-knowledge, then reload.' + const withClear = any && state.kinds.size > 0 + // Rebuild only when the note changes, so a focused Clear keeps its node (and focus) across live events. + const key = `${withClear ? 1 : 0}${text}` + if (key === knowNoteKey) return + knowNoteKey = key + note.textContent = text // A chip dims the map: the note carries its own Clear (ui/index.html has none, so it is made here). - if (any && state.kinds.size > 0) { + if (withClear) { const clear = el('button', 'Clear', 'btn map-know-clear') clear.type = 'button' clear.addEventListener('click', () => dispatch(clearKinds(state))) From 4b3e5640c90f26e6f7a4da9b22a558946373c8ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:21:15 +0000 Subject: [PATCH 17/25] Show the error note when a review-queue reload fails over an earlier queue (#30) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/ui-views.spec.ts | 27 ++++++++++++++++++++++++++- ui/app.ts | 9 ++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/bun/ui-views.spec.ts b/tests/bun/ui-views.spec.ts index e729a51..b34a074 100644 --- a/tests/bun/ui-views.spec.ts +++ b/tests/bun/ui-views.spec.ts @@ -724,7 +724,7 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { let app: Record let saved: unknown const realFetch = globalThis.fetch - let mode: 'real' | 'fail500' | 'throw' = 'real' + let mode: 'real' | 'fail500' | 'throw' | 'failQueue' = 'real' const calls: { path: string; method: string; body: string }[] = [] const focused: AppNode[] = [] // When set, /api/memories requests wait here until the test lets each one through (out of order). @@ -835,6 +835,7 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { calls.push({ path, method: init.method ?? 'GET', body: typeof init.body === 'string' ? init.body : '' }) if (held && path.startsWith('/api/memories?')) await new Promise(release => held!.push({ path, release })) if (mode === 'fail500') return new Response(JSON.stringify({ error: 'boom' }), { status: 500 }) + if (mode === 'failQueue' && path === '/api/review') return new Response(JSON.stringify({ error: 'boom' }), { status: 500 }) // The server itself still answers /api/health (the page's check on a failed request), so these are tab failures, not a lost server. if (mode === 'throw' && path !== '/api/health') throw new TypeError('network down') const headers = { ...(init.headers as Record), Authorization: `Bearer ${server.token}`, Origin: `http://127.0.0.1:${server.port}` } @@ -1041,6 +1042,30 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { expect(await state()).toBe('retired') }) + test('#30: a failed queue reload keeps the last lists but says the refresh failed; the next good load clears the note', async () => { + const pick = (name: string) => (byId('review-filters').children as AppNode[]).find(button => button.dataset.filter === name)!.fire('click') + mode = 'real' + await app.loadReview() + pick('notlive') + const good = byId('review-text').textContent + expect(good).toContain('not live') + expect(byId('review-notlive-rows').children.length).toBeGreaterThan(0) + mode = 'failQueue' + await app.loadReview() + mode = 'real' + pick('notlive') + // The old rows stay, and the text says they are the last loaded ones. + expect(byId('review-notlive-rows').children.length).toBeGreaterThan(0) + expect(byId('review-text').textContent).toContain('Refresh failed: the server answered 500') + expect(byId('review-text').textContent).toContain('showing the last loaded queue') + pick('retire') + expect(byId('review-text').textContent).toContain('Refresh failed: the server answered 500') + await app.loadReview() + pick('notlive') + expect(byId('review-text').textContent).toBe(good) + pick('never') + }) + // ---- QAPAGE R1: the glue items, on the same stand-in document ---- /** A memory of project-glue-1a2b with one fact and one instruction mined from it (real engine tools); its id. */ diff --git a/ui/app.ts b/ui/app.ts index ba29e5d..e8bf57f 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -1898,6 +1898,9 @@ function groupedReviewRows(list: readon /** The sentence under the filters when the review queue is not there (yet): still loading, or why it failed. */ const queueMissing = () => (reviewView.queueError !== '' ? reviewView.queueError : 'Loading the review queue…') +/** The queue sentence, with the failed refresh's note after it when the lists shown are the last loaded ones. */ +const queueStale = (text: string) => (reviewView.queueError === '' ? text : `${text} ${reviewView.queueError}`) + /** Takes a row out of the live lists after the server retired it: the candidates (and the near-duplicate reasons that named it), and it joins the Not live list. */ function applyRetired(id: string) { const now = new Date().toISOString() @@ -2038,13 +2041,13 @@ export function drawReview() { let rows: HTMLElement[] = [] let shown = 0 if (filter === 'retire') { - byId('review-text').textContent = queue === null ? queueMissing() : queueText('retire', candidates.length, queue.candidateTotal) + byId('review-text').textContent = queue === null ? queueMissing() : queueStale(queueText('retire', candidates.length, queue.candidateTotal)) rows = groupedReviewRows(candidates, 4, (candidate, index) => candidateRow(candidate, reviewHost, again(index))) shown = candidates.length byId('review-candidate-rows').replaceChildren(...rows) } else if (filter === 'notlive') { const list = queue?.notLive ?? [] - byId('review-text').textContent = queue === null ? queueMissing() : queueText('notlive', list.length, queue.notLiveTotal) + byId('review-text').textContent = queue === null ? queueMissing() : queueStale(queueText('notlive', list.length, queue.notLiveTotal)) rows = list.map((row, index) => notLiveRow(row, reviewHost, again(index))) shown = list.length byId('review-notlive-rows').replaceChildren(...rows) @@ -2127,7 +2130,7 @@ async function loadQueue() { reviewView.queueError = '' } catch (err) { if (err instanceof Expired) return - reviewView.queueError = `Could not load the review queue: ${failure(err)}` + reviewView.queueError = reviewView.queue === null ? `Could not load the review queue: ${failure(err)}` : `Refresh failed: ${failure(err)}; showing the last loaded queue.` } } From d9dbcf1a161b7c6c57cfd4f5703c67884f174cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:24:48 +0000 Subject: [PATCH 18/25] Keep the shown search hits after a forget instead of collapsing to 10 (#31) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/ui-views.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++ ui/app.ts | 10 +++++---- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/bun/ui-views.spec.ts b/tests/bun/ui-views.spec.ts index b34a074..70976ea 100644 --- a/tests/bun/ui-views.spec.ts +++ b/tests/bun/ui-views.spec.ts @@ -770,6 +770,15 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { this.children = [] this.append(...nodes) } + get lastChild() { + return this.children.at(-1) ?? null + } + insertBefore(node: AppNode | AppText, before: AppNode | AppText | null) { + const at = before === null ? -1 : this.children.indexOf(before) + if (at === -1) this.children.push(node) + else this.children.splice(at, 0, node) + return node + } setAttribute(name: string, value: string) { this.attributes.set(name, String(value)) } @@ -1331,6 +1340,39 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { } }) }) + + test('#31: forgetting a search hit after Show more keeps the shown count (minus that hit) and rewrites the count note', async () => { + const hits = Array.from({ length: 25 }, (_, i) => ({ id: `mem_search_${i}`, owner: 'project-glue-1a2b', kind: 'note', content: `zebra note ${i}`, score: 0.9 - i * 0.001, matched: 'keyword' })) + const g = globalThis as Record + const doc = g.document + const fetchBefore = g.fetch + doc.addEventListener = () => undefined + g.fetch = async (path: string, init: RequestInit = {}) => (path === '/api/search' ? Response.json({ results: hits, mode: 'keyword' }) : fetchBefore(path, init)) + try { + app.wireSearch() + byId('search-query').value = 'zebra' + for (const submit of byId('search-form').listeners.get('submit') ?? []) submit({ preventDefault() {} }) + const cards = () => (byId('search-results').children as AppNode[]).filter(node => node.tagName === 'li' && node.className.split(' ').includes('sh')) + await until(() => cards().length === 10, 'the first page of hits') + expect(byId('search-note').textContent).toContain('25 results') + byId('search-more').fire('click') + expect(cards()).toHaveLength(20) + let result: string | undefined = 'pending' + app.forgetById('mem_search_3', (error?: string) => { + result = error + }) + await until(() => result !== 'pending', 'the search forget') + expect(result).toBeUndefined() + expect(cards()).toHaveLength(19) + expect(cards().map(card => card.dataset.id)).not.toContain('mem_search_3') + expect(byId('search-more').hidden).toBe(false) + expect(byId('search-note').textContent).toContain('24 results') + expect(byId('search-note').textContent).toContain('showing 19') + } finally { + g.fetch = fetchBefore + delete doc.addEventListener + } + }) }) // QAPAGE R1: the page itself, without a browser: the served markup read as a tree (Bun's HTMLRewriter), diff --git a/ui/app.ts b/ui/app.ts index e8bf57f..fabe523 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -1574,9 +1574,11 @@ export function forgetById(id: string, done: (error?: string) => void) { if (opened.has('knowledge')) void loadKnowledge() // Search: the forgotten hit leaves the list. if (searchHits.some(hit => hit.id === id)) { + // #31: the rows already shown stay shown (less the forgotten one); Show more is not undone. + const keep = searchShown - (visibleHits().slice(0, searchShown).some(hit => hit.id === id) ? 1 : 0) searchHits = searchHits.filter(hit => hit.id !== id) drawSearchAside() - drawHits(false) + drawHits(false, keep) } done() } catch (err) { @@ -3834,14 +3836,14 @@ function weakCut() { } // The first SHOW_FIRST hits, then SHOW_FIRST more for each press of Show more. -function drawHits(more: boolean) { +function drawHits(more: boolean, keep = 0) { const results = byId('search-results') // A hit opens on this tab: beside the list on a wide screen, else in the bottom sheet. const open = (id: string, from?: HTMLElement) => void openMemory(id, from) const list = visibleHits() const rank = new Map(searchHits.map((hit, i) => [hit.id, i + 1])) const from = more ? searchShown : 0 - searchShown = Math.min(list.length, more ? searchShown + SHOW_FIRST : SHOW_FIRST) + searchShown = Math.min(list.length, more ? searchShown + SHOW_FIRST : Math.max(SHOW_FIRST, keep)) const rows: HTMLElement[] = [] list.slice(from, searchShown).forEach((hit, i) => { const at = from + i @@ -4058,7 +4060,7 @@ export function searchKey(event: KeyboardEvent) { } } -function wireSearch() { +export function wireSearch() { const form = byId('search-form') form.addEventListener('submit', event => { event.preventDefault() From fef79194a89c1c27adce53371d2567f7f483e7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:43:57 +0000 Subject: [PATCH 19/25] Keep the shown count after a forget on the first page too, without backfilling to 10 (#31) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/ui-views.spec.ts | 31 +++++++++++++++++++++++++++++++ ui/app.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/bun/ui-views.spec.ts b/tests/bun/ui-views.spec.ts index 70976ea..f2f6071 100644 --- a/tests/bun/ui-views.spec.ts +++ b/tests/bun/ui-views.spec.ts @@ -1373,6 +1373,37 @@ describe('VIEWS R2: the Knowledge and Health glue in app.ts', () => { delete doc.addEventListener } }) + + test('#31: forgetting a search hit on the first page keeps 9 shown (no backfill) and rewrites the count note', async () => { + const hits = Array.from({ length: 25 }, (_, i) => ({ id: `mem_first_${i}`, owner: 'project-glue-1a2b', kind: 'note', content: `zebra note ${i}`, score: 0.9 - i * 0.001, matched: 'keyword' })) + const g = globalThis as Record + const doc = g.document + const fetchBefore = g.fetch + doc.addEventListener = () => undefined + g.fetch = async (path: string, init: RequestInit = {}) => (path === '/api/search' ? Response.json({ results: hits, mode: 'keyword' }) : fetchBefore(path, init)) + try { + app.wireSearch() + byId('search-query').value = 'zebra' + for (const submit of byId('search-form').listeners.get('submit') ?? []) submit({ preventDefault() {} }) + const cards = () => (byId('search-results').children as AppNode[]).filter(node => node.tagName === 'li' && node.className.split(' ').includes('sh')) + await until(() => cards().length === 10 && cards()[0].dataset.id === 'mem_first_0', 'the first page of hits') + let result: string | undefined = 'pending' + app.forgetById('mem_first_3', (error?: string) => { + result = error + }) + await until(() => result !== 'pending', 'the search forget') + expect(result).toBeUndefined() + expect(cards()).toHaveLength(9) + expect(cards().map(card => card.dataset.id)).not.toContain('mem_first_3') + expect(cards().map(card => card.dataset.id)).not.toContain('mem_first_10') + expect(byId('search-more').hidden).toBe(false) + expect(byId('search-note').textContent).toContain('24 results') + expect(byId('search-note').textContent).toContain('showing 9') + } finally { + g.fetch = fetchBefore + delete doc.addEventListener + } + }) }) // QAPAGE R1: the page itself, without a browser: the served markup read as a tree (Bun's HTMLRewriter), diff --git a/ui/app.ts b/ui/app.ts index fabe523..a02c3bf 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -3843,7 +3843,7 @@ function drawHits(more: boolean, keep = 0) { const list = visibleHits() const rank = new Map(searchHits.map((hit, i) => [hit.id, i + 1])) const from = more ? searchShown : 0 - searchShown = Math.min(list.length, more ? searchShown + SHOW_FIRST : Math.max(SHOW_FIRST, keep)) + searchShown = Math.min(list.length, more ? searchShown + SHOW_FIRST : keep > 0 ? keep : SHOW_FIRST) const rows: HTMLElement[] = [] list.slice(from, searchShown).forEach((hit, i) => { const at = from + i From b6efa14eac400aad2e41985149f955126ab6314d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:23:54 +0000 Subject: [PATCH 20/25] Live recall sets the star's last-recalled time (#32) recallMemory now sets star.lastRecalled to the live event's timestamp (carried on the spark) or to now, beside the recall count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/map-state.spec.ts | 14 ++++++++++++++ ui/map-state.ts | 7 +++++-- ui/map.ts | 4 ++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/bun/map-state.spec.ts b/tests/bun/map-state.spec.ts index 1cc30de..e71ed6f 100644 --- a/tests/bun/map-state.spec.ts +++ b/tests/bun/map-state.spec.ts @@ -1341,6 +1341,20 @@ describe('LIVE.1: what an event does to the galaxy', () => { expect(touchMemory(g, 'nope')).toBe(false) }) + test('LIVE.1.a3b: a recall sets the star\'s last-recalled time to the event\'s timestamp, else to now (#32)', () => { + const g = freshGalaxy() + const star = g.layout.stars[g.layout.starById.m1!]! + expect(recallMemory(g, 'm1', '2026-10-27T09:30:00.000Z')).toBe(true) + expect(star.lastRecalled).toBe('2026-10-27T09:30:00.000Z') + const before = Date.now() + expect(recallMemory(g, 'm1')).toBe(true) + expect(Date.parse(star.lastRecalled!)).toBeGreaterThanOrEqual(before) + expect(usageText(star.recalls, star.lastRecalled)).toBe('its memory recalled 4 times, last today') + // A spark carries its event's timestamp to the star it lands on. + const [spark] = sparksFor({ kind: 'recall', ids: ['m1'], client: 'x', t: '2026-10-27T10:00:00.000Z' }, [{ to: 'm1', toKind: 'star' }], false, () => 0.5) + expect(spark!.t).toBe('2026-10-27T10:00:00.000Z') + }) + test('LIVE.1.a2: a forget removes the star from every count and every list, and a second forget is nothing', () => { const g = freshGalaxy() expect(forgetMemory(g, 'm2')).toBe(true) diff --git a/ui/map-state.ts b/ui/map-state.ts index fdd94ee..3056375 100644 --- a/ui/map-state.ts +++ b/ui/map-state.ts @@ -1565,10 +1565,11 @@ export function addMemory(galaxy: Galaxy, row: GalaxyMemory): number { } /** A recall raises the star's count (its size and colour follow); false when it is not on the map. */ -export function recallMemory(galaxy: Galaxy, id: string): boolean { +export function recallMemory(galaxy: Galaxy, id: string, at: string = new Date().toISOString()): boolean { const star = galaxy.layout.stars[slotOf(galaxy.layout.starById, id)] if (star === undefined || galaxy.gone.has(id)) return false star.recalls++ + star.lastRecalled = at galaxy.maxRecalls = Math.max(galaxy.maxRecalls, star.recalls) return true } @@ -1704,6 +1705,8 @@ export type Spark = { age: number /** True for a demo event: it plays and changes nothing. */ demo: boolean + /** The event's timestamp, when it has one. */ + t?: string } type SparkPool = { active: (Spark | null)[]; queue: Spark[] } @@ -1712,7 +1715,7 @@ export const newSparkPool = (size = SPARK_POOL): SparkPool => ({ active: Array.f /** The sparks an event sends: one per target, staggered 0.12 s; none under prefers-reduced-motion (the stars still pulse). */ export function sparksFor(event: MapEvent, targets: readonly { to: string; toKind: 'star' | 'hub' }[], reducedMotion: boolean, random: () => number): Spark[] { if (reducedMotion) return [] - return targets.map((target, i) => ({ ...target, kind: event.kind, delay: i * SPARK_STAGGER, curve: random() * 2 - 1, age: 0, demo: event.demo === true })) + return targets.map((target, i) => ({ ...target, kind: event.kind, delay: i * SPARK_STAGGER, curve: random() * 2 - 1, age: 0, demo: event.demo === true, t: event.t })) } /** Adds sparks to the queue; the next step starts them as slots free up (an extra event waits, it is never dropped). */ diff --git a/ui/map.ts b/ui/map.ts index a30dc3f..d8cfe41 100644 --- a/ui/map.ts +++ b/ui/map.ts @@ -2670,7 +2670,7 @@ export function createMap(host: MapHost): MapView { if (star === undefined) return if (spark.kind === 'recall') { const was = star.recalls - if (!recallMemory(g, id)) return + if (!recallMemory(g, id, spark.t || undefined)) return if (v) { v.size = starSize(star.recalls) // A star that was a hollow ring becomes a glow with its first recall. @@ -2740,7 +2740,7 @@ export function createMap(host: MapHost): MapView { renderStrip() const sparks = graph !== null && shown && layerOn(state, event.kind) ? sparksFor(event, targets, state.reducedMotion, Math.random) : [] if (sparks.length === 0) { - for (const target of targets) arrive({ ...target, kind: event.kind, delay: 0, curve: 0, age: 0, demo: event.demo === true }) + for (const target of targets) arrive({ ...target, kind: event.kind, delay: 0, curve: 0, age: 0, demo: event.demo === true, t: event.t }) return } enqueueSparks(sparkPool, sparks) From 37157272cbac16c150264913b3dee2986be6d9c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:26:08 +0000 Subject: [PATCH 21/25] Settings: only the last copied command's button says Copied (#33) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/settings-view.spec.ts | 31 ++++++++++++++++++++++++++++++- ui/settings.ts | 15 +++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/bun/settings-view.spec.ts b/tests/bun/settings-view.spec.ts index e8b6093..744af49 100644 --- a/tests/bun/settings-view.spec.ts +++ b/tests/bun/settings-view.spec.ts @@ -749,7 +749,36 @@ describe('UI.3: Setup and backups, and This browser', () => { expect(sent.copied).toEqual(['/mnemosyne-setup', '/mnemosyne-embed', '/mnemosyne-tags', '/mnemosyne-knowledge']) expect(sent.posted).toHaveLength(0) // No button anywhere on this card runs setup or rebuilds vectors: only Copy and Back up now. - expect(setup.findAll('button').map(button => button.textContent).sort()).toEqual(['Back up now', 'Copied', 'Copied', 'Copied', 'Copied']) + // Only the last copied command's button says Copied (#33). + expect(setup.findAll('button').map(button => button.textContent).sort()).toEqual(['Back up now', 'Copied', 'Copy', 'Copy', 'Copy']) + }) + }) + + test('copying a second command puts the first button back to Copy; a failure stays on its button (#33)', async () => { + const world = await boot() + const data = await load(world) + const sent = realHost(world) + await withDocument(async () => { + const cards = drawGroups(data, newView(), sent.host) as FakeNode[] + const setup = cards.find(card => card.find('h2')!.textContent === 'Setup')! + const [first, second, third] = setup.findAll('button').filter(button => button.textContent === 'Copy') + first.fire('click') + await settle() + expect(first.textContent).toBe('Copied') + second.fire('click') + await settle() + expect(second.textContent).toBe('Copied') + expect(first.textContent).toBe('Copy') + sent.host.copy = async () => false + third.fire('click') + await settle() + expect(third.textContent).toContain('Copy failed') + sent.host.copy = async () => true + first.fire('click') + await settle() + expect(first.textContent).toBe('Copied') + expect(second.textContent).toBe('Copy') + expect(third.textContent).toContain('Copy failed') }) }) diff --git a/ui/settings.ts b/ui/settings.ts index 9bc6422..b8a6d95 100644 --- a/ui/settings.ts +++ b/ui/settings.ts @@ -295,6 +295,17 @@ function button(label: string, className: string, onClick: () => void, title?: s return element } +/** The copy buttons drawn for a view, so a new copy puts the last one back to Copy (#33). */ +const copyButtons = new WeakMap>() + +/** Remembers a copy button; on a copy that worked, every other button showing Copied goes back to Copy. */ +function trackCopy(view: SettingsView, copy: HTMLButtonElement, ok?: boolean) { + const set = copyButtons.get(view) ?? new Set() + copyButtons.set(view, set) + set.add(copy) + if (ok === true) for (const other of set) if (other !== copy && other.textContent === 'Copied') other.textContent = 'Copy' +} + let fieldCount = 0 const nextId = (prefix: string) => `${prefix}-${++fieldCount}` @@ -680,8 +691,10 @@ export function settingRow(row: SettingRow, view: SettingsView, host: SettingsHo const ok = await host.copy('/config') copy.textContent = ok ? 'Copied' : 'Copy failed' view.copied = ok ? '/config' : '' + trackCopy(view, copy, ok) })() }, 'Copy /config') + trackCopy(view, copy) how.append(el('code', '/config', 'mono'), copy, el('span', ' A plugin option: change it in Claude Code.', 'muted')) control.append(how) } @@ -878,8 +891,10 @@ function drawSetup(rows: readonly SettingRow[], view: SettingsView, host: Settin const ok = await host.copy(item.command) copy.textContent = ok ? 'Copied' : 'Copy failed: select the command and copy it' view.copied = ok ? item.command : '' + trackCopy(view, copy, ok) })() }, `Copy ${item.command}`) + trackCopy(view, copy) li.append(el('code', item.command, 'mono'), el('span', item.what, 'muted small'), copy) list.append(li) } From 80b693f9579eeef8f951be1408638aad30b7038b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:42:59 +0000 Subject: [PATCH 22/25] Settings: let go of copy buttons from earlier draws (#33) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSrn4zdhsS2cocxtA3V --- tests/bun/settings-view.spec.ts | 26 ++++++++++++++++++++++++++ ui/settings.ts | 9 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/bun/settings-view.spec.ts b/tests/bun/settings-view.spec.ts index 744af49..fd8c2b4 100644 --- a/tests/bun/settings-view.spec.ts +++ b/tests/bun/settings-view.spec.ts @@ -782,6 +782,32 @@ describe('UI.3: Setup and backups, and This browser', () => { }) }) + test('copy buttons from an earlier draw or a replaced row are let go, not kept for ever (#33)', async () => { + const world = await boot() + const data = await load(world) + const sent = realHost(world) + await withDocument(async () => { + const view = newView() + const setupOf = (cards: FakeNode[]) => cards.find(card => card.find('h2')!.textContent === 'Setup')! + const [old] = setupOf(drawGroups(data, view, sent.host) as FakeNode[]).findAll('button').filter(button => button.textContent === 'Copy') + old.fire('click') + await settle() + expect(old.textContent).toBe('Copied') + // A filter keystroke draws every card again; the old tree is gone and must not stay tracked. + const fresh = setupOf(drawGroups(data, view, sent.host) as FakeNode[]).findAll('button').filter(button => button.textContent === 'Copy') + fresh[0].fire('click') + await settle() + expect(fresh[0].textContent).toBe('Copied') + expect(old.textContent).toBe('Copied') + // A button whose row was replaced (no longer connected) is dropped on the next copy. + Object.defineProperty(fresh[0], 'isConnected', { value: false }) + fresh[1].fire('click') + await settle() + expect(fresh[1].textContent).toBe('Copied') + expect(fresh[0].textContent).toBe('Copied') + }) + }) + test('a failed copy says so, and the command stays on the page as text', async () => { const world = await boot() const data = await load(world) diff --git a/ui/settings.ts b/ui/settings.ts index b8a6d95..6fd96dd 100644 --- a/ui/settings.ts +++ b/ui/settings.ts @@ -303,7 +303,12 @@ function trackCopy(view: SettingsView, copy: HTMLButtonElement, ok?: boolean) { const set = copyButtons.get(view) ?? new Set() copyButtons.set(view, set) set.add(copy) - if (ok === true) for (const other of set) if (other !== copy && other.textContent === 'Copied') other.textContent = 'Copy' + if (ok !== true) return + for (const other of set) { + // A button whose row was drawn again is gone from the page: let it go rather than keep it for ever. + if (other.isConnected === false) set.delete(other) + else if (other !== copy && other.textContent === 'Copied') other.textContent = 'Copy' + } } let fieldCount = 0 @@ -964,6 +969,8 @@ function drawSetup(rows: readonly SettingRow[], view: SettingsView, host: Settin /** Everything under the page title: the banner, Reset all, the groups. The filter box redraws only this. */ export function drawGroups(data: SettingsData, view: SettingsView, host: SettingsHost): HTMLElement[] { const out: HTMLElement[] = [] + // Every card is drawn anew, so the copy buttons of the last draw are dropped (#33). + copyButtons.delete(view) const mode = view.mode ?? 'all' const rows = filterRows(data.settings, data.groups, view.filter, mode) const matching = view.filter.trim() !== '' || mode !== 'all' From 7fb7348f3978f33c486177bd2af5b24890e5bcc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 15:27:02 +0000 Subject: [PATCH 23/25] chore(release): 0.31.2, engine build .18 with changes 34-40 (#19, #7, #12, #17, #26, #9, #23) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSobwTXWVRg7fVZcKYo --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 12 +++++++ engine/UPSTREAM.md | 66 +++++++++++++++++++++++++++++++++++++- engine/package.json | 2 +- hooks/engine-build.ts | 2 +- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 73f1551..4c64675 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "mnemosyne", "displayName": "Claude Mnemosyne", - "version": "0.31.1", + "version": "0.31.2", "description": "Long-term memory for Claude Code across sessions and repos, on its own private copy of the Mnemosyne Memory engine and its own store.", "author": { "name": "Interstellar-code" diff --git a/CHANGELOG.md b/CHANGELOG.md index edd05cb..a4f4f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Newest first. Each release is tagged `vX.Y.Z` on GitHub; the README and DESIGN.md describe the contracts. +## 0.31.2 (bug-hunt batch B: recall and query) + +Engine build 18.12.3-mnemosyne.18 (engine changes 34 to 40). Run `/mnemosyne-setup` once after updating; outside Claude Code, `bun bin/update.ts` or Update… on the Health tab. + +1. **Meaning search looks at every memory.** With embeddings on, only the 500 newest memories were compared by meaning, so an older memory that matched by meaning but shared no words was never found. Now every memory with an embedding is compared (the work grows with the store; fine for thousands of memories) (#19). +2. **Recall settings from the engine's own API are honoured.** `useSynonyms`, `useIntent`, `useMmr` and `mmrLambda` passed to `recall()` were silently dropped (#7). +3. **Words with two meanings search both.** "token", "push", "setup", "check" and similar words now find memories that use either meaning's words (#12). +4. **"Chicago" is not a time word.** Words that merely contain "ago", "last" or "before" (Chicago, Elasticsearch, plastic, beforehand) no longer pull recent unrelated notes into results (#17). +5. **Long questions keep all their words.** In a question with several words, later words were dropped from the keyword search when earlier ones had many synonyms (#26). +6. **"The day before yesterday" means two days back** (#9). +7. **Notes are not mistaken for events.** The letters "at" and "on" inside other words made almost every note look a bit like an event (#23). + ## 0.31.1 (bug-hunt batch C: extraction and parsing) Engine build 18.12.3-mnemosyne.17 (engine changes 29 to 33). Run `/mnemosyne-setup` once after updating; outside Claude Code, `bun bin/update.ts` or Update… on the Health tab. diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index 3a80271..2f5c0bd 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -40,8 +40,9 @@ Ours, not upstream's (engine-diff ignores them): `UPSTREAM.md`, `bun.lock` (our | 18.12.3-mnemosyne.15 | 7840532d977bf4b9b7087d9ec73955d34fac3e7613ba60bfd88e0a1854fd9ff7 | | 18.12.3-mnemosyne.16 | a920529105abee6a7f0bfd0ebb845f8afeb513a8e9522a379d469edaf8be34a5 | | 18.12.3-mnemosyne.17 | ac1064bc11c5b1460b7388860986c4aa4edd5062ccb32e5b7096efe7fb4f304f | +| 18.12.3-mnemosyne.18 | 2c3cdefb964baa90f40786ccb1bf9d731d45bf7350270cf5fa81b1a79330a4d2 | -The `18.12.3` row is the untouched upstream source, for reference. `.1` had changes 1 to 3; `.2` makes the change 1 merge keep a `__proto__` key as plain data (it was dropped); `.3` adds the two knowledge tools (change 4) and keeps the first author on dedupe with empty author env unset (change 5); `.4` is the review fix round: the knowledge reply is normalised by `parseExtractedFactCategories` (change 4), and the author pair merges as one unit (change 5); `.5` turns embeddings on: fastembed is a real dependency with an onnxruntime override (change 6), the CLI exits naturally on success (change 7), the on-demand runtime install is gone (change 8), and a `reembed` command backfills vectors (change 9); `.6` is the review fix round: `reembed` recounts after the drain and proves the model before a rebuild (change 9), and the API embedding route is refused without an explicit opt-in (change 10); `.7` is the second fix round: the fastembed cache dir is read from the plugin-pinned `PI_CONFIG_DIR` directly, immune to the dotenv/XDG/profile refill (change 11), and the dry run prints the 'apply' hint only when something is missing (change 9). `.8` is phase MODEL (v3): the trim spares every row whose scope is not 'session' (change 12), remember and update store `memory_type`, `trust_tier` and `valid_until` and update can restore a retired row (change 13), the read-only `mnemopi_similar` (change 14), `mnemopi_expire_unused` (change 15), and the audit trail in `memory_validations` that forget purges (change 16). `.9` MODEL.7 fix round: a re-save of the same text by the same owner never lowers a stored scope, keeps the stored trust tier unless the call names one, and never relabels a fact, preference, instruction or decision or gives it an expiry (change 17). `.10` MODEL.8: a re-save also keeps the first writer's `source` and `trust_tier` (change 18). `.11` the `mnemopi_remember` reply says `duplicate` (change 19). `.12` entity questions rank the memories about the thing asked (change 20). `.13` a process can take the owner per call, for the dashboard's warm search engine (change 21). `.14` a query naming a memory kind finds that kind (change 22), and an entity question gets the extracted facts about it (change 23). `.15` an explicit nested `llm.enabled: false` is kept (change 24), and model replies keep their list and `Source:` lines outside summaries (change 25). `.16` a backdated triple is stored as history and no triple range ends before it starts (change 26), a re-save of a retired or expired memory's text leaves it so and says so (change 27), and `mnemopi_validate`'s `private` and `surface` banks reach the real stores (change 28). `.17` bug-hunt batch C: the broken-JSON fallback stores no keys or JSON fragments as facts (change 29), the extraction prompt keeps `$` patterns in the conversation (change 30), percent metrics are extracted (change 31), the `launched` EVENT pattern matches (change 32), and the is / has / uses fact patterns no longer clip the next word (change 33). +The `18.12.3` row is the untouched upstream source, for reference. `.1` had changes 1 to 3; `.2` makes the change 1 merge keep a `__proto__` key as plain data (it was dropped); `.3` adds the two knowledge tools (change 4) and keeps the first author on dedupe with empty author env unset (change 5); `.4` is the review fix round: the knowledge reply is normalised by `parseExtractedFactCategories` (change 4), and the author pair merges as one unit (change 5); `.5` turns embeddings on: fastembed is a real dependency with an onnxruntime override (change 6), the CLI exits naturally on success (change 7), the on-demand runtime install is gone (change 8), and a `reembed` command backfills vectors (change 9); `.6` is the review fix round: `reembed` recounts after the drain and proves the model before a rebuild (change 9), and the API embedding route is refused without an explicit opt-in (change 10); `.7` is the second fix round: the fastembed cache dir is read from the plugin-pinned `PI_CONFIG_DIR` directly, immune to the dotenv/XDG/profile refill (change 11), and the dry run prints the 'apply' hint only when something is missing (change 9). `.8` is phase MODEL (v3): the trim spares every row whose scope is not 'session' (change 12), remember and update store `memory_type`, `trust_tier` and `valid_until` and update can restore a retired row (change 13), the read-only `mnemopi_similar` (change 14), `mnemopi_expire_unused` (change 15), and the audit trail in `memory_validations` that forget purges (change 16). `.9` MODEL.7 fix round: a re-save of the same text by the same owner never lowers a stored scope, keeps the stored trust tier unless the call names one, and never relabels a fact, preference, instruction or decision or gives it an expiry (change 17). `.10` MODEL.8: a re-save also keeps the first writer's `source` and `trust_tier` (change 18). `.11` the `mnemopi_remember` reply says `duplicate` (change 19). `.12` entity questions rank the memories about the thing asked (change 20). `.13` a process can take the owner per call, for the dashboard's warm search engine (change 21). `.14` a query naming a memory kind finds that kind (change 22), and an entity question gets the extracted facts about it (change 23). `.15` an explicit nested `llm.enabled: false` is kept (change 24), and model replies keep their list and `Source:` lines outside summaries (change 25). `.16` a backdated triple is stored as history and no triple range ends before it starts (change 26), a re-save of a retired or expired memory's text leaves it so and says so (change 27), and `mnemopi_validate`'s `private` and `surface` banks reach the real stores (change 28). `.17` bug-hunt batch C: the broken-JSON fallback stores no keys or JSON fragments as facts (change 29), the extraction prompt keeps `$` patterns in the conversation (change 30), percent metrics are extracted (change 31), the `launched` EVENT pattern matches (change 32), and the is / has / uses fact patterns no longer clip the next word (change 33). `.18` bug-hunt batch B: meaning search covers every embedded memory (change 34), the facade passes the recall flags through (change 35), a word in several synonym groups expands to all of them (change 36), the temporal voice matches whole words (change 37), every query word stays in the FTS query (change 38), 'the day before yesterday' is two days back (change 39), and confidence boosters match whole words (change 40). ## Changes @@ -343,3 +344,66 @@ Not changed: `dist/types/*.d.ts` still describe upstream 18.12.3 (no `metadata` - Tests: `test/fact-article.spec.ts` - Upstream issue: none yet - Status: open + +### 34. Meaning search covers every embedded memory + +- What: with a query embedding, recall computes vector similarity over every visible row that has a stored embedding (`embeddedVisibleIds`, in chunks of 500), with the same visibility, scope, date and kind filters, not only the 500 newest per tier. Without an embedding nothing changes; the result limit and weights are unchanged. The cost is a linear cosine scan of the embedded rows; `vec_episodes` is not used (never created by `schema.ts`, episodic only, and its top-k cannot apply the filters). +- Why: an older memory with no keyword overlap but the best embedding match was never found (claude-mnemosyne#19). +- Files: `src/core/beam/recall.ts` +- Tests: `test/vector-recall-coverage.spec.ts` +- Upstream issue: none yet +- Status: open + +### 35. The facade passes the recall flags through + +- What: `toRecallOptions` in `src/core/memory.ts` passes `useSynonyms`, `useIntent`, `useMmr` and `mmrLambda` from `recall()` / `recallEnhanced()` to beam recall; unset stays unset. +- Why: the flags were dropped, so the caller's settings were silently ignored (claude-mnemosyne#7). +- Files: `src/core/memory.ts` +- Tests: `test/recall-facade-options.spec.ts` +- Upstream issue: none yet +- Status: open + +### 36. A word in several synonym groups expands to all of them + +- What: `getSynonyms` covers every group a word is in, deduped, in group order (built on the new `getSynonymGroups`). Recall's `expandedTokens` lists the literal query words first, then one variant per synonym group in turn, so the 12-term FTS query and the fact fallback reach every word and every meaning; the token set is unchanged, only the order. `normalizeQuery` and `expandQuery` keep one canonical. +- Why: recall for 'token', 'push', 'setup', 'check' and similar words missed memories using the other meaning's words (claude-mnemosyne#12). +- Files: `src/core/synonyms.ts` `src/core/beam/recall.ts` +- Tests: `test/synonyms.spec.ts` +- Upstream issue: none yet +- Status: open + +### 37. The temporal voice matches whole words + +- What: `looksTemporal` (now exported) matches its keywords on word boundaries; the list is unchanged. `recently` and `lastly` no longer fire it. +- Why: 'Chicago', 'Elasticsearch', 'plastic' and 'beforehand' switched the temporal voice on and mixed unrelated recent notes into results (claude-mnemosyne#17). +- Files: `src/core/polyphonic-recall.ts` +- Tests: `test/temporal-trigger.spec.ts` +- Upstream issue: none yet +- Status: open + +### 38. Every query word stays in the FTS query + +- What: `ftsQuery` keeps every distinct query word (more than 12 for a longer query) and adds synonyms only in the room left under the 12-term cap. +- Why: early query words with many synonyms pushed later query words out of the FTS MATCH (claude-mnemosyne#26). +- Files: `src/core/beam/recall.ts` +- Tests: `test/fts-query-terms.spec.ts` +- Upstream issue: none yet +- Status: open + +### 39. 'The day before yesterday' is two days back + +- What: the two-day branch is checked before `/\byesterday\b/`. +- Why: the yesterday check matched the phrase first, so it resolved to one day back (claude-mnemosyne#9). +- Files: `src/core/temporal-parser.ts` +- Tests: `test/temporal-day-before-yesterday.spec.ts` +- Upstream issue: none yet +- Status: open + +### 40. Confidence boosters match whole words + +- What: boosters in `classifyMemory` match as whole words or phrases (`\b`-bounded, regex-escaped); the lists and the +0.05 step are unchanged. +- Why: the EVENT boosters 'at' and 'on' matched inside nearly every note, a false +0.10 that could flip the type to EVENT (claude-mnemosyne#23). +- Files: `src/core/typed-memory.ts` +- Tests: `test/confidence-boosters.spec.ts` +- Upstream issue: none yet +- Status: open diff --git a/engine/package.json b/engine/package.json index 98298ca..deb7460 100644 --- a/engine/package.json +++ b/engine/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-mnemopi", - "version": "18.12.3-mnemosyne.17", + "version": "18.12.3-mnemosyne.18", "description": "Local SQLite memory engine for omp agents", "homepage": "https://omp.sh", "author": { diff --git a/hooks/engine-build.ts b/hooks/engine-build.ts index 176d2df..0e53533 100644 --- a/hooks/engine-build.ts +++ b/hooks/engine-build.ts @@ -1,7 +1,7 @@ // The engine build this plugin expects: engine/package.json's version, which // /mnemosyne-setup installs (engine/UPSTREAM.md, change 3). No imports, so the // hooks, the proxy, scripts and Bun specs can all read it. -export const ENGINE_BUILD = '18.12.3-mnemosyne.17' +export const ENGINE_BUILD = '18.12.3-mnemosyne.18' // /mnemosyne-setup's limit on bin/install-engine.ts, and the installer's own // limits on its steps: the inner ones stay well below the outer one, so the From a09ba22d0827a5e0d0a5e56bb301d5b29c87589c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 15:27:43 +0000 Subject: [PATCH 24/25] chore(release): 0.31.3 and 0.31.4 changelog (#20, #22, #29, #30, #31, #32, #33; #36, #37, #38, #39) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSobwTXWVRg7fVZcKYo --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4c64675..3a59ad2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "mnemosyne", "displayName": "Claude Mnemosyne", - "version": "0.31.2", + "version": "0.31.4", "description": "Long-term memory for Claude Code across sessions and repos, on its own private copy of the Mnemosyne Memory engine and its own store.", "author": { "name": "Interstellar-code" diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f4f79..888e501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ Newest first. Each release is tagged `vX.Y.Z` on GitHub; the README and DESIGN.md describe the contracts. +## 0.31.4 (bug-hunt batch E: housekeeping) + +No engine change. + +1. **A session marker that disappears mid-list no longer stops the summaries.** A marker claimed by another session between listing and reading is skipped (#36). +2. **A live sweep lock is never deleted.** When a stale lock was taken over just as another process renewed it, the fresh lock was removed; it is now put back (#37). +3. **Session summaries that keep failing give up.** A summary whose model call fails waits 1, 2, 4, then 8 hours before the next try, and after 5 failed attempts it is dropped with one line in the activity log, instead of being retried on every first prompt forever (#38). +4. **The plans in `docs/plans/` say what shipped** (#39). + +`adm-zip` advisories (#35, still open): reviewed; the vulnerable code runs only in `onnxruntime-node`'s install script, which the plugin's own install skips, so nothing reachable at runtime. Moving the engine's `onnxruntime-node` pin from 1.26.0 to 1.31.1 would clear all 8 when the engine is next touched. + +## 0.31.3 (bug-hunt batch D: the dashboard) + +No engine change. + +1. **The Memories list shows the latest load.** A slow reply to an earlier load could overwrite the rows of a later one (#20). +2. **A search typed after Escape runs.** Typing a new search while the previous request was still running did nothing (#22). +3. **The Knowledge note's Clear button keeps focus** across re-renders (#29). +4. **A failed Review reload says so** instead of silently showing the old queue (#30). +5. **Forgetting a search hit keeps your place.** The list no longer collapses back to 10 and the count stays right (#31). +6. **Live recall updates a memory's last-recalled time** on the map (#32). +7. **Only the last copied command says "Copied"** on the Settings page (#33). + +#21 (reconnect replays duplicates after 20,000 lines) was already fixed: the stream resumes from a fresh cursor. + ## 0.31.2 (bug-hunt batch B: recall and query) Engine build 18.12.3-mnemosyne.18 (engine changes 34 to 40). Run `/mnemosyne-setup` once after updating; outside Claude Code, `bun bin/update.ts` or Update… on the Health tab. From 05b10eeed2e342a71a90166610f382723da5a7cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 15:46:43 +0000 Subject: [PATCH 25/25] test(session): the harness rig reads the session id past #38's --failed flag (#38) tests/session.test.ts releasesOf took the last argument as the session id; a failed model call now releases with 'release --failed', so it read '--failed'. It now takes the last non-flag argument, and SESSION.1.a4 checks that only the lost model call is released as failed. claude plugin test: 544 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2ePSobwTXWVRg7fVZcKYo --- tests/session.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/session.test.ts b/tests/session.test.ts index c7b3e60..a6d37d5 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -480,7 +480,9 @@ test('SESSION.1.a3: in strict mode (the default) a warned summary is dropped, no }) const claimsOf = (state: ReturnType) => sessionRuns(state, 'session-pending').filter(run => run.argv.includes('claim')).map(run => run.argv.at(-1)) -const releasesOf = (state: ReturnType) => sessionRuns(state, 'session-pending').filter(run => run.argv.includes('release')).map(run => run.argv.at(-1)) +// The session id is the last argument that is not a flag (issue #38 adds --failed after it for a failed model call). +const releasesOf = (state: ReturnType) => sessionRuns(state, 'session-pending').filter(run => run.argv.includes('release')).map(run => run.argv.filter(arg => !arg.startsWith('--')).at(-1)) +const failedReleasesOf = (state: ReturnType) => sessionRuns(state, 'session-pending').filter(run => run.argv.includes('release') && run.argv.includes('--failed')).map(run => run.argv.filter(arg => !arg.startsWith('--')).at(-1)) test('SESSION.1.a4: every marker is claimed before its transcript is read; a marker another session claimed is left alone', { options: { sessionSummary: 'on' } }, async ($, on) => { const state = rig(on, withPending(['taken', 'mine'], { memories: argv => (argv.includes('claim') && argv.at(-1) === 'taken' ? { stdout: '{"claimed":false}\n' } : undefined) })) @@ -502,6 +504,8 @@ test('SESSION.1.a4: a claim that is not finished (a lost call, a read that faile }) await firstReply($) expect(releasesOf(state)).toEqual(['lost', 'broken']) + // Issue #38: only the failed model call counts as an attempt; a failed transcript read is released as before. + expect(failedReleasesOf(state)).toEqual(['lost']) expect(removed(state)).toEqual(['done']) })