claude-mem-lite 2.30.0 → 2.31.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.30.0",
13
+ "version": "2.31.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
@@ -13,6 +13,11 @@ if (cmd === '--version' || cmd === '-v') {
13
13
  } else if (cmd === '--help' || cmd === '-h') {
14
14
  const { run } = await import('./mem-cli.mjs');
15
15
  await run(['help']);
16
+ } else if (cmd === 'doctor' && process.argv.slice(3).includes('--benchmark')) {
17
+ // doctor --benchmark is a baseline-capture tool, routed through mem-cli (DB layer);
18
+ // plain `doctor` continues to run the install health-check below.
19
+ const { run } = await import('./mem-cli.mjs');
20
+ await run(process.argv.slice(2));
16
21
  } else if (CLI_COMMANDS.has(cmd)) {
17
22
  const { run } = await import('./mem-cli.mjs');
18
23
  await run(process.argv.slice(2));
package/hook-context.mjs CHANGED
@@ -241,9 +241,12 @@ export function cleanupClaudeMdLegacyBlock() {
241
241
  * @param {import('better-sqlite3').Database} db Opened main DB
242
242
  * @param {string} project Canonical project name (from inferProject())
243
243
  * @param {Date} [now=new Date()] Clock reference for time windows and table header
244
+ * @param {string|null} [currentCcSessionId=null] Claude Code session id — when provided,
245
+ * the "Working State (from /clear)" block is filtered to handoffs owned by this
246
+ * session, preventing parallel-session bleed (see docs/bug.txt).
244
247
  * @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
245
248
  */
246
- export function buildSessionContextLines(db, project, now = new Date()) {
249
+ export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null) {
247
250
  // 1. Token-budgeted observation selection
248
251
  const selected = selectWithTokenBudget(db, project, 2000);
249
252
  const observations = selected.observations;
@@ -334,13 +337,22 @@ export function buildSessionContextLines(db, project, now = new Date()) {
334
337
  }
335
338
  }
336
339
 
337
- // 5. Working state from latest /clear handoff
338
- const prevClearHandoff = db.prepare(`
339
- SELECT working_on, unfinished, key_files
340
- FROM session_handoffs
341
- WHERE project = ? AND type = 'clear'
342
- ORDER BY created_at_epoch DESC LIMIT 1
343
- `).get(project);
340
+ // 5. Working state from latest /clear handoff.
341
+ // Session scoping: when currentCcSessionId is provided, restrict to this session's
342
+ // own clear handoff so parallel sessions don't see each other's Working State block.
343
+ const prevClearHandoff = currentCcSessionId
344
+ ? db.prepare(`
345
+ SELECT working_on, unfinished, key_files
346
+ FROM session_handoffs
347
+ WHERE project = ? AND type = 'clear' AND session_id = ?
348
+ ORDER BY created_at_epoch DESC LIMIT 1
349
+ `).get(project, currentCcSessionId)
350
+ : db.prepare(`
351
+ SELECT working_on, unfinished, key_files
352
+ FROM session_handoffs
353
+ WHERE project = ? AND type = 'clear'
354
+ ORDER BY created_at_epoch DESC LIMIT 1
355
+ `).get(project);
344
356
 
345
357
  const handoffLines = [];
346
358
  if (prevClearHandoff) {
package/hook-handoff.mjs CHANGED
@@ -6,6 +6,11 @@ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SI
6
6
  import {
7
7
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
8
8
  } from './hook-shared.mjs';
9
+ // T10d: import the whole module (not a named export) so tests can spy on
10
+ // gitStateModule.readGitState via vi.spyOn. Named-import bindings are
11
+ // immutable in ESM and cannot be mocked after the fact.
12
+ import * as gitStateModule from './lib/git-state.mjs';
13
+ import * as taskReaderModule from './lib/task-reader.mjs';
9
14
 
10
15
  /**
11
16
  * Build and save a handoff snapshot to session_handoffs table.
@@ -51,6 +56,24 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
51
56
  .filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
52
57
  if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
53
58
  }
59
+
60
+ // T10d: TaskList-sourced Unfinished. When no episode pending entries exist,
61
+ // prefer the structured signal from ~/.claude/tasks/<list>/*.json over the
62
+ // narrative-only fallback — a user-maintained task list is a stronger signal
63
+ // than a session with no recent tool activity. When the episode already has
64
+ // pending entries, those stay (they're fresher than the task file).
65
+ if (!unfinished) {
66
+ try {
67
+ const tasks = taskReaderModule.readProjectTasks({ projectPath: process.cwd() });
68
+ if (tasks.length > 0) {
69
+ unfinished = tasks
70
+ .slice(0, 5)
71
+ .map(t => `[${t.status}] ${t.title}`)
72
+ .join('\n');
73
+ }
74
+ } catch { /* task reader is best-effort; never block handoff */ }
75
+ }
76
+
54
77
  // Enrich unfinished with full session edit history from observation narratives.
55
78
  // Since handoff is UPSERT (max 2 rows per project), storing more data is free.
56
79
  // Always use \n---\n separator so extractUnfinishedSummary can distinguish
@@ -89,19 +112,27 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
89
112
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
90
113
  const keywords = extractMatchKeywords(allText, [...fileSet]);
91
114
 
92
- // UPSERT
115
+ // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
116
+ // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
117
+ let gitShaAtHandoff = null;
118
+ try {
119
+ gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
120
+ } catch { /* swallow — handoff must still persist */ }
121
+
122
+ // UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
123
+ // Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
93
124
  db.prepare(`
94
- INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
95
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
- ON CONFLICT(project, type) DO UPDATE SET
97
- session_id = excluded.session_id,
125
+ INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
127
+ ON CONFLICT(project, type, session_id) DO UPDATE SET
98
128
  working_on = excluded.working_on,
99
129
  completed = excluded.completed,
100
130
  unfinished = excluded.unfinished,
101
131
  key_files = excluded.key_files,
102
132
  key_decisions = excluded.key_decisions,
103
133
  match_keywords = excluded.match_keywords,
104
- created_at_epoch = excluded.created_at_epoch
134
+ created_at_epoch = excluded.created_at_epoch,
135
+ git_sha_at_handoff = excluded.git_sha_at_handoff
105
136
  `).run(
106
137
  project, type, sessionId,
107
138
  truncate(workingOn, 1000),
@@ -110,24 +141,68 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
110
141
  JSON.stringify([...fileSet].slice(0, 20)),
111
142
  decisions.map(d => d.title).join('\n'),
112
143
  keywords,
113
- Date.now()
144
+ Date.now(),
145
+ gitShaAtHandoff,
114
146
  );
115
147
  }
116
148
 
117
149
  /**
118
150
  * Detect if user's prompt indicates continuation of previous work.
151
+ * Stage 0: Non-expired clear handoff + short prompt → auto-continue.
119
152
  * Stage 1: Explicit keyword match (zero false positives).
120
153
  * Stage 2: FTS5-style term overlap with handoff keywords.
154
+ *
155
+ * Session scoping (currentCcSessionId): when provided, clear handoffs from a
156
+ * DIFFERENT session are excluded from Stage 0 auto-match and from the general
157
+ * pool (prevents cross-session bleed when running parallel sessions for the
158
+ * same project — see docs/bug.txt). When null, legacy behavior is preserved.
159
+ *
121
160
  * @param {Database} db Opened main database
122
161
  * @param {string} promptText User's prompt text
123
162
  * @param {string} project Project identifier
163
+ * @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
124
164
  * @returns {boolean}
125
165
  */
126
- export function detectContinuationIntent(db, promptText, project) {
127
- // Stage 0: Non-expired 'clear' handoff assume continuation unless long unrelated prompt
128
- const clearHandoff = db.prepare(`
129
- SELECT created_at_epoch, match_keywords FROM session_handoffs WHERE project = ? AND type = 'clear'
130
- `).get(project);
166
+ export function detectContinuationIntent(db, promptText, project, currentCcSessionId = null) {
167
+ // Input guard: empty / whitespace / single-char prompts never trigger auto-injection.
168
+ // The bug was a single-char 'a' + fresh clear handoff → Stage 0 auto-match.
169
+ if (!promptText || typeof promptText !== 'string') return false;
170
+ if (promptText.trim().length < 2) return false;
171
+
172
+ // T10d Stage -1: Git-commit anchor — if ANY handoff (any age) has a
173
+ // git_sha_at_handoff matching current HEAD, the working tree hasn't moved
174
+ // since that handoff, so assume continuation regardless of time / prompt.
175
+ //
176
+ // Trade-off: after weeks of no commits this fires aggressively. Users can
177
+ // reset by making a commit or by typing a long unrelated prompt (this
178
+ // anchor runs BEFORE the Stage 0 long-prompt guard, so that escape hatch
179
+ // does not apply here). This is an MVP choice — see plan 10d concern.
180
+ try {
181
+ const currentSha = gitStateModule.readGitState({ cwd: process.cwd() }).headSha;
182
+ if (currentSha) {
183
+ const anchor = db.prepare(`
184
+ SELECT 1 FROM session_handoffs
185
+ WHERE project = ? AND git_sha_at_handoff = ?
186
+ ORDER BY created_at_epoch DESC LIMIT 1
187
+ `).get(project, currentSha);
188
+ if (anchor) return true;
189
+ }
190
+ } catch { /* git/DB failure must not break the rest of the pipeline */ }
191
+
192
+ // Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
193
+ // Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
194
+ const clearHandoff = currentCcSessionId
195
+ ? db.prepare(`
196
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
197
+ WHERE project = ? AND type = 'clear' AND session_id = ?
198
+ ORDER BY created_at_epoch DESC LIMIT 1
199
+ `).get(project, currentCcSessionId)
200
+ : db.prepare(`
201
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
202
+ WHERE project = ? AND type = 'clear'
203
+ ORDER BY created_at_epoch DESC LIMIT 1
204
+ `).get(project);
205
+
131
206
  if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
132
207
  // Short/ambiguous prompts: assume continuation (user may say "ok", "start", etc.)
133
208
  if (promptText.length < 40) return true;
@@ -142,11 +217,20 @@ export function detectContinuationIntent(db, promptText, project) {
142
217
  // Stage 1: Explicit keyword match — always works, even without handoff
143
218
  if (CONTINUE_KEYWORDS.test(promptText)) return true;
144
219
 
145
- // Stage 2: FTS5-style term overlap with handoff keywords
146
- const handoffs = db.prepare(`
147
- SELECT type, match_keywords, created_at_epoch FROM session_handoffs
148
- WHERE project = ? ORDER BY created_at_epoch DESC
149
- `).all(project);
220
+ // Stage 2: FTS5-style term overlap with handoff keywords.
221
+ // Session scoping: exit handoffs from OTHER sessions are still candidates (you may
222
+ // be resuming a previous session), but clear handoffs must be same-session.
223
+ const handoffs = currentCcSessionId
224
+ ? db.prepare(`
225
+ SELECT type, match_keywords, created_at_epoch FROM session_handoffs
226
+ WHERE project = ?
227
+ AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
228
+ ORDER BY created_at_epoch DESC
229
+ `).all(project, currentCcSessionId)
230
+ : db.prepare(`
231
+ SELECT type, match_keywords, created_at_epoch FROM session_handoffs
232
+ WHERE project = ? ORDER BY created_at_epoch DESC
233
+ `).all(project);
150
234
  if (handoffs.length === 0) return false;
151
235
 
152
236
  // Filter expired handoffs
@@ -176,18 +260,32 @@ export function detectContinuationIntent(db, promptText, project) {
176
260
  /**
177
261
  * Render handoff injection text for stdout.
178
262
  * Reads the most recent handoff + optional session summary.
263
+ *
264
+ * Session scoping (currentCcSessionId): when provided,
265
+ * - clear handoffs: only from the CURRENT session (you continue your own /clear)
266
+ * - exit handoffs: only from OTHER sessions (you resume a previous exit)
267
+ * When null, legacy behavior (most-recent handoff regardless of session).
268
+ *
179
269
  * @param {Database} db Opened main database
180
270
  * @param {string} project Project identifier
271
+ * @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
181
272
  * @returns {string|null} Injection text or null if no handoff
182
273
  */
183
- export function renderHandoffInjection(db, project) {
274
+ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
184
275
  const now = Date.now();
185
276
  // Fetch recent handoffs and find the most recent non-expired one.
186
- // A newer but expired 'clear' handoff (1h) must not shadow a still-valid 'exit' handoff (7d).
187
- const handoffs = db.prepare(`
188
- SELECT * FROM session_handoffs
189
- WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
190
- `).all(project);
277
+ // A newer but expired 'clear' handoff must not shadow a still-valid 'exit' handoff.
278
+ const handoffs = currentCcSessionId
279
+ ? db.prepare(`
280
+ SELECT * FROM session_handoffs
281
+ WHERE project = ?
282
+ AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
283
+ ORDER BY created_at_epoch DESC LIMIT 5
284
+ `).all(project, currentCcSessionId, currentCcSessionId)
285
+ : db.prepare(`
286
+ SELECT * FROM session_handoffs
287
+ WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
288
+ `).all(project);
191
289
  const handoff = handoffs.find(h => {
192
290
  const age = now - h.created_at_epoch;
193
291
  const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
package/hook-llm.mjs CHANGED
@@ -15,6 +15,11 @@ import {
15
15
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
16
16
  sessionFile, getSessionId, openDb, callLLM, sleep,
17
17
  } from './hook-shared.mjs';
18
+ import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
19
+
20
+ // T9: memdir-incompatible types live in the `events` table, not `observations`.
21
+ // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
22
+ const EVENT_TYPE_SET = new Set(EVENT_TYPES);
18
23
 
19
24
  // ─── Save Observation to DB ─────────────────────────────────────────────────
20
25
 
@@ -172,6 +177,96 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
172
177
  }
173
178
  }
174
179
 
180
+ // ─── obs → summary shape mapping ────────────────────────────────────────────
181
+ // handleLLMEpisode's internal `obs` uses camelCase/legacy names (lessonLearned,
182
+ // files, filesRead). persistHaikuSummary takes the plan's stable snake_case
183
+ // shape. Keep the mapping here so the dispatcher's public signature stays
184
+ // clean for external callers that already use the plan shape.
185
+ function obsToSummary(obs) {
186
+ return {
187
+ type: obs.type,
188
+ title: obs.title,
189
+ subtitle: obs.subtitle,
190
+ narrative: obs.narrative,
191
+ concepts: obs.concepts,
192
+ facts: obs.facts,
193
+ files_modified: obs.files,
194
+ files_read: obs.filesRead,
195
+ importance: obs.importance,
196
+ lesson_learned: obs.lessonLearned,
197
+ search_aliases: obs.searchAliases,
198
+ };
199
+ }
200
+
201
+ // ─── T9: Haiku Summary Dispatcher (events vs observations routing) ──────────
202
+ //
203
+ // Routes memdir-incompatible types (the 8 values in EVENT_TYPES) to the
204
+ // activity `events` table, and keeps legacy/memdir-aligned types (e.g.
205
+ // `change`, which is the only non-event type hook-llm currently emits) on
206
+ // the existing observations path.
207
+ //
208
+ // Input shape (matches the v2.31 MVP plan's stable interface):
209
+ // summary: { type, title, lesson_learned?, narrative?, importance?, files_modified? }
210
+ // ctx: { project, session_id, preSavedObsId? }
211
+ //
212
+ // Returns { table: 'events'|'observations', id: number|null }. Callers inspect
213
+ // `table` to decide whether follow-up observations-only logic (linking, vector
214
+ // refresh) applies.
215
+ //
216
+ // NOTE: The foreground pre-save in hook.mjs:110/336 intentionally still writes
217
+ // to `observations` for immediate visibility. When the background worker
218
+ // processes the episode (handleLLMEpisode), it passes the pre-saved id in
219
+ // `ctx.preSavedObsId`; this dispatcher then deletes the pre-saved observations
220
+ // row and inserts a fresh event for event-typed summaries (upgrade-delete
221
+ // semantics). Observations-typed summaries reuse the pre-saved row directly
222
+ // (caller handles the UPDATE, since it needs the enriched FTS fields).
223
+ export function persistHaikuSummary(db, summary, ctx) {
224
+ if (EVENT_TYPE_SET.has(summary.type)) {
225
+ // Upgrade-delete: the foreground pre-save landed in `observations` with a
226
+ // rule-inferred type; now that Haiku has classified it as an event type,
227
+ // we must remove the stale observations row before inserting the event.
228
+ // Atomic via better-sqlite3 transaction: either both succeed or neither.
229
+ const insertEvent = () => saveEvent(db, {
230
+ project: ctx.project,
231
+ event_type: summary.type,
232
+ title: summary.title,
233
+ body: summary.lesson_learned || summary.narrative || null,
234
+ file_paths: (Array.isArray(summary.files_modified) && summary.files_modified.length > 0)
235
+ ? summary.files_modified
236
+ : null,
237
+ importance: summary.importance ?? 1,
238
+ created_at_epoch: Date.now(),
239
+ });
240
+
241
+ if (ctx.preSavedObsId) {
242
+ const id = db.transaction(() => {
243
+ db.prepare(`DELETE FROM observations WHERE id = ?`).run(ctx.preSavedObsId);
244
+ return insertEvent();
245
+ })();
246
+ return { table: 'events', id };
247
+ }
248
+
249
+ return { table: 'events', id: insertEvent() };
250
+ }
251
+
252
+ // Fallthrough: memdir-compatible / legacy types use the observations path.
253
+ // Map the Haiku/plan field names to saveObservation's expected shape.
254
+ const id = saveObservation({
255
+ type: summary.type,
256
+ title: summary.title,
257
+ subtitle: summary.subtitle || '',
258
+ narrative: summary.narrative || '',
259
+ concepts: summary.concepts || [],
260
+ facts: summary.facts || [],
261
+ files: summary.files_modified || [],
262
+ filesRead: summary.files_read || [],
263
+ importance: summary.importance ?? 1,
264
+ lessonLearned: summary.lesson_learned || null,
265
+ searchAliases: summary.search_aliases || null,
266
+ }, ctx.project, ctx.session_id, db);
267
+ return { table: 'observations', id };
268
+ }
269
+
175
270
  // ─── Related Observation Linking ─────────────────────────────────────────────
176
271
 
177
272
  function linkRelatedObservations(db, savedId, obs, episode) {
@@ -504,46 +599,72 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
504
599
 
505
600
  try {
506
601
  let savedId;
602
+ let savedTable;
507
603
 
508
604
  if (episode.savedId && obs) {
509
- // Upgrade pre-saved observation with LLM-enriched data
510
- const { conceptsText, factsText, textField } = buildFtsTextField(obs);
511
- const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
512
- db.prepare(`
513
- UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
514
- text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
515
- WHERE id = ?
516
- `).run(
517
- obs.type, truncate(obs.title, 120), obs.subtitle || '',
518
- truncate(obs.narrative || '', 500),
519
- conceptsText, factsText, textField,
520
- obs.importance,
521
- JSON.stringify(obs.filesRead || []),
522
- minhashSig,
523
- obs.lessonLearned || null,
524
- obs.searchAliases || null,
525
- episode.savedId
526
- );
527
- savedId = episode.savedId;
528
- debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
529
-
530
- // Update TF-IDF vector with enriched content
531
- try {
532
- const vocab = getVocabulary(db);
533
- if (vocab) {
534
- const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
535
- const vec = computeVector(vecText, vocab);
536
- if (vec) {
537
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
538
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
605
+ if (EVENT_TYPE_SET.has(obs.type)) {
606
+ // Upgrade-delete: pre-saved observation's rule-inferred type was later
607
+ // classified by Haiku as an event type. Delete the stale observations
608
+ // row and insert into events instead. Dispatcher handles the atomic
609
+ // swap via transaction.
610
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
611
+ project: episode.project,
612
+ session_id: episode.sessionId,
613
+ preSavedObsId: episode.savedId,
614
+ });
615
+ savedId = result.id;
616
+ savedTable = result.table;
617
+ debugLog('DEBUG', 'llm-episode', `upgrade-delete: obs #${episode.savedId} event #${savedId}`);
618
+ } else {
619
+ // Non-event type (e.g. `change`) — upgrade pre-saved observations row in place
620
+ // so the enriched FTS text field + minhash + vector are refreshed atomically.
621
+ const { conceptsText, factsText, textField } = buildFtsTextField(obs);
622
+ const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
623
+ db.prepare(`
624
+ UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
625
+ text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
626
+ WHERE id = ?
627
+ `).run(
628
+ obs.type, truncate(obs.title, 120), obs.subtitle || '',
629
+ truncate(obs.narrative || '', 500),
630
+ conceptsText, factsText, textField,
631
+ obs.importance,
632
+ JSON.stringify(obs.filesRead || []),
633
+ minhashSig,
634
+ obs.lessonLearned || null,
635
+ obs.searchAliases || null,
636
+ episode.savedId
637
+ );
638
+ savedId = episode.savedId;
639
+ savedTable = 'observations';
640
+ debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
641
+
642
+ // Update TF-IDF vector with enriched content
643
+ try {
644
+ const vocab = getVocabulary(db);
645
+ if (vocab) {
646
+ const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
647
+ const vec = computeVector(vecText, vocab);
648
+ if (vec) {
649
+ db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
650
+ .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
651
+ }
539
652
  }
540
- }
541
- } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
653
+ } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
654
+ }
542
655
  } else {
543
- savedId = saveObservation(obs, episode.project, episode.sessionId, db);
656
+ // Clean insert (no pre-save) — dispatcher routes by type.
657
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
658
+ project: episode.project,
659
+ session_id: episode.sessionId,
660
+ });
661
+ savedId = result.id;
662
+ savedTable = result.table;
544
663
  }
545
664
 
546
- if (savedId) {
665
+ // Related-observation linking only applies to rows in `observations` —
666
+ // the `events` table has its own lifecycle (supersede/accessed_count).
667
+ if (savedId && savedTable === 'observations') {
547
668
  try {
548
669
  linkRelatedObservations(db, savedId, obs, episode);
549
670
  } catch (e) { debugCatch(e, 'relatedObsLinking'); }
package/hook.mjs CHANGED
@@ -288,8 +288,21 @@ function triggerErrorRecall(db, toolInput, response) {
288
288
  // ─── Stop Handler ───────────────────────────────────────────────────────────
289
289
 
290
290
  async function handleStop() {
291
- // Capture session info BEFORE cleanup
292
- const sessionId = getSessionId();
291
+ // Read Claude Code's real session_id from hook stdin for parallel-session scoping.
292
+ // This is the stable CC identifier — the mem plugin's file-based getSessionId()
293
+ // collides across parallel sessions for the same project (see docs/bug.txt).
294
+ let ccSessionId = null;
295
+ try {
296
+ const raw = await readStdin();
297
+ const hookData = JSON.parse(raw.text);
298
+ if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
299
+ ccSessionId = hookData.session_id;
300
+ }
301
+ } catch { /* stdin unavailable — fall back to local session id */ }
302
+
303
+ // Capture session info BEFORE cleanup. Prefer CC session id (parallel-safe);
304
+ // fall back to file-based id for backward compat.
305
+ const sessionId = ccSessionId || getSessionId();
293
306
  const project = inferProject();
294
307
 
295
308
  // Snapshot episode BEFORE flush for handoff extraction
@@ -384,6 +397,17 @@ async function handleStop() {
384
397
  // ─── SessionStart Handler + CLAUDE.md Persistence (Tier 1 A, E) ─────────────
385
398
 
386
399
  async function handleSessionStart() {
400
+ // Read CC real session_id from hook stdin — used to scope handoff rows so parallel
401
+ // sessions for the same project don't clobber each other (see docs/bug.txt).
402
+ let ccSessionId = null;
403
+ try {
404
+ const raw = await readStdin();
405
+ const hookData = JSON.parse(raw.text);
406
+ if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
407
+ ccSessionId = hookData.session_id;
408
+ }
409
+ } catch { /* stdin unavailable — legacy behavior */ }
410
+
387
411
  // Snapshot episode BEFORE flush for handoff extraction
388
412
  const episodeSnapshot = readEpisodeRaw();
389
413
 
@@ -566,15 +590,21 @@ async function handleSessionStart() {
566
590
  let prevClearHandoff = null;
567
591
 
568
592
  if (prevSessionId) {
569
- // Save handoff for cross-session continuity (/clear or /compact)
570
- try { buildAndSaveHandoff(db, prevSessionId, prevProject || project, 'clear', episodeSnapshot); }
593
+ // Save handoff for cross-session continuity (/clear or /compact).
594
+ // Prefer CC session id (stable across /clear within same CC session, and
595
+ // unique across parallel sessions for the same project) so UserPromptSubmit
596
+ // can scope by hookData.session_id. Fall back to the mem plugin's file-based
597
+ // id for legacy/test paths.
598
+ const handoffSessionId = ccSessionId || prevSessionId;
599
+ try { buildAndSaveHandoff(db, handoffSessionId, prevProject || project, 'clear', episodeSnapshot); }
571
600
  catch (e) { debugCatch(e, 'session-start-handoff'); }
572
601
 
573
- // Read the just-saved handoff for downstream consumers (fast summary remaining, working state)
602
+ // Read the just-saved handoff for downstream consumers (fast summary remaining, working state).
603
+ // Session-scoped read to avoid picking up a parallel session's clear handoff.
574
604
  try {
575
605
  prevClearHandoff = db.prepare(
576
- 'SELECT working_on, unfinished, key_files FROM session_handoffs WHERE project = ? AND type = ?'
577
- ).get(prevProject || project, 'clear');
606
+ 'SELECT working_on, unfinished, key_files FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?'
607
+ ).get(prevProject || project, 'clear', handoffSessionId);
578
608
  } catch {}
579
609
 
580
610
  // Generate session summary for previous session (background Haiku — richer version)
@@ -686,10 +716,29 @@ async function handleSessionStart() {
686
716
  } catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
687
717
  }
688
718
 
719
+ // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
720
+ // structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
721
+ // <claude-mem-context> so both surfaces coexist. Empty string → skip.
722
+ try {
723
+ const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
724
+ const dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
725
+ if (dashboardText) {
726
+ process.stdout.write(JSON.stringify({
727
+ suppressOutput: true,
728
+ hookSpecificOutput: {
729
+ hookEventName: 'SessionStart',
730
+ additionalContext: dashboardText,
731
+ },
732
+ }) + '\n');
733
+ }
734
+ } catch (e) { debugCatch(e, 'session-start-dashboard'); }
735
+
689
736
  // Build the full context body via shared helper (also used by `mem-cli context`).
690
737
  // Queries session_summaries, key observations, clear handoff, and the
691
738
  // token-budgeted observation pool directly from the DB.
692
- const fullContext = buildSessionContextLines(db, project, now);
739
+ // Pass CC session id so the Working State block is scoped to this session,
740
+ // preventing parallel sessions from seeing each other's /clear handoff.
741
+ const fullContext = buildSessionContextLines(db, project, now, ccSessionId);
693
742
 
694
743
  // Stdout is the sole context-delivery channel. The SessionStart hook output
695
744
  // is injected as a <system-reminder> at session start, giving Claude the
@@ -766,16 +815,29 @@ async function handleUserPrompt() {
766
815
  now.toISOString(), now.getTime()
767
816
  );
768
817
 
769
- // Cross-session handoff injection (first 3 prompts window, before semantic memory)
818
+ // Cross-session handoff injection (first 3 prompts window, before semantic memory).
819
+ // Use Claude Code's real session_id from hook stdin to scope handoffs to this CC
820
+ // session — prevents cross-session bleed when running parallel sessions for the
821
+ // same project (see docs/bug.txt). Falls back to null (legacy behavior) if the
822
+ // hook input does not carry session_id.
823
+ const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
824
+ ? hookData.session_id
825
+ : null;
770
826
  if (counter?.prompt_counter <= 3) {
771
827
  try {
772
- if (detectContinuationIntent(db, promptText, project)) {
773
- const injection = renderHandoffInjection(db, project);
828
+ if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
829
+ const injection = renderHandoffInjection(db, project, ccSessionId);
774
830
  if (injection) {
775
831
  process.stdout.write(injection + '\n');
776
832
  // Consume clear handoff after injection to prevent duplicate injection on prompts 2-3.
777
- // Exit handoffs are kept (7d TTL, content-dependent keyword/FTS matching won't re-trigger).
778
- try { db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type = 'clear'").run(project); } catch {}
833
+ // Scope the delete to THIS session's clear handoff do not clobber parallel sessions' rows.
834
+ try {
835
+ if (ccSessionId) {
836
+ db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type = 'clear' AND session_id = ?").run(project, ccSessionId);
837
+ } else {
838
+ db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type = 'clear'").run(project);
839
+ }
840
+ } catch {}
779
841
  }
780
842
  }
781
843
  } catch (e) { debugCatch(e, 'handleUserPrompt-handoff'); }