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-llm.mjs
CHANGED
|
@@ -4,20 +4,47 @@
|
|
|
4
4
|
import { basename, join } from 'path';
|
|
5
5
|
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
6
6
|
import {
|
|
7
|
-
jaccardSimilarity,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
jaccardSimilarity,
|
|
8
|
+
truncate,
|
|
9
|
+
clampImportance,
|
|
10
|
+
computeRuleImportance,
|
|
11
|
+
inferProject,
|
|
12
|
+
parseJsonFromLLM,
|
|
13
|
+
scrubSecrets,
|
|
14
|
+
computeMinHash,
|
|
15
|
+
estimateJaccardFromMinHash,
|
|
16
|
+
cjkBigrams,
|
|
17
|
+
EDIT_TOOLS,
|
|
18
|
+
LOW_SIGNAL_TITLE,
|
|
19
|
+
debugCatch,
|
|
20
|
+
debugLog,
|
|
21
|
+
OBS_BM25,
|
|
22
|
+
getCurrentBranch,
|
|
23
|
+
notLowSignalTitleClause,
|
|
11
24
|
} from './utils.mjs';
|
|
12
25
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
26
|
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
14
27
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
15
28
|
import { vecTextForRow } from './tfidf.mjs';
|
|
16
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
insertObservationRow,
|
|
31
|
+
insertObservationFiles,
|
|
32
|
+
insertObservationVector,
|
|
33
|
+
upsertObservationVector,
|
|
34
|
+
normalizeScope,
|
|
35
|
+
SCOPE_PROMPT_LEGEND,
|
|
36
|
+
} from './lib/observation-write.mjs';
|
|
17
37
|
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
18
38
|
import {
|
|
19
|
-
RUNTIME_DIR,
|
|
20
|
-
|
|
39
|
+
RUNTIME_DIR,
|
|
40
|
+
DEDUP_WINDOW_MS,
|
|
41
|
+
RELATED_OBS_WINDOW_MS,
|
|
42
|
+
ORPHAN_EPISODE_AGE_MS,
|
|
43
|
+
sessionFile,
|
|
44
|
+
getSessionId,
|
|
45
|
+
openDb,
|
|
46
|
+
callLLM,
|
|
47
|
+
sleep,
|
|
21
48
|
} from './hook-shared.mjs';
|
|
22
49
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
23
50
|
import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
|
|
@@ -56,9 +83,7 @@ export function retractPreSavedObs(db, obsId, where) {
|
|
|
56
83
|
// either; nesting under persistHaikuSummary's transaction is safe (better-sqlite3 uses
|
|
57
84
|
// savepoints).
|
|
58
85
|
return db.transaction(() => {
|
|
59
|
-
const live = db.prepare(
|
|
60
|
-
`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
61
|
-
).get(obsId);
|
|
86
|
+
const live = db.prepare(`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`).get(obsId);
|
|
62
87
|
if (!live) {
|
|
63
88
|
debugLog('DEBUG', 'llm-episode', `${where}: pre-saved obs #${obsId} no longer live — left in place`);
|
|
64
89
|
return false;
|
|
@@ -119,13 +144,15 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
|
|
|
119
144
|
// IGNORE and the UPDATE; ON CONFLICT collapses this to one statement
|
|
120
145
|
// that runs entirely under the writer lock with no observable middle
|
|
121
146
|
// state. SQLite ≥3.24 supports the syntax (better-sqlite3 ships ≥3.30).
|
|
122
|
-
db.prepare(
|
|
147
|
+
db.prepare(
|
|
148
|
+
`
|
|
123
149
|
INSERT INTO lesson_retry_stats (date_bucket, attempts, recovered)
|
|
124
150
|
VALUES (?, 1, ?)
|
|
125
151
|
ON CONFLICT(date_bucket) DO UPDATE SET
|
|
126
152
|
attempts = attempts + 1,
|
|
127
153
|
recovered = recovered + excluded.recovered
|
|
128
|
-
|
|
154
|
+
`,
|
|
155
|
+
).run(bucket, recovered ? 1 : 0);
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
/**
|
|
@@ -135,10 +162,12 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
|
|
|
135
162
|
*/
|
|
136
163
|
export function readRetryStats(db, days = 30) {
|
|
137
164
|
const cutoff = new Date(Date.now() - days * DAY_MS);
|
|
138
|
-
return db
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
return db
|
|
166
|
+
.prepare(
|
|
167
|
+
`SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
|
|
168
|
+
WHERE date_bucket >= ? ORDER BY date_bucket DESC`,
|
|
169
|
+
)
|
|
170
|
+
.all(dateBucketUtc(cutoff));
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
// ─── Save Observation to DB ─────────────────────────────────────────────────
|
|
@@ -156,18 +185,29 @@ function buildFtsTextField(obs) {
|
|
|
156
185
|
if (!conceptsText && !factsText && !aliasesText) {
|
|
157
186
|
const raw = (obs.title || '') + ' ' + (obs.narrative || '');
|
|
158
187
|
// Extract file basenames (without extension) as searchable terms
|
|
159
|
-
const fileNames = [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
188
|
+
const fileNames = [
|
|
189
|
+
...new Set(
|
|
190
|
+
[
|
|
191
|
+
...raw.matchAll(
|
|
192
|
+
/\b([\w.-]+\.(?:mjs|js|ts|tsx|jsx|py|rs|go|vue|css|html|json|yaml|yml|md|sh|sql|toml|cfg))\b/g,
|
|
193
|
+
),
|
|
194
|
+
].map((m) => m[1].replace(/\.[^.]+$/, '')),
|
|
195
|
+
),
|
|
196
|
+
];
|
|
163
197
|
// Extract error keywords from "→ ERROR: ..." patterns
|
|
164
|
-
const errorTerms = raw
|
|
165
|
-
.
|
|
166
|
-
.
|
|
198
|
+
const errorTerms = raw
|
|
199
|
+
.split(/→ ERROR[: ]+/)
|
|
200
|
+
.slice(1)
|
|
201
|
+
.map((s) => s.split(/[;{[\]"\\|→\n]/)[0].trim())
|
|
202
|
+
.filter((t) => t.length >= 4 && t.length <= 50);
|
|
167
203
|
fallbackText = [...fileNames, ...errorTerms].join(' ');
|
|
168
204
|
}
|
|
169
205
|
|
|
170
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
conceptsText,
|
|
208
|
+
factsText,
|
|
209
|
+
textField: [conceptsText, factsText, aliasesText, bigramText, fallbackText].filter(Boolean).join(' '),
|
|
210
|
+
};
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
// TF-IDF vector text. Must mirror the FTS-searchable content so the vector arm and
|
|
@@ -178,8 +218,11 @@ export function buildVecText(obs) {
|
|
|
178
218
|
// Single source (V-F1): map the camelCase obs onto vecTextForRow's row shape so save and
|
|
179
219
|
// every rebuild path encode the identical field set (title/narrative/concepts/lesson/aliases).
|
|
180
220
|
return vecTextForRow({
|
|
181
|
-
title: obs.title,
|
|
182
|
-
|
|
221
|
+
title: obs.title,
|
|
222
|
+
narrative: obs.narrative,
|
|
223
|
+
concepts: obs.concepts,
|
|
224
|
+
lesson_learned: obs.lessonLearned,
|
|
225
|
+
search_aliases: obs.searchAliases,
|
|
183
226
|
});
|
|
184
227
|
}
|
|
185
228
|
|
|
@@ -197,10 +240,12 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
197
240
|
const project = projectOverride || inferProject();
|
|
198
241
|
const sessionId = sessionIdOverride || getSessionId();
|
|
199
242
|
|
|
200
|
-
db.prepare(
|
|
243
|
+
db.prepare(
|
|
244
|
+
`
|
|
201
245
|
INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
202
246
|
VALUES (?, ?, ?, ?, ?, 'active')
|
|
203
|
-
|
|
247
|
+
`,
|
|
248
|
+
).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
204
249
|
|
|
205
250
|
// P0: write-side noise block — LOW_SIGNAL title with no recoverable signal
|
|
206
251
|
// (no lesson, importance<2, empty facts, thin narrative) is dropped before
|
|
@@ -232,20 +277,28 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
232
277
|
// enter the 7-day accelerated auto-compress window in hook.mjs.
|
|
233
278
|
const capped = capNoiseImportance(obs);
|
|
234
279
|
if (capped !== (obs.importance ?? 1)) {
|
|
235
|
-
debugLog(
|
|
280
|
+
debugLog(
|
|
281
|
+
'DEBUG',
|
|
282
|
+
'saveObservation',
|
|
283
|
+
`capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`,
|
|
284
|
+
);
|
|
236
285
|
obs.importance = capped;
|
|
237
286
|
}
|
|
238
287
|
|
|
239
288
|
// Three-tier dedup — returns null (not throw) for dedup hits
|
|
240
289
|
// Tier 1 (fast): 5-min Jaccard on titles
|
|
241
290
|
const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
|
|
242
|
-
const recent = db
|
|
291
|
+
const recent = db
|
|
292
|
+
.prepare(
|
|
293
|
+
`
|
|
243
294
|
SELECT title FROM observations
|
|
244
295
|
WHERE project = ? AND created_at_epoch > ?
|
|
245
296
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
246
|
-
|
|
297
|
+
`,
|
|
298
|
+
)
|
|
299
|
+
.all(project, fiveMinAgo);
|
|
247
300
|
|
|
248
|
-
if (obs.title && recent.some(r => jaccardSimilarity(r.title, obs.title) > DEDUP_JACCARD_THRESHOLD)) {
|
|
301
|
+
if (obs.title && recent.some((r) => jaccardSimilarity(r.title, obs.title) > DEDUP_JACCARD_THRESHOLD)) {
|
|
249
302
|
return null; // dedup: Jaccard title match
|
|
250
303
|
}
|
|
251
304
|
|
|
@@ -258,19 +311,27 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
258
311
|
const sevenDaysAgo = now.getTime() - 7 * DAY_MS;
|
|
259
312
|
const threeDaysAgo = now.getTime() - 3 * DAY_MS;
|
|
260
313
|
// Phase 1: exact title match within 7 days
|
|
261
|
-
const exactDup = db
|
|
314
|
+
const exactDup = db
|
|
315
|
+
.prepare(
|
|
316
|
+
`
|
|
262
317
|
SELECT 1 FROM observations
|
|
263
318
|
WHERE project = ? AND title = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
264
319
|
LIMIT 1
|
|
265
|
-
|
|
320
|
+
`,
|
|
321
|
+
)
|
|
322
|
+
.get(project, obs.title, sevenDaysAgo, fiveMinAgo);
|
|
266
323
|
if (exactDup) return null; // dedup: exact title match
|
|
267
324
|
// Phase 2: Jaccard similarity for near-duplicates (3-day window)
|
|
268
|
-
const extRecent = db
|
|
325
|
+
const extRecent = db
|
|
326
|
+
.prepare(
|
|
327
|
+
`
|
|
269
328
|
SELECT title FROM observations
|
|
270
329
|
WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
271
330
|
ORDER BY created_at_epoch DESC LIMIT 60
|
|
272
|
-
|
|
273
|
-
|
|
331
|
+
`,
|
|
332
|
+
)
|
|
333
|
+
.all(project, threeDaysAgo, fiveMinAgo);
|
|
334
|
+
if (extRecent.some((r) => jaccardSimilarity(r.title, obs.title) > AUTO_MERGE_THRESHOLD)) {
|
|
274
335
|
return null; // dedup: low-signal Jaccard match (stricter cutoff for degraded titles)
|
|
275
336
|
}
|
|
276
337
|
}
|
|
@@ -279,13 +340,17 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
279
340
|
const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
|
|
280
341
|
if (minhashSig) {
|
|
281
342
|
const sevenDaysAgo = now.getTime() - RELATED_OBS_WINDOW_MS;
|
|
282
|
-
const recentSigs = db
|
|
343
|
+
const recentSigs = db
|
|
344
|
+
.prepare(
|
|
345
|
+
`
|
|
283
346
|
SELECT minhash_sig FROM observations
|
|
284
347
|
WHERE project = ? AND created_at_epoch > ? AND minhash_sig IS NOT NULL
|
|
285
348
|
ORDER BY created_at_epoch DESC LIMIT 200
|
|
286
|
-
|
|
349
|
+
`,
|
|
350
|
+
)
|
|
351
|
+
.all(project, sevenDaysAgo);
|
|
287
352
|
|
|
288
|
-
if (recentSigs.some(r => estimateJaccardFromMinHash(minhashSig, r.minhash_sig) > 0.8)) {
|
|
353
|
+
if (recentSigs.some((r) => estimateJaccardFromMinHash(minhashSig, r.minhash_sig) > 0.8)) {
|
|
289
354
|
return null; // dedup: MinHash similarity match
|
|
290
355
|
}
|
|
291
356
|
}
|
|
@@ -309,14 +374,24 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
309
374
|
// Column list single-sourced in lib/observation-write (shared with manual mem_save).
|
|
310
375
|
const savedId = db.transaction(() => {
|
|
311
376
|
const id = insertObservationRow(db, {
|
|
312
|
-
memory_session_id: sessionId,
|
|
313
|
-
|
|
314
|
-
|
|
377
|
+
memory_session_id: sessionId,
|
|
378
|
+
project,
|
|
379
|
+
text: safe.text,
|
|
380
|
+
type: obs.type,
|
|
381
|
+
title: safe.title,
|
|
382
|
+
subtitle: safe.subtitle,
|
|
383
|
+
narrative: safe.narrative,
|
|
384
|
+
concepts: safe.concepts,
|
|
385
|
+
facts: safe.facts,
|
|
315
386
|
files_read: JSON.stringify(obs.filesRead || []),
|
|
316
387
|
files_modified: JSON.stringify(obs.files || []),
|
|
317
|
-
importance: obs.importance ?? 1,
|
|
318
|
-
|
|
319
|
-
|
|
388
|
+
importance: obs.importance ?? 1,
|
|
389
|
+
minhash_sig: minhashSig,
|
|
390
|
+
lesson_learned: safe.lesson_learned,
|
|
391
|
+
search_aliases: safe.search_aliases,
|
|
392
|
+
branch: getCurrentBranch(),
|
|
393
|
+
created_at: now.toISOString(),
|
|
394
|
+
created_at_epoch: now.getTime(),
|
|
320
395
|
// P3 (D#78): re-validate at the write boundary — saveObservation is also
|
|
321
396
|
// reached by immediate-save / manual callers whose scope never saw the
|
|
322
397
|
// handleLLMEpisode whitelist.
|
|
@@ -385,17 +460,19 @@ export function persistHaikuSummary(db, summary, ctx) {
|
|
|
385
460
|
// rule-inferred type; now that Haiku has classified it as an event type,
|
|
386
461
|
// we must remove the stale observations row before inserting the event.
|
|
387
462
|
// Atomic via better-sqlite3 transaction: either both succeed or neither.
|
|
388
|
-
const insertEvent = () =>
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
463
|
+
const insertEvent = () =>
|
|
464
|
+
saveEvent(db, {
|
|
465
|
+
project: ctx.project,
|
|
466
|
+
event_type: summary.type,
|
|
467
|
+
title: summary.title,
|
|
468
|
+
body: summary.lesson_learned || summary.narrative || null,
|
|
469
|
+
file_paths:
|
|
470
|
+
Array.isArray(summary.files_modified) && summary.files_modified.length > 0
|
|
471
|
+
? summary.files_modified
|
|
472
|
+
: null,
|
|
473
|
+
importance: summary.importance ?? 1,
|
|
474
|
+
created_at_epoch: Date.now(),
|
|
475
|
+
});
|
|
399
476
|
|
|
400
477
|
if (ctx.preSavedObsId) {
|
|
401
478
|
const id = db.transaction(() => {
|
|
@@ -413,75 +490,112 @@ export function persistHaikuSummary(db, summary, ctx) {
|
|
|
413
490
|
|
|
414
491
|
// Fallthrough: memdir-compatible / legacy types use the observations path.
|
|
415
492
|
// Map the Haiku/plan field names to saveObservation's expected shape.
|
|
416
|
-
const id = saveObservation(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
493
|
+
const id = saveObservation(
|
|
494
|
+
{
|
|
495
|
+
type: summary.type,
|
|
496
|
+
title: summary.title,
|
|
497
|
+
subtitle: summary.subtitle || '',
|
|
498
|
+
narrative: summary.narrative || '',
|
|
499
|
+
concepts: summary.concepts || [],
|
|
500
|
+
facts: summary.facts || [],
|
|
501
|
+
files: summary.files_modified || [],
|
|
502
|
+
filesRead: summary.files_read || [],
|
|
503
|
+
importance: summary.importance ?? 1,
|
|
504
|
+
lessonLearned: summary.lesson_learned || null,
|
|
505
|
+
searchAliases: summary.search_aliases || null,
|
|
506
|
+
scope: summary.scope ?? null,
|
|
507
|
+
},
|
|
508
|
+
ctx.project,
|
|
509
|
+
ctx.session_id,
|
|
510
|
+
db,
|
|
511
|
+
);
|
|
430
512
|
return { table: 'observations', id };
|
|
431
513
|
}
|
|
432
514
|
|
|
433
515
|
// ─── Related Observation Linking ─────────────────────────────────────────────
|
|
434
516
|
|
|
435
517
|
function linkRelatedObservations(db, savedId, obs, episode) {
|
|
436
|
-
const newObs = db
|
|
518
|
+
const newObs = db
|
|
519
|
+
.prepare(
|
|
520
|
+
`
|
|
437
521
|
SELECT id, title, files_modified, related_ids FROM observations WHERE id = ?
|
|
438
|
-
|
|
522
|
+
`,
|
|
523
|
+
)
|
|
524
|
+
.get(savedId);
|
|
439
525
|
if (!newObs) return;
|
|
440
526
|
|
|
441
527
|
const candidates = new Set();
|
|
442
528
|
|
|
443
529
|
// Strategy 1: FTS5 title similarity (cross-session)
|
|
444
530
|
if (obs.title) {
|
|
445
|
-
const titleTokens = obs.title
|
|
446
|
-
.
|
|
531
|
+
const titleTokens = obs.title
|
|
532
|
+
.replace(/[^a-zA-Z0-9_\s-]/g, ' ')
|
|
533
|
+
.split(/\s+/)
|
|
534
|
+
.filter((t) => t.length > 2)
|
|
535
|
+
.slice(0, 5);
|
|
447
536
|
if (titleTokens.length > 0) {
|
|
448
|
-
const ftsQuery = titleTokens.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
537
|
+
const ftsQuery = titleTokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
449
538
|
try {
|
|
450
|
-
const ftsMatches = db
|
|
539
|
+
const ftsMatches = db
|
|
540
|
+
.prepare(
|
|
541
|
+
`
|
|
451
542
|
SELECT o.id FROM observations_fts
|
|
452
543
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
453
544
|
WHERE observations_fts MATCH ? AND o.id != ? AND o.project = ?
|
|
454
545
|
ORDER BY ${OBS_BM25}
|
|
455
546
|
LIMIT 5
|
|
456
|
-
|
|
547
|
+
`,
|
|
548
|
+
)
|
|
549
|
+
.all(ftsQuery, newObs.id, episode.project);
|
|
457
550
|
for (const m of ftsMatches) candidates.add(m.id);
|
|
458
|
-
} catch (e) {
|
|
551
|
+
} catch (e) {
|
|
552
|
+
debugCatch(e, 'linkRelated-fts');
|
|
553
|
+
}
|
|
459
554
|
}
|
|
460
555
|
}
|
|
461
556
|
|
|
462
557
|
// Strategy 2: file overlap (any session, recent observations)
|
|
463
558
|
let newFiles;
|
|
464
|
-
try {
|
|
465
|
-
|
|
559
|
+
try {
|
|
560
|
+
newFiles = JSON.parse(newObs.files_modified || '[]');
|
|
561
|
+
} catch (e) {
|
|
562
|
+
debugCatch(e, 'linkRelated-newFiles');
|
|
563
|
+
newFiles = [];
|
|
564
|
+
}
|
|
565
|
+
if (!Array.isArray(newFiles) || !newFiles.every((f) => typeof f === 'string')) newFiles = [];
|
|
466
566
|
if (newFiles.length > 0) {
|
|
467
|
-
const recentObs = db
|
|
567
|
+
const recentObs = db
|
|
568
|
+
.prepare(
|
|
569
|
+
`
|
|
468
570
|
SELECT id, files_modified FROM observations
|
|
469
571
|
WHERE id != ? AND created_at_epoch > ? AND project = ?
|
|
470
572
|
ORDER BY created_at_epoch DESC LIMIT 50
|
|
471
|
-
|
|
573
|
+
`,
|
|
574
|
+
)
|
|
575
|
+
.all(newObs.id, Date.now() - RELATED_OBS_WINDOW_MS, episode.project);
|
|
472
576
|
for (const r of recentObs) {
|
|
473
577
|
let rFiles;
|
|
474
|
-
try {
|
|
475
|
-
|
|
476
|
-
|
|
578
|
+
try {
|
|
579
|
+
rFiles = JSON.parse(r.files_modified || '[]');
|
|
580
|
+
} catch (e) {
|
|
581
|
+
debugCatch(e, 'linkRelated-rFiles');
|
|
582
|
+
rFiles = [];
|
|
583
|
+
}
|
|
584
|
+
if (!Array.isArray(rFiles) || !rFiles.every((f) => typeof f === 'string')) rFiles = [];
|
|
585
|
+
if (rFiles.some((f) => newFiles.includes(f))) candidates.add(r.id);
|
|
477
586
|
}
|
|
478
587
|
}
|
|
479
588
|
|
|
480
589
|
// Apply bidirectional links (max 5 related)
|
|
481
590
|
if (candidates.size > 0) {
|
|
482
591
|
let newRelated;
|
|
483
|
-
try {
|
|
484
|
-
|
|
592
|
+
try {
|
|
593
|
+
newRelated = JSON.parse(newObs.related_ids || '[]');
|
|
594
|
+
} catch (e) {
|
|
595
|
+
debugCatch(e, 'linkRelated-newRelated');
|
|
596
|
+
newRelated = [];
|
|
597
|
+
}
|
|
598
|
+
if (!Array.isArray(newRelated) || !newRelated.every((id) => Number.isInteger(id))) newRelated = [];
|
|
485
599
|
|
|
486
600
|
for (const relId of [...candidates].slice(0, 5)) {
|
|
487
601
|
if (newRelated.includes(relId)) continue;
|
|
@@ -490,16 +604,27 @@ function linkRelatedObservations(db, savedId, obs, episode) {
|
|
|
490
604
|
const rel = db.prepare('SELECT related_ids FROM observations WHERE id = ?').get(relId);
|
|
491
605
|
if (rel) {
|
|
492
606
|
let relRelated;
|
|
493
|
-
try {
|
|
494
|
-
|
|
607
|
+
try {
|
|
608
|
+
relRelated = JSON.parse(rel.related_ids || '[]');
|
|
609
|
+
} catch (e) {
|
|
610
|
+
debugCatch(e, 'linkRelated-relRelated');
|
|
611
|
+
relRelated = [];
|
|
612
|
+
}
|
|
613
|
+
if (!Array.isArray(relRelated) || !relRelated.every((id) => Number.isInteger(id))) relRelated = [];
|
|
495
614
|
if (!relRelated.includes(newObs.id)) {
|
|
496
615
|
relRelated.push(newObs.id);
|
|
497
|
-
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
|
|
616
|
+
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
|
|
617
|
+
JSON.stringify(relRelated.slice(-10)),
|
|
618
|
+
relId,
|
|
619
|
+
);
|
|
498
620
|
}
|
|
499
621
|
}
|
|
500
622
|
}
|
|
501
623
|
|
|
502
|
-
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
|
|
624
|
+
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
|
|
625
|
+
JSON.stringify(newRelated.slice(-10)),
|
|
626
|
+
newObs.id,
|
|
627
|
+
);
|
|
503
628
|
}
|
|
504
629
|
}
|
|
505
630
|
|
|
@@ -509,13 +634,13 @@ function linkRelatedObservations(db, savedId, obs, episode) {
|
|
|
509
634
|
|
|
510
635
|
export function buildDegradedTitle(episode) {
|
|
511
636
|
const files = (episode.files || []).filter(Boolean);
|
|
512
|
-
const hasError = episode.entries.some(e => e.isError);
|
|
513
|
-
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
637
|
+
const hasError = episode.entries.some((e) => e.isError);
|
|
638
|
+
const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
|
|
514
639
|
|
|
515
640
|
// Extract a short error hint from the first error entry's desc
|
|
516
641
|
let errorHint = '';
|
|
517
642
|
if (hasError) {
|
|
518
|
-
const errEntry = episode.entries.find(e => e.isError);
|
|
643
|
+
const errEntry = episode.entries.find((e) => e.isError);
|
|
519
644
|
if (errEntry?.desc) {
|
|
520
645
|
// Extract meaningful error text from "cmd → ERROR: ..." format
|
|
521
646
|
const errMatch = errEntry.desc.match(/→ ERROR: (.{3,80})/);
|
|
@@ -534,12 +659,12 @@ export function buildDegradedTitle(episode) {
|
|
|
534
659
|
}
|
|
535
660
|
|
|
536
661
|
if (files.length > 0) {
|
|
537
|
-
const uniqueNames = [...new Set(files.map(f => basename(f)))];
|
|
662
|
+
const uniqueNames = [...new Set(files.map((f) => basename(f)))];
|
|
538
663
|
const names = uniqueNames.slice(0, 3).join(', ');
|
|
539
664
|
const suffix = uniqueNames.length > 3 ? ` +${uniqueNames.length - 3} more` : '';
|
|
540
665
|
if (hasError) {
|
|
541
666
|
// Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
|
|
542
|
-
const errEntry = episode.entries.find(e => e.isError);
|
|
667
|
+
const errEntry = episode.entries.find((e) => e.isError);
|
|
543
668
|
const cmd = errEntry?.desc?.match(/^(.{3,30}?) →/)?.[1]?.trim();
|
|
544
669
|
const cmdHint = cmd ? ` — ${cmd}` : '';
|
|
545
670
|
return `Error: ${names}${suffix}${errorHint || cmdHint}`;
|
|
@@ -549,7 +674,8 @@ export function buildDegradedTitle(episode) {
|
|
|
549
674
|
}
|
|
550
675
|
// No files: strip raw output (JSON, arrays, long tails) from Bash descriptions
|
|
551
676
|
const desc = episode.entries[0]?.desc || '(no description)';
|
|
552
|
-
return desc
|
|
677
|
+
return desc
|
|
678
|
+
.replace(/ → (?:ERROR: )?[[{].*$/, hasError ? ' (error)' : '')
|
|
553
679
|
.replace(/ → .*---EXIT:\d+$/, hasError ? ' (error)' : '')
|
|
554
680
|
.replace(/\t/g, ' ')
|
|
555
681
|
.replace(/\s{2,}/g, ' ')
|
|
@@ -586,20 +712,19 @@ export function saveEpisodeImmediate(episode, externalDb, scope = 'saveEpisodeIm
|
|
|
586
712
|
* @returns {object} Observation object ready for saveObservation()
|
|
587
713
|
*/
|
|
588
714
|
export function buildImmediateObservation(episode) {
|
|
589
|
-
const hasError = episode.entries.some(e => e.isError);
|
|
590
|
-
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
591
|
-
const readCount = episode.entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
|
|
715
|
+
const hasError = episode.entries.some((e) => e.isError);
|
|
716
|
+
const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
|
|
717
|
+
const readCount = episode.entries.filter((e) => e.tool === 'Read' || e.tool === 'Grep').length;
|
|
592
718
|
const isReviewPattern = !hasEdit && !hasError && readCount >= 5;
|
|
593
719
|
const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
|
|
594
|
-
const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
|
|
720
|
+
const fileList = (episode.files || []).map((f) => basename(f)).join(', ') || '(multiple)';
|
|
595
721
|
|
|
596
722
|
// Review/research episodes: use a descriptive title with file count
|
|
597
723
|
let title;
|
|
598
724
|
if (isReviewPattern) {
|
|
599
|
-
const allFiles = [...new Set([
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
])].map(f => basename(f));
|
|
725
|
+
const allFiles = [...new Set([...(episode.files || []), ...(episode.filesRead || [])])].map((f) =>
|
|
726
|
+
basename(f),
|
|
727
|
+
);
|
|
603
728
|
const names = allFiles.slice(0, 4).join(', ');
|
|
604
729
|
const suffix = allFiles.length > 4 ? ` +${allFiles.length - 4} more` : '';
|
|
605
730
|
title = truncate(`Reviewed ${allFiles.length} files: ${names}${suffix}`, 120);
|
|
@@ -656,7 +781,7 @@ export function buildImmediateObservation(episode) {
|
|
|
656
781
|
type: inferredType,
|
|
657
782
|
title,
|
|
658
783
|
subtitle: fileList,
|
|
659
|
-
narrative: episode.entries.map(e => e.desc).join('; '),
|
|
784
|
+
narrative: episode.entries.map((e) => e.desc).join('; '),
|
|
660
785
|
concepts: [],
|
|
661
786
|
facts: [],
|
|
662
787
|
files: [...modifiedFiles],
|
|
@@ -713,7 +838,8 @@ export function hasEnrichmentContent(parsed) {
|
|
|
713
838
|
if (!parsed || typeof parsed !== 'object') return false;
|
|
714
839
|
if (typeof parsed.lesson_learned === 'string' && !isLowSignalLesson(parsed.lesson_learned)) return true;
|
|
715
840
|
if (typeof parsed.narrative === 'string' && parsed.narrative.trim().length > 0) return true;
|
|
716
|
-
if (Array.isArray(parsed.facts) && parsed.facts.some(f => typeof f === 'string' && f.trim().length > 0))
|
|
841
|
+
if (Array.isArray(parsed.facts) && parsed.facts.some((f) => typeof f === 'string' && f.trim().length > 0))
|
|
842
|
+
return true;
|
|
717
843
|
return false;
|
|
718
844
|
}
|
|
719
845
|
|
|
@@ -732,12 +858,13 @@ export function hasEnrichmentContent(parsed) {
|
|
|
732
858
|
// Module-private: the only call site is the retry branch below. Same D#207 reasoning as
|
|
733
859
|
// MEMORY_INPUT_GUARD — exported by habit, never imported.
|
|
734
860
|
function buildLessonRetryPrompt(episode, firstPass) {
|
|
735
|
-
const actionList = episode.entries
|
|
736
|
-
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
737
|
-
|
|
738
|
-
const typeHint =
|
|
739
|
-
|
|
740
|
-
|
|
861
|
+
const actionList = episode.entries
|
|
862
|
+
.map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
|
|
863
|
+
.join('\n');
|
|
864
|
+
const typeHint =
|
|
865
|
+
firstPass.type === 'bugfix'
|
|
866
|
+
? 'For this bugfix: what was the root cause + how to spot it next time? Example: "FTS5 trigger fires on any UPDATE — wrap access_count writes in try/catch."'
|
|
867
|
+
: 'For this decision: what tradeoff was made + why? Example: "Chose single-source module over schema column because 1 drift point, not 4."';
|
|
741
868
|
|
|
742
869
|
const system = `${typeHint}
|
|
743
870
|
|
|
@@ -762,22 +889,28 @@ export async function handleLLMEpisode() {
|
|
|
762
889
|
try {
|
|
763
890
|
episode = JSON.parse(readFileSync(tmpFile, 'utf8'));
|
|
764
891
|
} catch {
|
|
765
|
-
try {
|
|
892
|
+
try {
|
|
893
|
+
unlinkSync(tmpFile);
|
|
894
|
+
} catch {}
|
|
766
895
|
return;
|
|
767
896
|
}
|
|
768
897
|
|
|
769
898
|
if (!episode.entries || episode.entries.length === 0) {
|
|
770
|
-
try {
|
|
899
|
+
try {
|
|
900
|
+
unlinkSync(tmpFile);
|
|
901
|
+
} catch {}
|
|
771
902
|
return;
|
|
772
903
|
}
|
|
773
904
|
|
|
774
905
|
// Rate-limit background LLM calls to avoid competing with active sessions
|
|
775
906
|
if (!process.env.CLAUDE_MEM_NO_DELAY) {
|
|
776
907
|
const sessionActive = existsSync(sessionFile());
|
|
777
|
-
const delayMs = sessionActive
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
908
|
+
const delayMs = sessionActive ? 2000 + Math.random() * 3000 : 500 + Math.random() * 1000;
|
|
909
|
+
debugLog(
|
|
910
|
+
'DEBUG',
|
|
911
|
+
'llm-episode',
|
|
912
|
+
`delay: ${Math.round(delayMs)}ms (session ${sessionActive ? 'active' : 'ended'})`,
|
|
913
|
+
);
|
|
781
914
|
await sleep(delayMs);
|
|
782
915
|
}
|
|
783
916
|
|
|
@@ -786,14 +919,13 @@ export async function handleLLMEpisode() {
|
|
|
786
919
|
// before any cleanup, leaking the tmp file (which is then retried and crashes
|
|
787
920
|
// forever). Guard defensively, mirroring buildImmediateObservation's `|| []`.
|
|
788
921
|
const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
|
|
789
|
-
const fileList = episodeFiles.map(f => basename(f)).join(', ') || '(multiple)';
|
|
922
|
+
const fileList = episodeFiles.map((f) => basename(f)).join(', ') || '(multiple)';
|
|
790
923
|
|
|
791
924
|
// Defense-in-depth (cso F#4): split static instructions (system) from
|
|
792
925
|
// per-call data (user). Episode descriptions and file paths come from tool
|
|
793
926
|
// events; treating them as a separate role + boundary marker reduces the
|
|
794
927
|
// attack surface for memory poisoning via crafted file content.
|
|
795
|
-
const SHARED_OBS_SCHEMA_TAIL =
|
|
796
|
-
`${MEMORY_INPUT_GUARD}
|
|
928
|
+
const SHARED_OBS_SCHEMA_TAIL = `${MEMORY_INPUT_GUARD}
|
|
797
929
|
type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
|
|
798
930
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
799
931
|
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
@@ -814,9 +946,9 @@ Action: ${e.desc}
|
|
|
814
946
|
Error: ${e.isError ? 'yes' : 'no'}`;
|
|
815
947
|
prompt = { system, user };
|
|
816
948
|
} else {
|
|
817
|
-
const actionList = episode.entries
|
|
818
|
-
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
819
|
-
|
|
949
|
+
const actionList = episode.entries
|
|
950
|
+
.map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
|
|
951
|
+
.join('\n');
|
|
820
952
|
|
|
821
953
|
const system = `Summarize this coding episode as ONE coherent observation. Return ONLY valid JSON, no markdown fences.
|
|
822
954
|
|
|
@@ -861,7 +993,12 @@ ${actionList}`;
|
|
|
861
993
|
// with neither a usable title nor content still falls through to
|
|
862
994
|
// buildImmediateObservation, which infers type/importance from the episode.
|
|
863
995
|
const titleUsable = parsed && typeof parsed.title === 'string' && !!parsed.title;
|
|
864
|
-
if (
|
|
996
|
+
if (
|
|
997
|
+
parsed &&
|
|
998
|
+
typeof parsed === 'object' &&
|
|
999
|
+
!Array.isArray(parsed) &&
|
|
1000
|
+
(titleUsable || hasEnrichmentContent(parsed))
|
|
1001
|
+
) {
|
|
865
1002
|
// Synthesize a rule-based title when Haiku's is unusable (crash-safe, and the
|
|
866
1003
|
// lesson survives). Only the title degrades; every other field is kept.
|
|
867
1004
|
if (!titleUsable) parsed.title = buildDegradedTitle(episode);
|
|
@@ -879,11 +1016,16 @@ ${actionList}`;
|
|
|
879
1016
|
if (episode.savedId) {
|
|
880
1017
|
const ddb = openDb();
|
|
881
1018
|
if (ddb) {
|
|
882
|
-
try {
|
|
883
|
-
|
|
1019
|
+
try {
|
|
1020
|
+
retractPreSavedObs(ddb, episode.savedId, 'low-value-discard');
|
|
1021
|
+
} finally {
|
|
1022
|
+
ddb.close();
|
|
1023
|
+
}
|
|
884
1024
|
}
|
|
885
1025
|
}
|
|
886
|
-
try {
|
|
1026
|
+
try {
|
|
1027
|
+
unlinkSync(tmpFile);
|
|
1028
|
+
} catch {}
|
|
887
1029
|
return;
|
|
888
1030
|
}
|
|
889
1031
|
|
|
@@ -904,9 +1046,11 @@ ${actionList}`;
|
|
|
904
1046
|
// episode. Opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1.
|
|
905
1047
|
let retryAttempted = false;
|
|
906
1048
|
let retryRecovered = false;
|
|
907
|
-
if (
|
|
908
|
-
|
|
909
|
-
|
|
1049
|
+
if (
|
|
1050
|
+
isLessonLowSignal &&
|
|
1051
|
+
(parsed.type === 'bugfix' || parsed.type === 'decision') &&
|
|
1052
|
+
!process.env.CLAUDE_MEM_NO_LESSON_RETRY
|
|
1053
|
+
) {
|
|
910
1054
|
retryAttempted = true;
|
|
911
1055
|
// The first callLLM released its slot in the finally above; this lesson
|
|
912
1056
|
// retry is a SECOND LLM call and must re-acquire the semaphore or it
|
|
@@ -924,11 +1068,18 @@ ${actionList}`;
|
|
|
924
1068
|
if (!retryIsLow) {
|
|
925
1069
|
lessonLearned = retryLesson.slice(0, 500);
|
|
926
1070
|
retryRecovered = true;
|
|
927
|
-
debugLog(
|
|
1071
|
+
debugLog(
|
|
1072
|
+
'DEBUG',
|
|
1073
|
+
'llm-episode',
|
|
1074
|
+
`lesson-retry: recovered ${retryLesson.length}-char lesson for ${parsed.type}`,
|
|
1075
|
+
);
|
|
928
1076
|
}
|
|
929
1077
|
}
|
|
930
|
-
} catch (e) {
|
|
931
|
-
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
debugCatch(e, 'lesson-retry');
|
|
1080
|
+
} finally {
|
|
1081
|
+
if (retrySlot) releaseLLMSlot();
|
|
1082
|
+
}
|
|
932
1083
|
}
|
|
933
1084
|
// v2.57.x B2: persist retry outcome counters. The retry path costs
|
|
934
1085
|
// 1 extra Haiku call per bugfix/decision episode; if recovered/attempts
|
|
@@ -941,9 +1092,15 @@ ${actionList}`;
|
|
|
941
1092
|
try {
|
|
942
1093
|
const cdb = openDb();
|
|
943
1094
|
if (cdb) {
|
|
944
|
-
try {
|
|
1095
|
+
try {
|
|
1096
|
+
recordRetryAttempt(cdb, retryRecovered);
|
|
1097
|
+
} finally {
|
|
1098
|
+
cdb.close();
|
|
1099
|
+
}
|
|
945
1100
|
}
|
|
946
|
-
} catch (e) {
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
debugCatch(e, 'retry-stats-write');
|
|
1103
|
+
}
|
|
947
1104
|
}
|
|
948
1105
|
|
|
949
1106
|
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
@@ -982,9 +1139,10 @@ ${actionList}`;
|
|
|
982
1139
|
// to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
|
|
983
1140
|
// path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
|
|
984
1141
|
// non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
|
|
985
|
-
importance:
|
|
986
|
-
|
|
987
|
-
|
|
1142
|
+
importance:
|
|
1143
|
+
isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
|
|
1144
|
+
? Math.min(ruleImportance, 1)
|
|
1145
|
+
: Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
|
|
988
1146
|
lessonLearned,
|
|
989
1147
|
searchAliases,
|
|
990
1148
|
// P3 (D#78): lesson applicability scope — whitelist-validated, invalid → null.
|
|
@@ -1004,11 +1162,16 @@ ${actionList}`;
|
|
|
1004
1162
|
if (episode.savedId) {
|
|
1005
1163
|
const ddb = openDb();
|
|
1006
1164
|
if (ddb) {
|
|
1007
|
-
try {
|
|
1008
|
-
|
|
1165
|
+
try {
|
|
1166
|
+
retractPreSavedObs(ddb, episode.savedId, 'low-yield-change-drop');
|
|
1167
|
+
} finally {
|
|
1168
|
+
ddb.close();
|
|
1169
|
+
}
|
|
1009
1170
|
}
|
|
1010
1171
|
}
|
|
1011
|
-
try {
|
|
1172
|
+
try {
|
|
1173
|
+
unlinkSync(tmpFile);
|
|
1174
|
+
} catch {}
|
|
1012
1175
|
return;
|
|
1013
1176
|
}
|
|
1014
1177
|
}
|
|
@@ -1019,14 +1182,21 @@ ${actionList}`;
|
|
|
1019
1182
|
// If pre-saved observation exists, LLM degraded mode doesn't need to overwrite — keep pre-saved data
|
|
1020
1183
|
if (episode.savedId) {
|
|
1021
1184
|
debugLog('DEBUG', 'llm-episode', `LLM failed but pre-saved obs #${episode.savedId} exists, keeping`);
|
|
1022
|
-
try {
|
|
1185
|
+
try {
|
|
1186
|
+
unlinkSync(tmpFile);
|
|
1187
|
+
} catch {}
|
|
1023
1188
|
return;
|
|
1024
1189
|
}
|
|
1025
1190
|
obs = buildImmediateObservation(episode);
|
|
1026
1191
|
}
|
|
1027
1192
|
|
|
1028
1193
|
const db = openDb();
|
|
1029
|
-
if (!db) {
|
|
1194
|
+
if (!db) {
|
|
1195
|
+
try {
|
|
1196
|
+
unlinkSync(tmpFile);
|
|
1197
|
+
} catch {}
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1030
1200
|
|
|
1031
1201
|
try {
|
|
1032
1202
|
let savedId;
|
|
@@ -1073,31 +1243,42 @@ ${actionList}`;
|
|
|
1073
1243
|
// tombstone: `changes` is 1, nothing looks wrong, and the row it landed on is
|
|
1074
1244
|
// excluded from every read face by liveObsFilterSql. Same clause as those read
|
|
1075
1245
|
// faces, so "what the update may touch" and "what a query may return" cannot drift.
|
|
1076
|
-
const upgraded = db
|
|
1246
|
+
const upgraded = db
|
|
1247
|
+
.prepare(
|
|
1248
|
+
`
|
|
1077
1249
|
UPDATE observations SET type=?, title=?, subtitle=?,
|
|
1078
1250
|
narrative=COALESCE(NULLIF(?, ''), narrative), concepts=?, facts=?,
|
|
1079
1251
|
text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?,
|
|
1080
1252
|
scope=COALESCE(?, scope)
|
|
1081
1253
|
WHERE id = ? AND ${liveObsFilterSql('')}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1254
|
+
`,
|
|
1255
|
+
)
|
|
1256
|
+
.run(
|
|
1257
|
+
obs.type,
|
|
1258
|
+
safe.title,
|
|
1259
|
+
safe.subtitle,
|
|
1260
|
+
safe.narrative,
|
|
1261
|
+
safe.concepts,
|
|
1262
|
+
safe.facts,
|
|
1263
|
+
safe.text,
|
|
1264
|
+
obs.importance,
|
|
1265
|
+
JSON.stringify(obs.filesRead || []),
|
|
1266
|
+
minhashSig,
|
|
1267
|
+
safe.lesson_learned,
|
|
1268
|
+
safe.search_aliases,
|
|
1269
|
+
normalizeScope(obs.scope),
|
|
1270
|
+
episode.savedId,
|
|
1271
|
+
).changes;
|
|
1094
1272
|
|
|
1095
1273
|
if (upgraded === 0) {
|
|
1096
1274
|
// The pre-saved row is gone or tombstoned. Dropping the enrichment here is the
|
|
1097
1275
|
// silent-loss shape this repository keeps paying for, so save it as a fresh row
|
|
1098
1276
|
// and let the normal dedup path decide whether it merges into the keeper.
|
|
1099
|
-
debugLog(
|
|
1100
|
-
|
|
1277
|
+
debugLog(
|
|
1278
|
+
'DEBUG',
|
|
1279
|
+
'llm-episode',
|
|
1280
|
+
`pre-saved obs #${episode.savedId} no longer live — saving enrichment as a fresh row`,
|
|
1281
|
+
);
|
|
1101
1282
|
const result = persistHaikuSummary(db, obsToSummary(obs), {
|
|
1102
1283
|
project: episode.project,
|
|
1103
1284
|
session_id: episode.sessionId,
|
|
@@ -1112,10 +1293,18 @@ ${actionList}`;
|
|
|
1112
1293
|
// Update TF-IDF vector with enriched content. SQL + text derivation are
|
|
1113
1294
|
// lib/observation-write.mjs's (audit 2026-09-02 P1-4); `gate: false` keeps this
|
|
1114
1295
|
// path's prior behaviour, which never consulted vectorsEnabled().
|
|
1115
|
-
upsertObservationVector(
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1296
|
+
upsertObservationVector(
|
|
1297
|
+
db,
|
|
1298
|
+
savedId,
|
|
1299
|
+
{
|
|
1300
|
+
title: obs.title,
|
|
1301
|
+
narrative: obs.narrative,
|
|
1302
|
+
concepts: conceptsText,
|
|
1303
|
+
lesson_learned: safe.lesson_learned,
|
|
1304
|
+
search_aliases: safe.search_aliases,
|
|
1305
|
+
},
|
|
1306
|
+
{ gate: false, scope: 'handleLLMEpisode-vector' },
|
|
1307
|
+
);
|
|
1119
1308
|
}
|
|
1120
1309
|
}
|
|
1121
1310
|
} else {
|
|
@@ -1133,13 +1322,17 @@ ${actionList}`;
|
|
|
1133
1322
|
if (savedId && savedTable === 'observations') {
|
|
1134
1323
|
try {
|
|
1135
1324
|
linkRelatedObservations(db, savedId, obs, episode);
|
|
1136
|
-
} catch (e) {
|
|
1325
|
+
} catch (e) {
|
|
1326
|
+
debugCatch(e, 'relatedObsLinking');
|
|
1327
|
+
}
|
|
1137
1328
|
}
|
|
1138
1329
|
} finally {
|
|
1139
1330
|
db.close();
|
|
1140
1331
|
}
|
|
1141
1332
|
|
|
1142
|
-
try {
|
|
1333
|
+
try {
|
|
1334
|
+
unlinkSync(tmpFile);
|
|
1335
|
+
} catch {}
|
|
1143
1336
|
}
|
|
1144
1337
|
|
|
1145
1338
|
// ─── Background: LLM Session Summary ────────────────────────────────────────
|
|
@@ -1174,17 +1367,31 @@ export async function handleLLMSummary() {
|
|
|
1174
1367
|
pending = readdirSync(RUNTIME_DIR)
|
|
1175
1368
|
.filter((f) => f.startsWith('ep-flush-'))
|
|
1176
1369
|
.filter((f) => {
|
|
1177
|
-
try {
|
|
1370
|
+
try {
|
|
1371
|
+
return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff;
|
|
1372
|
+
} catch {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1178
1375
|
});
|
|
1179
|
-
} catch {
|
|
1376
|
+
} catch {
|
|
1377
|
+
pending = [];
|
|
1378
|
+
}
|
|
1180
1379
|
|
|
1181
1380
|
for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
|
|
1182
1381
|
await sleep(1000);
|
|
1183
1382
|
pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
|
|
1184
|
-
debugLog(
|
|
1383
|
+
debugLog(
|
|
1384
|
+
'DEBUG',
|
|
1385
|
+
'llm-summary',
|
|
1386
|
+
`waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`,
|
|
1387
|
+
);
|
|
1185
1388
|
}
|
|
1186
1389
|
if (pending.length > 0) {
|
|
1187
|
-
debugLog(
|
|
1390
|
+
debugLog(
|
|
1391
|
+
'DEBUG',
|
|
1392
|
+
'llm-summary',
|
|
1393
|
+
`gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`,
|
|
1394
|
+
);
|
|
1188
1395
|
}
|
|
1189
1396
|
|
|
1190
1397
|
const db = openDb();
|
|
@@ -1197,29 +1404,38 @@ export async function handleLLMSummary() {
|
|
|
1197
1404
|
// Exclude LOW_SIGNAL hook-llm fallback titles ("Error: files +2 more: ...",
|
|
1198
1405
|
// "Modified X", "Worked on X", etc.) from the Haiku summary input — they
|
|
1199
1406
|
// pollute the `completed` field and mislead session-resume context.
|
|
1200
|
-
const recentObs = db
|
|
1407
|
+
const recentObs = db
|
|
1408
|
+
.prepare(
|
|
1409
|
+
`
|
|
1201
1410
|
SELECT id, type, title, narrative
|
|
1202
1411
|
FROM observations
|
|
1203
1412
|
WHERE memory_session_id = ?
|
|
1204
1413
|
AND ${notLowSignalTitleClause('')}
|
|
1205
1414
|
ORDER BY created_at_epoch DESC
|
|
1206
1415
|
LIMIT 30
|
|
1207
|
-
|
|
1416
|
+
`,
|
|
1417
|
+
)
|
|
1418
|
+
.all(sessionId);
|
|
1208
1419
|
|
|
1209
1420
|
if (recentObs.length < 1) return;
|
|
1210
1421
|
|
|
1211
|
-
const obsList = recentObs
|
|
1212
|
-
|
|
1213
|
-
|
|
1422
|
+
const obsList = recentObs
|
|
1423
|
+
.map(
|
|
1424
|
+
(o, i) => `${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 200) : ''}`,
|
|
1425
|
+
)
|
|
1426
|
+
.join('\n');
|
|
1214
1427
|
|
|
1215
1428
|
// Include user prompts for richer context
|
|
1216
|
-
const userPrompts = db
|
|
1429
|
+
const userPrompts = db
|
|
1430
|
+
.prepare(
|
|
1431
|
+
`
|
|
1217
1432
|
SELECT prompt_text FROM user_prompts
|
|
1218
1433
|
WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 10
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1434
|
+
`,
|
|
1435
|
+
)
|
|
1436
|
+
.all(sessionId)
|
|
1437
|
+
.map((p) => truncate(p.prompt_text, 300));
|
|
1438
|
+
const promptCtx = userPrompts.length > 0 ? `\nUser requests: ${userPrompts.join(' → ')}\n` : '';
|
|
1223
1439
|
|
|
1224
1440
|
// cso F#4: split system/user. The userPrompts content (line 921) is the
|
|
1225
1441
|
// single highest-leakage path for memory poisoning — putting it in the
|
|
@@ -1253,9 +1469,12 @@ ${obsList}`;
|
|
|
1253
1469
|
// ("Too many parameter values") out of this try/finally and drops the WHOLE summary incl.
|
|
1254
1470
|
// lessons + key_decisions. Join array items; non-strings → ''. (lessons/key_decisions are
|
|
1255
1471
|
// JSON.stringify'd separately below.)
|
|
1256
|
-
const asText = v =>
|
|
1257
|
-
|
|
1258
|
-
|
|
1472
|
+
const asText = (v) =>
|
|
1473
|
+
Array.isArray(v)
|
|
1474
|
+
? v.filter((x) => typeof x === 'string' && x.trim()).join('; ')
|
|
1475
|
+
: typeof v === 'string'
|
|
1476
|
+
? v
|
|
1477
|
+
: '';
|
|
1259
1478
|
|
|
1260
1479
|
// Persist when ANY meaningful field is present — not just `request`. Gating on `request`
|
|
1261
1480
|
// alone dropped the whole INSERT/UPDATE (losing the session's highest-value fields:
|
|
@@ -1264,24 +1483,35 @@ ${obsList}`;
|
|
|
1264
1483
|
// empty request: INSERT writes '' and the UPDATE COALESCE(NULLIF(?, ''), request) preserves
|
|
1265
1484
|
// the prior value. Use asText in the gate so a non-string / empty-array field can't falsely
|
|
1266
1485
|
// trigger it.
|
|
1267
|
-
const hasSummaryContent =
|
|
1268
|
-
|
|
1269
|
-
(
|
|
1270
|
-
|
|
1271
|
-
|
|
1486
|
+
const hasSummaryContent =
|
|
1487
|
+
llmParsed &&
|
|
1488
|
+
(asText(llmParsed.request) ||
|
|
1489
|
+
asText(llmParsed.completed) ||
|
|
1490
|
+
asText(llmParsed.remaining_items) ||
|
|
1491
|
+
asText(llmParsed.next_steps) ||
|
|
1492
|
+
(Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0) ||
|
|
1493
|
+
(Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0));
|
|
1272
1494
|
if (hasSummaryContent) {
|
|
1273
1495
|
const now = new Date();
|
|
1274
|
-
const lessonsJson =
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1496
|
+
const lessonsJson =
|
|
1497
|
+
Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
|
|
1498
|
+
? JSON.stringify(llmParsed.lessons)
|
|
1499
|
+
: null;
|
|
1500
|
+
const decisionsJson =
|
|
1501
|
+
Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
|
|
1502
|
+
? JSON.stringify(llmParsed.key_decisions)
|
|
1503
|
+
: null;
|
|
1278
1504
|
|
|
1279
1505
|
// Upgrade existing fast summary instead of creating a duplicate
|
|
1280
|
-
const existingFast = db
|
|
1506
|
+
const existingFast = db
|
|
1507
|
+
.prepare(
|
|
1508
|
+
`
|
|
1281
1509
|
SELECT id FROM session_summaries
|
|
1282
1510
|
WHERE memory_session_id = ? AND notes = 'fast'
|
|
1283
1511
|
LIMIT 1
|
|
1284
|
-
|
|
1512
|
+
`,
|
|
1513
|
+
)
|
|
1514
|
+
.get(sessionId);
|
|
1285
1515
|
|
|
1286
1516
|
if (existingFast) {
|
|
1287
1517
|
// Preserve structural-extractor content (completed / remaining_items written
|
|
@@ -1306,7 +1536,8 @@ ${obsList}`;
|
|
|
1306
1536
|
lessons: lessonsJson,
|
|
1307
1537
|
key_decisions: decisionsJson,
|
|
1308
1538
|
});
|
|
1309
|
-
db.prepare(
|
|
1539
|
+
db.prepare(
|
|
1540
|
+
`
|
|
1310
1541
|
UPDATE session_summaries
|
|
1311
1542
|
SET request = COALESCE(NULLIF(?, ''), request),
|
|
1312
1543
|
investigated = COALESCE(NULLIF(?, ''), investigated),
|
|
@@ -1320,13 +1551,19 @@ ${obsList}`;
|
|
|
1320
1551
|
created_at = ?,
|
|
1321
1552
|
created_at_epoch = ?
|
|
1322
1553
|
WHERE id = ?
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
safe.
|
|
1554
|
+
`,
|
|
1555
|
+
).run(
|
|
1556
|
+
safe.request,
|
|
1557
|
+
safe.investigated,
|
|
1558
|
+
safe.learned,
|
|
1559
|
+
safe.completed,
|
|
1560
|
+
safe.next_steps,
|
|
1326
1561
|
safe.remaining_items,
|
|
1327
|
-
safe.lessons,
|
|
1328
|
-
|
|
1329
|
-
|
|
1562
|
+
safe.lessons,
|
|
1563
|
+
safe.key_decisions,
|
|
1564
|
+
now.toISOString(),
|
|
1565
|
+
now.getTime(),
|
|
1566
|
+
existingFast.id,
|
|
1330
1567
|
);
|
|
1331
1568
|
} else {
|
|
1332
1569
|
const safe = scrubRecord('session_summaries', {
|
|
@@ -1339,16 +1576,24 @@ ${obsList}`;
|
|
|
1339
1576
|
lessons: lessonsJson,
|
|
1340
1577
|
key_decisions: decisionsJson,
|
|
1341
1578
|
});
|
|
1342
|
-
db.prepare(
|
|
1579
|
+
db.prepare(
|
|
1580
|
+
`
|
|
1343
1581
|
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
|
|
1344
1582
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1583
|
+
`,
|
|
1584
|
+
).run(
|
|
1585
|
+
sessionId,
|
|
1586
|
+
project,
|
|
1587
|
+
safe.request,
|
|
1588
|
+
safe.investigated,
|
|
1589
|
+
safe.learned,
|
|
1590
|
+
safe.completed,
|
|
1591
|
+
safe.next_steps,
|
|
1349
1592
|
safe.remaining_items,
|
|
1350
|
-
safe.lessons,
|
|
1351
|
-
|
|
1593
|
+
safe.lessons,
|
|
1594
|
+
safe.key_decisions,
|
|
1595
|
+
now.toISOString(),
|
|
1596
|
+
now.getTime(),
|
|
1352
1597
|
);
|
|
1353
1598
|
}
|
|
1354
1599
|
}
|
|
@@ -1369,12 +1614,27 @@ ${obsList}`;
|
|
|
1369
1614
|
// the observations INSERT path scrubs all configured text fields.
|
|
1370
1615
|
export const __insertObservationForTest = (db, obs) => {
|
|
1371
1616
|
const safe = scrubRecord('observations', obs);
|
|
1372
|
-
db.prepare(
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
obs.
|
|
1378
|
-
|
|
1617
|
+
db.prepare(
|
|
1618
|
+
`INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
|
|
1619
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1620
|
+
).run(
|
|
1621
|
+
obs.session_id,
|
|
1622
|
+
obs.project,
|
|
1623
|
+
safe.text,
|
|
1624
|
+
'change',
|
|
1625
|
+
safe.title,
|
|
1626
|
+
safe.subtitle,
|
|
1627
|
+
safe.narrative,
|
|
1628
|
+
safe.concepts,
|
|
1629
|
+
safe.facts,
|
|
1630
|
+
obs.files_read,
|
|
1631
|
+
obs.files_modified,
|
|
1632
|
+
obs.importance,
|
|
1633
|
+
obs.minhash_sig,
|
|
1634
|
+
safe.lesson_learned,
|
|
1635
|
+
safe.search_aliases,
|
|
1636
|
+
obs.branch,
|
|
1637
|
+
new Date().toISOString(),
|
|
1638
|
+
Date.now(),
|
|
1379
1639
|
);
|
|
1380
1640
|
};
|