claude-mem-lite 6.13.4 → 6.13.5

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.13.4",
12
+ "version": "6.13.5",
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.4",
3
+ "version": "6.13.5",
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,7 @@ 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 } from './lib/fast-summary.mjs';
28
29
  import {
29
30
  insertObservationRow,
30
31
  insertObservationFiles,
@@ -561,7 +562,7 @@ function linkRelatedObservations(db, savedId, obs, episode) {
561
562
  `
562
563
  SELECT id, files_modified FROM observations
563
564
  WHERE id != ? AND created_at_epoch > ? AND project = ?
564
- ORDER BY created_at_epoch DESC LIMIT 50
565
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 50
565
566
  `,
566
567
  )
567
568
  .all(newObs.id, Date.now() - RELATED_OBS_WINDOW_MS, episode.project);
@@ -1459,8 +1460,8 @@ ${obsList}`;
1459
1460
  // alone dropped the whole INSERT/UPDATE (losing the session's highest-value fields:
1460
1461
  // lessons + key_decisions) whenever Haiku returned an empty request string but a rich
1461
1462
  // `{completed, lessons, key_decisions}` — a common degraded shape. Downstream tolerates an
1462
- // empty request: INSERT writes '' and the UPDATE COALESCE(NULLIF(?, ''), request) preserves
1463
- // the prior value. Use asText in the gate so a non-string / empty-array field can't falsely
1463
+ // empty request: INSERT writes '' and the UPDATE keeps the row's own request (or an older row's)
1464
+ // when the reply's is empty. Use asText in the gate so a non-string / empty-array field can't falsely
1464
1465
  // trigger it.
1465
1466
  const hasSummaryContent =
1466
1467
  llmParsed &&
@@ -1481,106 +1482,27 @@ ${obsList}`;
1481
1482
  ? JSON.stringify(llmParsed.key_decisions)
1482
1483
  : null;
1483
1484
 
1484
- // Upgrade existing fast summary instead of creating a duplicate. With two fast rows
1485
- // for one session (Stop, then SessionStart's unguarded /clear or /compact path), the LOWEST id is
1486
- // the Stop row, which carries the structural Done / Not done extract that the COALESCE
1487
- // floor below preserves. Upgrading the highest id lost that content from Last Session
1488
- // (v6.13.4 defect review P2-1), so the order is spelled out rather than left to the
1489
- // index. No order covers every shape: the floor reads only the upgraded row (D#80).
1490
- const existingFast = db
1491
- .prepare(
1492
- `
1493
- SELECT id FROM session_summaries
1494
- WHERE memory_session_id = ? AND notes = 'fast'
1495
- ORDER BY id ASC
1496
- LIMIT 1
1497
- `,
1498
- )
1499
- .get(sessionId);
1500
-
1501
- if (existingFast) {
1502
- // Preserve structural-extractor content (completed / remaining_items written
1503
- // by handleStop fast-baseline from CLAUDE.md §10 markers) when Haiku returns
1504
- // empty for that field. Without COALESCE, a degraded Haiku pass would erase
1505
- // the deterministic floor — the exact regression that made 72% of prod
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
- );
1553
- } else {
1554
- const safe = scrubRecord('session_summaries', {
1555
- request: asText(llmParsed.request),
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
- );
1583
- }
1485
+ // Upgrade the session's summary row instead of creating another. This worker runs after
1486
+ // EVERY Stop (one per assistant turn) and again from SessionStart's /clear path; selecting
1487
+ // only a `notes = 'fast'` row found nothing once the first run had upgraded it, and each
1488
+ // later turn INSERTed (one live session: 37 rows in 65 minutes). mergeModelSummary lands
1489
+ // on the session's newest row and keeps a report's Done / Not done over the model's
1490
+ // (lib/fast-summary.mjs).
1491
+ //
1492
+ // Scrub LLM-output text fields at the write boundary. lessons / key_decisions are
1493
+ // JSON.stringify(array<string>); scrubSecrets uses opaque placeholders that preserve
1494
+ // JSON structure, so the JSON string is scrubbed whole.
1495
+ const safe = scrubRecord('session_summaries', {
1496
+ request: asText(llmParsed.request),
1497
+ investigated: asText(llmParsed.investigated),
1498
+ learned: asText(llmParsed.learned),
1499
+ completed: asText(llmParsed.completed),
1500
+ next_steps: asText(llmParsed.next_steps),
1501
+ remaining_items: asText(llmParsed.remaining_items),
1502
+ lessons: lessonsJson,
1503
+ key_decisions: decisionsJson,
1504
+ });
1505
+ mergeModelSummary(db, { sessionId, project, fields: safe, now });
1584
1506
  }
1585
1507
  } finally {
1586
1508
  db.close();
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 { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
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';
@@ -1089,59 +1095,48 @@ function markSessionCompletedAndSaveHandoff(db, { sessionId, project, ccSessionI
1089
1095
 
1090
1096
  /** Fast summary baseline — ensures a summary exists even if the background LLM fails. */
1091
1097
  function writeFastSummaryBaseline(db, { sessionId, project, transcriptPath }) {
1092
- // Fast summary baseline — ensures summary exists even if background LLM fails.
1093
- // T4-P2-B: guard against Stop firing twice for the same session (rare but possible;
1094
- // mirrors handleSessionStart line 795 hasSummary guard). Uses mem-internal sessionId
1095
- // as the WHERE key per the top-of-file dual-id invariant (#7789).
1098
+ // Stop fires once per assistant TURN and the mem session survives it (R10-P1-1), so this
1099
+ // runs on every turn of a session. The first turn with anything to say INSERTs the row
1100
+ // (T4-P2-B's guard: never a second row); every later turn REFRESHES that row from its tail's
1101
+ // report, or, without one, from the current observation titles where the row's Done is
1102
+ // still titles (lib/fast-summary.mjs writeStopSummary). The guard alone used to stop there, so the
1103
+ // row kept the FIRST turn's report: over 7 days of this machine's transcripts (09:40Z), 20
1104
+ // of the 31 sessions that wrote §10 markers had a first-turn extract different from their
1105
+ // last report. Uses the mem-internal sessionId as the WHERE key per the top-of-file
1106
+ // dual-id invariant (#7789).
1096
1107
  try {
1097
- const existingSummary = db
1098
- .prepare('SELECT 1 FROM session_summaries WHERE memory_session_id = ? LIMIT 1')
1099
- .get(sessionId);
1100
- if (!existingSummary) {
1101
- const { request: fastRequestRaw, completed: obsCompleted } = readFastSummarySource(db, sessionId);
1102
-
1103
- // Structural extraction from the assistant's tail message.
1104
- // CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
1105
- // tail is deterministically parseable without Haiku. Prior baseline left
1106
- // remaining_items=='' for every session whose Haiku pass failed (≈66%
1107
- // in prod data), losing the user-visible "Not done" list.
1108
- let structuredCompleted = '';
1109
- let structuredNotDone = '';
1110
- let structuredNotes = '';
1111
- try {
1112
- const tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
1113
- if (tail) {
1114
- const s = extractStructuredSummary(tail);
1115
- structuredCompleted = s.done;
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
- });
1108
+ // Structural extraction from the assistant's tail message.
1109
+ // CLAUDE.md §10 mandates Done/Not done/Failed/Uncertain markers, so the
1110
+ // tail is deterministically parseable without Haiku. Prior baseline left
1111
+ // remaining_items=='' for every session whose Haiku pass failed (≈66%
1112
+ // in prod data), losing the user-visible "Not done" list. handleStop calls this
1113
+ // AFTER trackCitationsAtStop so the parse is the one it left memoized.
1114
+ let structuredCompleted = '';
1115
+ let structuredNotDone = '';
1116
+ let structuredNotes = '';
1117
+ try {
1118
+ const tail = transcriptPath ? extractTailAssistantText(transcriptPath) : null;
1119
+ if (tail) {
1120
+ const s = extractStructuredSummary(tail);
1121
+ structuredCompleted = s.done;
1122
+ structuredNotDone = s.notDone;
1123
+ const notesParts = [];
1124
+ if (s.failed) notesParts.push(`Failed: ${s.failed}`);
1125
+ if (s.uncertain) notesParts.push(`Uncertain: ${s.uncertain}`);
1126
+ structuredNotes = notesParts.join('\n');
1143
1127
  }
1128
+ } catch (e) {
1129
+ debugCatch(e, 'handleStop-structured-extract');
1144
1130
  }
1131
+
1132
+ writeStopSummary(db, {
1133
+ sessionId,
1134
+ project,
1135
+ report: { done: structuredCompleted, notDone: structuredNotDone, lines: structuredNotes },
1136
+ source: readFastSummarySource(db, sessionId),
1137
+ now: new Date(),
1138
+ limits: FAST_SUMMARY_LIMITS.stop,
1139
+ });
1145
1140
  } catch (e) {
1146
1141
  debugCatch(e, 'handleStop-fast-summary');
1147
1142
  }
@@ -1587,8 +1582,11 @@ async function handleStop() {
1587
1582
  if (db) {
1588
1583
  try {
1589
1584
  markSessionCompletedAndSaveHandoff(db, { sessionId, project, ccSessionId, episodeSnapshot });
1590
- writeFastSummaryBaseline(db, { sessionId, project, transcriptPath });
1585
+ // Citations first: they read the subagent transcripts before the parent, so the parent is
1586
+ // parsed once and stays memoized (D#152). The summary reads the parent's tail on every
1587
+ // turn; run before them, it parsed the parent a second time in any session with subagents.
1591
1588
  trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptPath });
1589
+ writeFastSummaryBaseline(db, { sessionId, project, transcriptPath });
1592
1590
  } finally {
1593
1591
  db.close();
1594
1592
  }
@@ -2144,8 +2142,8 @@ function saveHandoffAndFastSummary(
2144
2142
  }
2145
2143
 
2146
2144
  // Build fast synchronous summary for immediate context availability.
2147
- // Background llm-summary will produce a richer Haiku version later;
2148
- // context injection query (ORDER BY created_at_epoch DESC, id DESC) auto-prefers latest.
2145
+ // The background llm-summary spawned above upgrades this same row in place later,
2146
+ // without moving its timestamp.
2149
2147
  try {
2150
2148
  const { request: fastRequestRaw, completed: fastCompletedRaw } = readFastSummarySource(
2151
2149
  db,
@@ -2166,13 +2164,17 @@ function saveHandoffAndFastSummary(
2166
2164
  if (errors.length > 0) fastRemainingRaw = errors.join('; ');
2167
2165
  }
2168
2166
 
2167
+ // One row per session: when Stop already wrote the previous session's row, this updates
2168
+ // it rather than INSERTing a second one beside it (80 live sessions had two, 2026-09-26).
2169
+ // The gate is unchanged, so the row moves to `now` exactly when the second row used to
2170
+ // be written with it.
2169
2171
  if (fastRequestRaw || fastCompletedRaw) {
2170
- insertFastSummary(db, {
2172
+ writeClearSummary(db, {
2171
2173
  sessionId: prevSessionId,
2172
2174
  project: prevProject || project,
2173
- now,
2174
2175
  values: { request: fastRequestRaw, completed: fastCompletedRaw, remaining: fastRemainingRaw },
2175
2176
  limits: FAST_SUMMARY_LIMITS.sessionStart,
2177
+ now,
2176
2178
  });
2177
2179
  }
2178
2180
  } 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
- const sess = db.prepare('SELECT COUNT(*) as c FROM session_summaries').get();
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 = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get()?.cnt || 0;
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
@@ -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
- // NOT collapsed in here: hook-llm.mjs's summary insert. That row is produced by the
13
- // model and carries two more columns (lessons, key_decisions); it is a different
14
- // record that happens to share a table, and merging it would mean inventing a shape
15
- // that fits neither.
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,274 @@ 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
+ const MODEL_TEXT_FIELDS = ['request', 'investigated', 'learned', 'next_steps'];
313
+ const MODEL_JSON_FIELDS = ['lessons', 'key_decisions'];
314
+ const SUMMARY_COLUMNS = [...MODEL_TEXT_FIELDS, 'completed', 'remaining_items', ...MODEL_JSON_FIELDS];
315
+
316
+ /**
317
+ * The LLM worker's write. `fields` are the model's values, already scrubbed (lessons /
318
+ * key_decisions as a JSON string or null). Lands on the session's newest row; a field the
319
+ * model left empty falls back to the row's own value, then to the session's older rows newest
320
+ * first (legacy duplicates, races), so a degraded reply erases nothing. Per field:
321
+ * - `completed`: a `report` Done is kept; otherwise the model's replaces it (done = model);
322
+ * - `remaining_items`: a `report` Not done is kept, '' included; otherwise the model's
323
+ * replaces it;
324
+ * - everything else: the model's replaces it.
325
+ * The row's timestamp is NOT moved (D#79): this worker can finish after the NEXT session has
326
+ * written its first row. A session with no row is inserted at its own last prompt, not at the
327
+ * worker's finish, for the same reason.
328
+ */
329
+ export function mergeModelSummary(db, { sessionId, project, fields, now }) {
330
+ db.transaction(() => {
331
+ const id = newestSummaryId(db, sessionId);
332
+ if (id === null) {
333
+ const last = db
334
+ .prepare('SELECT MAX(created_at_epoch) AS e FROM user_prompts WHERE content_session_id = ?')
335
+ .get(sessionId)?.e;
336
+ const stamp = Number.isFinite(last) && last > 0 && last <= now.getTime() ? new Date(last) : now;
337
+ db.prepare(
338
+ `INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps,
339
+ remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
340
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?, ?, ?, ?)`,
341
+ ).run(
342
+ sessionId,
343
+ project,
344
+ fields.request || '',
345
+ fields.investigated || '',
346
+ fields.learned || '',
347
+ fields.completed || '',
348
+ fields.next_steps || '',
349
+ fields.remaining_items || '',
350
+ formatSummaryNotes({
351
+ done: nonEmpty(fields.completed) ? 'model' : 'titles',
352
+ left: 'other',
353
+ lines: '',
354
+ }),
355
+ fields.lessons ?? null,
356
+ fields.key_decisions ?? null,
357
+ stamp.toISOString(),
358
+ stamp.getTime(),
359
+ );
360
+ return;
361
+ }
362
+ const rows = db
363
+ .prepare(
364
+ `SELECT id, notes, ${SUMMARY_COLUMNS.join(', ')} FROM session_summaries
365
+ WHERE memory_session_id = ? ORDER BY created_at_epoch DESC, id DESC`,
366
+ )
367
+ .all(sessionId);
368
+ const own = rows.find((r) => r.id === id);
369
+ const siblings = rows.filter((r) => r.id !== id);
370
+ const floor = (col) =>
371
+ nonEmpty(own[col]) ?? siblings.map((r) => nonEmpty(r[col])).find(Boolean) ?? own[col];
372
+ const prov = parseSummaryNotes(own.notes);
373
+ const next = {};
374
+ for (const col of [...MODEL_TEXT_FIELDS, ...MODEL_JSON_FIELDS])
375
+ next[col] = nonEmpty(fields[col]) ?? floor(col);
376
+ if (prov.done !== 'report' && nonEmpty(fields.completed)) {
377
+ next.completed = fields.completed;
378
+ prov.done = 'model';
379
+ } else {
380
+ next.completed = floor('completed');
381
+ }
382
+ next.remaining_items =
383
+ prov.left === 'report'
384
+ ? own.remaining_items
385
+ : (nonEmpty(fields.remaining_items) ?? floor('remaining_items'));
386
+ db.prepare(
387
+ `UPDATE session_summaries SET ${SUMMARY_COLUMNS.map((c) => `${c} = ?`).join(', ')}, notes = ? WHERE id = ?`,
388
+ ).run(
389
+ ...SUMMARY_COLUMNS.map((c) => next[c]),
390
+ formatSummaryNotes(prov, FAST_SUMMARY_LIMITS.stop.notes),
391
+ id,
392
+ );
393
+ }).immediate();
394
+ }
@@ -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(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`)
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(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`)
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)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.13.4",
3
+ "version": "6.13.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.13.4",
9
+ "version": "6.13.5",
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.4",
3
+ "version": "6.13.5",
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",