claude-mem-lite 2.41.0 → 2.43.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/cli/common.mjs +9 -20
- package/lib/id-routing.mjs +53 -5
- package/mem-cli.mjs +28 -26
- package/package.json +1 -1
- package/server.mjs +167 -63
- package/tool-schemas.mjs +76 -5
package/cli/common.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// cli/common.mjs — shared helpers used by every per-command file under cli/.
|
|
2
2
|
// Extracted from mem-cli.mjs (v2.41) as first step in the god-module split.
|
|
3
3
|
//
|
|
4
|
-
// Scope: pure utilities only. No DB, no imports from other cli/ files
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Scope: pure utilities only. No DB, no imports from other cli/ files; only
|
|
5
|
+
// `lib/` leaf utilities may be re-exported through here (currently:
|
|
6
|
+
// parseIdToken). This module is the single source of truth for stdout/stderr
|
|
7
|
+
// framing, arg parsing, ID-token parsing, and relative-time formatting —
|
|
8
|
+
// every command imports from here so the CLI stays consistent.
|
|
8
9
|
|
|
9
10
|
// ─── Argument Parsing ────────────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -75,22 +76,10 @@ export function fmtDateShort(iso) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
// ─── ID Token Parsing ────────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* @returns {{ source: 'obs'|'session'|'prompt'|null, id: number } | null}
|
|
83
|
-
* source===null means no explicit prefix — caller picks default (typically 'obs').
|
|
84
|
-
*/
|
|
85
|
-
export 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
|
-
}
|
|
79
|
+
// Re-exported from lib/id-routing.mjs so CLI and MCP (server.mjs) share a single
|
|
80
|
+
// parser — parity per #8050. Keep this re-export for back-compat with the
|
|
81
|
+
// 5 CLI call sites that already import parseIdToken from cli/common.mjs.
|
|
82
|
+
export { parseIdToken } from '../lib/id-routing.mjs';
|
|
94
83
|
|
|
95
84
|
/**
|
|
96
85
|
* Format the shared `probeIdSources` output as CLI hint strings.
|
package/lib/id-routing.mjs
CHANGED
|
@@ -1,10 +1,58 @@
|
|
|
1
|
-
// Shared probe for "ID-not-found-in-requested-source" hints
|
|
2
|
-
// Used by CLI
|
|
3
|
-
//
|
|
4
|
-
// paths update together.
|
|
1
|
+
// Shared probe for "ID-not-found-in-requested-source" hints + shared token
|
|
2
|
+
// parser. Used by CLI (mem-cli.mjs, cli/common.mjs re-export) and MCP
|
|
3
|
+
// (server.mjs) so both paths stay aligned — parity per #8050.
|
|
5
4
|
//
|
|
6
5
|
// The formatter stays per-call-site because CLI and MCP surface format
|
|
7
|
-
// differently (stderr vs response text); only the SQL
|
|
6
|
+
// differently (stderr vs response text); only the SQL + token-parse layers
|
|
7
|
+
// are shared.
|
|
8
|
+
|
|
9
|
+
// ─── ID Token Parsing ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse an ID token as it appears in search output or CLI positional args.
|
|
13
|
+
* Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
|
|
14
|
+
* @param {unknown} raw
|
|
15
|
+
* @returns {{ source: 'obs'|'session'|'prompt'|null, id: number } | null}
|
|
16
|
+
* source===null means no explicit prefix — caller picks default (typically 'obs').
|
|
17
|
+
*/
|
|
18
|
+
export function parseIdToken(raw) {
|
|
19
|
+
const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
const p = m[1].toUpperCase();
|
|
22
|
+
const id = parseInt(m[2], 10);
|
|
23
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
24
|
+
const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
|
|
25
|
+
return { source, id };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Group mixed ID tokens by source. Accepts bare ints, `#N`, `P#N`, `S#N`,
|
|
30
|
+
* and raw strings — the same shapes parseIdToken handles. Used by CLI
|
|
31
|
+
* cmdGet and MCP mem_get so both paths route paste-from-search tokens
|
|
32
|
+
* consistently (closes the #8127 parity gap).
|
|
33
|
+
*
|
|
34
|
+
* An explicit source override (from `--source` or `args.source`) wins over
|
|
35
|
+
* per-token prefixes. Un-prefixed tokens fall back to `defaultSource`.
|
|
36
|
+
*
|
|
37
|
+
* @param {Array<string|number>} tokens Mixed input — order preserved within each bucket.
|
|
38
|
+
* @param {{explicit?: 'obs'|'session'|'prompt'|null, defaultSource?: 'obs'|'session'|'prompt'}} opts
|
|
39
|
+
* @returns {{bySrc: {obs:number[], session:number[], prompt:number[]}, invalid: string[]}}
|
|
40
|
+
*/
|
|
41
|
+
export function bucketIdTokens(tokens, { explicit = null, defaultSource = 'obs' } = {}) {
|
|
42
|
+
const bySrc = { obs: [], session: [], prompt: [] };
|
|
43
|
+
const invalid = [];
|
|
44
|
+
for (const raw of tokens) {
|
|
45
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
|
46
|
+
bySrc[explicit || defaultSource].push(raw);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const p = parseIdToken(raw);
|
|
50
|
+
if (!p) { invalid.push(String(raw)); continue; }
|
|
51
|
+
const src = explicit || p.source || defaultSource;
|
|
52
|
+
bySrc[src].push(p.id);
|
|
53
|
+
}
|
|
54
|
+
return { bySrc, invalid };
|
|
55
|
+
}
|
|
8
56
|
|
|
9
57
|
/**
|
|
10
58
|
* Probe the observations / session_summaries / user_prompts tables for any
|
package/mem-cli.mjs
CHANGED
|
@@ -15,7 +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
|
+
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
19
19
|
import { basename } from 'path';
|
|
20
20
|
import { readFileSync } from 'fs';
|
|
21
21
|
|
|
@@ -476,7 +476,7 @@ function cmdRecent(db, args) {
|
|
|
476
476
|
for (const r of rows) {
|
|
477
477
|
const time = relativeTime(r.created_at_epoch);
|
|
478
478
|
const title = truncate(r.title || r.subtitle || '(untitled)', 80);
|
|
479
|
-
out(
|
|
479
|
+
out(`${('#' + r.id).padEnd(6)} ${typeIcon(r.type)} ${time.padEnd(8)} ${title}`);
|
|
480
480
|
}
|
|
481
481
|
}
|
|
482
482
|
|
|
@@ -597,20 +597,6 @@ function cmdGet(db, args) {
|
|
|
597
597
|
}
|
|
598
598
|
|
|
599
599
|
const tokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
600
|
-
const unparseable = [];
|
|
601
|
-
const parsed = [];
|
|
602
|
-
for (const t of tokens) {
|
|
603
|
-
const p = parseIdToken(t);
|
|
604
|
-
if (p) parsed.push(p);
|
|
605
|
-
else unparseable.push(t);
|
|
606
|
-
}
|
|
607
|
-
if (unparseable.length > 0) {
|
|
608
|
-
process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
|
|
609
|
-
}
|
|
610
|
-
if (parsed.length === 0) {
|
|
611
|
-
fail('[mem] No valid IDs provided');
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
600
|
|
|
615
601
|
// Explicit --source overrides any prefix; otherwise each token's prefix routes individually.
|
|
616
602
|
const explicit = flags.source;
|
|
@@ -620,10 +606,14 @@ function cmdGet(db, args) {
|
|
|
620
606
|
return;
|
|
621
607
|
}
|
|
622
608
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
609
|
+
// Shared bucketing with MCP mem_get — single source of truth for P#/S#/# routing (#8050).
|
|
610
|
+
const { bySrc, invalid: unparseable } = bucketIdTokens(tokens, { explicit, defaultSource: 'obs' });
|
|
611
|
+
if (unparseable.length > 0) {
|
|
612
|
+
process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
|
|
613
|
+
}
|
|
614
|
+
if (bySrc.obs.length + bySrc.session.length + bySrc.prompt.length === 0) {
|
|
615
|
+
fail('[mem] No valid IDs provided');
|
|
616
|
+
return;
|
|
627
617
|
}
|
|
628
618
|
|
|
629
619
|
// Validate --fields against obs schema (only meaningful for obs rows).
|
|
@@ -659,7 +649,7 @@ function cmdGet(db, args) {
|
|
|
659
649
|
if (totalFound === 0) {
|
|
660
650
|
// Probe the OTHER sources so the caller can retry with the right prefix.
|
|
661
651
|
const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
|
|
662
|
-
const allIds =
|
|
652
|
+
const allIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt];
|
|
663
653
|
const probe = probeIdSources(db, allIds, queried);
|
|
664
654
|
const hits = formatProbeHints(probe);
|
|
665
655
|
const hint = hits.length > 0 ? ` Try: ${hits.join('; ')}.` : '';
|
|
@@ -716,9 +706,21 @@ function cmdTimeline(db, args) {
|
|
|
716
706
|
// Bare integer (no prefix): try observation first. Fall back to user_prompts
|
|
717
707
|
// then session_summaries so pasted P#/S# IDs still work when the prefix is
|
|
718
708
|
// omitted — matches the prefix-aware routing used by search/probe.
|
|
719
|
-
const
|
|
720
|
-
if (
|
|
721
|
-
|
|
709
|
+
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
710
|
+
if (obsRow) {
|
|
711
|
+
const ci = obsRow.compressed_into;
|
|
712
|
+
if (ci && ci > 0) {
|
|
713
|
+
// Compressed into a live parent: re-anchor so the window doesn't silently
|
|
714
|
+
// straddle a dead record. Negative sentinels (-1 dropped, -2 pending purge)
|
|
715
|
+
// have no canonical parent — surface an explicit error instead.
|
|
716
|
+
anchorId = ci;
|
|
717
|
+
anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
|
|
718
|
+
} else if (ci && ci < 0) {
|
|
719
|
+
fail(`[mem] Observation #${parsed.id} was compressed and pruned; no canonical anchor available`);
|
|
720
|
+
return;
|
|
721
|
+
} else {
|
|
722
|
+
anchorId = parsed.id;
|
|
723
|
+
}
|
|
722
724
|
} else {
|
|
723
725
|
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
724
726
|
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
@@ -787,7 +789,7 @@ function cmdTimeline(db, args) {
|
|
|
787
789
|
for (const r of rows.reverse()) {
|
|
788
790
|
const time = relativeTime(r.created_at_epoch);
|
|
789
791
|
const title = truncate(r.title || r.subtitle || '(untitled)', 80);
|
|
790
|
-
out(
|
|
792
|
+
out(`${('#' + r.id).padEnd(6)} ${typeIcon(r.type)} ${time.padEnd(8)} ${title}`);
|
|
791
793
|
}
|
|
792
794
|
return;
|
|
793
795
|
}
|
|
@@ -840,7 +842,7 @@ function cmdTimeline(db, args) {
|
|
|
840
842
|
const marker = r.id === anchorId ? ' <--' : '';
|
|
841
843
|
const time = relativeTime(r.created_at_epoch);
|
|
842
844
|
const title = truncate(r.title || r.subtitle || '(untitled)', 80);
|
|
843
|
-
out(
|
|
845
|
+
out(`${('#' + r.id).padEnd(6)} ${typeIcon(r.type)} ${time.padEnd(8)} ${title}${marker}`);
|
|
844
846
|
}
|
|
845
847
|
}
|
|
846
848
|
|
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -27,7 +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
|
+
import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
|
|
31
31
|
import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
|
|
32
32
|
import { createRequire } from 'module';
|
|
33
33
|
|
|
@@ -762,6 +762,71 @@ server.registerTool(
|
|
|
762
762
|
const before = args.before ?? 5;
|
|
763
763
|
const after = args.after ?? 5;
|
|
764
764
|
let anchorId = args.anchor;
|
|
765
|
+
let anchorNote = null;
|
|
766
|
+
|
|
767
|
+
// Resolve prefixed-token anchor (e.g. "P#3462" / "S#53" / "#8121") — users pasting
|
|
768
|
+
// from mem_search results expect the same routing as CLI `timeline --anchor`.
|
|
769
|
+
// Prompt/session anchors resolve to the nearest-in-time observation so
|
|
770
|
+
// before/after semantics still apply to the observations timeline.
|
|
771
|
+
// Also covers bare numeric anchors so compressed-obs routing applies uniformly —
|
|
772
|
+
// without this, `anchor: 7826` (int) would bypass the compressed check and
|
|
773
|
+
// silently straddle a dead record.
|
|
774
|
+
if (typeof anchorId === 'string' || typeof anchorId === 'number') {
|
|
775
|
+
const parsed = parseIdToken(anchorId);
|
|
776
|
+
if (!parsed) {
|
|
777
|
+
return { content: [{ type: 'text', text: `Invalid anchor "${args.anchor}". Expected N, #N, P#N, or S#N.` }] };
|
|
778
|
+
}
|
|
779
|
+
if (parsed.source === 'prompt' || parsed.source === 'session') {
|
|
780
|
+
const srcTable = parsed.source === 'prompt' ? 'user_prompts' : 'session_summaries';
|
|
781
|
+
const srcPrefix = parsed.source === 'prompt' ? 'P#' : 'S#';
|
|
782
|
+
const row = db.prepare(`SELECT created_at_epoch FROM ${srcTable} WHERE id = ?`).get(parsed.id);
|
|
783
|
+
if (!row) return { content: [{ type: 'text', text: `${parsed.source === 'prompt' ? 'Prompt' : 'Session'} ${srcPrefix}${parsed.id} not found.` }] };
|
|
784
|
+
const projArg = args.project;
|
|
785
|
+
const nearest = db.prepare(`
|
|
786
|
+
SELECT id FROM observations
|
|
787
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
|
|
788
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
789
|
+
`).get(...(projArg ? [projArg, row.created_at_epoch] : [row.created_at_epoch]));
|
|
790
|
+
if (!nearest) return { content: [{ type: 'text', text: `No observations near ${srcPrefix}${parsed.id}.` }] };
|
|
791
|
+
anchorId = nearest.id;
|
|
792
|
+
anchorNote = `(anchored to #${nearest.id}, closest obs to ${srcPrefix}${parsed.id})`;
|
|
793
|
+
} else {
|
|
794
|
+
// Bare "#N" or "N" — resolve to obs, falling back to prompt/session like CLI bare-int path.
|
|
795
|
+
// Route compressed obs to its parent so the before/after window (which filters compressed)
|
|
796
|
+
// isn't shown around a dead anchor. Negative sentinels (-1 dropped, -2 pending purge) surface
|
|
797
|
+
// an explicit error — they have no canonical parent.
|
|
798
|
+
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
799
|
+
if (obsRow) {
|
|
800
|
+
const ci = obsRow.compressed_into;
|
|
801
|
+
if (ci && ci > 0) {
|
|
802
|
+
anchorId = ci;
|
|
803
|
+
anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
|
|
804
|
+
} else if (ci && ci < 0) {
|
|
805
|
+
return { content: [{ type: 'text', text: `Observation #${parsed.id} was compressed and pruned; no canonical anchor available.` }] };
|
|
806
|
+
} else {
|
|
807
|
+
anchorId = parsed.id;
|
|
808
|
+
}
|
|
809
|
+
} else {
|
|
810
|
+
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
811
|
+
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
812
|
+
const hit = promptRow ? { row: promptRow, prefix: 'P#', name: 'prompt' }
|
|
813
|
+
: sessionRow ? { row: sessionRow, prefix: 'S#', name: 'session' }
|
|
814
|
+
: null;
|
|
815
|
+
if (!hit) {
|
|
816
|
+
return { content: [{ type: 'text', text: `Observation, prompt, or session with id ${parsed.id} not found.` }] };
|
|
817
|
+
}
|
|
818
|
+
const projArg = args.project;
|
|
819
|
+
const nearest = db.prepare(`
|
|
820
|
+
SELECT id FROM observations
|
|
821
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${projArg ? 'AND project = ?' : ''}
|
|
822
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
823
|
+
`).get(...(projArg ? [projArg, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
|
|
824
|
+
if (!nearest) return { content: [{ type: 'text', text: `No observations near ${hit.prefix}${parsed.id} (${hit.name}).` }] };
|
|
825
|
+
anchorId = nearest.id;
|
|
826
|
+
anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}${parsed.id})`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
765
830
|
|
|
766
831
|
// Auto-find anchor via FTS (with recency decay)
|
|
767
832
|
if (!anchorId && args.query) {
|
|
@@ -845,7 +910,7 @@ server.registerTool(
|
|
|
845
910
|
const anchor = db.prepare('SELECT id, type, title, subtitle, project, created_at FROM observations WHERE id = ?').get(anchorId);
|
|
846
911
|
|
|
847
912
|
const all = [...beforeRows.reverse(), anchor, ...afterRows];
|
|
848
|
-
const lines = [`Timeline around #${anchorId}:\n`];
|
|
913
|
+
const lines = [`Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:\n`];
|
|
849
914
|
for (const r of all) {
|
|
850
915
|
const marker = r.id === anchorId ? ' ◀' : '';
|
|
851
916
|
lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.created_at)}${marker}`);
|
|
@@ -864,84 +929,123 @@ server.registerTool(
|
|
|
864
929
|
inputSchema: memGetSchema,
|
|
865
930
|
},
|
|
866
931
|
safeHandler(async (args) => {
|
|
867
|
-
|
|
868
|
-
|
|
932
|
+
// Bucket by per-token prefix (or force all to `args.source` when explicit).
|
|
933
|
+
// coerceMixedIdTokens has already stringified + regex-validated each token.
|
|
934
|
+
const { bySrc, invalid } = bucketIdTokens(args.ids, { explicit: args.source || null, defaultSource: 'obs' });
|
|
935
|
+
if (invalid.length > 0) {
|
|
936
|
+
// Should not happen — schema regex already rejected bad tokens — but guard defensively.
|
|
937
|
+
return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, or S#N.` }] };
|
|
938
|
+
}
|
|
939
|
+
const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length;
|
|
940
|
+
if (totalRequested === 0) {
|
|
941
|
+
return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
|
|
942
|
+
}
|
|
869
943
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
944
|
+
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'];
|
|
945
|
+
|
|
946
|
+
// `fields` filter only makes sense for obs rows; session/prompt ignore it.
|
|
947
|
+
// Validate when obs is queried — throw on all-invalid, note on partial-invalid.
|
|
948
|
+
let fieldsNote = '';
|
|
949
|
+
let obsFieldFilter = null;
|
|
950
|
+
if (args.fields?.length && bySrc.obs.length > 0) {
|
|
951
|
+
const invalidFields = args.fields.filter(f => !OBS_FIELDS.includes(f));
|
|
952
|
+
const validFields = args.fields.filter(f => OBS_FIELDS.includes(f));
|
|
953
|
+
if (validFields.length === 0) {
|
|
954
|
+
throw new Error(`No valid fields. Unknown field(s): ${invalidFields.join(', ')}. Valid: ${OBS_FIELDS.join(', ')}`);
|
|
955
|
+
}
|
|
956
|
+
if (invalidFields.length > 0) {
|
|
957
|
+
fieldsNote = `Note: unknown field(s) dropped: ${invalidFields.join(', ')}. Valid: ${OBS_FIELDS.join(', ')}`;
|
|
958
|
+
}
|
|
959
|
+
obsFieldFilter = validFields;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Per-source fetchers — each returns { rows, foundIds:Set, prefix }.
|
|
963
|
+
const sections = [];
|
|
964
|
+
const foundBySource = { obs: new Set(), session: new Set(), prompt: new Set() };
|
|
965
|
+
|
|
966
|
+
if (bySrc.obs.length > 0) {
|
|
967
|
+
const ph = bySrc.obs.map(() => '?').join(',');
|
|
883
968
|
try {
|
|
884
|
-
db.prepare(
|
|
885
|
-
|
|
886
|
-
).run(Date.now(), ...args.ids);
|
|
887
|
-
autoBoostIfNeeded(db, args.ids);
|
|
969
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...bySrc.obs);
|
|
970
|
+
autoBoostIfNeeded(db, bySrc.obs);
|
|
888
971
|
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
889
|
-
rows = db.prepare(`SELECT * FROM observations WHERE id IN (${
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
972
|
+
const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.obs);
|
|
973
|
+
const renderFields = obsFieldFilter || OBS_FIELDS;
|
|
974
|
+
for (const row of rows) {
|
|
975
|
+
foundBySource.obs.add(row.id);
|
|
976
|
+
const lines = [`── #${row.id} ──`];
|
|
977
|
+
for (const f of renderFields) {
|
|
978
|
+
const val = row[f];
|
|
979
|
+
if (val === null || val === undefined || val === '') continue;
|
|
980
|
+
if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
|
|
981
|
+
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
982
|
+
lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
|
|
983
|
+
}
|
|
984
|
+
sections.push(lines.join('\n'));
|
|
985
|
+
}
|
|
893
986
|
}
|
|
894
987
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
988
|
+
if (bySrc.session.length > 0) {
|
|
989
|
+
const ph = bySrc.session.map(() => '?').join(',');
|
|
990
|
+
const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.session);
|
|
991
|
+
const sessFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
|
|
992
|
+
for (const row of rows) {
|
|
993
|
+
foundBySource.session.add(row.id);
|
|
994
|
+
const lines = [`── S#${row.id} ──`];
|
|
995
|
+
for (const f of sessFields) {
|
|
996
|
+
const val = row[f];
|
|
997
|
+
if (val === null || val === undefined || val === '') continue;
|
|
998
|
+
const maxLen = 500;
|
|
999
|
+
lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
|
|
1000
|
+
}
|
|
1001
|
+
sections.push(lines.join('\n'));
|
|
903
1002
|
}
|
|
904
|
-
|
|
905
|
-
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (bySrc.prompt.length > 0) {
|
|
1006
|
+
const ph = bySrc.prompt.map(() => '?').join(',');
|
|
1007
|
+
const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.prompt);
|
|
1008
|
+
for (const row of rows) {
|
|
1009
|
+
foundBySource.prompt.add(row.id);
|
|
1010
|
+
const lines = [`── P#${row.id} ──`];
|
|
1011
|
+
if (row.prompt_text) lines.push(`prompt_text: ${row.prompt_text.length > 500 ? row.prompt_text.slice(0, 500) + '…' : row.prompt_text}`);
|
|
1012
|
+
if (row.content_session_id) lines.push(`content_session_id: ${row.content_session_id}`);
|
|
1013
|
+
if (row.prompt_number !== null && row.prompt_number !== undefined) lines.push(`prompt_number: ${row.prompt_number}`);
|
|
1014
|
+
if (row.created_at) lines.push(`created_at: ${row.created_at}`);
|
|
1015
|
+
sections.push(lines.join('\n'));
|
|
906
1016
|
}
|
|
907
1017
|
}
|
|
908
1018
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1019
|
+
const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size;
|
|
1020
|
+
|
|
1021
|
+
if (totalFound === 0) {
|
|
1022
|
+
// Probe other sources so callers can retry with the right prefix/source override.
|
|
1023
|
+
const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
|
|
1024
|
+
const allNumericIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt];
|
|
1025
|
+
const probe = probeIdSources(db, allNumericIds, queried);
|
|
913
1026
|
const hints = [];
|
|
914
|
-
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs — use source='obs')`);
|
|
915
|
-
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session — use source='session')`);
|
|
916
|
-
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt — use source='prompt')`);
|
|
1027
|
+
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs — use source='obs' or bare #N)`);
|
|
1028
|
+
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session — use source='session' or S#N)`);
|
|
1029
|
+
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt — use source='prompt' or P#N)`);
|
|
917
1030
|
const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
|
|
918
|
-
const
|
|
1031
|
+
const queriedList = [...queried].join(', ');
|
|
1032
|
+
const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
|
|
919
1033
|
return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
|
|
920
1034
|
}
|
|
921
1035
|
|
|
922
|
-
|
|
1036
|
+
// Missing-ID note per bucket (mirrors mem_delete). Show missing IDs with their bucket prefix
|
|
1037
|
+
// so callers can tell which source returned nothing.
|
|
1038
|
+
const missingHints = [];
|
|
1039
|
+
const miss = (arr, found, prefix) => arr.filter(id => !found.has(id)).map(id => `${prefix}${id}`);
|
|
1040
|
+
missingHints.push(...miss(bySrc.obs, foundBySource.obs, '#'));
|
|
1041
|
+
missingHints.push(...miss(bySrc.session, foundBySource.session, 'S#'));
|
|
1042
|
+
missingHints.push(...miss(bySrc.prompt, foundBySource.prompt, 'P#'));
|
|
923
1043
|
|
|
924
1044
|
const parts = [];
|
|
925
1045
|
if (fieldsNote) parts.push(fieldsNote);
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const val = row[f];
|
|
930
|
-
if (val === null || val === undefined || val === '') continue;
|
|
931
|
-
// Skip 'text' field when it duplicates narrative (text = narrative + optional CJK bigrams)
|
|
932
|
-
if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
|
|
933
|
-
// Field-aware truncation: narrative and lesson need more space than metadata
|
|
934
|
-
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
935
|
-
lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
|
|
936
|
-
}
|
|
937
|
-
parts.push(lines.join('\n'));
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// P1-4: surface IDs that weren't found (mirrors mem_delete's missing-ID note).
|
|
941
|
-
const foundIds = new Set(rows.map(r => r.id));
|
|
942
|
-
const missing = args.ids.filter(id => !foundIds.has(id));
|
|
943
|
-
if (missing.length > 0) {
|
|
944
|
-
parts.push(`Note: ID(s) ${missing.join(', ')} not found.`);
|
|
1046
|
+
parts.push(...sections);
|
|
1047
|
+
if (missingHints.length > 0) {
|
|
1048
|
+
parts.push(`Note: ID(s) ${missingHints.join(', ')} not found.`);
|
|
945
1049
|
}
|
|
946
1050
|
|
|
947
1051
|
return { content: [{ type: 'text', text: parts.join('\n\n') }] };
|
package/tool-schemas.mjs
CHANGED
|
@@ -28,6 +28,55 @@ const coerceIntArray = z.preprocess(
|
|
|
28
28
|
z.array(z.number().int())
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
// Coerce string arrays: accept array, comma-separated string, JSON-array string, or bare string.
|
|
32
|
+
// MCP bridges sometimes JSON-stringify complex args — bare `z.array(z.string())` rejects those
|
|
33
|
+
// with "expected array, received string" and the caller loses the field silently. Parity with
|
|
34
|
+
// coerceIntArray: tolerate the same shapes so files/fields survive client serialization quirks.
|
|
35
|
+
const coerceStringArray = z.preprocess(
|
|
36
|
+
(v) => {
|
|
37
|
+
if (Array.isArray(v)) return v.map(x => typeof x === 'string' ? x : String(x));
|
|
38
|
+
if (typeof v === 'string') {
|
|
39
|
+
const s = v.trim();
|
|
40
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(s);
|
|
43
|
+
if (Array.isArray(parsed)) return parsed.map(x => typeof x === 'string' ? x : String(x));
|
|
44
|
+
} catch { /* fall through to comma-split */ }
|
|
45
|
+
}
|
|
46
|
+
return s.split(',').map(x => x.trim()).filter(x => x.length > 0);
|
|
47
|
+
}
|
|
48
|
+
return v;
|
|
49
|
+
},
|
|
50
|
+
z.array(z.string())
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Coerce mixed ID tokens (#N / P#N / S#N / bare N) for mem_get. Accepts:
|
|
54
|
+
// - native arrays: [1, "P#2", "#3"]
|
|
55
|
+
// - single number: 1
|
|
56
|
+
// - single/comma string: "1,P#2,S#3"
|
|
57
|
+
// - JSON-array string: '[1,"P#2"]' (MCP bridges that stringify complex args)
|
|
58
|
+
// Piped to a regex-validated string[] so each token stays parseable by lib/id-routing.parseIdToken
|
|
59
|
+
// at the handler. Closes the CLI↔MCP gap noted in #8127.
|
|
60
|
+
const coerceMixedIdTokens = z.preprocess(
|
|
61
|
+
(v) => {
|
|
62
|
+
const norm = (x) => typeof x === 'string' ? x.trim() : String(x);
|
|
63
|
+
if (Array.isArray(v)) return v.map(norm).filter(s => s.length > 0);
|
|
64
|
+
if (typeof v === 'number') return [String(v)];
|
|
65
|
+
if (typeof v === 'string') {
|
|
66
|
+
const s = v.trim();
|
|
67
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(s);
|
|
70
|
+
if (Array.isArray(parsed)) return parsed.map(norm).filter(x => x.length > 0);
|
|
71
|
+
} catch { /* fall through to comma-split */ }
|
|
72
|
+
}
|
|
73
|
+
return s.split(',').map(x => x.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
return v;
|
|
76
|
+
},
|
|
77
|
+
z.array(z.string().regex(/^[PpSs]?#?\d+$/, 'Expected N, #N, P#N, or S#N')).min(1).max(20)
|
|
78
|
+
);
|
|
79
|
+
|
|
31
80
|
export const memSearchSchema = {
|
|
32
81
|
query: z.string().optional().describe('Search query (FTS5 syntax supported)'),
|
|
33
82
|
type: z.enum(['observations', 'sessions', 'prompts']).optional().describe('Limit to one table'),
|
|
@@ -50,8 +99,27 @@ export const memRecentSchema = {
|
|
|
50
99
|
project: z.string().optional().describe('Filter by project (default: inferred from CWD)'),
|
|
51
100
|
};
|
|
52
101
|
|
|
102
|
+
// Anchor accepts plain int, "123" string-int, or prefixed token from search output:
|
|
103
|
+
// "#123" / "P#123" (prompt) / "S#123" (session). Prompt/session anchors resolve to
|
|
104
|
+
// the nearest-in-time observation so timeline semantics still apply. CLI --anchor
|
|
105
|
+
// parity per #8050 (CLI has supported prefixed tokens since v2.39.0).
|
|
106
|
+
const coerceAnchor = z.preprocess(
|
|
107
|
+
(v) => {
|
|
108
|
+
if (typeof v === 'string') {
|
|
109
|
+
const s = v.trim();
|
|
110
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
return v;
|
|
114
|
+
},
|
|
115
|
+
z.union([
|
|
116
|
+
z.number().int(),
|
|
117
|
+
z.string().regex(/^[PpSs]?#?\d+$/, 'Expected N, #N, P#N, or S#N'),
|
|
118
|
+
])
|
|
119
|
+
);
|
|
120
|
+
|
|
53
121
|
export const memTimelineSchema = {
|
|
54
|
-
anchor:
|
|
122
|
+
anchor: coerceAnchor.optional().describe('Anchor as observation ID (int) or prefixed token string: "#123", "P#123" (prompt → nearest obs), "S#123" (session → nearest obs). Takes precedence over query.'),
|
|
55
123
|
query: z.string().optional().describe('FTS5 query to auto-find anchor. Ignored when anchor is also given; use one or the other.'),
|
|
56
124
|
before: coerceInt.pipe(z.number().int().min(0).max(50)).optional().describe('Items before anchor (default 5)'),
|
|
57
125
|
after: coerceInt.pipe(z.number().int().min(0).max(50)).optional().describe('Items after anchor (default 5)'),
|
|
@@ -59,9 +127,12 @@ export const memTimelineSchema = {
|
|
|
59
127
|
};
|
|
60
128
|
|
|
61
129
|
export const memGetSchema = {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
130
|
+
// Accepts mixed tokens so pasted search results work verbatim: [1], [1, "P#2"], "1,P#2,S#3",
|
|
131
|
+
// or the JSON-stringified form ["1","P#2"]. Each token's prefix routes to its source bucket
|
|
132
|
+
// in server.mjs via lib/id-routing.bucketIdTokens. An explicit `source` override still wins.
|
|
133
|
+
ids: coerceMixedIdTokens.describe('Mixed observation/prompt/session IDs — accepts N, #N, P#N, S#N; comma-strings and JSON arrays also coerced'),
|
|
134
|
+
source: z.enum(['obs', 'session', 'prompt']).optional().describe('Force all IDs to this source (overrides per-token prefixes). Omit to let P#/S#/# prefixes route individually.'),
|
|
135
|
+
fields: coerceStringArray.optional().describe('Specific fields to return (default: all; validated against obs schema — session/prompt sources ignore this filter)'),
|
|
65
136
|
};
|
|
66
137
|
|
|
67
138
|
export const memDeleteSchema = {
|
|
@@ -75,7 +146,7 @@ export const memSaveSchema = {
|
|
|
75
146
|
type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
|
|
76
147
|
project: z.string().optional().describe('Project name (default: inferred from CWD)'),
|
|
77
148
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
|
|
78
|
-
files:
|
|
149
|
+
files: coerceStringArray.optional().describe('File paths associated with this observation'),
|
|
79
150
|
lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway (for bugfix: root cause & fix; for decision: rationale)'),
|
|
80
151
|
};
|
|
81
152
|
|