claude-mem-lite 3.68.0 → 3.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.68.0",
13
+ "version": "3.69.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.68.0",
3
+ "version": "3.69.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli/common.mjs CHANGED
@@ -200,10 +200,10 @@ export const KNOWN_CLI_FLAGS = new Set([
200
200
  'after', 'age-days', 'all', 'anchor', 'batch', 'before', 'benchmark', 'body', 'branch',
201
201
  'capability-summary', 'category', 'closes-deferred', 'concepts', 'confirm', 'days', 'deep',
202
202
  'detail', 'domain-tags', 'dry-run', 'enrich', 'execute', 'fields', 'file', 'files', 'floors',
203
- 'force', 'format', 'from', 'has', 'help', 'id', 'ids', 'content', 'importance', 'include-compressed', 'include-noise',
203
+ 'force', 'format', 'from', 'help', 'id', 'ids', 'content', 'importance', 'include-compressed', 'include-noise',
204
204
  'intent-tags', 'invocation-name', 'json', 'key', 'keywords', 'lesson', 'lesson-learned', 'limit',
205
205
  'local-path', 'margins', 'max', 'memdir', 'merge-ids', 'metrics', 'name', 'narrative', 'no-deep',
206
- 'offset', 'ops', 'or', 'out', 'priority', 'project', 'quality', 'query', 'reason', 'repo-url',
206
+ 'offset', 'ops', 'or', 'priority', 'project', 'quality', 'query', 'reason', 'repo-url',
207
207
  'rerank', 'resource-type', 'retain-days', 'retry', 'run', 'run-all', 'scope', 'session-audit',
208
208
  'sidechain', 'since', 'sort', 'source', 'status', 'sweep', 'task', 'tech-stack', 'text', 'tier', 'title',
209
209
  'to', 'trigger-patterns', 'type', 'use-cases', 'verbose',
@@ -217,6 +217,15 @@ export const KNOWN_CLI_FLAGS = new Set([
217
217
  // warn-on-every-unknown-flag flip turned the omission into a false warning on a
218
218
  // documented, working command.
219
219
  'prompts-limit',
220
+ // Entries here MUST be read by a `claude-mem-lite` subcommand. A flag that no
221
+ // command reads is worse than an absent one: it converts the "ignored, it had no
222
+ // effect" warning into silence, so the user's dropped flag reads as accepted.
223
+ // `out` sat here until the 2026-08-17 e2e round for that exact reason: `--out` is a
224
+ // benchmark-script flag (benchmark/longmemeval-rerank.mjs), never a CLI one, so
225
+ // `export --out backup.json` printed the whole export to stdout and said nothing
226
+ // about the file it did not write. `has` went the same round: no reader, no help
227
+ // entry, and (per the v3.34.0 notes) the source of the misleading "did you mean
228
+ // --has?" suggestion. Locked by tests/cli-flag-allowlist.test.mjs.
220
229
  ]);
221
230
 
222
231
  /** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
@@ -56,6 +56,17 @@ export const sleepMs = (ms) => new Promise(r => setTimeout(r, ms));
56
56
  // same age escape hatch the reaper applies to other processes' slots. 0 = not held.
57
57
  let localHeldAt = 0;
58
58
 
59
+ /**
60
+ * Test-only: force the local hold record to an arbitrary age. The staleness
61
+ * escape hatch above is otherwise unreachable in a test — it needs a record
62
+ * older than LLM_SEM_STALE_MS (120s), and a suite cannot wait that long. It was
63
+ * shipped untested in v3.68.0 and a post-tag review showed the gate still passed
64
+ * 16/16 when the escape hatch was deleted outright. Mirrors the `_resetMode` /
65
+ * `_resetHeadlessFlag` hooks in haiku-client.mjs.
66
+ * @param {number} ts epoch ms, or 0 for "not held"
67
+ */
68
+ export function _setLocalHeldAt(ts) { localHeldAt = ts; }
69
+
59
70
  /**
60
71
  * Acquire a file-based semaphore slot for LLM calls.
61
72
  * Uses acquire-then-verify: atomically creates a slot file, then checks total count.
package/hook.mjs CHANGED
@@ -878,8 +878,15 @@ async function handleStop() {
878
878
  }
879
879
  }
880
880
 
881
- // Spawn background for session summary (pass sessionId and project)
882
- spawnBackground('llm-summary', sessionId, project);
881
+ // Spawn background for session summary (pass sessionId and project).
882
+ // CLAUDE_MEM_SKIP_SUMMARY brings this in line with every other background
883
+ // worker (auto-compress / llm-optimize / auto-maintain all have one). It was
884
+ // the only ungated spawnBackground, which made Stop untestable end-to-end
885
+ // without residue: the detached child outlives the parent process an e2e test
886
+ // waits on, then recreates the sandbox tree behind the test's cleanup. Any
887
+ // grace period for that is a race, not a barrier — the post-tag review timed a
888
+ // recreate at 432ms and watched a 300ms grace lose.
889
+ if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) spawnBackground('llm-summary', sessionId, project);
883
890
 
884
891
  // Clean session file AFTER spawning background
885
892
  try { unlinkSync(sessionFile()); } catch {}
@@ -1523,7 +1530,14 @@ async function handleSessionStart() {
1523
1530
  // Stdout is the sole context-delivery channel. The SessionStart hook output
1524
1531
  // is injected as a <system-reminder> at session start, giving Claude the
1525
1532
  // full summary + handoff state + observations table fresh from the DB.
1526
- process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1533
+ // Skip the wrapper entirely when there is no body. On a brand-new install every
1534
+ // section is empty, and the hook still emitted `<claude-mem-context>\n\n</...>` —
1535
+ // a framing block that asserts a memory surface and then shows nothing, which is
1536
+ // both wasted context and an active misread ("memory exists and is empty" is a
1537
+ // reason NOT to call mem_*). Non-empty output is byte-identical.
1538
+ if (fullContext.trim()) {
1539
+ process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1540
+ }
1527
1541
 
1528
1542
  // D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
1529
1543
  // handleUserPrompt can exclude exactly those from <memory-context> — and
package/install.mjs CHANGED
@@ -1721,24 +1721,63 @@ async function doctor() {
1721
1721
  try {
1722
1722
  const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
1723
1723
  const r = checkDevDrift(INSTALL_DIR, SOURCE_FILES);
1724
- if (r.drift || (r.devMode && r.missingCount > 0)) {
1724
+ const devRemedy = `re-run: node ${join(PROJECT_DIR, 'install.mjs')} install --dev`;
1725
+ const nameList = (files, count) => {
1726
+ const suffix = count > files.length ? ` +${count - files.length} more` : '';
1727
+ return `${files.join(', ')}${suffix}`;
1728
+ };
1729
+ if (r.devMode) {
1725
1730
  const parts = [];
1726
1731
  if (r.plainCount > 0) {
1727
- const names = r.plainFiles.slice(0, 5).join(', ');
1728
- const suffix = r.plainCount > 5 ? ` +${r.plainCount - 5} more` : '';
1729
- parts.push(`${r.plainCount} non-symlink: ${names}${suffix}`);
1732
+ parts.push(`${r.plainCount} non-symlink: ${nameList(r.plainFiles.slice(0, 5), r.plainCount)}`);
1730
1733
  }
1731
- if (r.missingCount > 0) {
1732
- const names = r.missingFiles.join(', ');
1733
- const suffix = r.missingCount > r.missingFiles.length ? ` +${r.missingCount - r.missingFiles.length} more` : '';
1734
- parts.push(`${r.missingCount} missing: ${names}${suffix}`);
1734
+ if (r.missingEntryCount > 0) {
1735
+ parts.push(`${r.missingEntryCount} missing ENTRY POINT: ${nameList(r.missingEntryFiles, r.missingEntryCount)}`);
1735
1736
  }
1736
- warn(`Dev drift: ${parts.join('; ')} (re-run: node ${join(PROJECT_DIR, 'install.mjs')} install --dev)`);
1737
+ if (parts.length > 0) {
1738
+ // Hard: a non-symlink means repo edits stop propagating, and a missing entry point
1739
+ // means the hook/CLI command that names that path cannot start at all. A hybrid
1740
+ // install also loses the realpath argument below — a COPIED entry point resolves
1741
+ // its imports against the install dir, so absent modules can throw there.
1742
+ if (r.missingModuleCount > 0) {
1743
+ parts.push(`${r.missingModuleCount} missing module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`);
1744
+ }
1745
+ warn(`Dev drift: ${parts.join('; ')} (${devRemedy})`);
1746
+ issues++;
1747
+ } else if (r.missingModuleCount > 0) {
1748
+ // Informational, NOT an issue: in a pure-symlink install every entry point resolves
1749
+ // to the repo, and Node resolves each module's imports against that REALPATH — so an
1750
+ // import-only file absent from the install dir is unreachable, not broken. Reporting
1751
+ // it as drift prescribed `install --dev` for a demonstrably healthy install (the
1752
+ // maintainer's own machine ran every one of those modules fine while doctor called
1753
+ // them missing).
1754
+ dwarn(`Dev drift: ${r.symlinkCount} symlinks, 0 plain, all entry points present — `
1755
+ + `${r.missingModuleCount} import-only file(s) not linked into the install dir `
1756
+ + `(${nameList(r.missingModuleFiles, r.missingModuleCount)}). Harmless: Node resolves `
1757
+ + `imports against each entry point's realpath, i.e. the repo. ${devRemedy} to link them.`);
1758
+ } else {
1759
+ ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain, 0 missing)`);
1760
+ }
1761
+ } else if (r.missingCount > 0) {
1762
+ // COPY install (npm / plugin / `install` without --dev). Here the realpath argument
1763
+ // does NOT apply: entry points are real files, so `../lib/x.mjs` resolves against the
1764
+ // install dir and a missing module is an ERR_MODULE_NOT_FOUND on every hook fire.
1765
+ // This case used to print NOTHING — checkDevDrift returns devMode=false and both the
1766
+ // warning and the all-clear were gated on devMode, so the shape where missing files
1767
+ // are FATAL was the silent one (#8268's rule failing in the other direction).
1768
+ const parts = [];
1769
+ if (r.missingEntryCount > 0) {
1770
+ parts.push(`${r.missingEntryCount} entry point: ${nameList(r.missingEntryFiles, r.missingEntryCount)}`);
1771
+ }
1772
+ if (r.missingModuleCount > 0) {
1773
+ parts.push(`${r.missingModuleCount} module: ${nameList(r.missingModuleFiles, r.missingModuleCount)}`);
1774
+ }
1775
+ warn(`Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves `
1776
+ + `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite update `
1777
+ + `(or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`);
1737
1778
  issues++;
1738
- } else if (r.devMode) {
1739
- ok(`Dev drift: clean (${r.symlinkCount} symlinks, 0 plain, 0 missing)`);
1740
1779
  }
1741
- // Prod (all plain) install: no message — dev-drift is a dev-only concern.
1780
+ // Complete copy install: no message — drift is a dev-install concern.
1742
1781
  } catch (e) {
1743
1782
  dwarn('Dev drift: check failed — ' + e.message);
1744
1783
  }
@@ -11,6 +11,25 @@
11
11
  import { existsSync, lstatSync } from 'fs';
12
12
  import { join } from 'path';
13
13
 
14
+ // Files something EXECUTES by path (the CLI, the MCP server, a hook entry) as opposed to
15
+ // files that are only ever `import`ed. The distinction decides whether an absent file
16
+ // matters in a symlink install: Node resolves an ESM specifier against the importing
17
+ // module's REALPATH, so a symlinked entry point resolves `../lib/x.mjs` inside the REPO and
18
+ // never looks at the install dir. An absent import-only module is therefore unreachable
19
+ // dead weight there — while an absent ENTRY POINT is fatal in every shape, because the
20
+ // command names that path directly.
21
+ //
22
+ // Scope note (pre-tag review): this classifies only what the CALLER passes in, and
23
+ // install.mjs passes SOURCE_FILES, which holds zero `scripts/` entries — hook scripts are
24
+ // installed from the separate HOOK_SCRIPT_FILES manifest. An earlier draft mapped those
25
+ // into this set; it could never match a single path, so it is gone rather than left as
26
+ // inert code implying coverage it does not have. Extending doctor to check the hook-script
27
+ // manifest is real work with its own fixture, and is tracked as deferred rather than
28
+ // implied here.
29
+ const ENTRY_POINTS = new Set([
30
+ 'cli.mjs', 'mem-cli.mjs', 'server.mjs', 'hook.mjs', 'install.mjs',
31
+ ]);
32
+
14
33
  export function checkDevDrift(installDir, sourceFiles) {
15
34
  if (!existsSync(installDir)) {
16
35
  return { devMode: false, drift: false, symlinkCount: 0, plainCount: 0, plainFiles: [], missingCount: 0, details: [] };
@@ -34,6 +53,8 @@ export function checkDevDrift(installDir, sourceFiles) {
34
53
  // because there's nothing to drift from.)
35
54
  const devMode = symlinkFiles.length > 0;
36
55
  const drift = devMode && plainFiles.length > 0;
56
+ const missingEntry = missing.filter((rel) => ENTRY_POINTS.has(rel));
57
+ const missingModules = missing.filter((rel) => !ENTRY_POINTS.has(rel));
37
58
  return {
38
59
  devMode,
39
60
  drift,
@@ -42,6 +63,12 @@ export function checkDevDrift(installDir, sourceFiles) {
42
63
  plainFiles,
43
64
  missingCount: missing.length,
44
65
  missingFiles: missing.slice(0, 5),
66
+ // Split so the caller can grade by consequence rather than by count: an entry point is
67
+ // fatal in every install shape, an import-only module only in a copy install.
68
+ missingEntryCount: missingEntry.length,
69
+ missingEntryFiles: missingEntry.slice(0, 5),
70
+ missingModuleCount: missingModules.length,
71
+ missingModuleFiles: missingModules.slice(0, 5),
45
72
  details: plainFiles.slice(0, 5),
46
73
  };
47
74
  }
package/lib/get-core.mjs CHANGED
@@ -23,6 +23,21 @@ export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned'
23
23
  * @param {number[]} ids
24
24
  * @returns {object[]} full observation rows (SELECT *), created order
25
25
  */
26
+ export function supersededNotice(row) {
27
+ if (!row || !row.superseded_at) return null;
28
+ // Every LIST surface (search / recent / timeline / browse / injection) filters
29
+ // superseded rows out, so the only way to reach one is to name its id — which is
30
+ // exactly what a stale citation in a transcript, a note, or an old handoff does.
31
+ // Both detail faces render fields in OBS_FIELDS order, putting `lesson_learned`
32
+ // near the top and `superseded_at` ~15 lines below it: a reader taking the first
33
+ // actionable line away from `mem_get(1)` takes the RETRACTED advice and never
34
+ // reaches the marker. Hoist it to the header so the retraction is read first.
35
+ const by = typeof row.superseded_by === 'number' ? `#${row.superseded_by}` : null;
36
+ return by
37
+ ? `⚠ RETRACTED — superseded by ${by}. Read ${by} instead; the fields below are the withdrawn version.`
38
+ : '⚠ RETRACTED — superseded (auto-dedup or merge). The fields below are the withdrawn version.';
39
+ }
40
+
26
41
  export function fetchObsDetail(db, ids) {
27
42
  const ph = ids.map(() => '?').join(',');
28
43
  try {
@@ -102,11 +102,18 @@ function importToolPair(db, toolUse, toolResult, project) {
102
102
  const filesRead = toolName === 'Read' && toolUse.input?.file_path
103
103
  ? [toolUse.input.file_path] : [];
104
104
 
105
+ // `narrative` carries the body and `text` is the derived search blob
106
+ // (lib/observation-write.mjs rebuildObservationDerived). Writing the payload to `text`
107
+ // ONLY left every imported row outside that invariant, so a later `update` rebuilt
108
+ // `text` from a narrative that was empty and dropped the payload. Store the body in
109
+ // both: `narrative` as the durable home, `text` as the index copy the ingest paths
110
+ // write directly.
111
+ const body = `${inputJson}\n---\n${resultText}`;
105
112
  const safe = scrubRecord('observations', {
106
113
  title: `${toolName}: ${(toolUse.input?.command || toolUse.input?.file_path || '').slice(0, 80)}`,
107
114
  subtitle: '',
108
- text: `${inputJson}\n---\n${resultText}`,
109
- narrative: '',
115
+ text: body,
116
+ narrative: body,
110
117
  concepts: '',
111
118
  facts: '',
112
119
  lesson_learned: null,
@@ -265,6 +272,13 @@ export async function importJsonl(db, path, { project }) {
265
272
 
266
273
  // Orphan tool_use fallback: persist tool_use events that never paired with
267
274
  // a tool_result (truncated transcript / killed Claude Code session).
275
+ //
276
+ // `orphans` is a SUBSET counter, not a sibling of `observations`: each one writes a
277
+ // real observation row via the same importToolPair path. Counting it only under
278
+ // `orphans` made the summary say "+0 observations" for an import that had just
279
+ // written rows — a user backfilling a truncated transcript (the common shape, since
280
+ // the newest session is usually still open) read that as "nothing imported" and had
281
+ // no reason to run `recent`. The caller renders the subset relation explicitly.
268
282
  let orphans = 0;
269
283
  if (pendingToolUse.size > 0) {
270
284
  const tx2 = db.transaction(() => {
@@ -273,7 +287,7 @@ export async function importJsonl(db, path, { project }) {
273
287
  content: '[tool_use without result — transcript truncated]',
274
288
  timestamp: useEv.timestamp,
275
289
  };
276
- if (tryImportToolPair(useEv, fauxResult)) orphans++;
290
+ if (tryImportToolPair(useEv, fauxResult)) { orphans++; observations++; }
277
291
  }
278
292
  });
279
293
  tx2();
@@ -86,12 +86,76 @@ export function insertObservationVector(db, obsId, vecText) {
86
86
  */
87
87
  // P2-12: internal-only since applyObsUpdate became the single update choke point
88
88
  // (both faces previously imported this directly; un-exported per knip discipline).
89
+ /**
90
+ * The `text` search blob a row's columns imply, for a given narrative. Factored out so the
91
+ * rebuild can ask "is `text` already what I would derive?" with the exact same expression
92
+ * it writes — a second, drifting copy of the concatenation would defeat the check.
93
+ * @param {object} row observations row (title/subtitle/concepts/facts/lesson_learned/search_aliases)
94
+ * @param {string} narrative narrative to derive with ('' probes the already-derived shape)
95
+ * @returns {string}
96
+ */
97
+ function derivedText(row, narrative) {
98
+ const base = [row.title, row.subtitle, narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases]
99
+ .filter(Boolean).join(' ');
100
+ const bigrams = cjkBigrams((row.title || '') + ' ' + (narrative || ''));
101
+ return bigrams ? base + ' ' + bigrams : base;
102
+ }
103
+
104
+ /**
105
+ * Does `text` look like an already-derived search blob rather than an orphaned body?
106
+ *
107
+ * Byte-equality against derivedText() cannot answer this: the OTHER producer of these rows
108
+ * (hook-llm.mjs buildFtsTextField) joins concepts + facts + aliases + bigrams and omits
109
+ * title and narrative entirely, so its output never equals this module's concatenation.
110
+ * What both derived shapes DO share is that every token comes from the row's own
111
+ * enrichment fields. A real body — an import-jsonl tool payload, a user's prose — carries
112
+ * tokens found nowhere else on the row. So: all-known ⇒ derived ⇒ do not promote.
113
+ * The title-only case falls out of the same test.
114
+ * @param {object} row observations row
115
+ * @returns {boolean}
116
+ */
117
+ function looksAlreadyDerived(row) {
118
+ const text = String(row.text || '').trim();
119
+ if (!text) return true;
120
+ const known = new Set(
121
+ ([row.title, row.subtitle, row.concepts, row.facts, row.lesson_learned, row.search_aliases]
122
+ .filter(Boolean).join(' ') + ' ' + cjkBigrams(String(row.title || '')))
123
+ .split(/\s+/).filter(Boolean)
124
+ );
125
+ const tokens = text.split(/\s+/).filter(Boolean);
126
+ return tokens.length > 0 && tokens.every((t) => known.has(t));
127
+ }
128
+
89
129
  function rebuildObservationDerived(db, obsId) {
90
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
130
+ const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases, text FROM observations WHERE id = ?').get(obsId);
91
131
  if (!row) return;
92
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
93
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
94
- const textField = bigrams ? base + ' ' + bigrams : base;
132
+ // Deriving `text` from these columns is sound ONLY while `narrative` holds the body.
133
+ // Two ingest shapes break that: import-jsonl writes `narrative: ''` with the whole
134
+ // payload in `text`, and OBS_DEFAULTS defaults `narrative` to '' for any caller that
135
+ // omits it. On such a row the rebuild used to derive from a base with no body in it, so
136
+ // `update <id> --importance 3` — a field unrelated to content — replaced the payload
137
+ // with nothing but the row's own title. Unrecoverable: update takes no snapshot (only
138
+ // `delete` does), and the row also stopped matching searches for its own contents.
139
+ //
140
+ // Repair in place instead of guessing: promote the orphaned body into `narrative`, then
141
+ // derive. Content-preserving, and idempotent because the next rebuild sees a non-empty
142
+ // narrative.
143
+ //
144
+ // "Empty narrative" alone is NOT enough to conclude that `text` holds a body. A second
145
+ // production shape has an empty narrative legitimately: hook-llm.mjs (`narrative:
146
+ // obs.narrative || ''`), persistHaikuSummary and hook-optimize.mjs all write rows whose
147
+ // `text` is ALREADY the derived FTS blob — concepts + facts + aliases + CJK bigrams,
148
+ // which never contains a narrative. Promoting that blob would write bigram fragments
149
+ // ("构认", "证模") into a user-visible field rendered by both get faces, injected into
150
+ // context and fed to compress — irreversibly, since update takes no snapshot. Caught
151
+ // pre-tag by review; reproduced, then closed with looksAlreadyDerived() — see there for
152
+ // why byte-equality against derivedText() is the wrong test.
153
+ let narrative = row.narrative;
154
+ if ((!narrative || !narrative.trim()) && !looksAlreadyDerived(row)) {
155
+ narrative = row.text;
156
+ db.prepare('UPDATE observations SET narrative = ? WHERE id = ?').run(narrative, obsId);
157
+ }
158
+ const textField = derivedText(row, narrative);
95
159
  db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
96
160
  insertObservationVector(db, obsId, textField);
97
161
  }
package/mem-cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { resolveProject } from './project-utils.mjs';
9
9
  import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
10
10
  import { reRankWithContext } from './search-scoring.mjs';
11
11
  import { searchObservationsHybrid } from './search-engine.mjs';
12
- import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
12
+ import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
13
13
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
14
14
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
15
15
  import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
@@ -532,6 +532,9 @@ function renderObsRows(db, ids, requestedFields) {
532
532
  const parts = [];
533
533
  for (const r of rows) {
534
534
  const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
535
+ // Retraction first (shared with mem_get via get-core) — see supersededNotice.
536
+ const retracted = supersededNotice(r);
537
+ if (retracted) lines.push(retracted);
535
538
  for (const f of fields) {
536
539
  if (f === 'id' || f === 'type' || f === 'created_at') continue;
537
540
  const val = r[f];
@@ -1853,7 +1856,21 @@ function cmdRestore(db, argv) {
1853
1856
  const totalMalformed = malformed + parseFailures;
1854
1857
  const totalLines = rows.length + parseFailures;
1855
1858
  const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (keeper-absorbed or auto-retired tombstones)` : '';
1856
- out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1859
+ // Past tense only when rows were actually written. `Restore (dry-run): 6 restored` reads
1860
+ // as done to anyone skimming past the parenthetical, and this command's whole job is to
1861
+ // let a user check a backup BEFORE trusting it.
1862
+ out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} ${dryRun ? 'would be restored (at most)' : 'restored'}`
1863
+ + `, ${skipped} duplicate(s) ${dryRun ? 'would be skipped' : 'skipped'}${tombstoneNote}`
1864
+ + `, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1865
+ if (dryRun) {
1866
+ // The preview applies the durable exact-dup guard (project+title+created_at) but NOT
1867
+ // saveObservation's Jaccard near-duplicate collapse, which only exists once rows are
1868
+ // being written. Measured: a backup holding two same-titled weekly summaries previewed
1869
+ // 10 and restored 9. Simulating Jaccard here would mean a second copy of the dedup rule
1870
+ // — the drift class this codebase keeps paying for — so the number is labelled an upper
1871
+ // bound instead. Run without --dry-run for the exact count.
1872
+ out('[mem] Note: the preview does not simulate near-duplicate collapse, so the real run may restore fewer.');
1873
+ }
1857
1874
  // Name the lossiness where the user meets it. Export omits related_ids and drops
1858
1875
  // superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
1859
1876
  // cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
@@ -2823,6 +2840,8 @@ Commands:
2823
2840
  recent [N] Most recent events [--type T] [--project P]
2824
2841
  show <id> Show full event row by id
2825
2842
  delete <id1,id2,…> Delete events by ID (preview by default; use --confirm to execute)
2843
+ promote Promote insight-bearing events (body + importance>=2) to searchable
2844
+ observations (preview by default; use --execute to apply)
2826
2845
 
2827
2846
  Valid types: bugfix, lesson, bug, discovery, refactor, feature, observation, decision
2828
2847
  --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
@@ -2964,14 +2983,18 @@ async function cmdImportJsonl(db, argv) {
2964
2983
  totalSkip += r.skipped;
2965
2984
  totalOrphans += r.orphans || 0;
2966
2985
  totalRecognized += r.recognized || 0;
2967
- out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2986
+ out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations`
2987
+ + `${r.orphans ? ` (${r.orphans} from unpaired tool_use)` : ''}, ${r.skipped} skipped`);
2968
2988
  }
2969
2989
  const errorTail = errorCount > 0 ? `, ${errorCount} file(s) errored` : '';
2970
- out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2971
- if (totalPrompts > 0 || totalObs > 0 || totalOrphans > 0) {
2972
- // Orphan tool_use events persist as (truncated) observations, so they count as
2973
- // "something was imported" otherwise an orphan-only first import would wrongly
2974
- // fall through to the "already imported" no-op branch below.
2990
+ out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations`
2991
+ + `${totalOrphans ? ` (${totalOrphans} from unpaired tool_use)` : ''}`
2992
+ + `, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2993
+ if (totalPrompts > 0 || totalObs > 0) {
2994
+ // Orphan tool_use events persist as (truncated) observations and are counted INSIDE
2995
+ // totalObs (lib/import-jsonl.mjs), so they already count as "something was imported"
2996
+ // — an orphan-only first import must not fall through to the "already imported"
2997
+ // no-op branch below.
2975
2998
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2976
2999
  } else if (totalRecognized > 0) {
2977
3000
  // Lines WERE Claude Code transcript events but produced no new rows — the file
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.68.0",
3
+ "version": "3.69.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.68.0",
9
+ "version": "3.69.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.68.0",
3
+ "version": "3.69.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/project-utils.mjs CHANGED
@@ -12,6 +12,20 @@ const _cache = new Map();
12
12
  /**
13
13
  * Infer a sanitized project name from CLAUDE_PROJECT_DIR, PWD, or cwd.
14
14
  * Format: "parent--basename" with non-alphanumeric chars replaced by hyphens.
15
+ *
16
+ * Deliberately does NOT anchor on the git work-tree root. That was tried and reverted
17
+ * before it shipped: it fixes `cd src/auth && claude-mem-lite recent` (session rooted at
18
+ * the repo root, CLI run deeper) but BREAKS the mirror case, which is more common —
19
+ * CLAUDE_PROJECT_DIR is the directory Claude Code was started in, not the repo root, so
20
+ * `cd packages/api && claude` makes hooks write `packages--api` while a plain terminal in
21
+ * the same directory would walk to the work-tree root and read `mono--monorepo`.
22
+ * Reproduced pre-tag: hooks saved to `packages--api`, `recent` answered
23
+ * `No recent observations (h1--mono)`. Splitting a monorepo user's namespace silently is
24
+ * worse than the subdirectory case, and unlike it, cwd-derivation at least keeps the hook
25
+ * and CLI faces agreeing whenever the session root and cwd match. A correct fix has to
26
+ * consult the DB for which candidate actually holds rows; this module is DB-free and on
27
+ * the hook hot path, so it is not the place. Tracked as deferred work.
28
+ *
15
29
  * @returns {string} Sanitized project identifier safe for use in filenames
16
30
  */
17
31
  export function inferProject() {
@@ -19,6 +19,13 @@ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/rerea
19
19
  import { recordMetric } from '../lib/metrics.mjs';
20
20
  import { presentIdents } from '../lib/lesson-idents.mjs';
21
21
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
22
+ // Recall queries the SAVE-path project, so this MUST produce the same string as the
23
+ // save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
24
+ // drifted once (missing the process.env.PWD fallback, so a symlinked project dir
25
+ // recalled nothing) and would have drifted again when the 2026-08-17 e2e round taught inferProject to
26
+ // anchor on the git work-tree root. project-utils.mjs is a leaf module over path/fs/os
27
+ // only — cheaper than several imports this script already carries.
28
+ import { inferProject } from '../project-utils.mjs';
22
29
 
23
30
  import { DAY_MS } from '../lib/time-constants.mjs';
24
31
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
@@ -127,21 +134,6 @@ async function bridgeTopLesson(rows, changeText) {
127
134
 
128
135
  // ─── Helpers ────────────────────────────────────────────────────────────────
129
136
 
130
- // SYNC: must produce the SAME string as utils.mjs::inferProject (the path obs are SAVED
131
- // under) and the bash post-tool-use.sh fast-path — recall queries the SAVE-path project.
132
- // This previously used process.cwd() WITHOUT the process.env.PWD fallback the other two
133
- // have, so under a symlinked project dir (PWD = logical/symlinked path, cwd = resolved)
134
- // with CLAUDE_PROJECT_DIR unset it computed a DIFFERENT project than the save path and
135
- // silently recalled nothing. Resolution order + sanitize + 100-char cap now match utils.
136
- function inferProject() {
137
- const dir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
138
- const base = basename(dir);
139
- const parent = basename(join(dir, '..'));
140
- const raw = (parent && parent !== '.' && parent !== '/')
141
- ? `${parent}--${base}` : base;
142
- return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
143
- }
144
-
145
137
  function readCooldown(cooldownPath) {
146
138
  try { return JSON.parse(readFileSync(cooldownPath, 'utf8')); } catch { return {}; }
147
139
  }
@@ -144,12 +144,35 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
144
144
  // one was dropped by the OR floor at |bm25| 3.8–15.2 < 30. The plugin is inert
145
145
  // during exactly the window where a new user decides whether it earns its keep.
146
146
  //
147
- // Fix: scale both floors by ln(N+1)/ln(N_REF+1), capped at 1.0 the same log
148
- // shape the IDF term has, so the SIGNAL↔NOISE separation the maintainer measured
147
+ // Fix: scale both floors by the corpus's MAX ATTAINABLE IDF over the reference
148
+ // corpus's, capped at 1.0, so the SIGNAL↔NOISE separation the maintainer measured
149
149
  // (signal ≥41, noise ≤22 at N_REF) is preserved proportionally at any N. At
150
150
  // N ≥ N_REF the factor is exactly 1.0, so every established install keeps
151
151
  // byte-identical behavior; only genuinely-new installs relax.
152
152
  //
153
+ // 2026-08-17 e2e round — the ramp shape. The first cut used ln(N+1)/ln(N_REF+1), which has the
154
+ // right asymptotics but the wrong small-N behavior: FTS5's IDF term is
155
+ // `log((N - df + 0.5) / (df + 0.5))`, which is EXACTLY 0 at N=2/df=1 and stays far
156
+ // below ln(N+1) for the whole first-week window. Re-measured end-to-end through the
157
+ // production write path (lib/save-observation.mjs — a raw INSERT skips CJK bigram
158
+ // expansion and understates the corpus, which is how the first cut's ramp table was
159
+ // misread), 1 planted target + topically clustered filler, CJK prose prompt carrying
160
+ // no identifier for the bypass to rescue:
161
+ //
162
+ // N 2 3 4 5 6 10 25 80
163
+ // top|bm25| 0.0 5.1 7.0 9.5 11.4 15.5 22.2 30.3
164
+ // ln ramp floor 5.2 6.5 7.6 8.4 9.2 11.3 15.3 20.7 ← DROP at N≤4
165
+ // idf ramp floor 0.0 2.6 4.3 5.5 6.5 9.3 14.1 20.0 ← admits all
166
+ //
167
+ // The two ramps agree within 8% at N≥30 and within 2% at N≥200, so this re-shape is
168
+ // confined to the window it is meant to fix. Accepted tradeoff: on a ≤2-row corpus the
169
+ // scale is EXACTLY 0 (FTS5's max IDF is 0 there), which disables both set-level floors
170
+ // rather than lowering them, and just above that they are small; so the best lexical match
171
+ // is injected even when it is weak.
172
+ // That is the intended trade — the alternative measured behavior is total silence, and
173
+ // a 4-row corpus has no room to bury signal under noise. The upstream
174
+ // hasExplicitSignal gate, not these floors, is what suppresses noise prompts.
175
+ //
153
176
  // N counts the WHOLE observations table, not the project: FTS5 computes IDF over
154
177
  // the entire index and `o.project = ?` is a post-MATCH filter. Verified — a
155
178
  // 2-row project on a 302-row install scores 31.5, matching the 300-row global
@@ -158,7 +181,25 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
158
181
  const FLOOR_REF_CORPUS = Number(process.env.CLAUDE_MEM_UPS_FLOOR_REF_CORPUS || 584);
159
182
 
160
183
  /**
161
- * Scale factor in (0, 1] for the absolute score floors, by total corpus size.
184
+ * FTS5's IDF term for the best case a query can hit: a term appearing in exactly
185
+ * one row (df=1) of an n-row index. SQLite computes
186
+ * `log((n - df + 0.5) / (df + 0.5))`, so this is the ceiling any single-term bm25
187
+ * contribution can reach at corpus size n — the quantity the absolute floors are
188
+ * implicitly denominated in. Clamped at 0: below n=2 the formula goes negative,
189
+ * which as a scale would flip the comparison rather than relax it.
190
+ * @param {number} n Row count.
191
+ * @returns {number} Max attainable IDF at this corpus size, ≥ 0.
192
+ */
193
+ function maxIdf(n) {
194
+ // n <= 1 makes the numerator non-positive → Math.log returns NaN or -Infinity, and
195
+ // Math.max(0, NaN) is NaN, not 0. Short-circuit instead: a 0- or 1-row index has no
196
+ // term that can discriminate, so the max attainable IDF is 0.
197
+ if (!(n > 1)) return 0;
198
+ return Math.max(0, Math.log((n - 1 + 0.5) / 1.5));
199
+ }
200
+
201
+ /**
202
+ * Scale factor in [0, 1] for the absolute score floors, by total corpus size.
162
203
  *
163
204
  * Short-circuits with a bounded probe: if a row exists at offset N_REF-1 the
164
205
  * corpus is at or above the reference and the factor is 1.0 — no COUNT scan on
@@ -173,7 +214,12 @@ export function corpusFloorScale(db) {
173
214
  const atRef = db.prepare('SELECT 1 FROM observations LIMIT 1 OFFSET ?').get(FLOOR_REF_CORPUS - 1);
174
215
  if (atRef) return 1;
175
216
  const { c = 0 } = db.prepare('SELECT count(*) AS c FROM observations').get() || {};
176
- return Math.min(1, Math.log(c + 1) / Math.log(FLOOR_REF_CORPUS + 1));
217
+ const refIdf = maxIdf(FLOOR_REF_CORPUS);
218
+ // Degenerate reference (CLAUDE_MEM_UPS_FLOOR_REF_CORPUS set to 2 or 3, where
219
+ // maxIdf is 0 or near it): division would blow up or divide by zero. Treat the
220
+ // floors as fully calibrated, matching the FLOOR_REF_CORPUS <= 1 guard above.
221
+ if (!(refIdf > 0)) return 1;
222
+ return Math.min(1, maxIdf(c) / refIdf);
177
223
  } catch {
178
224
  // Any probe failure → behave exactly as before the ramp existed.
179
225
  return 1;
@@ -761,10 +807,22 @@ async function main() {
761
807
  // when CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=0 (bypass is default-on), then it is a no-op.
762
808
  const promptIdentifiers = IDENTIFIER_BYPASS ? extractTechIdentifiers(promptText) : [];
763
809
 
764
- if (intent?.useRecent) {
765
- // Recall intent: show recent observations
766
- rows = searchRecent(db, project, intent.limit);
767
- } else if (REQUIRE_EXPLICIT_SIGNAL && !signalPresent) {
810
+ // Recall intent ("之前 / previously / 记得 …") used to short-circuit straight to
811
+ // searchRecent, discarding the prompt text — so the most explicit memory request a
812
+ // user can make was the one answered without reading what they asked about. Measured
813
+ // on a 600-row corpus, two prompts one word apart: "分页接口又报 500 了,边界问题怎么处理"
814
+ // put the right row at rank 1, while "…之前那个边界问题是怎么处理的" surfaced NEITHER it
815
+ // nor anything related — 5 unrelated recency rows instead. Adding the recall keyword
816
+ // removed the answer and spent the injection budget on noise.
817
+ //
818
+ // Recency is still the right answer for a CONTENTLESS recall prompt ("之前我们在做什么"),
819
+ // which has no topic to match — so it becomes a FALLBACK (below) rather than a
820
+ // short-circuit. The explicit-signal gate needs no recall-intent carve-out:
821
+ // hasExplicitSignal already returns true whenever `intent` is truthy, and recall intent
822
+ // implies that, so a recall prompt cannot reach the no-signal branch. (An earlier draft
823
+ // added `!recentFallback &&` here; review showed the condition was dead.)
824
+ const recentFallback = Boolean(intent?.useRecent);
825
+ if (REQUIRE_EXPLICIT_SIGNAL && !signalPresent) {
768
826
  // No explicit signal — skip FTS pipeline + prompt-fallback. sigRows
769
827
  // is already empty (errSig was null else signalPresent would be true).
770
828
  // Registry skill pointer below remains unaffected (its own name match).
@@ -848,6 +906,13 @@ async function main() {
848
906
  rows = rows.slice(0, MAX_RESULTS);
849
907
  }
850
908
 
909
+ // Recall-intent fallback (see the `recentFallback` rationale above): only when the
910
+ // prompt named nothing the corpus matches. A contentless "之前我们在做什么" lands here
911
+ // and behaves exactly as it did before; a topical recall prompt no longer does.
912
+ if (rows.length === 0 && recentFallback) {
913
+ rows = searchRecent(db, project, intent.limit);
914
+ }
915
+
851
916
  // A (v2.32.8): prepend error-signature hits (higher precision), dedup, cap.
852
917
  if (sigRows.length > 0) {
853
918
  const sigIds = new Set(sigRows.map(r => r.id));
package/server.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from './lib/maintain-core.mjs';
24
24
  import { snapshotDb } from './lib/db-backup.mjs';
25
25
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
26
- import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
26
+ import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
27
27
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
28
28
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
29
29
  import { computeStatsFeed } from './lib/stats-core.mjs';
@@ -628,6 +628,9 @@ server.registerTool(
628
628
  for (const row of rows) {
629
629
  foundBySource.obs.add(row.id);
630
630
  const lines = [`── #${row.id} ──`];
631
+ // Retraction first (shared with the CLI `get` via get-core) — see supersededNotice.
632
+ const retracted = supersededNotice(row);
633
+ if (retracted) lines.push(retracted);
631
634
  for (const f of renderFields) {
632
635
  const val = row[f];
633
636
  if (val === null || val === undefined || val === '') continue;
@@ -826,6 +829,9 @@ server.registerTool(
826
829
  inputSchema: memSaveSchema,
827
830
  },
828
831
  safeHandler(async (args) => {
832
+ // `obs_type` → `type`: the sibling read tools all name it obs_type (see the schema
833
+ // comment). Without this, an unknown key was dropped and the row saved as `discovery`.
834
+ args = applyArgAliases(args, { obs_type: 'type' });
829
835
  if (args.project) args = { ...args, project: resolveProject(args.project) };
830
836
  const project = args.project || inferProject();
831
837
 
@@ -1661,6 +1667,8 @@ server.registerTool(
1661
1667
  inputSchema: memUpdateSchema,
1662
1668
  },
1663
1669
  safeHandler(async (args) => {
1670
+ // `obs_type` → `type`, same alias mem_save takes — see the schema comment.
1671
+ args = applyArgAliases(args, { obs_type: 'type' });
1664
1672
  const obs = db.prepare('SELECT id, title FROM observations WHERE id = ?').get(args.id);
1665
1673
  if (!obs) return { content: [{ type: 'text', text: `Observation #${args.id} not found` }], isError: true };
1666
1674
 
package/tool-schemas.mjs CHANGED
@@ -204,6 +204,13 @@ export const memSaveSchema = {
204
204
  content: z.string().min(1).max(50000).describe('Memory content to save'),
205
205
  title: z.string().optional().describe('Short title'),
206
206
  type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
207
+ // Alias, same treatment memRecentSchema gives `type`. mem_search/mem_recall/mem_recent
208
+ // all name this field `obs_type`, mem_save named it only `type`, and the schema is
209
+ // non-strict — so `mem_save(obs_type: "bugfix")` (the shape a caller reaches for right
210
+ // after a search) dropped the unknown key and saved a `discovery` row with no error.
211
+ // A silently-wrong type changes type_quality ranking AND makes the row invisible to
212
+ // every `--type bugfix` filter, so it must not be a silent coercion.
213
+ obs_type: OBS_TYPE_ENUM.optional().describe('Alias for `type` (parity with mem_search/mem_recent)'),
207
214
  project: z.string().optional().describe('Project name (default: inferred from CWD)'),
208
215
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
209
216
  files: coerceStringArray.optional().describe('File paths associated with this observation. Stored in the `files_modified` column and rendered as `files` — passing a path here does not assert the file was edited; a file you only read belongs here too'),
@@ -261,6 +268,11 @@ export const memUpdateSchema = {
261
268
  // '' would blank narrative/lesson/concepts irrecoverably (mem_update takes no snapshot).
262
269
  narrative: z.string().refine(s => s.trim() !== '', 'narrative cannot be empty').optional().describe('New narrative/content'),
263
270
  type: OBS_TYPE_ENUM.optional().describe('New observation type'),
271
+ // Same alias as memSaveSchema, for the same reason and found by the same review: without
272
+ // it `mem_update({id, importance: 3, obs_type: 'bugfix'})` reported
273
+ // "Updated observation #N: importance" and dropped the type silently. (obs_type alone
274
+ // errored loudly with "No fields to update", so only the mixed call was dangerous.)
275
+ obs_type: OBS_TYPE_ENUM.optional().describe('Alias for `type` (parity with mem_search/mem_recent)'),
264
276
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
265
277
  // 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
266
278
  // that let overlong lessons leak into the DB via MCP.