clawmem 0.7.2 → 0.8.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/AGENTS.md +12 -3
- package/CLAUDE.md +12 -3
- package/README.md +29 -0
- package/SKILL.md +10 -3
- package/package.json +1 -1
- package/src/consolidation.ts +146 -16
- package/src/hooks/context-surfacing.ts +160 -16
- package/src/hooks.ts +9 -1
- package/src/maintenance.ts +540 -0
- package/src/mcp.ts +34 -0
- package/src/store.ts +96 -6
- package/src/worker-lease.ts +141 -0
|
@@ -69,6 +69,19 @@ const INSTRUCTION_TOKEN_COST = estimateTokens(INSTRUCTION_XML);
|
|
|
69
69
|
const RELATIONSHIPS_XML_OVERHEAD_TOKENS = estimateTokens("<relationships>\n\n</relationships>");
|
|
70
70
|
const MAX_RELATION_SNIPPETS = 10;
|
|
71
71
|
|
|
72
|
+
// Ext 6b: Multi-turn prior-query lookback
|
|
73
|
+
// The retrieval query is built from the current prompt plus up to
|
|
74
|
+
// MULTI_TURN_LOOKBACK recent same-session prior prompts within
|
|
75
|
+
// MULTI_TURN_MAX_AGE_MINUTES. The combined query is clamped to
|
|
76
|
+
// MULTI_TURN_MAX_CHARS with newest content preserved first — so the
|
|
77
|
+
// current prompt is always the first N chars even when older priors
|
|
78
|
+
// would otherwise push it out. All other hook signals (scoring,
|
|
79
|
+
// composite recency intent, recall attribution, routing hints)
|
|
80
|
+
// continue to use the raw current prompt.
|
|
81
|
+
const MULTI_TURN_LOOKBACK = 2;
|
|
82
|
+
const MULTI_TURN_MAX_AGE_MINUTES = 10;
|
|
83
|
+
const MULTI_TURN_MAX_CHARS = 2000;
|
|
84
|
+
|
|
72
85
|
// File path patterns to extract from prompts (E13 replacement: file-aware UserPromptSubmit)
|
|
73
86
|
const FILE_PATH_RE = /(?:^|\s)((?:\/[\w.@-]+)+(?:\.\w+)?|[\w.@-]+\.(?:ts|js|py|md|sh|yaml|yml|json|toml|rs|go|tsx|jsx|css|html))\b/g;
|
|
74
87
|
|
|
@@ -133,12 +146,25 @@ export async function contextSurfacing(
|
|
|
133
146
|
const isRecency = hasRecencyIntent(prompt);
|
|
134
147
|
const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
|
|
135
148
|
|
|
149
|
+
// Ext 6b: Build the retrieval query from the current prompt plus up to
|
|
150
|
+
// MULTI_TURN_LOOKBACK recent same-session prior prompts. Used only for
|
|
151
|
+
// the discovery path (vector, FTS, query expansion, reranking) so that
|
|
152
|
+
// a short "do that" / "same for X" turn can inherit the vocabulary of
|
|
153
|
+
// earlier turns. All other prompt-dependent signals (recency intent,
|
|
154
|
+
// composite scoring, recall attribution, snippet highlighting, routing
|
|
155
|
+
// hints, dedupe, heartbeat check) continue to use the raw current
|
|
156
|
+
// prompt. If the session has no priors in the window, the helper
|
|
157
|
+
// returns the current prompt unchanged.
|
|
158
|
+
const retrievalQuery = input.sessionId
|
|
159
|
+
? buildMultiTurnSurfacingQuery(store, input.sessionId, prompt)
|
|
160
|
+
: prompt;
|
|
161
|
+
|
|
136
162
|
// Search: try vector first (if profile allows), fall back to BM25
|
|
137
163
|
// When vector succeeds, also supplement with FTS for keyword-exact recall
|
|
138
164
|
let results: SearchResult[] = [];
|
|
139
165
|
if (profile.useVector) {
|
|
140
166
|
try {
|
|
141
|
-
const vectorPromise = store.searchVec(
|
|
167
|
+
const vectorPromise = store.searchVec(retrievalQuery, DEFAULT_EMBED_MODEL, maxResults);
|
|
142
168
|
const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
|
|
143
169
|
setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout)
|
|
144
170
|
);
|
|
@@ -149,11 +175,11 @@ export async function contextSurfacing(
|
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
if (results.length === 0) {
|
|
152
|
-
results = store.searchFTS(
|
|
178
|
+
results = store.searchFTS(retrievalQuery, maxResults);
|
|
153
179
|
} else {
|
|
154
180
|
// Supplement vector results with FTS for keyword-exact matches (<10ms)
|
|
155
181
|
const seen = new Set(results.map(r => r.filepath));
|
|
156
|
-
const ftsSupplemental = store.searchFTS(
|
|
182
|
+
const ftsSupplemental = store.searchFTS(retrievalQuery, 5);
|
|
157
183
|
for (const r of ftsSupplemental) {
|
|
158
184
|
if (!seen.has(r.filepath)) {
|
|
159
185
|
seen.add(r.filepath);
|
|
@@ -166,7 +192,7 @@ export async function contextSurfacing(
|
|
|
166
192
|
if (getVaultPath("skill")) {
|
|
167
193
|
try {
|
|
168
194
|
const skillStore = resolveStore("skill");
|
|
169
|
-
const skillResults = skillStore.searchFTS(
|
|
195
|
+
const skillResults = skillStore.searchFTS(retrievalQuery, 5);
|
|
170
196
|
// Tag skill vault results for identification in output
|
|
171
197
|
for (const r of skillResults) {
|
|
172
198
|
(r as any)._fromVault = "skill";
|
|
@@ -178,7 +204,9 @@ export async function contextSurfacing(
|
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
// File-aware supplemental search (E13 replacement): extract file paths/names from prompt
|
|
181
|
-
// and run targeted FTS queries to surface file-specific vault context
|
|
207
|
+
// and run targeted FTS queries to surface file-specific vault context.
|
|
208
|
+
// File-path extraction stays on the raw current prompt so priors cannot
|
|
209
|
+
// pollute the file-specific discovery channel with stale filenames.
|
|
182
210
|
const fileMatches = [...prompt.matchAll(FILE_PATH_RE)].map(m => m[1]!.trim()).filter(Boolean);
|
|
183
211
|
if (fileMatches.length > 0) {
|
|
184
212
|
const seen = new Set(results.map(r => r.filepath));
|
|
@@ -195,17 +223,23 @@ export async function contextSurfacing(
|
|
|
195
223
|
}
|
|
196
224
|
}
|
|
197
225
|
|
|
198
|
-
if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
|
|
226
|
+
if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
|
|
199
227
|
|
|
200
228
|
// Budget-aware deep escalation (deep profile only):
|
|
201
229
|
// If the fast path finished quickly and found results, spend remaining time budget
|
|
202
230
|
// on query expansion (discovers new candidates) and cross-encoder reranking (reorders).
|
|
231
|
+
// Ext 6b: expansion + FTS variants use the multi-turn retrieval query so
|
|
232
|
+
// short current prompts still inherit prior-turn vocabulary. Reranking
|
|
233
|
+
// continues to use the RAW current prompt so relevance scoring is not
|
|
234
|
+
// diluted by older turns — the cross-encoder is asked "how well does
|
|
235
|
+
// this doc match the user's current question", not "how well does it
|
|
236
|
+
// match the last 10 minutes of questions".
|
|
203
237
|
if (profile.deepEscalation && results.length >= 2) {
|
|
204
238
|
const elapsed = Date.now() - startTime;
|
|
205
239
|
if (elapsed < profile.escalationBudgetMs) {
|
|
206
240
|
try {
|
|
207
241
|
// Phase 1: Query expansion — discover candidates BM25+vector missed
|
|
208
|
-
const expanded = await store.expandQuery(
|
|
242
|
+
const expanded = await store.expandQuery(retrievalQuery, DEFAULT_QUERY_MODEL);
|
|
209
243
|
if (expanded.length > 0) {
|
|
210
244
|
const seen = new Set(results.map(r => r.filepath));
|
|
211
245
|
for (const eq of expanded.slice(0, 3)) {
|
|
@@ -253,7 +287,7 @@ export async function contextSurfacing(
|
|
|
253
287
|
!FILTERED_PATHS.some(p => r.displayPath.includes(p))
|
|
254
288
|
);
|
|
255
289
|
|
|
256
|
-
if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
|
|
290
|
+
if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
|
|
257
291
|
|
|
258
292
|
// Filter out snoozed documents
|
|
259
293
|
const now = new Date();
|
|
@@ -269,7 +303,7 @@ export async function contextSurfacing(
|
|
|
269
303
|
return true;
|
|
270
304
|
});
|
|
271
305
|
|
|
272
|
-
if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
|
|
306
|
+
if (results.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
|
|
273
307
|
|
|
274
308
|
// Deduplicate by filepath (keep best score per path)
|
|
275
309
|
const deduped = new Map<string, SearchResult>();
|
|
@@ -311,7 +345,7 @@ export async function contextSurfacing(
|
|
|
311
345
|
: 0;
|
|
312
346
|
|
|
313
347
|
// Activation floor: if even the best result is too weak, bail entirely
|
|
314
|
-
if (bestScore < profile.activationFloor) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
|
|
348
|
+
if (bestScore < profile.activationFloor) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
|
|
315
349
|
|
|
316
350
|
const adaptiveMin = Math.max(bestScore * profile.minScoreRatio, profile.absoluteFloor);
|
|
317
351
|
scored = allScored.filter(r => r.compositeScore >= adaptiveMin);
|
|
@@ -320,7 +354,7 @@ export async function contextSurfacing(
|
|
|
320
354
|
scored = allScored.filter(r => r.compositeScore >= minScore);
|
|
321
355
|
}
|
|
322
356
|
|
|
323
|
-
if (scored.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
|
|
357
|
+
if (scored.length === 0) { logEmptyTurn(store, input, prompt); return makeEmptyOutput("context-surfacing"); }
|
|
324
358
|
|
|
325
359
|
// Spreading activation (E11): boost results co-activated with top HOT results
|
|
326
360
|
if (scored.length > 3) {
|
|
@@ -369,7 +403,7 @@ export async function contextSurfacing(
|
|
|
369
403
|
const { context, paths, tokens } = buildContext(scored, prompt, factsBudget);
|
|
370
404
|
|
|
371
405
|
if (!context) {
|
|
372
|
-
logEmptyTurn(store, input);
|
|
406
|
+
logEmptyTurn(store, input, prompt);
|
|
373
407
|
return makeEmptyOutput("context-surfacing");
|
|
374
408
|
}
|
|
375
409
|
|
|
@@ -377,8 +411,10 @@ export async function contextSurfacing(
|
|
|
377
411
|
if (input.sessionId) {
|
|
378
412
|
const turnIndex = (input as any)._turnIndex ?? 0;
|
|
379
413
|
|
|
380
|
-
// Log the injection — returns usage_id for recall event linkage
|
|
381
|
-
|
|
414
|
+
// Log the injection — returns usage_id for recall event linkage.
|
|
415
|
+
// Ext 6b: persist the raw prompt as query_text so future turns in
|
|
416
|
+
// the same session can reconstitute a multi-turn retrieval query.
|
|
417
|
+
const usageId = logInjection(store, input.sessionId, "context-surfacing", paths, tokens, turnIndex, prompt);
|
|
382
418
|
|
|
383
419
|
// Record recall events ONLY for docs that made it into the injected context
|
|
384
420
|
// (post-budget). Docs trimmed by token budget were never seen by the model.
|
|
@@ -469,12 +505,21 @@ export async function contextSurfacing(
|
|
|
469
505
|
* Log an empty context_usage row for a skipped turn.
|
|
470
506
|
* Keeps turn_index aligned with transcript turns so per-turn recall
|
|
471
507
|
* attribution doesn't drift when some prompts are gated.
|
|
508
|
+
*
|
|
509
|
+
* Ext 6b: `queryText` is optional. Callers that gated BEFORE the
|
|
510
|
+
* retrieval stage (slash commands, heartbeat dedupe, too-short prompts,
|
|
511
|
+
* `shouldSkipRetrieval`) pass nothing — those turns are not meaningful
|
|
512
|
+
* user questions and their raw text is not worth persisting for future
|
|
513
|
+
* multi-turn lookback. Callers that gated AFTER retrieval (empty result
|
|
514
|
+
* set, threshold filter, budget) pass the prompt so a follow-up turn
|
|
515
|
+
* can still reuse the intent even though the current turn surfaced
|
|
516
|
+
* nothing.
|
|
472
517
|
*/
|
|
473
|
-
function logEmptyTurn(store: Store, input: HookInput): void {
|
|
518
|
+
function logEmptyTurn(store: Store, input: HookInput, queryText?: string): void {
|
|
474
519
|
if (!input.sessionId) return;
|
|
475
520
|
try {
|
|
476
521
|
const turnIndex = (input as any)._turnIndex ?? 0;
|
|
477
|
-
logInjection(store, input.sessionId, "context-surfacing", [], 0, turnIndex);
|
|
522
|
+
logInjection(store, input.sessionId, "context-surfacing", [], 0, turnIndex, queryText);
|
|
478
523
|
} catch { /* non-fatal */ }
|
|
479
524
|
}
|
|
480
525
|
|
|
@@ -700,6 +745,105 @@ export function buildVaultContextInner(
|
|
|
700
745
|
return lines.join("\n");
|
|
701
746
|
}
|
|
702
747
|
|
|
748
|
+
// =============================================================================
|
|
749
|
+
// Ext 6b: Multi-turn prior-query lookback
|
|
750
|
+
// =============================================================================
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Build the retrieval query from the current prompt plus up to `lookback`
|
|
754
|
+
* recent prior prompts from the same session within `maxAgeMinutes`.
|
|
755
|
+
*
|
|
756
|
+
* Returns the current prompt unchanged when:
|
|
757
|
+
* - no `sessionId` (nothing to scope by)
|
|
758
|
+
* - the `query_text` column is missing (pre-migration store)
|
|
759
|
+
* - no prior rows within the window / all NULL
|
|
760
|
+
* - any DB error (fail-open — never throws)
|
|
761
|
+
*
|
|
762
|
+
* The combined query format is
|
|
763
|
+
* `<current>\n\n<newest prior>\n\n<older prior>...`
|
|
764
|
+
* truncated to `MULTI_TURN_MAX_CHARS` with **current content preserved
|
|
765
|
+
* first** — so even when older priors would push the current prompt
|
|
766
|
+
* past the char limit, the truncation drops the tail (older priors),
|
|
767
|
+
* not the head. This guarantees the retrieval query always contains the
|
|
768
|
+
* user's current question verbatim.
|
|
769
|
+
*
|
|
770
|
+
* Exported for direct unit testing.
|
|
771
|
+
*/
|
|
772
|
+
export function buildMultiTurnSurfacingQuery(
|
|
773
|
+
store: Store,
|
|
774
|
+
sessionId: string,
|
|
775
|
+
currentQuery: string,
|
|
776
|
+
lookback: number = MULTI_TURN_LOOKBACK,
|
|
777
|
+
maxAgeMinutes: number = MULTI_TURN_MAX_AGE_MINUTES,
|
|
778
|
+
maxChars: number = MULTI_TURN_MAX_CHARS,
|
|
779
|
+
): string {
|
|
780
|
+
if (!sessionId || currentQuery.length === 0) return currentQuery;
|
|
781
|
+
|
|
782
|
+
let priors: string[] = [];
|
|
783
|
+
try {
|
|
784
|
+
// ISO 8601 cutoff computed in JS (same lesson as the v0.8.0
|
|
785
|
+
// countRecentContextUsages fix — datetime('now', ...) returns a
|
|
786
|
+
// space-separated string that sorts incorrectly against the
|
|
787
|
+
// T-separated ISO 8601 timestamps stored in context_usage).
|
|
788
|
+
const cutoff = new Date(Date.now() - maxAgeMinutes * 60 * 1000).toISOString();
|
|
789
|
+
// Self-match guard lives in SQL so a duplicate submit/retry cannot eat
|
|
790
|
+
// into the lookback budget. Turn 18 review found that filtering in
|
|
791
|
+
// application code with `LIMIT lookback + 1` under-fills when multiple
|
|
792
|
+
// prior rows carry the same text as the current prompt — the SELECT
|
|
793
|
+
// returned only `lookback + 1` rows and application-level skipping
|
|
794
|
+
// then dropped legitimate distinct priors along with the dupes.
|
|
795
|
+
// Pushing the inequality into WHERE means every returned row is a
|
|
796
|
+
// valid non-self prior and the LIMIT == lookback fits exactly.
|
|
797
|
+
const rows = store.db.prepare(
|
|
798
|
+
`SELECT query_text FROM context_usage
|
|
799
|
+
WHERE session_id = ?
|
|
800
|
+
AND hook_name = 'context-surfacing'
|
|
801
|
+
AND timestamp > ?
|
|
802
|
+
AND query_text IS NOT NULL
|
|
803
|
+
AND query_text != ''
|
|
804
|
+
AND query_text != ?
|
|
805
|
+
ORDER BY id DESC
|
|
806
|
+
LIMIT ?`,
|
|
807
|
+
).all(sessionId, cutoff, currentQuery, lookback) as { query_text: string }[];
|
|
808
|
+
|
|
809
|
+
for (const row of rows) {
|
|
810
|
+
if (!row.query_text) continue;
|
|
811
|
+
priors.push(row.query_text);
|
|
812
|
+
}
|
|
813
|
+
} catch {
|
|
814
|
+
// query_text column may be missing on a pre-migration store, or
|
|
815
|
+
// the DB might be in a corrupted state — fall back to current-only.
|
|
816
|
+
return currentQuery;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (priors.length === 0) return currentQuery;
|
|
820
|
+
|
|
821
|
+
// Assemble newest-first: current first, then newest prior, then older.
|
|
822
|
+
// The SQL already ordered rows DESC by id, so `priors[0]` is the newest.
|
|
823
|
+
const segments = [currentQuery, ...priors];
|
|
824
|
+
const combined = segments.join("\n\n");
|
|
825
|
+
|
|
826
|
+
if (combined.length <= maxChars) return combined;
|
|
827
|
+
|
|
828
|
+
// Over budget. Current query ALWAYS wins — include the full current
|
|
829
|
+
// prompt first, then add priors newest-first until the budget runs out.
|
|
830
|
+
// If the current prompt alone is already over budget, return it
|
|
831
|
+
// truncated (same as pre-v0.8.1 behavior — MAX_QUERY_LENGTH is
|
|
832
|
+
// enforced earlier in the handler so this branch is rare).
|
|
833
|
+
if (currentQuery.length >= maxChars) return currentQuery.slice(0, maxChars);
|
|
834
|
+
|
|
835
|
+
const parts: string[] = [currentQuery];
|
|
836
|
+
let used = currentQuery.length;
|
|
837
|
+
const separator = "\n\n";
|
|
838
|
+
for (const prior of priors) {
|
|
839
|
+
const cost = separator.length + prior.length;
|
|
840
|
+
if (used + cost > maxChars) break;
|
|
841
|
+
parts.push(prior);
|
|
842
|
+
used += cost;
|
|
843
|
+
}
|
|
844
|
+
return parts.join(separator);
|
|
845
|
+
}
|
|
846
|
+
|
|
703
847
|
/**
|
|
704
848
|
* Check if the agent should be nudged to use lifecycle tools.
|
|
705
849
|
* Returns true if N+ context-surfacing invocations have occurred since the
|
package/src/hooks.ts
CHANGED
|
@@ -379,6 +379,12 @@ export function smartTruncate(text: string, maxChars: number = 300): string {
|
|
|
379
379
|
|
|
380
380
|
/**
|
|
381
381
|
* Log a context injection to the usage tracking table.
|
|
382
|
+
*
|
|
383
|
+
* `queryText` (v0.8.1 Ext 6b) is the raw prompt for this turn. Persisted
|
|
384
|
+
* only when the caller passes it — logEmptyTurn-style skip paths omit it
|
|
385
|
+
* so gated turns (slash commands, heartbeats, noise) cannot leak raw
|
|
386
|
+
* prompt text into `context_usage.query_text`. Pre-migration stores
|
|
387
|
+
* transparently drop the column via `insertUsageFn`'s feature-detect.
|
|
382
388
|
*/
|
|
383
389
|
export function logInjection(
|
|
384
390
|
store: Store,
|
|
@@ -386,7 +392,8 @@ export function logInjection(
|
|
|
386
392
|
hookName: string,
|
|
387
393
|
injectedPaths: string[],
|
|
388
394
|
estimatedTokens: number,
|
|
389
|
-
turnIndex?: number
|
|
395
|
+
turnIndex?: number,
|
|
396
|
+
queryText?: string
|
|
390
397
|
): number {
|
|
391
398
|
try {
|
|
392
399
|
const usageId = store.insertUsage({
|
|
@@ -397,6 +404,7 @@ export function logInjection(
|
|
|
397
404
|
estimatedTokens,
|
|
398
405
|
wasReferenced: 0,
|
|
399
406
|
turnIndex,
|
|
407
|
+
queryText,
|
|
400
408
|
});
|
|
401
409
|
|
|
402
410
|
// Record co-activation for all injected paths (E3)
|