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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +20 -8
- package/hook-handoff.mjs +65 -19
- package/hook.mjs +58 -13
- package/package.json +1 -1
- package/schema.mjs +39 -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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
187
|
-
const handoffs =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
//
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
778
|
-
try {
|
|
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
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 =
|
|
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
|