claude-mem-lite 3.96.0 → 3.97.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/adopt-cli.mjs +125 -51
- package/bash-utils.mjs +105 -29
- package/claudemd.mjs +63 -19
- package/cli/activity.mjs +36 -18
- package/cli/common.mjs +101 -18
- package/cli/doctor.mjs +29 -9
- package/cli.mjs +69 -10
- package/deep-search.mjs +63 -20
- package/format-utils.mjs +15 -7
- package/haiku-client.mjs +189 -111
- package/hash-utils.mjs +15 -6
- package/hook-context.mjs +135 -46
- package/hook-episode.mjs +76 -23
- package/hook-handoff.mjs +216 -84
- package/hook-llm.mjs +476 -216
- package/hook-memory.mjs +106 -43
- package/hook-optimize.mjs +272 -109
- package/hook-precompact.mjs +1 -1
- package/hook-semaphore.mjs +46 -12
- package/hook-shared.mjs +101 -131
- package/hook-update.mjs +286 -88
- package/hook.mjs +1207 -617
- package/install-metadata.mjs +246 -125
- package/install.mjs +1327 -917
- package/lib/activity.mjs +52 -29
- package/lib/atomic-write.mjs +23 -4
- package/lib/binding-probe.mjs +40 -38
- package/lib/browse-core.mjs +22 -8
- package/lib/citation-tracker.mjs +189 -79
- package/lib/cite-back-hint.mjs +37 -19
- package/lib/cite-recall-path.mjs +3 -1
- package/lib/cli-flags.mjs +4 -2
- package/lib/cli-project.mjs +8 -5
- package/lib/compress-core.mjs +34 -9
- package/lib/cooldown-path.mjs +3 -1
- package/lib/db-backup.mjs +33 -11
- package/lib/deferred-work.mjs +73 -32
- package/lib/delete-core.mjs +25 -9
- package/lib/doctor-benchmark.mjs +5 -1
- package/lib/doctor-drift.mjs +25 -8
- package/lib/edge-attribution.mjs +23 -9
- package/lib/env-number.mjs +7 -8
- package/lib/err-sampler.mjs +22 -11
- package/lib/error-recall-core.mjs +15 -17
- package/lib/events-injection.mjs +25 -8
- package/lib/export-columns.mjs +49 -10
- package/lib/fast-summary.mjs +22 -9
- package/lib/file-edge-match.mjs +4 -2
- package/lib/file-intel.mjs +38 -13
- package/lib/frontmatter.mjs +22 -5
- package/lib/get-core.mjs +78 -11
- package/lib/handoff-constants.mjs +18 -0
- package/lib/hook-stdin.mjs +10 -3
- package/lib/hook-stdout.mjs +21 -7
- package/lib/hook-telemetry.mjs +44 -17
- package/lib/id-routing.mjs +11 -6
- package/lib/import-jsonl.mjs +92 -41
- package/lib/inject-search-core.mjs +5 -2
- package/lib/injected-ids.mjs +19 -12
- package/lib/install-shape.mjs +47 -23
- package/lib/keyctx-marker.mjs +3 -1
- package/lib/lesson-bridge.mjs +8 -5
- package/lib/llm-call.mjs +76 -0
- package/lib/llm-provider-probe.mjs +24 -12
- package/lib/low-signal-patterns.mjs +25 -22
- package/lib/maintain-core.mjs +221 -92
- package/lib/mem-override.mjs +4 -2
- package/lib/metrics.mjs +30 -25
- package/lib/native-binding-hint.mjs +29 -8
- package/lib/observation-write.mjs +74 -19
- package/lib/patha-exclude-meter.mjs +10 -6
- package/lib/persist-reminder.mjs +22 -10
- package/lib/plan-reader.mjs +2 -2
- package/lib/private-strip.mjs +3 -3
- package/lib/proc-lock.mjs +15 -3
- package/lib/proxy-fetch.mjs +49 -13
- package/lib/quiet-scope.mjs +42 -0
- package/lib/recall-core.mjs +19 -7
- package/lib/recent-core.mjs +18 -5
- package/lib/registry-core.mjs +12 -6
- package/lib/release-digest.mjs +4 -1
- package/lib/relevance-floor.mjs +5 -2
- package/lib/reread-guard.mjs +16 -8
- package/lib/resolve-data-dir.mjs +1 -1
- package/lib/rrf.mjs +4 -1
- package/lib/save-enrich.mjs +36 -18
- package/lib/save-observation.mjs +78 -35
- package/lib/scrub-record.mjs +20 -6
- package/lib/search-core.mjs +456 -123
- package/lib/shard-gc.mjs +50 -0
- package/lib/startup-dashboard.mjs +20 -9
- package/lib/stats-core.mjs +101 -35
- package/lib/stats-quality.mjs +56 -20
- package/lib/summary-extractor.mjs +2 -2
- package/lib/task-imperative.mjs +12 -4
- package/lib/timeline-core.mjs +88 -28
- package/lib/tmp-fixture-sweep.mjs +32 -9
- package/lib/transcript-scan.mjs +20 -4
- package/lib/upgrade-banner.mjs +11 -7
- package/mem-cli.mjs +1314 -506
- package/memdir.mjs +53 -21
- package/nlp.mjs +68 -38
- package/npm-shrinkwrap.json +2 -2
- package/package.json +9 -2
- package/plugin-cache-guard.mjs +31 -15
- package/project-utils.mjs +44 -20
- package/registry-enricher.mjs +26 -6
- package/registry-github.mjs +4 -2
- package/registry-importer.mjs +151 -40
- package/registry-recommend.mjs +189 -96
- package/registry-retriever.mjs +229 -70
- package/registry-scanner.mjs +11 -6
- package/registry.mjs +163 -66
- package/rerank.mjs +18 -4
- package/resource-discovery.mjs +14 -4
- package/schema.mjs +258 -99
- package/scoring-sql.mjs +21 -10
- package/scripts/binding-probe-cli.mjs +17 -12
- package/scripts/hook-launcher.mjs +148 -31
- package/scripts/launch-preflight.mjs +5 -7
- package/scripts/launch.mjs +21 -9
- package/scripts/post-tool-recall.js +21 -5
- package/scripts/pre-agent-inject.js +51 -13
- package/scripts/pre-skill-bridge.js +22 -7
- package/scripts/pre-tool-recall.js +157 -77
- package/scripts/prompt-search-utils.mjs +70 -25
- package/scripts/setup.sh +16 -1
- package/scripts/user-prompt-search.js +238 -97
- package/search-engine.mjs +329 -96
- package/search-scoring.mjs +75 -33
- package/secret-scrub.mjs +48 -12
- package/server/fts-check.mjs +12 -9
- package/server.mjs +674 -243
- package/skip-tools.mjs +19 -8
- package/source-files.mjs +54 -21
- package/stop-words.mjs +152 -19
- package/synonyms.mjs +579 -164
- package/tfidf.mjs +130 -40
- package/tier.mjs +1 -1
- package/tool-schemas.mjs +381 -131
- package/utils.mjs +184 -36
package/hook-handoff.mjs
CHANGED
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
// Extracted for testability — hook.mjs has module-level side effects
|
|
3
3
|
|
|
4
4
|
import { basename } from 'path';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
truncate,
|
|
7
|
+
extractMatchKeywords,
|
|
8
|
+
tokenizeHandoff,
|
|
9
|
+
isSpecificTerm,
|
|
10
|
+
scrubSecrets,
|
|
11
|
+
LOW_SIGNAL_TITLE,
|
|
12
|
+
EDIT_TOOLS,
|
|
13
|
+
isMetaTriggerPrompt,
|
|
14
|
+
notLowSignalTitleClause,
|
|
15
|
+
neutralizeContextDelimiters,
|
|
16
|
+
} from './utils.mjs';
|
|
6
17
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
7
18
|
import {
|
|
8
|
-
HANDOFF_EXPIRY_CLEAR,
|
|
9
|
-
|
|
19
|
+
HANDOFF_EXPIRY_CLEAR,
|
|
20
|
+
HANDOFF_EXPIRY_EXIT,
|
|
21
|
+
HANDOFF_ANCHOR_MAX_AGE,
|
|
22
|
+
HANDOFF_MATCH_THRESHOLD,
|
|
23
|
+
CONTINUE_KEYWORDS,
|
|
10
24
|
} from './hook-shared.mjs';
|
|
11
25
|
// T10d: import the whole module (not a named export) so tests can spy on
|
|
12
26
|
// gitStateModule.readGitState via vi.spyOn. Named-import bindings are
|
|
@@ -43,17 +57,25 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
43
57
|
// unfiltered query (identical to pre-D#26 behavior).
|
|
44
58
|
const ccScope = scopeSessionId && scopeSessionId !== sessionId ? scopeSessionId : null;
|
|
45
59
|
const prompts = ccScope
|
|
46
|
-
? db
|
|
60
|
+
? db
|
|
61
|
+
.prepare(
|
|
62
|
+
`
|
|
47
63
|
SELECT prompt_text FROM user_prompts
|
|
48
64
|
WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
|
|
49
65
|
ORDER BY prompt_number ASC LIMIT 5
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
`,
|
|
67
|
+
)
|
|
68
|
+
.all(sessionId, ccScope)
|
|
69
|
+
: db
|
|
70
|
+
.prepare(
|
|
71
|
+
`
|
|
52
72
|
SELECT prompt_text FROM user_prompts
|
|
53
73
|
WHERE content_session_id = ?
|
|
54
74
|
ORDER BY prompt_number ASC LIMIT 5
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
`,
|
|
76
|
+
)
|
|
77
|
+
.all(sessionId);
|
|
78
|
+
if (prompts.length === 0) return; // Empty session — nothing to hand off
|
|
57
79
|
|
|
58
80
|
// Filter prompts whose only content is workflow/control language ("继续",
|
|
59
81
|
// "提交代码", "/exit", etc.). Storing them verbatim into working_on creates
|
|
@@ -62,26 +84,30 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
62
84
|
// the project's most recent importance≥3 non-low-signal observation as the
|
|
63
85
|
// carry-forward anchor — that's the closest durable signal of "what was
|
|
64
86
|
// being worked on at a higher level than this session".
|
|
65
|
-
const subjectPrompts = prompts.filter(p => !isMetaTriggerPrompt(p.prompt_text));
|
|
87
|
+
const subjectPrompts = prompts.filter((p) => !isMetaTriggerPrompt(p.prompt_text));
|
|
66
88
|
const sourcePrompts = subjectPrompts.length > 0 ? subjectPrompts : prompts;
|
|
67
89
|
|
|
68
90
|
const seen = new Set();
|
|
69
|
-
const uniquePrompts = sourcePrompts.filter(p => {
|
|
91
|
+
const uniquePrompts = sourcePrompts.filter((p) => {
|
|
70
92
|
const t = truncate(p.prompt_text, 200);
|
|
71
93
|
if (seen.has(t)) return false;
|
|
72
94
|
seen.add(t);
|
|
73
95
|
return true;
|
|
74
96
|
});
|
|
75
|
-
let workingOn = uniquePrompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
|
|
97
|
+
let workingOn = uniquePrompts.map((p) => truncate(p.prompt_text, 200)).join(' → ');
|
|
76
98
|
|
|
77
99
|
if (subjectPrompts.length === 0) {
|
|
78
|
-
const fallback = db
|
|
100
|
+
const fallback = db
|
|
101
|
+
.prepare(
|
|
102
|
+
`
|
|
79
103
|
SELECT title FROM observations
|
|
80
104
|
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
81
105
|
AND COALESCE(importance, 1) >= 3
|
|
82
106
|
AND ${notLowSignalTitleClause('')}
|
|
83
107
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
84
|
-
|
|
108
|
+
`,
|
|
109
|
+
)
|
|
110
|
+
.get(project);
|
|
85
111
|
if (fallback?.title) {
|
|
86
112
|
workingOn = `(carry-forward subject) ${truncate(fallback.title, 180)}`;
|
|
87
113
|
}
|
|
@@ -96,21 +122,29 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
96
122
|
// concurrent same-project sessions whose windows overlap can still co-attribute a few rows.
|
|
97
123
|
let ccWindowStart = null;
|
|
98
124
|
if (ccScope) {
|
|
99
|
-
const w = db
|
|
125
|
+
const w = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`
|
|
100
128
|
SELECT MIN(created_at_epoch) AS startEpoch FROM user_prompts
|
|
101
129
|
WHERE content_session_id = ? AND cc_session_id = ?
|
|
102
|
-
|
|
130
|
+
`,
|
|
131
|
+
)
|
|
132
|
+
.get(sessionId, ccScope);
|
|
103
133
|
if (typeof w?.startEpoch === 'number') ccWindowStart = w.startEpoch;
|
|
104
134
|
}
|
|
105
135
|
const obsWindowClause = ccWindowStart !== null ? 'AND created_at_epoch >= ?' : '';
|
|
106
136
|
const obsWindowParams = ccWindowStart !== null ? [ccWindowStart] : [];
|
|
107
137
|
|
|
108
138
|
// 2. Completed — from observations (include narrative for richer handoff)
|
|
109
|
-
const completed = db
|
|
139
|
+
const completed = db
|
|
140
|
+
.prepare(
|
|
141
|
+
`
|
|
110
142
|
SELECT title, type, narrative FROM observations
|
|
111
143
|
WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
|
|
112
144
|
ORDER BY created_at_epoch DESC LIMIT 15
|
|
113
|
-
|
|
145
|
+
`,
|
|
146
|
+
)
|
|
147
|
+
.all(sessionId, ...obsWindowParams);
|
|
114
148
|
|
|
115
149
|
// 3. Recent activity — episode snapshot + full session edit history from narratives.
|
|
116
150
|
// Keep only entries that represent in-flight work (file edits) or outright failures
|
|
@@ -121,9 +155,13 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
121
155
|
if (episodeSnapshot?.entries) {
|
|
122
156
|
const seenDescs = new Set();
|
|
123
157
|
const pendingDescs = episodeSnapshot.entries
|
|
124
|
-
.filter(e => e.isError || EDIT_TOOLS.has(e.tool))
|
|
125
|
-
.map(e => e.desc)
|
|
126
|
-
.filter(d => {
|
|
158
|
+
.filter((e) => e.isError || EDIT_TOOLS.has(e.tool))
|
|
159
|
+
.map((e) => e.desc)
|
|
160
|
+
.filter((d) => {
|
|
161
|
+
if (seenDescs.has(d)) return false;
|
|
162
|
+
seenDescs.add(d);
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
127
165
|
if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
|
|
128
166
|
}
|
|
129
167
|
|
|
@@ -141,19 +179,19 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
141
179
|
// '\n'-join collapsed the whole task list into one unreadable multi-line bullet.
|
|
142
180
|
unfinished = tasks
|
|
143
181
|
.slice(0, 5)
|
|
144
|
-
.map(t => `[${t.status}] ${t.title}`)
|
|
182
|
+
.map((t) => `[${t.status}] ${t.title}`)
|
|
145
183
|
.join(UNFINISHED_ENTRY_SEP);
|
|
146
184
|
}
|
|
147
|
-
} catch {
|
|
185
|
+
} catch {
|
|
186
|
+
/* task reader is best-effort; never block handoff */
|
|
187
|
+
}
|
|
148
188
|
}
|
|
149
189
|
|
|
150
190
|
// Enrich unfinished with full session edit history from observation narratives.
|
|
151
191
|
// Since handoff is UPSERT (max 2 rows per project), storing more data is free.
|
|
152
192
|
// Always use \n---\n separator so extractUnfinishedSummary can distinguish
|
|
153
193
|
// pending work (before separator) from narrative history (after separator).
|
|
154
|
-
const narratives = completed
|
|
155
|
-
.filter(c => c.narrative)
|
|
156
|
-
.map(c => c.narrative);
|
|
194
|
+
const narratives = completed.filter((c) => c.narrative).map((c) => c.narrative);
|
|
157
195
|
if (narratives.length > 0) {
|
|
158
196
|
const editHistory = narratives.join('\n');
|
|
159
197
|
unfinished += '\n---\n' + editHistory;
|
|
@@ -161,16 +199,30 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
161
199
|
|
|
162
200
|
// 4. Key files — from episode snapshot + observations
|
|
163
201
|
const fileSet = new Set();
|
|
164
|
-
const isValidFile =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
const isValidFile = (f) =>
|
|
203
|
+
f &&
|
|
204
|
+
f.length > 2 &&
|
|
205
|
+
f.includes('/') &&
|
|
206
|
+
f.indexOf('/', 1) !== -1 &&
|
|
207
|
+
!f.startsWith('/dev/') &&
|
|
208
|
+
!f.startsWith('/proc/') &&
|
|
209
|
+
!f.startsWith('/tmp/');
|
|
210
|
+
if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach((f) => fileSet.add(f));
|
|
211
|
+
const obsFiles = db
|
|
212
|
+
.prepare(
|
|
213
|
+
`
|
|
168
214
|
SELECT files_modified FROM observations
|
|
169
215
|
WHERE memory_session_id = ? AND files_modified IS NOT NULL ${obsWindowClause}
|
|
170
216
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
171
|
-
|
|
217
|
+
`,
|
|
218
|
+
)
|
|
219
|
+
.all(sessionId, ...obsWindowParams);
|
|
172
220
|
for (const row of obsFiles) {
|
|
173
|
-
try {
|
|
221
|
+
try {
|
|
222
|
+
JSON.parse(row.files_modified)
|
|
223
|
+
.filter(isValidFile)
|
|
224
|
+
.forEach((f) => fileSet.add(f));
|
|
225
|
+
} catch {}
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
// 5. Key decisions — high importance observations (skip low-signal degraded titles).
|
|
@@ -182,15 +234,21 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
182
234
|
// retracted decision rendered there is indistinguishable from live policy. The
|
|
183
235
|
// carry-forward fallback at the top of this function already filters the same column;
|
|
184
236
|
// this is the sibling that did not.
|
|
185
|
-
const decisions = db
|
|
237
|
+
const decisions = db
|
|
238
|
+
.prepare(
|
|
239
|
+
`
|
|
186
240
|
SELECT title FROM observations
|
|
187
241
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
188
242
|
AND ${liveObsFilterSql('')} ${obsWindowClause}
|
|
189
243
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
190
|
-
|
|
244
|
+
`,
|
|
245
|
+
)
|
|
246
|
+
.all(sessionId, ...obsWindowParams)
|
|
247
|
+
.filter((d) => d.title && !LOW_SIGNAL_TITLE.test(d.title))
|
|
248
|
+
.slice(0, 5);
|
|
191
249
|
|
|
192
250
|
// 6. Match keywords
|
|
193
|
-
const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
|
|
251
|
+
const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished].join(' ');
|
|
194
252
|
const keywords = extractMatchKeywords(allText, [...fileSet]);
|
|
195
253
|
|
|
196
254
|
// T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
|
|
@@ -198,7 +256,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
198
256
|
let gitShaAtHandoff = null;
|
|
199
257
|
try {
|
|
200
258
|
gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
|
|
201
|
-
} catch {
|
|
259
|
+
} catch {
|
|
260
|
+
/* swallow — handoff must still persist */
|
|
261
|
+
}
|
|
202
262
|
|
|
203
263
|
// UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
|
|
204
264
|
// Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
|
|
@@ -214,15 +274,14 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
214
274
|
// string would risk breaking downstream JSON.parse.
|
|
215
275
|
const safe = scrubRecord('session_handoffs', {
|
|
216
276
|
working_on: workingOn,
|
|
217
|
-
completed: completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
|
|
277
|
+
completed: completed.map((c) => `[${c.type}] ${c.title}`).join('\n'),
|
|
218
278
|
unfinished,
|
|
219
|
-
key_decisions: decisions.map(d => d.title).join('\n'),
|
|
279
|
+
key_decisions: decisions.map((d) => d.title).join('\n'),
|
|
220
280
|
match_keywords: keywords,
|
|
221
281
|
});
|
|
222
|
-
const safeKeyFiles = JSON.stringify(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
db.prepare(`
|
|
282
|
+
const safeKeyFiles = JSON.stringify([...fileSet].slice(0, 20).map((f) => scrubSecrets(String(f))));
|
|
283
|
+
db.prepare(
|
|
284
|
+
`
|
|
226
285
|
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)
|
|
227
286
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
228
287
|
ON CONFLICT(project, type, session_id) DO UPDATE SET
|
|
@@ -234,11 +293,16 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
234
293
|
match_keywords = excluded.match_keywords,
|
|
235
294
|
created_at_epoch = excluded.created_at_epoch,
|
|
236
295
|
git_sha_at_handoff = excluded.git_sha_at_handoff
|
|
237
|
-
|
|
238
|
-
|
|
296
|
+
`,
|
|
297
|
+
).run(
|
|
298
|
+
project,
|
|
299
|
+
type,
|
|
300
|
+
storedSessionId,
|
|
239
301
|
truncate(safe.working_on, 1000),
|
|
240
302
|
safe.completed,
|
|
241
|
-
safe.unfinished.length > 3000
|
|
303
|
+
safe.unfinished.length > 3000
|
|
304
|
+
? Array.from(safe.unfinished).slice(0, 2999).join('') + '…'
|
|
305
|
+
: safe.unfinished,
|
|
242
306
|
safeKeyFiles,
|
|
243
307
|
safe.key_decisions,
|
|
244
308
|
safe.match_keywords,
|
|
@@ -283,48 +347,66 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
|
|
|
283
347
|
// anchor is same-session only — else a parallel same-project session at the same commit
|
|
284
348
|
// would hijack (and then delete) another session's clear handoff.
|
|
285
349
|
const anchor = currentCcSessionId
|
|
286
|
-
? db
|
|
350
|
+
? db
|
|
351
|
+
.prepare(
|
|
352
|
+
`
|
|
287
353
|
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
288
354
|
WHERE project = ? AND git_sha_at_handoff = ? AND (type = 'exit' OR session_id = ?)
|
|
289
355
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
290
|
-
|
|
291
|
-
|
|
356
|
+
`,
|
|
357
|
+
)
|
|
358
|
+
.get(project, currentSha, currentCcSessionId)
|
|
359
|
+
: db
|
|
360
|
+
.prepare(
|
|
361
|
+
`
|
|
292
362
|
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
293
363
|
WHERE project = ? AND git_sha_at_handoff = ?
|
|
294
364
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
295
|
-
|
|
296
|
-
|
|
365
|
+
`,
|
|
366
|
+
)
|
|
367
|
+
.get(project, currentSha);
|
|
368
|
+
if (anchor && Date.now() - anchor.created_at_epoch <= HANDOFF_ANCHOR_MAX_AGE) {
|
|
297
369
|
// Unmoved HEAD is a strong resume signal, but must not hijack a NEW task typed at the
|
|
298
370
|
// same commit: gate long prompts on keyword overlap (mirror Stage 0). Short prompts
|
|
299
371
|
// (resume nudges) auto-continue.
|
|
300
372
|
if (promptText.length < 40) return true;
|
|
301
373
|
const hTokens = anchor.match_keywords ? new Set(tokenizeHandoff(anchor.match_keywords)) : null;
|
|
302
|
-
if (!hTokens || tokenizeHandoff(promptText).some(t => hTokens.has(t))) return true;
|
|
374
|
+
if (!hTokens || tokenizeHandoff(promptText).some((t) => hTokens.has(t))) return true;
|
|
303
375
|
// long prompt with zero keyword overlap → fall through to Stage 0/1/2
|
|
304
376
|
}
|
|
305
377
|
}
|
|
306
|
-
} catch {
|
|
378
|
+
} catch {
|
|
379
|
+
/* git/DB failure must not break the rest of the pipeline */
|
|
380
|
+
}
|
|
307
381
|
|
|
308
382
|
// Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
|
|
309
383
|
// Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
|
|
310
384
|
const clearHandoff = currentCcSessionId
|
|
311
|
-
? db
|
|
385
|
+
? db
|
|
386
|
+
.prepare(
|
|
387
|
+
`
|
|
312
388
|
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
313
389
|
WHERE project = ? AND type = 'clear' AND session_id = ?
|
|
314
390
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
315
|
-
|
|
316
|
-
|
|
391
|
+
`,
|
|
392
|
+
)
|
|
393
|
+
.get(project, currentCcSessionId)
|
|
394
|
+
: db
|
|
395
|
+
.prepare(
|
|
396
|
+
`
|
|
317
397
|
SELECT created_at_epoch, match_keywords FROM session_handoffs
|
|
318
398
|
WHERE project = ? AND type = 'clear'
|
|
319
399
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
320
|
-
|
|
400
|
+
`,
|
|
401
|
+
)
|
|
402
|
+
.get(project);
|
|
321
403
|
|
|
322
|
-
if (clearHandoff &&
|
|
404
|
+
if (clearHandoff && Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR) {
|
|
323
405
|
const pTokens = tokenizeHandoff(promptText);
|
|
324
406
|
const hTokens = clearHandoff.match_keywords
|
|
325
407
|
? new Set(tokenizeHandoff(clearHandoff.match_keywords))
|
|
326
408
|
: null;
|
|
327
|
-
const hasOverlap = hTokens && pTokens.some(t => hTokens.has(t));
|
|
409
|
+
const hasOverlap = hTokens && pTokens.some((t) => hTokens.has(t));
|
|
328
410
|
if (promptText.length < 40) {
|
|
329
411
|
// Short prompts: session-scoped clear = same user/context, auto-continue.
|
|
330
412
|
// Unscoped (legacy / no session_id in hook input) requires an explicit
|
|
@@ -348,21 +430,29 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
|
|
|
348
430
|
// Session scoping: exit handoffs from OTHER sessions are still candidates (you may
|
|
349
431
|
// be resuming a previous session), but clear handoffs must be same-session.
|
|
350
432
|
const handoffs = currentCcSessionId
|
|
351
|
-
? db
|
|
433
|
+
? db
|
|
434
|
+
.prepare(
|
|
435
|
+
`
|
|
352
436
|
SELECT type, match_keywords, created_at_epoch FROM session_handoffs
|
|
353
437
|
WHERE project = ?
|
|
354
438
|
AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
|
|
355
439
|
ORDER BY created_at_epoch DESC
|
|
356
|
-
|
|
357
|
-
|
|
440
|
+
`,
|
|
441
|
+
)
|
|
442
|
+
.all(project, currentCcSessionId)
|
|
443
|
+
: db
|
|
444
|
+
.prepare(
|
|
445
|
+
`
|
|
358
446
|
SELECT type, match_keywords, created_at_epoch FROM session_handoffs
|
|
359
447
|
WHERE project = ? ORDER BY created_at_epoch DESC
|
|
360
|
-
|
|
448
|
+
`,
|
|
449
|
+
)
|
|
450
|
+
.all(project);
|
|
361
451
|
if (handoffs.length === 0) return false;
|
|
362
452
|
|
|
363
453
|
// Filter expired handoffs
|
|
364
454
|
const now = Date.now();
|
|
365
|
-
const validHandoffs = handoffs.filter(h => {
|
|
455
|
+
const validHandoffs = handoffs.filter((h) => {
|
|
366
456
|
const age = now - h.created_at_epoch;
|
|
367
457
|
const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
|
|
368
458
|
return age <= maxAge;
|
|
@@ -403,21 +493,31 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
|
|
|
403
493
|
// Fetch recent handoffs and find the most recent non-expired one.
|
|
404
494
|
// A newer but expired 'clear' handoff must not shadow a still-valid 'exit' handoff.
|
|
405
495
|
const handoffs = currentCcSessionId
|
|
406
|
-
? db
|
|
496
|
+
? db
|
|
497
|
+
.prepare(
|
|
498
|
+
`
|
|
407
499
|
SELECT * FROM session_handoffs
|
|
408
500
|
WHERE project = ?
|
|
409
501
|
AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
|
|
410
502
|
ORDER BY created_at_epoch DESC LIMIT 5
|
|
411
|
-
|
|
412
|
-
|
|
503
|
+
`,
|
|
504
|
+
)
|
|
505
|
+
.all(project, currentCcSessionId, currentCcSessionId)
|
|
506
|
+
: db
|
|
507
|
+
.prepare(
|
|
508
|
+
`
|
|
413
509
|
SELECT * FROM session_handoffs
|
|
414
510
|
WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
511
|
+
`,
|
|
512
|
+
)
|
|
513
|
+
.all(project);
|
|
514
|
+
return (
|
|
515
|
+
handoffs.find((h) => {
|
|
516
|
+
const age = now - h.created_at_epoch;
|
|
517
|
+
const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
|
|
518
|
+
return age <= maxAge;
|
|
519
|
+
}) || null
|
|
520
|
+
);
|
|
421
521
|
}
|
|
422
522
|
|
|
423
523
|
export function renderHandoffInjection(db, project, currentCcSessionId = null) {
|
|
@@ -428,10 +528,14 @@ export function renderHandoffInjection(db, project, currentCcSessionId = null) {
|
|
|
428
528
|
|
|
429
529
|
function renderHandoffFromRow(handoff, db, project) {
|
|
430
530
|
const ageSec = Math.round((Date.now() - handoff.created_at_epoch) / 1000);
|
|
431
|
-
const ageStr =
|
|
432
|
-
ageSec <
|
|
433
|
-
|
|
434
|
-
|
|
531
|
+
const ageStr =
|
|
532
|
+
ageSec < 60
|
|
533
|
+
? `${ageSec}s`
|
|
534
|
+
: ageSec < 3600
|
|
535
|
+
? `${Math.round(ageSec / 60)}m`
|
|
536
|
+
: ageSec < 86400
|
|
537
|
+
? `${Math.round(ageSec / 3600)}h`
|
|
538
|
+
: `${Math.round(ageSec / 86400)}d`;
|
|
435
539
|
|
|
436
540
|
// Framing header: `UserPromptSubmit` hook writes this block to stdout, which
|
|
437
541
|
// Claude Code surfaces alongside the real user prompt. Without an explicit
|
|
@@ -452,7 +556,13 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
452
556
|
lines.push('## Working On', neutralizeContextDelimiters(handoff.working_on), '');
|
|
453
557
|
}
|
|
454
558
|
if (handoff.completed) {
|
|
455
|
-
lines.push(
|
|
559
|
+
lines.push(
|
|
560
|
+
'## Completed',
|
|
561
|
+
...neutralizeContextDelimiters(handoff.completed)
|
|
562
|
+
.split('\n')
|
|
563
|
+
.map((l) => `- ${l}`),
|
|
564
|
+
'',
|
|
565
|
+
);
|
|
456
566
|
}
|
|
457
567
|
if (handoff.unfinished) {
|
|
458
568
|
// Extract only the pending-work portion (before narrative history separator).
|
|
@@ -461,7 +571,13 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
461
571
|
// completeness claim the episode buffer can't support.
|
|
462
572
|
const pending = extractUnfinishedSummary(handoff.unfinished);
|
|
463
573
|
if (pending) {
|
|
464
|
-
lines.push(
|
|
574
|
+
lines.push(
|
|
575
|
+
'## Recent activity',
|
|
576
|
+
...neutralizeContextDelimiters(pending)
|
|
577
|
+
.split('; ')
|
|
578
|
+
.map((l) => `- ${l}`),
|
|
579
|
+
'',
|
|
580
|
+
);
|
|
465
581
|
}
|
|
466
582
|
}
|
|
467
583
|
if (handoff.key_files) {
|
|
@@ -470,11 +586,18 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
470
586
|
// Defang basenames too: a filename on disk can contain a literal authority tag
|
|
471
587
|
// (Linux allows almost any char but '/'), and this is the one field in this block
|
|
472
588
|
// that was rendered raw while working_on/unfinished/key_decisions all neutralize.
|
|
473
|
-
if (files.length > 0)
|
|
589
|
+
if (files.length > 0)
|
|
590
|
+
lines.push('## Key Files', neutralizeContextDelimiters(files.map((f) => basename(f)).join(', ')), '');
|
|
474
591
|
} catch {}
|
|
475
592
|
}
|
|
476
593
|
if (handoff.key_decisions) {
|
|
477
|
-
lines.push(
|
|
594
|
+
lines.push(
|
|
595
|
+
'## Key Decisions',
|
|
596
|
+
...neutralizeContextDelimiters(handoff.key_decisions)
|
|
597
|
+
.split('\n')
|
|
598
|
+
.map((l) => `- ${l}`),
|
|
599
|
+
'',
|
|
600
|
+
);
|
|
478
601
|
}
|
|
479
602
|
|
|
480
603
|
lines.push('</session-handoff>');
|
|
@@ -488,21 +611,29 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
488
611
|
// ids align — legacy rows + tests), then fall back to the most-recent summary for the
|
|
489
612
|
// project, which at resume time is the summary from the session that wrote this handoff.
|
|
490
613
|
try {
|
|
491
|
-
let summary = db
|
|
614
|
+
let summary = db
|
|
615
|
+
.prepare(
|
|
616
|
+
`
|
|
492
617
|
SELECT completed, next_steps, remaining_items FROM session_summaries
|
|
493
618
|
WHERE memory_session_id = ? AND project = ?
|
|
494
619
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
495
|
-
|
|
620
|
+
`,
|
|
621
|
+
)
|
|
622
|
+
.get(handoff.session_id, project);
|
|
496
623
|
if (!summary) {
|
|
497
624
|
// Pick the project summary CLOSEST IN TIME to this handoff, not merely the newest:
|
|
498
625
|
// a handoff and its own session's summary are written within ms of each other at
|
|
499
626
|
// session end, so nearest-timestamp recovers the right session even when a different
|
|
500
627
|
// session later wrote a newer summary for the same project (concurrent/interleaved use).
|
|
501
|
-
summary = db
|
|
628
|
+
summary = db
|
|
629
|
+
.prepare(
|
|
630
|
+
`
|
|
502
631
|
SELECT completed, next_steps, remaining_items FROM session_summaries
|
|
503
632
|
WHERE project = ?
|
|
504
633
|
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
505
|
-
|
|
634
|
+
`,
|
|
635
|
+
)
|
|
636
|
+
.get(project, handoff.created_at_epoch ?? 0);
|
|
506
637
|
}
|
|
507
638
|
if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
|
|
508
639
|
lines.push('');
|
|
@@ -511,7 +642,8 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
511
642
|
// extractStructuredSummary over the assistant transcript tail — replayed text that can
|
|
512
643
|
// carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
|
|
513
644
|
if (summary.completed) lines.push(neutralizeContextDelimiters(summary.completed));
|
|
514
|
-
if (summary.remaining_items)
|
|
645
|
+
if (summary.remaining_items)
|
|
646
|
+
lines.push(`Remaining: ${neutralizeContextDelimiters(summary.remaining_items)}`);
|
|
515
647
|
if (summary.next_steps) lines.push(`Next steps: ${neutralizeContextDelimiters(summary.next_steps)}`);
|
|
516
648
|
lines.push('</session-summary>');
|
|
517
649
|
}
|