claude-mem-lite 3.41.0 → 3.42.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": "3.41.0",
13
+ "version": "3.42.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.41.0",
3
+ "version": "3.42.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"
@@ -626,9 +626,10 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
626
626
  // cite-rate reads N/2 for a single injected-then-cited obs instead of N/1.
627
627
  const updatePromote = db.prepare(`
628
628
  UPDATE observations
629
- SET importance = MIN(?, importance + 1),
629
+ SET importance = MIN(?, COALESCE(importance, 1) + 1),
630
630
  cited_count = cited_count + 1,
631
631
  uncited_streak = 0,
632
+ demoted_at = NULL,
632
633
  last_decided_session_id = ?,
633
634
  last_cited_session_id = ?,
634
635
  decay_seen_count = decay_seen_count + ?
@@ -655,7 +656,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
655
656
  `);
656
657
  const updateDemote = db.prepare(`
657
658
  UPDATE observations
658
- SET importance = MAX(?, importance - 1),
659
+ SET importance = MAX(?, COALESCE(importance, 1) - 1),
659
660
  uncited_streak = 0,
660
661
  last_decided_session_id = ?,
661
662
  demoted_at = ?,
@@ -147,7 +147,11 @@ export async function importJsonl(db, path, { project }) {
147
147
  const mb = (n) => Math.round(n / (1024 * 1024));
148
148
  throw new Error(`transcript too large (${mb(st.size)}MB > ${mb(MAX_IMPORT_BYTES)}MB cap); split it (e.g. split -l 50000 into parts) and import the parts`);
149
149
  }
150
- const lines = readFileSync(path, 'utf8').split('\n');
150
+ // Strip a leading UTF-8 BOM — Node's utf8 read leaves it on, so line 1 would become
151
+ // a U+FEFF-prefixed "{...}" and fail JSON.parse (silently dropped as "skipped"). Real CC
152
+ // are BOM-less, but an editor-touched or re-encoded file can carry one.
153
+ const rawText = readFileSync(path, 'utf8');
154
+ const lines = (rawText.charCodeAt(0) === 0xFEFF ? rawText.slice(1) : rawText).split('\n');
151
155
  const seenPrompts = new Set();
152
156
  const seenObs = new Set();
153
157
  // Pre-seed dedup sets from existing rows so a second run on the same file
@@ -28,6 +28,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE
28
28
  import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
29
29
  import { computeTier } from '../tier.mjs';
30
30
  import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
31
+ import { notLowSignalTitleClause } from '../scoring-sql.mjs';
31
32
 
32
33
  /** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
33
34
  export function buildSearchFtsQuery(query, { or = false } = {}) {
@@ -459,6 +460,11 @@ export async function coreRunSearchPipeline(ctx, opts) {
459
460
  // ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
460
461
  if (obsTypeFallback && results.length === 0 && obsType) {
461
462
  const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
463
+ // Mirror the FTS path's low-signal filter (buildObsFtsQuery): this fallback is still a
464
+ // SEARCH surface, so degraded titles ("Modified X", "Error: …") must not lead it —
465
+ // acute for obs_type='change', the noise band. (The no-query recent-listing in
466
+ // searchObservationsHybrid deliberately does NOT filter — it mirrors mem_recent.)
467
+ if (!includeNoise) typeWheres.push(notLowSignalTitleClause(''));
462
468
  const typeParams = [obsType];
463
469
  if (project) { typeWheres.push('project = ?'); typeParams.push(project); }
464
470
  if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
package/mem-cli.mjs CHANGED
@@ -1538,7 +1538,15 @@ function cmdUpdate(db, args) {
1538
1538
  }
1539
1539
  updates.push('title = ?'); params.push(scrubSecrets(flags.title));
1540
1540
  }
1541
- if (flags.narrative !== undefined) { updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative)); }
1541
+ if (flags.narrative !== undefined) {
1542
+ // Reject empty (mirror --title): an explicit '' would blank the narrative
1543
+ // irrecoverably (update takes no snapshot). Omit the flag to leave it unchanged.
1544
+ if (typeof flags.narrative === 'string' && flags.narrative.trim() === '') {
1545
+ fail('[mem] --narrative cannot be empty. Omit the flag to leave it unchanged.');
1546
+ return;
1547
+ }
1548
+ updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1549
+ }
1542
1550
  if (flags.type) {
1543
1551
  const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1544
1552
  if (!validTypes.has(flags.type)) {
@@ -1566,13 +1574,23 @@ function cmdUpdate(db, args) {
1566
1574
  fail(`[mem] --lesson too long (${rawLesson.length} chars, max 500).`);
1567
1575
  return;
1568
1576
  }
1577
+ if (typeof rawLesson === 'string' && rawLesson.trim() === '') {
1578
+ fail('[mem] --lesson cannot be empty. Omit the flag to leave it unchanged.');
1579
+ return;
1580
+ }
1569
1581
  updates.push('lesson_learned = ?');
1570
1582
  params.push(scrubSecrets(rawLesson));
1571
1583
  }
1572
1584
  // Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
1573
1585
  // mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
1574
1586
  // lands searchable + exportable (rebuildObservationDerived folds it into `text`).
1575
- if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts)); }
1587
+ if (flags.concepts !== undefined) {
1588
+ if (typeof flags.concepts === 'string' && flags.concepts.trim() === '') {
1589
+ fail('[mem] --concepts cannot be empty. Omit the flag to leave it unchanged.');
1590
+ return;
1591
+ }
1592
+ updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts));
1593
+ }
1576
1594
 
1577
1595
  if (updates.length === 0) {
1578
1596
  fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
@@ -1664,7 +1682,7 @@ function cmdExport(db, args) {
1664
1682
  // id + memory_session_id are informational (restore remaps id and buckets under
1665
1683
  // a restore session).
1666
1684
  const rows = db.prepare(`
1667
- SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1685
+ SELECT id, memory_session_id, project, type, title, subtitle, narrative, text, concepts, facts,
1668
1686
  files_read, files_modified, lesson_learned, search_aliases, importance, branch,
1669
1687
  access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1670
1688
  last_accessed_at, created_at, created_at_epoch
@@ -1740,6 +1758,7 @@ function cmdRestore(db, argv) {
1740
1758
 
1741
1759
  const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1742
1760
  const signalUpdate = db.prepare(`UPDATE observations SET
1761
+ text = COALESCE(?, text),
1743
1762
  subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1744
1763
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1745
1764
  decay_seen_count = ?, last_accessed_at = ?
@@ -1770,15 +1789,20 @@ function cmdRestore(db, argv) {
1770
1789
  });
1771
1790
  if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1772
1791
  // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1773
- // search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
1774
- // (via the observations FTS triggers) and restored aliases stay searchable.
1775
- // Scrub the FTS-indexed text fields on the way in the sibling ingest paths
1776
- // (import-jsonl, compress-core) scrub as defense-in-depth, and restore is the only
1777
- // rewrite path that skipped it. A backup made before a SECRET_PATTERNS entry existed
1778
- // would otherwise re-index an old secret in facts/concepts even though narrative
1779
- // (routed through saveObservation) gets re-scrubbed. files_read/branch are
1780
- // paths/identifiers, not scrub-target text — left as-is.
1792
+ // `text` is the observation BODY and its own FTS5 column import-jsonl / cold-start
1793
+ // rows keep the body there with an empty narrative, so saveObservation (content =
1794
+ // narrative || title) would collapse it to the bare title. COALESCE re-applies the
1795
+ // exported body (NULL when absent keep saveObservation's derived text, so old
1796
+ // backups without the column degrade gracefully). search_aliases is its own FTS5
1797
+ // column too, so this UPDATE re-syncs the index (via the observations FTS triggers)
1798
+ // and restored body/aliases stay searchable. Scrub the FTS-indexed text fields on
1799
+ // the way in — the sibling ingest paths (import-jsonl, compress-core) scrub as
1800
+ // defense-in-depth, and restore is the only rewrite path that skipped it. A backup
1801
+ // made before a SECRET_PATTERNS entry existed would otherwise re-index an old secret
1802
+ // in text/facts/concepts. files_read/branch are paths/identifiers, not scrub-target
1803
+ // text — left as-is.
1781
1804
  signalUpdate.run(
1805
+ r.text ? scrubSecrets(r.text) : null,
1782
1806
  scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1783
1807
  r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1784
1808
  r.files_read || '[]', r.branch ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.41.0",
3
+ "version": "3.42.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
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/registry.mjs CHANGED
@@ -390,11 +390,16 @@ const UPSERT_SQL = `
390
390
  capability_summary=CASE WHEN excluded.capability_summary != '' THEN excluded.capability_summary ELSE capability_summary END,
391
391
  input_type=CASE WHEN excluded.input_type != '' THEN excluded.input_type ELSE input_type END,
392
392
  output_type=CASE WHEN excluded.output_type != '' THEN excluded.output_type ELSE output_type END,
393
- prerequisites=excluded.prerequisites,
393
+ -- Preserve-on-empty (same class as the FTS text columns above): a partial re-import
394
+ -- omits prerequisites/complexity, so upsertResource supplies their DEFAULTS ('{}' /
395
+ -- 'intermediate'). No CLI flag sets these (only a full re-index does), so treating the
396
+ -- default as the "absent" sentinel is safe: a real re-index sends non-default values,
397
+ -- which still overwrite; a metadata edit sends the default, which now preserves.
398
+ prerequisites=CASE WHEN excluded.prerequisites NOT IN ('', '{}') THEN excluded.prerequisites ELSE prerequisites END,
394
399
  keywords=CASE WHEN excluded.keywords != '' THEN excluded.keywords ELSE keywords END,
395
400
  tech_stack=CASE WHEN excluded.tech_stack != '' THEN excluded.tech_stack ELSE tech_stack END,
396
401
  use_cases=CASE WHEN excluded.use_cases != '' THEN excluded.use_cases ELSE use_cases END,
397
- complexity=excluded.complexity,
402
+ complexity=CASE WHEN excluded.complexity NOT IN ('', 'intermediate') THEN excluded.complexity ELSE complexity END,
398
403
  indexed_at=excluded.indexed_at, updated_at=datetime('now')
399
404
  `;
400
405
 
package/search-engine.mjs CHANGED
@@ -27,13 +27,13 @@ const FULL_SCORE = `${OBS_BM25}
27
27
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
28
28
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
29
29
  * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
30
- * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
30
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
31
31
 
32
32
  const SIMPLE_SCORE = `${OBS_BM25}
33
33
  * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
34
34
  * ${TYPE_QUALITY_CASE}
35
35
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
36
- * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
36
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
37
37
 
38
38
  export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
39
39
  const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
package/secret-scrub.mjs CHANGED
@@ -44,6 +44,11 @@ export const SECRET_PATTERNS = [
44
44
  // low-FP decision that `topsecret=` / `access_token_count:` are non-credentials
45
45
  // (#8283 + utils.test.mjs:1089-1100); bare `pwd` is omitted so `PWD=` (a path) survives.
46
46
  [/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|pgpassword|pgpass|mysql_pwd)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
47
+ // Space-separated credential CLI flag: `--password <value>` (long-form). The KV
48
+ // patterns above require `=`/`:`; the shell long-flag form uses a space. Long-form
49
+ // only — `-p`/`-u` short flags collide with unit/user/update flags (too FP-risky).
50
+ // `(?!-)` stops it eating a following `--flag` when --password has no value.
51
+ [/(--(?:password|passwd)[=\s]+)(?!-)[^\s'"]{6,}/gi, '$1***'],
47
52
  // Bare-key QUOTED values — `api_key="..."`, `password: '...'`. The unquoted KV
48
53
  // patterns above stop at `'`/`"` (excluded from their value class), so a quoted
49
54
  // value matched 0 chars and slipped through. Consumes the opening quote, the value,
@@ -68,35 +73,46 @@ export const SECRET_PATTERNS = [
68
73
  [/\bsk-(?:proj|ant|ant-api\d{2})-[a-zA-Z0-9_-]{8,}\b/g, '***'],
69
74
  [/\bsk-[a-zA-Z0-9_-]{20,}\b/g, '***'],
70
75
  // GitHub tokens (ghp_, gho_, github_pat_)
71
- [/\b(?:ghp_|gho_|ghs_|ghr_)[a-zA-Z0-9_]{30,}\b/g, '***'],
76
+ [/\b(?:ghp_|gho_|ghs_|ghr_|ghu_)[a-zA-Z0-9_]{30,}\b/g, '***'],
72
77
  [/\bgithub_pat_[a-zA-Z0-9_]{22,}\b/g, '***'],
73
78
  // GitLab tokens (glpat-)
74
79
  [/\bglpat-[a-zA-Z0-9_-]{20,}\b/g, '***'],
75
- // Slack tokens (xox[bpas]-)
76
- [/\bxox[bpas]-[a-zA-Z0-9-]{10,}\b/g, '***'],
80
+ // Slack tokens (xox[bpasr]-, xapp-, xoxe-)
81
+ [/\b(?:xox[bpasr]|xapp|xoxe)-[a-zA-Z0-9-]{10,}\b/g, '***'],
82
+ // Slack incoming-webhook URL — the path after /services/ is the shared secret.
83
+ [/(https:\/\/hooks\.slack\.com\/services\/)[A-Za-z0-9/]+/g, '$1***'],
77
84
  // JWT tokens (eyJ...eyJ...)
78
85
  [/\beyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+\b/g, '***'],
79
86
  // PEM private key blocks. `[A-Z0-9 ]*` covers every armor label — RSA/EC/DSA/
80
87
  // OPENSSH plus ENCRYPTED and PGP (… PRIVATE KEY BLOCK) — that the fixed
81
88
  // alternation missed; the block delimiters make FP impossible.
82
89
  [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g, '***PEM_KEY***'],
83
- // Long hex strings in assignments (e.g. SECRET_KEY=abc123def456...)
84
- [/(\b(?:key|secret|token|hash)\s*[=:]\s*)[0-9a-f]{32,}\b/gi, '$1***'],
90
+ // Long hex strings in credential assignments (e.g. SECRET_KEY=abc123def456...).
91
+ // `hash` deliberately excluded: `hash: <40hex>` / `hash=<md5>` are git SHAs and
92
+ // checksums (real, preserved data in this hash-heavy repo), not credentials.
93
+ [/(\b(?:key|secret|token)\s*[=:]\s*)[0-9a-f]{32,}\b/gi, '$1***'],
85
94
  // Google Cloud API keys (AIza...)
86
95
  [/\bAIza[A-Za-z0-9_-]{35}\b/g, '***'],
87
- // Generic Bearer tokens in Authorization headers
88
- [/(Authorization:\s*Bearer\s+)[^\s,;'"}\]]+/gi, '$1***'],
96
+ // Authorization header credentials — Bearer (opaque), Basic (base64 user:pass),
97
+ // and GitHub's `token` scheme all carry secrets after the scheme word.
98
+ [/(Authorization:\s*(?:Bearer|Basic|token)\s+)[^\s,;'"}\]]+/gi, '$1***'],
89
99
  // Supabase / generic long base64 keys (40+ chars, common in env vars)
90
100
  [/(\b(?:SUPABASE_KEY|SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL|REDIS_URL)\s*[=:]\s*)[^\s,;'"}\]]+/gi, '$1***'],
91
101
  // Basic auth in URLs (https://user:password@host). ftp/ftps added — file-drop
92
- // creds are a common leak shape the https-only form missed.
93
- [/(https?|ftps?):\/\/[^@/\s]+:[^@/\s]+@/gi, '$1://***:***@'],
94
- // Database connection strings (postgres, mysql, mongodb, redis, amqp)
95
- [/\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s,;'"}\]]+/gi, '$1://***'],
102
+ // creds are a common leak shape the https-only form missed. The userinfo run
103
+ // EXCLUDES `:` (`[^@/\s:]+`) so the two runs can't overlap on a colon — the
104
+ // overlapping form caused O(n²) catastrophic backtracking on a colon-heavy
105
+ // non-terminating input (an availability DoS on the synchronous prompt path).
106
+ [/(https?|ftps?):\/\/[^@/\s:]+:[^@/\s]+@/gi, '$1://***:***@'],
107
+ // Database connection strings (postgres, mysql, mariadb, mssql, mongodb, redis,
108
+ // amqp) incl. their TLS/alias variants (rediss/amqps/mssql/sqlserver) — managed
109
+ // cloud DBs almost always use the TLS scheme, which the base-only list leaked.
110
+ [/\b(postgres(?:ql)?|mysql|mariadb|mssql|sqlserver|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s,;'"}\]]+/gi, '$1://***'],
96
111
  // npm tokens (npm_...)
97
112
  [/\bnpm_[a-zA-Z0-9]{36,}\b/g, '***'],
98
- // Stripe keys (sk_live_, rk_live_, pk_live_, sk_test_, pk_test_)
113
+ // Stripe keys (sk_live_, rk_live_, pk_live_, sk_test_, pk_test_) + webhook signing secret (whsec_)
99
114
  [/\b[srp]k_(?:live|test)_[a-zA-Z0-9]{20,}\b/g, '***'],
115
+ [/\bwhsec_[a-zA-Z0-9]{20,}\b/g, '***'],
100
116
  // SendGrid API keys: SG.<22>.<43> — two dots at fixed offsets make this
101
117
  // structurally unmistakable; near-zero false-positive risk.
102
118
  [/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, '***'],
package/tool-schemas.mjs CHANGED
@@ -237,13 +237,15 @@ export const memUpdateSchema = {
237
237
  // CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
238
238
  // in every listing — reject here like the CLI does, instead of persisting it.
239
239
  title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
240
- narrative: z.string().optional().describe('New narrative/content'),
240
+ // Reject empty content fields (parity with `title` above + cmdUpdate): an explicit
241
+ // '' would blank narrative/lesson/concepts irrecoverably (mem_update takes no snapshot).
242
+ narrative: z.string().refine(s => s.trim() !== '', 'narrative cannot be empty').optional().describe('New narrative/content'),
241
243
  type: OBS_TYPE_ENUM.optional().describe('New observation type'),
242
244
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
243
245
  // 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
244
246
  // that let overlong lessons leak into the DB via MCP.
245
- lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
246
- concepts: z.string().optional().describe('Space-separated concept tags'),
247
+ lesson_learned: z.string().max(500).refine(s => s.trim() !== '', 'lesson_learned cannot be empty').optional().describe('Add or update lesson learned'),
248
+ concepts: z.string().refine(s => s.trim() !== '', 'concepts cannot be empty').optional().describe('Space-separated concept tags'),
247
249
  };
248
250
 
249
251
  export const memExportSchema = {