claude-mem-lite 3.42.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.42.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.42.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 });
@@ -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(', ');
@@ -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);
@@ -1677,15 +1684,12 @@ function cmdExport(db, args) {
1677
1684
  // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1678
1685
  // `search_aliases` is an FTS5-indexed column (BM25 weight 5) — dropping it on
1679
1686
  // export silently lost the LLM-generated alternate query terms on restore, so a
1680
- // restored memory became unfindable by its aliases. Additive vs the pre-v2.90
1681
- // 13-col shape; existing `export | jq '.[].title'` consumers are unaffected.
1682
- // id + memory_session_id are informational (restore remaps id and buckets under
1683
- // 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).
1684
1691
  const rows = db.prepare(`
1685
- SELECT id, memory_session_id, project, type, title, subtitle, narrative, text, concepts, facts,
1686
- files_read, files_modified, lesson_learned, search_aliases, importance, branch,
1687
- access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1688
- last_accessed_at, created_at, created_at_epoch
1692
+ SELECT ${EXPORT_COLUMNS_SQL}
1689
1693
  FROM observations WHERE ${wheres.join(' AND ')}
1690
1694
  ORDER BY created_at_epoch DESC LIMIT ?
1691
1695
  `).all(...params, limit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.42.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/search-engine.mjs CHANGED
@@ -35,6 +35,15 @@ const SIMPLE_SCORE = `${OBS_BM25}
35
35
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
36
36
  * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
37
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';
46
+
38
47
  export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
39
48
  const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
40
49
  const mult = multiplier ? ` * ${multiplier}` : '';
@@ -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/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
  ];