claude-mem-lite 2.30.0 → 2.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.30.0",
13
+ "version": "2.30.1",
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.30.1",
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/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
@@ -89,12 +89,12 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
89
89
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
90
90
  const keywords = extractMatchKeywords(allText, [...fileSet]);
91
91
 
92
- // UPSERT
92
+ // UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
93
+ // Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
93
94
  db.prepare(`
94
95
  INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
95
96
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
- ON CONFLICT(project, type) DO UPDATE SET
97
- session_id = excluded.session_id,
97
+ ON CONFLICT(project, type, session_id) DO UPDATE SET
98
98
  working_on = excluded.working_on,
99
99
  completed = excluded.completed,
100
100
  unfinished = excluded.unfinished,
@@ -116,18 +116,41 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
116
116
 
117
117
  /**
118
118
  * Detect if user's prompt indicates continuation of previous work.
119
+ * Stage 0: Non-expired clear handoff + short prompt → auto-continue.
119
120
  * Stage 1: Explicit keyword match (zero false positives).
120
121
  * Stage 2: FTS5-style term overlap with handoff keywords.
122
+ *
123
+ * Session scoping (currentCcSessionId): when provided, clear handoffs from a
124
+ * DIFFERENT session are excluded from Stage 0 auto-match and from the general
125
+ * pool (prevents cross-session bleed when running parallel sessions for the
126
+ * same project — see docs/bug.txt). When null, legacy behavior is preserved.
127
+ *
121
128
  * @param {Database} db Opened main database
122
129
  * @param {string} promptText User's prompt text
123
130
  * @param {string} project Project identifier
131
+ * @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
124
132
  * @returns {boolean}
125
133
  */
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);
134
+ export function detectContinuationIntent(db, promptText, project, currentCcSessionId = null) {
135
+ // Input guard: empty / whitespace / single-char prompts never trigger auto-injection.
136
+ // The bug was a single-char 'a' + fresh clear handoff → Stage 0 auto-match.
137
+ if (!promptText || typeof promptText !== 'string') return false;
138
+ if (promptText.trim().length < 2) return false;
139
+
140
+ // Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
141
+ // Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
142
+ const clearHandoff = currentCcSessionId
143
+ ? db.prepare(`
144
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
145
+ WHERE project = ? AND type = 'clear' AND session_id = ?
146
+ ORDER BY created_at_epoch DESC LIMIT 1
147
+ `).get(project, currentCcSessionId)
148
+ : db.prepare(`
149
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
150
+ WHERE project = ? AND type = 'clear'
151
+ ORDER BY created_at_epoch DESC LIMIT 1
152
+ `).get(project);
153
+
131
154
  if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
132
155
  // Short/ambiguous prompts: assume continuation (user may say "ok", "start", etc.)
133
156
  if (promptText.length < 40) return true;
@@ -142,11 +165,20 @@ export function detectContinuationIntent(db, promptText, project) {
142
165
  // Stage 1: Explicit keyword match — always works, even without handoff
143
166
  if (CONTINUE_KEYWORDS.test(promptText)) return true;
144
167
 
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);
168
+ // Stage 2: FTS5-style term overlap with handoff keywords.
169
+ // Session scoping: exit handoffs from OTHER sessions are still candidates (you may
170
+ // be resuming a previous session), but clear handoffs must be same-session.
171
+ const handoffs = currentCcSessionId
172
+ ? db.prepare(`
173
+ SELECT type, match_keywords, created_at_epoch FROM session_handoffs
174
+ WHERE project = ?
175
+ AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
176
+ ORDER BY created_at_epoch DESC
177
+ `).all(project, currentCcSessionId)
178
+ : db.prepare(`
179
+ SELECT type, match_keywords, created_at_epoch FROM session_handoffs
180
+ WHERE project = ? ORDER BY created_at_epoch DESC
181
+ `).all(project);
150
182
  if (handoffs.length === 0) return false;
151
183
 
152
184
  // Filter expired handoffs
@@ -176,18 +208,32 @@ export function detectContinuationIntent(db, promptText, project) {
176
208
  /**
177
209
  * Render handoff injection text for stdout.
178
210
  * Reads the most recent handoff + optional session summary.
211
+ *
212
+ * Session scoping (currentCcSessionId): when provided,
213
+ * - clear handoffs: only from the CURRENT session (you continue your own /clear)
214
+ * - exit handoffs: only from OTHER sessions (you resume a previous exit)
215
+ * When null, legacy behavior (most-recent handoff regardless of session).
216
+ *
179
217
  * @param {Database} db Opened main database
180
218
  * @param {string} project Project identifier
219
+ * @param {string|null} [currentCcSessionId=null] Claude Code session id for scoping
181
220
  * @returns {string|null} Injection text or null if no handoff
182
221
  */
183
- export function renderHandoffInjection(db, project) {
222
+ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
184
223
  const now = Date.now();
185
224
  // 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);
225
+ // A newer but expired 'clear' handoff must not shadow a still-valid 'exit' handoff.
226
+ const handoffs = currentCcSessionId
227
+ ? db.prepare(`
228
+ SELECT * FROM session_handoffs
229
+ WHERE project = ?
230
+ AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
231
+ ORDER BY created_at_epoch DESC LIMIT 5
232
+ `).all(project, currentCcSessionId, currentCcSessionId)
233
+ : db.prepare(`
234
+ SELECT * FROM session_handoffs
235
+ WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
236
+ `).all(project);
191
237
  const handoff = handoffs.find(h => {
192
238
  const age = now - h.created_at_epoch;
193
239
  const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
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)
@@ -689,7 +719,9 @@ async function handleSessionStart() {
689
719
  // Build the full context body via shared helper (also used by `mem-cli context`).
690
720
  // Queries session_summaries, key observations, clear handoff, and the
691
721
  // token-budgeted observation pool directly from the DB.
692
- const fullContext = buildSessionContextLines(db, project, now);
722
+ // Pass CC session id so the Working State block is scoped to this session,
723
+ // preventing parallel sessions from seeing each other's /clear handoff.
724
+ const fullContext = buildSessionContextLines(db, project, now, ccSessionId);
693
725
 
694
726
  // Stdout is the sole context-delivery channel. The SessionStart hook output
695
727
  // is injected as a <system-reminder> at session start, giving Claude the
@@ -766,16 +798,29 @@ async function handleUserPrompt() {
766
798
  now.toISOString(), now.getTime()
767
799
  );
768
800
 
769
- // Cross-session handoff injection (first 3 prompts window, before semantic memory)
801
+ // Cross-session handoff injection (first 3 prompts window, before semantic memory).
802
+ // Use Claude Code's real session_id from hook stdin to scope handoffs to this CC
803
+ // session — prevents cross-session bleed when running parallel sessions for the
804
+ // same project (see docs/bug.txt). Falls back to null (legacy behavior) if the
805
+ // hook input does not carry session_id.
806
+ const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
807
+ ? hookData.session_id
808
+ : null;
770
809
  if (counter?.prompt_counter <= 3) {
771
810
  try {
772
- if (detectContinuationIntent(db, promptText, project)) {
773
- const injection = renderHandoffInjection(db, project);
811
+ if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
812
+ const injection = renderHandoffInjection(db, project, ccSessionId);
774
813
  if (injection) {
775
814
  process.stdout.write(injection + '\n');
776
815
  // 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 {}
816
+ // Scope the delete to THIS session's clear handoff do not clobber parallel sessions' rows.
817
+ try {
818
+ if (ccSessionId) {
819
+ db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type = 'clear' AND session_id = ?").run(project, ccSessionId);
820
+ } else {
821
+ db.prepare("DELETE FROM session_handoffs WHERE project = ? AND type = 'clear'").run(project);
822
+ }
823
+ } catch {}
779
824
  }
780
825
  }
781
826
  } catch (e) { debugCatch(e, 'handleUserPrompt-handoff'); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
package/schema.mjs CHANGED
@@ -13,7 +13,7 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
13
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
14
14
 
15
15
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
- export const CURRENT_SCHEMA_VERSION = 21;
16
+ export const CURRENT_SCHEMA_VERSION = 22;
17
17
 
18
18
  const CORE_SCHEMA = `
19
19
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -91,7 +91,7 @@ const CORE_SCHEMA = `
91
91
  key_decisions TEXT,
92
92
  match_keywords TEXT,
93
93
  created_at_epoch INTEGER,
94
- PRIMARY KEY (project, type)
94
+ PRIMARY KEY (project, type, session_id)
95
95
  );
96
96
  `;
97
97
 
@@ -136,6 +136,42 @@ export function initSchema(db) {
136
136
  }
137
137
  }
138
138
 
139
+ // session_handoffs PK widen: (project, type) → (project, type, session_id)
140
+ // Old PK assumed one session per project, causing cross-session handoff overwrite
141
+ // (see docs/bug.txt). Rebuild table if still on old PK. Idempotent.
142
+ try {
143
+ const handoffDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='session_handoffs'`).get();
144
+ const oldPk = handoffDdl && /PRIMARY KEY\s*\(\s*project\s*,\s*type\s*\)/i.test(handoffDdl.sql);
145
+ if (oldPk) {
146
+ const rebuild = db.transaction(() => {
147
+ db.exec(`
148
+ CREATE TABLE session_handoffs_new (
149
+ project TEXT NOT NULL,
150
+ type TEXT NOT NULL,
151
+ session_id TEXT NOT NULL,
152
+ working_on TEXT,
153
+ completed TEXT,
154
+ unfinished TEXT,
155
+ key_files TEXT,
156
+ key_decisions TEXT,
157
+ match_keywords TEXT,
158
+ created_at_epoch INTEGER,
159
+ PRIMARY KEY (project, type, session_id)
160
+ )
161
+ `);
162
+ db.exec(`
163
+ INSERT INTO session_handoffs_new
164
+ (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
165
+ SELECT project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch
166
+ FROM session_handoffs
167
+ `);
168
+ db.exec(`DROP TABLE session_handoffs`);
169
+ db.exec(`ALTER TABLE session_handoffs_new RENAME TO session_handoffs`);
170
+ });
171
+ rebuild();
172
+ }
173
+ } catch { /* non-critical — next open retries */ }
174
+
139
175
  // Dedup migration: ensure memory_session_id is unique, then enable FK
140
176
  const hasIdx = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_sess_memory_sid'`).get();
141
177
  if (!hasIdx) {
@@ -173,6 +209,7 @@ export function initSchema(db) {
173
209
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
174
210
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
175
211
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`);
212
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`);
176
213
 
177
214
  // FTS5 migration: recreate observations_fts when columns are missing (one-time)
178
215
  // Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set