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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -0
- package/hash-utils.mjs +12 -0
- package/hook-llm.mjs +40 -9
- package/hook-optimize.mjs +4 -1
- package/hook-shared.mjs +6 -1
- package/hook.mjs +45 -31
- package/install.mjs +15 -11
- package/lib/export-columns.mjs +45 -0
- package/lib/get-core.mjs +17 -0
- package/lib/hook-stdin.mjs +134 -0
- package/lib/plugin-key.mjs +44 -0
- package/lib/registry-core.mjs +48 -0
- package/lib/resolve-data-dir.mjs +58 -0
- package/mem-cli.mjs +21 -37
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/registry-recommend.mjs +2 -2
- package/schema.mjs +18 -1
- package/scripts/hook-launcher.mjs +10 -0
- package/scripts/post-tool-recall.js +7 -4
- package/scripts/pre-agent-inject.js +12 -4
- package/scripts/pre-skill-bridge.js +9 -5
- package/scripts/pre-tool-recall.js +35 -7
- package/scripts/user-prompt-search.js +24 -24
- package/server.mjs +28 -46
- package/source-files.mjs +2 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.93.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.
|
|
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
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -895,6 +895,19 @@ Set by the tool or by the test harness. Setting these by hand is not supported:
|
|
|
895
895
|
`CLAUDE_MEM_DB_PATH`, `CLAUDE_MEM_RUNTIME_DIR`, `MEM_DISABLE_SPAWN_LOG`.
|
|
896
896
|
`CLAUDE_PLUGIN_ROOT` is set by Claude Code itself.
|
|
897
897
|
|
|
898
|
+
The last two are worth one more sentence each, because they are the ones a harness reaches
|
|
899
|
+
for. `CLAUDE_MEM_RUNTIME_DIR` relocates the runtime directory for hook-written state — markers,
|
|
900
|
+
cooldowns, hook-error telemetry, the native-binding breakage marker, `metrics/`, episode
|
|
901
|
+
buffers. Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
|
|
902
|
+
split the runtime rather than moving it. Installation-identity state (`install.lock`,
|
|
903
|
+
`update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
|
|
904
|
+
installers pointed at different override directories would otherwise each take their own
|
|
905
|
+
lock and both proceed. **`CLAUDE_MEM_DB_PATH` still has the split shape**, and more narrowly
|
|
906
|
+
than it looks: exactly ONE component reads it — `scripts/pre-tool-recall.js` — so setting it
|
|
907
|
+
aims that single hook at one database and leaves the other four hook faces, the CLI and the
|
|
908
|
+
MCP server on the default. Use `CLAUDE_MEM_DIR` — the only override
|
|
909
|
+
every component respects, including the bash pre-filter — to isolate state.
|
|
910
|
+
|
|
898
911
|
Three more are set by `vitest.config.mjs` / `tests/global-setup.mjs` and exist only to
|
|
899
912
|
keep a test run off the live database: `CLAUDE_MEM_TEST_GUARD` (`1` arms the guard, `off`
|
|
900
913
|
opts a test out), `CLAUDE_MEM_TEST_REALDIR` (the live data dir, captured before the suite
|
package/hash-utils.mjs
CHANGED
|
@@ -68,6 +68,18 @@ export function estimateJaccardFromMinHash(sig1, sig2) {
|
|
|
68
68
|
if (sig1.length !== sig2.length) return 0;
|
|
69
69
|
const numHashes = sig1.length / 8;
|
|
70
70
|
if (numHashes === 0) return 0;
|
|
71
|
+
// NEGATIVE RESULT, kept so nobody re-proposes it (audit 2026-09-02 P2-13 suggested an
|
|
72
|
+
// "allocation-free comparison"). `slice()` does allocate two 8-char strings per band, and
|
|
73
|
+
// the caller is a full nested pair loop — ~125k pairs at the 500-row scan bound. A
|
|
74
|
+
// charCodeAt inner loop was written and measured against this form over all 124,750 pairs
|
|
75
|
+
// of a 500-title fixture: identical results (0 mismatches, so the rewrite was correct) and
|
|
76
|
+
// NO time difference — 0.77× / 1.08× / 1.03× across three passes, i.e. the first pass was
|
|
77
|
+
// slower and the rest were noise. V8 handles short slices well enough that the byte loop
|
|
78
|
+
// buys nothing, and the whole pass is 0.6 ms.
|
|
79
|
+
//
|
|
80
|
+
// So the slice form stays: it is the more readable of two equally fast implementations,
|
|
81
|
+
// and shipping the other would be churn with a performance claim behind it that the
|
|
82
|
+
// measurement does not support.
|
|
71
83
|
let matches = 0;
|
|
72
84
|
for (let i = 0; i < numHashes; i++) {
|
|
73
85
|
const offset = i * 8;
|
package/hook-llm.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// claude-mem-lite: Background LLM workers for episode extraction and session summaries
|
|
2
2
|
// Extracted from hook.mjs for testability and reduced complexity
|
|
3
3
|
|
|
4
|
-
import { basename } from 'path';
|
|
5
|
-
import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
4
|
+
import { basename, join } from 'path';
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
6
6
|
import {
|
|
7
7
|
jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
|
|
8
8
|
inferProject, parseJsonFromLLM, scrubSecrets,
|
|
@@ -16,7 +16,7 @@ import { vecTextForRow } from './tfidf.mjs';
|
|
|
16
16
|
import { insertObservationRow, insertObservationFiles, insertObservationVector, upsertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
|
|
17
17
|
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
18
18
|
import {
|
|
19
|
-
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
19
|
+
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS, ORPHAN_EPISODE_AGE_MS,
|
|
20
20
|
sessionFile, getSessionId, openDb, callLLM, sleep,
|
|
21
21
|
} from './hook-shared.mjs';
|
|
22
22
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
@@ -1147,13 +1147,44 @@ ${actionList}`;
|
|
|
1147
1147
|
export async function handleLLMSummary() {
|
|
1148
1148
|
const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
|
|
1149
1149
|
const flushTimeout = Number.isNaN(parsed) ? 15 : parsed;
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1150
|
+
|
|
1151
|
+
// Wait for a DEFINED SET of flush files, not for "the directory is empty" (audit
|
|
1152
|
+
// 2026-09-02 P1-7). RUNTIME_DIR is shared by every project on the machine, and the old
|
|
1153
|
+
// predicate was `readdirSync(RUNTIME_DIR).some(f => f.startsWith('ep-flush-'))` — so:
|
|
1154
|
+
//
|
|
1155
|
+
// • ONE crashed llm-episode worker leaves a file nothing will ever delete, and from
|
|
1156
|
+
// then on EVERY project's summary burns the full 15 s on every Stop until the next
|
|
1157
|
+
// maintain run sweeps it. Orphan cleanup lives behind a 24 h gate, so "until then"
|
|
1158
|
+
// is up to a day.
|
|
1159
|
+
// • A flush spawned by an unrelated project WHILE this summary is waiting extends the
|
|
1160
|
+
// wait, for work this summary will never read.
|
|
1161
|
+
//
|
|
1162
|
+
// The set is snapshotted at entry and filtered to files young enough to belong to a live
|
|
1163
|
+
// worker. A file that appears after this point is somebody else's; a file older than
|
|
1164
|
+
// ORPHAN_EPISODE_AGE_MS is nobody's. Both were previously indistinguishable from work in
|
|
1165
|
+
// progress.
|
|
1166
|
+
//
|
|
1167
|
+
// Not narrowed to this project: the flush filename is `ep-flush-<ts>-<rand>.json` and
|
|
1168
|
+
// carries no project. Widening it is a marker-format change with in-flight files during
|
|
1169
|
+
// an upgrade, and the two filters above already remove the unbounded cases — what is left
|
|
1170
|
+
// is a bounded overlap with genuinely concurrent work.
|
|
1171
|
+
let pending;
|
|
1172
|
+
try {
|
|
1173
|
+
const cutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
|
|
1174
|
+
pending = readdirSync(RUNTIME_DIR)
|
|
1175
|
+
.filter((f) => f.startsWith('ep-flush-'))
|
|
1176
|
+
.filter((f) => {
|
|
1177
|
+
try { return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff; } catch { return false; }
|
|
1178
|
+
});
|
|
1179
|
+
} catch { pending = []; }
|
|
1180
|
+
|
|
1181
|
+
for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
|
|
1156
1182
|
await sleep(1000);
|
|
1183
|
+
pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
|
|
1184
|
+
debugLog('DEBUG', 'llm-summary', `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`);
|
|
1185
|
+
}
|
|
1186
|
+
if (pending.length > 0) {
|
|
1187
|
+
debugLog('DEBUG', 'llm-summary', `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`);
|
|
1157
1188
|
}
|
|
1158
1189
|
|
|
1159
1190
|
const db = openDb();
|
package/hook-optimize.mjs
CHANGED
|
@@ -22,9 +22,12 @@ import { DB_DIR } from './schema.mjs';
|
|
|
22
22
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
23
23
|
import { normalizeScope, SCOPE_PROMPT_LEGEND, upsertObservationVector } from './lib/observation-write.mjs';
|
|
24
24
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
25
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
25
26
|
|
|
26
27
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
27
|
-
|
|
28
|
+
// P1-14: same resolver as hook-shared.mjs — this was the second module that had never
|
|
29
|
+
// heard of CLAUDE_MEM_RUNTIME_DIR, and a third hand-written copy of the join().
|
|
30
|
+
const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
|
|
28
31
|
|
|
29
32
|
// ─── Budget ─────────────────────────────────────────────────────────────────
|
|
30
33
|
|
package/hook-shared.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdir
|
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
9
|
import { CITE_RECALL_FILE_PREFIX } from './lib/cite-recall-path.mjs';
|
|
10
10
|
import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
11
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
11
12
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
12
13
|
// neither imports this file) — no cycle.
|
|
13
14
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
@@ -22,7 +23,11 @@ import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
|
|
|
22
23
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
23
24
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
|
|
27
|
+
// standalone hook scripts already did. It did not, and hook.mjs / server.mjs /
|
|
28
|
+
// hook-context.mjs / hook-episode.mjs all take RUNTIME_DIR from here — so the override
|
|
29
|
+
// split the runtime dir in half instead of relocating it.
|
|
30
|
+
export const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
|
|
26
31
|
export const SCRIPT_PATH = process.argv[1];
|
|
27
32
|
|
|
28
33
|
// Timing constants
|
package/hook.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
// backward-compat surface that knip already lists as unused; new shared symbols go to
|
|
34
34
|
// their canonical module.
|
|
35
35
|
import { inferProjectDir } from './project-utils.mjs';
|
|
36
|
+
import { isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
|
|
37
|
+
import { readHookStdin } from './lib/hook-stdin.mjs';
|
|
36
38
|
// Aliased: `acquireLock` from hook-episode.mjs below is the episode buffer's own
|
|
37
39
|
// (argument-less) lock — a different mutex with a different staleness policy.
|
|
38
40
|
import { acquireLock as acquireProcLock } from './lib/proc-lock.mjs';
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
createEpisode, addFileToEpisode, planEpisodeFlush,
|
|
43
45
|
writePendingEntry, mergePendingEntries, episodeHasSignificantContent, explainSignificance,
|
|
44
46
|
} from './hook-episode.mjs';
|
|
47
|
+
import { DB_DIR } from './schema.mjs';
|
|
45
48
|
import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
|
|
46
49
|
import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
47
50
|
import {
|
|
@@ -149,18 +152,22 @@ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-o
|
|
|
149
152
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
150
153
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
151
154
|
// in Claude UI does not automatically remove them. Exit early to make disable actually work.
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
// The KEY and the predicate live in lib/plugin-key.mjs (P2-7) — install.mjs branches on the
|
|
156
|
+
// same decision, and a key that drifts on one side leaves the user with a plugin they
|
|
157
|
+
// switched off and a settings.json hook set that never noticed. Reading the file stays here:
|
|
158
|
+
// install.mjs already holds a parsed settings object when it asks, this process does not.
|
|
159
|
+
function pluginDisabledHere() {
|
|
154
160
|
try {
|
|
155
161
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
156
|
-
|
|
157
|
-
return settings.enabledPlugins?.[PLUGIN_KEY] === false;
|
|
162
|
+
return isPluginExplicitlyDisabled(JSON.parse(readFileSync(settingsPath, 'utf8')));
|
|
158
163
|
} catch {
|
|
164
|
+
// Missing or unparseable settings.json → not disabled. Fail OPEN: a corrupt file must
|
|
165
|
+
// not switch the plugin off for a user who never asked for that.
|
|
159
166
|
return false;
|
|
160
167
|
}
|
|
161
168
|
}
|
|
162
169
|
|
|
163
|
-
if (event &&
|
|
170
|
+
if (event && pluginDisabledHere()) process.exit(0);
|
|
164
171
|
if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
|
|
165
172
|
|
|
166
173
|
// Crash-safe: flush episode buffer on unexpected termination to prevent data loss
|
|
@@ -395,7 +402,7 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
|
395
402
|
// and with the flag on that case is strictly better than before, because the reads file
|
|
396
403
|
// was never touched and the retry still finds it.
|
|
397
404
|
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
398
|
-
recordMetric(
|
|
405
|
+
recordMetric(DB_DIR, {
|
|
399
406
|
event: 'episode_reads',
|
|
400
407
|
readsConsumed: (episode.filesRead || []).length,
|
|
401
408
|
readsHeld,
|
|
@@ -459,7 +466,7 @@ function flushEpisodeGroup(ep, db) {
|
|
|
459
466
|
// episodes are kept ONLY because of their Greps — and demoting what the product
|
|
460
467
|
// remembers on a deduction is how work disappears silently. This is that counter.
|
|
461
468
|
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
462
|
-
recordMetric(
|
|
469
|
+
recordMetric(DB_DIR, {
|
|
463
470
|
event: 'episode_significance',
|
|
464
471
|
rule: verdict.rule,
|
|
465
472
|
significant: isSignificant,
|
|
@@ -682,7 +689,7 @@ function triggerErrorRecall(db, toolInput, response, opts = {}) {
|
|
|
682
689
|
// G13: this surface feeds the citation denominator but had zero metering —
|
|
683
690
|
// the G8 gate change (isError→isHardError) could not be volume-verified
|
|
684
691
|
// from metrics. Counter only; no latency (query is bundled in the hook).
|
|
685
|
-
recordMetric(
|
|
692
|
+
recordMetric(DB_DIR, { event: metricEvent, returned: rows.length });
|
|
686
693
|
// MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
|
|
687
694
|
// a raw multi-line write corrupts a co-emitted episode-flush receipt.
|
|
688
695
|
// The follow-up correction (2026-08-17): "two separate JSON lines each parse
|
|
@@ -1485,7 +1492,13 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1485
1492
|
// Auto-dedup (fuzzy): catches near-identical titles that exact-match
|
|
1486
1493
|
// misses across larger time windows — e.g. episode-batch titles like
|
|
1487
1494
|
// "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
|
|
1488
|
-
// MinHash pre-filter (≥0.7)
|
|
1495
|
+
// MinHash pre-filter (≥0.7) makes each PAIR cheap; it does not reduce the number of
|
|
1496
|
+
// pairs. Said precisely because the comment here used to read "cuts the O(N²) scan",
|
|
1497
|
+
// which is false and reads as an algorithmic bound (audit 2026-09-02 P2-13): the
|
|
1498
|
+
// estimate is evaluated INSIDE the inner loop of a full nested scan in
|
|
1499
|
+
// `lib/maintain-core.mjs`, so every pair is still visited — what it skips is the
|
|
1500
|
+
// expensive exact Jaccard behind it. Measured, the whole pair pass is ~0.6 ms at
|
|
1501
|
+
// n=500. Jaccard ≥0.95 stays
|
|
1489
1502
|
// well clear of legit "two updates same area" pairs (those typically
|
|
1490
1503
|
// score 0.7–0.85, surfaced via `maintain scan` for manual review).
|
|
1491
1504
|
// Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
|
|
@@ -1835,9 +1848,9 @@ async function handleSessionStart() {
|
|
|
1835
1848
|
try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
|
|
1836
1849
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1837
1850
|
try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1838
|
-
// Same for the opt-in metrics sink
|
|
1851
|
+
// Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
|
|
1839
1852
|
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
1840
|
-
try { gcOldMetricShards(
|
|
1853
|
+
try { gcOldMetricShards(DB_DIR); } catch { /* best-effort */ }
|
|
1841
1854
|
|
|
1842
1855
|
// Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
|
|
1843
1856
|
// re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
|
|
@@ -2213,8 +2226,11 @@ async function handleUserPrompt() {
|
|
|
2213
2226
|
// (inject, maybe duplicate) rather than fail closed (suppress).
|
|
2214
2227
|
const keyContextIds = [];
|
|
2215
2228
|
try {
|
|
2216
|
-
|
|
2217
|
-
|
|
2229
|
+
// `keyCtxRaw`, not `raw`: this function already binds `raw` to the stdin payload
|
|
2230
|
+
// ~120 lines up, and a second `raw` holding a marker FILE's contents reads as that
|
|
2231
|
+
// one (audit 2026-09-02 P2-17 — the one hit in the tree worth a rename).
|
|
2232
|
+
const keyCtxRaw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
|
|
2233
|
+
const { ids, session } = JSON.parse(keyCtxRaw);
|
|
2218
2234
|
if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
|
|
2219
2235
|
keyContextIds.push(...ids);
|
|
2220
2236
|
}
|
|
@@ -2402,7 +2418,7 @@ async function handleUserPrompt() {
|
|
|
2402
2418
|
// counterfactual search and the second lesson selection off a stock install.
|
|
2403
2419
|
try {
|
|
2404
2420
|
if (meterCoerced) {
|
|
2405
|
-
recordPathAExclude(
|
|
2421
|
+
recordPathAExclude(DB_DIR, {
|
|
2406
2422
|
markerIds: pathAInjectedIds,
|
|
2407
2423
|
emitted: memories,
|
|
2408
2424
|
after: meterArmB,
|
|
@@ -2437,14 +2453,14 @@ async function handleEnrichSave(rawId) {
|
|
|
2437
2453
|
// work" was invisible (32% alias coverage with 3 indistinguishable failure
|
|
2438
2454
|
// causes). reason 'filled-concurrently' = txn ran but a concurrent optimize/
|
|
2439
2455
|
// update had already filled every empty field.
|
|
2440
|
-
recordMetric(
|
|
2456
|
+
recordMetric(DB_DIR, {
|
|
2441
2457
|
event: 'enrich_save',
|
|
2442
2458
|
id,
|
|
2443
2459
|
enriched: result.enriched,
|
|
2444
2460
|
reason: result.reason ?? (result.enriched ? 'enriched' : 'filled-concurrently'),
|
|
2445
2461
|
});
|
|
2446
2462
|
} catch (e) {
|
|
2447
|
-
recordMetric(
|
|
2463
|
+
recordMetric(DB_DIR, { event: 'enrich_save', id, enriched: false, reason: 'worker-error' });
|
|
2448
2464
|
debugCatch(e, 'enrich-save');
|
|
2449
2465
|
} finally {
|
|
2450
2466
|
try { db.close(); } catch {}
|
|
@@ -2487,22 +2503,20 @@ function handleAutoCompress() {
|
|
|
2487
2503
|
|
|
2488
2504
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
2489
2505
|
|
|
2506
|
+
// P1-9: the mechanism is shared (lib/hook-stdin.mjs); the CALIBER stays this entry point's
|
|
2507
|
+
// own. 256 KB because a tool response is the largest payload the host sends here, and
|
|
2508
|
+
// `rejectOnTimeout` because this reader's callers treat a timeout as "drop the event" — the
|
|
2509
|
+
// alternative, acting on a partial payload, means writing a truncated tool response into
|
|
2510
|
+
// memory as if it were the whole thing. The other four hook processes are advisory and
|
|
2511
|
+
// resolve instead; those are different decisions about different payloads, not drift.
|
|
2490
2512
|
function readStdin() {
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
if (data.length > MAX_STDIN) {
|
|
2499
|
-
process.stdin.destroy(); clearTimeout(timeout);
|
|
2500
|
-
resolve({ text: data.slice(0, MAX_STDIN), truncated: true });
|
|
2501
|
-
}
|
|
2502
|
-
});
|
|
2503
|
-
process.stdin.on('end', () => { clearTimeout(timeout); resolve({ text: data, truncated: false }); });
|
|
2504
|
-
process.stdin.on('error', err => { clearTimeout(timeout); reject(err); });
|
|
2505
|
-
process.stdin.resume();
|
|
2513
|
+
return readHookStdin({
|
|
2514
|
+
timeoutMs: 3000,
|
|
2515
|
+
maxBytes: MAX_HOOK_STDIN_BYTES, // shared tier, utils.mjs
|
|
2516
|
+
rejectOnTimeout: true,
|
|
2517
|
+
}).catch((err) => {
|
|
2518
|
+
if (err?.message === 'timeout') debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped');
|
|
2519
|
+
throw err;
|
|
2506
2520
|
});
|
|
2507
2521
|
}
|
|
2508
2522
|
|
package/install.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join, resolve, dirname, isAbsolute, basename } from 'path';
|
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
-
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
10
|
+
import { resolveDataDir, resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
11
11
|
|
|
12
12
|
const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
|
|
13
13
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
@@ -22,6 +22,11 @@ const DATA_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
22
22
|
// the relocated dir → preinstalled skills silently vanished, doctor read the wrong
|
|
23
23
|
// DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
|
|
24
24
|
const MEM_DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
25
|
+
// Hook-WRITTEN runtime state (breakage markers, ep-flush/pending buffers) lives here.
|
|
26
|
+
// Installation-identity state — install.lock, update-state.json, update residue — stays
|
|
27
|
+
// under MEM_DATA_DIR on purpose: those are about the one real installation, and moving
|
|
28
|
+
// them with a per-harness override would let two concurrent installs take separate locks.
|
|
29
|
+
const MEM_RUNTIME_DIR = resolveRuntimeDir(MEM_DATA_DIR);
|
|
25
30
|
const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
26
31
|
const OLD_DATA_DIR = join(homedir(), '.claude-mem');
|
|
27
32
|
|
|
@@ -33,8 +38,9 @@ const IS_NPX = process.env.npm_command === 'exec' ||
|
|
|
33
38
|
const INSTALL_DIR = DATA_DIR;
|
|
34
39
|
const SERVER_PATH = join(INSTALL_DIR, 'server.mjs');
|
|
35
40
|
const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
// P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
|
|
42
|
+
// imports — this pair used to be typed out in each.
|
|
43
|
+
import { MARKETPLACE_KEY, PLUGIN_KEY, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
|
|
38
44
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
39
45
|
|
|
40
46
|
import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
@@ -1616,7 +1622,7 @@ async function doctor() {
|
|
|
1616
1622
|
// broken install to exit 0 so it never spams a Node stack trace on every hook
|
|
1617
1623
|
// fire. That silence is intentional but hides failure — it drops a breakage
|
|
1618
1624
|
// marker so this check can surface the otherwise-invisible degraded state.
|
|
1619
|
-
const brokenMarker = join(
|
|
1625
|
+
const brokenMarker = join(MEM_RUNTIME_DIR, 'hook-launcher-broken');
|
|
1620
1626
|
if (existsSync(brokenMarker)) {
|
|
1621
1627
|
let detail = '';
|
|
1622
1628
|
try {
|
|
@@ -1634,7 +1640,7 @@ async function doctor() {
|
|
|
1634
1640
|
// is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
|
|
1635
1641
|
// right now". A Node upgrade breaks every DB-touching path at once, so this is
|
|
1636
1642
|
// the single highest-value line in doctor when it fires.
|
|
1637
|
-
const breakage = readNativeBindingBreakage(
|
|
1643
|
+
const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
|
|
1638
1644
|
// Reuses the per-root probes above — same trees, same question, and doctor
|
|
1639
1645
|
// should not pay for another round of child spawns to ask it twice.
|
|
1640
1646
|
if (brokenRoots.length > 0) {
|
|
@@ -2210,9 +2216,7 @@ function cleanupMemHooksFromSettings(settings) {
|
|
|
2210
2216
|
return removed;
|
|
2211
2217
|
}
|
|
2212
2218
|
|
|
2213
|
-
|
|
2214
|
-
return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
|
|
2215
|
-
}
|
|
2219
|
+
|
|
2216
2220
|
|
|
2217
2221
|
function getInstalledPluginEntries(installed) {
|
|
2218
2222
|
if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
|
|
@@ -2273,8 +2277,8 @@ function cleanup() {
|
|
|
2273
2277
|
}
|
|
2274
2278
|
}
|
|
2275
2279
|
|
|
2276
|
-
// Clean pending-* / ep-flush-* in runtime/ (
|
|
2277
|
-
const runtimeDir =
|
|
2280
|
+
// Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
|
|
2281
|
+
const runtimeDir = MEM_RUNTIME_DIR;
|
|
2278
2282
|
if (existsSync(runtimeDir)) {
|
|
2279
2283
|
for (const f of readdirSync(runtimeDir)) {
|
|
2280
2284
|
if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
|
|
@@ -2592,7 +2596,7 @@ async function rebuildBinding() {
|
|
|
2592
2596
|
process.exitCode = 1;
|
|
2593
2597
|
} else {
|
|
2594
2598
|
// Every tree is loadable → drop the marker so session-start stops retrying.
|
|
2595
|
-
clearNativeBindingBreakage(
|
|
2599
|
+
clearNativeBindingBreakage(MEM_RUNTIME_DIR);
|
|
2596
2600
|
}
|
|
2597
2601
|
} finally {
|
|
2598
2602
|
release();
|
package/lib/export-columns.mjs
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
// LIVE row and turned up in search results next to the weekly-summary keeper that
|
|
25
25
|
// already absorbed it. Restore does not remap it (the keeper's id is meaningless in
|
|
26
26
|
// the target store); it REJECTS marked rows instead — see cmdRestore.
|
|
27
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
28
|
+
|
|
27
29
|
export const EXPORT_COLUMNS = [
|
|
28
30
|
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
29
31
|
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
@@ -34,3 +36,46 @@ export const EXPORT_COLUMNS = [
|
|
|
34
36
|
|
|
35
37
|
// The SELECT column fragment (comma-joined) — drop into `SELECT ${EXPORT_COLUMNS_SQL} FROM …`.
|
|
36
38
|
export const EXPORT_COLUMNS_SQL = EXPORT_COLUMNS.join(', ');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Assemble the `WHERE` predicate both export faces share.
|
|
42
|
+
*
|
|
43
|
+
* Audit 2026-09-02 P2-5. The COLUMN set above has been shared since v3.42 (HIGH-2, when the
|
|
44
|
+
* MCP handler was found carrying a narrower 16-column SELECT and silently dropping
|
|
45
|
+
* text/aliases/citation-signals from the advertised backup→restore flow). The PREDICATE was
|
|
46
|
+
* still typed out twice — `mem-cli.mjs cmdExport` and `server.mjs runExport` — which is the
|
|
47
|
+
* half-collapsed shape that lets a `WHERE` drift while the columns stay in step, on the one
|
|
48
|
+
* command whose output people restore from.
|
|
49
|
+
*
|
|
50
|
+
* Only the SQL is shared. Everything the two faces genuinely disagree about stays with them,
|
|
51
|
+
* because each disagreement is a decision rather than an accident:
|
|
52
|
+
* - PARSING and validation: the CLI `fail()`s with a usage message on a bad date and
|
|
53
|
+
* rejects an unknown `--type`; the MCP tool throws, and its type arrives through a
|
|
54
|
+
* schema enum. Callers pass epochs, already parsed.
|
|
55
|
+
* - The LIMIT: the CLI defaults to the complete matching set (it is the documented backup
|
|
56
|
+
* half of backup/restore); MCP defaults to 200 and probes limit+1, because an MCP result
|
|
57
|
+
* is model context and a bare exploratory call must not dump a store into a transcript.
|
|
58
|
+
* - The inverted-range note, which only the CLI has a stderr channel for.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} o
|
|
61
|
+
* @param {boolean} [o.includeCompressed] Include compressed rows. Superseded rows are
|
|
62
|
+
* excluded EITHER WAY — exporting tombstones is opt-in, exporting retractions never is.
|
|
63
|
+
* @param {string|null} [o.project] Already resolved to a canonical project name.
|
|
64
|
+
* @param {string|null} [o.type] Already validated by the caller.
|
|
65
|
+
* @param {number|null} [o.fromEpoch] Inclusive lower bound, ms.
|
|
66
|
+
* @param {number|null} [o.toEpoch] Inclusive upper bound, ms (callers push it to
|
|
67
|
+
* end-of-day themselves when the user gave a bare date).
|
|
68
|
+
* @returns {{wheres: string[], params: Array<string|number>, where: string}}
|
|
69
|
+
* `wheres`/`params` for a caller that composes its own clause; `where` is the ready
|
|
70
|
+
* `WHERE …` string (never empty — the live-row predicate is always present).
|
|
71
|
+
*/
|
|
72
|
+
export function buildExportWhere({ includeCompressed = false, project = null, type = null, fromEpoch = null, toEpoch = null } = {}) {
|
|
73
|
+
const wheres = [];
|
|
74
|
+
const params = [];
|
|
75
|
+
wheres.push(includeCompressed ? 'superseded_at IS NULL' : liveObsFilterSql(''));
|
|
76
|
+
if (project) { wheres.push('project = ?'); params.push(project); }
|
|
77
|
+
if (type) { wheres.push('type = ?'); params.push(type); }
|
|
78
|
+
if (fromEpoch !== null && fromEpoch !== undefined) { wheres.push('created_at_epoch >= ?'); params.push(fromEpoch); }
|
|
79
|
+
if (toEpoch !== null && toEpoch !== undefined) { wheres.push('created_at_epoch <= ?'); params.push(toEpoch); }
|
|
80
|
+
return { wheres, params, where: 'WHERE ' + wheres.join(' AND ') };
|
|
81
|
+
}
|
package/lib/get-core.mjs
CHANGED
|
@@ -34,6 +34,23 @@ export function fetchPromptDetail(db, ids) {
|
|
|
34
34
|
return db.prepare(`SELECT * FROM user_prompts WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Fetch session-summary detail rows, oldest-first.
|
|
39
|
+
*
|
|
40
|
+
* Audit 2026-09-02 P2-4: the session leg was the one detail source with no shared fetch.
|
|
41
|
+
* Its FIELD SET already came from `SESSION_DETAIL_FIELDS` here, so the twin was down to the
|
|
42
|
+
* query itself — typed out in `mem-cli.mjs renderSessionRows` and in `server.mjs mem_get`.
|
|
43
|
+
* A shared field list over two hand-copied SELECTs is the half-collapsed shape that lets a
|
|
44
|
+
* `WHERE` clause drift while the columns stay in step.
|
|
45
|
+
*
|
|
46
|
+
* No access bump, matching `fetchPromptDetail`: session summaries are not ranked, so
|
|
47
|
+
* reading one is not a usage signal.
|
|
48
|
+
*/
|
|
49
|
+
export function fetchSessionDetail(db, ids) {
|
|
50
|
+
const ph = ids.map(() => '?').join(',');
|
|
51
|
+
return db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* Fetch event detail rows, oldest-first.
|
|
39
56
|
*
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// lib/hook-stdin.mjs — one bounded stdin reader for every hook entry point.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-09-02 P1-9. Six hook processes read the host's JSON payload off stdin and they
|
|
4
|
+
// did it six ways, in two groups:
|
|
5
|
+
//
|
|
6
|
+
// BOUNDED, three different calibers: hook.mjs (3 s / 256 KB / `{text, truncated}`),
|
|
7
|
+
// scripts/user-prompt-search.js (2 s / 64 KB / bare string), scripts/pre-agent-inject.js
|
|
8
|
+
// (1.5 s / 262144 / never rejects).
|
|
9
|
+
//
|
|
10
|
+
// UNBOUNDED, three copies of `for await (const chunk of process.stdin) input += chunk`:
|
|
11
|
+
// pre-tool-recall.js, pre-skill-bridge.js, post-tool-recall.js. No cap and no timeout.
|
|
12
|
+
// That matters most on `PreToolUse:Write`, whose `tool_input.content` is the ENTIRE file
|
|
13
|
+
// being written: writing a multi-megabyte file made pre-tool-recall buffer all of it and
|
|
14
|
+
// `JSON.parse` all of it, to read `file_path`. The only bound was the host's own 3 s
|
|
15
|
+
// fail-open — i.e. the hook silently did nothing, which is indistinguishable from the
|
|
16
|
+
// hook having nothing to say.
|
|
17
|
+
//
|
|
18
|
+
// ZERO DEPENDENCIES, deliberately: the importers are latency-sensitive hook processes whose
|
|
19
|
+
// whole cost is module loading, and one of them (`pre-agent-inject.js`) exists to be the
|
|
20
|
+
// cheap default-off path. Nothing here imports from the repo.
|
|
21
|
+
//
|
|
22
|
+
// The calibers are NOT unified. Each caller passes its own, because those numbers are
|
|
23
|
+
// decisions about different payloads — a user prompt is not a tool response — and quietly
|
|
24
|
+
// giving one caller another's limits is a behaviour change wearing a refactor's clothes.
|
|
25
|
+
// What is shared is the mechanism: cap, timer, teardown, and the guarantee that the promise
|
|
26
|
+
// settles exactly once.
|
|
27
|
+
|
|
28
|
+
/** Default cap: matches the host's own full-payload tier. */
|
|
29
|
+
export const DEFAULT_STDIN_MAX_BYTES = 256 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Cap for a payload that carries a WHOLE FILE: `PreToolUse`/`PostToolUse` on `Write` puts the
|
|
32
|
+
* entire file being written in `tool_input.content`. Exported and named rather than typed at
|
|
33
|
+
* each call site because two entry points see that same payload class, and two hand-written
|
|
34
|
+
* numbers for one class is the drift shape this module exists to remove — this is NOT the
|
|
35
|
+
* calibers being unified, which the note above rules out: a caller with a different payload
|
|
36
|
+
* class still passes its own.
|
|
37
|
+
*
|
|
38
|
+
* Sized as a MEMORY backstop, not a functional gate. The v3.93.0 pre-tag review measured the
|
|
39
|
+
* 256 KB default silently dropping `pretool` recall for any Write over that size, on the
|
|
40
|
+
* reasoning that a truncated payload matched what the host's 3 s fail-open already did —
|
|
41
|
+
* `JSON.parse` is 2.99 ms at 5 MB and 10.8 ms at 10 MB, so it did not.
|
|
42
|
+
*/
|
|
43
|
+
export const TOOL_INPUT_FILE_MAX_BYTES = 8 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recover the scalar fields a hook needs from a TRUNCATED JSON payload prefix.
|
|
47
|
+
*
|
|
48
|
+
* Mirrors `hook.mjs handlePostToolUse`'s salvage of `tool_name`. Returns null when the
|
|
49
|
+
* prefix does not carry a `file_path` — the caller then behaves exactly as it did before
|
|
50
|
+
* salvage existed, so this can only add recalls, never remove one.
|
|
51
|
+
*
|
|
52
|
+
* Lives here rather than in the one script that calls it because it is about a payload this
|
|
53
|
+
* module bounded: the cap and the recovery from the cap are one decision, and a helper that
|
|
54
|
+
* only the truncating module's caller can reach is a helper nothing can unit-test.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately regex over the prefix rather than a streaming parser: the fields are flat
|
|
57
|
+
* string scalars, and a partial-JSON parser is a dependency and a failure mode for a path
|
|
58
|
+
* whose entire budget is the host's fail-open window.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} prefix Truncated payload text.
|
|
61
|
+
* @returns {{filePath: string, sessionId: string|null, toolName: string|null} | null}
|
|
62
|
+
*/
|
|
63
|
+
export function salvageTruncatedHookEvent(prefix) {
|
|
64
|
+
const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
65
|
+
if (!fp) return null;
|
|
66
|
+
let filePath;
|
|
67
|
+
// The captured group is still JSON-escaped (Windows paths arrive as `C:\\x`).
|
|
68
|
+
try { filePath = JSON.parse(`"${fp[1]}"`); } catch { return null; }
|
|
69
|
+
if (!filePath) return null;
|
|
70
|
+
const sid = prefix.match(/"session_id"\s*:\s*"([^"\\]*)"/);
|
|
71
|
+
const tn = prefix.match(/"tool_name"\s*:\s*"([^"\\]*)"/);
|
|
72
|
+
return { filePath, sessionId: sid ? sid[1] : null, toolName: tn ? tn[1] : null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
/** Default timeout: under the host's ~3 s fail-open, so we decide rather than get killed. */
|
|
77
|
+
export const DEFAULT_STDIN_TIMEOUT_MS = 3000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read the hook payload from a stream, bounded in both bytes and time.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {number} [opts.timeoutMs] Give up after this long.
|
|
84
|
+
* @param {number} [opts.maxBytes] Stop reading past this many characters.
|
|
85
|
+
* @param {boolean} [opts.rejectOnTimeout=false] `true` reproduces hook.mjs's contract, where
|
|
86
|
+
* a timeout drops the event rather than acting on a partial payload. `false` resolves with
|
|
87
|
+
* whatever arrived and `timedOut: true` — the right call for a path that is advisory and
|
|
88
|
+
* must never throw into a host hook.
|
|
89
|
+
* @param {NodeJS.ReadableStream} [opts.stream=process.stdin] Injectable for tests; nothing
|
|
90
|
+
* else about this module is observable without it.
|
|
91
|
+
* @returns {Promise<{text: string, truncated: boolean, timedOut: boolean}>}
|
|
92
|
+
*/
|
|
93
|
+
export function readHookStdin({
|
|
94
|
+
timeoutMs = DEFAULT_STDIN_TIMEOUT_MS,
|
|
95
|
+
maxBytes = DEFAULT_STDIN_MAX_BYTES,
|
|
96
|
+
rejectOnTimeout = false,
|
|
97
|
+
stream = process.stdin,
|
|
98
|
+
} = {}) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
let data = '';
|
|
101
|
+
// Every path below routes through settle(), and settle() is a no-op after the first
|
|
102
|
+
// call. The hand-written copies each had to get this right on four separate paths
|
|
103
|
+
// (cap hit, end, error, timer) and the `.destroy()` in the cap branch can itself emit
|
|
104
|
+
// 'error' — so "resolve twice" and "resolve then reject" were both reachable shapes.
|
|
105
|
+
let done = false;
|
|
106
|
+
const settle = (fn, arg) => {
|
|
107
|
+
if (done) return;
|
|
108
|
+
done = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
try { stream.destroy(); } catch { /* already gone */ }
|
|
111
|
+
fn(arg);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
if (rejectOnTimeout) settle(reject, new Error('timeout'));
|
|
116
|
+
else settle(resolve, { text: data, truncated: false, timedOut: true });
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
stream.setEncoding('utf8');
|
|
121
|
+
stream.on('data', (chunk) => {
|
|
122
|
+
data += chunk;
|
|
123
|
+
if (data.length > maxBytes) {
|
|
124
|
+
settle(resolve, { text: data.slice(0, maxBytes), truncated: true, timedOut: false });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
stream.on('end', () => settle(resolve, { text: data, truncated: false, timedOut: false }));
|
|
128
|
+
stream.on('error', (err) => settle(reject, err));
|
|
129
|
+
stream.resume();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
settle(reject, err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|