claude-mem-lite 2.42.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.42.0",
13
+ "version": "2.43.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.42.0",
3
+ "version": "2.43.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
@@ -25,6 +25,35 @@ export function parseIdToken(raw) {
25
25
  return { source, id };
26
26
  }
27
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
+ }
56
+
28
57
  /**
29
58
  * Probe the observations / session_summaries / user_prompts tables for any
30
59
  * of the given numeric IDs, excluding the sources the caller already queried.
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
 
@@ -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
- const bySrc = { obs: [], session: [], prompt: [] };
624
- for (const p of parsed) {
625
- const src = explicit || p.source || 'obs';
626
- bySrc[src].push(p.id);
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 = parsed.map(p => p.id);
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 obsExists = db.prepare('SELECT 1 FROM observations WHERE id = ?').get(parsed.id);
720
- if (obsExists) {
721
- anchorId = parsed.id;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.42.0",
3
+ "version": "2.43.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
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, parseIdToken } 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
 
@@ -768,7 +768,10 @@ server.registerTool(
768
768
  // from mem_search results expect the same routing as CLI `timeline --anchor`.
769
769
  // Prompt/session anchors resolve to the nearest-in-time observation so
770
770
  // before/after semantics still apply to the observations timeline.
771
- if (typeof anchorId === 'string') {
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') {
772
775
  const parsed = parseIdToken(anchorId);
773
776
  if (!parsed) {
774
777
  return { content: [{ type: 'text', text: `Invalid anchor "${args.anchor}". Expected N, #N, P#N, or S#N.` }] };
@@ -789,9 +792,20 @@ server.registerTool(
789
792
  anchorNote = `(anchored to #${nearest.id}, closest obs to ${srcPrefix}${parsed.id})`;
790
793
  } else {
791
794
  // Bare "#N" or "N" — resolve to obs, falling back to prompt/session like CLI bare-int path.
792
- const obsExists = db.prepare('SELECT 1 FROM observations WHERE id = ?').get(parsed.id);
793
- if (obsExists) {
794
- anchorId = parsed.id;
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
+ }
795
809
  } else {
796
810
  const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
797
811
  const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
@@ -915,84 +929,123 @@ server.registerTool(
915
929
  inputSchema: memGetSchema,
916
930
  },
917
931
  safeHandler(async (args) => {
918
- const source = args.source || 'obs';
919
- const placeholders = args.ids.map(() => '?').join(',');
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
+ }
920
943
 
921
- let rows, allFields, prefix, sourceLabel;
922
- if (source === 'session') {
923
- rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
924
- allFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
925
- prefix = 'S#';
926
- sourceLabel = 'sessions';
927
- } else if (source === 'prompt') {
928
- rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
929
- allFields = ['id', 'prompt_text', 'content_session_id', 'prompt_number', 'created_at'];
930
- prefix = 'P#';
931
- sourceLabel = 'prompts';
932
- } else {
933
- // Increment access_count for retrieved observations (batch UPDATE)
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(',');
934
968
  try {
935
- db.prepare(
936
- `UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
937
- ).run(Date.now(), ...args.ids);
938
- 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);
939
971
  } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
940
- rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
941
- allFields = ['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'];
942
- prefix = '#';
943
- sourceLabel = 'observations';
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
+ }
944
986
  }
945
987
 
946
- // P1-3: validate requested fields — throw on all-invalid so callers don't silently get an
947
- // empty record (header only). Partial-invalid is tolerated but surfaced as a note.
948
- let fieldsNote = '';
949
- if (args.fields?.length) {
950
- const invalid = args.fields.filter(f => !allFields.includes(f));
951
- const valid = args.fields.filter(f => allFields.includes(f));
952
- if (valid.length === 0) {
953
- throw new Error(`No valid fields. Unknown field(s): ${invalid.join(', ')}. Valid: ${allFields.join(', ')}`);
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'));
954
1002
  }
955
- if (invalid.length > 0) {
956
- fieldsNote = `Note: unknown field(s) dropped: ${invalid.join(', ')}. Valid: ${allFields.join(', ')}`;
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'));
957
1016
  }
958
1017
  }
959
1018
 
960
- if (rows.length === 0) {
961
- // Symmetric probe via shared lib/id-routing.mjs so CLI cmdGet and MCP mem_get
962
- // stay aligned if a table's ID semantics change.
963
- const probe = probeIdSources(db, args.ids, new Set([source]));
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);
964
1026
  const hints = [];
965
- if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs — use source='obs')`);
966
- if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session — use source='session')`);
967
- 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)`);
968
1030
  const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
969
- const msg = `No ${sourceLabel} found for given IDs.${hint}`;
1031
+ const queriedList = [...queried].join(', ');
1032
+ const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
970
1033
  return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
971
1034
  }
972
1035
 
973
- const fields = args.fields?.length ? args.fields.filter(f => allFields.includes(f)) : allFields;
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#'));
974
1043
 
975
1044
  const parts = [];
976
1045
  if (fieldsNote) parts.push(fieldsNote);
977
- for (const row of rows) {
978
- const lines = [`── ${prefix}${row.id} ──`];
979
- for (const f of fields) {
980
- const val = row[f];
981
- if (val === null || val === undefined || val === '') continue;
982
- // Skip 'text' field when it duplicates narrative (text = narrative + optional CJK bigrams)
983
- if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
984
- // Field-aware truncation: narrative and lesson need more space than metadata
985
- const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
986
- lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
987
- }
988
- parts.push(lines.join('\n'));
989
- }
990
-
991
- // P1-4: surface IDs that weren't found (mirrors mem_delete's missing-ID note).
992
- const foundIds = new Set(rows.map(r => r.id));
993
- const missing = args.ids.filter(id => !foundIds.has(id));
994
- if (missing.length > 0) {
995
- 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.`);
996
1049
  }
997
1050
 
998
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'),
@@ -78,12 +127,12 @@ export const memTimelineSchema = {
78
127
  };
79
128
 
80
129
  export const memGetSchema = {
81
- // TODO(#8126): accept P#/S#/# prefix strings for paste-from-search parity with
82
- // CLI cmdGet bucketed routing (~40 LOC handler refactor). mem_timeline already
83
- // accepts prefixes via coerceAnchor; this is the matched-pair gap.
84
- ids: coerceIntArray.pipe(z.array(z.number().int()).min(1).max(20)).describe('Observation IDs to retrieve'),
85
- source: z.enum(['obs', 'session', 'prompt']).optional().describe('Record type: obs (default), session (S# from search), prompt (P# from search)'),
86
- fields: z.array(z.string()).optional().describe('Specific fields to return (default: all)'),
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)'),
87
136
  };
88
137
 
89
138
  export const memDeleteSchema = {
@@ -97,7 +146,7 @@ export const memSaveSchema = {
97
146
  type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
98
147
  project: z.string().optional().describe('Project name (default: inferred from CWD)'),
99
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)'),
100
- files: z.array(z.string()).optional().describe('File paths associated with this observation'),
149
+ files: coerceStringArray.optional().describe('File paths associated with this observation'),
101
150
  lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway (for bugfix: root cause & fix; for decision: rationale)'),
102
151
  };
103
152