claude-mem-lite 3.74.1 → 3.75.1

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,10 +10,14 @@
10
10
  // column UPDATE including access_count. Per-row UPDATEs wrapped in try-catch
11
11
  // to prevent SQLITE_CORRUPT_VTAB cascades from stopping the whole scan.
12
12
 
13
- import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
13
+ import { readFileSync, readdirSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { debugCatch } from '../utils.mjs';
16
16
  import { keyContextIdsFileName } from './injected-ids.mjs';
17
+ import { readTranscriptEntries } from './transcript-scan.mjs';
18
+ // The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
19
+ // than re-typing the framing is what keeps emit and extract from becoming two lists.
20
+ import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
17
21
 
18
22
  import { DAY_MS } from './time-constants.mjs';
19
23
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
@@ -32,13 +36,7 @@ const CITATION_RE = /#(\d{1,7})\b/g;
32
36
  export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
33
37
  const { mainOnly = false } = opts;
34
38
  const ids = new Set();
35
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
36
- let raw;
37
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
38
- for (const line of raw.split('\n')) {
39
- if (!line.trim()) continue;
40
- let entry;
41
- try { entry = JSON.parse(line); } catch { continue; }
39
+ for (const entry of readTranscriptEntries(transcriptPath)) {
42
40
  // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
43
41
  if (entry.type !== 'assistant' || !entry.message) continue;
44
42
  // Citation-decay loop scopes citation signal to main-thread text only —
@@ -77,18 +75,10 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
77
75
  * Returns zeros if transcript is missing or empty.
78
76
  */
79
77
  export function computeCiteRecall(transcriptPath) {
80
- const empty = { injected: 0, cited: 0, recalled: 0, ratio: 0 };
81
- if (!transcriptPath || !existsSync(transcriptPath)) return empty;
82
- let raw;
83
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return empty; }
84
-
85
78
  const injected = new Set();
86
79
  const cited = new Set();
87
80
 
88
- for (const line of raw.split('\n')) {
89
- if (!line.trim()) continue;
90
- let entry;
91
- try { entry = JSON.parse(line); } catch { continue; }
81
+ for (const entry of readTranscriptEntries(transcriptPath)) {
92
82
  const target = entry.type === 'assistant' ? cited : injected;
93
83
  // Walk every text-bearing surface the transcript carries: top-level content,
94
84
  // nested message content (assistant/user blocks), and tool_result-style
@@ -199,13 +189,7 @@ function normalizeHookCommand(command) {
199
189
  */
200
190
  function eachHookAttachment(transcriptPath, fn, opts = {}) {
201
191
  const { mainOnly = false } = opts;
202
- if (!transcriptPath || !existsSync(transcriptPath)) return;
203
- let raw;
204
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return; }
205
- for (const line of raw.split('\n')) {
206
- if (!line.trim()) continue;
207
- let entry;
208
- try { entry = JSON.parse(line); } catch { continue; }
192
+ for (const entry of readTranscriptEntries(transcriptPath)) {
209
193
  if (entry.type !== 'attachment') continue;
210
194
  if (mainOnly && entry.isSidechain === true) continue;
211
195
  const att = entry.attachment;
@@ -250,15 +234,21 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
250
234
 
251
235
  /**
252
236
  * The injection FACES memory can reach the model through, as stored in
253
- * `citation_surface_log.surface` (schema v45). The first four are
237
+ * `citation_surface_log.surface` (schema v45). The first five are
254
238
  * query-conditioned — a row appears there because it MATCHED something — and
255
239
  * are the ones that feed the citation-decay denominator via extractAllInjected.
256
240
  * `keyctx` is the odd one out: an unconditional SessionStart render, recorded
257
241
  * for VISIBILITY only and promotion-only in the decay loop (see
258
242
  * extractInjectedFromKeyContext).
259
- * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'keyctx'>}
243
+ *
244
+ * `task_imperative` (v3.76) rides the SAME attachment as `ups` — one
245
+ * `hook.mjs user-prompt` invocation writes the `<memory-context>` block and then the
246
+ * imperative line — which is how it stayed unmetered since v3.23 while being a live
247
+ * injection: the `ups` matcher gates on `<memory-context` and collects only `- [` rows.
248
+ * The two faces therefore OVERLAP on attachments but never on ids.
249
+ * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'>}
260
250
  */
261
- export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'keyctx'];
251
+ export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx'];
262
252
 
263
253
  // Single source of truth for "which attachment belongs to which face, and how
264
254
  // its ids are read off". Both the per-face extractors below AND the one-pass
@@ -326,6 +316,24 @@ const SURFACE_MATCHERS = {
326
316
  }
327
317
  },
328
318
  },
319
+ task_imperative: {
320
+ // Same hook entry as `ups`, different row. Gating on the emitter's own exported
321
+ // prefix (not a copy of the wording) keeps the meter tied to the framing; gating on
322
+ // the COMMAND too means a transcript that merely quotes the framing — a review of
323
+ // this code, say — is not counted as an injection.
324
+ accepts: ({ command, text }) =>
325
+ command.includes(UPS_COMMAND_SUFFIX) && text.includes(TASK_IMPERATIVE_PREFIX),
326
+ collect: (text, add) => {
327
+ for (const line of text.split('\n')) {
328
+ if (!line.startsWith(TASK_IMPERATIVE_PREFIX)) continue;
329
+ // Trailing `(#NN)` is this lesson's own id; earlier ones are cross-references
330
+ // inside the body (#8850 — an inlined lesson body must not pollute the set).
331
+ const matches = [...line.matchAll(UPS_ID_RE)];
332
+ if (matches.length === 0) continue;
333
+ add(matches[matches.length - 1][1]);
334
+ }
335
+ },
336
+ },
329
337
  };
330
338
 
331
339
  // The query-conditioned faces, in citation_surface_log label order. keyctx is
@@ -471,19 +479,39 @@ export function extractAllInjected(transcriptPath, opts = {}) {
471
479
  return unionSurfaces(extractInjectedBySurface(transcriptPath, opts));
472
480
  }
473
481
 
482
+ /**
483
+ * Faces that are METERED (a citation_surface_log row) but deliberately kept OUT of the
484
+ * citation-decay denominator, with the reason each is here. Membership is a decision,
485
+ * never a default: `unionSurfaces` still walks every SURFACE_MATCHERS key, and a test
486
+ * requires each face to be either in the union or listed here — so a face added later
487
+ * cannot slip out of the denominator by being forgotten, only by being argued for.
488
+ *
489
+ * - `task_imperative` (v3.76): metering it is the point of adding it — D#137/D#150/D#151
490
+ * are all blocked on not knowing this face's cite-rate. Widening the denominator at the
491
+ * same moment would change what gets demoted in every live install on upgrade, and it
492
+ * would do so on the face whose framing is itself the open question: if the imperative
493
+ * framing under-performs, the penalty lands on the LESSONS it carried (imperativePick
494
+ * selects high-value ones) rather than on the framing. Read the rate first, then decide.
495
+ * Removing an entry from this set is the one-line change that widens the denominator.
496
+ */
497
+ const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
498
+
499
+ /** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
500
+ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
501
+
474
502
  /**
475
503
  * Flatten a per-face breakdown into the single injected set the decay loop
476
504
  * takes. Derived — NOT a second hand-maintained face list — so adding a face to
477
505
  * SURFACE_MATCHERS automatically widens the denominator (v45; the pre-v45 union
478
506
  * enumerated the faces a second time and that is exactly how a face goes
479
- * unmetered).
507
+ * unmetered), unless that face is argued into DECAY_EXCLUDED_SURFACES above.
480
508
  *
481
509
  * @param {Record<string, Set<number>>} bySurface
482
510
  * @returns {Set<number>}
483
511
  */
484
512
  export function unionSurfaces(bySurface) {
485
513
  const out = new Set();
486
- for (const face of ATTACHMENT_SURFACES) {
514
+ for (const face of DECAY_DENOMINATOR_SURFACES) {
487
515
  for (const id of bySurface?.[face] || []) out.add(id);
488
516
  }
489
517
  return out;
@@ -523,12 +551,7 @@ const SUBAGENT_INJECT_ID_RE = /^\s{0,4}#(\d{1,7})\s+—/;
523
551
  */
524
552
  export function extractInjectedFromSubagentPrompt(transcriptPath) {
525
553
  const ids = new Set();
526
- let raw;
527
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
528
- for (const line of raw.split('\n')) {
529
- if (!line) continue;
530
- let entry;
531
- try { entry = JSON.parse(line); } catch { continue; }
554
+ for (const entry of readTranscriptEntries(transcriptPath)) {
532
555
  const c = entry.message?.content;
533
556
  const text = typeof c === 'string'
534
557
  ? c
@@ -612,22 +635,15 @@ export function aggregateProjectCiteRecall(txDir, { cutoff = 0 } = {}) {
612
635
  * @returns {boolean}
613
636
  */
614
637
  export function hasMainThreadAssistantText(transcriptPath) {
615
- if (!transcriptPath || !existsSync(transcriptPath)) return false;
616
- let raw;
617
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return false; }
618
- // Reverse-iterate so a turn that just produced text returns true on the
619
- // FIRST line examined instead of walking the entire transcript. Common case
620
- // (model wrote a paragraph → Stop fires) short-circuits in O(1) line parses;
621
- // pathological case (no text anywhere) still walks all entries but that's
622
- // the degenerate state we want false for anyway. v2.80 perf polish — the
623
- // pre-v2.80 forward scan held the full transcript in memory and walked
624
- // every line on the common case too.
625
- const lines = raw.split('\n');
626
- for (let i = lines.length - 1; i >= 0; i--) {
627
- const line = lines[i];
628
- if (!line || !line.trim()) continue;
629
- let entry;
630
- try { entry = JSON.parse(line); } catch { continue; }
638
+ // Reverse-iterate so a turn that just produced text returns true on the FIRST entry
639
+ // examined instead of walking the whole transcript; the pathological case (no text
640
+ // anywhere) still walks everything, which is the degenerate state we want false for.
641
+ // v2.80 polish. The short-circuit is now over an already-parsed array rather than over
642
+ // JSON.parse calls, so it saves iteration rather than parsing — the parse is shared
643
+ // with the other seven scanners of the same file (audit P2-8).
644
+ const entries = readTranscriptEntries(transcriptPath);
645
+ for (let i = entries.length - 1; i >= 0; i--) {
646
+ const entry = entries[i];
631
647
  if (entry.type !== 'assistant' || !entry.message) continue;
632
648
  if (entry.isSidechain === true) continue;
633
649
  const content = entry.message.content;
@@ -12,7 +12,8 @@
12
12
  // Legacy schema (pre-v2.81): { "<path>": <number> } — tolerated, never emits.
13
13
 
14
14
  import { basename, join } from 'path';
15
- import { readFileSync, existsSync } from 'fs';
15
+ import { readFileSync } from 'fs';
16
+ import { readTranscriptEntries } from './transcript-scan.mjs';
16
17
  import { EDIT_TOOLS } from '../utils.mjs';
17
18
 
18
19
  const MAX_FILES = 2;
@@ -140,19 +141,10 @@ const MEM_SAVE_TOOL_NAMES = new Set([
140
141
  ]);
141
142
 
142
143
  export function countUnsavedBugfixShape(transcriptPath) {
143
- const empty = { nudged: 0, saved: 0, unsaved: 0 };
144
- if (!transcriptPath || !existsSync(transcriptPath)) return empty;
145
- let raw;
146
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return empty; }
147
-
148
144
  let nudged = 0;
149
145
  let saved = 0;
150
146
 
151
- for (const line of raw.split('\n')) {
152
- if (!line.trim()) continue;
153
- let entry;
154
- try { entry = JSON.parse(line); } catch { continue; }
155
-
147
+ for (const entry of readTranscriptEntries(transcriptPath)) {
156
148
  // Bugfix-shape hint emissions live in PostToolUse attachment.stdout.
157
149
  if (entry.type === 'attachment') {
158
150
  const stdout = entry.attachment?.stdout || '';
@@ -281,13 +273,7 @@ const CITE_BACK_ID_RE = /#(\d{1,7})\b/g;
281
273
 
282
274
  export function extractCiteBackSignals(transcriptPath) {
283
275
  const ids = new Set();
284
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
285
- let raw;
286
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
287
- for (const line of raw.split('\n')) {
288
- if (!line.trim()) continue;
289
- let entry;
290
- try { entry = JSON.parse(line); } catch { continue; }
276
+ for (const entry of readTranscriptEntries(transcriptPath)) {
291
277
  if (entry.type !== 'attachment') continue;
292
278
  const stdout = entry.attachment?.stdout || '';
293
279
  if (!stdout.includes(CITE_BACK_HINT_LEADER)) continue;
@@ -82,9 +82,16 @@ export function checkDevDrift(installDir, sourceFiles) {
82
82
  // stale in silence: tests/doctor-hook-script-manifest.test.mjs re-derives it from the
83
83
  // `command` strings in hooks/hooks.json and asserts equality, so registering a new hook
84
84
  // script without classifying it here goes red.
85
+ //
86
+ // `pre-agent-inject.js` moved OUT of this set on 2026-08-22 (audit P2-5): no command line
87
+ // names it any more — `pre-agent-inject.sh` does, and execs the .js only when the feature
88
+ // is switched on. It is still shipped and still executed, so `checkHookScriptDrift` keeps
89
+ // grading it; it now reports as the module class, whose message ("ERR_MODULE_NOT_FOUND at
90
+ // hook time") is the truer description of what its absence does. Same severity either way,
91
+ // which is why this set is documented as driving the MESSAGE, not the grade.
85
92
  export const HOOK_SCRIPT_ENTRY_POINTS = new Set([
86
93
  'post-tool-use.sh', 'user-prompt-search.js', 'pre-tool-recall.js',
87
- 'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.js',
94
+ 'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.sh',
88
95
  'hook-launcher.mjs',
89
96
  ]);
90
97
 
@@ -0,0 +1,87 @@
1
+ // The non-LLM session summary — one shape, three callers.
2
+ //
3
+ // Audit 2026-08-22 P2-9. hook.mjs carried three hand-copied versions of "read this
4
+ // session's first prompt and its last few observation titles, scrub them, insert a
5
+ // session_summaries row": the Stop-time fast path, the SessionStart previous-session
6
+ // path, and the SessionStart /exit-restart fallback. They had already drifted — the
7
+ // 13-column INSERT was retyped each time, and the truncation limits split 600/600/400
8
+ // against 300/200. Copy-and-miss on exactly this kind of triplicate is what broke
9
+ // v3.35.2, and the comment in one of these blocks announcing "parity with the other"
10
+ // is the tell that parity was being maintained by hand.
11
+ //
12
+ // NOT collapsed in here: hook-llm.mjs's summary insert. That row is produced by the
13
+ // model and carries two more columns (lessons, key_decisions); it is a different
14
+ // record that happens to share a table, and merging it would mean inventing a shape
15
+ // that fits neither.
16
+ import { scrubRecord } from './scrub-record.mjs';
17
+ import { truncate } from '../format-utils.mjs';
18
+
19
+ /** Column list + placeholder row, written once. */
20
+ const INSERT_SQL = `
21
+ INSERT INTO session_summaries
22
+ (memory_session_id, project, request, investigated, learned, completed, next_steps,
23
+ remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
24
+ VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', ?, ?, ?)
25
+ `;
26
+
27
+ /**
28
+ * Per-caller truncation limits. These are NOT unified on purpose: the Stop path stores
29
+ * roughly twice what the two SessionStart paths do, and every one of these strings is
30
+ * re-injected into a later session's context. Collapsing them to one number changes how
31
+ * much text the product injects, which is a measurable behaviour change and not
32
+ * something a refactor gets to decide. Passed explicitly so the difference is visible
33
+ * at the call site instead of living in three retyped `truncate(...)` arguments.
34
+ */
35
+ export const FAST_SUMMARY_LIMITS = {
36
+ stop: { request: 200, completed: 600, remaining: 600, notes: 400 },
37
+ sessionStart: { request: 200, completed: 300, remaining: 200, notes: 400 },
38
+ exitRestart: { request: 200, completed: 300, remaining: 200, notes: 400 },
39
+ };
40
+
41
+ /**
42
+ * The two reads every fast summary is built from: the session's opening prompt, and the
43
+ * titles of its most recent observations.
44
+ * @returns {{request: string, completed: string}} raw (unscrubbed, untruncated) values
45
+ */
46
+ export function readFastSummarySource(db, sessionId) {
47
+ const firstPrompt = db.prepare(`
48
+ SELECT prompt_text FROM user_prompts
49
+ WHERE content_session_id = ?
50
+ ORDER BY prompt_number ASC LIMIT 1
51
+ `).get(sessionId);
52
+ const recentObs = db.prepare(`
53
+ SELECT title FROM observations
54
+ WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
55
+ ORDER BY created_at_epoch DESC LIMIT 5
56
+ `).all(sessionId);
57
+ return {
58
+ request: firstPrompt?.prompt_text || '',
59
+ completed: recentObs.map((o) => o.title).filter(Boolean).join('; '),
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Scrub, truncate, insert. Raw values go into scrubRecord and truncation happens after,
65
+ * at the bind site — a secret straddling the truncation boundary would otherwise fall
66
+ * below scrubSecrets' length floors and survive into the row (privacy review, v3.x).
67
+ * That ordering is the reason this function exists in one copy.
68
+ *
69
+ * @param {{request?: string, completed?: string, remaining?: string, notes?: string}} values raw
70
+ * @param {{request: number, completed: number, remaining: number, notes: number}} limits
71
+ */
72
+ export function insertFastSummary(db, { sessionId, project, values, limits, now }) {
73
+ const safe = scrubRecord('session_summaries', {
74
+ request: values.request || '',
75
+ completed: values.completed || '',
76
+ remaining_items: values.remaining || '',
77
+ notes: values.notes || 'fast',
78
+ });
79
+ db.prepare(INSERT_SQL).run(
80
+ sessionId, project,
81
+ truncate(safe.request, limits.request),
82
+ truncate(safe.completed, limits.completed),
83
+ truncate(safe.remaining_items, limits.remaining),
84
+ truncate(safe.notes, limits.notes),
85
+ now.toISOString(), now.getTime(),
86
+ );
87
+ }
package/lib/get-core.mjs CHANGED
@@ -15,6 +15,41 @@ export const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text
15
15
  * 6-field subset made notes/remaining_items/files_* searchable-but-unrenderable. */
16
16
  export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'remaining_items', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
17
17
 
18
+ /** User-prompt detail render set. The CLI face rendered only prompt_text +
19
+ * content_session_id while MCP rendered prompt_number and created_at too — the same
20
+ * searchable-but-invisible shape SESSION_DETAIL_FIELDS was created to close, reopened on
21
+ * the prompt source (audit 2026-08-22, P2-6). */
22
+ export const PROMPT_DETAIL_FIELDS = ['id', 'prompt_text', 'content_session_id', 'prompt_number', 'created_at'];
23
+
24
+ /** Event detail render set. `body` carries the distilled lesson persistHaikuSummary
25
+ * writes (lesson_learned || narrative). */
26
+ export const EVENT_DETAIL_FIELDS = ['id', 'event_type', 'title', 'body', 'project', 'importance', 'file_paths', 'git_sha', 'created_at'];
27
+
28
+ /**
29
+ * Fetch user-prompt detail rows, oldest-first.
30
+ * No access bump: prompts are not ranked, so reading one is not a usage signal.
31
+ */
32
+ export function fetchPromptDetail(db, ids) {
33
+ const ph = ids.map(() => '?').join(',');
34
+ return db.prepare(`SELECT * FROM user_prompts WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
35
+ }
36
+
37
+ /**
38
+ * Fetch event detail rows, oldest-first.
39
+ *
40
+ * The events table has no `created_at` ISO column — only `created_at_epoch`. Both faces
41
+ * used to derive the ISO string themselves; deriving it once here means EVENT_DETAIL_FIELDS
42
+ * can name `created_at` like every other field set and neither face special-cases it.
43
+ */
44
+ export function fetchEventDetail(db, ids) {
45
+ const ph = ids.map(() => '?').join(',');
46
+ const rows = db.prepare(`SELECT * FROM events WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
47
+ return rows.map(r => ({
48
+ ...r,
49
+ created_at: r.created_at_epoch ? new Date(r.created_at_epoch).toISOString() : null,
50
+ }));
51
+ }
52
+
18
53
  /**
19
54
  * Fetch observation detail rows: bump access_count/last_accessed_at (reading a
20
55
  * detail IS an access signal — feeds noisePenalty's ratio guard), run the
@@ -12,7 +12,7 @@
12
12
  // auto-maintain block). `ctx` carries the per-caller knobs:
13
13
  // { projectFilter: 'AND project = ?' | '', baseParams: [project?] , staleAge, opCap }
14
14
 
15
- import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
15
+ import { COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
16
16
  import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled, vecTextForRow } from '../tfidf.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
18
18
  import { liveObsFilterSql } from './inject-search-core.mjs';
@@ -81,6 +81,100 @@ export function selectFuzzyDedupeIds(rows, {
81
81
  return removeIds;
82
82
  }
83
83
 
84
+ /**
85
+ * Tombstone the ids an auto-dedup pass selected.
86
+ *
87
+ * `AND superseded_at IS NULL` is the load-bearing half. Both dedup channels select live
88
+ * rows and then stamp them, and between those two steps another writer can supersede a
89
+ * row — `save --supersedes=[#A]` writes A.superseded_by = B's NUMERIC id, and the chain
90
+ * citation-tracker's decay hand-off and timeline re-anchoring both follow. An unguarded
91
+ * UPDATE overwrites that numeric chain with the string marker and the chain silently dead-
92
+ * ends. The window is narrow and the failure is not reproducible on demand, which is
93
+ * exactly why it survived seven rounds of this invariant being re-broken.
94
+ *
95
+ * The exact channel grew this guard in v3.63; the fuzzy channel did not, and the audit of
96
+ * 2026-08-22 (P2-1) found the asymmetry still open. Both channels now stamp through this
97
+ * one function, so the guard cannot be present on one and missing on the other.
98
+ *
99
+ * @param {object} db open DB handle
100
+ * @param {number[]} ids observation ids selected for superseding
101
+ * @param {string} marker superseded_by value ('auto-dedup' | 'auto-dedup-fuzzy')
102
+ * @returns {number} rows actually stamped — less than ids.length means the guard held
103
+ */
104
+ export function stampDedupSuperseded(db, ids, marker) {
105
+ if (!Array.isArray(ids) || ids.length === 0) return 0;
106
+ const ph = ids.map(() => '?').join(',');
107
+ const res = db.prepare(
108
+ `UPDATE observations SET superseded_at = ?, superseded_by = ?
109
+ WHERE id IN (${ph}) AND superseded_at IS NULL`
110
+ ).run(Date.now(), marker, ...ids);
111
+ return res.changes;
112
+ }
113
+
114
+ /**
115
+ * Mark auto-compressible observations for one project — the two full-table conditional
116
+ * UPDATEs that used to run inside the SessionStart transaction on every single boot
117
+ * (audit 2026-08-22, P2-11). They are maintenance by definition: nothing about a new
118
+ * session makes a 30-day-old row newly compressible, and the same 24h auto-maintain gate
119
+ * that guards decay/purge/backup was already sitting right next to them.
120
+ *
121
+ * Both predicates are unchanged, including the project scope — this moves *when* the
122
+ * marking runs, not *what* it marks.
123
+ *
124
+ * @param {object} db open DB handle
125
+ * @param {string} project project scope (unchanged from the SessionStart behavior)
126
+ * @param {object} [opts]
127
+ * @param {number} [opts.agedAgeMs] age floor for the general 30d pass
128
+ * @param {number} [opts.noiseAgeMs] age floor for the accelerated 7d LOW_SIGNAL pass
129
+ * @returns {{aged: number, noise: number}} rows marked by each pass
130
+ */
131
+ export function markAutoCompressible(db, project, {
132
+ agedAgeMs = 30 * DAY_MS,
133
+ noiseAgeMs = 7 * DAY_MS,
134
+ } = {}) {
135
+ if (!project) return { aged: 0, noise: 0 };
136
+ const now = Date.now();
137
+
138
+ // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant via
139
+ // hook-memory injection, even if the user never explicitly fetched them).
140
+ // `<= 1` (was `= 1`): citation-decay floors importance at 0 and the LLM low-signal
141
+ // filter saves at imp=0 — those rows are STRICTLY lower value than imp=1 yet escaped
142
+ // GC, accumulating to ~40% of a mature DB (immortal: hidden from injection by the
143
+ // imp>=1 floor, but visible as explicit-search noise).
144
+ // v3.23: never auto-hide a row that carries a real lesson — compression folds sources
145
+ // into a title-only summary (lesson lost) and COMPRESSED_AUTO hides the row from search
146
+ // entirely (audit: 62 lessons buried this way).
147
+ const aged = db.prepare(`
148
+ UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
149
+ WHERE COALESCE(compressed_into, 0) = 0
150
+ AND COALESCE(importance, 1) <= 1
151
+ AND COALESCE(injection_count, 0) = 0
152
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
153
+ AND created_at_epoch < ?
154
+ AND project = ?
155
+ `).run(now - agedAgeMs, project).changes;
156
+
157
+ // v2.47 P0-3: accelerated pass for LOW_SIGNAL + no-signal noise — 7 days instead of 30.
158
+ // The write-side capNoiseImportance already forces imp=1 on these; this only shrinks GC
159
+ // latency so the corpus reduction materializes within a week instead of bleeding into
160
+ // the 30-day tier.
161
+ const noise = db.prepare(`
162
+ UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
163
+ WHERE COALESCE(compressed_into, 0) = 0
164
+ AND COALESCE(importance, 1) <= 1
165
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
166
+ AND (facts IS NULL OR facts = '' OR facts = '[]')
167
+ AND (
168
+ title LIKE 'Modified %' OR title LIKE 'Worked on %'
169
+ OR title LIKE 'Reviewed %' OR title LIKE 'Error%'
170
+ )
171
+ AND created_at_epoch < ?
172
+ AND project = ?
173
+ `).run(now - noiseAgeMs, project).changes;
174
+
175
+ return { aged, noise };
176
+ }
177
+
84
178
  /** Delete broken observations (no title AND no narrative). Returns rows deleted. */
85
179
  // Before hard-deleting observations, un-hide any rows merged INTO them. A child has
86
180
  // compressed_into = <keeperId>; deleting that keeper (compressed_into has no FK) would
@@ -11,7 +11,7 @@
11
11
  // the context). Remind-only by design: false positives cost one line, so the
12
12
  // signal list stays conservative and never auto-writes anything.
13
13
 
14
- import { readFileSync, existsSync } from 'fs';
14
+ import { readTranscriptEntries } from './transcript-scan.mjs';
15
15
 
16
16
  // Distinctive finalization word forms (CJK + EN). Deliberately NOT included:
17
17
  // "方案 A 定" and bare "定" (too FP-prone), bare "ok/好" (noise). The list is
@@ -62,14 +62,14 @@ const MEMDIR_WRITE_RE = /[\\/]\.claude[\\/]projects[\\/][^\\/]+[\\/]memory[\\/][
62
62
  * `save` / `defer add`) in the session transcript. 0 on missing/unreadable.
63
63
  */
64
64
  export function countDeliberatePersistence(transcriptPath) {
65
- if (!transcriptPath || !existsSync(transcriptPath)) return 0;
66
- let raw;
67
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return 0; }
65
+ // Shares the Stop-time parse (lib/transcript-scan.mjs) rather than doing its own
66
+ // read+split+parse. It was the NINTH scanner of the same file and was missed when the
67
+ // other eight were collapsed — sitting two lines away from two that were migrated, so
68
+ // it was quietly charging a full re-parse (~20-25ms on a 5.7MB transcript) against a
69
+ // pass whose whole point was to stop doing that. Missing/unreadable paths still yield
70
+ // 0, because readTranscriptEntries returns [] for them.
68
71
  let count = 0;
69
- for (const line of raw.split('\n')) {
70
- if (!line.trim()) continue;
71
- let entry;
72
- try { entry = JSON.parse(line); } catch { continue; }
72
+ for (const entry of readTranscriptEntries(transcriptPath)) {
73
73
  if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
74
74
  const content = entry.message?.content;
75
75
  if (!Array.isArray(content)) continue;
@@ -0,0 +1,94 @@
1
+ // Shared body for the registry write actions — import / remove / reindex.
2
+ //
3
+ // These were the last un-collapsed CLI/MCP twin (audit 2026-08-22 P1-3): mem-cli.mjs and
4
+ // server.mjs each wrote their own SQL for the same three actions, and had already drifted —
5
+ // the CLI granted a user-initiated import `quality_tier = 'installed'` and the MCP twin did
6
+ // not, so the same intent produced differently-ranked rows depending on which surface the
7
+ // user reached for. That is this project's first-listed病类 ("a guard wired into one face,
8
+ // missing on the other"), so the fix is a single body both faces call, not a second patch.
9
+ //
10
+ // Contract: these functions decide and write. They return structured results and never
11
+ // format output or touch process state — rendering (and CLI-only concerns like bare-flag
12
+ // rejection or the "add --capability-summary" tip) stays in the surface.
13
+
14
+ import { upsertResource } from '../registry.mjs';
15
+
16
+ /**
17
+ * String columns an import may set, in canonical snake_case. Single source: the CLI derives
18
+ * its kebab-case flag names from this list, the MCP tool reads its args by these keys, so a
19
+ * new column is added once and both surfaces pick it up.
20
+ */
21
+ export const IMPORT_STRING_FIELDS = [
22
+ 'repo_url',
23
+ 'local_path',
24
+ 'invocation_name',
25
+ 'intent_tags',
26
+ 'domain_tags',
27
+ 'trigger_patterns',
28
+ 'capability_summary',
29
+ 'keywords',
30
+ 'tech_stack',
31
+ 'use_cases',
32
+ ];
33
+
34
+ /**
35
+ * Resolve the `source` column for an import.
36
+ *
37
+ * Preserve provenance on a metadata-only re-import: default to 'user' only for a genuinely
38
+ * NEW resource. Re-importing an existing github/preinstalled row without an explicit source
39
+ * must not flip it to 'user' (which also mis-grants the user-source rank boost).
40
+ */
41
+ function resolveSource(db, { type, name, source }) {
42
+ if (source) return source;
43
+ const existing = db.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(type, name);
44
+ return existing ? existing.source : 'user';
45
+ }
46
+
47
+ /**
48
+ * Upsert one resource.
49
+ *
50
+ * @param {object} db open resource-registry.db handle
51
+ * @param {object} params
52
+ * @param {string} params.name
53
+ * @param {string} params.type 'skill' | 'agent'
54
+ * @param {string} [params.source] explicit provenance; absent = user-initiated
55
+ * @param {object} [params.fields] IMPORT_STRING_FIELDS values (snake_case keys)
56
+ * @returns {{id: number, source: string, installedTierGranted: boolean}}
57
+ */
58
+ export function importResource(db, { name, type, source, fields = {} }) {
59
+ const resolvedSource = resolveSource(db, { type, name, source });
60
+
61
+ const row = { name, type, status: 'active', source: resolvedSource };
62
+ for (const f of IMPORT_STRING_FIELDS) row[f] = fields[f] || '';
63
+
64
+ const id = upsertResource(db, row);
65
+
66
+ // A user-initiated import (no explicit --source/source arg) means the user deliberately
67
+ // added this resource — it gets the 'installed' quality tier, which the retriever reads as
68
+ // a ranking bonus and the recommendation gate reads as a precision signal.
69
+ const installedTierGranted = Boolean(id) && !source;
70
+ if (installedTierGranted) {
71
+ db.prepare("UPDATE resources SET quality_tier = 'installed' WHERE id = ?").run(id);
72
+ }
73
+
74
+ return { id, source: resolvedSource, installedTierGranted };
75
+ }
76
+
77
+ /**
78
+ * Delete one resource.
79
+ * @returns {{removed: boolean}} removed=false means nothing matched (not an error).
80
+ */
81
+ export function removeResource(db, { name, type }) {
82
+ const result = db.prepare('DELETE FROM resources WHERE type = ? AND name = ?').run(type, name);
83
+ return { removed: result.changes > 0 };
84
+ }
85
+
86
+ /**
87
+ * Rebuild the FTS5 index over the resources table.
88
+ * @returns {{activeCount: number}}
89
+ */
90
+ export function reindexResources(db) {
91
+ db.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')");
92
+ const row = db.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
93
+ return { activeCount: row.c };
94
+ }