claude-mem-lite 2.37.0 → 2.39.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/hook-memory.mjs +53 -2
- package/lib/id-routing.mjs +37 -0
- package/mem-cli.mjs +227 -73
- package/package.json +2 -1
- package/server.mjs +9 -11
- package/source-files.mjs +1 -0
- package/tool-schemas.mjs +3 -1
package/hook-memory.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// claude-mem-lite — Semantic Memory Injection
|
|
2
2
|
// Search past observations for relevant memories to inject as context at user-prompt time.
|
|
3
3
|
|
|
4
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause } from './utils.mjs';
|
|
4
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
|
|
5
5
|
|
|
6
6
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
7
7
|
const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
@@ -16,6 +16,43 @@ const BM25_THRESHOLD = { TINY: 0, SMALL: 1.5, MEDIUM: 2.5, LARGE: 3.5 };
|
|
|
16
16
|
// OR fallback max token count — queries with 3+ tokens that fail AND are likely off-topic
|
|
17
17
|
const OR_FALLBACK_MAX_TOKENS = 2;
|
|
18
18
|
|
|
19
|
+
// v27: term-coverage post-filter. Drops high-BM25 candidates whose visible
|
|
20
|
+
// fields (title + lesson_learned) cover <N% of the query's significant terms.
|
|
21
|
+
// Catches the failure mode where FTS tokenization reduces a rich query to a
|
|
22
|
+
// sparse token set and rows sharing only one common word get ranked high.
|
|
23
|
+
// Default 0.4 (≥40% term coverage). Env override `MEM_COVERAGE_THRESHOLD` ∈ [0,1];
|
|
24
|
+
// set to 0 to disable entirely.
|
|
25
|
+
const COVERAGE_MIN_QUERY_TERMS = 2;
|
|
26
|
+
function getCoverageThreshold() {
|
|
27
|
+
const raw = process.env.MEM_COVERAGE_THRESHOLD;
|
|
28
|
+
if (raw === undefined || raw === '') return 0.4;
|
|
29
|
+
const n = parseFloat(raw);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
|
|
31
|
+
}
|
|
32
|
+
function extractQueryTerms(text) {
|
|
33
|
+
if (!text) return [];
|
|
34
|
+
const ascii = tokenizeHandoff(text).filter(t => !HANDOFF_STOP_WORDS.has(t));
|
|
35
|
+
let cjk = [];
|
|
36
|
+
try { cjk = extractCjkKeywords(text) || []; } catch { /* CJK extraction best-effort */ }
|
|
37
|
+
return [...new Set([...ascii, ...cjk.map(t => String(t).toLowerCase())])];
|
|
38
|
+
}
|
|
39
|
+
function candidateCoverage(row, queryTerms) {
|
|
40
|
+
if (queryTerms.length === 0) return 1.0;
|
|
41
|
+
const hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
|
|
42
|
+
let hits = 0;
|
|
43
|
+
for (const t of queryTerms) {
|
|
44
|
+
if (/[^ -~]/.test(t)) {
|
|
45
|
+
// Non-ASCII (CJK etc): no word boundary semantics, substring match is correct.
|
|
46
|
+
if (hay.includes(t)) hits++;
|
|
47
|
+
} else {
|
|
48
|
+
// ASCII: require word-boundary match so "race" doesn't match "trace".
|
|
49
|
+
const re = new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
50
|
+
if (re.test(hay)) hits++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return hits / queryTerms.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
20
57
|
const MAX_FILE_RECALL = 2;
|
|
21
58
|
|
|
@@ -142,13 +179,27 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
142
179
|
const aboveThreshold = scored.filter(r => r.score >= threshold);
|
|
143
180
|
if (aboveThreshold.length === 0) return [];
|
|
144
181
|
|
|
182
|
+
// v27: term-coverage filter — drop candidates whose title+lesson_learned
|
|
183
|
+
// covers <threshold of the query's significant terms. Skipped for
|
|
184
|
+
// single-term queries (coverage signal is meaningless) and when the env
|
|
185
|
+
// override disables it.
|
|
186
|
+
let coverageFiltered = aboveThreshold;
|
|
187
|
+
const coverageThreshold = getCoverageThreshold();
|
|
188
|
+
if (coverageThreshold > 0) {
|
|
189
|
+
const queryTerms = extractQueryTerms(userPrompt);
|
|
190
|
+
if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
|
|
191
|
+
coverageFiltered = aboveThreshold.filter(r => candidateCoverage(r, queryTerms) >= coverageThreshold);
|
|
192
|
+
if (coverageFiltered.length === 0) return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
145
196
|
// v26 P0: bump injection_count (NOT access_count) for injected rows.
|
|
146
197
|
// Before v26 this was bumping access_count, which conflated auto-injection
|
|
147
198
|
// with real cites/recalls/opens — polluting the noise-ratio signal the
|
|
148
199
|
// penalty clause now depends on. access_count is reserved for explicit
|
|
149
200
|
// access (cmdRecall/cmdGet/cmdTimeline/pre-tool-recall/citation-tracker).
|
|
150
201
|
// Per-row try/catch for FTS trigger safety (project_non_obvious.md).
|
|
151
|
-
const result =
|
|
202
|
+
const result = coverageFiltered.slice(0, MAX_MEMORY_INJECTIONS);
|
|
152
203
|
const now = Date.now();
|
|
153
204
|
const bumpStmt = db.prepare(
|
|
154
205
|
'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Shared probe for "ID-not-found-in-requested-source" hints.
|
|
2
|
+
// Used by CLI cmdGet (mem-cli.mjs) and MCP mem_get (server.mjs) so both
|
|
3
|
+
// produce consistent redirect hints — if the probe schema drifts, both
|
|
4
|
+
// paths update together.
|
|
5
|
+
//
|
|
6
|
+
// The formatter stays per-call-site because CLI and MCP surface format
|
|
7
|
+
// differently (stderr vs response text); only the SQL layer is shared.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Probe the observations / session_summaries / user_prompts tables for any
|
|
11
|
+
* of the given numeric IDs, excluding the sources the caller already queried.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('better-sqlite3').Database} db
|
|
14
|
+
* @param {number[]} ids Numeric IDs to probe (non-negative ints).
|
|
15
|
+
* @param {Set<'obs'|'session'|'prompt'>} excludeSrcs Sources to skip.
|
|
16
|
+
* @returns {{obs:number[], session:number[], prompt:number[]}}
|
|
17
|
+
*/
|
|
18
|
+
export function probeOtherSources(db, ids, excludeSrcs) {
|
|
19
|
+
const result = { obs: [], session: [], prompt: [] };
|
|
20
|
+
if (!ids || ids.length === 0) return result;
|
|
21
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
22
|
+
try {
|
|
23
|
+
if (!excludeSrcs.has('obs')) {
|
|
24
|
+
const hits = db.prepare(`SELECT id FROM observations WHERE id IN (${placeholders})`).all(...ids);
|
|
25
|
+
result.obs = hits.map(r => r.id);
|
|
26
|
+
}
|
|
27
|
+
if (!excludeSrcs.has('session')) {
|
|
28
|
+
const hits = db.prepare(`SELECT id FROM session_summaries WHERE id IN (${placeholders})`).all(...ids);
|
|
29
|
+
result.session = hits.map(r => r.id);
|
|
30
|
+
}
|
|
31
|
+
if (!excludeSrcs.has('prompt')) {
|
|
32
|
+
const hits = db.prepare(`SELECT id FROM user_prompts WHERE id IN (${placeholders})`).all(...ids);
|
|
33
|
+
result.prompt = hits.map(r => r.id);
|
|
34
|
+
}
|
|
35
|
+
} catch { /* best-effort hint; never block the caller */ }
|
|
36
|
+
return result;
|
|
37
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { searchResources } from './registry-retriever.mjs';
|
|
|
15
15
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
16
16
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
17
17
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
18
|
+
import { probeOtherSources as probeIdSources } from './lib/id-routing.mjs';
|
|
18
19
|
import { basename } from 'path';
|
|
19
20
|
import { readFileSync } from 'fs';
|
|
20
21
|
|
|
@@ -77,6 +78,30 @@ function fmtDateShort(iso) {
|
|
|
77
78
|
return iso.slice(0, 10); // YYYY-MM-DD
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// Parse an ID token from a command positional argument.
|
|
82
|
+
// Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
|
|
83
|
+
// Returns { source: 'obs'|'session'|'prompt'|null, id: number } or null if unparseable.
|
|
84
|
+
// source===null means no explicit prefix — caller picks default (typically 'obs').
|
|
85
|
+
function parseIdToken(raw) {
|
|
86
|
+
const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const p = m[1].toUpperCase();
|
|
89
|
+
const id = parseInt(m[2], 10);
|
|
90
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
91
|
+
const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
|
|
92
|
+
return { source, id };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Format the shared `probeIdSources` output as CLI hint strings.
|
|
96
|
+
// Example: ["#5419 (obs)", "P#5417 (prompt)"] — callers join with "; ".
|
|
97
|
+
function formatProbeHints(probe) {
|
|
98
|
+
const hints = [];
|
|
99
|
+
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs)`);
|
|
100
|
+
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session)`);
|
|
101
|
+
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt)`);
|
|
102
|
+
return hints;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
81
106
|
|
|
82
107
|
function cmdSearch(db, args) {
|
|
@@ -571,57 +596,107 @@ function cmdRecall(db, args) {
|
|
|
571
596
|
}
|
|
572
597
|
}
|
|
573
598
|
|
|
599
|
+
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'];
|
|
600
|
+
|
|
601
|
+
function renderObsRows(db, ids, requestedFields) {
|
|
602
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
603
|
+
try {
|
|
604
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
|
|
605
|
+
autoBoostIfNeeded(db, ids);
|
|
606
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
607
|
+
|
|
608
|
+
const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
609
|
+
if (rows.length === 0) return null;
|
|
610
|
+
const fields = requestedFields || OBS_FIELDS;
|
|
611
|
+
const parts = [];
|
|
612
|
+
for (const r of rows) {
|
|
613
|
+
const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
|
|
614
|
+
for (const f of fields) {
|
|
615
|
+
if (f === 'id' || f === 'type' || f === 'created_at') continue;
|
|
616
|
+
const val = r[f];
|
|
617
|
+
if (val === null || val === undefined || val === '') continue;
|
|
618
|
+
if (f === 'text' && r.narrative && typeof val === 'string' && val.startsWith(r.narrative)) continue;
|
|
619
|
+
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
620
|
+
const display = typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
|
|
621
|
+
lines.push(`${f}: ${display}`);
|
|
622
|
+
}
|
|
623
|
+
parts.push(lines.join('\n'));
|
|
624
|
+
}
|
|
625
|
+
return { text: parts.join('\n\n'), count: rows.length };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function renderSessionRows(db, ids) {
|
|
629
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
630
|
+
const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
631
|
+
if (rows.length === 0) return null;
|
|
632
|
+
const parts = [];
|
|
633
|
+
for (const r of rows) {
|
|
634
|
+
const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
|
|
635
|
+
if (r.request) lines.push(`Request: ${r.request}`);
|
|
636
|
+
if (r.completed) lines.push(`Completed: ${r.completed}`);
|
|
637
|
+
if (r.investigated) lines.push(`Investigated: ${r.investigated}`);
|
|
638
|
+
if (r.learned) lines.push(`Learned: ${r.learned}`);
|
|
639
|
+
if (r.next_steps) lines.push(`Next steps: ${r.next_steps}`);
|
|
640
|
+
if (r.project) lines.push(`Project: ${r.project}`);
|
|
641
|
+
parts.push(lines.join('\n'));
|
|
642
|
+
}
|
|
643
|
+
return { text: parts.join('\n\n'), count: rows.length };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function renderPromptRows(db, ids) {
|
|
647
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
648
|
+
const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
649
|
+
if (rows.length === 0) return null;
|
|
650
|
+
const parts = [];
|
|
651
|
+
for (const r of rows) {
|
|
652
|
+
const lines = [`P#${r.id} ${fmtDateShort(r.created_at)}`];
|
|
653
|
+
if (r.prompt_text) lines.push(`Text: ${r.prompt_text}`);
|
|
654
|
+
if (r.content_session_id) lines.push(`Session: ${r.content_session_id}`);
|
|
655
|
+
parts.push(lines.join('\n'));
|
|
656
|
+
}
|
|
657
|
+
return { text: parts.join('\n\n'), count: rows.length };
|
|
658
|
+
}
|
|
659
|
+
|
|
574
660
|
function cmdGet(db, args) {
|
|
575
661
|
const { positional, flags } = parseArgs(args);
|
|
576
662
|
const idStr = positional.join(',');
|
|
577
663
|
if (!idStr) {
|
|
578
|
-
fail('[mem] Usage: mem get <id1,id2,...> [--source obs|session|prompt] [--fields f1,f2,...]'
|
|
664
|
+
fail('[mem] Usage: mem get <id1,id2,...> [--source obs|session|prompt] [--fields f1,f2,...]\n' +
|
|
665
|
+
' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session).');
|
|
579
666
|
return;
|
|
580
667
|
}
|
|
581
668
|
|
|
582
|
-
const
|
|
583
|
-
|
|
669
|
+
const tokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
670
|
+
const unparseable = [];
|
|
671
|
+
const parsed = [];
|
|
672
|
+
for (const t of tokens) {
|
|
673
|
+
const p = parseIdToken(t);
|
|
674
|
+
if (p) parsed.push(p);
|
|
675
|
+
else unparseable.push(t);
|
|
676
|
+
}
|
|
677
|
+
if (unparseable.length > 0) {
|
|
678
|
+
process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
|
|
679
|
+
}
|
|
680
|
+
if (parsed.length === 0) {
|
|
584
681
|
fail('[mem] No valid IDs provided');
|
|
585
682
|
return;
|
|
586
683
|
}
|
|
587
684
|
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
|
|
593
|
-
if (rows.length === 0) { fail('[mem] No sessions found for given IDs'); return; }
|
|
594
|
-
const parts = [];
|
|
595
|
-
for (const r of rows) {
|
|
596
|
-
const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
|
|
597
|
-
if (r.request) lines.push(`Request: ${r.request}`);
|
|
598
|
-
if (r.completed) lines.push(`Completed: ${r.completed}`);
|
|
599
|
-
if (r.investigated) lines.push(`Investigated: ${r.investigated}`);
|
|
600
|
-
if (r.learned) lines.push(`Learned: ${r.learned}`);
|
|
601
|
-
if (r.next_steps) lines.push(`Next steps: ${r.next_steps}`);
|
|
602
|
-
if (r.project) lines.push(`Project: ${r.project}`);
|
|
603
|
-
parts.push(lines.join('\n'));
|
|
604
|
-
}
|
|
605
|
-
out(parts.join('\n\n'));
|
|
685
|
+
// Explicit --source overrides any prefix; otherwise each token's prefix routes individually.
|
|
686
|
+
const explicit = flags.source;
|
|
687
|
+
const validSources = new Set(['obs', 'session', 'prompt']);
|
|
688
|
+
if (explicit && !validSources.has(explicit)) {
|
|
689
|
+
fail(`[mem] Invalid --source "${explicit}". Use: obs, session, prompt`);
|
|
606
690
|
return;
|
|
607
691
|
}
|
|
608
692
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
for (const r of rows) {
|
|
614
|
-
const lines = [`P#${r.id} ${fmtDateShort(r.created_at)}`];
|
|
615
|
-
if (r.prompt_text) lines.push(`Text: ${r.prompt_text}`);
|
|
616
|
-
if (r.content_session_id) lines.push(`Session: ${r.content_session_id}`);
|
|
617
|
-
parts.push(lines.join('\n'));
|
|
618
|
-
}
|
|
619
|
-
out(parts.join('\n\n'));
|
|
620
|
-
return;
|
|
693
|
+
const bySrc = { obs: [], session: [], prompt: [] };
|
|
694
|
+
for (const p of parsed) {
|
|
695
|
+
const src = explicit || p.source || 'obs';
|
|
696
|
+
bySrc[src].push(p.id);
|
|
621
697
|
}
|
|
622
698
|
|
|
623
|
-
//
|
|
624
|
-
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'];
|
|
699
|
+
// Validate --fields against obs schema (only meaningful for obs rows).
|
|
625
700
|
let requestedFields = null;
|
|
626
701
|
if (flags.fields) {
|
|
627
702
|
const allRequested = flags.fields.split(',').map(s => s.trim());
|
|
@@ -636,50 +711,107 @@ function cmdGet(db, args) {
|
|
|
636
711
|
}
|
|
637
712
|
}
|
|
638
713
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
fail('[mem] No observations found for given IDs');
|
|
653
|
-
return;
|
|
714
|
+
const sections = [];
|
|
715
|
+
let totalFound = 0;
|
|
716
|
+
if (bySrc.obs.length > 0) {
|
|
717
|
+
const s = renderObsRows(db, bySrc.obs, requestedFields);
|
|
718
|
+
if (s) { sections.push(s.text); totalFound += s.count; }
|
|
719
|
+
}
|
|
720
|
+
if (bySrc.session.length > 0) {
|
|
721
|
+
const s = renderSessionRows(db, bySrc.session);
|
|
722
|
+
if (s) { sections.push(s.text); totalFound += s.count; }
|
|
723
|
+
}
|
|
724
|
+
if (bySrc.prompt.length > 0) {
|
|
725
|
+
const s = renderPromptRows(db, bySrc.prompt);
|
|
726
|
+
if (s) { sections.push(s.text); totalFound += s.count; }
|
|
654
727
|
}
|
|
655
728
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
667
|
-
const display = typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
|
|
668
|
-
lines.push(`${f}: ${display}`);
|
|
669
|
-
}
|
|
670
|
-
parts.push(lines.join('\n'));
|
|
729
|
+
if (totalFound === 0) {
|
|
730
|
+
// Probe the OTHER sources so the caller can retry with the right prefix.
|
|
731
|
+
const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
|
|
732
|
+
const allIds = parsed.map(p => p.id);
|
|
733
|
+
const probe = probeIdSources(db, allIds, queried);
|
|
734
|
+
const hits = formatProbeHints(probe);
|
|
735
|
+
const hint = hits.length > 0 ? ` Try: ${hits.join('; ')}.` : '';
|
|
736
|
+
const queriedList = [...queried].join(', ');
|
|
737
|
+
fail(`[mem] No records found in source(s) [${queriedList}] for the given ID(s).${hint}`);
|
|
738
|
+
return;
|
|
671
739
|
}
|
|
672
740
|
|
|
673
|
-
out(
|
|
741
|
+
out(sections.join('\n\n'));
|
|
674
742
|
}
|
|
675
743
|
|
|
676
744
|
function cmdTimeline(db, args) {
|
|
677
745
|
const { positional, flags } = parseArgs(args);
|
|
678
|
-
let anchorId = parseInt(flags.anchor, 10);
|
|
679
746
|
const before = parseInt(flags.before, 10) || 5;
|
|
680
747
|
const after = parseInt(flags.after, 10) || 5;
|
|
681
748
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
682
749
|
|
|
750
|
+
// Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
|
|
751
|
+
// For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
|
|
752
|
+
// (before/after observations) still apply.
|
|
753
|
+
let anchorId = null;
|
|
754
|
+
let anchorNote = null; // hint line for output when anchor was resolved via conversion
|
|
755
|
+
if (flags.anchor !== undefined && flags.anchor !== true) {
|
|
756
|
+
const parsed = parseIdToken(flags.anchor);
|
|
757
|
+
if (!parsed) {
|
|
758
|
+
fail(`[mem] Invalid --anchor "${flags.anchor}". Expected N, #N, P#N, or S#N.`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (parsed.source === 'prompt') {
|
|
762
|
+
const row = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
763
|
+
if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
|
|
764
|
+
const proj = project;
|
|
765
|
+
const nearest = db.prepare(`
|
|
766
|
+
SELECT id FROM observations
|
|
767
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
768
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
769
|
+
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
770
|
+
if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
|
|
771
|
+
anchorId = nearest.id;
|
|
772
|
+
anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
|
|
773
|
+
} else if (parsed.source === 'session') {
|
|
774
|
+
const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
775
|
+
if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
|
|
776
|
+
const proj = project;
|
|
777
|
+
const nearest = db.prepare(`
|
|
778
|
+
SELECT id FROM observations
|
|
779
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
780
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
781
|
+
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
782
|
+
if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
|
|
783
|
+
anchorId = nearest.id;
|
|
784
|
+
anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
|
|
785
|
+
} else {
|
|
786
|
+
// Bare integer (no prefix): try observation first. Fall back to user_prompts
|
|
787
|
+
// then session_summaries so pasted P#/S# IDs still work when the prefix is
|
|
788
|
+
// omitted — matches the prefix-aware routing used by search/probe.
|
|
789
|
+
const obsExists = db.prepare('SELECT 1 FROM observations WHERE id = ?').get(parsed.id);
|
|
790
|
+
if (obsExists) {
|
|
791
|
+
anchorId = parsed.id;
|
|
792
|
+
} else {
|
|
793
|
+
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
794
|
+
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
795
|
+
const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
|
|
796
|
+
: sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
|
|
797
|
+
: null;
|
|
798
|
+
if (!hit) {
|
|
799
|
+
fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const proj = project;
|
|
803
|
+
const nearest = db.prepare(`
|
|
804
|
+
SELECT id FROM observations
|
|
805
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
806
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
807
|
+
`).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
|
|
808
|
+
if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
|
|
809
|
+
anchorId = nearest.id;
|
|
810
|
+
anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
683
815
|
// Support query-based anchor: `timeline --query "search terms"` or positional
|
|
684
816
|
// Uses recency-weighted BM25 + project filter (aligned with MCP mem_timeline)
|
|
685
817
|
const queryStr = flags.query || positional.join(' ');
|
|
@@ -773,7 +905,7 @@ function cmdTimeline(db, args) {
|
|
|
773
905
|
|
|
774
906
|
const all = [...beforeRows.reverse(), anchor, ...afterRows];
|
|
775
907
|
|
|
776
|
-
out(`[mem] Timeline around #${anchorId}:`);
|
|
908
|
+
out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
|
|
777
909
|
for (const r of all) {
|
|
778
910
|
const marker = r.id === anchorId ? ' <--' : '';
|
|
779
911
|
const time = relativeTime(r.created_at_epoch);
|
|
@@ -1162,7 +1294,19 @@ function cmdDelete(db, args) {
|
|
|
1162
1294
|
return;
|
|
1163
1295
|
}
|
|
1164
1296
|
|
|
1165
|
-
|
|
1297
|
+
// delete operates on observations only. Reject P#/S# explicitly so callers aren't
|
|
1298
|
+
// surprised by silent NaN filtering when they paste search-output IDs.
|
|
1299
|
+
const tokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
1300
|
+
const nonObs = tokens.filter(t => /^[PpSs]#?\d+$/.test(t));
|
|
1301
|
+
if (nonObs.length > 0) {
|
|
1302
|
+
fail(`[mem] delete only works on observations. Rejected: ${nonObs.join(', ')}. ` +
|
|
1303
|
+
`Prompts and sessions are append-only — inspect with \`mem get P#N --source prompt\` / \`--source session\`.`);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const ids = tokens.map(t => {
|
|
1307
|
+
const p = parseIdToken(t);
|
|
1308
|
+
return p && p.source === null ? p.id : NaN;
|
|
1309
|
+
}).filter(n => !isNaN(n));
|
|
1166
1310
|
if (ids.length === 0) {
|
|
1167
1311
|
fail('[mem] No valid IDs provided');
|
|
1168
1312
|
return;
|
|
@@ -1215,7 +1359,14 @@ function cmdDelete(db, args) {
|
|
|
1215
1359
|
|
|
1216
1360
|
function cmdUpdate(db, args) {
|
|
1217
1361
|
const { positional, flags } = parseArgs(args);
|
|
1218
|
-
const
|
|
1362
|
+
const raw = positional[0];
|
|
1363
|
+
if (raw && /^[PpSs]#?\d+$/.test(String(raw).trim())) {
|
|
1364
|
+
fail(`[mem] update only works on observations. Rejected: ${raw}. ` +
|
|
1365
|
+
`Prompts and sessions are append-only.`);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const parsed = raw ? parseIdToken(raw) : null;
|
|
1369
|
+
const id = parsed && parsed.source === null ? parsed.id : parseInt(raw, 10);
|
|
1219
1370
|
if (!id || isNaN(id)) {
|
|
1220
1371
|
fail('[mem] Usage: mem update <id> [--title T] [--type T] [--importance N] [--lesson T] [--narrative T] [--concepts T]');
|
|
1221
1372
|
return;
|
|
@@ -1919,11 +2070,14 @@ Commands:
|
|
|
1919
2070
|
--limit N Max results (default 10)
|
|
1920
2071
|
|
|
1921
2072
|
get <id1,id2,...> Get full details by ID
|
|
1922
|
-
|
|
1923
|
-
|
|
2073
|
+
IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
|
|
2074
|
+
Bare N defaults to obs. Mixed prefixes in one call route each token correctly.
|
|
2075
|
+
--source S Force record type (obs|session|prompt); overrides prefixes.
|
|
2076
|
+
--fields f1,f2,... Select specific fields to return (observations only).
|
|
1924
2077
|
|
|
1925
2078
|
timeline Show observations around an anchor (shows recent if no anchor)
|
|
1926
|
-
--anchor ID Center on this
|
|
2079
|
+
--anchor ID Center on this ID. Accepts N, #N, P#N, or S#N — P#/S# anchors
|
|
2080
|
+
resolve to the nearest-in-time observation in the same project.
|
|
1927
2081
|
--query "text" Find anchor by FTS5 search
|
|
1928
2082
|
--before N Show N before anchor (default 5)
|
|
1929
2083
|
--after N Show N after anchor (default 5)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.39.0",
|
|
4
4
|
"description": "Lightweight persistent memory system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"lib/stats-quality.mjs",
|
|
52
52
|
"lib/low-signal-patterns.mjs",
|
|
53
53
|
"lib/citation-tracker.mjs",
|
|
54
|
+
"lib/id-routing.mjs",
|
|
54
55
|
"registry.mjs",
|
|
55
56
|
"registry-retriever.mjs",
|
|
56
57
|
"registry-indexer.mjs",
|
package/server.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { basename, join } from 'path';
|
|
|
27
27
|
import { homedir } from 'os';
|
|
28
28
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
29
29
|
import { searchResources } from './registry-retriever.mjs';
|
|
30
|
+
import { probeOtherSources as probeIdSources } from './lib/id-routing.mjs';
|
|
30
31
|
import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
|
|
31
32
|
import { createRequire } from 'module';
|
|
32
33
|
|
|
@@ -898,17 +899,14 @@ server.registerTool(
|
|
|
898
899
|
}
|
|
899
900
|
|
|
900
901
|
if (rows.length === 0) {
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
910
|
-
} catch { /* best-effort hint */ }
|
|
911
|
-
}
|
|
902
|
+
// Symmetric probe via shared lib/id-routing.mjs so CLI cmdGet and MCP mem_get
|
|
903
|
+
// stay aligned if a table's ID semantics change.
|
|
904
|
+
const probe = probeIdSources(db, args.ids, new Set([source]));
|
|
905
|
+
const hints = [];
|
|
906
|
+
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs — use source='obs')`);
|
|
907
|
+
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session — use source='session')`);
|
|
908
|
+
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt — use source='prompt')`);
|
|
909
|
+
const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
|
|
912
910
|
const msg = `No ${sourceLabel} found for given IDs.${hint}`;
|
|
913
911
|
return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
|
|
914
912
|
}
|
package/source-files.mjs
CHANGED
package/tool-schemas.mjs
CHANGED
|
@@ -275,7 +275,9 @@ export const tools = [
|
|
|
275
275
|
' - A mem_search hit looks relevant and you need the supporting detail\n' +
|
|
276
276
|
' - For session (S#) or prompt (P#) hits, pass source="session" or "prompt"\n' +
|
|
277
277
|
'\n' +
|
|
278
|
-
'
|
|
278
|
+
'On miss, response includes "Try: …" hint listing other sources the ID lives in.\n' +
|
|
279
|
+
'\n' +
|
|
280
|
+
'Equivalent CLI: claude-mem-lite get <id>[,<id>,...] — accepts P#/S#/# prefix.',
|
|
279
281
|
inputSchema: memGetSchema,
|
|
280
282
|
},
|
|
281
283
|
{
|