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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +6 -1
- package/hook-context.mjs +20 -8
- package/hook-handoff.mjs +121 -23
- package/hook-llm.mjs +155 -34
- package/hook.mjs +75 -13
- package/install.mjs +21 -1
- package/mem-cli.mjs +128 -0
- package/package.json +1 -1
- package/schema.mjs +102 -2
- package/scripts/pre-tool-recall.js +46 -7
- package/scripts/user-prompt-search.js +48 -52
- package/server.mjs +27 -21
- package/tool-schemas.mjs +323 -0
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
|
-
|
|
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
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
187
|
-
const handoffs =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
}
|
|
653
|
+
} catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
|
|
654
|
+
}
|
|
542
655
|
} else {
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
778
|
-
try {
|
|
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'); }
|