claude-mem-lite 6.13.4 → 6.13.6
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-llm.mjs +64 -104
- package/hook.mjs +93 -87
- package/install.mjs +5 -3
- package/lib/fast-summary.mjs +313 -4
- package/lib/stats-core.mjs +10 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.13.
|
|
12
|
+
"version": "6.13.6",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.13.
|
|
3
|
+
"version": "6.13.6",
|
|
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-llm.mjs
CHANGED
|
@@ -25,6 +25,9 @@ import {
|
|
|
25
25
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
26
26
|
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
27
27
|
import { scrubRecord, scrubFilePaths } from './lib/scrub-record.mjs';
|
|
28
|
+
import { mergeModelSummary, summarySuperseded } from './lib/fast-summary.mjs';
|
|
29
|
+
import { recordMetric } from './lib/metrics.mjs';
|
|
30
|
+
import { DB_DIR } from './schema.mjs';
|
|
28
31
|
import {
|
|
29
32
|
insertObservationRow,
|
|
30
33
|
insertObservationFiles,
|
|
@@ -561,7 +564,7 @@ function linkRelatedObservations(db, savedId, obs, episode) {
|
|
|
561
564
|
`
|
|
562
565
|
SELECT id, files_modified FROM observations
|
|
563
566
|
WHERE id != ? AND created_at_epoch > ? AND project = ?
|
|
564
|
-
ORDER BY created_at_epoch DESC LIMIT 50
|
|
567
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 50
|
|
565
568
|
`,
|
|
566
569
|
)
|
|
567
570
|
.all(newObs.id, Date.now() - RELATED_OBS_WINDOW_MS, episode.project);
|
|
@@ -1373,8 +1376,28 @@ export async function handleLLMSummary() {
|
|
|
1373
1376
|
);
|
|
1374
1377
|
}
|
|
1375
1378
|
|
|
1379
|
+
// The spawning Stop's epoch (absent for the /clear spawn and pre-upgrade workers). Checked
|
|
1380
|
+
// before the model call and again in the write's transaction, because two workers of one
|
|
1381
|
+
// session can finish out of order (P3-6); the later Stop's worker reads the newer window.
|
|
1382
|
+
const parsedEpoch = Number(process.argv[5]);
|
|
1383
|
+
const spawnEpoch = Number.isFinite(parsedEpoch) && parsedEpoch > 0 ? parsedEpoch : null;
|
|
1384
|
+
// One `summary_worker` metric row per exit (CLAUDE_MEM_METRICS=1), carrying the session and
|
|
1385
|
+
// the Stop epoch so a superseded worker can be paired with its successor's outcome, and how
|
|
1386
|
+
// many model calls a session costs can be counted (D#95). This process runs detached with no
|
|
1387
|
+
// stderr, so nothing else shows it.
|
|
1388
|
+
const metricSession = process.argv[3] || null;
|
|
1389
|
+
let llmMs;
|
|
1390
|
+
const outcome = (name) =>
|
|
1391
|
+
recordMetric(DB_DIR, {
|
|
1392
|
+
event: 'summary_worker',
|
|
1393
|
+
outcome: name,
|
|
1394
|
+
session: metricSession,
|
|
1395
|
+
stopEpoch: spawnEpoch,
|
|
1396
|
+
...(llmMs === undefined ? {} : { llmMs }),
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1376
1399
|
const db = openDb();
|
|
1377
|
-
if (!db) return;
|
|
1400
|
+
if (!db) return outcome('no-db');
|
|
1378
1401
|
|
|
1379
1402
|
try {
|
|
1380
1403
|
const sessionId = process.argv[3] || getSessionId();
|
|
@@ -1396,7 +1419,7 @@ export async function handleLLMSummary() {
|
|
|
1396
1419
|
)
|
|
1397
1420
|
.all(sessionId);
|
|
1398
1421
|
|
|
1399
|
-
if (recentObs.length < 1) return;
|
|
1422
|
+
if (recentObs.length < 1) return outcome('no-obs');
|
|
1400
1423
|
|
|
1401
1424
|
const obsList = recentObs
|
|
1402
1425
|
.map(
|
|
@@ -1432,12 +1455,18 @@ ${obsList}`;
|
|
|
1432
1455
|
|
|
1433
1456
|
if (!(await acquireLLMSlot())) {
|
|
1434
1457
|
debugLog('WARN', 'llm-summary', 'semaphore timeout, skipping summary');
|
|
1435
|
-
return;
|
|
1458
|
+
return outcome('slot-timeout');
|
|
1436
1459
|
}
|
|
1437
1460
|
|
|
1438
1461
|
let raw, llmParsed;
|
|
1439
1462
|
try {
|
|
1463
|
+
if (summarySuperseded(db, sessionId, spawnEpoch)) {
|
|
1464
|
+
debugLog('DEBUG', 'llm-summary', 'a later Stop owns this session summary, skipping');
|
|
1465
|
+
return outcome('superseded-before-call');
|
|
1466
|
+
}
|
|
1467
|
+
const callStart = Date.now();
|
|
1440
1468
|
raw = await callLLM(prompt, BG_LLM_TIMEOUT_MS);
|
|
1469
|
+
llmMs = Date.now() - callStart;
|
|
1441
1470
|
llmParsed = parseJsonFromLLM(raw);
|
|
1442
1471
|
} finally {
|
|
1443
1472
|
releaseLLMSlot();
|
|
@@ -1459,8 +1488,8 @@ ${obsList}`;
|
|
|
1459
1488
|
// alone dropped the whole INSERT/UPDATE (losing the session's highest-value fields:
|
|
1460
1489
|
// lessons + key_decisions) whenever Haiku returned an empty request string but a rich
|
|
1461
1490
|
// `{completed, lessons, key_decisions}` — a common degraded shape. Downstream tolerates an
|
|
1462
|
-
// empty request: INSERT writes '' and the UPDATE
|
|
1463
|
-
// the
|
|
1491
|
+
// empty request: INSERT writes '' and the UPDATE keeps the row's own request (or an older row's)
|
|
1492
|
+
// when the reply's is empty. Use asText in the gate so a non-string / empty-array field can't falsely
|
|
1464
1493
|
// trigger it.
|
|
1465
1494
|
const hasSummaryContent =
|
|
1466
1495
|
llmParsed &&
|
|
@@ -1481,107 +1510,38 @@ ${obsList}`;
|
|
|
1481
1510
|
? JSON.stringify(llmParsed.key_decisions)
|
|
1482
1511
|
: null;
|
|
1483
1512
|
|
|
1484
|
-
// Upgrade
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
)
|
|
1499
|
-
.
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
// session_summaries ship with empty remaining_items.
|
|
1507
|
-
//
|
|
1508
|
-
// Scrub LLM-output text fields at the UPDATE boundary. lessons /
|
|
1509
|
-
// key_decisions are JSON.stringify(array<string>); we scrub the JSON
|
|
1510
|
-
// string here to match the sibling INSERT path. scrubSecrets uses
|
|
1511
|
-
// opaque placeholders that preserve JSON structure; element-level
|
|
1512
|
-
// pre-scrub remains safer in principle but would diverge from the
|
|
1513
|
-
// merged INSERT contract.
|
|
1514
|
-
const safe = scrubRecord('session_summaries', {
|
|
1515
|
-
request: asText(llmParsed.request),
|
|
1516
|
-
investigated: asText(llmParsed.investigated),
|
|
1517
|
-
learned: asText(llmParsed.learned),
|
|
1518
|
-
completed: asText(llmParsed.completed),
|
|
1519
|
-
next_steps: asText(llmParsed.next_steps),
|
|
1520
|
-
remaining_items: asText(llmParsed.remaining_items),
|
|
1521
|
-
lessons: lessonsJson,
|
|
1522
|
-
key_decisions: decisionsJson,
|
|
1523
|
-
});
|
|
1524
|
-
db.prepare(
|
|
1525
|
-
`
|
|
1526
|
-
UPDATE session_summaries
|
|
1527
|
-
SET request = COALESCE(NULLIF(?, ''), request),
|
|
1528
|
-
investigated = COALESCE(NULLIF(?, ''), investigated),
|
|
1529
|
-
learned = COALESCE(NULLIF(?, ''), learned),
|
|
1530
|
-
completed = COALESCE(NULLIF(?, ''), completed),
|
|
1531
|
-
next_steps = COALESCE(NULLIF(?, ''), next_steps),
|
|
1532
|
-
remaining_items = COALESCE(NULLIF(?, ''), remaining_items),
|
|
1533
|
-
lessons = COALESCE(?, lessons),
|
|
1534
|
-
key_decisions = COALESCE(?, key_decisions),
|
|
1535
|
-
notes = 'llm',
|
|
1536
|
-
created_at = ?,
|
|
1537
|
-
created_at_epoch = ?
|
|
1538
|
-
WHERE id = ?
|
|
1539
|
-
`,
|
|
1540
|
-
).run(
|
|
1541
|
-
safe.request,
|
|
1542
|
-
safe.investigated,
|
|
1543
|
-
safe.learned,
|
|
1544
|
-
safe.completed,
|
|
1545
|
-
safe.next_steps,
|
|
1546
|
-
safe.remaining_items,
|
|
1547
|
-
safe.lessons,
|
|
1548
|
-
safe.key_decisions,
|
|
1549
|
-
now.toISOString(),
|
|
1550
|
-
now.getTime(),
|
|
1551
|
-
existingFast.id,
|
|
1552
|
-
);
|
|
1513
|
+
// Upgrade the session's summary row instead of creating another. This worker runs after
|
|
1514
|
+
// EVERY Stop (one per assistant turn) and again from SessionStart's /clear path; selecting
|
|
1515
|
+
// only a `notes = 'fast'` row found nothing once the first run had upgraded it, and each
|
|
1516
|
+
// later turn INSERTed (one live session: 37 rows in 65 minutes). mergeModelSummary lands
|
|
1517
|
+
// on the session's newest row and keeps a report's Done / Not done over the model's
|
|
1518
|
+
// (lib/fast-summary.mjs).
|
|
1519
|
+
//
|
|
1520
|
+
// Scrub LLM-output text fields at the write boundary. lessons / key_decisions are
|
|
1521
|
+
// JSON.stringify(array<string>); scrubSecrets uses opaque placeholders that preserve
|
|
1522
|
+
// JSON structure, so the JSON string is scrubbed whole.
|
|
1523
|
+
const safe = scrubRecord('session_summaries', {
|
|
1524
|
+
request: asText(llmParsed.request),
|
|
1525
|
+
investigated: asText(llmParsed.investigated),
|
|
1526
|
+
learned: asText(llmParsed.learned),
|
|
1527
|
+
completed: asText(llmParsed.completed),
|
|
1528
|
+
next_steps: asText(llmParsed.next_steps),
|
|
1529
|
+
remaining_items: asText(llmParsed.remaining_items),
|
|
1530
|
+
lessons: lessonsJson,
|
|
1531
|
+
key_decisions: decisionsJson,
|
|
1532
|
+
});
|
|
1533
|
+
if (mergeModelSummary(db, { sessionId, project, fields: safe, now, spawnEpoch })) {
|
|
1534
|
+
outcome('written');
|
|
1553
1535
|
} else {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
investigated: asText(llmParsed.investigated),
|
|
1557
|
-
learned: asText(llmParsed.learned),
|
|
1558
|
-
completed: asText(llmParsed.completed),
|
|
1559
|
-
next_steps: asText(llmParsed.next_steps),
|
|
1560
|
-
remaining_items: asText(llmParsed.remaining_items),
|
|
1561
|
-
lessons: lessonsJson,
|
|
1562
|
-
key_decisions: decisionsJson,
|
|
1563
|
-
});
|
|
1564
|
-
db.prepare(
|
|
1565
|
-
`
|
|
1566
|
-
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
|
|
1567
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
|
|
1568
|
-
`,
|
|
1569
|
-
).run(
|
|
1570
|
-
sessionId,
|
|
1571
|
-
project,
|
|
1572
|
-
safe.request,
|
|
1573
|
-
safe.investigated,
|
|
1574
|
-
safe.learned,
|
|
1575
|
-
safe.completed,
|
|
1576
|
-
safe.next_steps,
|
|
1577
|
-
safe.remaining_items,
|
|
1578
|
-
safe.lessons,
|
|
1579
|
-
safe.key_decisions,
|
|
1580
|
-
now.toISOString(),
|
|
1581
|
-
now.getTime(),
|
|
1582
|
-
);
|
|
1536
|
+
debugLog('DEBUG', 'llm-summary', 'a later Stop landed during the model call, reply dropped');
|
|
1537
|
+
outcome('superseded-at-write');
|
|
1583
1538
|
}
|
|
1539
|
+
} else {
|
|
1540
|
+
outcome('no-content');
|
|
1584
1541
|
}
|
|
1542
|
+
} catch (e) {
|
|
1543
|
+
outcome('error');
|
|
1544
|
+
throw e;
|
|
1585
1545
|
} finally {
|
|
1586
1546
|
db.close();
|
|
1587
1547
|
}
|
package/hook.mjs
CHANGED
|
@@ -88,7 +88,13 @@ import {
|
|
|
88
88
|
lastDbUnusable,
|
|
89
89
|
} from './hook-shared.mjs';
|
|
90
90
|
import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
91
|
-
import {
|
|
91
|
+
import {
|
|
92
|
+
readFastSummarySource,
|
|
93
|
+
insertFastSummary,
|
|
94
|
+
writeStopSummary,
|
|
95
|
+
writeClearSummary,
|
|
96
|
+
FAST_SUMMARY_LIMITS,
|
|
97
|
+
} from './lib/fast-summary.mjs';
|
|
92
98
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
93
99
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
94
100
|
import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
|
|
@@ -1069,13 +1075,20 @@ function flushEpisodeAtStop(sessionId, project) {
|
|
|
1069
1075
|
* parallel-safe row identity). Without the split, CC UUID-based queries miss
|
|
1070
1076
|
* user_prompts and the handoff row is silently skipped (see hook-handoff.mjs).
|
|
1071
1077
|
*/
|
|
1072
|
-
function markSessionCompletedAndSaveHandoff(
|
|
1078
|
+
function markSessionCompletedAndSaveHandoff(
|
|
1079
|
+
db,
|
|
1080
|
+
{ sessionId, project, ccSessionId, episodeSnapshot, stopEpoch },
|
|
1081
|
+
) {
|
|
1082
|
+
// Every Stop, not only the first: Stop fires per assistant turn, and `status = 'active'` alone
|
|
1083
|
+
// matched the first turn only, so completed_at kept the first turn's end for the whole
|
|
1084
|
+
// session. completed_at_epoch is now the session's LATEST Stop, which the llm-summary worker
|
|
1085
|
+
// compares against its own Stop's epoch to learn that a later worker owns the row (P3-6).
|
|
1073
1086
|
db.prepare(
|
|
1074
1087
|
`
|
|
1075
1088
|
UPDATE sdk_sessions SET status = 'completed', completed_at = ?, completed_at_epoch = ?
|
|
1076
|
-
WHERE content_session_id = ? AND status
|
|
1089
|
+
WHERE content_session_id = ? AND status IN ('active', 'completed')
|
|
1077
1090
|
`,
|
|
1078
|
-
).run(new Date().toISOString(),
|
|
1091
|
+
).run(new Date(stopEpoch).toISOString(), stopEpoch, sessionId);
|
|
1079
1092
|
// Save handoff snapshot for cross-session continuity.
|
|
1080
1093
|
// sessionId = mem-internal (query key); ccSessionId = CC UUID (scope key for
|
|
1081
1094
|
// parallel-safe row identity). Without the split, CC UUID-based queries miss
|
|
@@ -1089,59 +1102,48 @@ function markSessionCompletedAndSaveHandoff(db, { sessionId, project, ccSessionI
|
|
|
1089
1102
|
|
|
1090
1103
|
/** Fast summary baseline — ensures a summary exists even if the background LLM fails. */
|
|
1091
1104
|
function writeFastSummaryBaseline(db, { sessionId, project, transcriptPath }) {
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1105
|
+
// Stop fires once per assistant TURN and the mem session survives it (R10-P1-1), so this
|
|
1106
|
+
// runs on every turn of a session. The first turn with anything to say INSERTs the row
|
|
1107
|
+
// (T4-P2-B's guard: never a second row); every later turn REFRESHES that row from its tail's
|
|
1108
|
+
// report, or, without one, from the current observation titles where the row's Done is
|
|
1109
|
+
// still titles (lib/fast-summary.mjs writeStopSummary). The guard alone used to stop there, so the
|
|
1110
|
+
// row kept the FIRST turn's report: over 7 days of this machine's transcripts (09:40Z), 20
|
|
1111
|
+
// of the 31 sessions that wrote §10 markers had a first-turn extract different from their
|
|
1112
|
+
// last report. Uses the mem-internal sessionId as the WHERE key per the top-of-file
|
|
1113
|
+
// dual-id invariant (#7789).
|
|
1096
1114
|
try {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
const
|
|
1113
|
-
if (
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
structuredNotDone = s.notDone;
|
|
1117
|
-
const notesParts = [];
|
|
1118
|
-
if (s.failed) notesParts.push(`Failed: ${s.failed}`);
|
|
1119
|
-
if (s.uncertain) notesParts.push(`Uncertain: ${s.uncertain}`);
|
|
1120
|
-
structuredNotes = notesParts.join('\n');
|
|
1121
|
-
}
|
|
1122
|
-
} catch (e) {
|
|
1123
|
-
debugCatch(e, 'handleStop-structured-extract');
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const finalCompleted = structuredCompleted || obsCompleted;
|
|
1127
|
-
const finalRemaining = structuredNotDone;
|
|
1128
|
-
const finalNotes = structuredNotes || 'fast';
|
|
1129
|
-
|
|
1130
|
-
if (fastRequestRaw || finalCompleted || finalRemaining) {
|
|
1131
|
-
insertFastSummary(db, {
|
|
1132
|
-
sessionId,
|
|
1133
|
-
project,
|
|
1134
|
-
now: new Date(),
|
|
1135
|
-
values: {
|
|
1136
|
-
request: fastRequestRaw,
|
|
1137
|
-
completed: finalCompleted,
|
|
1138
|
-
remaining: finalRemaining,
|
|
1139
|
-
notes: finalNotes,
|
|
1140
|
-
},
|
|
1141
|
-
limits: FAST_SUMMARY_LIMITS.stop,
|
|
1142
|
-
});
|
|
1115
|
+
// Structural extraction from the assistant's tail message.
|
|
1116
|
+
// CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
|
|
1117
|
+
// tail is deterministically parseable without Haiku. Prior baseline left
|
|
1118
|
+
// remaining_items=='' for every session whose Haiku pass failed (≈66%
|
|
1119
|
+
// in prod data), losing the user-visible "Not done" list. handleStop calls this
|
|
1120
|
+
// AFTER trackCitationsAtStop so the parse is the one it left memoized.
|
|
1121
|
+
let structuredCompleted = '';
|
|
1122
|
+
let structuredNotDone = '';
|
|
1123
|
+
let structuredNotes = '';
|
|
1124
|
+
try {
|
|
1125
|
+
const tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
|
|
1126
|
+
if (tail) {
|
|
1127
|
+
const s = extractStructuredSummary(tail);
|
|
1128
|
+
structuredCompleted = s.done;
|
|
1129
|
+
structuredNotDone = s.notDone;
|
|
1130
|
+
const notesParts = [];
|
|
1131
|
+
if (s.failed) notesParts.push(`Failed: ${s.failed}`);
|
|
1132
|
+
if (s.uncertain) notesParts.push(`Uncertain: ${s.uncertain}`);
|
|
1133
|
+
structuredNotes = notesParts.join('\n');
|
|
1143
1134
|
}
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
debugCatch(e, 'handleStop-structured-extract');
|
|
1144
1137
|
}
|
|
1138
|
+
|
|
1139
|
+
writeStopSummary(db, {
|
|
1140
|
+
sessionId,
|
|
1141
|
+
project,
|
|
1142
|
+
report: { done: structuredCompleted, notDone: structuredNotDone, lines: structuredNotes },
|
|
1143
|
+
source: readFastSummarySource(db, sessionId),
|
|
1144
|
+
now: new Date(),
|
|
1145
|
+
limits: FAST_SUMMARY_LIMITS.stop,
|
|
1146
|
+
});
|
|
1145
1147
|
} catch (e) {
|
|
1146
1148
|
debugCatch(e, 'handleStop-fast-summary');
|
|
1147
1149
|
}
|
|
@@ -1582,13 +1584,18 @@ async function handleStop() {
|
|
|
1582
1584
|
|
|
1583
1585
|
flushEpisodeAtStop(sessionId, project);
|
|
1584
1586
|
|
|
1585
|
-
// Mark session completed + save handoff (sync, instant)
|
|
1587
|
+
// Mark session completed + save handoff (sync, instant). The same epoch goes to the summary
|
|
1588
|
+
// worker below, so it can tell whether a later Stop has superseded it.
|
|
1589
|
+
const stopEpoch = Date.now();
|
|
1586
1590
|
const db = openDb();
|
|
1587
1591
|
if (db) {
|
|
1588
1592
|
try {
|
|
1589
|
-
markSessionCompletedAndSaveHandoff(db, { sessionId, project, ccSessionId, episodeSnapshot });
|
|
1590
|
-
|
|
1593
|
+
markSessionCompletedAndSaveHandoff(db, { sessionId, project, ccSessionId, episodeSnapshot, stopEpoch });
|
|
1594
|
+
// Citations first: they read the subagent transcripts before the parent, so the parent is
|
|
1595
|
+
// parsed once and stays memoized (D#152). The summary reads the parent's tail on every
|
|
1596
|
+
// turn; run before them, it parsed the parent a second time in any session with subagents.
|
|
1591
1597
|
trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptPath });
|
|
1598
|
+
writeFastSummaryBaseline(db, { sessionId, project, transcriptPath });
|
|
1592
1599
|
} finally {
|
|
1593
1600
|
db.close();
|
|
1594
1601
|
}
|
|
@@ -1602,7 +1609,8 @@ async function handleStop() {
|
|
|
1602
1609
|
// waits on, then recreates the sandbox tree behind the test's cleanup. Any
|
|
1603
1610
|
// grace period for that is a race, not a barrier — the post-tag review timed a
|
|
1604
1611
|
// recreate at 432ms and watched a 300ms grace lose.
|
|
1605
|
-
if (!process.env.CLAUDE_MEM_SKIP_SUMMARY)
|
|
1612
|
+
if (!process.env.CLAUDE_MEM_SKIP_SUMMARY)
|
|
1613
|
+
spawnBackground('llm-summary', sessionId, project, String(stopEpoch));
|
|
1606
1614
|
|
|
1607
1615
|
// The session file deliberately SURVIVES Stop (R10-P1-1). It used to be unlinked here,
|
|
1608
1616
|
// on the model "Stop = /exit = the session is over". The host does not work that way:
|
|
@@ -2144,8 +2152,8 @@ function saveHandoffAndFastSummary(
|
|
|
2144
2152
|
}
|
|
2145
2153
|
|
|
2146
2154
|
// Build fast synchronous summary for immediate context availability.
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2155
|
+
// The background llm-summary spawned above upgrades this same row in place later,
|
|
2156
|
+
// without moving its timestamp.
|
|
2149
2157
|
try {
|
|
2150
2158
|
const { request: fastRequestRaw, completed: fastCompletedRaw } = readFastSummarySource(
|
|
2151
2159
|
db,
|
|
@@ -2166,13 +2174,17 @@ function saveHandoffAndFastSummary(
|
|
|
2166
2174
|
if (errors.length > 0) fastRemainingRaw = errors.join('; ');
|
|
2167
2175
|
}
|
|
2168
2176
|
|
|
2177
|
+
// One row per session: when Stop already wrote the previous session's row, this updates
|
|
2178
|
+
// it rather than INSERTing a second one beside it (80 live sessions had two, 2026-09-26).
|
|
2179
|
+
// The gate is unchanged, so the row moves to `now` exactly when the second row used to
|
|
2180
|
+
// be written with it.
|
|
2169
2181
|
if (fastRequestRaw || fastCompletedRaw) {
|
|
2170
|
-
|
|
2182
|
+
writeClearSummary(db, {
|
|
2171
2183
|
sessionId: prevSessionId,
|
|
2172
2184
|
project: prevProject || project,
|
|
2173
|
-
now,
|
|
2174
2185
|
values: { request: fastRequestRaw, completed: fastCompletedRaw, remaining: fastRemainingRaw },
|
|
2175
2186
|
limits: FAST_SUMMARY_LIMITS.sessionStart,
|
|
2187
|
+
now,
|
|
2176
2188
|
});
|
|
2177
2189
|
}
|
|
2178
2190
|
} catch (e) {
|
|
@@ -2244,38 +2256,32 @@ function buildFallbackFastSummary(db, { project, now, prevSessionId }) {
|
|
|
2244
2256
|
const recentSession = db
|
|
2245
2257
|
.prepare(
|
|
2246
2258
|
`
|
|
2247
|
-
SELECT content_session_id, project FROM sdk_sessions
|
|
2259
|
+
SELECT content_session_id, project FROM sdk_sessions s
|
|
2248
2260
|
WHERE project = ? AND status = 'completed' AND completed_at_epoch > ?
|
|
2261
|
+
AND NOT EXISTS (SELECT 1 FROM session_summaries WHERE memory_session_id = s.content_session_id)
|
|
2249
2262
|
ORDER BY completed_at_epoch DESC LIMIT 1
|
|
2250
2263
|
`,
|
|
2251
2264
|
)
|
|
2252
2265
|
.get(project, Date.now() - 120000); // within last 2 minutes
|
|
2253
2266
|
|
|
2267
|
+
// "Has no summary" is in the WHERE, not checked after LIMIT 1: every Stop now records
|
|
2268
|
+
// itself (P3-6), so a parallel session still live ranks by its latest turn and, holding a
|
|
2269
|
+
// summary, would take the one slot from the session that actually exited.
|
|
2254
2270
|
if (recentSession) {
|
|
2255
|
-
const
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
.
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
// No remaining_items on this path: an /exit restart has no handoff and no
|
|
2270
|
-
// episode snapshot to infer one from. It was a bare '' in the SQL before.
|
|
2271
|
-
insertFastSummary(db, {
|
|
2272
|
-
sessionId: recentSession.content_session_id,
|
|
2273
|
-
project,
|
|
2274
|
-
now,
|
|
2275
|
-
values: { request: frRaw, completed: fcRaw },
|
|
2276
|
-
limits: FAST_SUMMARY_LIMITS.exitRestart,
|
|
2277
|
-
});
|
|
2278
|
-
}
|
|
2271
|
+
const { request: frRaw, completed: fcRaw } = readFastSummarySource(
|
|
2272
|
+
db,
|
|
2273
|
+
recentSession.content_session_id,
|
|
2274
|
+
);
|
|
2275
|
+
if (frRaw || fcRaw) {
|
|
2276
|
+
// No remaining_items on this path: an /exit restart has no handoff and no
|
|
2277
|
+
// episode snapshot to infer one from. It was a bare '' in the SQL before.
|
|
2278
|
+
insertFastSummary(db, {
|
|
2279
|
+
sessionId: recentSession.content_session_id,
|
|
2280
|
+
project,
|
|
2281
|
+
now,
|
|
2282
|
+
values: { request: frRaw, completed: fcRaw },
|
|
2283
|
+
limits: FAST_SUMMARY_LIMITS.exitRestart,
|
|
2284
|
+
});
|
|
2279
2285
|
}
|
|
2280
2286
|
}
|
|
2281
2287
|
} catch (e) {
|
package/install.mjs
CHANGED
|
@@ -1604,7 +1604,8 @@ async function status() {
|
|
|
1604
1604
|
const Database = (await import('better-sqlite3')).default;
|
|
1605
1605
|
const db = new Database(DB_PATH, { readonly: true });
|
|
1606
1606
|
const obs = db.prepare('SELECT COUNT(*) as c FROM observations').get();
|
|
1607
|
-
|
|
1607
|
+
// DISTINCT, like stats: a session can own several summary rows (legacy duplicates).
|
|
1608
|
+
const sess = db.prepare('SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries').get();
|
|
1608
1609
|
db.close();
|
|
1609
1610
|
push('ok', 'database', `Database: ${obs.c} observations, ${sess.c} sessions`, {
|
|
1610
1611
|
exists: true,
|
|
@@ -2603,8 +2604,9 @@ async function doctor() {
|
|
|
2603
2604
|
const Database = (await import('better-sqlite3')).default;
|
|
2604
2605
|
const db = new Database(DB_PATH, { readonly: true });
|
|
2605
2606
|
const obsCount = db.prepare('SELECT COUNT(*) as cnt FROM observations').get()?.cnt || 0;
|
|
2606
|
-
// Align with stats / MCP mem_stats: session_summaries, not sdk_sessions
|
|
2607
|
-
const sessCount =
|
|
2607
|
+
// Align with stats / MCP mem_stats: session_summaries, not sdk_sessions, counted DISTINCT
|
|
2608
|
+
const sessCount =
|
|
2609
|
+
db.prepare('SELECT COUNT(DISTINCT memory_session_id) as cnt FROM session_summaries').get()?.cnt || 0;
|
|
2608
2610
|
db.close();
|
|
2609
2611
|
const stats = `DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`;
|
|
2610
2612
|
// The read succeeds on a too-new file — the tables are still there — so this
|
package/lib/fast-summary.mjs
CHANGED
|
@@ -9,10 +9,22 @@
|
|
|
9
9
|
// v3.35.2, and the comment in one of these blocks announcing "parity with the other"
|
|
10
10
|
// is the tell that parity was being maintained by hand.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// ONE ROW PER SESSION. Stop fires once per assistant TURN (the session file survives it
|
|
13
|
+
// since R10-P1-1), so every writer here runs many times against a session that already has
|
|
14
|
+
// a row. Each one lands on the session's newest row (`newestSummaryId`) and INSERTs only when
|
|
15
|
+
// there is none: Stop (`writeStopSummary`), SessionStart's /clear-or-/compact path
|
|
16
|
+
// (`writeClearSummary`) and hook-llm.mjs's model upgrade (`mergeModelSummary`). The
|
|
17
|
+
// /exit-restart fallback in hook.mjs inserts only for a session with no row at all.
|
|
18
|
+
// Inserting instead left one live session with 37 rows in 65 minutes, which is 9 and 10 of
|
|
19
|
+
// the top ten session search hits for its own vocabulary.
|
|
20
|
+
//
|
|
21
|
+
// Which writer wins a field depends on where that field came from, recorded per field at
|
|
22
|
+
// the head of `notes` (parseSummaryNotes): the assistant's own report beats the model's
|
|
23
|
+
// summary, which beats the last observation titles.
|
|
24
|
+
//
|
|
25
|
+
// The model's write lives here too (`mergeModelSummary`): its precedence against the other
|
|
26
|
+
// two writers is the point of this module, and keeping the three in one place is what lets
|
|
27
|
+
// them agree on it.
|
|
16
28
|
import { scrubRecord } from './scrub-record.mjs';
|
|
17
29
|
import { truncate } from '../format-utils.mjs';
|
|
18
30
|
|
|
@@ -109,3 +121,300 @@ export function insertFastSummary(db, { sessionId, project, values, limits, now
|
|
|
109
121
|
now.getTime(),
|
|
110
122
|
);
|
|
111
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where a row's Done and Not done came from, stored as the head of `notes`:
|
|
127
|
+
*
|
|
128
|
+
* `done<report|model|titles> left<report|other>[ <Failed / Uncertain lines>]`
|
|
129
|
+
*
|
|
130
|
+
* - done: `report` = the assistant's own Done; `model` = the LLM summary; `titles` = the last
|
|
131
|
+
* observation titles, a fallback.
|
|
132
|
+
* - left: `report` = the assistant's own Not done, where '' means "nothing left"; `other` =
|
|
133
|
+
* the model's inference or the handoff's unfinished list, which only fill a gap.
|
|
134
|
+
*
|
|
135
|
+
* Precedence per field: report > model > titles / other. It is recorded per FIELD because one
|
|
136
|
+
* report can carry a Not done and no Done, or only Failed lines; a single per-row tag read
|
|
137
|
+
* those as a full report and froze stale titles as its Done (v6.13.5 delta review P2-1,
|
|
138
|
+
* P2-2). Rows written before this carry older values: `fast` and bare Failed / Uncertain
|
|
139
|
+
* text read as done = titles, left = other (so fresh titles or a report replace them — a
|
|
140
|
+
* pre-upgrade report Done among them can be replaced once); `llm`, '' and NULL read as done
|
|
141
|
+
* = model, left = other; any EMPTY Done takes the titles whatever its tag. `get` prints
|
|
142
|
+
* `notes` as stored; FTS indexes it at weight 1, and each tag is a single token nobody types.
|
|
143
|
+
* A space separates the head from the lines because `truncate` folds newlines into spaces.
|
|
144
|
+
*/
|
|
145
|
+
export function parseSummaryNotes(notes) {
|
|
146
|
+
const text = typeof notes === 'string' ? notes : '';
|
|
147
|
+
const m = /^done(report|model|titles) left(report|other)(?: ([\s\S]*))?$/.exec(text);
|
|
148
|
+
if (m) return { done: m[1], left: m[2], lines: m[3] || '' };
|
|
149
|
+
if (text === '' || text === 'llm') return { done: 'model', left: 'other', lines: '' };
|
|
150
|
+
if (text === 'fast') return { done: 'titles', left: 'other', lines: '' };
|
|
151
|
+
return { done: 'titles', left: 'other', lines: text };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inverse of parseSummaryNotes. `lines` must already be scrubbed. */
|
|
155
|
+
export function formatSummaryNotes({ done, left, lines }, max) {
|
|
156
|
+
const head = `done${done} left${left}`;
|
|
157
|
+
if (!lines) return head;
|
|
158
|
+
return truncate(`${head} ${lines}`, max ?? Number.MAX_SAFE_INTEGER);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The session's summary row every later write lands on: its newest, by the same order Last
|
|
163
|
+
* Session reads (`created_at_epoch DESC, id DESC`). Rows older than it exist only from before
|
|
164
|
+
* one-row-per-session, or from two writers racing an empty session.
|
|
165
|
+
*
|
|
166
|
+
* @returns {number|null}
|
|
167
|
+
*/
|
|
168
|
+
export function newestSummaryId(db, sessionId) {
|
|
169
|
+
const row = db
|
|
170
|
+
.prepare(
|
|
171
|
+
`
|
|
172
|
+
SELECT id FROM session_summaries
|
|
173
|
+
WHERE memory_session_id = ?
|
|
174
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
175
|
+
LIMIT 1
|
|
176
|
+
`,
|
|
177
|
+
)
|
|
178
|
+
.get(sessionId);
|
|
179
|
+
return row ? row.id : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const nonEmpty = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
183
|
+
|
|
184
|
+
/** Scrub one raw value, then truncate it: the order insertFastSummary documents. */
|
|
185
|
+
function clean(field, value, max) {
|
|
186
|
+
return truncate(scrubRecord('session_summaries', { [field]: value || '' })[field], max);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Stop's write, on every turn. `report` is this turn's tail extract ({done, notDone, lines}
|
|
191
|
+
* — lines are its Failed / Uncertain lines, raw); `source` is readFastSummarySource's output.
|
|
192
|
+
* The session's first write INSERTs; every later one updates that row:
|
|
193
|
+
* - a Done in the report replaces `completed` (done = report); without one, a `titles` row
|
|
194
|
+
* takes the current titles;
|
|
195
|
+
* - any Done or Not done makes `remaining_items` this report's Not done, '' included
|
|
196
|
+
* (left = report) — a Done with no Not done says nothing is left;
|
|
197
|
+
* - Failed / Uncertain lines replace the previous report's whenever the tail carries a
|
|
198
|
+
* report or such lines.
|
|
199
|
+
* The timestamp is the first write's. Read and write share one IMMEDIATE transaction, so a
|
|
200
|
+
* model upgrade committing in between cannot be overwritten with a stale read.
|
|
201
|
+
*/
|
|
202
|
+
export function writeStopSummary(db, { sessionId, project, report, source, now, limits }) {
|
|
203
|
+
const done = clean('completed', report.done, limits.completed);
|
|
204
|
+
const notDone = clean('remaining_items', report.notDone, limits.remaining);
|
|
205
|
+
const lines = scrubRecord('session_summaries', { notes: report.lines || '' }).notes;
|
|
206
|
+
const titles = clean('completed', source.completed, limits.completed);
|
|
207
|
+
const hasReport = Boolean(done || notDone);
|
|
208
|
+
db.transaction(() => {
|
|
209
|
+
const id = newestSummaryId(db, sessionId);
|
|
210
|
+
if (id === null) {
|
|
211
|
+
const completed = done || titles;
|
|
212
|
+
if (!(source.request || completed || notDone)) return;
|
|
213
|
+
db.prepare(INSERT_SQL).run(
|
|
214
|
+
sessionId,
|
|
215
|
+
project,
|
|
216
|
+
clean('request', source.request, limits.request),
|
|
217
|
+
completed,
|
|
218
|
+
notDone,
|
|
219
|
+
formatSummaryNotes(
|
|
220
|
+
{ done: done ? 'report' : 'titles', left: hasReport ? 'report' : 'other', lines },
|
|
221
|
+
limits.notes,
|
|
222
|
+
),
|
|
223
|
+
now.toISOString(),
|
|
224
|
+
now.getTime(),
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const row = db
|
|
229
|
+
.prepare('SELECT completed, remaining_items, notes FROM session_summaries WHERE id = ?')
|
|
230
|
+
.get(id);
|
|
231
|
+
const prov = parseSummaryNotes(row.notes);
|
|
232
|
+
let completed = row.completed;
|
|
233
|
+
let remaining = row.remaining_items;
|
|
234
|
+
if (done) {
|
|
235
|
+
completed = done;
|
|
236
|
+
prov.done = 'report';
|
|
237
|
+
} else if (titles && (prov.done === 'titles' || !nonEmpty(row.completed))) {
|
|
238
|
+
// A titles Done follows the current titles, and an EMPTY Done takes them whatever its
|
|
239
|
+
// tag: a model-created row without a Done, or a legacy '' / NULL notes row (third
|
|
240
|
+
// review P3-1), must not block the fallback.
|
|
241
|
+
completed = titles;
|
|
242
|
+
prov.done = 'titles';
|
|
243
|
+
}
|
|
244
|
+
if (hasReport) {
|
|
245
|
+
remaining = notDone;
|
|
246
|
+
prov.left = 'report';
|
|
247
|
+
}
|
|
248
|
+
if (hasReport || lines) prov.lines = lines;
|
|
249
|
+
db.prepare('UPDATE session_summaries SET completed = ?, remaining_items = ?, notes = ? WHERE id = ?').run(
|
|
250
|
+
completed,
|
|
251
|
+
remaining,
|
|
252
|
+
formatSummaryNotes(prov, limits.notes),
|
|
253
|
+
id,
|
|
254
|
+
);
|
|
255
|
+
}).immediate();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* SessionStart's previous-session write (/clear or /compact). It has the opening prompt, the
|
|
260
|
+
* last observation titles and the handoff's unfinished list:
|
|
261
|
+
* - no row yet: INSERT (done = titles, left = other);
|
|
262
|
+
* - `request` fills a gap only;
|
|
263
|
+
* - `completed`: a `titles` row takes the fresh titles, any other fills a gap only;
|
|
264
|
+
* - `remaining_items`: a `report` Not done is left alone ('' = nothing left); otherwise it
|
|
265
|
+
* fills a gap only.
|
|
266
|
+
* The row moves to `now`: this runs when the previous session ended, which is what its own
|
|
267
|
+
* INSERT used to record, and Last Session orders by it.
|
|
268
|
+
*/
|
|
269
|
+
export function writeClearSummary(db, { sessionId, project, values, limits, now }) {
|
|
270
|
+
const request = clean('request', values.request, limits.request);
|
|
271
|
+
const titles = clean('completed', values.completed, limits.completed);
|
|
272
|
+
const unfinished = clean('remaining_items', values.remaining, limits.remaining);
|
|
273
|
+
db.transaction(() => {
|
|
274
|
+
const id = newestSummaryId(db, sessionId);
|
|
275
|
+
if (id === null) {
|
|
276
|
+
db.prepare(INSERT_SQL).run(
|
|
277
|
+
sessionId,
|
|
278
|
+
project,
|
|
279
|
+
request,
|
|
280
|
+
titles,
|
|
281
|
+
unfinished,
|
|
282
|
+
formatSummaryNotes({ done: 'titles', left: 'other', lines: '' }),
|
|
283
|
+
now.toISOString(),
|
|
284
|
+
now.getTime(),
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const row = db
|
|
289
|
+
.prepare('SELECT request, completed, remaining_items, notes FROM session_summaries WHERE id = ?')
|
|
290
|
+
.get(id);
|
|
291
|
+
const prov = parseSummaryNotes(row.notes);
|
|
292
|
+
const completed =
|
|
293
|
+
prov.done === 'titles' && titles ? titles : (nonEmpty(row.completed) ?? (titles || row.completed));
|
|
294
|
+
const remaining =
|
|
295
|
+
prov.left === 'report'
|
|
296
|
+
? row.remaining_items
|
|
297
|
+
: (nonEmpty(row.remaining_items) ?? (unfinished || row.remaining_items));
|
|
298
|
+
db.prepare(
|
|
299
|
+
`UPDATE session_summaries SET request = ?, completed = ?, remaining_items = ?, created_at = ?, created_at_epoch = ?
|
|
300
|
+
WHERE id = ?`,
|
|
301
|
+
).run(
|
|
302
|
+
nonEmpty(row.request) ?? (request || row.request),
|
|
303
|
+
completed,
|
|
304
|
+
remaining,
|
|
305
|
+
now.toISOString(),
|
|
306
|
+
now.getTime(),
|
|
307
|
+
id,
|
|
308
|
+
);
|
|
309
|
+
}).immediate();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Whether a Stop later than the one at `spawnEpoch` has been recorded for the session
|
|
314
|
+
* (sdk_sessions.completed_at_epoch holds the LATEST Stop). Stop spawns one model worker per
|
|
315
|
+
* turn and two of them can finish out of order; the later Stop's worker reads the newer
|
|
316
|
+
* window and owns the row, so this one's reply must not land after it (P3-6). Not a strict
|
|
317
|
+
* superset: observations can be deleted between the two reads (the episode upgrade-delete),
|
|
318
|
+
* and the later worker can exit on no-obs / slot-timeout / an empty reply, leaving the row an
|
|
319
|
+
* earlier turn behind — the summary_worker metric pairs the two by session. A missing or
|
|
320
|
+
* non-numeric epoch is never superseded.
|
|
321
|
+
*/
|
|
322
|
+
export function summarySuperseded(db, sessionId, spawnEpoch) {
|
|
323
|
+
if (!Number.isFinite(spawnEpoch) || spawnEpoch <= 0) return false;
|
|
324
|
+
const latest = db
|
|
325
|
+
.prepare('SELECT completed_at_epoch AS e FROM sdk_sessions WHERE content_session_id = ?')
|
|
326
|
+
.get(sessionId)?.e;
|
|
327
|
+
return Number.isFinite(latest) && latest > spawnEpoch;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const MODEL_TEXT_FIELDS = ['request', 'investigated', 'learned', 'next_steps'];
|
|
331
|
+
const MODEL_JSON_FIELDS = ['lessons', 'key_decisions'];
|
|
332
|
+
const SUMMARY_COLUMNS = [...MODEL_TEXT_FIELDS, 'completed', 'remaining_items', ...MODEL_JSON_FIELDS];
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The LLM worker's write. `fields` are the model's values, already scrubbed (lessons /
|
|
336
|
+
* key_decisions as a JSON string or null). Lands on the session's newest row; a field the
|
|
337
|
+
* model left empty falls back to the row's own value, then to the session's older rows newest
|
|
338
|
+
* first (legacy duplicates, races), so a degraded reply erases nothing. Per field:
|
|
339
|
+
* - `completed`: a `report` Done is kept; otherwise the model's replaces it (done = model);
|
|
340
|
+
* - `remaining_items`: a `report` Not done is kept, '' included; otherwise the model's
|
|
341
|
+
* replaces it;
|
|
342
|
+
* - everything else: the model's replaces it.
|
|
343
|
+
* The row's timestamp is NOT moved (D#79): this worker can finish after the NEXT session has
|
|
344
|
+
* written its first row. A session with no row is inserted at its own last prompt, not at the
|
|
345
|
+
* worker's finish, for the same reason.
|
|
346
|
+
*
|
|
347
|
+
* `spawnEpoch` is the epoch of the Stop that spawned this worker; when a later Stop has been
|
|
348
|
+
* recorded, nothing is written and false is returned (see summarySuperseded). Omitted — the
|
|
349
|
+
* /clear spawn, a worker spawned by an older version — it always writes.
|
|
350
|
+
*/
|
|
351
|
+
export function mergeModelSummary(db, { sessionId, project, fields, now, spawnEpoch }) {
|
|
352
|
+
return db
|
|
353
|
+
.transaction(() => {
|
|
354
|
+
if (summarySuperseded(db, sessionId, spawnEpoch)) return false;
|
|
355
|
+
const id = newestSummaryId(db, sessionId);
|
|
356
|
+
if (id === null) {
|
|
357
|
+
const last = db
|
|
358
|
+
.prepare('SELECT MAX(created_at_epoch) AS e FROM user_prompts WHERE content_session_id = ?')
|
|
359
|
+
.get(sessionId)?.e;
|
|
360
|
+
const stamp = Number.isFinite(last) && last > 0 && last <= now.getTime() ? new Date(last) : now;
|
|
361
|
+
db.prepare(
|
|
362
|
+
`INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps,
|
|
363
|
+
remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
|
|
364
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?, ?, ?, ?)`,
|
|
365
|
+
).run(
|
|
366
|
+
sessionId,
|
|
367
|
+
project,
|
|
368
|
+
fields.request || '',
|
|
369
|
+
fields.investigated || '',
|
|
370
|
+
fields.learned || '',
|
|
371
|
+
fields.completed || '',
|
|
372
|
+
fields.next_steps || '',
|
|
373
|
+
fields.remaining_items || '',
|
|
374
|
+
formatSummaryNotes({
|
|
375
|
+
done: nonEmpty(fields.completed) ? 'model' : 'titles',
|
|
376
|
+
left: 'other',
|
|
377
|
+
lines: '',
|
|
378
|
+
}),
|
|
379
|
+
fields.lessons ?? null,
|
|
380
|
+
fields.key_decisions ?? null,
|
|
381
|
+
stamp.toISOString(),
|
|
382
|
+
stamp.getTime(),
|
|
383
|
+
);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
const rows = db
|
|
387
|
+
.prepare(
|
|
388
|
+
`SELECT id, notes, ${SUMMARY_COLUMNS.join(', ')} FROM session_summaries
|
|
389
|
+
WHERE memory_session_id = ? ORDER BY created_at_epoch DESC, id DESC`,
|
|
390
|
+
)
|
|
391
|
+
.all(sessionId);
|
|
392
|
+
const own = rows.find((r) => r.id === id);
|
|
393
|
+
const siblings = rows.filter((r) => r.id !== id);
|
|
394
|
+
const floor = (col) =>
|
|
395
|
+
nonEmpty(own[col]) ?? siblings.map((r) => nonEmpty(r[col])).find(Boolean) ?? own[col];
|
|
396
|
+
const prov = parseSummaryNotes(own.notes);
|
|
397
|
+
const next = {};
|
|
398
|
+
for (const col of [...MODEL_TEXT_FIELDS, ...MODEL_JSON_FIELDS])
|
|
399
|
+
next[col] = nonEmpty(fields[col]) ?? floor(col);
|
|
400
|
+
if (prov.done !== 'report' && nonEmpty(fields.completed)) {
|
|
401
|
+
next.completed = fields.completed;
|
|
402
|
+
prov.done = 'model';
|
|
403
|
+
} else {
|
|
404
|
+
next.completed = floor('completed');
|
|
405
|
+
}
|
|
406
|
+
next.remaining_items =
|
|
407
|
+
prov.left === 'report'
|
|
408
|
+
? own.remaining_items
|
|
409
|
+
: (nonEmpty(fields.remaining_items) ?? floor('remaining_items'));
|
|
410
|
+
db.prepare(
|
|
411
|
+
`UPDATE session_summaries SET ${SUMMARY_COLUMNS.map((c) => `${c} = ?`).join(', ')}, notes = ? WHERE id = ?`,
|
|
412
|
+
).run(
|
|
413
|
+
...SUMMARY_COLUMNS.map((c) => next[c]),
|
|
414
|
+
formatSummaryNotes(prov, FAST_SUMMARY_LIMITS.stop.notes),
|
|
415
|
+
id,
|
|
416
|
+
);
|
|
417
|
+
return true;
|
|
418
|
+
})
|
|
419
|
+
.immediate();
|
|
420
|
+
}
|
package/lib/stats-core.mjs
CHANGED
|
@@ -36,12 +36,17 @@ export function computeStatsFeed(
|
|
|
36
36
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
37
37
|
const baseParams = project ? [project] : [];
|
|
38
38
|
|
|
39
|
-
// Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned)
|
|
39
|
+
// Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned). Sessions are counted
|
|
40
|
+
// DISTINCT: a session can own several summary rows (legacy ones from before one row per
|
|
41
|
+
// session: 458 rows for 312 sessions on the live DB before a one-off dedup, 2026-09-26), and both faces print this
|
|
42
|
+
// number as "N sessions".
|
|
40
43
|
const obsTotal = db
|
|
41
44
|
.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`)
|
|
42
45
|
.get(...baseParams);
|
|
43
46
|
const sessTotal = db
|
|
44
|
-
.prepare(
|
|
47
|
+
.prepare(
|
|
48
|
+
`SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries WHERE 1=1 ${projectFilter}`,
|
|
49
|
+
)
|
|
45
50
|
.get(...baseParams);
|
|
46
51
|
const promptTotal = project
|
|
47
52
|
? db
|
|
@@ -56,7 +61,9 @@ export function computeStatsFeed(
|
|
|
56
61
|
.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`)
|
|
57
62
|
.get(cutoff, ...baseParams);
|
|
58
63
|
const sessRecent = db
|
|
59
|
-
.prepare(
|
|
64
|
+
.prepare(
|
|
65
|
+
`SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`,
|
|
66
|
+
)
|
|
60
67
|
.get(cutoff, ...baseParams);
|
|
61
68
|
|
|
62
69
|
// Type distribution (recent)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.13.
|
|
3
|
+
"version": "6.13.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.13.
|
|
9
|
+
"version": "6.13.6",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.13.
|
|
3
|
+
"version": "6.13.6",
|
|
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",
|