claude-mem-lite 3.92.0 → 3.93.1

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.
@@ -20,19 +20,21 @@
20
20
 
21
21
  import { existsSync, readFileSync } from 'fs';
22
22
  import { basename, join } from 'path';
23
- import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
23
+ import { resolveDataDir, resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
24
24
  import { recordHookError } from '../lib/hook-telemetry.mjs';
25
25
  // D#154: every envelope on this stdout goes through the one writer. This script has a
26
26
  // single emit today, so the change buys nothing on its own — it buys that a SECOND
27
27
  // emit added later merges instead of producing two JSON documents, which the host
28
28
  // parses as neither (lib/hook-stdout.mjs). Import-free module over no runtime deps.
29
29
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
30
+ // P1-9: one bounded stdin reader. Import-free, like hook-stdout.mjs beside it.
31
+ import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES } from '../lib/hook-stdin.mjs';
30
32
  import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
31
33
 
32
34
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
33
35
 
34
36
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
35
- const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
37
+ const RUNTIME_DIR = resolveRuntimeDir(DATA_DIR);
36
38
  const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
37
39
 
38
40
  // The no-session legacy fallback stays local: it is this script's own back-compat with
@@ -46,8 +48,9 @@ function cooldownPathFor(sessionId) {
46
48
  async function main() {
47
49
  if (!SALIENCE_BIND) return;
48
50
  if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
49
- let input = '';
50
- for await (const chunk of process.stdin) input += chunk;
51
+ // Bounded stdin (P1-9) — was an unbounded `for await` accumulate with no cap or timeout.
52
+ // Same payload class as pre-tool-recall: a PostToolUse on `Write` carries the whole file.
53
+ const { text: input } = await readHookStdin({ maxBytes: TOOL_INPUT_FILE_MAX_BYTES });
51
54
  let filePath, sessionId;
52
55
  try {
53
56
  const e = JSON.parse(input);
@@ -22,16 +22,24 @@ const ENABLED = process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on'
22
22
  // 2026-08-14 M-5). Swallows everything: telemetry must never break a dispatch.
23
23
  async function recordFailure(scope, err, ctx) {
24
24
  try {
25
- const [{ recordHookError }, { resolveDataDir }, { join }] = await Promise.all([
25
+ const [{ recordHookError }, { resolveDataDir, resolveRuntimeDir }] = await Promise.all([
26
26
  import('../lib/hook-telemetry.mjs'),
27
27
  import('../lib/resolve-data-dir.mjs'),
28
- import('path'),
29
28
  ]);
30
- const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
31
- recordHookError(scope, err, process.env.CLAUDE_MEM_RUNTIME_DIR || join(dataDir, 'runtime'), ctx);
29
+ // P1-14: the shared resolver, not a fourth hand-written `env || join(...)`. This is on
30
+ // the error path, which already dynamic-imports, so the script's zero-import budget on
31
+ // the HAPPY path is untouched.
32
+ recordHookError(scope, err, resolveRuntimeDir(resolveDataDir(process.env.CLAUDE_MEM_DIR)), ctx);
32
33
  } catch { /* never */ }
33
34
  }
34
35
 
36
+ // The ONE hand-written stdin reader left in the tree, and it stays deliberately (P1-9).
37
+ // The other five now share `lib/hook-stdin.mjs`; this script is the default-OFF path whose
38
+ // entire reason to exist is costing nothing when the feature is disabled, and it reaches
39
+ // this line before importing anything at all — even an import-free module is a module
40
+ // resolution. Its caliber (1.5 s, 262144, never rejects) is the same shape the shared
41
+ // reader implements with `rejectOnTimeout: false`, so if this ever gains an import, delete
42
+ // this function and call `readHookStdin({ timeoutMs: 1500, maxBytes: 262144 })`.
35
43
  function readStdin() {
36
44
  return new Promise((resolve) => {
37
45
  let data = '';
@@ -7,17 +7,20 @@ import { existsSync, readFileSync } from 'fs';
7
7
  import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import { recordHookError } from '../lib/hook-telemetry.mjs';
10
- import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
+ import { resolveDataDir, resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
11
11
  // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
12
  // inside its "lightweight standalone" budget (no heavy transitive deps).
13
13
  import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
14
14
  // D#154: single envelope writer. Also import-free (no runtime deps), so it stays
15
15
  // inside this script's "lightweight standalone" budget.
16
16
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
17
+ // P1-9: bounded stdin. Also import-free, so it stays inside the "lightweight standalone"
18
+ // budget this script's header claims.
19
+ import { readHookStdin } from '../lib/hook-stdin.mjs';
17
20
 
18
21
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
19
22
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
20
- const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
23
+ const RUNTIME_DIR = resolveRuntimeDir(DATA_DIR);
21
24
  // D#29: all data artifacts follow DATA_DIR (CLAUDE_MEM_DIR-aware), not a hardcoded
22
25
  // homedir — previously REGISTRY_DB_PATH/MANAGED_BASE/MARKER pinned homedir while line 12
23
26
  // honored the env, so relocated installs opened the wrong DB and the marker never matched
@@ -32,9 +35,10 @@ try {
32
35
  // Skip if recursive hook
33
36
  if (process.env.CLAUDE_MEM_HOOK_RUNNING) process.exit(0);
34
37
 
35
- // Read stdin
36
- let input = '';
37
- for await (const chunk of process.stdin) input += chunk;
38
+ // Read stdin, bounded (P1-9). This used to be an unbounded `for await` accumulate: no
39
+ // cap, no timeout, the only limit being the host's own fail-open — which looks exactly
40
+ // like the hook having nothing to say.
41
+ const { text: input } = await readHookStdin();
38
42
 
39
43
  // Parse event
40
44
  let skillName;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
- import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
9
+ import { resolveDataDir, resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
11
11
  import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX, readInjectedMarker, mergeInjectedMarker } from '../lib/injected-ids.mjs';
12
12
  import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
@@ -39,6 +39,8 @@ import { neutralizeContextDelimiters } from '../format-utils.mjs';
39
39
  //
40
40
  // Import-free module, no runtime deps — nothing added to this script's load cost.
41
41
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
42
+ // P1-9: one bounded stdin reader. Import-free, like hook-stdout.mjs beside it.
43
+ import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES, salvageTruncatedHookEvent } from '../lib/hook-stdin.mjs';
42
44
  // Recall queries the SAVE-path project, so this MUST produce the same string as the
43
45
  // save path. It used to be a hand-kept copy of the same 6 lines; that copy had already
44
46
  // drifted once (missing the process.env.PWD fallback, so a symlinked project dir
@@ -53,7 +55,8 @@ import { DAY_MS } from '../lib/time-constants.mjs';
53
55
  // per-component overrides for tests that mix isolated + real paths.
54
56
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
55
57
  const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
56
- const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
58
+ const RUNTIME_DIR = resolveRuntimeDir(DATA_DIR);
59
+
57
60
  // A3 (v2.83): cross-hook dedup window. UPS writes
58
61
  // `runtime/.claude-mem-injected-<project>` after each inject; we read it to drop IDs the
59
62
  // agent already saw in this window. Imported, not inlined (ARCH-3): the copy's stated
@@ -301,9 +304,25 @@ try {
301
304
  // Skip if DB doesn't exist
302
305
  if (!existsSync(DB_PATH)) process.exit(0);
303
306
 
304
- // Read stdin
305
- let input = '';
306
- for await (const chunk of process.stdin) input += chunk;
307
+ // Read stdin, bounded (P1-9), at THIS path's own caliber — not the module default.
308
+ //
309
+ // A first cut called `readHookStdin()` bare, taking the 256 KB default, and justified it
310
+ // by saying a truncated payload "is what the host's 3 s fail-open did anyway". That was
311
+ // measured at the v3.93.0 pre-tag review and is FALSE: `JSON.parse` of a 5 MB payload
312
+ // takes 2.99 ms and 10 MB takes 10.8 ms — three orders of magnitude inside the fail-open,
313
+ // so the old unbounded read was not timing out, it was working. The cap did not make an
314
+ // existing loss deliberate, it CREATED one, on `pretool` — the highest-cite-rate injection
315
+ // face this project measures. This repo's own CHANGELOG.md is 1 MB, so a `Write` to it
316
+ // crossed the default cap and silently lost the recall while logging a hook error.
317
+ //
318
+ // Two changes, because they cover different halves. The cap is now a MEMORY backstop
319
+ // sized to the payload class (a whole file), not a functional gate. And a truncated read
320
+ // is salvaged rather than dropped, the same way `hook.mjs handlePostToolUse` salvages
321
+ // `tool_name` from a truncated prefix: everything below needs `file_path` / `tool_name` /
322
+ // `session_id`, all of which are scalars the host emits alongside `content`. Salvage is
323
+ // strictly better than the previous behaviour — when the prefix does not carry them we
324
+ // land exactly where the drop landed.
325
+ const { text: input, truncated } = await readHookStdin({ maxBytes: TOOL_INPUT_FILE_MAX_BYTES });
307
326
 
308
327
  // Parse event
309
328
  let filePath;
@@ -323,8 +342,17 @@ try {
323
342
  const lim = event.tool_input?.limit;
324
343
  isFullRead = (off === undefined || off === null) && (lim === undefined || lim === null);
325
344
  } catch (e) {
326
- recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length });
327
- process.exit(0);
345
+ const salvaged = truncated ? salvageTruncatedHookEvent(input) : null;
346
+ if (!salvaged) {
347
+ // A genuinely malformed payload. Kept distinct from the truncation case above so the
348
+ // hook-error log that `stats` and `doctor` read as an install-health signal does not
349
+ // fill with rows for perfectly normal large writes.
350
+ recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length, truncated });
351
+ process.exit(0);
352
+ }
353
+ ({ filePath, sessionId, toolName } = salvaged);
354
+ toolInput = {};
355
+ isFullRead = true;
328
356
  }
329
357
 
330
358
  // Upstream-shape probe: hook ran but neither field nor input shape matches the
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
7
  import { relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
+ import { readHookStdin } from '../lib/hook-stdin.mjs';
9
+ import { resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
8
10
  import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
9
11
  import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
10
12
  import { cjkPrecisionOk } from '../nlp.mjs';
@@ -25,13 +27,20 @@ import { DAY_MS } from '../lib/time-constants.mjs';
25
27
 
26
28
  // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
27
29
  // <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
28
- const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
30
+ // EVERY runtime path in this file goes through this constant, including the two
31
+ // below. They used to be `join(DB_DIR, 'runtime', …)`, which reproduced the exact
32
+ // defect P1-14 closed one seam over: this file's RUNTIME_DIR honoured the override
33
+ // while its MARKER did not, so under `CLAUDE_MEM_RUNTIME_DIR` the `fyi` face wrote
34
+ // the shared cross-hook marker to <data>/runtime while `pre-tool-recall` (`pretool`)
35
+ // wrote it to the override and `hook.mjs` (`ups`) read the override. Caught by the
36
+ // v3.93.0 pre-tag test-effectiveness review, from a surviving mutation.
37
+ const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
29
38
  // D#120: one marker file per CC session — payload-only session keying (M-6) let
30
39
  // two concurrent windows full-replace each other's marker, killing dedup between
31
40
  // them and resetting `count` on every alternation. Derived per invocation once
32
41
  // the session id is parsed from stdin; no session id → legacy project-keyed file.
33
42
  const injectedIdsFileFor = (sessionId) =>
34
- join(DB_DIR, 'runtime', injectedIdsFileName(inferProject(), sessionId));
43
+ join(RUNTIME_DIR, injectedIdsFileName(inferProject(), sessionId));
35
44
  // Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
36
45
  // scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
37
46
  // recall, vs PreToolUse:Read at 94% recall on a tighter file-keyed set.
@@ -482,28 +491,19 @@ function searchRecent(db, project, limit) {
482
491
 
483
492
  // ─── stdin Reader ───────────────────────────────────────────────────────────
484
493
 
485
- function readStdin() {
486
- return new Promise((resolve, reject) => {
487
- let data = '';
488
- const timeout = setTimeout(() => {
489
- process.stdin.destroy();
490
- reject(new Error('timeout'));
491
- }, 2000);
492
- process.stdin.setEncoding('utf8');
493
- process.stdin.on('data', chunk => {
494
- data += chunk;
495
- // Cap the prompt (#9494 huge-prompt guard) — deliberately tighter than the
496
- // 256KB full-payload tier; both tiers live in utils.mjs (G19).
497
- if (data.length > MAX_UPS_PROMPT_BYTES) {
498
- process.stdin.destroy();
499
- clearTimeout(timeout);
500
- resolve(data.slice(0, MAX_UPS_PROMPT_BYTES));
501
- }
502
- });
503
- process.stdin.on('end', () => { clearTimeout(timeout); resolve(data); });
504
- process.stdin.on('error', err => { clearTimeout(timeout); reject(err); });
505
- process.stdin.resume();
494
+ // P1-9: shared mechanism (lib/hook-stdin.mjs), this entry point's own caliber. 2 s and
495
+ // MAX_UPS_PROMPT_BYTES (64 KB, #9494's huge-prompt guard) are deliberately tighter than the
496
+ // 256 KB full-payload tier hook.mjs uses — both tiers live in utils.mjs (G19) — because the
497
+ // payload here is a user PROMPT, not a tool response. `rejectOnTimeout` matches the previous
498
+ // behaviour: the caller treats a timeout as "skip the injection".
499
+ // Returns a bare string, as this script's callers expect.
500
+ async function readStdin() {
501
+ const { text } = await readHookStdin({
502
+ timeoutMs: 2000,
503
+ maxBytes: MAX_UPS_PROMPT_BYTES,
504
+ rejectOnTimeout: true,
506
505
  });
506
+ return text;
507
507
  }
508
508
 
509
509
  // ─── Format Output ──────────────────────────────────────────────────────────
@@ -550,7 +550,7 @@ function formatPromptResults(rows) {
550
550
  // pointer line so Claude can decide to invoke via SkillTool. The cooldown
551
551
  // and match mechanics below are unchanged.
552
552
 
553
- const SKILL_COOLDOWN_FILE = join(DB_DIR, 'runtime', `.skill-cooldown-${inferProject()}`);
553
+ const SKILL_COOLDOWN_FILE = join(RUNTIME_DIR, `.skill-cooldown-${inferProject()}`);
554
554
  const SKILL_COOLDOWN_MS = 300_000; // 5 minutes
555
555
 
556
556
  function loadManagedSkillNames() {
package/server.mjs CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  // snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
22
22
  // (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
23
23
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
24
- import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
24
+ import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, fetchSessionDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
25
25
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
26
26
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
27
  import { computeStatsFeed } from './lib/stats-core.mjs';
@@ -45,13 +45,12 @@ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
45
45
  import { join, sep } from 'path';
46
46
  import { homedir } from 'os';
47
47
  import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
48
- import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichImportedResources, enrichNamedResource } from './lib/registry-core.mjs';
48
+ import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichImportedResources, enrichNamedResource, resourceUseHint } from './lib/registry-core.mjs';
49
49
  import { searchResources } from './registry-retriever.mjs';
50
50
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
51
51
  import { saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
52
52
  import { applyObsUpdate } from './lib/observation-write.mjs';
53
- import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
54
- import { liveObsFilterSql } from './lib/inject-search-core.mjs';
53
+ import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
55
54
  import { recallByFile } from './lib/recall-core.mjs';
56
55
  import { fetchRecent } from './lib/recent-core.mjs';
57
56
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
@@ -646,8 +645,7 @@ server.registerTool(
646
645
  }
647
646
 
648
647
  if (bySrc.session.length > 0) {
649
- const ph = bySrc.session.map(() => '?').join(',');
650
- const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.session);
648
+ const rows = fetchSessionDetail(db, bySrc.session);
651
649
  // SESSION_DETAIL_FIELDS (get-core, P2-12): shared full set — adds remaining_items
652
650
  // (searchable via FTS but previously unrendered on BOTH detail faces).
653
651
  const sessFields = SESSION_DETAIL_FIELDS;
@@ -1307,27 +1305,12 @@ server.registerTool(
1307
1305
  return { content: [{ type: 'text', text: `No matching resources for: "${args.query}"` }] };
1308
1306
  }
1309
1307
  const home = homedir();
1310
- const toPortable = (p) => p && p.startsWith(home) ? '~' + p.slice(home.length) : (p || '');
1311
1308
  const lines = results.map(r => {
1312
1309
  const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
1313
1310
  const categoryLabel = r.category ? ` [${r.category}]` : '';
1314
- const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
1315
- const portablePath = isManaged ? toPortable(r.local_path) : '';
1316
- let howToUse;
1317
- if (isManaged) {
1318
- // Managed: use Read(path) or mem_use — Skill() won't work for managed resources
1319
- // Agents always have complete .md paths (e.g., agents/group/agents/name.md)
1320
- // Only skills can be directory paths (9 cases) — resolve to /SKILL.md
1321
- const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
1322
- howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
1323
- } else if (r.invocation_name) {
1324
- // Native plugin/user skill: Skill() with full invocation name
1325
- howToUse = r.type === 'skill'
1326
- ? `Skill("${r.invocation_name}")`
1327
- : `Agent(subagent_type="${r.invocation_name}")`;
1328
- } else {
1329
- howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
1330
- }
1311
+ // P2-6: the invocation rule is shared; only the Markdown bold and the two-space
1312
+ // indent below are this face's.
1313
+ const { portablePath, howToUse } = resourceUseHint(r, { home, managedPrefix: join(DB_DIR, 'managed') + sep });
1331
1314
  const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
1332
1315
  return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`;
1333
1316
  });
@@ -1594,33 +1577,32 @@ export async function handleExportForTest(db, args) {
1594
1577
  }
1595
1578
 
1596
1579
  async function runExport(db, args) {
1597
- const wheres = [];
1598
- const params = [];
1599
- // Composed, not hand-written: the CLI twin (mem-cli.mjs cmdExport) already
1600
- // routes through liveObsFilterSql, and a hand-rolled copy here is how the
1601
- // live-row predicate drifted apart on other surfaces (P2-11/D#123). With
1602
- // include_compressed the compressed half is dropped but retractions still
1603
- // are not — tombstone export is opt-in, supersession is never exported.
1604
- wheres.push(args.include_compressed ? 'superseded_at IS NULL' : liveObsFilterSql(''));
1605
- if (args.project) { wheres.push('project = ?'); params.push(_resolveProjectShared(db, args.project)); }
1606
- if (args.type) { wheres.push('type = ?'); params.push(args.type); }
1607
- // T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
1608
- // mem_search, which threw. A dropped filter can quietly expand the export blast radius.
1580
+ // PARSE + VALIDATE here; the SQL predicate comes from buildExportWhere (P2-5), shared
1581
+ // with mem-cli.mjs cmdExport. This face THROWS where the CLI prints a usage message —
1582
+ // T3-P1-A: surface invalid dates instead of silently dropping the filter (mirrors
1583
+ // mem_search). A dropped filter can quietly expand the export blast radius.
1584
+ let fromEpoch = null;
1585
+ let toEpoch = null;
1609
1586
  if (args.date_from) {
1610
- const epoch = new Date(args.date_from).getTime();
1611
- if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
1612
- wheres.push('created_at_epoch >= ?');
1613
- params.push(epoch);
1587
+ fromEpoch = new Date(args.date_from).getTime();
1588
+ if (isNaN(fromEpoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
1614
1589
  }
1615
1590
  if (args.date_to) {
1616
1591
  const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
1617
- const epoch = new Date(d).getTime();
1618
- if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
1619
- wheres.push('created_at_epoch <= ?');
1620
- params.push(epoch);
1592
+ toEpoch = new Date(d).getTime();
1593
+ if (isNaN(toEpoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
1621
1594
  }
1622
-
1623
- const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
1595
+ const { params, where } = buildExportWhere({
1596
+ includeCompressed: Boolean(args.include_compressed),
1597
+ // `|| args.project`: buildExportWhere gates the predicate on TRUTHINESS, so a falsy
1598
+ // resolution would DROP the project filter and export the whole store. The code this
1599
+ // replaced pushed `project = ?` unconditionally, so an unresolvable name yielded zero
1600
+ // rows — which is the safe direction, and the comment above is specifically about not
1601
+ // letting a dropped filter widen the export.
1602
+ project: args.project ? (_resolveProjectShared(db, args.project) || args.project) : null,
1603
+ type: args.type || null,
1604
+ fromEpoch, toEpoch,
1605
+ });
1624
1606
  // No clamp (audit 2026-08-14 A2): `Math.min(args.limit ?? 200, 1000)` made an MCP-driven
1625
1607
  // backup of a >1000-row store impossible, on the tool whose own description says "USE
1626
1608
  // when: Backing up memory before a migration or reinstall" — while the CLI twin exported
package/source-files.mjs CHANGED
@@ -110,6 +110,8 @@ export const SOURCE_FILES = [
110
110
  // Single-envelope stdout for hook processes — imported by hook.mjs. Claude Code
111
111
  // parses hook stdout as ONE JSON document; missing from the manifest → an updated
112
112
  // install throws ERR_MODULE_NOT_FOUND on every hook fire.
113
+ 'lib/hook-stdin.mjs',
114
+ 'lib/plugin-key.mjs',
113
115
  'lib/hook-stdout.mjs',
114
116
  // audit P0/P1: inter-process install lock + atomic config writes — imported by
115
117
  // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json