claude-mem-lite 3.63.0 → 3.65.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/deep-search.mjs +2 -1
- package/hook-context.mjs +23 -18
- package/hook-handoff.mjs +3 -4
- package/hook-memory.mjs +6 -9
- package/hook-optimize.mjs +4 -6
- package/hook-precompact.mjs +15 -2
- package/hook-shared.mjs +6 -0
- package/hook.mjs +59 -31
- package/lib/browse-core.mjs +67 -0
- package/lib/delete-core.mjs +18 -1
- package/lib/get-core.mjs +33 -0
- package/lib/inject-search-core.mjs +87 -0
- package/lib/injected-ids.mjs +49 -0
- package/lib/maintain-core.mjs +3 -3
- package/lib/observation-write.mjs +38 -2
- package/lib/recall-core.mjs +2 -2
- package/lib/recent-core.mjs +3 -1
- package/lib/search-core.mjs +14 -4
- package/lib/stats-core.mjs +2 -1
- package/lib/timeline-core.mjs +7 -6
- package/mem-cli.mjs +75 -141
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/pre-skill-bridge.js +3 -1
- package/scripts/pre-tool-recall.js +13 -9
- package/scripts/prompt-search-utils.mjs +3 -0
- package/scripts/user-prompt-search.js +26 -25
- package/search-engine.mjs +21 -15
- package/search-scoring.mjs +2 -2
- package/server.mjs +43 -112
- package/source-files.mjs +14 -0
- package/tfidf.mjs +3 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.65.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.65.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/deep-search.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { sanitizeFtsQuery } from './utils.mjs';
|
|
|
36
36
|
import { RRF_K } from './tfidf.mjs';
|
|
37
37
|
import { rrfAccumulate } from './lib/rrf.mjs';
|
|
38
38
|
import { llmRerankOrder, defaultRerankLLM } from './rerank.mjs';
|
|
39
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
39
40
|
|
|
40
41
|
// original + up to 3 rewrites (keyword / concept-expansion / HyDE).
|
|
41
42
|
export const MAX_VARIANTS = 4;
|
|
@@ -68,7 +69,7 @@ export const AUTO_DEEP_MIN_CORPUS = 10;
|
|
|
68
69
|
*/
|
|
69
70
|
export function hasEscalatableCorpus(db, project, min = AUTO_DEEP_MIN_CORPUS) {
|
|
70
71
|
try {
|
|
71
|
-
const where = ['
|
|
72
|
+
const where = [liveObsFilterSql('')];
|
|
72
73
|
const params = [];
|
|
73
74
|
if (project) { where.push('project = ?'); params.push(project); }
|
|
74
75
|
const row = db.prepare(`SELECT COUNT(*) AS c FROM observations WHERE ${where.join(' AND ')}`).get(...params);
|
package/hook-context.mjs
CHANGED
|
@@ -12,9 +12,10 @@ import {
|
|
|
12
12
|
debugLog, debugCatch, neutralizeContextDelimiters,
|
|
13
13
|
DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
|
|
14
14
|
} from './utils.mjs';
|
|
15
|
-
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
|
|
15
|
+
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks, KEY_CONTEXT_LIMIT } from './hook-shared.mjs';
|
|
16
16
|
import { extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
17
17
|
import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
18
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Infer the project directory from environment variables or cwd.
|
|
@@ -83,12 +84,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
83
84
|
const obsPool = db.prepare(`
|
|
84
85
|
SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
|
|
85
86
|
FROM observations
|
|
86
|
-
WHERE project = ? AND
|
|
87
|
-
-- superseded invisibility: auto-dedup (hook.mjs) sets superseded_at but leaves
|
|
88
|
-
-- compressed_into=0, so the compressed filter alone lets the hidden near-duplicate
|
|
89
|
-
-- resurface in the most-visible surface (injected every SessionStart). Sibling
|
|
90
|
-
-- keyObs already filters this; obsPool + fallbackObs must match.
|
|
91
|
-
AND superseded_at IS NULL
|
|
87
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
92
88
|
AND ${notLowSignalTitleClause('')}
|
|
93
89
|
AND (
|
|
94
90
|
(created_at_epoch > ? AND importance >= 1)
|
|
@@ -293,9 +289,15 @@ export function cleanupClaudeMdLegacyBlock() {
|
|
|
293
289
|
* @param {string|null} [currentCcSessionId=null] Claude Code session id — when provided,
|
|
294
290
|
* the "Working State (from /clear)" block is filtered to handoffs owned by this
|
|
295
291
|
* session, preventing parallel-session bleed (see docs/bug.txt).
|
|
292
|
+
* @param {object|null} [collector=null] Optional out-param: when given, its
|
|
293
|
+
* `keyContextIds` property is set to the obs ids ACTUALLY rendered into the
|
|
294
|
+
* File Lessons / Key Context sections ([] under quiet/adopted or when the
|
|
295
|
+
* sections are empty). handleUserPrompt persists this as its exclude-set
|
|
296
|
+
* (D#123: the exclude-set must mirror real injections, not the keyObs query).
|
|
296
297
|
* @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
|
|
297
298
|
*/
|
|
298
|
-
export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null) {
|
|
299
|
+
export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null, collector = null) {
|
|
300
|
+
if (collector) collector.keyContextIds = [];
|
|
299
301
|
// 1. Token-budgeted observation selection
|
|
300
302
|
const selected = selectWithTokenBudget(db, project, 2000);
|
|
301
303
|
const observations = selected.observations;
|
|
@@ -308,8 +310,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
308
310
|
fallbackObs = db.prepare(`
|
|
309
311
|
SELECT id, type, title, project, created_at
|
|
310
312
|
FROM observations
|
|
311
|
-
WHERE
|
|
312
|
-
AND superseded_at IS NULL
|
|
313
|
+
WHERE ${liveObsFilterSql('')}
|
|
313
314
|
AND ${notLowSignalTitleClause('')}
|
|
314
315
|
AND (
|
|
315
316
|
(created_at_epoch > ? AND importance >= 1)
|
|
@@ -335,10 +336,9 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
335
336
|
// and Key Context (informational). Pushed into summaryLines.
|
|
336
337
|
const keyObs = db.prepare(`
|
|
337
338
|
SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
|
|
338
|
-
WHERE o.project = ? AND
|
|
339
|
-
AND o.superseded_at IS NULL
|
|
339
|
+
WHERE o.project = ? AND ${liveObsFilterSql('o')}
|
|
340
340
|
AND COALESCE(o.importance, 1) >= 2
|
|
341
|
-
ORDER BY o.created_at_epoch DESC LIMIT
|
|
341
|
+
ORDER BY o.created_at_epoch DESC LIMIT ${KEY_CONTEXT_LIMIT}
|
|
342
342
|
`).all(project);
|
|
343
343
|
|
|
344
344
|
if (keyObs.length > 0) {
|
|
@@ -357,30 +357,35 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
357
357
|
const files = JSON.parse(o.files_modified);
|
|
358
358
|
const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
|
|
359
359
|
if (fname) {
|
|
360
|
-
fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
|
|
360
|
+
fileLessons.push({ id: o.id, line: `- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})` });
|
|
361
361
|
continue;
|
|
362
362
|
}
|
|
363
363
|
} catch { /* fall through to keyContext */ }
|
|
364
364
|
}
|
|
365
365
|
const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
|
|
366
|
-
keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
|
|
366
|
+
keyContext.push({ id: o.id, line: `- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}` });
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
// Phase A (QUIET_HOOKS) + Phase D (adopted sentinel): drop descriptive
|
|
370
370
|
// File Lessons / Key Context sections when the user has opted into low-noise
|
|
371
371
|
// hooks OR adopted invited-memory (MEMORY.md sentinel carries the triggers
|
|
372
372
|
// at higher system-prompt authority). The Recent table still fires so #IDs
|
|
373
|
-
// remain reachable via mem_get.
|
|
373
|
+
// remain reachable via mem_get. The collector sees only rows that survive
|
|
374
|
+
// BOTH the quiet gate and the per-section slice — rendered rows, nothing else.
|
|
374
375
|
const quiet = effectiveQuiet();
|
|
375
376
|
if (fileLessons.length > 0 && !quiet) {
|
|
377
|
+
const shown = fileLessons.slice(0, 5);
|
|
376
378
|
summaryLines.push('### File Lessons');
|
|
377
|
-
summaryLines.push(...
|
|
379
|
+
summaryLines.push(...shown.map((e) => e.line));
|
|
378
380
|
summaryLines.push('');
|
|
381
|
+
if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
|
|
379
382
|
}
|
|
380
383
|
if (keyContext.length > 0 && !quiet) {
|
|
384
|
+
const shown = keyContext.slice(0, 5);
|
|
381
385
|
summaryLines.push('### Key Context');
|
|
382
|
-
summaryLines.push(...
|
|
386
|
+
summaryLines.push(...shown.map((e) => e.line));
|
|
383
387
|
summaryLines.push('');
|
|
388
|
+
if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
|
|
384
389
|
}
|
|
385
390
|
} else if (!latestSummary && !effectiveQuiet()) {
|
|
386
391
|
// Fallback: no summary AND no key observations — show recent activity.
|
package/hook-handoff.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
// immutable in ESM and cannot be mocked after the fact.
|
|
14
14
|
import * as gitStateModule from './lib/git-state.mjs';
|
|
15
15
|
import * as taskReaderModule from './lib/task-reader.mjs';
|
|
16
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Build and save a handoff snapshot to session_handoffs table.
|
|
@@ -76,8 +77,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
76
77
|
if (subjectPrompts.length === 0) {
|
|
77
78
|
const fallback = db.prepare(`
|
|
78
79
|
SELECT title FROM observations
|
|
79
|
-
WHERE project = ? AND
|
|
80
|
-
AND superseded_at IS NULL
|
|
80
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
81
81
|
AND COALESCE(importance, 1) >= 3
|
|
82
82
|
AND ${notLowSignalTitleClause('')}
|
|
83
83
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
@@ -185,8 +185,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
185
185
|
const decisions = db.prepare(`
|
|
186
186
|
SELECT title FROM observations
|
|
187
187
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
188
|
-
AND
|
|
189
|
-
AND superseded_at IS NULL ${obsWindowClause}
|
|
188
|
+
AND ${liveObsFilterSql('')} ${obsWindowClause}
|
|
190
189
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
191
190
|
`).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
192
191
|
|
package/hook-memory.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
|
|
5
5
|
import { citeFactorJs } from './scoring-sql.mjs';
|
|
6
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
6
7
|
import { recordMetric } from './lib/metrics.mjs';
|
|
7
8
|
import { DB_DIR } from './schema.mjs';
|
|
8
9
|
import { extractIdents } from './lib/lesson-idents.mjs';
|
|
@@ -218,8 +219,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
218
219
|
AND o.project = ?
|
|
219
220
|
AND o.importance >= 1
|
|
220
221
|
AND o.created_at_epoch > ?
|
|
221
|
-
AND
|
|
222
|
-
AND o.superseded_at IS NULL
|
|
222
|
+
AND ${liveObsFilterSql('o')}
|
|
223
223
|
AND ${notLowSignalTitleClause('o')}
|
|
224
224
|
ORDER BY ${OBS_BM25}
|
|
225
225
|
LIMIT 10
|
|
@@ -266,8 +266,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
266
266
|
AND o.type IN ('decision', 'discovery')
|
|
267
267
|
AND o.importance >= 2
|
|
268
268
|
AND o.created_at_epoch > ?
|
|
269
|
-
AND
|
|
270
|
-
AND o.superseded_at IS NULL
|
|
269
|
+
AND ${liveObsFilterSql('o')}
|
|
271
270
|
AND ${notLowSignalTitleClause('o')}
|
|
272
271
|
ORDER BY ${OBS_BM25}
|
|
273
272
|
LIMIT 5
|
|
@@ -316,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
316
315
|
// Adaptive threshold: scales with corpus size to filter noise.
|
|
317
316
|
// Each result must individually exceed the threshold (not just the top one).
|
|
318
317
|
const obsCount = db.prepare(
|
|
319
|
-
|
|
318
|
+
`SELECT COUNT(*) as c FROM observations WHERE project = ? AND ${liveObsFilterSql('')}`,
|
|
320
319
|
).get(project)?.c || 0;
|
|
321
320
|
const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
|
|
322
321
|
const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
|
|
@@ -389,8 +388,7 @@ export function recallForFile(db, filePath, project) {
|
|
|
389
388
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
390
389
|
WHERE o.project = ?
|
|
391
390
|
AND o.importance >= 2
|
|
392
|
-
AND
|
|
393
|
-
AND o.superseded_at IS NULL
|
|
391
|
+
AND ${liveObsFilterSql('o')}
|
|
394
392
|
AND o.created_at_epoch > ?
|
|
395
393
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
396
394
|
ORDER BY o.created_at_epoch DESC
|
|
@@ -431,8 +429,7 @@ export function rankImperativeCandidates(db, userPrompt, project, excludeIds = [
|
|
|
431
429
|
SELECT id, title, lesson_learned, importance
|
|
432
430
|
FROM observations
|
|
433
431
|
WHERE project = ?
|
|
434
|
-
AND
|
|
435
|
-
AND superseded_at IS NULL
|
|
432
|
+
AND ${liveObsFilterSql('')}
|
|
436
433
|
AND COALESCE(importance, 1) >= 2
|
|
437
434
|
AND lesson_learned IS NOT NULL
|
|
438
435
|
AND TRIM(lesson_learned) != ''
|
package/hook-optimize.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from '.
|
|
|
20
20
|
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
21
21
|
import { DB_DIR } from './schema.mjs';
|
|
22
22
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
23
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
23
24
|
|
|
24
25
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
25
26
|
|
|
@@ -104,8 +105,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
104
105
|
const stmt = db.prepare(`
|
|
105
106
|
SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project
|
|
106
107
|
FROM observations
|
|
107
|
-
WHERE
|
|
108
|
-
AND superseded_at IS NULL
|
|
108
|
+
WHERE ${liveObsFilterSql('')}
|
|
109
109
|
AND (search_aliases IS NULL OR search_aliases = '')
|
|
110
110
|
AND LENGTH(COALESCE(narrative, '')) > 100
|
|
111
111
|
AND ${notLowSignalTitleClause('')}
|
|
@@ -119,8 +119,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
119
119
|
const stmt = db.prepare(`
|
|
120
120
|
SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
|
|
121
121
|
FROM observations
|
|
122
|
-
WHERE
|
|
123
|
-
AND superseded_at IS NULL
|
|
122
|
+
WHERE ${liveObsFilterSql('')}
|
|
124
123
|
AND optimized_at IS NULL
|
|
125
124
|
AND type IN ('bugfix','refactor','feature','decision')
|
|
126
125
|
AND (lesson_learned IS NULL OR lesson_learned = '')
|
|
@@ -459,8 +458,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
459
458
|
const stmt = db.prepare(`
|
|
460
459
|
SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
|
|
461
460
|
FROM observations
|
|
462
|
-
WHERE
|
|
463
|
-
AND superseded_at IS NULL
|
|
461
|
+
WHERE ${liveObsFilterSql('')}
|
|
464
462
|
AND optimized_at IS NULL
|
|
465
463
|
AND title IS NOT NULL AND title != ''
|
|
466
464
|
AND created_at_epoch > ?
|
package/hook-precompact.mjs
CHANGED
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
// Differs from SessionStart-on-compact (which fires AFTER compaction):
|
|
6
6
|
// PreCompact ensures memory survives the compaction step itself.
|
|
7
7
|
|
|
8
|
+
import { writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
8
10
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
9
11
|
import { inferProject, debugCatch, debugLog } from './utils.mjs';
|
|
12
|
+
import { RUNTIME_DIR } from './hook-shared.mjs';
|
|
13
|
+
import { keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
|
-
* Build + emit the memory context block on stdout. Pure read; no DB writes
|
|
16
|
+
* Build + emit the memory context block on stdout. Pure read; no DB writes
|
|
17
|
+
* (one runtime marker file: the Key Context ids the re-emitted block renders,
|
|
18
|
+
* refreshing handleUserPrompt's exclude-set — see D#123 in hook.mjs).
|
|
13
19
|
*
|
|
14
20
|
* @param {object} ctx
|
|
15
21
|
* @param {import('better-sqlite3').Database} ctx.db
|
|
@@ -19,9 +25,16 @@ import { inferProject, debugCatch, debugLog } from './utils.mjs';
|
|
|
19
25
|
*/
|
|
20
26
|
export function handlePreCompact({ db, project, sessionId }) {
|
|
21
27
|
try {
|
|
22
|
-
const
|
|
28
|
+
const collector = {};
|
|
29
|
+
const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
|
|
23
30
|
if (!body || String(body).trim() === '') return;
|
|
24
31
|
process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(RUNTIME_DIR, keyContextIdsFileName(project, sessionId || null)),
|
|
35
|
+
JSON.stringify({ ids: collector.keyContextIds || [], ts: Date.now(), session: sessionId || null }),
|
|
36
|
+
);
|
|
37
|
+
} catch (e) { debugCatch(e, 'pre-compact-keyctx-marker'); }
|
|
25
38
|
} catch (e) {
|
|
26
39
|
debugCatch(e, 'handlePreCompact');
|
|
27
40
|
}
|
package/hook-shared.mjs
CHANGED
|
@@ -32,6 +32,12 @@ export const STALE_LOCK_MS = 30000; // 30s
|
|
|
32
32
|
export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
33
33
|
export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
|
|
34
34
|
export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
|
|
35
|
+
// Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
|
|
36
|
+
// keyObs; each of the two sections then renders at most 5). The user-prompt
|
|
37
|
+
// exclude-set does NOT mirror this query — it reads the ids actually rendered
|
|
38
|
+
// from the keyctx marker (D#123 review C-1: query-mirroring suppressed
|
|
39
|
+
// <memory-context> injection on quiet/adopted projects where nothing renders).
|
|
40
|
+
export const KEY_CONTEXT_LIMIT = 10;
|
|
35
41
|
|
|
36
42
|
// Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
|
|
37
43
|
// bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
|
package/hook.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
|
68
68
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
69
69
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
70
70
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
71
|
+
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
72
|
+
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
71
73
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
72
74
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
73
75
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
@@ -491,15 +493,12 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
491
493
|
-- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
|
|
492
494
|
-- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
|
|
493
495
|
-- so that pointer would resolve to nothing.
|
|
494
|
-
AND
|
|
495
|
-
AND o.superseded_at IS NULL
|
|
496
|
+
AND ${liveObsFilterSql('o')}
|
|
496
497
|
AND ${notLowSignalTitleClause('o')}
|
|
497
|
-
--
|
|
498
|
-
--
|
|
499
|
-
-- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
|
|
500
|
-
-- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
|
|
498
|
+
-- Decay via the shared core (P2-11): the M-1 MAX(0,…) age clamp lives there.
|
|
499
|
+
-- Fixed 14d half-life (error recency matters more than obs type here).
|
|
501
500
|
ORDER BY ${OBS_BM25}
|
|
502
|
-
* (
|
|
501
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
|
|
503
502
|
LIMIT 3
|
|
504
503
|
`).all(ftsQuery, project, nowR);
|
|
505
504
|
|
|
@@ -857,16 +856,22 @@ function buildCiteRecallNudge(project) {
|
|
|
857
856
|
return libBuildCiteRecallNudge(project, RUNTIME_DIR);
|
|
858
857
|
}
|
|
859
858
|
|
|
860
|
-
// GC
|
|
861
|
-
// (
|
|
862
|
-
//
|
|
863
|
-
//
|
|
859
|
+
// GC stale per-session runtime files older than 24h: pre-recall cooldowns AND
|
|
860
|
+
// (D#120) the per-session injected-ids markers — both grow one file per session.
|
|
861
|
+
// Pulled out of pre-tool-recall.js (where it ran on every Edit, costing 15-30
|
|
862
|
+
// disk stats per call on long-lived projects) and consolidated here — once per
|
|
863
|
+
// SessionStart is enough to keep RUNTIME_DIR from growing unbounded.
|
|
864
864
|
const PRE_RECALL_COOLDOWN_STALE_MS = 24 * 60 * 60 * 1000;
|
|
865
865
|
function gcStalePreRecallCooldowns() {
|
|
866
866
|
try {
|
|
867
867
|
const now = Date.now();
|
|
868
868
|
for (const name of readdirSync(RUNTIME_DIR)) {
|
|
869
|
-
|
|
869
|
+
// D#120: the injected-ids marker is also per-session now — same growth
|
|
870
|
+
// shape as the cooldown files, same 24h GC (dedup window is 5 min).
|
|
871
|
+
const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
|
|
872
|
+
const isInjectedMarker = name.startsWith('.claude-mem-injected-')
|
|
873
|
+
|| name.startsWith('.claude-mem-keyctx-'); // D#123 Key Context marker — same per-session growth, same 24h GC
|
|
874
|
+
if (!isCooldown && !isInjectedMarker) continue;
|
|
870
875
|
try {
|
|
871
876
|
const p = join(RUNTIME_DIR, name);
|
|
872
877
|
const st = statSync(p);
|
|
@@ -1036,10 +1041,8 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1036
1041
|
JOIN observations b ON a.title = b.title AND a.project = b.project
|
|
1037
1042
|
AND a.id < b.id
|
|
1038
1043
|
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
1039
|
-
AND
|
|
1040
|
-
AND
|
|
1041
|
-
AND a.superseded_at IS NULL
|
|
1042
|
-
AND b.superseded_at IS NULL
|
|
1044
|
+
AND ${liveObsFilterSql('a')}
|
|
1045
|
+
AND ${liveObsFilterSql('b')}
|
|
1043
1046
|
LIMIT 20
|
|
1044
1047
|
`).all();
|
|
1045
1048
|
if (dupPairs.length > 0) {
|
|
@@ -1062,8 +1065,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1062
1065
|
const recent = db.prepare(`
|
|
1063
1066
|
SELECT id, title, importance, created_at_epoch, narrative, text
|
|
1064
1067
|
FROM observations
|
|
1065
|
-
WHERE
|
|
1066
|
-
AND superseded_at IS NULL
|
|
1068
|
+
WHERE ${liveObsFilterSql('')}
|
|
1067
1069
|
AND created_at_epoch > ?
|
|
1068
1070
|
AND title IS NOT NULL AND title != ''
|
|
1069
1071
|
ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
|
|
@@ -1470,13 +1472,28 @@ async function handleSessionStart() {
|
|
|
1470
1472
|
// token-budgeted observation pool directly from the DB.
|
|
1471
1473
|
// Pass CC session id so the Working State block is scoped to this session,
|
|
1472
1474
|
// preventing parallel sessions from seeing each other's /clear handoff.
|
|
1473
|
-
const
|
|
1475
|
+
const contextCollector = {};
|
|
1476
|
+
const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
|
|
1474
1477
|
|
|
1475
1478
|
// Stdout is the sole context-delivery channel. The SessionStart hook output
|
|
1476
1479
|
// is injected as a <system-reminder> at session start, giving Claude the
|
|
1477
1480
|
// full summary + handoff state + observations table fresh from the DB.
|
|
1478
1481
|
process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
|
|
1479
1482
|
|
|
1483
|
+
// D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
|
|
1484
|
+
// handleUserPrompt can exclude exactly those from <memory-context> — and
|
|
1485
|
+
// nothing else. Under quiet/adopted the sections don't render, the id list is
|
|
1486
|
+
// empty, and prompt-time injection is NOT suppressed (the old query-mirroring
|
|
1487
|
+
// exclude-set blanked the same-project leg on adopted projects). Written
|
|
1488
|
+
// unconditionally (even when empty) so a resumed session can't act on a
|
|
1489
|
+
// previous session's stale marker semantics; 24h GC below.
|
|
1490
|
+
try {
|
|
1491
|
+
writeFileSync(
|
|
1492
|
+
join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)),
|
|
1493
|
+
JSON.stringify({ ids: contextCollector.keyContextIds || [], ts: Date.now(), session: ccSessionId || null }),
|
|
1494
|
+
);
|
|
1495
|
+
} catch (e) { debugCatch(e, 'session-start-keyctx-marker'); }
|
|
1496
|
+
|
|
1480
1497
|
// One-time migration: remove any stale <claude-mem-context> block left in
|
|
1481
1498
|
// CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
|
|
1482
1499
|
cleanupClaudeMdLegacyBlock();
|
|
@@ -1650,23 +1667,33 @@ async function handleUserPrompt() {
|
|
|
1650
1667
|
// (mirrors CC built-in memoryTypes.ts:215). Skip both Key Context lookup
|
|
1651
1668
|
// and the <memory-context> emission for this turn.
|
|
1652
1669
|
if (!detectMemOverride(promptText)) try {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1670
|
+
// D#123 (review C-1): the exclude-set is the Key Context ids ACTUALLY
|
|
1671
|
+
// rendered at SessionStart — read from the marker handleSessionStart wrote,
|
|
1672
|
+
// not re-derived from a query. The old query-mirroring set excluded rows
|
|
1673
|
+
// that were never shown (quiet/adopted projects render no Key Context at
|
|
1674
|
+
// all), blanking the same-project <memory-context> leg outright. Missing
|
|
1675
|
+
// or other-session marker → empty set: unknown injections must fail open
|
|
1676
|
+
// (inject, maybe duplicate) rather than fail closed (suppress).
|
|
1677
|
+
const keyContextIds = [];
|
|
1678
|
+
try {
|
|
1679
|
+
const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
|
|
1680
|
+
const { ids, session } = JSON.parse(raw);
|
|
1681
|
+
if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
|
|
1682
|
+
keyContextIds.push(...ids);
|
|
1683
|
+
}
|
|
1684
|
+
} catch { /* no marker — nothing was injected, exclude nothing */ }
|
|
1660
1685
|
const pathAInjectedIds = [];
|
|
1661
1686
|
|
|
1662
1687
|
// Read IDs already injected by user-prompt-search.js to avoid duplicate injection
|
|
1663
1688
|
try {
|
|
1664
|
-
|
|
1689
|
+
// D#120: the marker file is session-keyed (no ccSessionId → legacy
|
|
1690
|
+
// project-keyed name), so a concurrent session's write can no longer
|
|
1691
|
+
// replace this session's payload between the UPS write and this read.
|
|
1692
|
+
const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
|
|
1665
1693
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
1666
1694
|
const { ids, ts, session } = JSON.parse(raw);
|
|
1667
1695
|
// Only use if written within last 10 seconds (same prompt cycle) AND by this
|
|
1668
|
-
// CC session
|
|
1669
|
-
// would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
|
|
1696
|
+
// CC session (M-6 payload gate, still load-bearing for legacy files).
|
|
1670
1697
|
// Legacy payloads without `session` keep the old time-window-only behavior.
|
|
1671
1698
|
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
|
|
1672
1699
|
&& !(session && ccSessionId && session !== ccSessionId)) {
|
|
@@ -1683,8 +1710,9 @@ async function handleUserPrompt() {
|
|
|
1683
1710
|
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
1684
1711
|
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
1685
1712
|
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
|
1686
|
-
//
|
|
1687
|
-
// the pick. The chosen id is excluded from the <memory-context>
|
|
1713
|
+
// SessionStart Key Context set, which overlaps the high-value lesson pool and
|
|
1714
|
+
// would suppress the pick. The chosen id is excluded from the <memory-context>
|
|
1715
|
+
// block below instead.
|
|
1688
1716
|
const imperativePick = taskImperativeOn
|
|
1689
1717
|
? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
|
|
1690
1718
|
: null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/browse-core.mjs — shared data collection for the CLI `browse` / MCP
|
|
2
|
+
// `mem_browse` twin (P2-12, audit 2026-08-14). The tier count + row queries were
|
|
3
|
+
// duplicated and had already drifted (the CLI SELECT carried `importance`, the
|
|
4
|
+
// MCP one had dropped it). Collection lives here with the superset column shape;
|
|
5
|
+
// each face keeps its own rendering (text dashboard vs --json vs MCP text).
|
|
6
|
+
|
|
7
|
+
import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
8
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
9
|
+
|
|
10
|
+
export const BROWSE_TIERS = ['working', 'active', 'archive'];
|
|
11
|
+
export const BROWSE_TIER_LABELS = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
|
|
12
|
+
|
|
13
|
+
/** Newest active memory_session_id for the project ('' when none) — the tier
|
|
14
|
+
* classifier's "current session" input, needed identically by both faces. */
|
|
15
|
+
export function getActiveMemorySessionId(db, project) {
|
|
16
|
+
const row = db.prepare(
|
|
17
|
+
"SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
|
|
18
|
+
).get(project);
|
|
19
|
+
return row?.memory_session_id ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collect per-tier counts + rows for the memory dashboard.
|
|
24
|
+
* Archive keeps its count but skips row fetch in the unfiltered view (both faces'
|
|
25
|
+
* documented behavior — the archive tail is reachable via `browse --tier archive`).
|
|
26
|
+
* @returns {{showTiers: string[], tierData: object, tierCounts: object, grandTotal: number}}
|
|
27
|
+
*/
|
|
28
|
+
export function collectBrowseTiers(db, { project, tierFilter, limit, now, currentSessionId }) {
|
|
29
|
+
const ctx = { now, currentProject: project, currentSessionId };
|
|
30
|
+
const params = tierSqlParams(ctx);
|
|
31
|
+
const showTiers = tierFilter ? [tierFilter] : BROWSE_TIERS;
|
|
32
|
+
|
|
33
|
+
const tierData = {};
|
|
34
|
+
const tierCounts = {};
|
|
35
|
+
let grandTotal = 0;
|
|
36
|
+
|
|
37
|
+
for (const tier of showTiers) {
|
|
38
|
+
const countRow = db.prepare(`
|
|
39
|
+
SELECT COUNT(*) as c FROM (
|
|
40
|
+
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
41
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
42
|
+
) WHERE tier = ?
|
|
43
|
+
`).get(...params, project, tier);
|
|
44
|
+
const count = countRow?.c ?? 0;
|
|
45
|
+
tierCounts[tier] = count;
|
|
46
|
+
grandTotal += count;
|
|
47
|
+
|
|
48
|
+
const skipRows = tier === 'archive' && !tierFilter;
|
|
49
|
+
if (count === 0 || skipRows) {
|
|
50
|
+
tierData[tier] = { count, rows: [] };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rows = db.prepare(`
|
|
55
|
+
SELECT * FROM (
|
|
56
|
+
SELECT id, type, title, importance, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
|
|
57
|
+
FROM observations
|
|
58
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
59
|
+
) WHERE tier = ?
|
|
60
|
+
ORDER BY created_at_epoch DESC
|
|
61
|
+
LIMIT ?
|
|
62
|
+
`).all(...params, project, tier, limit);
|
|
63
|
+
tierData[tier] = { count, rows };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { showTiers, tierData, tierCounts, grandTotal };
|
|
67
|
+
}
|
package/lib/delete-core.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// merged/compressed-child recovery, and the delete transaction.
|
|
8
8
|
import { snapshotDb } from './db-backup.mjs';
|
|
9
9
|
import { recoverChildrenOf } from './maintain-core.mjs';
|
|
10
|
-
import { debugCatch } from '../utils.mjs';
|
|
10
|
+
import { debugCatch, truncate } from '../utils.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Hard-delete the given observation ids with full orchestration. The CALLER owns
|
|
@@ -70,3 +70,20 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
|
|
|
70
70
|
const result = deleteTx();
|
|
71
71
|
return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shared delete-preview body (P2-12): fetch the doomed rows and format the
|
|
76
|
+
* per-row lines both faces print between their own header ("Preview: N …")
|
|
77
|
+
* and footer (--confirm vs confirm=true remedy). The SELECT and the row shape
|
|
78
|
+
* were duplicated in mem-cli.mjs + server.mjs and are the exact place a
|
|
79
|
+
* preview/execute drift would hide.
|
|
80
|
+
* @param {import('better-sqlite3').Database} db
|
|
81
|
+
* @param {number[]} ids
|
|
82
|
+
* @returns {{rows: Array<{id:number,type:string,title:string,project:string}>, lines: string[]}}
|
|
83
|
+
*/
|
|
84
|
+
export function previewDeleteRows(db, ids) {
|
|
85
|
+
const ph = ids.map(() => '?').join(',');
|
|
86
|
+
const rows = db.prepare(`SELECT id, type, title, project FROM observations WHERE id IN (${ph})`).all(...ids);
|
|
87
|
+
const lines = rows.map((r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
|
|
88
|
+
return { rows, lines };
|
|
89
|
+
}
|
package/lib/get-core.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// lib/get-core.mjs — shared core for the CLI `get` / MCP `mem_get` twin (P2-12,
|
|
2
|
+
// audit 2026-08-14). The 23-element OBS_FIELDS array was duplicated verbatim in
|
|
3
|
+
// mem-cli.mjs and server.mjs (the 16-vs-24-column export data-loss incident's
|
|
4
|
+
// precursor shape), and the session detail field sets had ALREADY diverged
|
|
5
|
+
// (MCP 13 fields vs CLI 6 — a `remaining_items` FTS hit was a dead end in the
|
|
6
|
+
// CLI detail view). Field sets + the access-bump fetch live here; each face
|
|
7
|
+
// keeps its own header/label rendering conventions.
|
|
8
|
+
|
|
9
|
+
import { autoBoostIfNeeded } from '../search-scoring.mjs';
|
|
10
|
+
|
|
11
|
+
/** Every observation column `get --fields` accepts, in render order. */
|
|
12
|
+
export const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
|
|
13
|
+
|
|
14
|
+
/** Session-summary detail render set — the FULL set (both faces). The CLI's old
|
|
15
|
+
* 6-field subset made notes/remaining_items/files_* searchable-but-unrenderable. */
|
|
16
|
+
export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'remaining_items', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch observation detail rows: bump access_count/last_accessed_at (reading a
|
|
20
|
+
* detail IS an access signal — feeds noisePenalty's ratio guard), run the
|
|
21
|
+
* auto-boost heuristic, and return rows oldest-first.
|
|
22
|
+
* @param {import('better-sqlite3').Database} db
|
|
23
|
+
* @param {number[]} ids
|
|
24
|
+
* @returns {object[]} full observation rows (SELECT *), created order
|
|
25
|
+
*/
|
|
26
|
+
export function fetchObsDetail(db, ids) {
|
|
27
|
+
const ph = ids.map(() => '?').join(',');
|
|
28
|
+
try {
|
|
29
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...ids);
|
|
30
|
+
autoBoostIfNeeded(db, ids);
|
|
31
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
32
|
+
return db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
33
|
+
}
|