claude-mem-lite 3.66.0 → 3.66.2
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/haiku-client.mjs +170 -40
- package/hook-shared.mjs +43 -18
- package/hook.mjs +19 -7
- package/lib/citation-tracker.mjs +24 -14
- package/lib/keyctx-marker.mjs +18 -15
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.66.
|
|
13
|
+
"version": "3.66.2",
|
|
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.66.
|
|
3
|
+
"version": "3.66.2",
|
|
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/haiku-client.mjs
CHANGED
|
@@ -428,26 +428,122 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
// ─── Headless CLI flag compatibility ─────────────────────────────────────────
|
|
432
|
+
//
|
|
433
|
+
// --no-session-persistence + DISABLE_CLAUDEMD_HOOKS (2026-08-16): these headless
|
|
434
|
+
// calls were paying the full interactive-session tax — 1,004 transcripts piled up
|
|
435
|
+
// in ~/.claude/projects/-tmp/, and every spawn ran the claudemd plugin's whole
|
|
436
|
+
// hook fan-out (its SessionStart banner alone logged 682 rows in 3 days, drowning
|
|
437
|
+
// that project's telemetry). The persistence flag is OAuth-safe (probed);
|
|
438
|
+
// `--bare`/CLAUDE_CODE_SIMPLE are NOT (they hard-require ANTHROPIC_API_KEY —
|
|
439
|
+
// "Not logged in" on OAuth machines). The user's global CLAUDE.md injection has
|
|
440
|
+
// no OAuth-safe opt-out; accepted (haiku + prompt caching keeps it cheap).
|
|
441
|
+
//
|
|
442
|
+
// The flag is an unguarded dependency on a recent Claude Code CLI: package.json
|
|
443
|
+
// declares only node>=20, no Claude Code floor. On an older binary the spawn dies
|
|
444
|
+
// in argument parsing, the catch swallows it, and every CLI-leg LLM call returns
|
|
445
|
+
// null — no retry, no telemetry. That leg is what the keyed providers degrade to,
|
|
446
|
+
// so such a user loses enrichment, summarization and optimize all at once. So:
|
|
447
|
+
// detect the arg-parse rejection, retry once without the flag, and cache the
|
|
448
|
+
// negative only AFTER the retry actually succeeded. Caching on the failure
|
|
449
|
+
// instead would let one transient non-zero exit that happens to mention the flag
|
|
450
|
+
// push a healthy CLI back onto the session tax for the rest of the process.
|
|
451
|
+
// Hooks are short-lived, so an old binary pays one extra fail-fast spawn per
|
|
452
|
+
// process; the long-lived MCP server pays it once per run.
|
|
453
|
+
const HEADLESS_FLAG = '--no-session-persistence';
|
|
454
|
+
let _headlessFlagOk = true;
|
|
455
|
+
|
|
456
|
+
/** @internal test hook — module-level compat state must not leak across cases. */
|
|
457
|
+
export function _resetHeadlessFlag() { _headlessFlagOk = true; }
|
|
458
|
+
|
|
459
|
+
function claudeArgs(modelName) {
|
|
460
|
+
return _headlessFlagOk
|
|
461
|
+
? ['-p', '--model', modelName, HEADLESS_FLAG]
|
|
462
|
+
: ['-p', '--model', modelName];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A retry is only ever worth it when the diagnostic NAMES the token it rejected —
|
|
466
|
+
// every argv parser does, and requiring it is what keeps this from firing on
|
|
467
|
+
// Claude Code's own config diagnostics. The installed CLI carries strings like
|
|
468
|
+
// `Skill X has invalid effort 'y'. Valid options: …` and `Input validation error:
|
|
469
|
+
// Invalid arguments for tool`, which an unanchored unknown-word/option-word regex
|
|
470
|
+
// matches outright. Those are emitted for a malformed agent/skill file — a
|
|
471
|
+
// *persistent* condition — so an unanchored match would fire on the next transient
|
|
472
|
+
// 529, permanently revert v3.66.0's session-tax fix on a perfectly healthy CLI,
|
|
473
|
+
// and log a WARN blaming a flag that was never the problem (pre-tag review, HIGH).
|
|
474
|
+
// Deliberately NOT keyed on exit code alone either: a non-zero exit is also the
|
|
475
|
+
// normal shape of an overload/auth failure.
|
|
476
|
+
const FLAG_TOKEN = /no-session-persistence/;
|
|
477
|
+
const PARSE_REJECTION = /(unknown|unrecognized|unsupported|invalid|unexpected)[^\n]{0,40}(option|argument|flag|switch)/i;
|
|
478
|
+
|
|
479
|
+
// Below this many ms left, a retry can only spawn a process and immediately kill
|
|
480
|
+
// it — worse than returning the original failure.
|
|
481
|
+
const RETRY_MIN_BUDGET_MS = 500;
|
|
482
|
+
|
|
483
|
+
export function _isUnknownFlagError(diagnostic) {
|
|
484
|
+
if (!diagnostic) return false;
|
|
485
|
+
return FLAG_TOKEN.test(diagnostic) && (PARSE_REJECTION.test(diagnostic) || /usage:/i.test(diagnostic));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// stdout as well as stderr: a parser that prints its rejection (or usage banner)
|
|
489
|
+
// on stdout is otherwise invisible here, and FLAG_TOKEN keeps the widened input
|
|
490
|
+
// from loosening the match.
|
|
491
|
+
function cliDiagnostic(e) {
|
|
492
|
+
const err = e?.stderr?.toString?.() || e?.output?.[2]?.toString?.() || '';
|
|
493
|
+
const out = e?.stdout?.toString?.() || e?.output?.[1]?.toString?.() || '';
|
|
494
|
+
return `${err}\n${out}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Shared blocking `claude -p` runner for every sync CLI leg (callModelCLI,
|
|
499
|
+
* callHaikuCLI, hook-shared#callLLM). Throws exactly what execFileSync throws so
|
|
500
|
+
* each caller keeps its own partial-output salvage; the only added behaviour is
|
|
501
|
+
* the one-shot flag-compat retry described above.
|
|
502
|
+
* @param {string} modelName CLI model name ('haiku'|'sonnet')
|
|
503
|
+
* @param {{input:string, timeout:number}} opts
|
|
504
|
+
* @returns {string} raw stdout
|
|
505
|
+
*/
|
|
506
|
+
export function execClaudeCliSync(modelName, { input, timeout }) {
|
|
507
|
+
const opts = {
|
|
508
|
+
input,
|
|
509
|
+
timeout,
|
|
510
|
+
encoding: 'utf8',
|
|
511
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
512
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
513
|
+
cwd: '/tmp', // Prevent ghost sessions in the user's /resume list
|
|
514
|
+
};
|
|
515
|
+
const args = claudeArgs(modelName);
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
try {
|
|
518
|
+
return execFileSync(getClaudePath(), args, opts);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
// A timeout is NOT a parse rejection. execFileSync kills the child and throws
|
|
521
|
+
// with its partial buffers attached (callModelCLI's salvage depends on exactly
|
|
522
|
+
// that), so without this guard a slow call whose output merely looked
|
|
523
|
+
// parse-shaped would be retried on the FULL original budget — doubling a
|
|
524
|
+
// latency-bound ceiling. lesson-bridge runs this leg at 2500ms on PreToolUse,
|
|
525
|
+
// where the CLI is measured at 8–13s and therefore times out routinely.
|
|
526
|
+
if (e?.killed || e?.signal) throw e;
|
|
527
|
+
// `args`, not the live flag: what matters is whether THIS attempt carried it.
|
|
528
|
+
// Symmetry with the async leg, where the distinction is load-bearing (a
|
|
529
|
+
// sibling can flip the flag across an await). Here execFileSync blocks the
|
|
530
|
+
// event loop for the whole child, so no other JS can interleave and the two
|
|
531
|
+
// readings are behaviourally identical — the substitution is deliberately
|
|
532
|
+
// mutation-silent, kept so the two legs cannot drift apart in meaning.
|
|
533
|
+
if (!args.includes(HEADLESS_FLAG) || !_isUnknownFlagError(cliDiagnostic(e))) throw e;
|
|
534
|
+
const remaining = timeout - (Date.now() - started);
|
|
535
|
+
if (remaining < RETRY_MIN_BUDGET_MS) throw e;
|
|
536
|
+
const out = execFileSync(getClaudePath(), ['-p', '--model', modelName], { ...opts, timeout: remaining });
|
|
537
|
+
_headlessFlagOk = false;
|
|
538
|
+
debugLog('WARN', 'cli-compat', `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
431
543
|
function callModelCLI(prompt, model, { timeout }) {
|
|
432
544
|
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
433
545
|
try {
|
|
434
|
-
|
|
435
|
-
// headless calls were paying the full interactive-session tax — 1,004
|
|
436
|
-
// transcripts piled up in ~/.claude/projects/-tmp/, and every spawn ran
|
|
437
|
-
// the claudemd plugin's whole hook fan-out (its SessionStart banner alone
|
|
438
|
-
// logged 682 rows in 3 days, drowning that project's telemetry). The
|
|
439
|
-
// persistence flag is OAuth-safe (probed); `--bare`/CLAUDE_CODE_SIMPLE are
|
|
440
|
-
// NOT (they hard-require ANTHROPIC_API_KEY — "Not logged in" on OAuth
|
|
441
|
-
// machines). The user's global CLAUDE.md injection has no OAuth-safe
|
|
442
|
-
// opt-out; accepted (haiku + prompt caching keeps it cheap).
|
|
443
|
-
const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
444
|
-
input: flattenForCLI(prompt),
|
|
445
|
-
timeout,
|
|
446
|
-
encoding: 'utf8',
|
|
447
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
448
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
449
|
-
cwd: '/tmp',
|
|
450
|
-
});
|
|
546
|
+
const result = execClaudeCliSync(modelName, { input: flattenForCLI(prompt), timeout });
|
|
451
547
|
const text = result.trim();
|
|
452
548
|
return text ? { text } : null;
|
|
453
549
|
} catch (e) {
|
|
@@ -479,23 +575,31 @@ function callModelCLI(prompt, model, { timeout }) {
|
|
|
479
575
|
* @param {{timeout:number}} opts SIGKILL after `timeout` ms; no retry.
|
|
480
576
|
* @returns {Promise<{text:string}|null>}
|
|
481
577
|
*/
|
|
482
|
-
export function callModelCLIAsync(prompt, model, { timeout }) {
|
|
483
|
-
|
|
484
|
-
|
|
578
|
+
export async function callModelCLIAsync(prompt, model, { timeout }) {
|
|
579
|
+
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
580
|
+
const payload = flattenForCLI(prompt);
|
|
581
|
+
const started = Date.now();
|
|
582
|
+
|
|
583
|
+
// One spawn. Resolves {result, stderr, stdout, code}, never rejects. `code` is
|
|
584
|
+
// a number ONLY when the child exited on its own; a timeout/SIGKILL or a spawn
|
|
585
|
+
// error reports null, which is what keeps either from being mistaken for an
|
|
586
|
+
// argument-parse rejection and costing a second full-budget spawn.
|
|
587
|
+
const attempt = (args, budget) => new Promise((resolve) => {
|
|
485
588
|
let child;
|
|
486
589
|
try {
|
|
487
|
-
// Same headless-tax flags as callModelCLI (rationale there).
|
|
488
|
-
child = spawn(getClaudePath(),
|
|
590
|
+
// Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
|
|
591
|
+
child = spawn(getClaudePath(), args, {
|
|
489
592
|
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
490
593
|
cwd: '/tmp',
|
|
491
594
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
492
595
|
});
|
|
493
596
|
} catch (e) {
|
|
494
597
|
debugCatch(e, `${model}-cli-async`);
|
|
495
|
-
resolve(null);
|
|
598
|
+
resolve({ result: null, stderr: '', stdout: '', code: null });
|
|
496
599
|
return;
|
|
497
600
|
}
|
|
498
601
|
let stdout = '';
|
|
602
|
+
let stderr = '';
|
|
499
603
|
let settled = false;
|
|
500
604
|
const done = (val) => {
|
|
501
605
|
if (settled) return;
|
|
@@ -510,26 +614,59 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
510
614
|
// brace check would discard a complete-but-```json-fenced payload (#8605);
|
|
511
615
|
// parseJsonFromLLM strips fences before validating, and the caller re-parses
|
|
512
616
|
// the returned text the same way.
|
|
513
|
-
if (t && parseJsonFromLLM(t) !== null) { done({ text: t }); return; }
|
|
514
|
-
done(null);
|
|
515
|
-
},
|
|
617
|
+
if (t && parseJsonFromLLM(t) !== null) { done({ result: { text: t }, stderr, stdout, code: null }); return; }
|
|
618
|
+
done({ result: null, stderr, stdout, code: null });
|
|
619
|
+
}, budget);
|
|
516
620
|
child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
|
|
517
621
|
child.stdout?.on('data', (d) => { stdout += d; });
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
622
|
+
// Keep draining stderr so a chatty child can't block on a full pipe, but keep
|
|
623
|
+
// a bounded head of it — the flag-compat probe needs the parser's complaint.
|
|
624
|
+
// Slice AFTER appending: checking the length first lets one arbitrarily large
|
|
625
|
+
// chunk through whole, which is the shape a single big stderr write takes.
|
|
626
|
+
child.stderr?.setEncoding?.('utf8');
|
|
627
|
+
child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(0, 4096); });
|
|
628
|
+
child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done({ result: null, stderr: '', stdout: '', code: null }); });
|
|
629
|
+
child.on('close', (code) => {
|
|
521
630
|
const t = stdout.trim();
|
|
522
|
-
done(t ? { text: t } : null);
|
|
631
|
+
done({ result: t ? { text: t } : null, stderr, stdout, code });
|
|
523
632
|
});
|
|
524
633
|
// EPIPE guard: the child may exit before we finish writing stdin.
|
|
525
634
|
child.stdin?.on('error', () => {});
|
|
526
635
|
try {
|
|
527
|
-
child.stdin?.write(
|
|
636
|
+
child.stdin?.write(payload);
|
|
528
637
|
child.stdin?.end();
|
|
529
638
|
} catch (e) {
|
|
530
639
|
debugCatch(e, `${model}-cli-async:stdin`);
|
|
531
640
|
}
|
|
532
641
|
});
|
|
642
|
+
|
|
643
|
+
const firstArgs = claudeArgs(modelName);
|
|
644
|
+
const first = await attempt(firstArgs, timeout);
|
|
645
|
+
// Judged on `firstArgs`, not the live flag: a concurrent sibling may have
|
|
646
|
+
// flipped it between our spawn and our resume, and reading the global there
|
|
647
|
+
// would silently deny THIS call the retry it earned (MCP server, concurrent
|
|
648
|
+
// deep-search escalations). Gating on the exit code before `first.result` also
|
|
649
|
+
// covers a CLI that prints its usage banner to stdout and exits non-zero —
|
|
650
|
+
// otherwise that banner is returned as the model's answer and nothing retries.
|
|
651
|
+
const rejected = firstArgs.includes(HEADLESS_FLAG)
|
|
652
|
+
&& typeof first.code === 'number' && first.code !== 0
|
|
653
|
+
&& _isUnknownFlagError(`${first.stderr}\n${first.stdout.slice(0, 4096)}`);
|
|
654
|
+
if (!rejected) return first.result;
|
|
655
|
+
// The rejection is instantaneous (the child dies in argv parsing), so the retry
|
|
656
|
+
// normally gets nearly the whole budget; spend only what is left of it.
|
|
657
|
+
const remaining = timeout - (Date.now() - started);
|
|
658
|
+
if (remaining < RETRY_MIN_BUDGET_MS) return first.result;
|
|
659
|
+
const second = await attempt(['-p', '--model', modelName], remaining);
|
|
660
|
+
// Cache on the retry's EXIT, not its payload. Empty output is a designed
|
|
661
|
+
// outcome here (emit-nothing prompts, an `N/A` that trims away), so keying on
|
|
662
|
+
// text left the long-lived MCP server re-probing — two spawns per call, for the
|
|
663
|
+
// life of the process — on exactly the old CLI this exists to rescue. The sync
|
|
664
|
+
// twin caches on any non-throwing run; this now means the same thing.
|
|
665
|
+
if (second.code === 0) {
|
|
666
|
+
_headlessFlagOk = false;
|
|
667
|
+
debugLog('WARN', `${model}-cli-async`, `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
|
|
668
|
+
}
|
|
669
|
+
return second.result;
|
|
533
670
|
}
|
|
534
671
|
|
|
535
672
|
// ─── API Mode ────────────────────────────────────────────────────────────────
|
|
@@ -635,15 +772,8 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
|
|
|
635
772
|
function callHaikuCLI(prompt, { timeout }) {
|
|
636
773
|
const { cli: modelName } = resolveModel();
|
|
637
774
|
try {
|
|
638
|
-
// Same headless-tax flags as callModelCLI (rationale there).
|
|
639
|
-
const result =
|
|
640
|
-
input: flattenForCLI(prompt),
|
|
641
|
-
timeout,
|
|
642
|
-
encoding: 'utf8',
|
|
643
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
644
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
645
|
-
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
646
|
-
});
|
|
775
|
+
// Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
|
|
776
|
+
const result = execClaudeCliSync(modelName, { input: flattenForCLI(prompt), timeout });
|
|
647
777
|
const text = result.trim();
|
|
648
778
|
return text ? { text } : null;
|
|
649
779
|
} catch (e) {
|
package/hook-shared.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// claude-mem-lite: Shared infrastructure for hook.mjs and hook-llm.mjs
|
|
2
2
|
// Constants, session management, DB access, LLM calls, process utilities
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
|
|
@@ -10,7 +10,7 @@ import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
|
10
10
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
11
|
// neither imports this file) — no cycle.
|
|
12
12
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { execClaudeCliSync, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
14
14
|
// Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
|
|
15
15
|
// fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
|
|
16
16
|
// neither imports hook-shared.
|
|
@@ -148,12 +148,6 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
|
148
148
|
'cite-recall-', // last session's cite-recall snapshot (nudge input)
|
|
149
149
|
'.skill-cooldown-', // suggestion throttle timestamp
|
|
150
150
|
'.skill-reco-cooldown-', // recommendation throttle timestamp
|
|
151
|
-
// These two have NO writer and NO reader left in the tree (verified by grep,
|
|
152
|
-
// 2026-08-16) — they are version-keyed one-time markers from retired code
|
|
153
|
-
// paths (live dir holds `.mcp-dedup-v2.10`, `.residue-warned-v2.55`). Nothing
|
|
154
|
-
// recreates them, so sweeping them is a one-shot cleanup, not a policy.
|
|
155
|
-
'.mcp-dedup-',
|
|
156
|
-
'.residue-warned-',
|
|
157
151
|
]);
|
|
158
152
|
|
|
159
153
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
@@ -164,8 +158,45 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
|
164
158
|
'.auto-adopt-',
|
|
165
159
|
'.deferred-block-migrated-',
|
|
166
160
|
'.legacy-claude-md-cleaned-',
|
|
161
|
+
// v3.66.1: these two shipped in the GC list for one release and had to come
|
|
162
|
+
// out. Both are version-keyed one-shot migration sentinels written by
|
|
163
|
+
// scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
|
|
164
|
+
// its migration. `.mcp-dedup-v2.78` gates a block that removes
|
|
165
|
+
// mcpServers.mem / mcpServers["mem-lite"] from the user's ~/.claude.json with
|
|
166
|
+
// a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
|
|
167
|
+
// documents as intentionally one-shot: "If a user later runs `claude mcp add
|
|
168
|
+
// mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
|
|
169
|
+
// turned that into a recurring purge of a config file we do not own.
|
|
170
|
+
// The mtime never refreshes (the gate skips the block once the file exists),
|
|
171
|
+
// so every install older than 30 days would have lost it on the first
|
|
172
|
+
// SessionStart after upgrading.
|
|
173
|
+
//
|
|
174
|
+
// Why it was missed: the search for writers used `grep --include=*.mjs
|
|
175
|
+
// --include=*.js`, and the writer is a SHELL script. `sentinelPrefixesFromShell`
|
|
176
|
+
// below now derives this class from scripts/*.sh instead of from memory.
|
|
177
|
+
'.mcp-dedup-',
|
|
178
|
+
'.residue-warned-',
|
|
167
179
|
]);
|
|
168
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Marker-name prefixes that scripts/*.sh treats as one-shot sentinels, derived
|
|
183
|
+
* from the shell source rather than restated here. `tests/runtime-marker-gc`
|
|
184
|
+
* asserts none of them is GC-able: a shell-written sentinel is invisible to a
|
|
185
|
+
* JS-only grep, which is exactly how `.mcp-dedup-` reached the GC list.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} shellSource concatenated contents of scripts/*.sh
|
|
188
|
+
* @returns {string[]} prefixes like `.mcp-dedup-`
|
|
189
|
+
*/
|
|
190
|
+
export function sentinelPrefixesFromShell(shellSource) {
|
|
191
|
+
const out = new Set();
|
|
192
|
+
// Matches `"$DATA_DIR/runtime/.mcp-dedup-v2.78"` and friends: a dotfile under
|
|
193
|
+
// runtime/ whose name carries a version-ish suffix.
|
|
194
|
+
for (const m of String(shellSource || '').matchAll(/runtime\/(\.[a-z0-9-]*?-)v?[0-9][0-9.]*/gi)) {
|
|
195
|
+
out.add(m[1]);
|
|
196
|
+
}
|
|
197
|
+
return [...out];
|
|
198
|
+
}
|
|
199
|
+
|
|
169
200
|
/**
|
|
170
201
|
* Sweep per-project runtime markers older than `ageMs`. fs-only, best-effort,
|
|
171
202
|
* never throws. Returns the number of files removed.
|
|
@@ -287,16 +318,10 @@ export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
|
|
|
287
318
|
|
|
288
319
|
const { cli: modelName } = resolveModelShared();
|
|
289
320
|
try {
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
timeout: timeoutMs,
|
|
295
|
-
encoding: 'utf8',
|
|
296
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
297
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
298
|
-
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
299
|
-
});
|
|
321
|
+
// Shared runner with haiku-client.mjs#callModelCLI (rationale there): no
|
|
322
|
+
// transcript persistence, no claudemd hook fan-out, and the one-shot
|
|
323
|
+
// retry-without-flag that keeps this leg alive on an older Claude Code CLI.
|
|
324
|
+
const result = execClaudeCliSync(modelName, { input: _flattenForCLI(prompt), timeout: timeoutMs });
|
|
300
325
|
return result.trim();
|
|
301
326
|
} catch (e) {
|
|
302
327
|
const out = _extractResponseFromError(e);
|
package/hook.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
|
|
|
54
54
|
import {
|
|
55
55
|
extractCitationsFromTranscript,
|
|
56
56
|
extractAllInjected,
|
|
57
|
+
extractInjectedFromKeyContext,
|
|
57
58
|
bumpCitationAccess,
|
|
58
59
|
computeCiteRecall,
|
|
59
60
|
applyCitationDecay,
|
|
@@ -740,19 +741,26 @@ async function handleStop() {
|
|
|
740
741
|
// filter as citedMain (the numerator, below) — an obs injected only
|
|
741
742
|
// inside a subagent (sidechain) would otherwise enter the denominator
|
|
742
743
|
// but never the numerator and streak-demote despite being used there.
|
|
743
|
-
|
|
744
|
-
// extractInjectedFromKeyContext: it is marker-derived, because the
|
|
745
|
-
// SessionStart block leaves no hook attachment to parse.
|
|
746
|
-
const injected = extractAllInjected(transcriptPath, {
|
|
747
|
-
mainOnly: true, runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
748
|
-
});
|
|
744
|
+
const injected = extractAllInjected(transcriptPath, { mainOnly: true });
|
|
749
745
|
// P5 ①: cite-back signals — observations whose warned file the agent
|
|
750
746
|
// edited this session. Union into injected so they're resolved (they
|
|
751
747
|
// were injected via pre-tool-recall) and, below, into cited so the
|
|
752
748
|
// edit promotes them even without a literal #NN in text.
|
|
753
749
|
const citeBackIds = extractCiteBackSignals(transcriptPath);
|
|
754
750
|
for (const id of citeBackIds) injected.add(id);
|
|
755
|
-
|
|
751
|
+
// D#124, promotion-only (v3.66.1): the SessionStart Key Context block
|
|
752
|
+
// leaves no hook attachment, so its ids come from the per-session
|
|
753
|
+
// marker. They are added to the decay set ONLY where they were
|
|
754
|
+
// actually cited (below), never as bare denominator: the block
|
|
755
|
+
// re-renders the same fixed top-10 unconditionally, so an uncited
|
|
756
|
+
// render says nothing about relevance — and since keyObs gates on
|
|
757
|
+
// `importance >= 2`, one demotion evicts the common importance-2 row
|
|
758
|
+
// from Key Context for good. v3.66.0 fed them in as denominator and
|
|
759
|
+
// that made the block eat its own contents.
|
|
760
|
+
const keyCtxIds = extractInjectedFromKeyContext({
|
|
761
|
+
runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
762
|
+
});
|
|
763
|
+
if (injected.size > 0 || keyCtxIds.size > 0) {
|
|
756
764
|
// Text-floor gate: skip decay on tool-only Stops. Without this,
|
|
757
765
|
// a turn that ends on tool_use locks every injected obs as
|
|
758
766
|
// uncited (last_decided_session_id set), so a later turn that
|
|
@@ -765,6 +773,10 @@ async function handleStop() {
|
|
|
765
773
|
} else {
|
|
766
774
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
767
775
|
for (const id of citeBackIds) citedMain.add(id);
|
|
776
|
+
// The promotion-only half: a Key Context row the agent actually
|
|
777
|
+
// cited joins the decay set (and takes the promote branch); one
|
|
778
|
+
// it ignored is never entered, so it cannot streak or demote.
|
|
779
|
+
for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
|
|
768
780
|
// D#60: the idempotency key must be the CC session UUID, NOT the
|
|
769
781
|
// project-scoped memory sessionId — concurrent same-project CC
|
|
770
782
|
// sessions share the latter, so the second session's decay pass
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -356,8 +356,20 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
|
|
|
356
356
|
* sections never appear).
|
|
357
357
|
*
|
|
358
358
|
* Session-gated: a marker whose recorded session differs from the caller's is
|
|
359
|
-
* another window's render and must not
|
|
360
|
-
*
|
|
359
|
+
* another window's render and must not be attributed to this session.
|
|
360
|
+
*
|
|
361
|
+
* PROMOTION-ONLY (v3.66.1). Deliberately NOT part of extractAllInjected: the
|
|
362
|
+
* other four faces are query-conditioned — a row appears there because it
|
|
363
|
+
* MATCHED something, so its absence from the cited set is evidence it was not
|
|
364
|
+
* useful. A Key Context render is unconditional and re-renders the same fixed
|
|
365
|
+
* top-10 every session, so an uncited render is evidence of nothing but elapsed
|
|
366
|
+
* time. Feeding these ids into the decay DENOMINATOR made the block consume
|
|
367
|
+
* itself: keyObs gates on `importance >= 2` (hook-context.mjs), and a demotion
|
|
368
|
+
* takes the common importance-2 row to 1, dropping it out of Key Context
|
|
369
|
+
* permanently after 3 uncited sessions — each departure promoting the next row
|
|
370
|
+
* into the same grinder. Callers must therefore intersect with the cited set
|
|
371
|
+
* (see handleStop) so a CITED Key Context row is credited while an uncited one
|
|
372
|
+
* is left alone.
|
|
361
373
|
*
|
|
362
374
|
* @param {object} [ctx]
|
|
363
375
|
* @param {string} [ctx.runtimeDir]
|
|
@@ -378,21 +390,22 @@ export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId =
|
|
|
378
390
|
}
|
|
379
391
|
|
|
380
392
|
/**
|
|
381
|
-
* Union of
|
|
382
|
-
* UserPromptSubmit `<memory-context>` + PostToolUse
|
|
383
|
-
* user-prompt-search FYI block
|
|
384
|
-
*
|
|
393
|
+
* Union of the QUERY-CONDITIONED injection surfaces for a transcript:
|
|
394
|
+
* pre-tool-recall + UserPromptSubmit `<memory-context>` + PostToolUse
|
|
395
|
+
* error-recall + the user-prompt-search FYI block. Single integration point the
|
|
396
|
+
* Stop handler calls for the decay DENOMINATOR.
|
|
397
|
+
*
|
|
398
|
+
* Key Context is intentionally absent (v3.66.1 — it was unioned here for one
|
|
399
|
+
* release): every face above appears because a row matched something, so an
|
|
400
|
+
* uncited appearance carries relevance information. An unconditional
|
|
401
|
+
* SessionStart render does not. Callers wanting the Key Context ids ask
|
|
402
|
+
* `extractInjectedFromKeyContext` directly and use them promotion-only.
|
|
385
403
|
*
|
|
386
404
|
* @param {string|null|undefined} transcriptPath
|
|
387
405
|
* @param {object} [opts]
|
|
388
406
|
* @param {boolean} [opts.mainOnly=false] Skip sidechain-injected IDs. The
|
|
389
407
|
* citation-decay caller passes true so the injected denominator matches the
|
|
390
408
|
* mainOnly cited numerator; the P4 access-bump caller omits it (broader).
|
|
391
|
-
* @param {string} [opts.runtimeDir] With `project`, enables the Key Context
|
|
392
|
-
* face. Omitted by callers that only have a transcript path (computeCiteRecall
|
|
393
|
-
* over an arbitrary file), which then see the four transcript-derived faces.
|
|
394
|
-
* @param {string} [opts.project]
|
|
395
|
-
* @param {string|null} [opts.sessionId]
|
|
396
409
|
* @returns {Set<number>}
|
|
397
410
|
*/
|
|
398
411
|
export function extractAllInjected(transcriptPath, opts = {}) {
|
|
@@ -401,9 +414,6 @@ export function extractAllInjected(transcriptPath, opts = {}) {
|
|
|
401
414
|
...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
|
|
402
415
|
...extractInjectedFromErrorRecall(transcriptPath, opts),
|
|
403
416
|
...extractInjectedFromFyi(transcriptPath, opts),
|
|
404
|
-
// Marker-derived, not transcript-derived: no-op unless the caller passes
|
|
405
|
-
// runtimeDir + project (see extractInjectedFromKeyContext).
|
|
406
|
-
...extractInjectedFromKeyContext(opts),
|
|
407
417
|
]);
|
|
408
418
|
}
|
|
409
419
|
|
package/lib/keyctx-marker.mjs
CHANGED
|
@@ -42,21 +42,24 @@ export function recordKeyContextInjection(db, { runtimeDir, project, sessionId =
|
|
|
42
42
|
if (Number.isInteger(id) && id > 0 && id < 1e7) clean.push(id);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
45
|
+
// NO injection_count bump. v3.66.0 added one here and it had to be reverted in
|
|
46
|
+
// v3.66.1: injection_count is not a neutral counter. scoring-sql.mjs states the
|
|
47
|
+
// invariant — "bumped ONLY on UserPromptSubmit / hook-memory auto-inject" —
|
|
48
|
+
// because noisePenaltyClause reads it as a NOISE signal: a row scores x0.5 once
|
|
49
|
+
// injection_count >= 4 (and > access_count * 3), x0.2 at >= 8. Nothing bumps
|
|
50
|
+
// access_count for a rendered row (bumpCitationAccess fires on CITED ids only),
|
|
51
|
+
// so the counter crosses those thresholds purely as a function of elapsed
|
|
52
|
+
// sessions, deprioritising the highest-importance rows — the exact rows Key
|
|
53
|
+
// Context renders — in mem_search, UPS ranking and injectionRelevanceSql.
|
|
54
|
+
//
|
|
55
|
+
// The UPS bump the reverted code claimed to "mirror verbatim" is
|
|
56
|
+
// query-conditioned: a row is counted only when it MATCHED a query, so the
|
|
57
|
+
// counter means "auto-injected and never useful". A Key Context render is
|
|
58
|
+
// unconditional and hits the same fixed row set every session, so it would have
|
|
59
|
+
// measured nothing but time. D#124's requirement is decay reachability, which
|
|
60
|
+
// the extractor face delivers on its own — decay reads decay_seen_count /
|
|
61
|
+
// uncited_streak / cited_count, never injection_count.
|
|
62
|
+
const bumped = 0;
|
|
60
63
|
|
|
61
64
|
let written = false;
|
|
62
65
|
try {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.66.
|
|
3
|
+
"version": "3.66.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.66.
|
|
9
|
+
"version": "3.66.2",
|
|
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.66.
|
|
3
|
+
"version": "3.66.2",
|
|
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",
|