claude-mem-lite 2.44.0 → 2.46.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-handoff.mjs +10 -3
- package/hook-llm.mjs +36 -7
- package/hook.mjs +50 -17
- package/lib/summary-extractor.mjs +112 -0
- package/package.json +2 -1
- package/source-files.mjs +1 -0
package/hook-handoff.mjs
CHANGED
|
@@ -298,7 +298,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
|
|
|
298
298
|
* @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
|
|
299
299
|
* @returns {string|null} Injection text or null if no handoff
|
|
300
300
|
*/
|
|
301
|
-
export function
|
|
301
|
+
export function pickHandoffToInject(db, project, currentCcSessionId = null) {
|
|
302
302
|
const now = Date.now();
|
|
303
303
|
// Fetch recent handoffs and find the most recent non-expired one.
|
|
304
304
|
// A newer but expired 'clear' handoff must not shadow a still-valid 'exit' handoff.
|
|
@@ -313,13 +313,20 @@ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
|
|
|
313
313
|
SELECT * FROM session_handoffs
|
|
314
314
|
WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
|
|
315
315
|
`).all(project);
|
|
316
|
-
|
|
316
|
+
return handoffs.find(h => {
|
|
317
317
|
const age = now - h.created_at_epoch;
|
|
318
318
|
const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
|
|
319
319
|
return age <= maxAge;
|
|
320
|
-
});
|
|
320
|
+
}) || null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function renderHandoffInjection(db, project, currentCcSessionId = null) {
|
|
324
|
+
const handoff = pickHandoffToInject(db, project, currentCcSessionId);
|
|
321
325
|
if (!handoff) return null;
|
|
326
|
+
return renderHandoffFromRow(handoff, db, project);
|
|
327
|
+
}
|
|
322
328
|
|
|
329
|
+
function renderHandoffFromRow(handoff, db, project) {
|
|
323
330
|
const ageSec = Math.round((Date.now() - handoff.created_at_epoch) / 1000);
|
|
324
331
|
const ageStr = ageSec < 60 ? `${ageSec}s` :
|
|
325
332
|
ageSec < 3600 ? `${Math.round(ageSec / 60)}m` :
|
package/hook-llm.mjs
CHANGED
|
@@ -431,15 +431,30 @@ export function buildImmediateObservation(episode) {
|
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
const ruleImportance = computeRuleImportance(episode);
|
|
434
|
-
// Low-signal degraded titles ("
|
|
435
|
-
//
|
|
434
|
+
// Low-signal degraded titles ("Modified X", "Worked on X", "Reviewed N files")
|
|
435
|
+
// should not inflate importance. computeRuleImportance's file-name heuristics
|
|
436
|
+
// (schema.*, migration, auth.*, .env, .pem) fire on any matching file in the
|
|
437
|
+
// episode, so a 5-file review that incidentally reads one schema.js triggers
|
|
438
|
+
// imp=3 even though schema.js was one of 5 scanned — not the focus. Combined
|
|
439
|
+
// with a LOW_SIGNAL title (Haiku couldn't extract meaning), we can't justify
|
|
440
|
+
// imp=3; cap at 2 so rule says "notable" but not "critical".
|
|
441
|
+
//
|
|
442
|
+
// Production baseline (2026-04-23, projects--mem): 34/100 discovery/imp=3
|
|
443
|
+
// obs were LOW_SIGNAL titles; 7 change/imp=3 same. Prior cap `rule<=2 → 1`
|
|
444
|
+
// only fired when rule was weak, letting rule=3 leak through. New cap:
|
|
445
|
+
// isReviewPattern → 2 (was Math.max(2, rule) → rule=3 leaked as 3)
|
|
446
|
+
// isLowSignal & !review:
|
|
447
|
+
// rule=3 → 2 (was 3) — the fix
|
|
448
|
+
// rule<=2 → 1 (unchanged) — original cap preserved
|
|
436
449
|
const LOW_SIGNAL = LOW_SIGNAL_TITLE;
|
|
437
450
|
const isLowSignal = LOW_SIGNAL.test(title);
|
|
438
451
|
let importance;
|
|
439
452
|
if (isReviewPattern) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
importance =
|
|
453
|
+
// Review titles are auto-generated from file count — can't distinguish
|
|
454
|
+
// "critical file was primary focus" from "one of N files read". Cap at 2.
|
|
455
|
+
importance = 2;
|
|
456
|
+
} else if (isLowSignal) {
|
|
457
|
+
importance = ruleImportance === 3 ? 2 : 1;
|
|
443
458
|
} else {
|
|
444
459
|
importance = ruleImportance;
|
|
445
460
|
}
|
|
@@ -838,10 +853,24 @@ key_decisions: Only decisions with lasting impact (library choices, architecture
|
|
|
838
853
|
`).get(sessionId);
|
|
839
854
|
|
|
840
855
|
if (existingFast) {
|
|
856
|
+
// Preserve structural-extractor content (completed / remaining_items written
|
|
857
|
+
// by handleStop fast-baseline from CLAUDE.md §10 markers) when Haiku returns
|
|
858
|
+
// empty for that field. Without COALESCE, a degraded Haiku pass would erase
|
|
859
|
+
// the deterministic floor — the exact regression that made 72% of prod
|
|
860
|
+
// session_summaries ship with empty remaining_items.
|
|
841
861
|
db.prepare(`
|
|
842
862
|
UPDATE session_summaries
|
|
843
|
-
SET request
|
|
844
|
-
|
|
863
|
+
SET request = COALESCE(NULLIF(?, ''), request),
|
|
864
|
+
investigated = COALESCE(NULLIF(?, ''), investigated),
|
|
865
|
+
learned = COALESCE(NULLIF(?, ''), learned),
|
|
866
|
+
completed = COALESCE(NULLIF(?, ''), completed),
|
|
867
|
+
next_steps = COALESCE(NULLIF(?, ''), next_steps),
|
|
868
|
+
remaining_items = COALESCE(NULLIF(?, ''), remaining_items),
|
|
869
|
+
lessons = COALESCE(?, lessons),
|
|
870
|
+
key_decisions = COALESCE(?, key_decisions),
|
|
871
|
+
notes = 'llm',
|
|
872
|
+
created_at = ?,
|
|
873
|
+
created_at_epoch = ?
|
|
845
874
|
WHERE id = ?
|
|
846
875
|
`).run(
|
|
847
876
|
llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
|
package/hook.mjs
CHANGED
|
@@ -43,8 +43,9 @@ import {
|
|
|
43
43
|
} from './hook-shared.mjs';
|
|
44
44
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
|
|
45
45
|
import { extractCitationsFromTranscript, bumpCitationAccess } from './lib/citation-tracker.mjs';
|
|
46
|
+
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
46
47
|
import { searchRelevantMemories } from './hook-memory.mjs';
|
|
47
|
-
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
48
|
+
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
48
49
|
import { checkForUpdate } from './hook-update.mjs';
|
|
49
50
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
50
51
|
import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
|
|
@@ -442,14 +443,46 @@ async function handleStop() {
|
|
|
442
443
|
ORDER BY created_at_epoch DESC LIMIT 5
|
|
443
444
|
`).all(sessionId);
|
|
444
445
|
const fastRequest = truncate(firstPrompt?.prompt_text || '', 200);
|
|
445
|
-
const
|
|
446
|
-
|
|
446
|
+
const obsCompleted = recentObs.map(o => o.title).filter(Boolean).join('; ');
|
|
447
|
+
|
|
448
|
+
// Structural extraction from the assistant's tail message.
|
|
449
|
+
// CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
|
|
450
|
+
// tail is deterministically parseable without Haiku. Prior baseline left
|
|
451
|
+
// remaining_items=='' for every session whose Haiku pass failed (≈66%
|
|
452
|
+
// in prod data), losing the user-visible "Not done" list.
|
|
453
|
+
let structuredCompleted = '';
|
|
454
|
+
let structuredNotDone = '';
|
|
455
|
+
let structuredNotes = '';
|
|
456
|
+
try {
|
|
457
|
+
const tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
|
|
458
|
+
if (tail) {
|
|
459
|
+
const s = extractStructuredSummary(tail);
|
|
460
|
+
structuredCompleted = s.done;
|
|
461
|
+
structuredNotDone = s.notDone;
|
|
462
|
+
const notesParts = [];
|
|
463
|
+
if (s.failed) notesParts.push(`Failed: ${s.failed}`);
|
|
464
|
+
if (s.uncertain) notesParts.push(`Uncertain: ${s.uncertain}`);
|
|
465
|
+
structuredNotes = notesParts.join('\n');
|
|
466
|
+
}
|
|
467
|
+
} catch (e) { debugCatch(e, 'handleStop-structured-extract'); }
|
|
468
|
+
|
|
469
|
+
const finalCompleted = structuredCompleted || obsCompleted;
|
|
470
|
+
const finalRemaining = structuredNotDone;
|
|
471
|
+
const finalNotes = structuredNotes || 'fast';
|
|
472
|
+
|
|
473
|
+
if (fastRequest || finalCompleted || finalRemaining) {
|
|
447
474
|
const now = new Date();
|
|
448
475
|
db.prepare(`
|
|
449
476
|
INSERT INTO session_summaries
|
|
450
477
|
(memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
451
|
-
VALUES (?, ?, ?, '', '', ?, '',
|
|
452
|
-
`).run(
|
|
478
|
+
VALUES (?, ?, ?, '', '', ?, '', ?, '[]', '[]', ?, ?, ?)
|
|
479
|
+
`).run(
|
|
480
|
+
sessionId, project, fastRequest,
|
|
481
|
+
truncate(finalCompleted, 600),
|
|
482
|
+
truncate(finalRemaining, 600),
|
|
483
|
+
truncate(finalNotes, 400),
|
|
484
|
+
now.toISOString(), now.getTime()
|
|
485
|
+
);
|
|
453
486
|
}
|
|
454
487
|
}
|
|
455
488
|
} catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
|
|
@@ -963,19 +996,19 @@ async function handleUserPrompt() {
|
|
|
963
996
|
if (promptNumber <= 3) {
|
|
964
997
|
try {
|
|
965
998
|
if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
|
|
966
|
-
const
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
999
|
+
const picked = pickHandoffToInject(db, project, ccSessionId);
|
|
1000
|
+
if (picked) {
|
|
1001
|
+
const injection = renderHandoffInjection(db, project, ccSessionId);
|
|
1002
|
+
if (injection) process.stdout.write(injection + '\n');
|
|
1003
|
+
// Consume ONLY the row we just injected — leave other projects' exit
|
|
1004
|
+
// handoffs intact so future sessions can still resume from them.
|
|
1005
|
+
// Pre-v2.46 wiped every exit handoff for the project on any continuation
|
|
1006
|
+
// intent, which made the DB effectively forgetful: 115 completed sessions
|
|
1007
|
+
// produced 1 persisted handoff.
|
|
973
1008
|
try {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type IN ('clear','exit')").run(project);
|
|
978
|
-
}
|
|
1009
|
+
db.prepare(
|
|
1010
|
+
'DELETE FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?'
|
|
1011
|
+
).run(project, picked.type, picked.session_id);
|
|
979
1012
|
} catch {}
|
|
980
1013
|
}
|
|
981
1014
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Structured summary extractor: reads the tail assistant message from a
|
|
2
|
+
// Claude Code transcript and pulls out Done / Not done / Failed / Uncertain
|
|
3
|
+
// sections using deterministic markers. This is the non-Haiku path — the
|
|
4
|
+
// markers are enforced by CLAUDE.md §10's four-section order rule, so they
|
|
5
|
+
// appear in ~every end-of-task message.
|
|
6
|
+
//
|
|
7
|
+
// Haiku summarization remains the richer best-effort enrichment, but it
|
|
8
|
+
// silently fails ~66% of Stop events in practice, leaving session_summaries
|
|
9
|
+
// with empty remaining_items. This extractor runs synchronously in
|
|
10
|
+
// handleStop and gives a deterministic floor.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from 'fs';
|
|
13
|
+
|
|
14
|
+
const EN_HEADER = /^[\s●*>-]*(Done|Not\s+done|Failed|Uncertain)\s*[::]\s*/im;
|
|
15
|
+
const ZH_HEADER = /^[\s●*>-]*(剩下的?|剩余|还剩|未完成|下次(?:要做|做|继续)?|待做|未做)\s*[::]?\s*/m;
|
|
16
|
+
|
|
17
|
+
// Recognised section keys, normalised.
|
|
18
|
+
const EN_KEY = { done: 'done', 'not done': 'notDone', failed: 'failed', uncertain: 'uncertain' };
|
|
19
|
+
const ZH_KEY_IS_NOTDONE = /剩下|剩余|还剩|未完成|下次|待做|未做/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the LAST assistant text block from a Claude Code transcript .jsonl.
|
|
23
|
+
* Returns concatenated text of all text blocks in the last `type='assistant'`
|
|
24
|
+
* entry, or null if the file is missing/empty/malformed.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} transcriptPath
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
export function extractTailAssistantText(transcriptPath) {
|
|
30
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
31
|
+
let raw;
|
|
32
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return null; }
|
|
33
|
+
let last = null;
|
|
34
|
+
for (const line of raw.split('\n')) {
|
|
35
|
+
if (!line.trim()) continue;
|
|
36
|
+
let entry;
|
|
37
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
38
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
39
|
+
const content = entry.message.content;
|
|
40
|
+
if (!Array.isArray(content)) continue;
|
|
41
|
+
const texts = content
|
|
42
|
+
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
43
|
+
.map(b => b.text);
|
|
44
|
+
if (texts.length === 0) continue;
|
|
45
|
+
last = texts.join('\n');
|
|
46
|
+
}
|
|
47
|
+
return last;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract Done / Not done / Failed / Uncertain sections from a message body.
|
|
52
|
+
* Returns an object with four string fields (empty when the section is absent).
|
|
53
|
+
*
|
|
54
|
+
* Strategy: scan line by line, recognise section headers in EN and 中文,
|
|
55
|
+
* attribute subsequent content to that section until the next header or a
|
|
56
|
+
* hard boundary (blank line followed by a non-bullet line).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} text
|
|
59
|
+
* @returns {{done: string, notDone: string, failed: string, uncertain: string}}
|
|
60
|
+
*/
|
|
61
|
+
export function extractStructuredSummary(text) {
|
|
62
|
+
const out = { done: '', notDone: '', failed: '', uncertain: '' };
|
|
63
|
+
if (!text || typeof text !== 'string') return out;
|
|
64
|
+
|
|
65
|
+
const lines = text.split('\n');
|
|
66
|
+
let current = null;
|
|
67
|
+
const buffers = { done: [], notDone: [], failed: [], uncertain: [] };
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
|
|
73
|
+
// Header detection — EN first (unambiguous), then 中文.
|
|
74
|
+
const enMatch = line.match(EN_HEADER);
|
|
75
|
+
if (enMatch) {
|
|
76
|
+
const key = EN_KEY[enMatch[1].toLowerCase().replace(/\s+/g, ' ')];
|
|
77
|
+
if (key) {
|
|
78
|
+
current = key;
|
|
79
|
+
const tail = line.slice(enMatch[0].length).trim();
|
|
80
|
+
if (tail) buffers[current].push(tail);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const zhMatch = line.match(ZH_HEADER);
|
|
85
|
+
if (zhMatch && ZH_KEY_IS_NOTDONE.test(zhMatch[1])) {
|
|
86
|
+
current = 'notDone';
|
|
87
|
+
const tail = line.slice(zhMatch[0].length).trim();
|
|
88
|
+
if (tail) buffers.notDone.push(tail);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!current) continue;
|
|
93
|
+
|
|
94
|
+
// Paragraph-break termination: blank line followed by a non-bullet,
|
|
95
|
+
// non-indented line starts a fresh paragraph unrelated to the section.
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
const next = (lines[i + 1] || '').trim();
|
|
98
|
+
const nextIsBullet = /^[-*•●\d]+[.)]?\s+/.test(next);
|
|
99
|
+
if (!nextIsBullet && next) {
|
|
100
|
+
current = null;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
buffers[current].push(trimmed);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const k of Object.keys(buffers)) {
|
|
109
|
+
out[k] = buffers[k].join('\n').trim();
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.0",
|
|
4
4
|
"description": "Lightweight persistent memory system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"lib/stats-quality.mjs",
|
|
52
52
|
"lib/low-signal-patterns.mjs",
|
|
53
53
|
"lib/citation-tracker.mjs",
|
|
54
|
+
"lib/summary-extractor.mjs",
|
|
54
55
|
"lib/id-routing.mjs",
|
|
55
56
|
"lib/err-sampler.mjs",
|
|
56
57
|
"lib/metrics.mjs",
|