claude-mem-lite 3.92.0 → 3.93.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.
@@ -0,0 +1,44 @@
1
+ // lib/plugin-key.mjs — the plugin's identity in Claude Code's settings, and the one
2
+ // predicate that reads it.
3
+ //
4
+ // Audit 2026-09-02 P2-7. `PLUGIN_KEY` and `isPluginExplicitlyDisabled` shipped twice in JS —
5
+ // `hook.mjs` (which exits 0 on every event when the plugin is off) and `install.mjs` (which
6
+ // reports it in `doctor`/`status` and branches on it during cleanup) — plus a third,
7
+ // deliberate copy in `scripts/post-tool-use.sh`.
8
+ //
9
+ // What makes the duplication load-bearing rather than untidy: `install.mjs` writes hooks
10
+ // DIRECTLY into `~/.claude/settings.json`, so turning the plugin off in the Claude UI does
11
+ // not remove them. They keep firing, and this predicate is the only thing that makes
12
+ // "disabled" mean disabled. A key that drifts on one side leaves the user with a plugin they
13
+ // switched off and a hook set that never noticed.
14
+ //
15
+ // ZERO DEPENDENCIES: `hook.mjs` imports this at module scope on the hottest path in the
16
+ // system, and the check runs before anything else can.
17
+ //
18
+ // The shell copy in `scripts/post-tool-use.sh` STAYS a copy and is not a defect. That path
19
+ // is the ~5 ms bash pre-filter whose entire purpose is never reaching `node`, so it cannot
20
+ // import anything; its own header documents the parity requirement and
21
+ // `tests/post-tool-use-disabled.test.mjs` pins it. Two homes with a tested bridge is a
22
+ // different thing from two homes and a comment.
23
+
24
+ /** Marketplace this plugin is published under. */
25
+ export const MARKETPLACE_KEY = 'sdsrss';
26
+
27
+ /** The key Claude Code writes under `enabledPlugins` in `~/.claude/settings.json`. */
28
+ export const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
29
+
30
+ /**
31
+ * Whether the user has EXPLICITLY switched the plugin off.
32
+ *
33
+ * Strict `=== false`, not falsiness: an absent key means "never installed via the
34
+ * marketplace" (an npm-only install, which must keep working) and `true` means enabled.
35
+ * Only an explicit `false` is a decision to honour — treating absent as disabled would
36
+ * silently kill every direct-install user.
37
+ *
38
+ * @param {object|null|undefined} settings Parsed ~/.claude/settings.json, or nullish when
39
+ * it is missing or unreadable — which reads as NOT disabled, so an unparseable settings
40
+ * file fails open rather than disabling the plugin the user is trying to use.
41
+ */
42
+ export function isPluginExplicitlyDisabled(settings) {
43
+ return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
44
+ }
@@ -208,3 +208,51 @@ export async function enrichNamedResource(db, name, { confineTo, enrichResource,
208
208
  const { status, error } = await enrichResourceRow(db, row, { confineTo, enrichResource, env });
209
209
  return { status, name: row.name, error };
210
210
  }
211
+
212
+ /**
213
+ * How a registry search hit should be invoked, and the path worth showing for it.
214
+ *
215
+ * Audit 2026-09-02 P2-6: this ran to ~15 lines in `mem-cli.mjs cmdRegistry` and again in
216
+ * `server.mjs mem_registry`, and the copies had already diverged on `portablePath`:
217
+ *
218
+ * MCP `isManaged ? toPortable(local_path) : ''`
219
+ * CLI `isManaged && local_path.startsWith(home) ? '~'+… : (local_path || '')`
220
+ *
221
+ * For a NON-managed resource the CLI therefore printed `Path: /home/<user>/…` — an absolute
222
+ * path, un-tilde'd, for a row whose `Use:` line is `Skill("x")` or `mem_use(name=…)` and
223
+ * never mentions the path at all. MCP printed nothing. The MCP behaviour is the one kept:
224
+ * the line carried no action for the reader and spelled out a home directory to do it.
225
+ *
226
+ * Returns DATA, not a rendered line. The two faces genuinely differ in dialect — the CLI
227
+ * indents four spaces and prints through `out()`, MCP indents two and emits Markdown bold —
228
+ * and collapsing that would replace a real duplicate with a formatting flag.
229
+ *
230
+ * @param {{name:string,type:string,local_path?:string,invocation_name?:string}} r
231
+ * @param {{home:string, managedPrefix:string}} ctx `managedPrefix` is `<dataDir>/managed/`,
232
+ * already including the trailing separator, so the caller owns CLAUDE_MEM_DIR resolution.
233
+ * @returns {{isManaged:boolean, portablePath:string, howToUse:string}}
234
+ */
235
+ export function resourceUseHint(r, { home, managedPrefix }) {
236
+ const isManaged = Boolean(r.local_path && r.local_path.includes(managedPrefix));
237
+ const portablePath = isManaged
238
+ ? (r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : r.local_path)
239
+ : '';
240
+ const agentArg = r.type === 'agent' ? ', type="agent"' : '';
241
+
242
+ let howToUse;
243
+ if (isManaged) {
244
+ // Managed: Read(path) or mem_use — Skill() does not resolve managed resources.
245
+ // Agents always carry a complete .md path; only skills can be a directory, which
246
+ // resolves to its SKILL.md.
247
+ const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
248
+ howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${agentArg})`;
249
+ } else if (r.invocation_name) {
250
+ // Native plugin/user resource: invoke by its full invocation name.
251
+ howToUse = r.type === 'skill'
252
+ ? `Skill("${r.invocation_name}")`
253
+ : `Agent(subagent_type="${r.invocation_name}")`;
254
+ } else {
255
+ howToUse = `mem_use(name="${r.name}"${agentArg})`;
256
+ }
257
+ return { isManaged, portablePath, howToUse };
258
+ }
@@ -82,3 +82,61 @@ export function resolveDataDir(raw) {
82
82
  }
83
83
  return containInTests(raw);
84
84
  }
85
+
86
+ /**
87
+ * The runtime directory: episode buffers, per-session markers, cooldowns, hook telemetry.
88
+ *
89
+ * Audit 2026-09-02 P1-14. `CLAUDE_MEM_RUNTIME_DIR` was honoured by exactly the five
90
+ * standalone hook scripts and `scripts/hook-launcher.mjs`, and IGNORED by `hook-shared.mjs`
91
+ * (which `hook.mjs`, `server.mjs`, `hook-context.mjs` and `hook-episode.mjs` all take it
92
+ * from) and by `hook-optimize.mjs`. So setting it did not relocate the runtime dir — it
93
+ * SPLIT it: the `fyi` and `pretool` faces wrote markers to the override while `ups` and
94
+ * `keyctx` read them from the real one. A harness pointing the system at an isolated
95
+ * runtime got a half-isolated one, silently, with no error and no empty directory to
96
+ * notice.
97
+ *
98
+ * The audit's own suggestion was to delete the variable from the five scripts. Measured
99
+ * before copying it: it is load-bearing in ten test files, in `hook-launcher.mjs`, and in
100
+ * `experiment/lib/arms.mjs`, which uses it to keep an experiment arm off the real state.
101
+ * Removing it would have cost all of that to fix a split that one shared resolver fixes.
102
+ * The defect was never that the knob exists — it is that the rule for reading it had eight
103
+ * hand-written homes and two modules that had never heard of it.
104
+ *
105
+ * WHAT BELONGS BEHIND THIS RESOLVER, AND WHAT DELIBERATELY DOES NOT. The v3.93.0 pre-tag
106
+ * review found the first cut of P1-14 half-applied — `scripts/user-prompt-search.js`
107
+ * resolved its RUNTIME_DIR here and then built its cross-hook marker from
108
+ * `join(DB_DIR, 'runtime')` anyway, so under the override the `fyi` face wrote the SHARED
109
+ * marker to one directory while `pretool` wrote it to another. Four subsystems were split
110
+ * that way (marker + skill cooldown, hook-error telemetry, the metrics sink and its GC, and
111
+ * the native-binding breakage marker, which is the self-heal trigger). The rule that
112
+ * resolved them, stated so the next sweep does not overshoot:
113
+ *
114
+ * MOVES with the override — state a HOOK writes and another component reads back:
115
+ * cross-hook injected-ids markers, skill/pre-recall cooldowns, hook-error telemetry,
116
+ * the native-binding breakage marker, `metrics/`, `ep-flush-*` / `pending-*` buffers,
117
+ * the shadow-recommendation log.
118
+ *
119
+ * STAYS under the data dir — state about the ONE REAL INSTALLATION: `install.lock`,
120
+ * `update-state.json`, `swap-in-progress`, and the `.update-staging-*` /
121
+ * `.update-backup-*` residue scan. These must NOT follow a per-harness override: the
122
+ * lock exists to serialise concurrent installers, and two installers pointed at
123
+ * different override directories would each take their own lock and both proceed.
124
+ *
125
+ * `tests/runtime-dir-single-home.test.mjs` sweeps for the defect FORM (a shipped module
126
+ * building `join(…, 'runtime')` itself) with the stays-put sites as a named allowlist, so
127
+ * moving one out of that list is a deliberate edit rather than an omission.
128
+ *
129
+ * @param {string} dataDir An already-resolved data dir (from resolveDataDir).
130
+ * @param {NodeJS.ProcessEnv} [env]
131
+ * @returns {string} An absolute runtime directory.
132
+ */
133
+ export function resolveRuntimeDir(dataDir, env = process.env) {
134
+ const raw = env.CLAUDE_MEM_RUNTIME_DIR;
135
+ // Same "falsy means default" rule as above. A relative override is NOT rejected the way
136
+ // CLAUDE_MEM_DIR is: this variable is set by test harnesses that predate that check, and
137
+ // turning a previously-working relative path into a throw would break isolation setups
138
+ // in order to enforce tidiness. It is resolved against cwd instead, so the value is at
139
+ // least absolute by the time anything writes to it.
140
+ if (raw === undefined || raw === null || raw === '') return join(dataDir, 'runtime');
141
+ return isAbsolute(raw) ? raw : resolve(raw);
142
+ }
package/mem-cli.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
7
8
  import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
8
9
  import { resolveProject } from './project-utils.mjs';
9
10
  // READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
@@ -17,11 +18,11 @@ import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
17
18
  import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
18
19
  import { reRankWithContext } from './search-scoring.mjs';
19
20
  import { searchObservationsHybrid } from './search-engine.mjs';
20
- import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
21
+ import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, fetchSessionDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
21
22
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
22
23
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
23
24
  import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
24
- import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichResourceRow, enrichImportedResources, enrichNamedResource, REGISTRY_CONFINE_ENV } from './lib/registry-core.mjs';
25
+ import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichResourceRow, enrichImportedResources, enrichNamedResource, REGISTRY_CONFINE_ENV, resourceUseHint } from './lib/registry-core.mjs';
25
26
  import { searchResources } from './registry-retriever.mjs';
26
27
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
27
28
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -56,7 +57,7 @@ import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
56
57
  import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
57
58
  import { saveObservation, saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
58
59
  import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
59
- import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
60
+ import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
60
61
  import { recallByFile } from './lib/recall-core.mjs';
61
62
  import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
62
63
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
@@ -561,8 +562,7 @@ function renderObsRows(db, ids, requestedFields) {
561
562
  }
562
563
 
563
564
  function renderSessionRows(db, ids) {
564
- const placeholders = ids.map(() => '?').join(',');
565
- const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
565
+ const rows = fetchSessionDetail(db, ids);
566
566
  if (rows.length === 0) return null;
567
567
  const parts = [];
568
568
  for (const r of rows) {
@@ -1248,7 +1248,7 @@ async function cmdStats(db, args) {
1248
1248
  // recorded in the last 24h. Surfaces silent breakage (DB corruption,
1249
1249
  // CC upstream field rename) that would otherwise stay invisible — the
1250
1250
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1251
- const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - DAY_MS);
1251
+ const hookErrors24h = countRecentHookErrors(resolveRuntimeDir(DB_DIR), now - DAY_MS);
1252
1252
 
1253
1253
  // M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
1254
1254
  // pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
@@ -1654,42 +1654,37 @@ function cmdExport(db, args) {
1654
1654
  // script (`export --to "$END" > backup.json`) with an unset `$END` would silently
1655
1655
  // write an empty backup and report success. Reject like cmdSearch does.
1656
1656
  if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
1657
- const wheres = [];
1658
- const params = [];
1659
- // --include-compressed: include compressed observations (aligned with MCP mem_export).
1660
- // Superseded rows are excluded either way; the flag only toggles the compressed half
1661
- // of the live-row pair (backup/export of tombstones is opt-in, retractions are not).
1662
- if (flags['include-compressed'] === true || flags['include-compressed'] === 'true') {
1663
- wheres.push('superseded_at IS NULL');
1664
- } else {
1665
- wheres.push(liveObsFilterSql(''));
1666
- }
1667
-
1657
+ // PARSE + VALIDATE here; the SQL predicate itself comes from buildExportWhere (P2-5),
1658
+ // shared with server.mjs runExport. The split is deliberate: the CLI `fail()`s with a
1659
+ // usage message where the MCP tool throws, and only this face has a stderr channel for
1660
+ // the inverted-range note below.
1668
1661
  const project = flags.project ? resolveProject(db, flags.project) : null;
1669
- if (project) { wheres.push('project = ?'); params.push(project); }
1670
1662
  if (flags.type) {
1671
1663
  // Reject unknown types — silently returning [] for `--type bogus` looked like a
1672
1664
  // legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
1673
- const validObsTypes = OBS_TYPE_SET;
1674
- if (!validObsTypes.has(flags.type)) {
1675
- fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
1665
+ if (!OBS_TYPE_SET.has(flags.type)) {
1666
+ fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...OBS_TYPE_SET].join(', ')}`);
1676
1667
  return;
1677
1668
  }
1678
- wheres.push('type = ?'); params.push(flags.type);
1679
1669
  }
1680
1670
  let exportFromEpoch = null;
1681
1671
  let exportToEpoch = null;
1682
1672
  if (flags.from) {
1683
1673
  exportFromEpoch = new Date(flags.from).getTime();
1684
1674
  if (isNaN(exportFromEpoch)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
1685
- wheres.push('created_at_epoch >= ?'); params.push(exportFromEpoch);
1686
1675
  }
1687
1676
  if (flags.to) {
1688
1677
  exportToEpoch = new Date(flags.to).getTime();
1689
1678
  if (isNaN(exportToEpoch)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
1690
1679
  if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += DAY_MS - 1;
1691
- wheres.push('created_at_epoch <= ?'); params.push(exportToEpoch);
1692
1680
  }
1681
+ // --include-compressed: include compressed observations (aligned with MCP mem_export).
1682
+ // Superseded rows are excluded either way; the flag only toggles the compressed half
1683
+ // of the live-row pair (backup/export of tombstones is opt-in, retractions are not).
1684
+ const { wheres, params } = buildExportWhere({
1685
+ includeCompressed: flags['include-compressed'] === true || flags['include-compressed'] === 'true',
1686
+ project, type: flags.type || null, fromEpoch: exportFromEpoch, toEpoch: exportToEpoch,
1687
+ });
1693
1688
  if (exportFromEpoch !== null && exportToEpoch !== null && exportFromEpoch > exportToEpoch) {
1694
1689
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
1695
1690
  }
@@ -2211,19 +2206,8 @@ function cmdRegistry(_memDb, args) {
2211
2206
  for (const r of results) {
2212
2207
  const badge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
2213
2208
  const categoryLabel = r.category ? ` [${r.category}]` : '';
2214
- const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
2215
- const portablePath = isManaged && r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : (r.local_path || '');
2216
- let howToUse;
2217
- if (isManaged) {
2218
- const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
2219
- howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
2220
- } else if (r.invocation_name) {
2221
- howToUse = r.type === 'skill'
2222
- ? `Skill("${r.invocation_name}")`
2223
- : `Agent(subagent_type="${r.invocation_name}")`;
2224
- } else {
2225
- howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
2226
- }
2209
+ // P2-6: the invocation rule is shared; only the four-space indent is this face's.
2210
+ const { portablePath, howToUse } = resourceUseHint(r, { home, managedPrefix: join(DB_DIR, 'managed') + sep });
2227
2211
  const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
2228
2212
  out(` ${badge} ${r.type === 'skill' ? 'S' : 'A'} ${r.name}${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`);
2229
2213
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.92.0",
3
+ "version": "3.93.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.92.0",
9
+ "version": "3.93.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.92.0",
3
+ "version": "3.93.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",
@@ -87,6 +87,8 @@
87
87
  "lib/lesson-bridge.mjs",
88
88
  "lib/binding-probe.mjs",
89
89
  "lib/install-shape.mjs",
90
+ "lib/hook-stdin.mjs",
91
+ "lib/plugin-key.mjs",
90
92
  "lib/hook-stdout.mjs",
91
93
  "lib/proc-lock.mjs",
92
94
  "lib/atomic-write.mjs",
@@ -7,7 +7,7 @@
7
7
  // `off` skips all work.
8
8
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from 'fs';
9
9
  import { join } from 'path';
10
- import { resolveDataDir } from './lib/resolve-data-dir.mjs';
10
+ import { resolveDataDir, resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
11
11
  import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
12
12
 
13
13
  import { DAY_MS } from './lib/time-constants.mjs';
@@ -57,7 +57,7 @@ const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS
57
57
  // DB_DIR formula) so tests sandbox via env without ESM-cache gymnastics, and prod reads
58
58
  // the same dir as the rest of the app.
59
59
  function recoRuntimeDir() {
60
- return join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime');
60
+ return resolveRuntimeDir(resolveDataDir(process.env.CLAUDE_MEM_DIR));
61
61
  }
62
62
 
63
63
  const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
package/schema.mjs CHANGED
@@ -152,7 +152,12 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
152
152
  // (citation_surface_log.surface) in LATEST_MIGRATION_COLUMNS: a table that only
153
153
  // the forced pass can create is unreachable forever once the version row says
154
154
  // "done", which is not a hypothetical — see the note there.
155
- export const CURRENT_SCHEMA_VERSION = 46;
155
+ // v47: two additive indexes (P2-11 + ALGO-7). The bump is LOAD-BEARING, not bookkeeping:
156
+ // `initSchema`'s fast path returns before the `CREATE INDEX IF NOT EXISTS` block, so on
157
+ // every existing install at v46 a new index there would simply never be created. Same trap
158
+ // the FTS5 migration hit — a DDL change that is not reachable from the version the DB
159
+ // already reports is a no-op with a convincing diff.
160
+ export const CURRENT_SCHEMA_VERSION = 47;
156
161
 
157
162
  // Sentinel columns for the LATEST migration set(s). The fast-path uses these
158
163
  // to self-heal half-migrated DBs — schema_version bumped but column ALTERs
@@ -551,6 +556,18 @@ export function initSchema(db) {
551
556
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
552
557
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`);
553
558
  db.exec(`CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`);
559
+ // v47 (audit 2026-09-02 P2-11 + the previous round's ALGO-7), one additive migration for
560
+ // both. Additive only: new indexes on existing columns, no table rewrite, no data move.
561
+ //
562
+ // The first was the ONLY genuine full table scan in the 30 statements the audit ran
563
+ // through EXPLAIN QUERY PLAN. Stop and SessionStart both probe "does a summary exist for
564
+ // this memory session?", `session_summaries` is 10,160 rows here, and the cost grows
565
+ // linearly with the table for a question asked on every hook event.
566
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_memory_session ON session_summaries(memory_session_id)`);
567
+ // The second narrows the live-row scan the injection faces run per project. Partial on the
568
+ // same predicate `liveObsFilterSql` uses, so the index covers exactly the rows those
569
+ // queries can return.
570
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_live ON observations(project, created_at_epoch DESC) WHERE superseded_at IS NULL AND COALESCE(compressed_into, 0) = 0`);
554
571
 
555
572
  // FTS5 migration: recreate observations_fts when columns are missing (one-time)
556
573
  // Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
@@ -66,6 +66,16 @@ const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
66
66
  // Marker dir mirrors the standalone hook scripts (pre-tool-recall /
67
67
  // pre-skill-bridge), which honor CLAUDE_MEM_RUNTIME_DIR — they write 78 of every
68
68
  // 79 of these markers, so reading a different dir would mean never healing.
69
+ // The last hand-written copy of this rule, and it stays: this launcher runs BEFORE the
70
+ // native binding is known to work, so it imports only `node:` builtins on purpose — even
71
+ // `lib/resolve-data-dir.mjs` is a module resolution it declines to make on the path whose
72
+ // job is to survive a broken install. `lib/resolve-data-dir.mjs::resolveRuntimeDir` is the
73
+ // canonical rule (audit 2026-09-02 P1-14). It is NOT identical to it, and saying so was
74
+ // wrong: the resolver additionally makes a RELATIVE override absolute (`isAbsolute(raw) ?
75
+ // raw : resolve(raw)`), while this expression hands the relative value straight to `fs`,
76
+ // which resolves it against cwd at call time instead of at module load. They agree on
77
+ // unset, on empty and on an absolute override — the three cases that reach a real install.
78
+ // Keep the DEFAULTING behaviour in step; do not read "identical" into the difference.
69
79
  const NB_RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || RUNTIME_DIR;
70
80
  const NB_BROKEN_MARKER = join(NB_RUNTIME_DIR, 'native-binding-broken');
71
81
  const NB_HEAL_MARKER = join(NB_RUNTIME_DIR, 'native-binding-lastheal');
@@ -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