claude-mem-lite 2.97.0 → 2.98.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": "2.97.0",
13
+ "version": "2.98.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": "2.97.0",
3
+ "version": "2.98.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"
@@ -9,7 +9,7 @@
9
9
  // + vector writes in one db.transaction so a failure can't leave a partial row).
10
10
 
11
11
  import { getVocabulary, computeVector } from '../tfidf.mjs';
12
- import { debugCatch } from '../utils.mjs';
12
+ import { debugCatch, cjkBigrams } from '../utils.mjs';
13
13
 
14
14
  // Canonical column order — must mirror the observations schema (schema.mjs).
15
15
  const OBS_COLUMNS = [
@@ -65,3 +65,20 @@ export function insertObservationVector(db, obsId, vecText) {
65
65
  .run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
66
66
  } catch (e) { debugCatch(e, 'insertObservationVector'); }
67
67
  }
68
+
69
+ /**
70
+ * Rebuild an observation's derived columns after a field UPDATE: the FTS `text`
71
+ * column (incl. CJK bigrams + search_aliases, matching the ingest paths) and the
72
+ * TF-IDF vector. cmdUpdate (CLI) and mem_update (MCP) previously hand-copied this
73
+ * block — the same drift class #8614/#8639 closed for compress/maintain. Caller
74
+ * owns the transaction (vector write is internally non-critical).
75
+ */
76
+ export function rebuildObservationDerived(db, obsId) {
77
+ const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
78
+ if (!row) return;
79
+ const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
80
+ const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
81
+ const textField = bigrams ? base + ' ' + bigrams : base;
82
+ db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
83
+ insertObservationVector(db, obsId, textField);
84
+ }
@@ -0,0 +1,43 @@
1
+ // Single source of truth for file-keyed recall: the observation_files junction
2
+ // query, LIKE-wildcard escaping, noise filtering, and the access-count bump.
3
+ // cmdRecall (mem-cli.mjs) and mem_recall (server.mjs) previously hand-copied all
4
+ // four — the drift class that produced the mem_get formatter drift (#8678) and
5
+ // the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
6
+ // contract lives here.
7
+
8
+ import { basename } from 'path';
9
+ import { notLowSignalTitleClause } from '../utils.mjs';
10
+
11
+ /**
12
+ * Recall observations linked to a file (basename or full path). Returns
13
+ * { filename, rows } where rows carry the column superset both surfaces render.
14
+ * Side effect: bumps access_count / last_accessed_at on every returned row —
15
+ * recall IS engagement, and the tier/decay system feeds on these counters.
16
+ */
17
+ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
18
+ const filename = basename(file);
19
+ const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
20
+ const likePattern = `%${escaped}`;
21
+ const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
22
+ const rows = db.prepare(`
23
+ SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
24
+ o.created_at, o.created_at_epoch, o.project
25
+ FROM observations o
26
+ JOIN observation_files of2 ON of2.obs_id = o.id
27
+ WHERE COALESCE(o.compressed_into, 0) = 0
28
+ AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
29
+ ${noiseClause}
30
+ ORDER BY o.created_at_epoch DESC
31
+ LIMIT ?
32
+ `).all(filename, likePattern, limit);
33
+
34
+ if (rows.length > 0) {
35
+ const ph = rows.map(() => '?').join(',');
36
+ try {
37
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`)
38
+ .run(Date.now(), ...rows.map(r => r.id));
39
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
40
+ }
41
+
42
+ return { filename, rows };
43
+ }
package/mem-cli.mjs CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, cjkBigrams, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
8
8
  import { cjkPrecisionOk } from './nlp.mjs';
9
9
  import { extractCjkLikePatterns } from './nlp.mjs';
10
10
  import { resolveProject } from './project-utils.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
- import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
12
+ import { _resetVocabCache } from './tfidf.mjs';
13
13
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
14
  import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
@@ -27,7 +27,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
27
27
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
28
28
  import { auditMemdir, memdirPath } from './memdir.mjs';
29
29
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
30
- import { basename, join, sep } from 'path';
30
+ import { join, sep } from 'path';
31
31
  import { readFileSync, existsSync, readdirSync } from 'fs';
32
32
 
33
33
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
@@ -35,6 +35,8 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
35
35
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
36
36
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
37
37
  import { saveObservation } from './lib/save-observation.mjs';
38
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
39
+ import { recallByFile } from './lib/recall-core.mjs';
38
40
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
39
41
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
40
42
  import {
@@ -493,35 +495,12 @@ function cmdRecall(db, args) {
493
495
  return;
494
496
  }
495
497
 
496
- const filename = basename(file);
497
498
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
498
499
  const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
499
500
  const jsonOutput = flags.json === true || flags.json === 'true';
500
501
 
501
- // Search via observation_files junction table for indexed filename lookups
502
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
503
- const likePattern = `%${escaped}`;
504
- const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
505
- const rows = db.prepare(`
506
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
507
- o.created_at, o.created_at_epoch, o.project
508
- FROM observations o
509
- JOIN observation_files of2 ON of2.obs_id = o.id
510
- WHERE COALESCE(o.compressed_into, 0) = 0
511
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
512
- ${noiseClause}
513
- ORDER BY o.created_at_epoch DESC
514
- LIMIT ?
515
- `).all(filename, likePattern, limit);
516
-
517
- if (rows.length > 0) {
518
- // Update access_count for recalled observations (aligned with MCP mem_recall)
519
- const recalledIds = rows.map(r => r.id);
520
- const recallPh = recalledIds.map(() => '?').join(',');
521
- try {
522
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
523
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
524
- }
502
+ // Shared core with MCP mem_recall: query + escaping + access bump (lib/recall-core.mjs)
503
+ const { filename, rows } = recallByFile(db, file, { limit, includeNoise });
525
504
 
526
505
  if (jsonOutput) {
527
506
  out(JSON.stringify({
@@ -1708,28 +1687,11 @@ function cmdUpdate(db, args) {
1708
1687
 
1709
1688
  params.push(id);
1710
1689
 
1711
- // Atomic: update fields + rebuild FTS text + re-vectorize (aligned with MCP mem_update)
1690
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1691
+ // shared core — single source with MCP mem_update (lib/observation-write.mjs).
1712
1692
  db.transaction(() => {
1713
1693
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1714
-
1715
- // Rebuild FTS text field
1716
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
1717
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1718
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1719
- const textField = bigrams ? base + ' ' + bigrams : base;
1720
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
1721
-
1722
- // Re-vectorize (non-critical — catch to avoid rollback)
1723
- try {
1724
- const vocab = getVocabulary(db);
1725
- if (vocab) {
1726
- const vec = computeVector(textField, vocab);
1727
- if (vec) {
1728
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1729
- .run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
1730
- }
1731
- }
1732
- } catch { /* non-critical */ }
1694
+ rebuildObservationDerived(db, id);
1733
1695
  })();
1734
1696
 
1735
1697
  out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.97.0",
3
+ "version": "2.98.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",
@@ -68,6 +68,7 @@
68
68
  "lib/mem-override.mjs",
69
69
  "lib/save-observation.mjs",
70
70
  "lib/observation-write.mjs",
71
+ "lib/recall-core.mjs",
71
72
  "lib/compress-core.mjs",
72
73
  "lib/maintain-core.mjs",
73
74
  "lib/dedup-constants.mjs",
@@ -29,6 +29,15 @@ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
29
29
  // legacy global path so env-less test harnesses still behave.
30
30
  const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
31
31
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
32
+ // v2.98 salience forcing-function (#8651: verified injection only moved
33
+ // bug-reintroduction 100%→50% — the agent sees lessons and ignores them; the
34
+ // bottleneck is ACTING). Default ON: Edit/Write lesson blocks end with an ack
35
+ // directive, and Read→Edit re-surfaces the Read-time lesson IDs as a one-line
36
+ // ack nudge at the actual action point. CLAUDE_MEM_SALIENCE=legacy (or 0)
37
+ // restores the pre-v2.98 passive behavior.
38
+ const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
39
+ || process.env.CLAUDE_MEM_SALIENCE === '0';
40
+ const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
32
41
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
33
42
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
34
43
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
@@ -183,7 +192,35 @@ try {
183
192
  const cooldown = readCooldown(cooldownPath);
184
193
  const now = Date.now();
185
194
  if (isSessionScoped) {
186
- if (cooldown[filePath]) process.exit(0); // already recalled this file in-session
195
+ const entry = cooldown[filePath];
196
+ if (entry) {
197
+ // v2.98 salience: the old full-dedup meant a Read-injected lesson left the
198
+ // actual Edit with ZERO context at the action point — the most likely spot
199
+ // for #8651's "saw it, ignored it". When this Edit/Write follows a
200
+ // Read-mode injection that surfaced lessons, emit a one-line ack nudge
201
+ // naming the IDs (no lesson bodies — token cost stays minimal), then mark
202
+ // the entry handled so the next Edit is silent again. Entries without a
203
+ // mode field (pre-v2.98) are treated as already handled.
204
+ const seenIds = (typeof entry === 'object' && Array.isArray(entry.lessonIds))
205
+ ? entry.lessonIds : [];
206
+ const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
207
+ if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
208
+ const idList = seenIds.map(id => `#${id}`).join(', ');
209
+ process.stdout.write(JSON.stringify({
210
+ suppressOutput: true,
211
+ hookSpecificOutput: {
212
+ hookEventName: 'PreToolUse',
213
+ additionalContext: [
214
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
215
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
216
+ ].join('\n'),
217
+ },
218
+ }));
219
+ cooldown[filePath] = { ...entry, mode: 'edit' };
220
+ writeCooldown(cooldownPath, cooldown, isSessionScoped);
221
+ }
222
+ process.exit(0); // already recalled this file in-session
223
+ }
187
224
  } else {
188
225
  const ts = entryTimestamp(cooldown[filePath]);
189
226
  if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
@@ -329,6 +366,14 @@ try {
329
366
  lines.push(` #${r.id} [${r.type}] ${title}`);
330
367
  }
331
368
  }
369
+ // v2.98 salience: Edit/Write is the action point — close the block with an
370
+ // explicit ack directive instead of leaving the lessons as passive FYI
371
+ // (#8651: passive framing was ignored ~half the time even when on-topic).
372
+ // Read keeps the quiet form; its forcing-function fires at the later Edit
373
+ // via the Read→Edit ack nudge above.
374
+ if (!isRead && !SALIENCE_LEGACY) {
375
+ lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
376
+ }
332
377
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
333
378
  // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
334
379
  // of the cross-project audit: this "no prior lessons, remember to /lesson"
@@ -360,7 +405,10 @@ try {
360
405
  // v2.81: record the emitted lesson IDs so flushEpisode (hook.mjs) can
361
406
  // build the PostToolUse cite-back hint when the user actually edits the
362
407
  // file. Empty array on no-lesson branches keeps the schema uniform.
363
- cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id) };
408
+ // v2.98: mode records WHERE the injection happened so the Read→Edit ack
409
+ // nudge can distinguish "lessons seen passively at Read" from "already
410
+ // surfaced at an action point".
411
+ cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
364
412
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
365
413
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
366
414
  // file so the next UPS prompt skips them too. Always write, even on
package/server.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
- import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
8
+ import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
9
9
  import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
10
10
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
11
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
@@ -32,18 +32,20 @@ function descriptionOf(name) {
32
32
  return d;
33
33
  }
34
34
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
35
- import { basename, join, sep } from 'path';
35
+ import { join, sep } from 'path';
36
36
  import { homedir } from 'os';
37
37
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
38
38
  import { searchResources } from './registry-retriever.mjs';
39
39
  import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
40
40
  import { saveObservation } from './lib/save-observation.mjs';
41
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
42
+ import { recallByFile } from './lib/recall-core.mjs';
41
43
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
42
44
  import {
43
45
  insertDeferred, listOpenWithOrdinal, dropDeferred,
44
46
  resolveDeferredIds, closeDeferredItems,
45
47
  } from './lib/deferred-work.mjs';
46
- import { getVocabulary, _resetVocabCache, computeVector } from './tfidf.mjs';
48
+ import { _resetVocabCache } from './tfidf.mjs';
47
49
  import { createRequire } from 'module';
48
50
 
49
51
  const require = createRequire(import.meta.url);
@@ -1819,28 +1821,11 @@ server.registerTool(
1819
1821
 
1820
1822
  params.push(args.id);
1821
1823
 
1822
- // Atomic: update fields + rebuild FTS text + re-vectorize
1824
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1825
+ // shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
1823
1826
  db.transaction(() => {
1824
1827
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1825
-
1826
- // Rebuild FTS text field (must include CJK bigrams + search_aliases to match mem_save/hook-llm)
1827
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(args.id);
1828
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1829
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1830
- const textField = bigrams ? base + ' ' + bigrams : base;
1831
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, args.id);
1832
-
1833
- // Re-vectorize (non-critical — catch to avoid rollback)
1834
- try {
1835
- const vocab = getVocabulary(db);
1836
- if (vocab) {
1837
- const vec = computeVector(textField, vocab);
1838
- if (vec) {
1839
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1840
- .run(args.id, Buffer.from(vec.buffer), vocab.version, Date.now());
1841
- }
1842
- }
1843
- } catch (e) { debugCatch(e, 'mem_update-vector'); }
1828
+ rebuildObservationDerived(db, args.id);
1844
1829
  })();
1845
1830
 
1846
1831
  return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
@@ -1906,35 +1891,16 @@ server.registerTool(
1906
1891
  inputSchema: memRecallSchema,
1907
1892
  },
1908
1893
  safeHandler(async (args) => {
1909
- const filename = basename(args.file);
1910
- const limit = args.limit ?? 10;
1911
- const includeNoise = args.include_noise === true;
1912
-
1913
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
1914
- const likePattern = `%${escaped}`;
1915
- const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
1916
- const rows = db.prepare(`
1917
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
1918
- FROM observations o
1919
- JOIN observation_files of2 ON of2.obs_id = o.id
1920
- WHERE COALESCE(o.compressed_into, 0) = 0
1921
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
1922
- ${noiseClause}
1923
- ORDER BY o.created_at_epoch DESC
1924
- LIMIT ?
1925
- `).all(filename, likePattern, limit);
1894
+ // Shared core with CLI cmdRecall: query + escaping + access bump (lib/recall-core.mjs)
1895
+ const { filename, rows } = recallByFile(db, args.file, {
1896
+ limit: args.limit ?? 10,
1897
+ includeNoise: args.include_noise === true,
1898
+ });
1926
1899
 
1927
1900
  if (rows.length === 0) {
1928
1901
  return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
1929
1902
  }
1930
1903
 
1931
- // Update access_count for recalled observations
1932
- const recalledIds = rows.map(r => r.id);
1933
- const ph = recalledIds.map(() => '?').join(',');
1934
- try {
1935
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1936
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
1937
-
1938
1904
  const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
1939
1905
  for (const r of rows) {
1940
1906
  const lesson = r.lesson_learned ? `\n Lesson: ${truncate(r.lesson_learned, 100)}` : '';
package/source-files.mjs CHANGED
@@ -82,6 +82,7 @@ export const SOURCE_FILES = [
82
82
  // entry-point-reachable); missing it from the manifest would break ALL saves on
83
83
  // auto-update. Same single-source-of-truth pattern (see #8217).
84
84
  'lib/observation-write.mjs',
85
+ 'lib/recall-core.mjs',
85
86
  // Shared "compress old low-value observations into weekly summaries" core.
86
87
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
87
88
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
package/tool-schemas.mjs CHANGED
@@ -218,11 +218,15 @@ export const memMaintainSchema = {
218
218
 
219
219
  export const memUpdateSchema = {
220
220
  id: coerceInt.pipe(z.number().int().positive()).describe('Observation ID to update'),
221
- title: z.string().optional().describe('New title'),
221
+ // CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
222
+ // in every listing — reject here like the CLI does, instead of persisting it.
223
+ title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
222
224
  narrative: z.string().optional().describe('New narrative/content'),
223
225
  type: OBS_TYPE_ENUM.optional().describe('New observation type'),
224
226
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
225
- lesson_learned: z.string().optional().describe('Add or update lesson learned'),
227
+ // 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
228
+ // that let overlong lessons leak into the DB via MCP.
229
+ lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
226
230
  concepts: z.string().optional().describe('Space-separated concept tags'),
227
231
  };
228
232