claude-mem-lite 6.5.0 → 6.6.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -1
- package/hook-context.mjs +78 -4
- package/hook-llm.mjs +32 -27
- package/hook-memory.mjs +11 -16
- package/hook-optimize.mjs +7 -0
- package/hook.mjs +39 -7
- package/install.mjs +61 -11
- package/lib/cite-back-hint.mjs +68 -7
- package/lib/save-observation.mjs +1 -1
- package/lib/search-core.mjs +13 -10
- package/lib/ups-query.mjs +14 -1
- package/mem-cli.mjs +3 -3
- package/nlp.mjs +23 -4
- package/npm-shrinkwrap.json +228 -149
- package/package.json +3 -3
- package/search-engine.mjs +9 -3
- package/search-scoring.mjs +10 -4
- package/utils.mjs +4 -2
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.6.0",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
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)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.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/README.md
CHANGED
|
@@ -896,7 +896,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
896
896
|
| `MEM_DISABLE_CITATION_DECAY` | `1` disables only the decay writes, keeping access-count bumps. | _(enabled)_ |
|
|
897
897
|
| `CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD` | **Removed — inert.** Tuned the per-project adoption gate, which is gone (D#204). Setting it warns on stderr and changes nothing. | _(n/a)_ |
|
|
898
898
|
| `CLAUDE_MEM_NO_CITE_NUDGE` | `1` fully silences the cite-back nudge. | _(enabled)_ |
|
|
899
|
-
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.
|
|
899
|
+
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.4` |
|
|
900
|
+
| `CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR` | `1` judges the wide cite-recall ratio (every `#NN`-shaped token the model saw) instead of the lessons the hooks injected. **Half of the revert**: the threshold moved too, so pre-v6.6.0 gating needs this **and** `CLAUDE_MEM_CITE_NUDGE_THRESHOLD=0.6`. This switch alone gives you the wide ratio judged at 0.4, which is neither release's behaviour. | unset |
|
|
900
901
|
| `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
|
|
901
902
|
| `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
|
|
902
903
|
| `CLAUDE_MEM_CITATION_RELEVANCE_GATE` | Stop credits an `access_count` to a memory the session cited only when something made that memory relevant to the session — it was injected, or you typed its `#NN` yourself. `off` restores the pre-v3.84.0 behaviour of crediting every `#NN` the assistant wrote, which over-counts sessions that discuss memories in prose (release notes, audit reports): measured on real transcripts, 267 of 859 credited (id, session) pairs — 31.1% — were mentions nothing had put in front of the model. Superseded citations are redirected to their keeper on both settings. | _(on)_ |
|
package/hook-context.mjs
CHANGED
|
@@ -336,6 +336,69 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
336
336
|
* removes the paired hint comment if present, and normalizes residual whitespace
|
|
337
337
|
* at the seam. Uses atomic tmp+rename write.
|
|
338
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* Half-open [start, end) ranges of every fenced code span in a markdown document.
|
|
341
|
+
*
|
|
342
|
+
* A fence opens on a line whose first non-space run is three or more backticks or tildes,
|
|
343
|
+
* and closes on the next line opening with at least as many of the SAME character — CommonMark's
|
|
344
|
+
* rule, and the reason a ```` ```` ```` block can contain a ``` line. An unterminated fence
|
|
345
|
+
* runs to EOF.
|
|
346
|
+
*/
|
|
347
|
+
function fencedRanges(content) {
|
|
348
|
+
const ranges = [];
|
|
349
|
+
let open = null;
|
|
350
|
+
let offset = 0;
|
|
351
|
+
for (const line of content.split('\n')) {
|
|
352
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
353
|
+
if (m) {
|
|
354
|
+
if (!open) {
|
|
355
|
+
open = { char: m[1][0], len: m[1].length, start: offset };
|
|
356
|
+
} else if (m[1][0] === open.char && m[1].length >= open.len) {
|
|
357
|
+
ranges.push([open.start, offset + line.length]);
|
|
358
|
+
open = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
offset += line.length + 1; // +1 for the '\n' split removed
|
|
362
|
+
}
|
|
363
|
+
if (open) ranges.push([open.start, content.length]);
|
|
364
|
+
return ranges;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** True when `idx` sits inside a backtick-delimited inline code span on its own line. */
|
|
368
|
+
function insideInlineCode(content, idx) {
|
|
369
|
+
const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
|
|
370
|
+
// Count backtick RUNS before the position; an odd count means the position is inside a
|
|
371
|
+
// span. Runs, not characters: ``code with ` inside`` is one span opened by two ticks.
|
|
372
|
+
const before = content.slice(lineStart, idx);
|
|
373
|
+
const runs = before.match(/`+/g);
|
|
374
|
+
return runs !== null && runs.length % 2 === 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The last index of `needle` that is a real block's tag, or -1.
|
|
379
|
+
*
|
|
380
|
+
* Three exclusions, and the pre-ship review is why there are three rather than one. A
|
|
381
|
+
* legacy block written by the old `updateClaudeMd` put its tags at COLUMN 0 on their own
|
|
382
|
+
* lines, so anything else is prose ABOUT the tag:
|
|
383
|
+
* - inside a fenced code span (```/~~~) — the first cut, and only a third of it
|
|
384
|
+
* - not at the start of a line — an inline mention mid-sentence
|
|
385
|
+
* - indented four or more spaces — CommonMark's other code block
|
|
386
|
+
* Measured before the widening, with the shipped function: an inline span went 101 -> 55
|
|
387
|
+
* bytes and an indented block 95 -> 33, atomically, with no backup.
|
|
388
|
+
*/
|
|
389
|
+
function lastIndexOutsideFences(content, needle, ranges) {
|
|
390
|
+
let idx = content.lastIndexOf(needle);
|
|
391
|
+
while (idx !== -1) {
|
|
392
|
+
const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
|
|
393
|
+
const indent = content.slice(lineStart, idx);
|
|
394
|
+
const atLineStart = indent.length === 0;
|
|
395
|
+
const inFence = ranges.some(([a, b]) => idx >= a && idx < b);
|
|
396
|
+
if (atLineStart && !inFence && !insideInlineCode(content, idx)) return idx;
|
|
397
|
+
idx = content.lastIndexOf(needle, idx - 1);
|
|
398
|
+
}
|
|
399
|
+
return -1;
|
|
400
|
+
}
|
|
401
|
+
|
|
339
402
|
export function cleanupClaudeMdLegacyBlock() {
|
|
340
403
|
// v2.48 P2-4: idempotent marker. First run (whether it finds a block or not,
|
|
341
404
|
// whether CLAUDE.md exists or not) drops a project-scoped marker in RUNTIME_DIR.
|
|
@@ -369,10 +432,21 @@ export function cleanupClaudeMdLegacyBlock() {
|
|
|
369
432
|
const startTag = '<claude-mem-context>';
|
|
370
433
|
const endTag = '</claude-mem-context>';
|
|
371
434
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
435
|
+
// A4 (audit 2026-09-08): `lastIndexOf` alone was NOT the protection the old comment
|
|
436
|
+
// here claimed. It shields a documentation reference only when a real block sits AFTER
|
|
437
|
+
// it; when the file's only occurrence IS the reference — the ordinary case for someone
|
|
438
|
+
// who wrote down what this plugin emits — both searches land on it, `startIdx < endIdx`
|
|
439
|
+
// holds, and the user's fenced sample is deleted with its two fences spliced into a
|
|
440
|
+
// broken ``````. Atomic write, no backup, once per project, so it shows up as a small
|
|
441
|
+
// stray diff days later. Measured on a plain 226-byte CLAUDE.md: 226 → 145.
|
|
442
|
+
//
|
|
443
|
+
// "The tag is alone on its line" does not discriminate — inside a fence it usually is.
|
|
444
|
+
// The fence itself is the signal, so fenced spans are excluded before the search, and an
|
|
445
|
+
// UNTERMINATED fence is treated as running to EOF: that errs toward leaving the file
|
|
446
|
+
// alone, which is the safe direction for a write into someone's own notes.
|
|
447
|
+
const fenced = fencedRanges(content);
|
|
448
|
+
const startIdx = lastIndexOutsideFences(content, startTag, fenced);
|
|
449
|
+
const endIdx = lastIndexOutsideFences(content, endTag, fenced);
|
|
376
450
|
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
377
451
|
dropMarker();
|
|
378
452
|
return;
|
package/hook-llm.mjs
CHANGED
|
@@ -279,7 +279,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
279
279
|
`
|
|
280
280
|
SELECT title FROM observations
|
|
281
281
|
WHERE project = ? AND created_at_epoch > ?
|
|
282
|
-
ORDER BY created_at_epoch DESC LIMIT 10
|
|
282
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 10
|
|
283
283
|
`,
|
|
284
284
|
)
|
|
285
285
|
.all(project, fiveMinAgo);
|
|
@@ -313,7 +313,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
313
313
|
`
|
|
314
314
|
SELECT title FROM observations
|
|
315
315
|
WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
316
|
-
ORDER BY created_at_epoch DESC LIMIT 60
|
|
316
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 60
|
|
317
317
|
`,
|
|
318
318
|
)
|
|
319
319
|
.all(project, threeDaysAgo, fiveMinAgo);
|
|
@@ -331,7 +331,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
331
331
|
`
|
|
332
332
|
SELECT minhash_sig FROM observations
|
|
333
333
|
WHERE project = ? AND created_at_epoch > ? AND minhash_sig IS NOT NULL
|
|
334
|
-
ORDER BY created_at_epoch DESC LIMIT 200
|
|
334
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 200
|
|
335
335
|
`,
|
|
336
336
|
)
|
|
337
337
|
.all(project, sevenDaysAgo);
|
|
@@ -1383,7 +1383,7 @@ export async function handleLLMSummary() {
|
|
|
1383
1383
|
FROM observations
|
|
1384
1384
|
WHERE memory_session_id = ?
|
|
1385
1385
|
AND ${notLowSignalTitleClause('')}
|
|
1386
|
-
ORDER BY created_at_epoch DESC
|
|
1386
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1387
1387
|
LIMIT 30
|
|
1388
1388
|
`,
|
|
1389
1389
|
)
|
|
@@ -1585,28 +1585,33 @@ ${obsList}`;
|
|
|
1585
1585
|
// spinning up the full LLM dispatcher. Lets the e2e leak test verify that
|
|
1586
1586
|
// the observations INSERT path scrubs all configured text fields.
|
|
1587
1587
|
export const __insertObservationForTest = (db, obs) => {
|
|
1588
|
+
// R11 C-P3-3: this used to hand-spell an 18-column INSERT of its own while the
|
|
1589
|
+
// shipping writer above went through insertObservationRow's 19-column list, so the
|
|
1590
|
+
// secret-scrub leak test asserted on a REPLICA of the write point rather than the
|
|
1591
|
+
// write point. Column-list drift between the two is exactly what
|
|
1592
|
+
// lib/observation-write.mjs exists to make impossible, and a test copy is the one
|
|
1593
|
+
// caller that can drift without anyone noticing — it has no user.
|
|
1588
1594
|
const safe = scrubRecord('observations', obs);
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
safe.
|
|
1596
|
-
|
|
1597
|
-
safe.
|
|
1598
|
-
safe.
|
|
1599
|
-
safe.
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
obs.
|
|
1603
|
-
obs.
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
);
|
|
1595
|
+
const now = new Date();
|
|
1596
|
+
return insertObservationRow(db, {
|
|
1597
|
+
memory_session_id: obs.session_id,
|
|
1598
|
+
project: obs.project,
|
|
1599
|
+
text: safe.text,
|
|
1600
|
+
type: 'change',
|
|
1601
|
+
title: safe.title,
|
|
1602
|
+
subtitle: safe.subtitle,
|
|
1603
|
+
narrative: safe.narrative,
|
|
1604
|
+
concepts: safe.concepts,
|
|
1605
|
+
facts: safe.facts,
|
|
1606
|
+
files_read: obs.files_read,
|
|
1607
|
+
files_modified: obs.files_modified,
|
|
1608
|
+
importance: obs.importance,
|
|
1609
|
+
minhash_sig: obs.minhash_sig,
|
|
1610
|
+
lesson_learned: safe.lesson_learned,
|
|
1611
|
+
search_aliases: safe.search_aliases,
|
|
1612
|
+
branch: obs.branch,
|
|
1613
|
+
created_at: now.toISOString(),
|
|
1614
|
+
created_at_epoch: now.getTime(),
|
|
1615
|
+
scope: normalizeScope(obs.scope),
|
|
1616
|
+
});
|
|
1612
1617
|
};
|
package/hook-memory.mjs
CHANGED
|
@@ -8,12 +8,9 @@ import {
|
|
|
8
8
|
OBS_BM25,
|
|
9
9
|
notLowSignalTitleClause,
|
|
10
10
|
noisePenaltyClause,
|
|
11
|
-
tokenizeHandoff,
|
|
12
|
-
HANDOFF_STOP_WORDS,
|
|
13
|
-
extractCjkKeywords,
|
|
14
11
|
neutralizeContextDelimiters,
|
|
15
12
|
} from './utils.mjs';
|
|
16
|
-
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
13
|
+
import { upsFtsQuery, upsQueryTerms } from './lib/ups-query.mjs';
|
|
17
14
|
import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
|
|
18
15
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
19
16
|
import { recordMetric } from './lib/metrics.mjs';
|
|
@@ -169,17 +166,6 @@ function getCrossProjectBoost() {
|
|
|
169
166
|
const n = parseFloat(raw);
|
|
170
167
|
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
|
|
171
168
|
}
|
|
172
|
-
function extractQueryTerms(text) {
|
|
173
|
-
if (!text) return [];
|
|
174
|
-
const ascii = tokenizeHandoff(text).filter((t) => !HANDOFF_STOP_WORDS.has(t));
|
|
175
|
-
let cjk = [];
|
|
176
|
-
try {
|
|
177
|
-
cjk = extractCjkKeywords(text) || [];
|
|
178
|
-
} catch {
|
|
179
|
-
/* CJK extraction best-effort */
|
|
180
|
-
}
|
|
181
|
-
return [...new Set([...ascii, ...cjk.map((t) => String(t).toLowerCase())])];
|
|
182
|
-
}
|
|
183
169
|
// v2.41: hay spans every FTS column whose BM25 weight is >=5 in OBS_BM25
|
|
184
170
|
// (title=10, subtitle=5, narrative=5, lesson_learned=8). Pre-v2.41 was only
|
|
185
171
|
// title + lesson_learned — rows that matched on narrative but happened to
|
|
@@ -509,7 +495,16 @@ export function searchRelevantMemories(
|
|
|
509
495
|
let coverageFiltered = aboveThreshold;
|
|
510
496
|
const coverageThreshold = getCoverageThreshold();
|
|
511
497
|
if (coverageThreshold > 0) {
|
|
512
|
-
|
|
498
|
+
// A1: the denominator is the terms the query was actually BUILT from, via the one
|
|
499
|
+
// source (lib/ups-query.mjs -> nlp.mjs::ftsQueryTokens). Counting anything else
|
|
500
|
+
// counts terms that were never searched, so no matched row can cover them and the
|
|
501
|
+
// ratio falls with prompt length until the whole surface goes silent.
|
|
502
|
+
//
|
|
503
|
+
// The first cut of this fix shared only maxChars and the pre-ship review measured
|
|
504
|
+
// the hole it left: sanitizeFtsQuery also caps at maxTokens = 64, so a 1511-character
|
|
505
|
+
// prompt — under the char cap, where the shared cut is a no-op — still returned []
|
|
506
|
+
// against a row whose narrative was the entire prompt.
|
|
507
|
+
const queryTerms = upsQueryTerms(userPrompt);
|
|
513
508
|
if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
|
|
514
509
|
coverageFiltered = aboveThreshold.filter(
|
|
515
510
|
(r) => candidateCoverage(r, queryTerms) >= coverageThreshold,
|
package/hook-optimize.mjs
CHANGED
|
@@ -1517,6 +1517,13 @@ export async function executeSmartCompressCluster(db, observations, project) {
|
|
|
1517
1517
|
//
|
|
1518
1518
|
// optimized_at is not in OBS_COLUMNS, so it stays a separate UPDATE below — the
|
|
1519
1519
|
// summary must be marked processed so the re-enrich pools do not pick it up.
|
|
1520
|
+
//
|
|
1521
|
+
// R11 C-P3-2 read the three-item list above as a completeness claim and asked why
|
|
1522
|
+
// the fourth OBS_COLUMNS entry, `scope`, is absent. It is absent on purpose: the
|
|
1523
|
+
// merge prompt does not ask for one, so there is no value to pass, and NULL is the
|
|
1524
|
+
// `scopes` backfill pool's own predicate (findReenrichCandidates, scope==='scopes')
|
|
1525
|
+
// — which is deliberately NOT gated on optimized_at, so stamping this row processed
|
|
1526
|
+
// does not evict it. Writing a guessed scope here would.
|
|
1520
1527
|
const newId = insertObservationRow(db, {
|
|
1521
1528
|
memory_session_id: sessionId,
|
|
1522
1529
|
project,
|
package/hook.mjs
CHANGED
|
@@ -1406,6 +1406,34 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1406
1406
|
// which was ~all of the cost.)
|
|
1407
1407
|
try {
|
|
1408
1408
|
const stats = computeCiteRecall(transcriptPath);
|
|
1409
|
+
// D#19: the RATIO GATE's own denominator, alongside the wide one. `stats` counts
|
|
1410
|
+
// every `#NN`-shaped token in non-assistant text — tool_result bodies, file
|
|
1411
|
+
// contents, CLI output, pasted reports — which is the right caliber for "what has
|
|
1412
|
+
// the model seen" and the wrong one for "did it cite back what the hooks gave it"
|
|
1413
|
+
// (measured 11.2x inflation, R11-B-P2-2). Both are persisted; buildCiteRecallNudge
|
|
1414
|
+
// gates on this pair and falls back to the wide one for older payloads.
|
|
1415
|
+
//
|
|
1416
|
+
// Cost is an array iteration, not a parse: this block runs after the decay loop's
|
|
1417
|
+
// own extractInjectedBySurface on the same path, and lib/transcript-scan.mjs memoizes
|
|
1418
|
+
// the parse. mainOnly mirrors the decay loop — an id injected only inside a subagent
|
|
1419
|
+
// would otherwise enter the denominator while its citation lands in another
|
|
1420
|
+
// transcript, scoring a miss the main thread never had a chance to avoid.
|
|
1421
|
+
let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
|
|
1422
|
+
try {
|
|
1423
|
+
const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
|
|
1424
|
+
const gateCited = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
1425
|
+
let hit = 0;
|
|
1426
|
+
for (const id of gateInjectedIds) if (gateCited.has(id)) hit++;
|
|
1427
|
+
gate = {
|
|
1428
|
+
gateInjected: gateInjectedIds.size,
|
|
1429
|
+
gateRecalled: hit,
|
|
1430
|
+
gateRatio: gateInjectedIds.size > 0 ? hit / gateInjectedIds.size : 0,
|
|
1431
|
+
};
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
// Leaving the gate* keys null is the defined fallback, not a silent loss: the
|
|
1434
|
+
// reader treats a payload without them exactly like a pre-release one.
|
|
1435
|
+
debugCatch(e, 'handleStop-cite-recall-gate-denominator');
|
|
1436
|
+
}
|
|
1409
1437
|
// B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
|
|
1410
1438
|
// the next SessionStart can surface "N unsaved bugfix-shape edits"
|
|
1411
1439
|
// alongside cite-recall. Same scan target (transcript already in OS
|
|
@@ -1422,11 +1450,13 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1422
1450
|
// always said. Stop fires once per assistant TURN, so incrementing here silenced
|
|
1423
1451
|
// the nudge inside the first session — this machine read lowStreak 58 against 26
|
|
1424
1452
|
// transcripts before the fix.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1453
|
+
// D#19: the streak advances on the SAME gate the nudge fires on, so the merged
|
|
1454
|
+
// stats go in. Handing it the wide triple alone would let the streak climb on a
|
|
1455
|
+
// verdict the SessionStart surface never reached — two policies, one counter.
|
|
1456
|
+
const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(prevPayload, ccSessionId, {
|
|
1457
|
+
...stats,
|
|
1458
|
+
...gate,
|
|
1459
|
+
});
|
|
1430
1460
|
// G3: finalized-in-conversation + zero deliberate persistence →
|
|
1431
1461
|
// decisionSignal rides the payload; next SessionStart reminds once.
|
|
1432
1462
|
let decisionSignal = null;
|
|
@@ -1449,6 +1479,7 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1449
1479
|
}
|
|
1450
1480
|
const payload = {
|
|
1451
1481
|
...stats,
|
|
1482
|
+
...gate,
|
|
1452
1483
|
...bugfixStats,
|
|
1453
1484
|
lowStreak,
|
|
1454
1485
|
streakBase,
|
|
@@ -1593,8 +1624,9 @@ async function handleStop() {
|
|
|
1593
1624
|
|
|
1594
1625
|
// Build the SessionStart nudge line shown when the prior session's cite-recall
|
|
1595
1626
|
// fell below threshold. Empty string = no surface (insufficient signal, recall
|
|
1596
|
-
// already healthy, or feature opted-out via env). Default threshold 0.
|
|
1597
|
-
// min injected 5 — both env-overridable for
|
|
1627
|
+
// already healthy, or feature opted-out via env). Default threshold 0.4 against the
|
|
1628
|
+
// HOOK-INJECTED denominator (v6.6.0, D#19), min injected 5 — both env-overridable for
|
|
1629
|
+
// ops tuning + tests; CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 restores the wide one.
|
|
1598
1630
|
// Thin wrapper: lib/cite-back-hint.mjs owns the logic so it stays unit-tested.
|
|
1599
1631
|
// Passing module-level RUNTIME_DIR keeps the call site identical to pre-v2.83.1.
|
|
1600
1632
|
function buildCiteRecallNudge(project) {
|
package/install.mjs
CHANGED
|
@@ -72,7 +72,6 @@ import {
|
|
|
72
72
|
} from './lib/binding-probe.mjs';
|
|
73
73
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
74
74
|
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
75
|
-
import { isDbUnusableError, dbUnusableRemedy } from './lib/db-unusable.mjs';
|
|
76
75
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
77
76
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
78
77
|
import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
@@ -442,12 +441,23 @@ export function nonPluginMemRegistrations(listOutput) {
|
|
|
442
441
|
* Exported for tests/doctor-db-remedy.test.mjs, which also drives the shipped doctor over a
|
|
443
442
|
* corrupt file — a pure function nothing calls is the wiring gap this repo keeps finding.
|
|
444
443
|
*/
|
|
445
|
-
export function dbCheckRemedy(dbPath, err) {
|
|
444
|
+
export async function dbCheckRemedy(dbPath, err) {
|
|
446
445
|
if (isNativeBindingError(err)) return `Repair: ${nativeBindingRepairHint(PROJECT_DIR)}`;
|
|
447
446
|
// Classification and remedy both live in lib/db-unusable.mjs since v6.5.0, because the hook
|
|
448
447
|
// path now has to answer the same question in-session and two copies of a SQLite-message
|
|
449
448
|
// regex is this repo's named twin-drift class. Doctor keeps its own SENTENCE (one line, no
|
|
450
449
|
// leading banner); only the decision is shared.
|
|
450
|
+
//
|
|
451
|
+
// Audit 2026-09-08 P1-1: loaded HERE, not at the top of the file. `lib/db-unusable.mjs`
|
|
452
|
+
// reaches utils.mjs through db-backup, and utils.mjs pulls the whole retrieval/NLP
|
|
453
|
+
// subtree (nlp / synonyms / stop-words / scoring-sql / secret-scrub / …). As a static
|
|
454
|
+
// import that made every one of those files a prerequisite for doctor STARTING, on a
|
|
455
|
+
// command whose whole job is to tell a user which file is missing — so a copy install
|
|
456
|
+
// short one of them answered with a bare ERR_MODULE_NOT_FOUND stack. This function runs
|
|
457
|
+
// only inside the Database check's catch, so the cost of loading it lazily is paid by
|
|
458
|
+
// the rare failure rather than by every invocation. Same shape as the six dynamic
|
|
459
|
+
// imports doctor already uses.
|
|
460
|
+
const { isDbUnusableError, dbUnusableRemedy } = await import('./lib/db-unusable.mjs');
|
|
451
461
|
if (!isDbUnusableError(err)) return null;
|
|
452
462
|
const remedy = dbUnusableRemedy(dbPath);
|
|
453
463
|
if (remedy.kind === 'unknown') return remedy.note;
|
|
@@ -1947,10 +1957,31 @@ async function doctor() {
|
|
|
1947
1957
|
}
|
|
1948
1958
|
|
|
1949
1959
|
// Plugin/hook lifecycle state
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1960
|
+
//
|
|
1961
|
+
// Audit 2026-09-08 P1-2: readSettings() THROWS on a settings.json that exists and does
|
|
1962
|
+
// not parse. That is the right answer for install / uninstall — every write path merges
|
|
1963
|
+
// into its return value, so refusing to act is the only safe move (R10 P1-8) — and the
|
|
1964
|
+
// wrong one to inherit here. doctor never writes settings.json, and a hand-edited
|
|
1965
|
+
// trailing comma is one of the most common self-inflicted "Claude Code is broken"
|
|
1966
|
+
// states, i.e. precisely when someone runs doctor. Inheriting the throw aborted the run
|
|
1967
|
+
// at this check: nine later checks never ran and `--json` emitted zero bytes.
|
|
1968
|
+
//
|
|
1969
|
+
// `null` means NOT READ, and each consumer says "not checked" rather than treating an
|
|
1970
|
+
// empty object as "nothing is configured". "I could not look" is not "there is nothing
|
|
1971
|
+
// there" — the same three-outcome rule the bash-hook and marketplace checks follow.
|
|
1972
|
+
let settings = null;
|
|
1973
|
+
try {
|
|
1974
|
+
settings = readSettings();
|
|
1975
|
+
} catch (e) {
|
|
1976
|
+
fail(`settings.json: unreadable — ${e.message}`);
|
|
1977
|
+
log(' The three checks that read it are skipped below; every other check still runs.');
|
|
1978
|
+
issues++;
|
|
1979
|
+
}
|
|
1980
|
+
const hasHooks = settings !== null && hasMemHooksConfigured(settings);
|
|
1981
|
+
const pluginDisabled = settings !== null && isPluginExplicitlyDisabled(settings);
|
|
1982
|
+
if (settings === null) {
|
|
1983
|
+
dwarn('Plugin lifecycle: not checked (settings.json unreadable)');
|
|
1984
|
+
} else if (pluginDisabled && hasHooks) {
|
|
1954
1985
|
fail('Plugin lifecycle: plugin is disabled but claude-mem-lite hooks still remain in settings.json');
|
|
1955
1986
|
issues++;
|
|
1956
1987
|
} else if (pluginDisabled) {
|
|
@@ -1988,8 +2019,10 @@ async function doctor() {
|
|
|
1988
2019
|
// with require-error noise every session. README's Uninstall section warns
|
|
1989
2020
|
// about the right ordering; this check flags the broken state so it surfaces
|
|
1990
2021
|
// even when the user skipped the README.
|
|
1991
|
-
const orphanPaths = collectOrphanHookPaths(settings);
|
|
1992
|
-
if (orphanPaths
|
|
2022
|
+
const orphanPaths = settings === null ? null : collectOrphanHookPaths(settings);
|
|
2023
|
+
if (orphanPaths === null) {
|
|
2024
|
+
dwarn('Orphan hooks: not checked (settings.json unreadable)');
|
|
2025
|
+
} else if (orphanPaths.length > 0) {
|
|
1993
2026
|
fail(
|
|
1994
2027
|
`Orphan hooks: ${orphanPaths.length} settings.json entr${orphanPaths.length === 1 ? 'y references a missing file' : 'ies reference missing files'}`,
|
|
1995
2028
|
);
|
|
@@ -2014,9 +2047,16 @@ async function doctor() {
|
|
|
2014
2047
|
const bare = nonPluginMemRegistrations(list);
|
|
2015
2048
|
// Registration, not directory — see pluginIsRegistered. Crediting a leftover cache dir
|
|
2016
2049
|
// here told a working npm-channel install to delete its ONLY MCP registration.
|
|
2050
|
+
// Reuses the `settings` read above rather than calling readSettings() a second time:
|
|
2051
|
+
// the second call carried the same throw, so guarding only the first would have moved
|
|
2052
|
+
// the abort eleven checks later instead of removing it (P1-2).
|
|
2017
2053
|
const viaPlugin =
|
|
2018
|
-
!!shape?.activePluginVersion && pluginIsRegistered({ home: homedir(), settings
|
|
2019
|
-
if (
|
|
2054
|
+
!!shape?.activePluginVersion && settings !== null && pluginIsRegistered({ home: homedir(), settings });
|
|
2055
|
+
if (settings === null) {
|
|
2056
|
+
dwarn(
|
|
2057
|
+
`MCP registration: found ${bare.length} bare registration(s); whether the plugin also provides one is not checked (settings.json unreadable)`,
|
|
2058
|
+
);
|
|
2059
|
+
} else if (viaPlugin && bare.length > 0) {
|
|
2020
2060
|
dwarn(
|
|
2021
2061
|
`MCP registration: the plugin manifest provides the server AND a bare "${bare.join('", "')}" registration exists — the server is registered twice`,
|
|
2022
2062
|
);
|
|
@@ -2106,7 +2146,17 @@ async function doctor() {
|
|
|
2106
2146
|
// Every other ✗ on this screen carries a remedy; this one used to be the exception,
|
|
2107
2147
|
// and a corrupt store is the failure a user is least able to diagnose unaided.
|
|
2108
2148
|
// dbCheckRemedy returns null rather than invent one for an error it cannot classify.
|
|
2109
|
-
|
|
2149
|
+
// Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
|
|
2150
|
+
// its failure mode from load time into THIS catch — and an unhandled rejection here
|
|
2151
|
+
// aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
|
|
2152
|
+
// on the compound shape "a file is missing AND the database will not open". A remedy
|
|
2153
|
+
// is an extra sentence on a check that has already failed; never let it take the run.
|
|
2154
|
+
let remedy = null;
|
|
2155
|
+
try {
|
|
2156
|
+
remedy = await dbCheckRemedy(DB_PATH, e);
|
|
2157
|
+
} catch (remedyErr) {
|
|
2158
|
+
log(` (could not build a repair hint: ${remedyErr.message})`);
|
|
2159
|
+
}
|
|
2110
2160
|
if (remedy) log(` ${remedy}`);
|
|
2111
2161
|
issues++;
|
|
2112
2162
|
}
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -189,8 +189,11 @@ export function countUnsavedBugfixShape(transcriptPath) {
|
|
|
189
189
|
// ─── buildCiteRecallNudge (extracted from hook.mjs for unit-testability) ────
|
|
190
190
|
// Reads `runtime/cite-recall-<project>.json` (written by handleStop) and
|
|
191
191
|
// builds the SessionStart nudge surface. Two independent gates compose:
|
|
192
|
-
// • cite-recall ratio gate: prior session's ratio <
|
|
193
|
-
// AND injected count >= floor (default 5)
|
|
192
|
+
// • cite-recall ratio gate: prior session's HOOK-INJECTED cite-recall ratio <
|
|
193
|
+
// threshold (default 0.4) AND that same injected count >= floor (default 5).
|
|
194
|
+
// D#19: the wide computeCiteRecall ratio is the fallback for payloads written
|
|
195
|
+
// before v6.6.0, and the forced reading under
|
|
196
|
+
// CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1.
|
|
194
197
|
// • B2 (v2.83.1) unsaved-bugfix gate: `unsaved > 0` (no min-volume floor —
|
|
195
198
|
// the bugfix-shape heuristic already requires ≥3 entries)
|
|
196
199
|
// Either gate can fire independently. Both off → empty string (no surface).
|
|
@@ -204,16 +207,66 @@ export function countUnsavedBugfixShape(transcriptPath) {
|
|
|
204
207
|
// CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER (0 = never silence).
|
|
205
208
|
export const CITE_NUDGE_SILENCE_AFTER = 3;
|
|
206
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Which (injected, recalled, ratio) triple the gate judges, and whether it is the
|
|
212
|
+
* hook-injected one.
|
|
213
|
+
*
|
|
214
|
+
* D#19. `computeCiteRecall`'s denominator is deliberately wide — every `#NN`-shaped
|
|
215
|
+
* token in non-assistant text, which includes tool_result bodies, file contents, CLI
|
|
216
|
+
* output and pasted reports. That is the right caliber for "which ids has the model
|
|
217
|
+
* SEEN", and it is not the question this nag asks. The nag asks whether the model cited
|
|
218
|
+
* back the lessons the HOOKS put in front of it, and against the wide denominator that
|
|
219
|
+
* ratio is structurally small: measured 2026-09-08 over all 69 transcripts on this
|
|
220
|
+
* machine, wide median 0.125 vs hook-injected median 0.429, and the shipped 0.6
|
|
221
|
+
* threshold fired on 46 of 48 qualifying sessions (96%).
|
|
222
|
+
*
|
|
223
|
+
* A gate that is 96% true carries almost no information, and because `nextCiteLowStreak`
|
|
224
|
+
* only resets when the gate does NOT fire, the streak climbs to CITE_NUDGE_SILENCE_AFTER
|
|
225
|
+
* within the first few sessions and the surface goes quiet for the life of the project.
|
|
226
|
+
* That — not "the threshold is unsatisfiable", which was D#19's premise and is false
|
|
227
|
+
* (max ratio is 0.833 today among the sessions that clear the volume floor, which is the
|
|
228
|
+
* population the gate judges; over all 69 transcripts the hook-injected side reaches 1.000
|
|
229
|
+
* — state which rows, the two differ) — is the defect.
|
|
230
|
+
*
|
|
231
|
+
* Back-compat: a payload written before this release has no `gate*` keys, so the wide
|
|
232
|
+
* triple is used and the only change such a project sees is the threshold. Full revert:
|
|
233
|
+
* CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 plus CLAUDE_MEM_CITE_NUDGE_THRESHOLD=0.6.
|
|
234
|
+
*/
|
|
235
|
+
function gateStats(data, env) {
|
|
236
|
+
const wide = {
|
|
237
|
+
injected: data?.injected,
|
|
238
|
+
recalled: data?.recalled,
|
|
239
|
+
ratio: data?.ratio,
|
|
240
|
+
hookScoped: false,
|
|
241
|
+
};
|
|
242
|
+
if (env.CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR === '1') return wide;
|
|
243
|
+
if (typeof data?.gateInjected === 'number' && typeof data?.gateRatio === 'number') {
|
|
244
|
+
return {
|
|
245
|
+
injected: data.gateInjected,
|
|
246
|
+
recalled: data.gateRecalled,
|
|
247
|
+
ratio: data.gateRatio,
|
|
248
|
+
hookScoped: true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return wide;
|
|
252
|
+
}
|
|
253
|
+
|
|
207
254
|
// True iff this session's stats satisfy the ratio-nag gate (low cite-recall with
|
|
208
255
|
// enough injection volume to judge). Shared by buildCiteRecallNudge (decide to
|
|
209
256
|
// nag) and nextCiteLowStreak (decide to keep silencing).
|
|
210
|
-
function ratioGateFires(
|
|
257
|
+
function ratioGateFires(rawData, env) {
|
|
258
|
+
const data = gateStats(rawData, env);
|
|
211
259
|
// `Number(x) || d` was NaN-safe but swallowed an explicit 0, and 0 is meaningful
|
|
212
260
|
// on BOTH knobs: threshold 0 means "never nag on ratio", min-injected 0 means "no
|
|
213
261
|
// volume requirement". Neither was reachable through the env before.
|
|
262
|
+
// D#19: 0.6 → 0.4. Chosen off the hook-injected distribution measured 2026-09-08 on
|
|
263
|
+
// this machine's 69 transcripts (n=14 qualifying: p25 0.333, median 0.429, p75 0.5),
|
|
264
|
+
// where 0.4 fires on 6/14 = 43% — often enough to be a signal, rarely enough that
|
|
265
|
+
// lowStreak resets and the surface survives. Stamped rather than presented as
|
|
266
|
+
// calibrated: n=14 is small and the corpus grows every session.
|
|
214
267
|
const threshold = envNumber(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD, {
|
|
215
268
|
name: 'CLAUDE_MEM_CITE_NUDGE_THRESHOLD',
|
|
216
|
-
defaultValue: 0.
|
|
269
|
+
defaultValue: 0.4,
|
|
217
270
|
min: 0,
|
|
218
271
|
max: 1,
|
|
219
272
|
});
|
|
@@ -291,7 +344,9 @@ export function nextCiteStreakState(prev, ccSessionId, stats, env = process.env)
|
|
|
291
344
|
|
|
292
345
|
// Env opt-outs:
|
|
293
346
|
// • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
|
|
294
|
-
// • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.
|
|
347
|
+
// • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.4)
|
|
348
|
+
// • CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 — judge the wide computeCiteRecall
|
|
349
|
+
// ratio instead of the hook-injected one (pre-v6.6.0 behaviour)
|
|
295
350
|
// • CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED — ratio gate min-volume (default 5)
|
|
296
351
|
// • CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER — consecutive-low streak before the
|
|
297
352
|
// ratio nag self-silences (default 3; 0 = never silence)
|
|
@@ -313,9 +368,15 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
|
|
|
313
368
|
const silenced = silenceAfter > 0 && typeof data.lowStreak === 'number' && data.lowStreak >= silenceAfter;
|
|
314
369
|
const lines = [];
|
|
315
370
|
if (!silenced && ratioGateFires(data, env)) {
|
|
316
|
-
|
|
371
|
+
// Report the SAME triple the gate judged — printing the wide numbers next to a
|
|
372
|
+
// narrow verdict is how a surface stops being auditable.
|
|
373
|
+
const g = gateStats(data, env);
|
|
374
|
+
const pct = Math.round(g.ratio * 100);
|
|
375
|
+
// The `(recalled/injected)` parenthetical keeps its exact shape — two guards
|
|
376
|
+
// assert on it, and it is the part a reader scans for.
|
|
377
|
+
const scope = g.hookScoped ? 'of the lessons the hooks injected' : 'of the ids seen';
|
|
317
378
|
lines.push(
|
|
318
|
-
`[mem] Last session cite-recall ${pct}% (${
|
|
379
|
+
`[mem] Last session cite-recall ${pct}% (${g.recalled}/${g.injected}) ${scope} — when injected lessons (#NN lines) inform your action, cite #NN explicitly so the contract loop stays observable.`,
|
|
319
380
|
);
|
|
320
381
|
}
|
|
321
382
|
if (typeof data.unsaved === 'number' && data.unsaved > 0) {
|
package/lib/save-observation.mjs
CHANGED
|
@@ -230,7 +230,7 @@ export function saveObservation(db, params) {
|
|
|
230
230
|
`
|
|
231
231
|
SELECT id, title, text FROM observations
|
|
232
232
|
WHERE project = ? AND created_at_epoch > ? AND ${liveObsFilterSql('')}
|
|
233
|
-
ORDER BY created_at_epoch DESC LIMIT ?
|
|
233
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT ?
|
|
234
234
|
`,
|
|
235
235
|
)
|
|
236
236
|
.all(project, dedupCutoff, DEDUP_RECENT_LIMIT);
|