claude-mem-lite 3.67.0 → 3.68.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/haiku-client.mjs +75 -0
- package/hook-optimize.mjs +6 -6
- package/hook-semaphore.mjs +45 -3
- package/hook.mjs +23 -8
- package/lib/citation-tracker.mjs +13 -3
- package/mem-cli.mjs +9 -6
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/registry-enricher.mjs +5 -2
- package/rerank.mjs +7 -4
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.68.1",
|
|
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.68.1",
|
|
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
|
@@ -286,6 +286,29 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
286
286
|
return parseJsonFromLLM(result.text);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Non-blocking sibling of callHaikuJSON for callers reachable from an MCP request
|
|
291
|
+
* handler (registry enrichment: mem_registry `enrich` / `import_url`). Same
|
|
292
|
+
* provider priority; the CLI leg — primary AND post-provider-failure fallback —
|
|
293
|
+
* is the async spawn, so a keyed-provider outage cannot freeze the server event
|
|
294
|
+
* loop for BG_LLM_TIMEOUT_MS (D#138 MEDIUM-3).
|
|
295
|
+
*
|
|
296
|
+
* `resolveModel().cli`, NOT the literal 'haiku': despite the name, callHaikuJSON
|
|
297
|
+
* reaches the model through resolveModel() on ALL three legs (callHaikuAPI,
|
|
298
|
+
* callOpenRouterAPI, callHaikuCLI), so it honours the documented CLAUDE_MEM_MODEL
|
|
299
|
+
* knob. Pinning 'haiku' here would silently downgrade registry enrichment for
|
|
300
|
+
* every user who set CLAUDE_MEM_MODEL=sonnet — pre-tag review finding, v3.68.0.
|
|
301
|
+
*
|
|
302
|
+
* Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
|
|
303
|
+
* 15s / 1000: a caller that omits opts must get the sync twin's budget.
|
|
304
|
+
* @param {string|{system?:string,user:string}} prompt
|
|
305
|
+
* @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
|
|
306
|
+
* @returns {Promise<object|null>} Parsed JSON or null
|
|
307
|
+
*/
|
|
308
|
+
export async function callHaikuJSONAsync(prompt, { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
309
|
+
return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
|
|
310
|
+
}
|
|
311
|
+
|
|
289
312
|
// ─── Model-Selectable API ────────────────────────────────────────────────────
|
|
290
313
|
|
|
291
314
|
/**
|
|
@@ -328,6 +351,44 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
|
|
|
328
351
|
catch (e) { debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`); return null; }
|
|
329
352
|
}
|
|
330
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Non-blocking sibling of callLLMWithModel — returns the RAW {text} envelope
|
|
356
|
+
* without JSON-parsing it. For MCP-reachable callers whose answer is not
|
|
357
|
+
* guaranteed to be an object: rerank accepts a bare `[2,1,3]` array, which a
|
|
358
|
+
* JSON-parsing dispatcher would keep but whose contract (rerank.mjs:72) is the
|
|
359
|
+
* envelope, not the parse. Both CLI legs use the async spawn, so a keyed-provider
|
|
360
|
+
* outage cannot freeze the server event loop (D#138 MEDIUM-3).
|
|
361
|
+
*
|
|
362
|
+
* Behaviourally identical to callLLMWithModel otherwise — same `if (primary)`
|
|
363
|
+
* test, same headless-flag compat retry and budget arithmetic, same timeout
|
|
364
|
+
* salvage. Only the CLI transport differs.
|
|
365
|
+
* @param {string|{system?:string,user:string}} prompt
|
|
366
|
+
* @param {'haiku'|'sonnet'} model
|
|
367
|
+
* @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
|
|
368
|
+
* @returns {Promise<{text: string}|null>} Response or null on failure
|
|
369
|
+
*/
|
|
370
|
+
export async function callLLMWithModelAsync(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
371
|
+
if (!prompt) return null;
|
|
372
|
+
const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
|
|
373
|
+
const mode = detectMode();
|
|
374
|
+
|
|
375
|
+
// CLI is terminal — no provider to fall back to.
|
|
376
|
+
if (mode === 'cli') return callModelCLIAsync(prompt, resolvedModel, { timeout });
|
|
377
|
+
|
|
378
|
+
let primary = null;
|
|
379
|
+
try {
|
|
380
|
+
primary = mode === 'api'
|
|
381
|
+
? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
|
|
382
|
+
: await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
|
|
383
|
+
} catch (e) {
|
|
384
|
+
debugCatch(e, `callLLMWithModelAsync:${mode}:${resolvedModel}`);
|
|
385
|
+
}
|
|
386
|
+
if (primary) return primary;
|
|
387
|
+
|
|
388
|
+
debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to async claude CLI (${resolvedModel})`);
|
|
389
|
+
return callModelCLIAsync(prompt, resolvedModel, { timeout });
|
|
390
|
+
}
|
|
391
|
+
|
|
331
392
|
/**
|
|
332
393
|
* Call LLM with model selection and parse JSON response.
|
|
333
394
|
* @param {string} prompt
|
|
@@ -628,6 +689,20 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
628
689
|
child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done({ result: null, stderr: '', stdout: '', code: null }); });
|
|
629
690
|
child.on('close', (code) => {
|
|
630
691
|
const t = stdout.trim();
|
|
692
|
+
// Parity with callModelCLI: execFileSync THROWS on a non-zero exit, so the
|
|
693
|
+
// sync leg only ever returns such output when parseJsonFromLLM accepts it
|
|
694
|
+
// (its catch-salvage). Without the same gate, a CLI that prints a
|
|
695
|
+
// diagnostic to stdout and dies — auth failure, overload banner, wrapper
|
|
696
|
+
// error — has that diagnostic returned as the model's ANSWER. rerank is the
|
|
697
|
+
// first caller to consume the raw {text}: extractRanked's last resort
|
|
698
|
+
// matches any bracketed number list in prose, so a `[1]` inside a stack
|
|
699
|
+
// frame becomes a ranking and silently reorders search results. The
|
|
700
|
+
// flag-compat probe below reads stderr/stdout/code directly, not `result`,
|
|
701
|
+
// so nulling here does not cost it its retry.
|
|
702
|
+
if (t && typeof code === 'number' && code !== 0 && parseJsonFromLLM(t) === null) {
|
|
703
|
+
done({ result: null, stderr, stdout, code });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
631
706
|
done({ result: t ? { text: t } : null, stderr, stdout, code });
|
|
632
707
|
});
|
|
633
708
|
// EPIPE guard: the child may exit before we finish writing stdin.
|
package/hook-optimize.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
|
|
14
14
|
notLowSignalTitleClause, scrubSecrets,
|
|
15
15
|
} from './utils.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
17
17
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
18
18
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
19
19
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
@@ -175,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
|
175
175
|
|
|
176
176
|
JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
|
|
177
177
|
Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
|
|
178
|
-
const parsed = await
|
|
178
|
+
const parsed = await callModelJSONAsync(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
|
|
179
179
|
const aliasArr = parsed && Array.isArray(parsed.search_aliases)
|
|
180
180
|
? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
|
|
181
181
|
: [];
|
|
@@ -204,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
|
|
|
204
204
|
lesson_learned: State what was learned. If routine, write "none".
|
|
205
205
|
search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
206
206
|
|
|
207
|
-
const parsed = await
|
|
207
|
+
const parsed = await callModelJSONAsync(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
208
208
|
if (!parsed || !parsed.title) { skipped++; continue; }
|
|
209
209
|
|
|
210
210
|
// Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
|
|
@@ -356,7 +356,7 @@ Rules:
|
|
|
356
356
|
- Include CJK ↔ English equivalents if present
|
|
357
357
|
- Skip terms that have no synonyms in the list`;
|
|
358
358
|
|
|
359
|
-
const parsed = await
|
|
359
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
360
360
|
if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
|
|
361
361
|
return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
|
|
362
362
|
} catch (e) {
|
|
@@ -524,7 +524,7 @@ Return ONLY valid JSON:
|
|
|
524
524
|
- If they should NOT be merged: {"should_merge":false}
|
|
525
525
|
- If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
|
|
526
526
|
|
|
527
|
-
const parsed = await
|
|
527
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
528
528
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
529
529
|
|
|
530
530
|
// Keeper = highest importance, then highest access_count. Previously access_count
|
|
@@ -758,7 +758,7 @@ ${obsDescriptions}
|
|
|
758
758
|
|
|
759
759
|
JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
|
|
760
760
|
|
|
761
|
-
const parsed = await
|
|
761
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
762
762
|
if (!parsed || !parsed.title) return { compressed: false };
|
|
763
763
|
|
|
764
764
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
package/hook-semaphore.mjs
CHANGED
|
@@ -34,9 +34,43 @@ export const LLM_SEM_STALE_MS = BG_LLM_TIMEOUT_MS * 2 + 30000; // 120s
|
|
|
34
34
|
|
|
35
35
|
export const sleepMs = (ms) => new Promise(r => setTimeout(r, ms));
|
|
36
36
|
|
|
37
|
+
// Does THIS process currently hold the (single, pid-named) slot?
|
|
38
|
+
//
|
|
39
|
+
// The slot file is one per process, and until the MCP LLM legs went async in
|
|
40
|
+
// v3.68.0 two acquires could not overlap inside one: execFileSync held the event
|
|
41
|
+
// loop, so a second tools/call was not even read from stdio while the first was
|
|
42
|
+
// in its LLM call. The EEXIST branch below encodes that era's assumption — "we
|
|
43
|
+
// are inside acquire and therefore do NOT hold a slot, so it is always stale" —
|
|
44
|
+
// and unlinks unconditionally. With two concurrent mem_optimize handlers that
|
|
45
|
+
// unlink would delete a LIVE sibling's slot: the cross-process count stops
|
|
46
|
+
// seeing it (so more than LLM_SEM_MAX `claude -p` children run at once), and the
|
|
47
|
+
// first holder's release then unlinks the second holder's file. This bookkeeping
|
|
48
|
+
// makes the EEXIST branch's claim true again by keeping same-process acquires out
|
|
49
|
+
// of it — they wait for the holder instead.
|
|
50
|
+
//
|
|
51
|
+
// A TIMESTAMP, not a boolean, and the difference is load-bearing: a boolean has no
|
|
52
|
+
// self-heal, so one caller that acquired and never released would deadlock every
|
|
53
|
+
// later LLM call in a long-lived MCP server for the life of the process — strictly
|
|
54
|
+
// worse than the race it fixes. Past LLM_SEM_STALE_MS the local record is treated
|
|
55
|
+
// as broken bookkeeping and we fall through to the file-side logic, which is the
|
|
56
|
+
// same age escape hatch the reaper applies to other processes' slots. 0 = not held.
|
|
57
|
+
let localHeldAt = 0;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Test-only: force the local hold record to an arbitrary age. The staleness
|
|
61
|
+
* escape hatch above is otherwise unreachable in a test — it needs a record
|
|
62
|
+
* older than LLM_SEM_STALE_MS (120s), and a suite cannot wait that long. It was
|
|
63
|
+
* shipped untested in v3.68.0 and a post-tag review showed the gate still passed
|
|
64
|
+
* 16/16 when the escape hatch was deleted outright. Mirrors the `_resetMode` /
|
|
65
|
+
* `_resetHeadlessFlag` hooks in haiku-client.mjs.
|
|
66
|
+
* @param {number} ts epoch ms, or 0 for "not held"
|
|
67
|
+
*/
|
|
68
|
+
export function _setLocalHeldAt(ts) { localHeldAt = ts; }
|
|
69
|
+
|
|
37
70
|
/**
|
|
38
71
|
* Acquire a file-based semaphore slot for LLM calls.
|
|
39
72
|
* Uses acquire-then-verify: atomically creates a slot file, then checks total count.
|
|
73
|
+
* At most one slot per process; a concurrent same-process caller queues behind it.
|
|
40
74
|
* @returns {Promise<boolean>} true if slot acquired, false on timeout
|
|
41
75
|
*/
|
|
42
76
|
export async function acquireLLMSlot() {
|
|
@@ -44,6 +78,12 @@ export async function acquireLLMSlot() {
|
|
|
44
78
|
const slotFile = join(RUNTIME_DIR, `llm-sem-${process.pid}`);
|
|
45
79
|
|
|
46
80
|
while (Date.now() < deadline) {
|
|
81
|
+
// A sibling call in this process holds the slot — queue, do not race. The
|
|
82
|
+
// file cannot be re-created without destroying the holder's.
|
|
83
|
+
if (localHeldAt && Date.now() - localHeldAt < LLM_SEM_STALE_MS) {
|
|
84
|
+
await sleepMs(200 + Math.random() * 800);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
47
87
|
// Acquire-then-verify: atomically create our slot first, then check total count
|
|
48
88
|
let created;
|
|
49
89
|
try {
|
|
@@ -59,8 +99,9 @@ export async function acquireLLMSlot() {
|
|
|
59
99
|
} catch {
|
|
60
100
|
// Our own pid-named slot file already exists: a leftover from a prior acquire
|
|
61
101
|
// that never released (crash between acquire and releaseLLMSlot, or PID reuse).
|
|
62
|
-
//
|
|
63
|
-
//
|
|
102
|
+
// `localHeldAt` was checked above, so this process either holds nothing right
|
|
103
|
+
// now or its record is past the stale threshold — either way the file is not
|
|
104
|
+
// a live sibling's. Remove it and retry. The await is essential: a bare `continue`
|
|
64
105
|
// here re-hits the same EEXIST every iteration, a synchronous tight loop that
|
|
65
106
|
// pins a core until the 30s deadline (the age-based cleanup below is unreachable
|
|
66
107
|
// on this path — it only runs after a successful create).
|
|
@@ -106,7 +147,7 @@ export async function acquireLLMSlot() {
|
|
|
106
147
|
}
|
|
107
148
|
} catch {}
|
|
108
149
|
|
|
109
|
-
if (active <= LLM_SEM_MAX) return true; // Slot acquired
|
|
150
|
+
if (active <= LLM_SEM_MAX) { localHeldAt = Date.now(); return true; } // Slot acquired
|
|
110
151
|
|
|
111
152
|
// Too many concurrent — release our slot and back off
|
|
112
153
|
try { unlinkSync(slotFile); } catch {}
|
|
@@ -119,5 +160,6 @@ export async function acquireLLMSlot() {
|
|
|
119
160
|
* Release the file-based semaphore slot for the current process.
|
|
120
161
|
*/
|
|
121
162
|
export function releaseLLMSlot() {
|
|
163
|
+
localHeldAt = 0;
|
|
122
164
|
try { unlinkSync(join(RUNTIME_DIR, `llm-sem-${process.pid}`)); } catch {}
|
|
123
165
|
}
|
package/hook.mjs
CHANGED
|
@@ -878,8 +878,15 @@ async function handleStop() {
|
|
|
878
878
|
}
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
-
// Spawn background for session summary (pass sessionId and project)
|
|
882
|
-
|
|
881
|
+
// Spawn background for session summary (pass sessionId and project).
|
|
882
|
+
// CLAUDE_MEM_SKIP_SUMMARY brings this in line with every other background
|
|
883
|
+
// worker (auto-compress / llm-optimize / auto-maintain all have one). It was
|
|
884
|
+
// the only ungated spawnBackground, which made Stop untestable end-to-end
|
|
885
|
+
// without residue: the detached child outlives the parent process an e2e test
|
|
886
|
+
// waits on, then recreates the sandbox tree behind the test's cleanup. Any
|
|
887
|
+
// grace period for that is a race, not a barrier — the post-tag review timed a
|
|
888
|
+
// recreate at 432ms and watched a 300ms grace lose.
|
|
889
|
+
if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) spawnBackground('llm-summary', sessionId, project);
|
|
883
890
|
|
|
884
891
|
// Clean session file AFTER spawning background
|
|
885
892
|
try { unlinkSync(sessionFile()); } catch {}
|
|
@@ -1749,12 +1756,20 @@ async function handleUserPrompt() {
|
|
|
1749
1756
|
}
|
|
1750
1757
|
} catch { /* file may not exist — that's fine */ }
|
|
1751
1758
|
|
|
1752
|
-
// Phase-2 task-imperative (default OFF — CLAUDE_MEM_TASK_IMPERATIVE):
|
|
1753
|
-
// highest-value lesson relevant to THIS prompt, delivered at the prompt
|
|
1754
|
-
// an imperative template. Excluded from the <memory-context> list so it
|
|
1755
|
-
// injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29):
|
|
1756
|
-
// 6-8/8 vs PreToolUse hook 0/8.
|
|
1757
|
-
//
|
|
1759
|
+
// Phase-2 task-imperative (EXPERIMENTAL, default OFF — CLAUDE_MEM_TASK_IMPERATIVE):
|
|
1760
|
+
// the single highest-value lesson relevant to THIS prompt, delivered at the prompt
|
|
1761
|
+
// position under an imperative template. Excluded from the <memory-context> list so it
|
|
1762
|
+
// is never injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29):
|
|
1763
|
+
// task-prompt 6-8/8 vs PreToolUse hook 0/8.
|
|
1764
|
+
//
|
|
1765
|
+
// The default flip is ABANDONED (D#137, 2026-08-16). rankImperativeCandidates requires
|
|
1766
|
+
// identifier overlap between the prompt and the lesson body/title, and over the last 400
|
|
1767
|
+
// real prompts that gate opened 76 times = 19.0% (CJK prompts 57/352 = 16.2%, ASCII
|
|
1768
|
+
// 19/48 = 39.6%). With 88% of prompts on this install in Chinese, the emitter fires
|
|
1769
|
+
// roughly once every six prompts — the canary can never accumulate n, because the
|
|
1770
|
+
// ceiling is the gate's DESIGN (precision-first symbol anchoring), not a defect.
|
|
1771
|
+
// Reviving the flip needs a CJK-viable anchor proven in A/B without a precision loss;
|
|
1772
|
+
// until then this stays experimental and off.
|
|
1758
1773
|
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
1759
1774
|
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
1760
1775
|
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -1016,13 +1016,20 @@ export function recordCitationSurfaces(db, project, sessionId, surfaceSets, cite
|
|
|
1016
1016
|
* the window, highest injection volume first (the face spending the most budget
|
|
1017
1017
|
* is the one worth aiming a lever at).
|
|
1018
1018
|
*
|
|
1019
|
+
* `unavailable` is set — and ONLY set — when the read could not run (no handle,
|
|
1020
|
+
* missing table, unreadable DB). An empty window leaves it undefined. Without
|
|
1021
|
+
* this split both render as `surfaces: []`, which is exactly how a table that was
|
|
1022
|
+
* never created reads as "no data yet" for as long as the surface stays unmetered
|
|
1023
|
+
* (#10650): the reader swallows `no such table` into the debug log, and the only
|
|
1024
|
+
* caller-visible signal is a shape identical to the benign case.
|
|
1025
|
+
*
|
|
1019
1026
|
* @param {import('better-sqlite3').Database} db
|
|
1020
1027
|
* @param {{days?: number, project?: string|null}} [opts]
|
|
1021
|
-
* @returns {{window_days: number, surfaces: Array<{surface: string, injected: number, cited: number, rate: number, sessions: number}
|
|
1028
|
+
* @returns {{window_days: number, surfaces: Array<{surface: string, injected: number, cited: number, rate: number, sessions: number}>, unavailable?: string}}
|
|
1022
1029
|
*/
|
|
1023
1030
|
export function computeSurfaceFunnel(db, { days = 7, project = null } = {}) {
|
|
1024
1031
|
const empty = { window_days: days, surfaces: [] };
|
|
1025
|
-
if (!db) return empty;
|
|
1032
|
+
if (!db) return { ...empty, unavailable: 'no database handle' };
|
|
1026
1033
|
try {
|
|
1027
1034
|
const windowStart = Date.now() - days * DAY_MS;
|
|
1028
1035
|
const params = project ? [windowStart, project] : [windowStart];
|
|
@@ -1043,7 +1050,10 @@ export function computeSurfaceFunnel(db, { days = 7, project = null } = {}) {
|
|
|
1043
1050
|
window_days: days,
|
|
1044
1051
|
surfaces: rows.map(r => ({ ...r, rate: r.injected > 0 ? r.cited / r.injected : 0 })),
|
|
1045
1052
|
};
|
|
1046
|
-
} catch (e) {
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
debugCatch(e, 'computeSurfaceFunnel');
|
|
1055
|
+
return { ...empty, unavailable: e?.message || 'query failed' };
|
|
1056
|
+
}
|
|
1047
1057
|
}
|
|
1048
1058
|
|
|
1049
1059
|
/**
|
package/mem-cli.mjs
CHANGED
|
@@ -2595,12 +2595,15 @@ function cmdCitationStats(db, args) {
|
|
|
2595
2595
|
// whether effectiveness is rising; this says WHICH face to aim a lever at.
|
|
2596
2596
|
out(`Cite rate by injection face (last ${days}d):`);
|
|
2597
2597
|
out(' a per-face VIEW, not a partition — do NOT reconcile against the funnel above: faces overlap (an obs carried by two counts in both) and the funnel also counts cite-back signals that belong to no face:');
|
|
2598
|
-
if (surfaceFunnel.
|
|
2599
|
-
//
|
|
2600
|
-
//
|
|
2601
|
-
//
|
|
2602
|
-
//
|
|
2603
|
-
out(
|
|
2598
|
+
if (surfaceFunnel.unavailable) {
|
|
2599
|
+
// The read FAILED — a missing/unreadable citation_surface_log. Pre-b4 this
|
|
2600
|
+
// rendered identically to an empty window, so the #10650 shape (table never
|
|
2601
|
+
// created, `no such table` swallowed into the debug log) read as "no data
|
|
2602
|
+
// yet" for as long as the surface stayed unmetered.
|
|
2603
|
+
out(` (UNAVAILABLE — the per-face table could not be read: ${surfaceFunnel.unavailable})`);
|
|
2604
|
+
out(' this is a failure, not an empty window: run `claude-mem-lite fts-check` to repair the schema');
|
|
2605
|
+
} else if (surfaceFunnel.surfaces.length === 0) {
|
|
2606
|
+
out(' (no rows in this window yet — rows accrue at Stop, one per injection face per session)');
|
|
2604
2607
|
} else {
|
|
2605
2608
|
for (const s of surfaceFunnel.surfaces) {
|
|
2606
2609
|
const pct = (s.rate * 100).toFixed(1) + '%';
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.68.1",
|
|
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.68.1",
|
|
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.68.1",
|
|
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/registry-enricher.mjs
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// Sends resource content to Haiku for semantic metadata generation
|
|
3
3
|
// Graceful degradation: failure preserves existing data
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Async dispatcher: enrichResource is reached from the MCP handlers for
|
|
6
|
+
// mem_registry `enrich` and `import_url`, where a blocking CLI leg would freeze
|
|
7
|
+
// the server event loop for the whole BG_LLM_TIMEOUT_MS budget (D#138 MEDIUM-3).
|
|
8
|
+
import { callHaikuJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
6
9
|
import { truncate, debugCatch } from './utils.mjs';
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -84,7 +87,7 @@ export async function enrichResource(db, name, type, content) {
|
|
|
84
87
|
|
|
85
88
|
try {
|
|
86
89
|
const prompt = buildEnrichPrompt(name, content, existing);
|
|
87
|
-
const result = await
|
|
90
|
+
const result = await callHaikuJSONAsync(prompt, { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
88
91
|
|
|
89
92
|
if (!result || !result.capability_summary) {
|
|
90
93
|
db.prepare("UPDATE resources SET enrichment_status = 'failed' WHERE name = ? AND type = ?").run(name, type);
|
package/rerank.mjs
CHANGED
|
@@ -69,10 +69,13 @@ export async function llmRerankOrder(query, cand /* [{sid,text}] */, llm) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Default provider — lazy import so stub-injected callers never load the client.
|
|
72
|
-
// Uses
|
|
72
|
+
// Uses the {text}-envelope dispatcher rather than callModelJSONAsync (which
|
|
73
73
|
// JSON-parses internally and nulls on any non-{...} output) so extractRanked can
|
|
74
|
-
// recover bare-array answers the strict JSON parse drops.
|
|
74
|
+
// recover bare-array answers the strict JSON parse drops. The Async variant is
|
|
75
|
+
// load-bearing: rerank runs inside the mem_search MCP handler (deep + rerank), so
|
|
76
|
+
// the blocking callLLMWithModel froze the server event loop whenever a keyed
|
|
77
|
+
// provider was down and the call degraded to the CLI (D#138 MEDIUM-3).
|
|
75
78
|
export async function defaultRerankLLM(prompt) {
|
|
76
|
-
const {
|
|
77
|
-
return
|
|
79
|
+
const { callLLMWithModelAsync } = await import('./haiku-client.mjs');
|
|
80
|
+
return callLLMWithModelAsync(prompt, 'haiku', { timeout: 20000, maxTokens: 300 });
|
|
78
81
|
}
|