claude-mem-lite 2.91.0 → 2.93.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.91.0",
13
+ "version": "2.93.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.91.0",
3
+ "version": "2.93.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/adopt-cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { join } from 'path';
15
15
  import {
16
16
  memdirPath, writePluginSection, removePluginSection,
17
17
  writePluginDoc, removePluginDoc,
18
- isAdopted, readMemoryIndex,
18
+ isAdopted, hasPluginState, readMemoryIndex,
19
19
  UserEditedError, BudgetExceededError,
20
20
  } from './memdir.mjs';
21
21
  import {
@@ -325,6 +325,7 @@ export function cmdUnadopt(args = []) {
325
325
 
326
326
  const all = hasFlag(args, '--all');
327
327
  const dryRun = hasFlag(args, '--dry-run');
328
+ const force = hasFlag(args, '--force');
328
329
  const targets = all
329
330
  ? listAllMemdirs().map((m) => m.memdir)
330
331
  : [memdirPath(detectCwd())];
@@ -334,23 +335,32 @@ export function cmdUnadopt(args = []) {
334
335
  return;
335
336
  }
336
337
 
337
- let removed = 0, absent = 0;
338
+ let removed = 0, absent = 0, skipped = 0;
338
339
  for (const memdir of targets) {
339
340
  if (dryRun) {
340
- const adopted = isAdopted(memdir, PLUGIN_SLUG);
341
- const action = adopted ? 'would-remove' : 'absent';
341
+ // Mirror the live foreign-content guard: a sentinel with no state sidecar would be
342
+ // skipped (not removed) unless --force, so dry-run must report it the same way.
343
+ const action = !isAdopted(memdir, PLUGIN_SLUG) ? 'absent'
344
+ : (hasPluginState(memdir, PLUGIN_SLUG) || force) ? 'would-remove'
345
+ : 'would-skip-foreign';
342
346
  log(`[unadopt --dry-run] ${memdir} → ${action}`);
343
- if (adopted) removed++; else absent++;
347
+ if (action === 'would-remove') removed++;
348
+ else if (action === 'would-skip-foreign') skipped++;
349
+ else absent++;
344
350
  continue;
345
351
  }
346
- const r = removePluginSection(memdir, PLUGIN_SLUG);
347
- removePluginDoc(memdir, PLUGIN_SLUG);
348
- if (r.action === 'removed') removed++;
352
+ const r = removePluginSection(memdir, PLUGIN_SLUG, { force });
353
+ if (r.action === 'removed') { removePluginDoc(memdir, PLUGIN_SLUG); removed++; }
354
+ else if (r.action === 'skipped-foreign') skipped++;
349
355
  else absent++;
350
356
  log(`[unadopt] ${memdir} → ${r.action}`);
351
357
  }
352
358
 
359
+ if (skipped > 0) {
360
+ log('[unadopt] skipped-foreign = a sentinel block with no plugin state file (not proven plugin-written).');
361
+ log('[unadopt] pass --force to remove it anyway.');
362
+ }
353
363
  log('');
354
364
  const verb = dryRun ? 'would remove' : 'removed';
355
- log(`[unadopt${dryRun ? ' --dry-run' : ''}] ${targets.length} target(s): ${removed} ${verb}, ${absent} absent`);
365
+ log(`[unadopt${dryRun ? ' --dry-run' : ''}] ${targets.length} target(s): ${removed} ${verb}, ${skipped} skipped-foreign, ${absent} absent`);
356
366
  }
package/bash-utils.mjs CHANGED
@@ -3,6 +3,38 @@
3
3
 
4
4
  import { basename } from 'path';
5
5
 
6
+ // Read/search commands whose output legitimately contains "error"-like keywords without
7
+ // being a failure. Matched against the PRIMARY command (see isReadOnlyCommand).
8
+ const SEARCH_VERBS = new Set([
9
+ 'grep', 'rg', 'ag', 'ack', 'cat', 'head', 'tail', 'less', 'more', 'find', 'locate', 'wc', 'file', 'which', 'type',
10
+ ]);
11
+ // Command prefixes that wrap the real command (env-assignments handled separately).
12
+ const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice', 'nohup', 'stdbuf', 'xargs']);
13
+ // git read subcommands whose output contains commit/log/match text, not failures.
14
+ const GIT_READ_SUBCMDS = new Set(['grep', 'log', 'show', 'diff', 'blame', 'ls-files', 'cat-file', 'whatchanged', 'shortlog', 'reflog', 'status']);
15
+
16
+ // True when the command's PRIMARY operation (left of the first pipe, past any
17
+ // env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
18
+ // `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
19
+ // anywhere") is what lets `npm run build 2>&1 | tail` stay an error while `sudo grep`,
20
+ // `git grep`, `cat f | head` are correctly exempt.
21
+ function isReadOnlyCommand(cmd) {
22
+ const primary = cmd.split('|')[0];
23
+ const toks = primary.trim().split(/\s+/).filter(Boolean);
24
+ let i = 0;
25
+ while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
26
+ const first = toks[i];
27
+ if (!first) return false;
28
+ if (SEARCH_VERBS.has(first)) return true;
29
+ return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]);
30
+ }
31
+
32
+ // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
33
+ // uniformly to both command-parsed paths and direct file_path/path/filePath fields.
34
+ function isExcludedPath(p) {
35
+ return p.startsWith('/dev/') || p.startsWith('/proc/') || p.startsWith('/tmp/');
36
+ }
37
+
6
38
  /**
7
39
  * Detect significance signals in a Bash command and its response.
8
40
  * Checks for errors, test runs, builds, git operations, and deployments.
@@ -12,9 +44,12 @@ import { basename } from 'path';
12
44
  */
13
45
  export function detectBashSignificance(input, response) {
14
46
  const cmd = (input.command || '').toLowerCase();
15
- // Skip error keyword matching when the command is a read/search operation
16
- // (grep output naturally contains matched keywords like "error")
17
- const isSearchCmd = /\b(grep|rg|ag|ack|cat|head|tail|less|more|find|locate|wc|file|which|type)\b/i.test(cmd);
47
+ // Skip error keyword matching only when the PRIMARY command is a read/search op (its
48
+ // output naturally contains "error"-like keywords that aren't failures). Anchored on the
49
+ // primary command — NOT "search verb appears anywhere" — so `npm run build 2>&1 | tail`
50
+ // stays a real failure while `sudo grep`, `git grep`, `git log --grep`, `cat f | head`
51
+ // remain exempt and `run-cat-tests` doesn't trip a substring match.
52
+ const isSearchCmd = isReadOnlyCommand(cmd);
18
53
  const looksLikeError = !isSearchCmd
19
54
  && /\berror\b|\bERR!|fail(ed|ure)?|exception|panic|traceback|errno|enoent|command not found/i.test(response)
20
55
  && response.length > 15;
@@ -38,7 +73,9 @@ export function detectBashSignificance(input, response) {
38
73
  const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
39
74
  || /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
40
75
  const isBuild = /\b(build|compile|tsc|webpack|vite|rollup|esbuild|make|cargo)\b/i.test(cmd);
41
- const isGit = /\bgit\s+(commit|merge|rebase|cherry-pick|push)\b/i.test(cmd);
76
+ // Allow intervening global git options (`-C <path>`, `-c k=v`, `--no-pager`, …) between
77
+ // `git` and the subcommand — `git -C /repo push` is the standard multi-repo/scripted form.
78
+ const isGit = /\bgit\s+(?:(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?)\s+)*(commit|merge|rebase|cherry-pick|push)\b/i.test(cmd);
42
79
  const isDeploy = /\b(deploy|docker|kubectl|terraform)\b/i.test(cmd);
43
80
  return {
44
81
  isError, isTest, isBuild, isGit, isDeploy,
@@ -92,6 +129,9 @@ export function extractErrorKeywords(cmd, response) {
92
129
  */
93
130
  export function extractFilePaths(input) {
94
131
  const paths = [];
132
+ // Direct fields (Edit/Write file_path) are kept unconditionally — an explicit edit to a
133
+ // /tmp path is real work the user chose to make, unlike a /tmp path that merely appears as
134
+ // a transient argument inside a Bash command (excluded as noise in the command branch below).
95
135
  if (input.file_path) paths.push(input.file_path);
96
136
  if (input.path) paths.push(input.path);
97
137
  if (input.filePath) paths.push(input.filePath);
@@ -101,7 +141,7 @@ export function extractFilePaths(input) {
101
141
  if (match) {
102
142
  for (const m of match) {
103
143
  const p = m.trim();
104
- if (!p.startsWith('/dev/') && !p.startsWith('/proc/') && !p.startsWith('/tmp/')
144
+ if (!isExcludedPath(p)
105
145
  // Skip single-component paths like /exit, /clear — likely slash commands, not files
106
146
  && (p.indexOf('/', 1) !== -1 || /\.\w+$/.test(p))) {
107
147
  paths.push(p);
package/cli/activity.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
 
11
11
  import { inferProject } from '../utils.mjs';
12
12
  import { resolveProject } from '../project-utils.mjs';
13
- import { parseArgs, out, fail } from './common.mjs';
14
- import { parseIntFlag } from '../lib/cli-flags.mjs';
13
+ import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
14
+ import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
15
15
 
16
16
  function formatActivityResults(rows) {
17
17
  if (!rows || rows.length === 0) return '(no events)';
@@ -31,6 +31,9 @@ export async function cmdActivity(db, args) {
31
31
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
32
32
 
33
33
  if (sub === 'save') {
34
+ // Reject value-less string flags before they reach saveEvent as a boolean `true`
35
+ // (#8470): bare --body / --title crashed with a raw "SQLite3 can only bind ..." error.
36
+ if (rejectBareStringFlags(flags, ['type', 'title', 'body', 'files', 'file', 'project'])) return;
34
37
  const type = flags.type || 'observation';
35
38
  if (!VALID_EVENT_TYPES.has(type)) {
36
39
  fail(`[mem] activity save: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
@@ -51,7 +54,9 @@ export async function cmdActivity(db, args) {
51
54
  const file_paths_merged = [...filesFromSingular, ...filesFromPlural];
52
55
  const file_paths = file_paths_merged.length > 0 ? file_paths_merged : null;
53
56
  const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
54
- if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
57
+ // isNumericToken first (mirrors cmdSave): bare parseInt coerces "3xyz"→3 and would
58
+ // persist a wrong importance that silently skews ranking. Float literals truncate (#8277).
59
+ if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
55
60
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
56
61
  return;
57
62
  }
@@ -112,7 +117,10 @@ export async function cmdActivity(db, args) {
112
117
  if (row) {
113
118
  out(JSON.stringify(row, null, 2));
114
119
  } else {
115
- out(`[mem] activity show: event #${id} Not found`);
120
+ // fail() (stderr + exit 1), matching the not-found contract of sibling commands
121
+ // (`get`, `activity delete`, `update`); previously stdout + exit 0, so scripts
122
+ // couldn't detect a missing event from the exit code.
123
+ fail(`[mem] activity show: event #${id} not found`);
116
124
  }
117
125
  return;
118
126
  }
package/cli/common.mjs CHANGED
@@ -54,6 +54,29 @@ export function fail(text) {
54
54
  process.exitCode = 1;
55
55
  }
56
56
 
57
+ /**
58
+ * Reject value-less `--flag` for string-valued flags. A bare trailing flag (or one
59
+ * immediately followed by another `--flag`) parses to boolean `true` (parseArgs above);
60
+ * that `true` then slips into code expecting a string and surfaces a raw
61
+ * `flags.x.split is not a function` / `SQLite3 can only bind ...` stacktrace (#8470).
62
+ * Returns true (and emits a clean `fail()`) when any listed key is a bare flag — the
63
+ * caller should `return` on true. Single source of the guard the update/registry paths
64
+ * previously inlined, so new string-flag commands stay consistent.
65
+ *
66
+ * @param {object} flags Parsed flags from parseArgs.
67
+ * @param {string[]} keys String-valued flag names to guard (without leading dashes).
68
+ * @returns {boolean} true if a bare flag was found and rejected.
69
+ */
70
+ export function rejectBareStringFlags(flags, keys) {
71
+ for (const key of keys) {
72
+ if (flags[key] === true) {
73
+ fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
57
80
  // ─── Time Formatting ─────────────────────────────────────────────────────────
58
81
 
59
82
  /** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
package/format-utils.mjs CHANGED
@@ -9,8 +9,19 @@
9
9
  */
10
10
  export function truncate(str, max = 80) {
11
11
  if (!str) return '';
12
+ // Defense-in-depth: a non-string (e.g. an LLM that returned title as an array/number)
13
+ // would throw `str.replace is not a function` and abort the caller. Coerce to '' rather
14
+ // than crash; the real type-guarding happens at the call site.
15
+ if (typeof str !== 'string') return '';
12
16
  str = str.replace(/\n/g, ' ').trim();
13
- return str.length > max ? str.slice(0, max - 1) + '\u2026' : str;
17
+ if (str.length <= max) return str;
18
+ // Never split a UTF-16 surrogate pair: slicing between the high and low half emits a
19
+ // lone surrogate (invalid UTF-16) that then gets persisted to the DB. If the last kept
20
+ // code unit is a high surrogate, drop it so we cut on a code-point boundary.
21
+ let end = max - 1;
22
+ const last = str.charCodeAt(end - 1);
23
+ if (last >= 0xD800 && last <= 0xDBFF) end--;
24
+ return str.slice(0, end) + '\u2026';
14
25
  }
15
26
 
16
27
  /**
package/hook-handoff.mjs CHANGED
@@ -446,13 +446,31 @@ function renderHandoffFromRow(handoff, db, project) {
446
446
 
447
447
  lines.push('</session-handoff>');
448
448
 
449
- // Append session summary if available (long-gap enrichment)
449
+ // Append session summary if available (long-gap enrichment).
450
+ // session_summaries is keyed by the mem-internal memory_session_id, but in production
451
+ // session_handoffs.session_id holds the Claude Code UUID (the scope tag) — the two id
452
+ // namespaces never match, so the exact lookup returned nothing and this block was always
453
+ // dropped on a real resume. There is no bridge column (the CC-UUID lives on user_prompts,
454
+ // not on sdk_sessions/session_summaries), so: try the exact id match first (correct when
455
+ // ids align — legacy rows + tests), then fall back to the most-recent summary for the
456
+ // project, which at resume time is the summary from the session that wrote this handoff.
450
457
  try {
451
- const summary = db.prepare(`
458
+ let summary = db.prepare(`
452
459
  SELECT completed, next_steps, remaining_items FROM session_summaries
453
460
  WHERE memory_session_id = ? AND project = ?
454
461
  ORDER BY created_at_epoch DESC LIMIT 1
455
462
  `).get(handoff.session_id, project);
463
+ if (!summary) {
464
+ // Pick the project summary CLOSEST IN TIME to this handoff, not merely the newest:
465
+ // a handoff and its own session's summary are written within ms of each other at
466
+ // session end, so nearest-timestamp recovers the right session even when a different
467
+ // session later wrote a newer summary for the same project (concurrent/interleaved use).
468
+ summary = db.prepare(`
469
+ SELECT completed, next_steps, remaining_items FROM session_summaries
470
+ WHERE project = ?
471
+ ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
472
+ `).get(project, handoff.created_at_epoch ?? 0);
473
+ }
456
474
  if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
457
475
  lines.push('');
458
476
  lines.push('<session-summary source="haiku">');
package/hook-llm.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { scrubRecord } from './lib/scrub-record.mjs';
14
14
  import { getVocabulary, computeVector } from './tfidf.mjs';
15
+ import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
15
16
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
16
17
  import {
17
18
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -209,48 +210,23 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
209
210
  search_aliases: obs.searchAliases || null,
210
211
  });
211
212
 
212
- // Atomic: observation INSERT + observation_files + vector in one transaction
213
+ // Atomic: observation INSERT + observation_files + vector in one transaction.
214
+ // Column list single-sourced in lib/observation-write (shared with manual mem_save).
213
215
  const savedId = db.transaction(() => {
214
- const result = db.prepare(`
215
- INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
216
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
217
- `).run(
218
- sessionId, project,
219
- safe.text, obs.type, safe.title, safe.subtitle,
220
- safe.narrative,
221
- safe.concepts,
222
- safe.facts,
223
- JSON.stringify(obs.filesRead || []),
224
- JSON.stringify(obs.files || []),
225
- obs.importance ?? 1,
226
- minhashSig,
227
- safe.lesson_learned,
228
- safe.search_aliases,
229
- getCurrentBranch(),
230
- now.toISOString(), now.getTime()
231
- );
232
- const id = Number(result.lastInsertRowid);
233
-
234
- // Populate observation_files junction table
235
- if (id && obs.files && obs.files.length > 0) {
236
- const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
237
- for (const f of obs.files) {
238
- if (typeof f === 'string' && f.length > 0) insertFile.run(id, f);
239
- }
240
- }
216
+ const id = insertObservationRow(db, {
217
+ memory_session_id: sessionId, project, text: safe.text, type: obs.type,
218
+ title: safe.title, subtitle: safe.subtitle, narrative: safe.narrative,
219
+ concepts: safe.concepts, facts: safe.facts,
220
+ files_read: JSON.stringify(obs.filesRead || []),
221
+ files_modified: JSON.stringify(obs.files || []),
222
+ importance: obs.importance ?? 1, minhash_sig: minhashSig,
223
+ lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
224
+ branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
225
+ });
241
226
 
242
- // Write TF-IDF vector (non-critical catch inside transaction to avoid rollback)
243
- try {
244
- const vocab = getVocabulary(db);
245
- if (vocab) {
246
- const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
247
- const vec = computeVector(vecText, vocab);
248
- if (vec) {
249
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
250
- .run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
251
- }
252
- }
253
- } catch (e) { debugCatch(e, 'saveObservation-vector'); }
227
+ insertObservationFiles(db, id, obs.files);
228
+ const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
229
+ insertObservationVector(db, id, vecText);
254
230
 
255
231
  return id;
256
232
  })();
@@ -681,7 +657,12 @@ ${actionList}`;
681
657
  releaseLLMSlot();
682
658
  }
683
659
 
684
- if (parsed && parsed.title) {
660
+ // Require a STRING title: a truthy non-string (LLM returned title as an array/number/
661
+ // object) would pass a bare `parsed.title` check, then crash truncate() downstream,
662
+ // aborting the worker before tmpFile cleanup (leak) and leaving the obs degraded.
663
+ if (parsed && typeof parsed.title === 'string' && parsed.title) {
664
+ // Normalize narrative to a string too — same non-string crash risk in truncate().
665
+ if (typeof parsed.narrative !== 'string') parsed.narrative = '';
685
666
  // Discard if LLM judges observation has no learning value
686
667
  if (parsed.importance === 0 || parsed.importance === '0') {
687
668
  debugLog('DEBUG', 'llm-episode', `Discarded low-value observation: ${parsed.title}`);
package/hook-optimize.mjs CHANGED
@@ -262,7 +262,7 @@ Rules:
262
262
  }
263
263
  }
264
264
 
265
- export function applyNormalization(db, groups) {
265
+ export function applyNormalization(db, groups, { project = null } = {}) {
266
266
  if (!groups || groups.length === 0) return { updated: 0 };
267
267
 
268
268
  const aliasMap = new Map();
@@ -272,11 +272,17 @@ export function applyNormalization(db, groups) {
272
272
  }
273
273
  }
274
274
 
275
+ // Scope the mutation to `project` when normalize was scoped (v2.72.0 --project).
276
+ // Without this, synonym groups derived from ONE project's concepts rewrote the
277
+ // concepts/search_aliases of EVERY project's observations — the exact cross-project
278
+ // contamination the --project flag was added to prevent. NULL → all projects (legacy
279
+ // unscoped run), matching the search-engine `(? IS NULL OR project = ?)` idiom.
275
280
  const rows = db.prepare(`
276
281
  SELECT id, concepts, search_aliases FROM observations
277
282
  WHERE COALESCE(compressed_into, 0) = 0
278
283
  AND concepts IS NOT NULL AND concepts != ''
279
- `).all();
284
+ AND (? IS NULL OR project = ?)
285
+ `).all(project, project);
280
286
 
281
287
  let updated = 0;
282
288
  const updateStmt = db.prepare(`
@@ -322,7 +328,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
322
328
  const groups = await identifySynonymGroups(concepts);
323
329
  if (groups.length === 0) return { processed: 0, groups: 0 };
324
330
 
325
- const result = applyNormalization(db, groups);
331
+ const result = applyNormalization(db, groups, { project });
326
332
 
327
333
  try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch {}
328
334
 
@@ -340,7 +346,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
340
346
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
341
347
  const projectClause = project ? 'AND project = ?' : '';
342
348
  const stmt = db.prepare(`
343
- SELECT id, title, narrative, project, type, access_count, created_at_epoch, minhash_sig
349
+ SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig
344
350
  FROM observations
345
351
  WHERE COALESCE(compressed_into, 0) = 0
346
352
  AND optimized_at IS NULL
@@ -410,10 +416,19 @@ Return ONLY valid JSON:
410
416
  const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
411
417
  if (!parsed || !parsed.should_merge) return { merged: false };
412
418
 
413
- const keeper = cluster.reduce((best, o) =>
414
- (o.access_count || 0) > (best.access_count || 0) ? o : best
415
- , cluster[0]);
419
+ // Keeper = highest importance, then highest access_count. Previously access_count
420
+ // alone, so a critical (importance=3) but never-accessed observation lost the keeper
421
+ // role to a trivial (importance=1) accessed one and was compressed away.
422
+ const keeper = cluster.reduce((best, o) => {
423
+ const oi = o.importance || 1, bi = best.importance || 1;
424
+ if (oi !== bi) return oi > bi ? o : best;
425
+ return (o.access_count || 0) > (best.access_count || 0) ? o : best;
426
+ }, cluster[0]);
416
427
  const others = cluster.filter(o => o.id !== keeper.id);
428
+ // Floor the merged importance at the cluster max — merging must never silently
429
+ // downgrade the ranking of the most-important member (the LLM default is 2). The keeper
430
+ // is selected by importance-first, so keeper.importance IS the cluster max by construction.
431
+ const maxClusterImportance = keeper.importance || 1;
417
432
 
418
433
  const concepts = Array.isArray(parsed.merged_concepts) ? parsed.merged_concepts.slice(0, 10) : [];
419
434
  const facts = Array.isArray(parsed.merged_facts) ? parsed.merged_facts.slice(0, 10) : [];
@@ -428,7 +443,7 @@ Return ONLY valid JSON:
428
443
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
429
444
  const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
430
445
  const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
431
- const importance = clampImportance(parsed.importance || 2);
446
+ const importance = Math.max(clampImportance(parsed.importance || 2), maxClusterImportance);
432
447
 
433
448
  // Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
434
449
  // importance is numeric; minhash_sig is hash bytes.
package/hook-update.mjs CHANGED
@@ -27,7 +27,10 @@ const STATE_DIR = DB_DIR;
27
27
  const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
28
28
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
29
29
  const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
30
- const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
30
+ // When rate-limited we got NO release data, so re-check sooner than the normal 24h
31
+ // cadence (GitHub's unauthenticated rate-limit window resets within the hour). 6h × ≤2
32
+ // requests = 4 polls/day, far under the 60/hr limit, so this is a faster retry, not a hammer.
33
+ const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h retry when rate-limited
31
34
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
32
35
 
33
36
  // ── Main Entry ─────────────────────────────────────────────
@@ -57,7 +60,12 @@ export async function checkForUpdate(options = {}) {
57
60
 
58
61
  const latest = await fetchLatestRelease();
59
62
  if (!latest) {
60
- saveState({ ...state, lastCheck: new Date().toISOString() });
63
+ // Re-read from disk: a 403 inside fetchWithTimeout just persisted rateLimited:true.
64
+ // Spreading the stale in-memory `state` (captured above with rateLimited:false) would
65
+ // clobber that flag back to false, so shouldCheck never honors the backoff and the
66
+ // rate-limit mechanism is dead. Re-reading preserves the freshly-written flag.
67
+ const fresh = readState();
68
+ saveState({ ...fresh, lastCheck: new Date().toISOString() });
61
69
  return null;
62
70
  }
63
71
 
@@ -174,7 +182,10 @@ async function fetchLatestRelease() {
174
182
  headers,
175
183
  );
176
184
  if (result === 'rate-limited') return null;
177
- if (result) {
185
+ // Guard tag_name: a 200-OK with a malformed body ({} / {tag_name:null}) would throw
186
+ // `Cannot read properties of undefined (reading 'replace')`. Caught upstream, but it
187
+ // poisons lastError and blocks the tags fallback below — fall through instead.
188
+ if (result && typeof result.tag_name === 'string') {
178
189
  return {
179
190
  version: result.tag_name.replace(/^v/, ''),
180
191
  tarballUrl: result.tarball_url,
@@ -188,7 +199,7 @@ async function fetchLatestRelease() {
188
199
  headers,
189
200
  );
190
201
  if (tags === 'rate-limited') return null;
191
- if (Array.isArray(tags) && tags.length > 0) {
202
+ if (Array.isArray(tags) && tags.length > 0 && typeof tags[0]?.name === 'string') {
192
203
  const tag = tags[0];
193
204
  return {
194
205
  version: tag.name.replace(/^v/, ''),
@@ -208,7 +219,7 @@ async function fetchWithTimeout(url, headers) {
208
219
  if (res.status === 403) {
209
220
  const state = readState();
210
221
  saveState({ ...state, rateLimited: true });
211
- debugLog('DEBUG', 'hook-update', 'GitHub API rate limited, extending interval');
222
+ debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
212
223
  return 'rate-limited';
213
224
  }
214
225
  if (!res.ok) return null;
package/hook.mjs CHANGED
@@ -202,13 +202,20 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
202
202
  // bugfix-shape nudge above and may co-fire.
203
203
  const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
204
204
  if (citeBack) lines.push(citeBack);
205
+ // Trailing newline is REQUIRED: when this receipt flushes at SessionStart
206
+ // (leftover episode after /clear or /compact), the startup dashboard writes a
207
+ // second hookSpecificOutput object right after. Without the '\n' the two land
208
+ // back-to-back as `}{` on one line and Claude Code's line-based JSON parser
209
+ // drops both — losing the episode-flush / cite-back context exactly at the
210
+ // session boundary. Every other hookSpecificOutput write appends '\n'; this
211
+ // was the lone exception.
205
212
  process.stdout.write(JSON.stringify({
206
213
  suppressOutput: true,
207
214
  hookSpecificOutput: {
208
215
  hookEventName,
209
216
  additionalContext: lines.join('\n'),
210
217
  },
211
- }));
218
+ }) + '\n');
212
219
  } catch { /* never block on receipt */ }
213
220
  }
214
221
  } else {
@@ -492,6 +492,18 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
492
492
  decay_seen_count = decay_seen_count + 1
493
493
  WHERE id = ?
494
494
  `);
495
+ // Suppressed (non-adopting) projects never demote, so uncited_streak would grow
496
+ // UNBOUNDED — and citeFactorClause penalizes -0.25*streak (floor 0.4), pinning every
497
+ // memory at the ranking floor with no recovery path. Cap at UNCITED_STREAK_THRESHOLD-1
498
+ // to hold the [0, threshold-1] steady state the scoring header asserts (in an adopting
499
+ // project the streak resets to 0 on demote, so the STORED value never exceeds 2).
500
+ const updateStreakCapped = db.prepare(`
501
+ UPDATE observations
502
+ SET uncited_streak = MIN(uncited_streak + 1, ?),
503
+ last_decided_session_id = ?,
504
+ decay_seen_count = decay_seen_count + 1
505
+ WHERE id = ?
506
+ `);
495
507
  const updateDemote = db.prepare(`
496
508
  UPDATE observations
497
509
  SET importance = MAX(?, importance - 1),
@@ -520,6 +532,9 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
520
532
  if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
521
533
  updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
522
534
  demoted++;
535
+ } else if (suppressDemotion) {
536
+ // Never-demoting project: cap the streak so cite_factor can't sink to floor.
537
+ updateStreakCapped.run(UNCITED_STREAK_THRESHOLD - 1, sessionId, id);
523
538
  } else {
524
539
  updateStreakOnly.run(sessionId, id);
525
540
  }