diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 73f1551..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.1",
+ "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 edd05cb..888e501 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,43 @@
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.
+
+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/bin/memories.ts b/bin/memories.ts
index 3792c2a..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_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_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'
@@ -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)
@@ -492,13 +524,22 @@ 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) {
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. 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
}
@@ -507,7 +548,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))
@@ -524,14 +565,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
}
@@ -761,8 +817,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/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)
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/engine/src/core/beam/recall.ts b/engine/src/core/beam/recall.ts
index a04a021..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];
}
@@ -483,7 +495,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 ");
}
@@ -711,6 +726,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 +1012,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/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/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/src/core/synonyms.ts b/engine/src/core/synonyms.ts
index 5780065..8612d18 100644
--- a/engine/src/core/synonyms.ts
+++ b/engine/src/core/synonyms.ts
@@ -189,9 +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 canonical = WORD_TO_CANONICAL.get(lowered);
- if (canonical === undefined) return [lowered];
- return [canonical, ...SYNONYM_GROUPS[canonical]];
+ const groups: string[][] = [];
+ for (const canonical in SYNONYM_GROUPS) {
+ const group: readonly string[] = SYNONYM_GROUPS[canonical as Canonical];
+ if (canonical === lowered || group.includes(lowered)) groups.push([canonical, ...group]);
+ }
+ 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/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/src/core/typed-memory.ts b/engine/src/core/typed-memory.ts
index 26fbbcc..8f5e610 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);
+ });
+});
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);
+});
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();
+});
diff --git a/engine/test/synonyms.spec.ts b/engine/test/synonyms.spec.ts
new file mode 100644
index 0000000..5269cf2
--- /dev/null
+++ b/engine/test/synonyms.spec.ts
@@ -0,0 +1,47 @@
+// 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);
+});
+
+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);
+});
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");
+ });
+});
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);
+ }
+});
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();
+ }
+});
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
diff --git a/hooks/register.ts b/hooks/register.ts
index 5314319..347ebcc 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,
@@ -1782,7 +1783,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)
@@ -1841,7 +1843,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..0863c38 100644
--- a/hooks/session.ts
+++ b/hooks/session.ts
@@ -119,8 +119,19 @@ 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 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), 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, '-')
@@ -138,7 +149,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/map-state.spec.ts b/tests/bun/map-state.spec.ts
index cf40888..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)
@@ -2049,6 +2063,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.')
@@ -3795,4 +3815,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/tests/bun/session.spec.ts b/tests/bun/session.spec.ts
index 2afa43d..a9332f0 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'
@@ -344,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)
@@ -506,6 +526,86 @@ 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)
+ })
+
+ 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)', () => {
+ 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)', () => {
@@ -784,13 +884,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 () => {
diff --git a/tests/bun/settings-view.spec.ts b/tests/bun/settings-view.spec.ts
index e8b6093..fd8c2b4 100644
--- a/tests/bun/settings-view.spec.ts
+++ b/tests/bun/settings-view.spec.ts
@@ -749,7 +749,62 @@ 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')
+ })
+ })
+
+ 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')
})
})
diff --git a/tests/bun/ui-views.spec.ts b/tests/bun/ui-views.spec.ts
index 1cb3d8f..f2f6071 100644
--- a/tests/bun/ui-views.spec.ts
+++ b/tests/bun/ui-views.spec.ts
@@ -724,9 +724,11 @@ 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).
+ let held: { path: string; release: () => void }[] | null = null
class AppText {
constructor(public data: string) {}
@@ -768,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))
}
@@ -831,7 +842,9 @@ 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 })
+ 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}` }
@@ -1038,6 +1051,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. */
@@ -1253,6 +1290,120 @@ 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
+ }
+ })
+ })
+
+ 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
+ }
+ })
+
+ 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/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'])
})
diff --git a/ui/app.ts b/ui/app.ts
index 05b4464..a02c3bf 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) {
@@ -1898,6 +1900,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 +2043,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 +2132,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.`
}
}
@@ -2520,6 +2525,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 +2572,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 +2585,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 +2844,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)
}
@@ -3826,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 : keep > 0 ? keep : SHOW_FIRST)
const rows: HTMLElement[] = []
list.slice(from, searchShown).forEach((hit, i) => {
const at = from + i
@@ -4050,7 +4060,7 @@ export function searchKey(event: KeyboardEvent) {
}
}
-function wireSearch() {
+export function wireSearch() {
const form = byId('search-form')
form.addEventListener('submit', event => {
event.preventDefault()
diff --git a/ui/map-state.ts b/ui/map-state.ts
index f572b75..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). */
@@ -1999,7 +2002,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. */
diff --git a/ui/map.ts b/ui/map.ts
index b7d07fd..d8cfe41 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)))
@@ -2662,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.
@@ -2732,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)
diff --git a/ui/settings.ts b/ui/settings.ts
index 9bc6422..6fd96dd 100644
--- a/ui/settings.ts
+++ b/ui/settings.ts
@@ -295,6 +295,22 @@ 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) 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
const nextId = (prefix: string) => `${prefix}-${++fieldCount}`
@@ -680,8 +696,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 +896,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)
}
@@ -949,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'