claude-mem-lite 3.41.0 → 3.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": "3.41.0",
13
+ "version": "3.43.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.43.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-optimize.mjs CHANGED
@@ -929,7 +929,23 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
929
929
  try {
930
930
  switch (task) {
931
931
  case 're-enrich':
932
- results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
932
+ if (reenrichScope === 'narrow') {
933
+ // P1-2: the default maintenance pass covers BOTH narrow (fill lesson/concepts on
934
+ // fully-degraded rows) AND aliases (backfill search_aliases on lesson-bearing
935
+ // manual saves that narrow+wide both skip — mem_save writes no aliases, so without
936
+ // this they stay paraphrase-unfindable). Split the budget so neither starves.
937
+ // An explicit --scope wide|aliases still runs exactly that one scope (below).
938
+ const aliasBudget = Math.max(1, Math.floor(budget.reenrich / 2));
939
+ const narrowRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: 'narrow', project });
940
+ const aliasRes = await executeReenrich(db, aliasBudget, { scope: 'aliases', project });
941
+ results.reenrich = {
942
+ processed: (narrowRes.processed || 0) + (aliasRes.processed || 0),
943
+ skipped: (narrowRes.skipped || 0) + (aliasRes.skipped || 0),
944
+ byScope: { narrow: narrowRes, aliases: aliasRes },
945
+ };
946
+ } else {
947
+ results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
948
+ }
933
949
  break;
934
950
  case 'normalize':
935
951
  results.normalize = await executeNormalize(db, force, { project });
@@ -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 = ?,
@@ -0,0 +1,26 @@
1
+ // Single source of truth for the observation columns that `export` emits and `restore`
2
+ // reads back. Both the CLI (`cmdExport` in mem-cli.mjs) and the MCP `mem_export` tool
3
+ // (server.mjs) build their SELECT from this list so the two surfaces can never drift.
4
+ //
5
+ // v3.42 audit HIGH-2: the MCP export handler had its own narrower 16-column SELECT while
6
+ // the CLI export carried 24 — missing exactly the columns cmdRestore reads back
7
+ // (text / files_read / search_aliases / cited_count / uncited_streak / injection_count /
8
+ // decay_seen_count / last_accessed_at). A backup taken via MCP export then restored via CLI
9
+ // silently collapsed every empty-`narrative` row (import-jsonl / cold-start bodies live in
10
+ // `text`) to its bare title — unrecoverable AND unsearchable. Sharing one list closes that
11
+ // twin-drift class permanently.
12
+ //
13
+ // Full round-trippable set: content + value-signals (access/cited/uncited/injection/decay)
14
+ // + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
15
+ // buckets under a synthetic restore session). Session-idempotency keys (last_decided/
16
+ // last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
17
+ // meaningless after a row is re-bucketed under a restore session.
18
+ export const EXPORT_COLUMNS = [
19
+ 'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
20
+ 'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
21
+ 'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
22
+ 'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
23
+ ];
24
+
25
+ // The SELECT column fragment (comma-joined) — drop into `SELECT ${EXPORT_COLUMNS_SQL} FROM …`.
26
+ export const EXPORT_COLUMNS_SQL = EXPORT_COLUMNS.join(', ');
@@ -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); }
@@ -7,6 +7,21 @@ import { notLowSignalTitleClause } from '../scoring-sql.mjs';
7
7
  import { truncate } from '../format-utils.mjs';
8
8
  import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
9
9
 
10
+ // v3.42 F7: noise/low-signal ratios must divide by the LIVE (non-compressed) observation
11
+ // count, NOT the all-rows total. Both numerators (lowVal, lowSignalTitle) already exclude
12
+ // compressed rows (`compressed_into = 0`); dividing by an all-inclusive denominator
13
+ // systematically UNDER-reports noise on a compress-heavy mature store (the store looks
14
+ // cleaner than it is right before a maintain/compress decision). Shared by the CLI `stats`
15
+ // and MCP `mem_stats` main gauges so the two can't drift. Returns 0 when there is no live
16
+ // corpus (all-compressed / empty).
17
+ export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
18
+ const denom = liveTotal > 0 ? liveTotal : 0;
19
+ return {
20
+ noiseRatio: denom ? lowValCount / denom : 0,
21
+ lowSignalRatio: denom ? lowSignalCount / denom : 0,
22
+ };
23
+ }
24
+
10
25
  export function computeQualityStats(db, { project, days }) {
11
26
  const projectFilter = project ? 'AND project = ?' : '';
12
27
  const baseParams = project ? [project] : [];
package/mem-cli.mjs CHANGED
@@ -40,6 +40,8 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
40
40
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
41
41
  import { saveObservation } from './lib/save-observation.mjs';
42
42
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
43
+ import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
44
+ import { computeNoiseGauge } from './lib/stats-quality.mjs';
43
45
  import { recallByFile } from './lib/recall-core.mjs';
44
46
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
45
47
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
@@ -1133,7 +1135,6 @@ async function cmdStats(db, args) {
1133
1135
  AND COALESCE(compressed_into, 0) = 0
1134
1136
  AND created_at_epoch < ? ${projectFilter}
1135
1137
  `).get(thirtyDaysAgo, ...baseParams);
1136
- const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
1137
1138
  // Low-signal-title population: template / tool-log titles (Modified, Worked on,
1138
1139
  // Error while working, Error:, node/npm/npx …) that the retrieval layer already
1139
1140
  // filters out by default. The imp=1 "Low-value" metric above structurally can't
@@ -1145,7 +1146,13 @@ async function cmdStats(db, args) {
1145
1146
  WHERE NOT ${buildNotLowSignalSql()}
1146
1147
  AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
1147
1148
  `).get(...baseParams);
1148
- const lowSignalRatio = obsTotal.c > 0 ? lowSignalTitle.c / obsTotal.c : 0;
1149
+ // F7: both noise numerators exclude compressed rows divide by the LIVE count, not
1150
+ // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
1151
+ // Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
1152
+ const liveTotal = db.prepare(
1153
+ `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
1154
+ ).get(...baseParams);
1155
+ const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
1149
1156
  const compressedCount = db.prepare(
1150
1157
  `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
1151
1158
  ).get(...baseParams);
@@ -1538,7 +1545,15 @@ function cmdUpdate(db, args) {
1538
1545
  }
1539
1546
  updates.push('title = ?'); params.push(scrubSecrets(flags.title));
1540
1547
  }
1541
- if (flags.narrative !== undefined) { updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative)); }
1548
+ if (flags.narrative !== undefined) {
1549
+ // Reject empty (mirror --title): an explicit '' would blank the narrative
1550
+ // irrecoverably (update takes no snapshot). Omit the flag to leave it unchanged.
1551
+ if (typeof flags.narrative === 'string' && flags.narrative.trim() === '') {
1552
+ fail('[mem] --narrative cannot be empty. Omit the flag to leave it unchanged.');
1553
+ return;
1554
+ }
1555
+ updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1556
+ }
1542
1557
  if (flags.type) {
1543
1558
  const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1544
1559
  if (!validTypes.has(flags.type)) {
@@ -1566,13 +1581,23 @@ function cmdUpdate(db, args) {
1566
1581
  fail(`[mem] --lesson too long (${rawLesson.length} chars, max 500).`);
1567
1582
  return;
1568
1583
  }
1584
+ if (typeof rawLesson === 'string' && rawLesson.trim() === '') {
1585
+ fail('[mem] --lesson cannot be empty. Omit the flag to leave it unchanged.');
1586
+ return;
1587
+ }
1569
1588
  updates.push('lesson_learned = ?');
1570
1589
  params.push(scrubSecrets(rawLesson));
1571
1590
  }
1572
1591
  // Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
1573
1592
  // mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
1574
1593
  // lands searchable + exportable (rebuildObservationDerived folds it into `text`).
1575
- if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts)); }
1594
+ if (flags.concepts !== undefined) {
1595
+ if (typeof flags.concepts === 'string' && flags.concepts.trim() === '') {
1596
+ fail('[mem] --concepts cannot be empty. Omit the flag to leave it unchanged.');
1597
+ return;
1598
+ }
1599
+ updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts));
1600
+ }
1576
1601
 
1577
1602
  if (updates.length === 0) {
1578
1603
  fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
@@ -1659,15 +1684,12 @@ function cmdExport(db, args) {
1659
1684
  // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1660
1685
  // `search_aliases` is an FTS5-indexed column (BM25 weight 5) — dropping it on
1661
1686
  // export silently lost the LLM-generated alternate query terms on restore, so a
1662
- // restored memory became unfindable by its aliases. Additive vs the pre-v2.90
1663
- // 13-col shape; existing `export | jq '.[].title'` consumers are unaffected.
1664
- // id + memory_session_id are informational (restore remaps id and buckets under
1665
- // a restore session).
1687
+ // restored memory became unfindable by its aliases. id + memory_session_id are
1688
+ // informational (restore remaps id and buckets under a restore session).
1689
+ // EXPORT_COLUMNS_SQL is the single source of truth shared with the MCP mem_export
1690
+ // tool (server.mjs) so the two export surfaces can never drift (v3.42 HIGH-2).
1666
1691
  const rows = db.prepare(`
1667
- SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1668
- files_read, files_modified, lesson_learned, search_aliases, importance, branch,
1669
- access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1670
- last_accessed_at, created_at, created_at_epoch
1692
+ SELECT ${EXPORT_COLUMNS_SQL}
1671
1693
  FROM observations WHERE ${wheres.join(' AND ')}
1672
1694
  ORDER BY created_at_epoch DESC LIMIT ?
1673
1695
  `).all(...params, limit);
@@ -1740,6 +1762,7 @@ function cmdRestore(db, argv) {
1740
1762
 
1741
1763
  const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1742
1764
  const signalUpdate = db.prepare(`UPDATE observations SET
1765
+ text = COALESCE(?, text),
1743
1766
  subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1744
1767
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1745
1768
  decay_seen_count = ?, last_accessed_at = ?
@@ -1770,15 +1793,20 @@ function cmdRestore(db, argv) {
1770
1793
  });
1771
1794
  if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1772
1795
  // 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.
1796
+ // `text` is the observation BODY and its own FTS5 column import-jsonl / cold-start
1797
+ // rows keep the body there with an empty narrative, so saveObservation (content =
1798
+ // narrative || title) would collapse it to the bare title. COALESCE re-applies the
1799
+ // exported body (NULL when absent keep saveObservation's derived text, so old
1800
+ // backups without the column degrade gracefully). search_aliases is its own FTS5
1801
+ // column too, so this UPDATE re-syncs the index (via the observations FTS triggers)
1802
+ // and restored body/aliases stay searchable. Scrub the FTS-indexed text fields on
1803
+ // the way in — the sibling ingest paths (import-jsonl, compress-core) scrub as
1804
+ // defense-in-depth, and restore is the only rewrite path that skipped it. A backup
1805
+ // made before a SECRET_PATTERNS entry existed would otherwise re-index an old secret
1806
+ // in text/facts/concepts. files_read/branch are paths/identifiers, not scrub-target
1807
+ // text — left as-is.
1781
1808
  signalUpdate.run(
1809
+ r.text ? scrubSecrets(r.text) : null,
1782
1810
  scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1783
1811
  r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1784
1812
  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.43.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",
@@ -68,6 +68,7 @@
68
68
  "lib/err-sampler.mjs",
69
69
  "lib/hook-telemetry.mjs",
70
70
  "lib/resolve-data-dir.mjs",
71
+ "lib/export-columns.mjs",
71
72
  "lib/file-intel.mjs",
72
73
  "lib/reread-guard.mjs",
73
74
  "lib/metrics.mjs",
package/project-utils.mjs CHANGED
@@ -49,11 +49,19 @@ export function resolveProject(db, name) {
49
49
  ).get(`%--${name}%`);
50
50
  if (prefixed) { _cache.set(name, prefixed.project); return prefixed.project; }
51
51
 
52
- // 3) Substring match: broader fallback for partial names
53
- const substr = db.prepare(
54
- 'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
55
- ).get(`%${name}%`);
56
- if (substr) { _cache.set(name, substr.project); return substr.project; }
52
+ // 3) Whole-token match: the name is a complete hyphen-delimited component of the base
53
+ // (e.g. "graph" → "projects--code-graph-mcp", "mcp" → "…-mcp"). Steps 1/2 already cover
54
+ // the exact base and the base *prefix*; this adds interior/trailing whole tokens ONLY.
55
+ // v3.42 F3: the old `%name%` substring fallback matched mid-token ("test" inside
56
+ // "loop-testing") and returned the highest-COUNT unrelated project, so `--project test`
57
+ // silently queried the wrong project. Require a hyphen boundary: an interior token
58
+ // (`%-name-%`) or a trailing token (`%-name`) so "test" no longer matches "testing".
59
+ const token = db.prepare(
60
+ `SELECT project FROM observations
61
+ WHERE (project LIKE '%-' || ? || '-%' OR project LIKE '%-' || ?)
62
+ GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
63
+ ).get(name, name);
64
+ if (token) { _cache.set(name, token.project); return token.project; }
57
65
 
58
66
  // 4) Fallback: synthesize canonical form from current directory
59
67
  const inferred = inferProject();
@@ -349,16 +349,29 @@ export function formatFunnel(s) {
349
349
  if (typeof s.sessions === 'number') {
350
350
  const mp = s.matched && s.matched.pass
351
351
  ? `${s.matched.adopt}/${s.matched.pass} (${(100 * s.matched.precision).toFixed(0)}%)` : 'n/a';
352
- const liftRows = Object.entries(s.lift || {}).filter(([, v]) => Number.isFinite(v.lift))
352
+ // v3.42 F5: lift = adoptGivenPass / baseRate is a ratio over `passSessions` observations.
353
+ // Below LIFT_MIN_PASS_SESSIONS the numerator is 1-2 sessions of noise (a single
354
+ // pass→adopt yields lift 7.0 at passSessions=1) — displaying it top-ranked next to a
355
+ // 30-session skill misleads the shadow→live flip decision. Gate the DISPLAY on a minimum
356
+ // observation count and report how many were suppressed (never a silent cap).
357
+ const finite = Object.entries(s.lift || {}).filter(([, v]) => Number.isFinite(v.lift));
358
+ const shown = finite.filter(([, v]) => (v.passSessions || 0) >= LIFT_MIN_PASS_SESSIONS);
359
+ const suppressed = finite.length - shown.length;
360
+ const liftRows = shown
353
361
  .sort((a, b) => b[1].lift - a[1].lift).slice(0, 5)
354
362
  .map(([k, v]) => `${k}(lift ${v.lift.toFixed(2)}; ${v.hitSessions}/${v.passSessions} pass→adopt vs base ${(100 * v.baseRate).toFixed(0)}%)`)
355
363
  .join(', ') || '(none)';
364
+ const suppressNote = suppressed > 0 ? ` [${suppressed} hidden: passSessions<${LIFT_MIN_PASS_SESSIONS}]` : '';
356
365
  lines.push(` sessions=${s.sessions} matched precision (in-session PASS→adopt): ${mp}`);
357
- lines.push(` targeting lift (>1 = gate beats organic base rate): ${liftRows}`);
366
+ lines.push(` targeting lift (>1 = gate beats organic base rate, min n=${LIFT_MIN_PASS_SESSIONS}): ${liftRows}${suppressNote}`);
358
367
  }
359
368
  return lines.join('\n');
360
369
  }
361
370
 
371
+ // Minimum passSessions before a per-skill lift is trustworthy enough to display. Below this,
372
+ // adoptGivenPass is computed over 1-2 sessions and the lift is noise (F5).
373
+ export const LIFT_MIN_PASS_SESSIONS = 3;
374
+
362
375
  /** Human-readable threshold sweep for `registry recommend-stats --sweep`. */
363
376
  export function formatSweep(grid) {
364
377
  const lines = ['gate threshold sweep (floor × margin → pass / matched precision):'];
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,22 @@ 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
+
38
+ // Shared column set for fetching an observation surfaced by the vector arm — used by BOTH
39
+ // the RRF-merge branch (FTS also had results) and the FTS-empty fallback branch. Single
40
+ // source so the two can't drift. v3.42 F4: the fallback branch's SELECT had dropped
41
+ // lesson_learned while its RRF twin kept it, so a vector-only hit returned
42
+ // lesson_learned: undefined — losing the lesson content AND the 1.5× lesson scoring boost
43
+ // downstream. Both branches build `{ …, date: obs.created_at, lesson_learned: obs.lesson_learned }`
44
+ // so the SELECT must carry created_at + lesson_learned.
45
+ export const VEC_HIT_OBS_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch, importance, files_modified, branch, lesson_learned';
37
46
 
38
47
  export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
39
48
  const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
@@ -432,7 +441,7 @@ export function searchObservationsHybrid(db, ctx) {
432
441
  const resultMap = new Map(results.map(r => [r.id, r]));
433
442
  for (const vr of vecResults) {
434
443
  if (!resultMap.has(vr.id)) {
435
- const obs = db.prepare('SELECT id, type, title, subtitle, project, created_at, created_at_epoch, importance, files_modified, branch, lesson_learned FROM observations WHERE id = ?').get(vr.id);
444
+ const obs = db.prepare(`SELECT ${VEC_HIT_OBS_COLS} FROM observations WHERE id = ?`).get(vr.id);
436
445
  if (!obs) continue;
437
446
  if (epochFrom !== null && obs.created_at_epoch < epochFrom) continue;
438
447
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
@@ -450,7 +459,7 @@ export function searchObservationsHybrid(db, ctx) {
450
459
  } else {
451
460
  // FTS5 found nothing but vector found results
452
461
  for (const vr of vecResults) {
453
- const obs = db.prepare('SELECT id, type, title, subtitle, project, created_at, created_at_epoch, importance, files_modified, branch FROM observations WHERE id = ?').get(vr.id);
462
+ const obs = db.prepare(`SELECT ${VEC_HIT_OBS_COLS} FROM observations WHERE id = ?`).get(vr.id);
454
463
  if (!obs) continue;
455
464
  if (epochFrom !== null && obs.created_at_epoch < epochFrom) continue;
456
465
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
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/server.mjs CHANGED
@@ -44,6 +44,8 @@ import { searchResources } from './registry-retriever.mjs';
44
44
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
45
45
  import { saveObservation } from './lib/save-observation.mjs';
46
46
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
47
+ import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
48
+ import { computeNoiseGauge } from './lib/stats-quality.mjs';
47
49
  import { recallByFile } from './lib/recall-core.mjs';
48
50
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
49
51
  import {
@@ -251,15 +253,14 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFall
251
253
  // Exported for tests: runs the full mem_search pipeline against an explicit db
252
254
  // with an optional injected llm (deepSearch dependency). The MCP tool handler
253
255
  // calls this with the module db and the default llm.
254
- // NOTE: resolveProject() inside runSearchPipeline closes over the module-level `db`,
255
- // not the injected one. Tests that pass a project: arg via this seam will trigger
256
- // resolveProject() against the real (module) DB, not the test DB.
256
+ // v3.42 F3: resolveProject now runs against the injected `db` param (not the module db), so
257
+ // a project: arg through this seam resolves against the TEST db real test isolation.
257
258
  export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
258
259
  return runSearchPipeline(db, args, { llm, rerankLlm });
259
260
  }
260
261
 
261
262
  async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
262
- if (args.project) args = { ...args, project: resolveProject(args.project) };
263
+ if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
263
264
  const limit = args.limit ?? 20;
264
265
  const offset = args.offset ?? 0;
265
266
  // args.or: force OR from the start (CLI `search --or` parity). The default path
@@ -352,15 +353,15 @@ server.registerTool(
352
353
 
353
354
  // ─── Tool: mem_recent ────────────────────────────────────────────────────────
354
355
 
355
- // In-process test seam (mirrors handleSearchForTest, #8743): threads an injected
356
- // db through the SAME body the registered handler runs. NOTE: a `project` arg is
357
- // still resolved via resolveProject() against the MODULE db, not the injected one.
356
+ // In-process test seam (mirrors handleSearchForTest, #8743): threads an injected db through
357
+ // the SAME body the registered handler runs. v3.42 F3: a `project` arg now resolves against
358
+ // the injected db (not the module db), so :memory: test isolation is actually achieved.
358
359
  export async function handleRecentForTest(db, args) {
359
360
  return runRecent(db, args);
360
361
  }
361
362
 
362
363
  async function runRecent(db, args) {
363
- if (args.project) args = { ...args, project: resolveProject(args.project) };
364
+ if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
364
365
  const limit = args.limit ?? 10;
365
366
  const project = args.project || inferProject();
366
367
 
@@ -906,7 +907,6 @@ server.registerTool(
906
907
  AND created_at_epoch < ? ${projectFilter}
907
908
  `).get(thirtyDaysAgo, ...baseParams);
908
909
 
909
- const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
910
910
  // Low-signal-title population (template / tool-log titles the read-side filter
911
911
  // already excludes). The imp=1 "Low-value" metric can't see these, so the
912
912
  // gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
@@ -915,7 +915,12 @@ server.registerTool(
915
915
  WHERE NOT ${buildNotLowSignalSql()}
916
916
  AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
917
917
  `).get(...baseParams);
918
- const lowSignalRatio = obsTotal.c > 0 ? lowSignalTitle.c / obsTotal.c : 0;
918
+ // F7: both noise numerators exclude compressed rows divide by the LIVE count, not
919
+ // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
920
+ const liveTotal = db.prepare(
921
+ `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
922
+ ).get(...baseParams);
923
+ const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
919
924
  const compressedCount = db.prepare(`
920
925
  SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
921
926
  `).get(...baseParams);
@@ -1622,52 +1627,64 @@ server.registerTool(
1622
1627
 
1623
1628
  // ─── Tool: mem_export ────────────────────────────────────────────────────────
1624
1629
 
1630
+ // In-process test seam (mirrors handleRecentForTest, #8743): threads an injected db
1631
+ // through the SAME body the registered handler runs. NOTE: a `project` arg is still
1632
+ // resolved via resolveProject() against the MODULE db, not the injected one.
1633
+ export async function handleExportForTest(db, args) {
1634
+ return runExport(db, args);
1635
+ }
1636
+
1637
+ async function runExport(db, args) {
1638
+ const wheres = [];
1639
+ const params = [];
1640
+ if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
1641
+ wheres.push('superseded_at IS NULL');
1642
+ if (args.project) { wheres.push('project = ?'); params.push(_resolveProjectShared(db, args.project)); }
1643
+ if (args.type) { wheres.push('type = ?'); params.push(args.type); }
1644
+ // T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
1645
+ // mem_search, which threw. A dropped filter can quietly expand the export blast radius.
1646
+ if (args.date_from) {
1647
+ const epoch = new Date(args.date_from).getTime();
1648
+ if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
1649
+ wheres.push('created_at_epoch >= ?');
1650
+ params.push(epoch);
1651
+ }
1652
+ if (args.date_to) {
1653
+ const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
1654
+ const epoch = new Date(d).getTime();
1655
+ if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
1656
+ wheres.push('created_at_epoch <= ?');
1657
+ params.push(epoch);
1658
+ }
1659
+
1660
+ const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
1661
+ const exportLimit = Math.min(args.limit ?? 200, 1000);
1662
+ // T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
1663
+ // "user got exactly what existed". Trim to exportLimit before rendering.
1664
+ // EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
1665
+ // reads back (v3.42 HIGH-2: this handler used to carry a narrower 16-col SELECT, silently
1666
+ // dropping text/aliases/citation-signals on the advertised MCP backup→restore flow).
1667
+ const probed = db.prepare(`SELECT ${EXPORT_COLUMNS_SQL} FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
1668
+ const rows = probed.slice(0, exportLimit);
1669
+ const moreAvailable = probed.length > exportLimit;
1670
+
1671
+ if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };
1672
+
1673
+ const output = args.format === 'jsonl'
1674
+ ? rows.map(r => JSON.stringify(r)).join('\n')
1675
+ : JSON.stringify(rows, null, 2);
1676
+
1677
+ const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
1678
+ return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
1679
+ }
1680
+
1625
1681
  server.registerTool(
1626
1682
  'mem_export',
1627
1683
  {
1628
1684
  description: descriptionOf('mem_export'),
1629
1685
  inputSchema: memExportSchema,
1630
1686
  },
1631
- safeHandler(async (args) => {
1632
- const wheres = [];
1633
- const params = [];
1634
- if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
1635
- wheres.push('superseded_at IS NULL');
1636
- if (args.project) { wheres.push('project = ?'); params.push(resolveProject(args.project)); }
1637
- if (args.type) { wheres.push('type = ?'); params.push(args.type); }
1638
- // T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
1639
- // mem_search, which threw. A dropped filter can quietly expand the export blast radius.
1640
- if (args.date_from) {
1641
- const epoch = new Date(args.date_from).getTime();
1642
- if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
1643
- wheres.push('created_at_epoch >= ?');
1644
- params.push(epoch);
1645
- }
1646
- if (args.date_to) {
1647
- const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
1648
- const epoch = new Date(d).getTime();
1649
- if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
1650
- wheres.push('created_at_epoch <= ?');
1651
- params.push(epoch);
1652
- }
1653
-
1654
- const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
1655
- const exportLimit = Math.min(args.limit ?? 200, 1000);
1656
- // T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
1657
- // "user got exactly what existed". Trim to exportLimit before rendering.
1658
- const probed = db.prepare(`SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, branch, access_count, memory_session_id, created_at, created_at_epoch FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
1659
- const rows = probed.slice(0, exportLimit);
1660
- const moreAvailable = probed.length > exportLimit;
1661
-
1662
- if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };
1663
-
1664
- const output = args.format === 'jsonl'
1665
- ? rows.map(r => JSON.stringify(r)).join('\n')
1666
- : JSON.stringify(rows, null, 2);
1667
-
1668
- const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
1669
- return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
1670
- })
1687
+ safeHandler(async (args) => runExport(db, args))
1671
1688
  );
1672
1689
 
1673
1690
  // ─── Tool: mem_recall ────────────────────────────────────────────────────────
package/source-files.mjs CHANGED
@@ -170,6 +170,10 @@ export const SOURCE_FILES = [
170
170
  // mem-cli.mjs::cmdImportJsonl; listed here so source-files-sync.test.mjs
171
171
  // and the npm tarball ship it on every release.
172
172
  'lib/import-jsonl.mjs',
173
+ // v3.42 HIGH-2: single source of truth for the export/restore round-trippable column
174
+ // set. Statically imported by server.mjs (mem_export) and mem-cli.mjs (cmdExport) so the
175
+ // two export surfaces can't drift. Must ship or auto-update breaks export on either.
176
+ 'lib/export-columns.mjs',
173
177
  ];
174
178
 
175
179
  /**
@@ -199,15 +203,39 @@ export const HOOK_SCRIPT_FILES = [
199
203
  'hook-launcher.mjs',
200
204
  ];
201
205
 
206
+ // Executable scripts that are NOT direct-install hook scripts (so they don't belong in
207
+ // HOOK_SCRIPT_FILES, which install.mjs materializes into ~/.claude-mem-lite/scripts/) but
208
+ // ARE run at runtime and MUST be signed:
209
+ // - launch.mjs / launch-preflight.mjs: the plugin MCP server (.mcp.json runs
210
+ // ${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs; launch.mjs imports launch-preflight.mjs).
211
+ // install.mjs::dedupePluginCacheAndHooks copies BOTH from the tarball into every plugin
212
+ // cache version dir during repair() — an unsigned launcher let a release published
213
+ // without the signing key (reusing the real manifest+sig) swap launch.mjs and gain RCE
214
+ // as the MCP server. v3.42 audit HIGH-1.
215
+ // - setup.sh: run on plugin SessionStart via hooks.json. Signed for defense-in-depth
216
+ // (no tarball→executed-path propagation today, but it ships in files[] and is executed).
217
+ // These ship via package.json files[] directly, not via HOOK_SCRIPT_FILES' copy path, so
218
+ // listing them here changes ONLY what is signed/verified, not what install materializes.
219
+ // Module-internal (spread into RELEASE_SIGNED_FILES below); not exported — no external
220
+ // consumer, and the signing test asserts coverage via the built manifest, not this list.
221
+ const LAUNCHER_SCRIPT_FILES = [
222
+ 'launch.mjs',
223
+ 'launch-preflight.mjs',
224
+ 'setup.sh',
225
+ ];
226
+
202
227
  // The complete set of files the release signature MUST cover: every runtime .mjs
203
228
  // (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
204
- // into the live dir and they run on every hook fire). HOOK_SCRIPT_FILES were historically
205
- // NOT in the signed manifest, so an attacker able to PUBLISH a release — but without the
206
- // signing key — could swap a hook script (e.g. post-tool-use.sh / hook-launcher.mjs) while
207
- // every SOURCE_FILES hash still matched, and fail-closed verification would still pass →
208
- // RCE on the next hook fire. Keys are ROOT-relative, matching the extracted-tarball layout
209
- // that verifyReleaseFiles hashes against.
229
+ // into the live dir and they run on every hook fire) PLUS the launcher/setup scripts.
230
+ // HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
231
+ // PUBLISH a release — but without the signing key — could swap a hook script (e.g.
232
+ // post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
233
+ // fail-closed verification would still pass → RCE on the next hook fire. The MCP launcher
234
+ // (LAUNCHER_SCRIPT_FILES) was the same gap reopened for the plugin+repair path (v3.42
235
+ // HIGH-1). Keys are ROOT-relative, matching the extracted-tarball layout that
236
+ // verifyReleaseFiles hashes against.
210
237
  export const RELEASE_SIGNED_FILES = [
211
238
  ...SOURCE_FILES,
212
239
  ...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
240
+ ...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
213
241
  ];
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 = {