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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +122 -4
- package/README.zh-CN.md +18 -10
- package/haiku-client.mjs +7 -58
- package/hook-context.mjs +4 -7
- package/hook-episode.mjs +44 -20
- package/hook-llm.mjs +7 -2
- package/hook-memory.mjs +25 -10
- package/hook.mjs +132 -178
- package/hooks/hooks.json +1 -1
- package/install.mjs +35 -2
- package/lib/citation-tracker.mjs +67 -51
- package/lib/cite-back-hint.mjs +4 -18
- package/lib/doctor-drift.mjs +8 -1
- package/lib/fast-summary.mjs +87 -0
- package/lib/get-core.mjs +35 -0
- package/lib/maintain-core.mjs +95 -1
- package/lib/persist-reminder.mjs +8 -8
- package/lib/registry-core.mjs +94 -0
- package/lib/resolve-data-dir.mjs +53 -4
- package/lib/summary-extractor.mjs +2 -8
- package/lib/task-imperative.mjs +8 -1
- package/lib/transcript-scan.mjs +66 -0
- package/lib/ups-query.mjs +26 -0
- package/mem-cli.mjs +53 -44
- package/memdir.mjs +5 -1
- package/nlp.mjs +20 -2
- package/npm-shrinkwrap.json +3 -2
- package/package.json +7 -1
- package/registry-recommend.mjs +30 -4
- package/schema.mjs +3 -1
- package/scoring-sql.mjs +21 -7
- package/scripts/pre-agent-inject.sh +43 -0
- package/scripts/user-prompt-search.js +8 -3
- package/server.mjs +30 -38
- package/source-files.mjs +15 -0
- package/utils.mjs +13 -5
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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;
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/doctor-drift.mjs
CHANGED
|
@@ -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.
|
|
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
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -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
|
package/lib/persist-reminder.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
+
}
|