claude-mem-lite 3.76.1 → 3.77.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/hook-memory.mjs +36 -48
- package/hook.mjs +36 -0
- package/lib/citation-tracker.mjs +92 -2
- package/lib/file-edge-match.mjs +40 -4
- package/lib/recall-core.mjs +11 -6
- package/mem-cli.mjs +17 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schema.mjs +1 -1
- package/scripts/pre-tool-recall.js +9 -2
- package/scripts/user-prompt-search.js +9 -5
- package/tool-schemas.mjs +1 -1
- package/utils.mjs +9 -14
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.77.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.77.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/hook-memory.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// claude-mem-lite — Semantic Memory Injection
|
|
2
2
|
// Search past observations for relevant memories to inject as context at user-prompt time.
|
|
3
3
|
|
|
4
|
-
import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters
|
|
4
|
+
import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters } from './utils.mjs';
|
|
5
5
|
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
6
6
|
import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
|
|
7
7
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
@@ -98,8 +98,6 @@ function candidateCoverage(row, queryTerms) {
|
|
|
98
98
|
return hits / queryTerms.length;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
const FILE_RECALL_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
102
|
-
const MAX_FILE_RECALL = 2;
|
|
103
101
|
|
|
104
102
|
// P1: stale-obs verify-before-use threshold. An injected obs older than this
|
|
105
103
|
// AND carrying file paths is flagged so Claude is reminded to grep/Read the
|
|
@@ -358,8 +356,27 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
358
356
|
// v26 P0: bump injection_count (NOT access_count) for injected rows.
|
|
359
357
|
// Before v26 this was bumping access_count, which conflated auto-injection
|
|
360
358
|
// with real cites/recalls/opens — polluting the noise-ratio signal the
|
|
361
|
-
// penalty clause now depends on.
|
|
362
|
-
//
|
|
359
|
+
// penalty clause now depends on.
|
|
360
|
+
//
|
|
361
|
+
// The two counters are NOT a metering pair, and reading them as one is how
|
|
362
|
+
// 2026-08-22 produced a wrong diagnosis off this very comment. Enumerated
|
|
363
|
+
// from the write sites rather than from memory:
|
|
364
|
+
// access_count — lib/recall-core.mjs (mem_recall / CLI recall),
|
|
365
|
+
// lib/get-core.mjs (mem_get), lib/timeline-core.mjs
|
|
366
|
+
// (timeline anchor), lib/citation-tracker.mjs (CITED
|
|
367
|
+
// ids only). All explicit. This comment used to list
|
|
368
|
+
// "pre-tool-recall" here too; scripts/pre-tool-recall.js
|
|
369
|
+
// bumps NOTHING, and the only code that would have was
|
|
370
|
+
// the unreferenced `recallForFile` twin deleted below.
|
|
371
|
+
// injection_count — this line and scripts/user-prompt-search.js only, and
|
|
372
|
+
// deliberately so: scoring-sql.mjs noisePenaltyClause
|
|
373
|
+
// reads it as a NOISE signal (x0.5 at >=4, x0.2 at >=8),
|
|
374
|
+
// so it is valid only on QUERY-CONDITIONED faces. v3.66.0
|
|
375
|
+
// added an unconditional Key Context bump "mirroring"
|
|
376
|
+
// this one and v3.66.1 reverted it — an always-rendered
|
|
377
|
+
// face measures elapsed sessions, not noise (D#124,
|
|
378
|
+
// lib/keyctx-marker.mjs:53). The complete per-face
|
|
379
|
+
// denominator is citation_surface_log, not this column.
|
|
363
380
|
// Per-row try/catch for FTS trigger safety (project_non_obvious.md).
|
|
364
381
|
const result = coverageFiltered.slice(0, MAX_MEMORY_INJECTIONS);
|
|
365
382
|
const now = Date.now();
|
|
@@ -380,49 +397,20 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
380
397
|
}
|
|
381
398
|
}
|
|
382
399
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const cutoff = Date.now() - FILE_RECALL_LOOKBACK_MS;
|
|
398
|
-
// Escape SQL LIKE wildcards in filename to prevent injection
|
|
399
|
-
const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
400
|
-
const likePattern = `%${escaped}`;
|
|
401
|
-
const rows = db.prepare(`
|
|
402
|
-
SELECT DISTINCT o.id, o.type, o.title, o.importance, o.lesson_learned
|
|
403
|
-
FROM observations o
|
|
404
|
-
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
405
|
-
WHERE o.project = ?
|
|
406
|
-
AND o.importance >= 2
|
|
407
|
-
AND ${liveObsFilterSql('o')}
|
|
408
|
-
AND o.created_at_epoch > ?
|
|
409
|
-
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
410
|
-
ORDER BY o.created_at_epoch DESC
|
|
411
|
-
LIMIT ?
|
|
412
|
-
`).all(project, cutoff, filePath, likePattern, MAX_FILE_RECALL);
|
|
413
|
-
const now = Date.now();
|
|
414
|
-
const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?');
|
|
415
|
-
// Per-row try/catch for FTS trigger safety — mirror the injection-bump loop
|
|
416
|
-
// (searchRelevantMemories) and project_non_obvious.md. Without it, one
|
|
417
|
-
// SQLITE_CORRUPT_VTAB on the access_count UPDATE trigger throws to the outer
|
|
418
|
-
// catch and discards the ENTIRE file-recall result set.
|
|
419
|
-
for (const r of rows) { try { updateStmt.run(now, r.id); } catch {} }
|
|
420
|
-
return rows;
|
|
421
|
-
} catch (e) {
|
|
422
|
-
debugCatch(e, 'recallForFile');
|
|
423
|
-
return [];
|
|
424
|
-
}
|
|
425
|
-
}
|
|
400
|
+
// `recallForFile` lived here until 2026-08-22: an in-process file-recall
|
|
401
|
+
// implementation with ZERO production callers, superseded by the standalone
|
|
402
|
+
// scripts/pre-tool-recall.js hook (which owns the cooldown, scope filter,
|
|
403
|
+
// edge-decay filter and event leg this function never had). Five test files
|
|
404
|
+
// asserted against it, which made it look alive and cost real money twice:
|
|
405
|
+
// - its bare `%<basename>` LIKE lacked the path-boundary arms that
|
|
406
|
+
// lib/file-edge-match.mjs added for the bash-utils.mjs/utils.mjs collision;
|
|
407
|
+
// - it was the ONLY code splitting basenames on either separator, so the six
|
|
408
|
+
// Windows-path tests aimed at it went green while the shipped predicate
|
|
409
|
+
// carried the gap (fixed in lib/file-edge-match.mjs, same round).
|
|
410
|
+
// Those suites now run through `fileEdgeMatchOnly` (tests/test-helpers.mjs),
|
|
411
|
+
// which calls the shipped MATCH clause — and only that clause; the rest of the
|
|
412
|
+
// injection query is guarded by the subprocess cases in
|
|
413
|
+
// tests/pre-tool-recall.test.mjs. Do not reintroduce an in-process twin here.
|
|
426
414
|
|
|
427
415
|
/**
|
|
428
416
|
* Phase-2 task-imperative ranking (spec 2026-06-29 §4.1): score every candidate lesson
|
package/hook.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
applyCitationDecay,
|
|
63
63
|
recordCitationFunnel,
|
|
64
64
|
recordCitationSurfaces,
|
|
65
|
+
collectSubagentSurface,
|
|
65
66
|
hasMainThreadAssistantText,
|
|
66
67
|
} from './lib/citation-tracker.mjs';
|
|
67
68
|
import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
|
|
@@ -860,6 +861,41 @@ async function handleStop() {
|
|
|
860
861
|
const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
|
|
861
862
|
writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
|
|
862
863
|
} catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
|
|
864
|
+
|
|
865
|
+
// D#152: the `subagent` face. Recorded in its OWN
|
|
866
|
+
// recordCitationSurfaces call because it carries a different `cited`
|
|
867
|
+
// set — a lesson handed to a dispatched subagent is cited in that
|
|
868
|
+
// subagent's own transcript. Folding it into the main call would score
|
|
869
|
+
// subagent injections against citedMain and report 0% by
|
|
870
|
+
// construction; folding its cites INTO citedMain would credit the
|
|
871
|
+
// main-thread faces for citations the main thread never made. The
|
|
872
|
+
// upsert key is (project, session, surface), so two calls with
|
|
873
|
+
// disjoint face sets do not collide. Metering only — `subagent` is in
|
|
874
|
+
// NON_ATTACHMENT_SURFACES and never reaches applyCitationDecay.
|
|
875
|
+
//
|
|
876
|
+
// Placed LAST on purpose: lib/transcript-scan.mjs memoizes ONE file,
|
|
877
|
+
// so reading the sidechain files evicts the parent transcript. Run
|
|
878
|
+
// earlier, this block costs ONE extra parse of the parent — the memo
|
|
879
|
+
// re-caches on the first re-read, so it is one, not one per later
|
|
880
|
+
// scanner — and breaks the "one parse per Stop" property the block
|
|
881
|
+
// above documents. Measured by instrumenting the parse: 1 parent parse
|
|
882
|
+
// at this position, 2 when relocated earlier. ~25ms on the largest
|
|
883
|
+
// real transcript here (5.4MB), matching lib/transcript-scan.mjs's
|
|
884
|
+
// own header figure.
|
|
885
|
+
// The text floor is re-checked rather than inherited — same reason as
|
|
886
|
+
// every other face: a tool-only Stop must not bank a verdict, and it
|
|
887
|
+
// must not enter the funnel's session denominator either.
|
|
888
|
+
try {
|
|
889
|
+
if (hasMainThreadAssistantText(transcriptPath)) {
|
|
890
|
+
const sub = collectSubagentSurface(transcriptPath);
|
|
891
|
+
if (sub.injected.size > 0) {
|
|
892
|
+
recordCitationSurfaces(db, project, ccSessionId || sessionId,
|
|
893
|
+
{ subagent: sub.injected }, sub.cited);
|
|
894
|
+
debugLog('DEBUG', 'handleStop',
|
|
895
|
+
`subagent-face: files=${sub.files} injected=${sub.injected.size} cited=${sub.cited.size}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch (e) { debugCatch(e, 'handleStop-subagent-face'); }
|
|
863
899
|
}
|
|
864
900
|
} catch (e) { debugCatch(e, 'handleStop-citation-track'); }
|
|
865
901
|
} finally {
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -246,9 +246,14 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
|
|
|
246
246
|
* imperative line — which is how it stayed unmetered since v3.23 while being a live
|
|
247
247
|
* injection: the `ups` matcher gates on `<memory-context` and collects only `- [` rows.
|
|
248
248
|
* The two faces therefore OVERLAP on attachments but never on ids.
|
|
249
|
-
*
|
|
249
|
+
* `subagent` (v3.77, D#152) is the second one: pre-agent-inject.js appends the
|
|
250
|
+
* memory block to a DISPATCHED subagent's task prompt via PreToolUse
|
|
251
|
+
* `updatedInput`, and Claude Code writes that turn to
|
|
252
|
+
* `<session>/subagents/agent-*.jsonl` — never to the parent transcript. See
|
|
253
|
+
* NON_ATTACHMENT_SURFACES.
|
|
254
|
+
* @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'|'subagent'>}
|
|
250
255
|
*/
|
|
251
|
-
export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx'];
|
|
256
|
+
export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx', 'subagent'];
|
|
252
257
|
|
|
253
258
|
// Single source of truth for "which attachment belongs to which face, and how
|
|
254
259
|
// its ids are read off". Both the per-face extractors below AND the one-pass
|
|
@@ -499,6 +504,30 @@ const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
|
|
|
499
504
|
/** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
|
|
500
505
|
export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
|
|
501
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Faces that leave NO hook attachment in the parent transcript. Because
|
|
509
|
+
* DECAY_DENOMINATOR_SURFACES is derived from ATTACHMENT_SURFACES, these can
|
|
510
|
+
* never enter the decay denominator by the derivation — they enter only if a
|
|
511
|
+
* call site unions them in deliberately. That silence is the danger, so each is
|
|
512
|
+
* named here with the reason it stays out:
|
|
513
|
+
*
|
|
514
|
+
* - `keyctx` (D#124): the SessionStart Key Context block re-renders the same
|
|
515
|
+
* fixed top-10 unconditionally, so an uncited render says nothing about
|
|
516
|
+
* relevance. handleStop feeds its ids in PROMOTION-ONLY — cited ones join the
|
|
517
|
+
* decay set, ignored ones never do. v3.66.0 fed them as a bare denominator
|
|
518
|
+
* and the block ate its own contents.
|
|
519
|
+
* - `subagent` (D#152): the injection lands in a dispatched subagent's PROMPT
|
|
520
|
+
* and its cite signal lands in that subagent's OWN transcript, so it is not
|
|
521
|
+
* commensurable with `citedMain` — scoring it there would mark every
|
|
522
|
+
* subagent-only injection uncited by construction. Metered first (this is the
|
|
523
|
+
* whole point of D#152: the face's cite-rate is unknown), decided second.
|
|
524
|
+
*
|
|
525
|
+
* A member here that the Stop path stops feeding becomes an all-zero face, not
|
|
526
|
+
* a silently-demoting one — which is the failure mode worth keeping.
|
|
527
|
+
* @type {ReadonlyArray<string>}
|
|
528
|
+
*/
|
|
529
|
+
export const NON_ATTACHMENT_SURFACES = ['keyctx', 'subagent'];
|
|
530
|
+
|
|
502
531
|
/**
|
|
503
532
|
* Flatten a per-face breakdown into the single injected set the decay loop
|
|
504
533
|
* takes. Derived — NOT a second hand-maintained face list — so adding a face to
|
|
@@ -565,6 +594,67 @@ export function extractInjectedFromSubagentPrompt(transcriptPath) {
|
|
|
565
594
|
return ids;
|
|
566
595
|
}
|
|
567
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Locate the sidechain (subagent) transcripts belonging to ONE session, given
|
|
599
|
+
* that session's main transcript path. Claude Code lays them out as
|
|
600
|
+
* `<dir>/<sessionId>.jsonl` + `<dir>/<sessionId>/subagents/agent-<name>-<hash>.jsonl`,
|
|
601
|
+
* so the directory is derived from the parent path — no directory scan, no
|
|
602
|
+
* dependence on the CC session id being available at the call site.
|
|
603
|
+
*
|
|
604
|
+
* Two search shapes that look right and silently return nothing (#10801, and the
|
|
605
|
+
* reason D#152 sat blocked): the files are NOT at the transcript directory's top
|
|
606
|
+
* level, and grepping the PARENT transcript for `isSidechain` finds 0 records —
|
|
607
|
+
* the flag lives inside the subagent files themselves.
|
|
608
|
+
*
|
|
609
|
+
* @param {string|null|undefined} transcriptPath main-thread transcript (.jsonl)
|
|
610
|
+
* @returns {string[]} absolute paths, empty when the session dispatched no subagents
|
|
611
|
+
*/
|
|
612
|
+
export function findSubagentTranscripts(transcriptPath) {
|
|
613
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return [];
|
|
614
|
+
if (!transcriptPath.endsWith('.jsonl')) return [];
|
|
615
|
+
const dir = join(transcriptPath.slice(0, -'.jsonl'.length), 'subagents');
|
|
616
|
+
let names;
|
|
617
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
618
|
+
return names.filter((n) => n.endsWith('.jsonl')).map((n) => join(dir, n)).sort();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Read the `subagent` face for ONE session: ids injected into dispatched
|
|
623
|
+
* subagents' prompts, and the ids those subagents actually cited.
|
|
624
|
+
*
|
|
625
|
+
* `cited` is read from the SUBAGENT transcripts, not the parent — a lesson
|
|
626
|
+
* handed to a subagent is cited (or not) in that subagent's own text, and
|
|
627
|
+
* scoring it against the main thread would report 0% by construction. That is
|
|
628
|
+
* also why the Stop path records this face in its own recordCitationSurfaces
|
|
629
|
+
* call: one call carries one `cited` set for every face in it.
|
|
630
|
+
*
|
|
631
|
+
* UNIT — read this before reading the rate it produces. Both sets are unioned
|
|
632
|
+
* across ALL of the session's sidechain files, so the resulting rate is
|
|
633
|
+
* "fraction of injected ids that appear anywhere in any sidechain of this
|
|
634
|
+
* session", NOT per-dispatch adoption. An id handed to agent A and cited by
|
|
635
|
+
* agent B counts as a hit, and an id handed to three agents and cited by one
|
|
636
|
+
* counts as one full hit rather than a third. Observed in real data (review of
|
|
637
|
+
* v3.77.0): 1 of this project's 7 historical hits is exactly that shape. The
|
|
638
|
+
* number is therefore biased HIGH against per-dispatch adoption — which matters
|
|
639
|
+
* because D#152's instruction is to read this rate and then decide whether the
|
|
640
|
+
* face enters the decay denominator (D#164). Per-dispatch attribution would
|
|
641
|
+
* need per-file accounting, deliberately not built yet.
|
|
642
|
+
*
|
|
643
|
+
* @param {string|null|undefined} transcriptPath main-thread transcript (.jsonl)
|
|
644
|
+
* @returns {{injected: Set<number>, cited: Set<number>, files: number}}
|
|
645
|
+
*/
|
|
646
|
+
export function collectSubagentSurface(transcriptPath) {
|
|
647
|
+
const injected = new Set();
|
|
648
|
+
const cited = new Set();
|
|
649
|
+
let files = 0;
|
|
650
|
+
for (const p of findSubagentTranscripts(transcriptPath)) {
|
|
651
|
+
files++;
|
|
652
|
+
for (const id of extractInjectedFromSubagentPrompt(p)) injected.add(id);
|
|
653
|
+
for (const id of extractCitationsFromTranscript(p)) cited.add(id);
|
|
654
|
+
}
|
|
655
|
+
return { injected, cited, files };
|
|
656
|
+
}
|
|
657
|
+
|
|
568
658
|
export function computeThreadCiteRecall(transcriptPath) {
|
|
569
659
|
const injected = extractAllInjected(transcriptPath);
|
|
570
660
|
// Subagent files carry NO hook-attachment injection; pre-agent-inject.js injects into
|
package/lib/file-edge-match.mjs
CHANGED
|
@@ -20,9 +20,30 @@
|
|
|
20
20
|
// LIKE wildcards in the basename are escaped (sqlite gotcha #9); LIKE itself
|
|
21
21
|
// is ASCII-case-insensitive, matching arm 1/2's NOCASE.
|
|
22
22
|
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// The basename split accepts EITHER separator regardless of host OS. node:path
|
|
24
|
+
// `basename` is host-native: on a POSIX host it does not treat '\' as a
|
|
25
|
+
// separator, so `basename('C:\\proj\\src\\x.mjs')` returns the WHOLE path and
|
|
26
|
+
// arms 2-4 degrade to garbage — a Windows-shaped payload then recalls nothing.
|
|
27
|
+
// That mattered because the header above declares filename heterogeneous with
|
|
28
|
+
// EITHER separator, and hook payloads carry the CLIENT machine's path shape.
|
|
29
|
+
// The correct split existed only in `recallForFile` (hook-memory.mjs), a twin
|
|
30
|
+
// with no production caller, and the Windows tests asserted against the twin —
|
|
31
|
+
// so the shipped half carried the gap unobserved until 2026-08-22.
|
|
32
|
+
//
|
|
33
|
+
// Accepting both separators WIDENS matching for one exotic case, on the record as a
|
|
34
|
+
// decision rather than a side effect: '\' is a legal POSIX filename character, so a
|
|
35
|
+
// file literally named `b\c.mjs` now derives to `c.mjs` and can match observations
|
|
36
|
+
// recorded against `c.mjs`. Arm 4 (`%\<basename>`) still catches the old spelling, so
|
|
37
|
+
// a pre-tag review measured zero lost matches across 9 probes × 15 stored filename
|
|
38
|
+
// shapes — the change is purely additive. Real exposure is nil: 0 of 6406
|
|
39
|
+
// observation_files rows on the maintainer's DB contain a backslash. A recall system
|
|
40
|
+
// over-recalling a hypothetical file is the right side to err on.
|
|
41
|
+
//
|
|
42
|
+
// Dependency-free on purpose: pre-tool-recall.js is a ~30ms cold-start script and
|
|
43
|
+
// imports nothing from utils.mjs (which pulls in child_process and five modules),
|
|
44
|
+
// so the split is inlined below rather than imported. utils.mjs used to export the
|
|
45
|
+
// same two lines as `basenameAnySep`; that copy was deleted in the same round once
|
|
46
|
+
// its only consumer went, so this file is now the sole home.
|
|
26
47
|
|
|
27
48
|
/**
|
|
28
49
|
* SQL boolean expression for the four-arm match. Placeholder order matches
|
|
@@ -34,9 +55,24 @@ export function fileMatchClause(alias = '') {
|
|
|
34
55
|
`OR ${p}filename LIKE ? ESCAPE '\\' OR ${p}filename LIKE ? ESCAPE '\\')`;
|
|
35
56
|
}
|
|
36
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Last path segment, splitting on '/' OR '\' whatever the host OS is.
|
|
60
|
+
* THE only copy in the repo — keep it that way, and import it rather than
|
|
61
|
+
* re-deriving. A second copy is what produced the gap this replaced: the
|
|
62
|
+
* derivation existed twice and the tests asserted the one that did not ship.
|
|
63
|
+
* Exported for the one caller that needs the key without the SQL
|
|
64
|
+
* (scripts/pre-tool-recall.js's events leg, which matches a JSON array in a
|
|
65
|
+
* TEXT column rather than the observation_files junction).
|
|
66
|
+
* Not for filesystem access — '\' is a legal POSIX filename character.
|
|
67
|
+
*/
|
|
68
|
+
export function basenameAnySep(p) {
|
|
69
|
+
const s = String(p ?? '').replace(/[/\\]+$/, '');
|
|
70
|
+
return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
/** Bind values for fileMatchClause, in placeholder order. */
|
|
38
74
|
export function fileMatchParams(filePath) {
|
|
39
|
-
const fname =
|
|
75
|
+
const fname = basenameAnySep(filePath);
|
|
40
76
|
const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
41
77
|
// `%\\` before the basename: under ESCAPE '\', a literal backslash is
|
|
42
78
|
// written '\\' — so the JS string carries two backslash characters.
|
package/lib/recall-core.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
|
|
6
6
|
// contract lives here.
|
|
7
7
|
|
|
8
|
-
import { basename } from 'path';
|
|
9
8
|
import { notLowSignalTitleClause } from '../utils.mjs';
|
|
10
9
|
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
10
|
+
import { fileMatchClause, fileMatchParams, basenameAnySep } from './file-edge-match.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Recall observations linked to a file (basename or full path). Returns
|
|
@@ -23,9 +23,14 @@ import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
|
23
23
|
* and must not reach this clause: nobody asks for retracted content.
|
|
24
24
|
*/
|
|
25
25
|
export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Shared predicate, not a hand-rolled one (pre-tag review of v3.76.2, SF-1/S3).
|
|
27
|
+
// This face carried BOTH defects v3.76.2 fixed in the injection path: node:path
|
|
28
|
+
// `basename` (so a Windows-shaped argument derived to the whole string and matched
|
|
29
|
+
// nothing) and a bare `%<basename>` suffix LIKE with no path boundary (so recalling
|
|
30
|
+
// `utils.mjs` returned `bash-utils.mjs` lessons). recallByFile is mem_recall (MCP)
|
|
31
|
+
// AND the CLI `recall` command, so both surfaces were wrong. fileMatchClause's
|
|
32
|
+
// four arms and fileMatchParams' escaping are the single home for this.
|
|
33
|
+
const filename = basenameAnySep(file);
|
|
29
34
|
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
30
35
|
const rows = db.prepare(`
|
|
31
36
|
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
|
|
@@ -33,11 +38,11 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
33
38
|
FROM observations o
|
|
34
39
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
35
40
|
WHERE ${liveObsFilterSql('o')}
|
|
36
|
-
AND (of2
|
|
41
|
+
AND ${fileMatchClause('of2')}
|
|
37
42
|
${noiseClause}
|
|
38
43
|
ORDER BY o.created_at_epoch DESC
|
|
39
44
|
LIMIT ?
|
|
40
|
-
`).all(
|
|
45
|
+
`).all(...fileMatchParams(file), limit);
|
|
41
46
|
|
|
42
47
|
if (rows.length > 0) {
|
|
43
48
|
const ph = rows.map(() => '?').join(',');
|
package/mem-cli.mjs
CHANGED
|
@@ -64,7 +64,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
64
64
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
65
65
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
66
66
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
67
|
-
import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation-tracker.mjs';
|
|
67
|
+
import { computeCitationFunnelTrend, computeSurfaceFunnel, DECAY_DENOMINATOR_SURFACES } from './lib/citation-tracker.mjs';
|
|
68
68
|
|
|
69
69
|
// Human labels for citation_surface_log.surface. Padded to a common width so
|
|
70
70
|
// the citation-stats face table lines up; the enum itself lives in
|
|
@@ -74,8 +74,9 @@ const SURFACE_LABELS = {
|
|
|
74
74
|
ups: 'UserPromptSubmit ',
|
|
75
75
|
error_recall: 'error-recall ',
|
|
76
76
|
fyi: 'FYI (prompt-search)',
|
|
77
|
-
task_imperative: 'task-imperative
|
|
77
|
+
task_imperative: 'task-imperative ',
|
|
78
78
|
keyctx: 'Key Context ',
|
|
79
|
+
subagent: 'subagent (dispatch)',
|
|
79
80
|
};
|
|
80
81
|
import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
|
|
81
82
|
import {
|
|
@@ -2154,7 +2155,8 @@ function cmdMaintain(db, args) {
|
|
|
2154
2155
|
if (ops.includes('demote_pinned')) {
|
|
2155
2156
|
// Repair the citation-decay blind spot: decay protects injection_count>0, so a
|
|
2156
2157
|
// heavily-injected-but-uncited memory stays pinned at max importance forever.
|
|
2157
|
-
// demotePinned (maintain-core)
|
|
2158
|
+
// demotePinned (maintain-core) floors it in one pass: no lesson_learned -> 1,
|
|
2159
|
+
// lesson-bearing -> 2 (v3.76.1 dual floor). Floor, not purge.
|
|
2158
2160
|
const demoted = demotePinned(db, mctx);
|
|
2159
2161
|
results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
|
|
2160
2162
|
}
|
|
@@ -2648,7 +2650,15 @@ function cmdCitationStats(db, args) {
|
|
|
2648
2650
|
} else {
|
|
2649
2651
|
for (const s of surfaceFunnel.surfaces) {
|
|
2650
2652
|
const pct = (s.rate * 100).toFixed(1) + '%';
|
|
2651
|
-
|
|
2653
|
+
// Which faces actually move importance is NOT readable from the rates —
|
|
2654
|
+
// and an annotated keyctx beside a bare task_imperative reads as "that
|
|
2655
|
+
// one does feed decay", which is false. Derived from the exported sets so
|
|
2656
|
+
// the note cannot drift from the behaviour it describes.
|
|
2657
|
+
const note = s.surface === 'keyctx'
|
|
2658
|
+
? ' (promotion-only: never demotes)'
|
|
2659
|
+
: (DECAY_DENOMINATOR_SURFACES.includes(s.surface)
|
|
2660
|
+
? ''
|
|
2661
|
+
: ' (metered only: outside the decay denominator)');
|
|
2652
2662
|
out(` ${SURFACE_LABELS[s.surface] || s.surface} inj ${String(s.injected).padStart(4)} cited ${String(s.cited).padStart(4)} ${pct.padStart(6)} over ${s.sessions} session(s)${note}`);
|
|
2653
2663
|
}
|
|
2654
2664
|
}
|
|
@@ -2796,7 +2806,9 @@ Commands:
|
|
|
2796
2806
|
--merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
|
|
2797
2807
|
--project P Filter by project
|
|
2798
2808
|
--retain-days N For purge_stale: keep last N days (default 30)
|
|
2799
|
-
demote_pinned: importance
|
|
2809
|
+
demote_pinned: floors importance for inj>=8 & cited=0 — to 1 with no
|
|
2810
|
+
lesson_learned, to 2 with one (clears pinned noise; a lesson-bearing
|
|
2811
|
+
row keeps eligibility on every importance>=2 injection face).
|
|
2800
2812
|
In the default set since v3.76.0; runs AFTER boost, which would
|
|
2801
2813
|
otherwise hand the row straight back. Opt out of the DEFAULT with
|
|
2802
2814
|
CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 — an explicit --ops demote_pinned
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.77.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.77.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.77.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/schema.mjs
CHANGED
|
@@ -280,7 +280,7 @@ const CORE_SCHEMA = `
|
|
|
280
280
|
-- v45: per-INJECTION-FACE twin of citation_log. One row per
|
|
281
281
|
-- (project, session, surface); the surface column is one of the
|
|
282
282
|
-- CITATION_SURFACES enum in lib/citation-tracker.mjs
|
|
283
|
-
-- (pretool | ups | error_recall | fyi | task_imperative | keyctx). The column is plain
|
|
283
|
+
-- (pretool | ups | error_recall | fyi | task_imperative | keyctx | subagent). The column is plain
|
|
284
284
|
-- TEXT with no CHECK: the JS enum is the gate (recordCitationSurfaces drops unknown
|
|
285
285
|
-- labels), which is why adding a face needs no migration.
|
|
286
286
|
--
|
|
@@ -13,7 +13,7 @@ import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
|
|
|
13
13
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
14
14
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
15
15
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
16
|
-
import { fileMatchClause, fileMatchParams } from '../lib/file-edge-match.mjs';
|
|
16
|
+
import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
|
|
17
17
|
import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
18
18
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
19
19
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
@@ -355,7 +355,14 @@ try {
|
|
|
355
355
|
|
|
356
356
|
try {
|
|
357
357
|
const project = inferProject();
|
|
358
|
-
|
|
358
|
+
// Same any-separator split the observations leg gets through fileMatchParams
|
|
359
|
+
// (pre-tag review of v3.76.2, SF-1/S1). This derivation feeds the EVENTS leg
|
|
360
|
+
// ~120 lines below, which matches a JSON array inside events.file_paths rather
|
|
361
|
+
// than the observation_files junction, so it cannot use fileMatchClause — but it
|
|
362
|
+
// needs the same key, and host-native `basename` gave it the whole path for a
|
|
363
|
+
// Windows-shaped payload. Fixing the observations leg alone would have left this
|
|
364
|
+
// hook recalling lessons but no events.
|
|
365
|
+
const fname = basenameAnySep(filePath);
|
|
359
366
|
// Escape LIKE wildcards (still needed below for the events file_paths arms)
|
|
360
367
|
const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
361
368
|
// P0 (D#78): path-boundary match — editing utils.mjs must NOT pull lessons
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
7
|
import { relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
|
|
8
8
|
import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
|
|
9
|
+
import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
|
|
9
10
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
11
|
import { upsFtsQuery } from '../lib/ups-query.mjs';
|
|
11
12
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
@@ -448,10 +449,13 @@ function searchByFile(db, files, project, limit) {
|
|
|
448
449
|
const results = [];
|
|
449
450
|
|
|
450
451
|
for (const file of files.slice(0, 3)) {
|
|
451
|
-
|
|
452
|
+
// Shared predicate (pre-tag review of v3.76.2, SF-1/S2). This leg used
|
|
453
|
+
// `file.split('/').pop()` — weaker than node:path `basename`, since it misses '\'
|
|
454
|
+
// even ON a Windows host — plus a bare `%<basename>` suffix LIKE with no path
|
|
455
|
+
// boundary, so a prompt mentioning `utils.mjs` recalled `bash-utils.mjs` lessons.
|
|
456
|
+
// fileMatchClause's four arms and fileMatchParams' escaping are the single home.
|
|
457
|
+
const basename = basenameAnySep(file);
|
|
452
458
|
if (!basename || basename.length < 2) continue;
|
|
453
|
-
const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
454
|
-
const likePattern = `%${escaped}`;
|
|
455
459
|
|
|
456
460
|
// R1: exclude LOW_SIGNAL degraded titles from file-level recall.
|
|
457
461
|
const rows = db.prepare(`
|
|
@@ -462,11 +466,11 @@ function searchByFile(db, files, project, limit) {
|
|
|
462
466
|
AND o.importance >= 1
|
|
463
467
|
AND ${liveObsFilterSql('o')}
|
|
464
468
|
AND o.created_at_epoch > ?
|
|
465
|
-
AND (of2
|
|
469
|
+
AND ${fileMatchClause('of2')}
|
|
466
470
|
AND ${notLowSignalTitleClause('o')}
|
|
467
471
|
ORDER BY o.created_at_epoch DESC
|
|
468
472
|
LIMIT ?
|
|
469
|
-
`).all(project, cutoff, file,
|
|
473
|
+
`).all(project, cutoff, ...fileMatchParams(file), limit);
|
|
470
474
|
|
|
471
475
|
results.push(...rows);
|
|
472
476
|
}
|
package/tool-schemas.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export const memOptimizeSchema = {
|
|
|
247
247
|
export const memMaintainSchema = {
|
|
248
248
|
action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
|
|
249
249
|
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
|
|
250
|
-
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance
|
|
250
|
+
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=floor importance for obs injected>=8 times but never cited — to 1 with no lesson_learned, to 2 with one (v3.76.1: a lesson-bearing row keeps eligibility on every importance>=2 injection face) (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
251
251
|
merge_ids: z.preprocess(
|
|
252
252
|
(v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
|
|
253
253
|
z.array(z.array(z.number().int()).min(2))
|
package/utils.mjs
CHANGED
|
@@ -47,20 +47,15 @@ export function isPathConfined(candidate, allowedBase) {
|
|
|
47
47
|
return resolved === base || resolved.startsWith(base + sep);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
export function basenameAnySep(p) {
|
|
61
|
-
const s = String(p ?? '').replace(/[/\\]+$/, '');
|
|
62
|
-
return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
|
|
63
|
-
}
|
|
50
|
+
// `basenameAnySep` lived here until 2026-08-22. Its sole production consumer was
|
|
51
|
+
// `recallForFile` (hook-memory.mjs), which had no callers of its own and was
|
|
52
|
+
// deleted the same round; keeping the export would have added a dead name to the
|
|
53
|
+
// knip baseline. The behaviour it encoded is NOT gone — it moved into
|
|
54
|
+
// lib/file-edge-match.mjs (module-private, so that ~30ms cold-start path stays
|
|
55
|
+
// free of this module's child_process import), which is where the split actually
|
|
56
|
+
// had to happen: `path.basename` follows the HOST's rules, so on POSIX it returns
|
|
57
|
+
// a Windows path unchanged, while observation_files.filename stores either
|
|
58
|
+
// separator. tests/win-path-basename.test.mjs asserts it through fileMatchParams.
|
|
64
59
|
|
|
65
60
|
// ─── Token Estimation ─────────────────────────────────────────────────────
|
|
66
61
|
|