claude-mem-lite 3.91.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 +14 -0
- package/README.zh-CN.md +1 -0
- package/claudemd.mjs +9 -6
- package/hash-utils.mjs +12 -0
- package/hook-context.mjs +9 -8
- package/hook-llm.mjs +96 -37
- package/hook-optimize.mjs +51 -29
- package/hook-shared.mjs +62 -12
- package/hook.mjs +139 -56
- package/install.mjs +24 -13
- package/lib/atomic-write.mjs +14 -3
- package/lib/cite-back-hint.mjs +3 -3
- package/lib/cite-recall-path.mjs +52 -0
- package/lib/compress-core.mjs +31 -15
- package/lib/export-columns.mjs +45 -0
- package/lib/frontmatter.mjs +67 -0
- package/lib/get-core.mjs +17 -0
- package/lib/hook-stdin.mjs +134 -0
- package/lib/injected-ids.mjs +93 -4
- package/lib/maintain-core.mjs +191 -5
- package/lib/observation-write.mjs +71 -13
- package/lib/plugin-key.mjs +44 -0
- package/lib/registry-core.mjs +165 -1
- package/lib/resolve-data-dir.mjs +58 -0
- package/lib/save-enrich.mjs +10 -6
- package/lib/save-observation.mjs +44 -0
- package/lib/transcript-scan.mjs +59 -8
- package/mem-cli.mjs +95 -229
- package/memdir.mjs +6 -6
- package/npm-shrinkwrap.json +20 -2
- package/package.json +11 -1
- package/registry-importer.mjs +4 -34
- package/registry-recommend.mjs +2 -2
- package/registry.mjs +2 -1
- package/schema.mjs +18 -1
- package/scripts/hook-launcher.mjs +10 -0
- package/scripts/post-tool-recall.js +19 -8
- package/scripts/pre-agent-inject.js +12 -4
- package/scripts/pre-skill-bridge.js +9 -5
- package/scripts/pre-tool-recall.js +44 -40
- package/scripts/user-prompt-search.js +37 -60
- package/search-scoring.mjs +10 -0
- package/server.mjs +95 -215
- package/source-files.mjs +4 -0
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,11 +44,13 @@ 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 {
|
|
48
51
|
RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
|
|
49
52
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
|
|
53
|
+
STALE_EPISODE_BUFFER_AGE_MS,
|
|
50
54
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
51
55
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
52
56
|
spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
|
|
@@ -80,20 +84,15 @@ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from
|
|
|
80
84
|
import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
81
85
|
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
82
86
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
83
|
-
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
84
87
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
85
88
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
86
|
-
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
87
|
-
import { pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } from './lib/patha-exclude-meter.mjs';
|
|
89
|
+
import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
|
|
88
90
|
import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
|
|
89
91
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
90
92
|
import { selectErrorRecall } from './lib/error-recall-core.mjs';
|
|
91
93
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
92
|
-
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
93
|
-
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
94
|
-
import { silentAutoAdopt } from './adopt-cli.mjs';
|
|
95
|
-
import { emitV270UpgradeBanner, hasPreV270Data } from './lib/upgrade-banner.mjs';
|
|
96
94
|
import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
95
|
+
import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
|
|
97
96
|
import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
|
|
98
97
|
// plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
|
|
99
98
|
// from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
|
|
@@ -105,6 +104,31 @@ async function loadCacheGuard() {
|
|
|
105
104
|
catch { _cacheGuardCache = {}; }
|
|
106
105
|
return _cacheGuardCache;
|
|
107
106
|
}
|
|
107
|
+
// Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
|
|
108
|
+
// import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
|
|
109
|
+
// modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
|
|
110
|
+
// that needs them instead: registry-recommend, patha-exclude-meter, hook-update,
|
|
111
|
+
// hook-optimize, adopt-cli, upgrade-banner. Measured marginal cost of the four largest,
|
|
112
|
+
// same process: hook-update 9.4 ms, registry-recommend 5.1, hook-optimize 5.9, the rest
|
|
113
|
+
// 0.5-2.4 each.
|
|
114
|
+
//
|
|
115
|
+
// A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
|
|
116
|
+
// same place a failing static import would have landed the whole process; each site keeps
|
|
117
|
+
// whatever local try/catch it already had, so a missing module degrades exactly one
|
|
118
|
+
// feature rather than the event.
|
|
119
|
+
//
|
|
120
|
+
// Three named by the audit are deliberately NOT converted, because the reason they are
|
|
121
|
+
// loaded is not that nobody looked:
|
|
122
|
+
// * hook-llm.mjs (+16.3 ms, the single largest) — `flushEpisode` is SYNCHRONOUS and on
|
|
123
|
+
// the PostToolUse path, and it calls `saveEpisodeImmediate` from that module. Lazy
|
|
124
|
+
// loading needs either an async rewrite of the flush path or extracting the function,
|
|
125
|
+
// and extraction does not pay: `saveEpisodeImmediate` reaches `saveObservation`, which
|
|
126
|
+
// pulls tfidf / observation-write / activity / maintain-core anyway — only
|
|
127
|
+
// haiku-client would actually stop loading.
|
|
128
|
+
// * lib/db-backup.mjs and lib/compress-core.mjs (<=2.4 ms each) — their call sites sit
|
|
129
|
+
// in `runSessionStartAutoMaintain` and `handleAutoCompress`, both synchronous. Turning
|
|
130
|
+
// two functions and their callers async across a background-worker path costs more
|
|
131
|
+
// risk than the milliseconds are worth.
|
|
108
132
|
import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
|
|
109
133
|
import { getVocabulary } from './tfidf.mjs';
|
|
110
134
|
|
|
@@ -128,18 +152,22 @@ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-o
|
|
|
128
152
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
129
153
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
130
154
|
// in Claude UI does not automatically remove them. Exit early to make disable actually work.
|
|
131
|
-
|
|
132
|
-
|
|
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() {
|
|
133
160
|
try {
|
|
134
161
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
135
|
-
|
|
136
|
-
return settings.enabledPlugins?.[PLUGIN_KEY] === false;
|
|
162
|
+
return isPluginExplicitlyDisabled(JSON.parse(readFileSync(settingsPath, 'utf8')));
|
|
137
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.
|
|
138
166
|
return false;
|
|
139
167
|
}
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
if (event &&
|
|
170
|
+
if (event && pluginDisabledHere()) process.exit(0);
|
|
143
171
|
if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
|
|
144
172
|
|
|
145
173
|
// Crash-safe: flush episode buffer on unexpected termination to prevent data loss
|
|
@@ -374,7 +402,7 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
|
374
402
|
// and with the flag on that case is strictly better than before, because the reads file
|
|
375
403
|
// was never touched and the retry still finds it.
|
|
376
404
|
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
377
|
-
recordMetric(
|
|
405
|
+
recordMetric(DB_DIR, {
|
|
378
406
|
event: 'episode_reads',
|
|
379
407
|
readsConsumed: (episode.filesRead || []).length,
|
|
380
408
|
readsHeld,
|
|
@@ -438,7 +466,7 @@ function flushEpisodeGroup(ep, db) {
|
|
|
438
466
|
// episodes are kept ONLY because of their Greps — and demoting what the product
|
|
439
467
|
// remembers on a deduction is how work disappears silently. This is that counter.
|
|
440
468
|
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
441
|
-
recordMetric(
|
|
469
|
+
recordMetric(DB_DIR, {
|
|
442
470
|
event: 'episode_significance',
|
|
443
471
|
rule: verdict.rule,
|
|
444
472
|
significant: isSignificant,
|
|
@@ -523,7 +551,12 @@ async function handlePostToolUse() {
|
|
|
523
551
|
const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
|
|
524
552
|
// hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
|
|
525
553
|
// UserPromptSubmit hook earlier in the same session (matched precision, B1).
|
|
526
|
-
|
|
554
|
+
// Lazy: only a `Skill` tool call needs this module, which is a small fraction of
|
|
555
|
+
// PostToolUse fires.
|
|
556
|
+
try {
|
|
557
|
+
const { recordSkillAdoption } = await import('./registry-recommend.mjs');
|
|
558
|
+
recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
|
|
559
|
+
} catch { /* telemetry only — never blocks capture */ }
|
|
527
560
|
}
|
|
528
561
|
|
|
529
562
|
const resp = normalizeToolResponse(tool_response);
|
|
@@ -656,7 +689,7 @@ function triggerErrorRecall(db, toolInput, response, opts = {}) {
|
|
|
656
689
|
// G13: this surface feeds the citation denominator but had zero metering —
|
|
657
690
|
// the G8 gate change (isError→isHardError) could not be volume-verified
|
|
658
691
|
// from metrics. Counter only; no latency (query is bundled in the hook).
|
|
659
|
-
recordMetric(
|
|
692
|
+
recordMetric(DB_DIR, { event: metricEvent, returned: rows.length });
|
|
660
693
|
// MED-3 (full audit 2026-07-16): go through the envelope, NOT raw stdout —
|
|
661
694
|
// a raw multi-line write corrupts a co-emitted episode-flush receipt.
|
|
662
695
|
// The follow-up correction (2026-08-17): "two separate JSON lines each parse
|
|
@@ -1160,7 +1193,7 @@ async function handleStop() {
|
|
|
1160
1193
|
// alongside cite-recall. Same scan target (transcript already in OS
|
|
1161
1194
|
// cache); same persistence file; one extra line in buildCiteRecallNudge.
|
|
1162
1195
|
const bugfixStats = countUnsavedBugfixShape(transcriptPath);
|
|
1163
|
-
const dest =
|
|
1196
|
+
const dest = citeRecallPathFor(RUNTIME_DIR, project);
|
|
1164
1197
|
// Carry the consecutive-low-cite streak forward so the SessionStart
|
|
1165
1198
|
// nag can self-silence after the project has ignored it N times.
|
|
1166
1199
|
let priorStreak = 0;
|
|
@@ -1459,7 +1492,13 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1459
1492
|
// Auto-dedup (fuzzy): catches near-identical titles that exact-match
|
|
1460
1493
|
// misses across larger time windows — e.g. episode-batch titles like
|
|
1461
1494
|
// "Modified A.mjs, B.mjs" vs "Modified B.mjs, A.mjs" written days apart.
|
|
1462
|
-
// 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
|
|
1463
1502
|
// well clear of legit "two updates same area" pairs (those typically
|
|
1464
1503
|
// score 0.7–0.85, surfaced via `maintain scan` for manual review).
|
|
1465
1504
|
// Bounded by ${SCAN_LIMIT} recent rows × ${FUZZY_MAX_MERGES}-merge cap.
|
|
@@ -1526,8 +1565,18 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1526
1565
|
// the file behind, and the doctor "Stale temp files" warning then
|
|
1527
1566
|
// accumulates indefinitely. fs-only; runs inside the 24h gate so it
|
|
1528
1567
|
// shares cadence with the rest of auto-maintain.
|
|
1568
|
+
//
|
|
1569
|
+
// Since audit P1-12 this also reclaims abandoned per-project episode buffers at 7d.
|
|
1570
|
+
// That one is named individually in the log: every other family it sweeps is residue
|
|
1571
|
+
// or a re-derivable tracker, while `ep-<project>.json` holds unflushed observations —
|
|
1572
|
+
// and the alternative to deleting it is worse (SessionStart flushes whatever it finds
|
|
1573
|
+
// with no staleness gate, so a revisit stamps months-old activity with today's date).
|
|
1529
1574
|
try {
|
|
1530
|
-
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR
|
|
1575
|
+
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR, {
|
|
1576
|
+
onSweep: (name, kind) => {
|
|
1577
|
+
if (kind === 'buffer') debugLog('DEBUG', 'auto-maintain', `discarding abandoned episode buffer ${name} (>7d; would otherwise flush mis-dated on revisit)`);
|
|
1578
|
+
},
|
|
1579
|
+
});
|
|
1531
1580
|
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
1532
1581
|
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
1533
1582
|
|
|
@@ -1798,10 +1847,10 @@ async function handleSessionStart() {
|
|
|
1798
1847
|
// SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
|
|
1799
1848
|
try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
|
|
1800
1849
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1801
|
-
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1802
|
-
// Same for the opt-in metrics sink
|
|
1850
|
+
try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1851
|
+
// Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
|
|
1803
1852
|
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
1804
|
-
try { gcOldMetricShards(
|
|
1853
|
+
try { gcOldMetricShards(DB_DIR); } catch { /* best-effort */ }
|
|
1805
1854
|
|
|
1806
1855
|
// Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
|
|
1807
1856
|
// re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
|
|
@@ -1842,6 +1891,7 @@ async function handleSessionStart() {
|
|
|
1842
1891
|
if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
|
|
1843
1892
|
const project = inferProject();
|
|
1844
1893
|
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1894
|
+
const { silentAutoAdopt } = await import('./adopt-cli.mjs');
|
|
1845
1895
|
const r = silentAutoAdopt({ cwd, markerDir: RUNTIME_DIR, markerKey: project });
|
|
1846
1896
|
if (r.ok) {
|
|
1847
1897
|
debugLog('DEBUG', 'session-start-auto-adopt', `action=${r.action} project=${project}`);
|
|
@@ -1865,12 +1915,30 @@ async function handleSessionStart() {
|
|
|
1865
1915
|
// Snapshot episode BEFORE flush for handoff extraction
|
|
1866
1916
|
const episodeSnapshot = readEpisodeRaw();
|
|
1867
1917
|
|
|
1868
|
-
// Flush any leftover episode buffer from previous session (e.g. after /clear)
|
|
1918
|
+
// Flush any leftover episode buffer from previous session (e.g. after /clear).
|
|
1919
|
+
//
|
|
1920
|
+
// A buffer older than STALE_EPISODE_BUFFER_AGE_MS is DISCARDED, not flushed. This is the
|
|
1921
|
+
// half of P1-12 the orphan sweep cannot reach: the sweep runs inside the detached
|
|
1922
|
+
// auto-maintain worker scheduled further down this same function, so on the revisit that
|
|
1923
|
+
// matters the flush below has already happened and the sweeper finds nothing. Without this
|
|
1924
|
+
// gate, returning to a project abandoned months ago injects its months-old tool activity
|
|
1925
|
+
// stamped with today's date — the exact harm hook-shared.mjs's threshold docblock names.
|
|
1926
|
+
// Same constant, because it is the same question ("did this buffer outlive its session by
|
|
1927
|
+
// an order of magnitude?"), asked at the other end.
|
|
1869
1928
|
if (acquireLock()) {
|
|
1870
1929
|
try {
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1930
|
+
let stale = false;
|
|
1931
|
+
try {
|
|
1932
|
+
stale = Date.now() - statSync(episodeFile()).mtimeMs > STALE_EPISODE_BUFFER_AGE_MS;
|
|
1933
|
+
} catch { /* no buffer file — readEpisode() returns null below */ }
|
|
1934
|
+
if (stale) {
|
|
1935
|
+
debugLog('INFO', 'session-start', `discarding stale episode buffer (>${STALE_EPISODE_BUFFER_AGE_MS}ms): ${episodeFile()}`);
|
|
1936
|
+
try { unlinkSync(episodeFile()); } catch { /* best-effort */ }
|
|
1937
|
+
} else {
|
|
1938
|
+
const prevEpisode = readEpisode();
|
|
1939
|
+
if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
|
|
1940
|
+
flushEpisode(prevEpisode, 'SessionStart');
|
|
1941
|
+
}
|
|
1874
1942
|
}
|
|
1875
1943
|
} finally {
|
|
1876
1944
|
releaseLock();
|
|
@@ -1949,6 +2017,7 @@ async function handleSessionStart() {
|
|
|
1949
2017
|
// the single envelope; the spawn stays a side effect and is fired below.
|
|
1950
2018
|
let updateCheckDue = false;
|
|
1951
2019
|
try {
|
|
2020
|
+
const { getCachedUpdateBanner, isUpdateCheckDue } = await import('./hook-update.mjs');
|
|
1952
2021
|
const banner = getCachedUpdateBanner();
|
|
1953
2022
|
// The human channel, not additionalContext: "vX available" is a notice for the
|
|
1954
2023
|
// USER. Folding it into additionalContext under suppressOutput:true kept its
|
|
@@ -1998,6 +2067,7 @@ async function handleSessionStart() {
|
|
|
1998
2067
|
// "Any observations at all" still misfired for someone who installed today
|
|
1999
2068
|
// and saved a few memories before their first SessionStart — age is what
|
|
2000
2069
|
// actually identifies an upgrader (see lib/upgrade-banner.mjs).
|
|
2070
|
+
const { emitV270UpgradeBanner, hasPreV270Data } = await import('./lib/upgrade-banner.mjs');
|
|
2001
2071
|
emitV270UpgradeBanner({
|
|
2002
2072
|
project,
|
|
2003
2073
|
runtimeDir: RUNTIME_DIR,
|
|
@@ -2156,8 +2226,11 @@ async function handleUserPrompt() {
|
|
|
2156
2226
|
// (inject, maybe duplicate) rather than fail closed (suppress).
|
|
2157
2227
|
const keyContextIds = [];
|
|
2158
2228
|
try {
|
|
2159
|
-
|
|
2160
|
-
|
|
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);
|
|
2161
2234
|
if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
|
|
2162
2235
|
keyContextIds.push(...ids);
|
|
2163
2236
|
}
|
|
@@ -2175,13 +2248,16 @@ async function handleUserPrompt() {
|
|
|
2175
2248
|
// project-keyed name), so a concurrent session's write can no longer
|
|
2176
2249
|
// replace this session's payload between the UPS write and this read.
|
|
2177
2250
|
const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
|
|
2184
|
-
|
|
2251
|
+
// The freshness + same-session gate is lib/injected-ids.mjs's (audit 2026-09-02
|
|
2252
|
+
// P1-2); this was the third hand-typed copy of it. THE 10 s WINDOW STAYS HERE and
|
|
2253
|
+
// is passed in: the two writers gate on DEDUP_STALE_MS (5 min) and this reader on
|
|
2254
|
+
// 10 s ("same prompt cycle"), and that disagreement is a real open question
|
|
2255
|
+
// (P1-2's second half — the 10 s window still accepts the PREVIOUS prompt's
|
|
2256
|
+
// marker), not a copy-paste slip to be normalised away by the consolidation.
|
|
2257
|
+
// Legacy payloads without `session` keep the old time-window-only behaviour.
|
|
2258
|
+
const { ids, fresh } = readInjectedMarker(injectedFile,
|
|
2259
|
+
{ sessionId: ccSessionId, maxAgeMs: 10000 });
|
|
2260
|
+
if (fresh) {
|
|
2185
2261
|
// D#193, DELIBERATELY NOT NUMERICALISED — read this before "fixing" it.
|
|
2186
2262
|
//
|
|
2187
2263
|
// Ids arrive here as written. `user-prompt-search.js` writes plain numbers, but
|
|
@@ -2258,7 +2334,16 @@ async function handleUserPrompt() {
|
|
|
2258
2334
|
// Arm B also carries its OWN imperative pick. Reusing arm A's put a pick the
|
|
2259
2335
|
// repaired system would not have made into arm B's exclude, so on any prompt where
|
|
2260
2336
|
// the pick changed, the delta described a system that does not exist.
|
|
2261
|
-
|
|
2337
|
+
// Lazy on "the marker carried ids", NOT on the metrics env. Gating the import on
|
|
2338
|
+
// `CLAUDE_MEM_METRICS === '1'` would read cheaper still, and would put a second copy
|
|
2339
|
+
// of `pathAMeterEnabled`'s own predicate here — the twin shape this meter's tests
|
|
2340
|
+
// exist to pin. `pathAMeterEnabled()` stays the only place that predicate lives; the
|
|
2341
|
+
// module still stops loading on every OTHER event, which is what P1-8 is about.
|
|
2342
|
+
let pathAMeterEnabled, coerceMarkerIds, recordPathAExclude;
|
|
2343
|
+
if (pathAInjectedIds.length > 0) {
|
|
2344
|
+
({ pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } = await import('./lib/patha-exclude-meter.mjs'));
|
|
2345
|
+
}
|
|
2346
|
+
const meterCoerced = (pathAMeterEnabled && pathAMeterEnabled())
|
|
2262
2347
|
? [...coerceMarkerIds(pathAInjectedIds)]
|
|
2263
2348
|
: null;
|
|
2264
2349
|
let meterArmB = null;
|
|
@@ -2333,7 +2418,7 @@ async function handleUserPrompt() {
|
|
|
2333
2418
|
// counterfactual search and the second lesson selection off a stock install.
|
|
2334
2419
|
try {
|
|
2335
2420
|
if (meterCoerced) {
|
|
2336
|
-
recordPathAExclude(
|
|
2421
|
+
recordPathAExclude(DB_DIR, {
|
|
2337
2422
|
markerIds: pathAInjectedIds,
|
|
2338
2423
|
emitted: memories,
|
|
2339
2424
|
after: meterArmB,
|
|
@@ -2368,14 +2453,14 @@ async function handleEnrichSave(rawId) {
|
|
|
2368
2453
|
// work" was invisible (32% alias coverage with 3 indistinguishable failure
|
|
2369
2454
|
// causes). reason 'filled-concurrently' = txn ran but a concurrent optimize/
|
|
2370
2455
|
// update had already filled every empty field.
|
|
2371
|
-
recordMetric(
|
|
2456
|
+
recordMetric(DB_DIR, {
|
|
2372
2457
|
event: 'enrich_save',
|
|
2373
2458
|
id,
|
|
2374
2459
|
enriched: result.enriched,
|
|
2375
2460
|
reason: result.reason ?? (result.enriched ? 'enriched' : 'filled-concurrently'),
|
|
2376
2461
|
});
|
|
2377
2462
|
} catch (e) {
|
|
2378
|
-
recordMetric(
|
|
2463
|
+
recordMetric(DB_DIR, { event: 'enrich_save', id, enriched: false, reason: 'worker-error' });
|
|
2379
2464
|
debugCatch(e, 'enrich-save');
|
|
2380
2465
|
} finally {
|
|
2381
2466
|
try { db.close(); } catch {}
|
|
@@ -2418,22 +2503,20 @@ function handleAutoCompress() {
|
|
|
2418
2503
|
|
|
2419
2504
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
2420
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.
|
|
2421
2512
|
function readStdin() {
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
if (data.length > MAX_STDIN) {
|
|
2430
|
-
process.stdin.destroy(); clearTimeout(timeout);
|
|
2431
|
-
resolve({ text: data.slice(0, MAX_STDIN), truncated: true });
|
|
2432
|
-
}
|
|
2433
|
-
});
|
|
2434
|
-
process.stdin.on('end', () => { clearTimeout(timeout); resolve({ text: data, truncated: false }); });
|
|
2435
|
-
process.stdin.on('error', err => { clearTimeout(timeout); reject(err); });
|
|
2436
|
-
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;
|
|
2437
2520
|
});
|
|
2438
2521
|
}
|
|
2439
2522
|
|
|
@@ -2493,7 +2576,7 @@ try {
|
|
|
2493
2576
|
case 'auto-compress': handleAutoCompress(); break;
|
|
2494
2577
|
case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
|
|
2495
2578
|
case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
|
|
2496
|
-
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
2579
|
+
case 'llm-optimize': { const { handleLLMOptimize } = await import('./hook-optimize.mjs'); await handleLLMOptimize(); break; }
|
|
2497
2580
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
2498
2581
|
// GitHub fetch off the SessionStart critical path, writing update-state.json so
|
|
2499
2582
|
// the NEXT session's cached banner is fresh.
|
|
@@ -2507,7 +2590,7 @@ try {
|
|
|
2507
2590
|
// self-installer back on in the same release that resurrects the worker. The
|
|
2508
2591
|
// module default and the installer's own guards are unchanged — install.mjs
|
|
2509
2592
|
// still passes allowInstall:true for the explicit, user-invoked update.
|
|
2510
|
-
case 'update-check': await checkForUpdate({ allowInstall: false }); break;
|
|
2593
|
+
case 'update-check': { const { checkForUpdate } = await import('./hook-update.mjs'); await checkForUpdate({ allowInstall: false }); break; }
|
|
2511
2594
|
}
|
|
2512
2595
|
} catch (err) {
|
|
2513
2596
|
// Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
|
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';
|
|
@@ -614,7 +620,11 @@ if (existsSync(pluginDir)) {
|
|
|
614
620
|
if (existsSync(pluginHooksPath)) {
|
|
615
621
|
const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
|
|
616
622
|
if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
|
|
617
|
-
|
|
623
|
+
// Atomic (audit 2026-09-02 P1-10): a torn hooks.json is not a fail-open marker —
|
|
624
|
+
// Claude Code parses it at plugin load, so half a file disables the plugin's hooks
|
|
625
|
+
// for that install until the next successful write. Same writer settings.json
|
|
626
|
+
// already uses 1600 lines down.
|
|
627
|
+
atomicWriteFileSync(pluginHooksPath, JSON.stringify({
|
|
618
628
|
description: pluginHooks.description || 'claude-mem-lite hooks',
|
|
619
629
|
_note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
|
|
620
630
|
hooks: {}
|
|
@@ -653,7 +663,10 @@ if (existsSync(pluginDir)) {
|
|
|
653
663
|
try {
|
|
654
664
|
const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
|
|
655
665
|
if (h.hooks && Object.keys(h.hooks).length > 0) {
|
|
656
|
-
|
|
666
|
+
// Atomic, same reason as the marketplace-source copy above (P1-10). This one
|
|
667
|
+
// is the higher-cost of the two: it runs once PER CACHED VERSION, so a tear
|
|
668
|
+
// here disables hooks for whichever version Claude Code happens to load.
|
|
669
|
+
atomicWriteFileSync(cachedHooksPath, JSON.stringify({
|
|
657
670
|
description: h.description || 'claude-mem-lite hooks',
|
|
658
671
|
_note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
|
|
659
672
|
hooks: {}
|
|
@@ -1609,7 +1622,7 @@ async function doctor() {
|
|
|
1609
1622
|
// broken install to exit 0 so it never spams a Node stack trace on every hook
|
|
1610
1623
|
// fire. That silence is intentional but hides failure — it drops a breakage
|
|
1611
1624
|
// marker so this check can surface the otherwise-invisible degraded state.
|
|
1612
|
-
const brokenMarker = join(
|
|
1625
|
+
const brokenMarker = join(MEM_RUNTIME_DIR, 'hook-launcher-broken');
|
|
1613
1626
|
if (existsSync(brokenMarker)) {
|
|
1614
1627
|
let detail = '';
|
|
1615
1628
|
try {
|
|
@@ -1627,7 +1640,7 @@ async function doctor() {
|
|
|
1627
1640
|
// is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
|
|
1628
1641
|
// right now". A Node upgrade breaks every DB-touching path at once, so this is
|
|
1629
1642
|
// the single highest-value line in doctor when it fires.
|
|
1630
|
-
const breakage = readNativeBindingBreakage(
|
|
1643
|
+
const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
|
|
1631
1644
|
// Reuses the per-root probes above — same trees, same question, and doctor
|
|
1632
1645
|
// should not pay for another round of child spawns to ask it twice.
|
|
1633
1646
|
if (brokenRoots.length > 0) {
|
|
@@ -2203,9 +2216,7 @@ function cleanupMemHooksFromSettings(settings) {
|
|
|
2203
2216
|
return removed;
|
|
2204
2217
|
}
|
|
2205
2218
|
|
|
2206
|
-
|
|
2207
|
-
return settings?.enabledPlugins?.[PLUGIN_KEY] === false;
|
|
2208
|
-
}
|
|
2219
|
+
|
|
2209
2220
|
|
|
2210
2221
|
function getInstalledPluginEntries(installed) {
|
|
2211
2222
|
if (installed?.plugins && typeof installed.plugins === 'object') return installed.plugins;
|
|
@@ -2266,8 +2277,8 @@ function cleanup() {
|
|
|
2266
2277
|
}
|
|
2267
2278
|
}
|
|
2268
2279
|
|
|
2269
|
-
// Clean pending-* / ep-flush-* in runtime/ (
|
|
2270
|
-
const runtimeDir =
|
|
2280
|
+
// Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
|
|
2281
|
+
const runtimeDir = MEM_RUNTIME_DIR;
|
|
2271
2282
|
if (existsSync(runtimeDir)) {
|
|
2272
2283
|
for (const f of readdirSync(runtimeDir)) {
|
|
2273
2284
|
if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
|
|
@@ -2585,7 +2596,7 @@ async function rebuildBinding() {
|
|
|
2585
2596
|
process.exitCode = 1;
|
|
2586
2597
|
} else {
|
|
2587
2598
|
// Every tree is loadable → drop the marker so session-start stops retrying.
|
|
2588
|
-
clearNativeBindingBreakage(
|
|
2599
|
+
clearNativeBindingBreakage(MEM_RUNTIME_DIR);
|
|
2589
2600
|
}
|
|
2590
2601
|
} finally {
|
|
2591
2602
|
release();
|
package/lib/atomic-write.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
|
|
9
9
|
// a one-time ".bak" so a logic bug in the caller's merge is recoverable.
|
|
10
10
|
|
|
11
|
-
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -43,7 +43,18 @@ export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
|
|
|
43
43
|
// pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
|
|
44
44
|
// each other's temp mid-write. Same-dir-as-target temp keeps the rename atomic (no
|
|
45
45
|
// cross-device move) even when the target lives in a dotfiles repo on another mount.
|
|
46
|
+
// The temp is removed on ANY failure. Without this a failed rename (target replaced by a
|
|
47
|
+
// directory, permissions, a symlink whose resolution path fails) leaves
|
|
48
|
+
// `<name>.tmp-<pid>` behind — and several call sites write into the USER's project root
|
|
49
|
+
// (CLAUDE.md), where the residue shows up in their `git status` and nothing sweeps it:
|
|
50
|
+
// the orphan sweep only walks RUNTIME_DIR. Cleaning here covers every caller rather than
|
|
51
|
+
// asking each one to remember, which is the mistake the private twins made.
|
|
46
52
|
const tmp = `${target}.tmp-${process.pid}`;
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(tmp, data);
|
|
55
|
+
renameSync(tmp, target);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try { unlinkSync(tmp); } catch { /* nothing written, or already gone */ }
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
49
60
|
}
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Cooldown schema (post-v2.81): { "<path>": { ts: <number>, lessonIds: [#NN, ...] } }
|
|
12
12
|
// Legacy schema (pre-v2.81): { "<path>": <number> } — tolerated, never emits.
|
|
13
13
|
|
|
14
|
-
import { basename
|
|
14
|
+
import { basename } from 'path';
|
|
15
15
|
import { readFileSync } from 'fs';
|
|
16
16
|
import { readTranscriptEntries } from './transcript-scan.mjs';
|
|
17
17
|
import { EDIT_TOOLS } from '../utils.mjs';
|
|
@@ -22,6 +22,7 @@ import { EDIT_TOOLS } from '../utils.mjs';
|
|
|
22
22
|
// in the ordinary case: it is whatever the repository being worked on happens to contain.
|
|
23
23
|
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
24
24
|
import { cooldownPathFor as sharedCooldownPathFor } from './cooldown-path.mjs';
|
|
25
|
+
import { citeRecallPathFor } from './cite-recall-path.mjs';
|
|
25
26
|
// One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
|
|
26
27
|
// is acyclic.
|
|
27
28
|
import { citationIdRe } from './citation-tracker.mjs';
|
|
@@ -230,8 +231,7 @@ export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
|
|
|
230
231
|
export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
|
|
231
232
|
if (env.CLAUDE_MEM_NO_CITE_NUDGE === '1') return '';
|
|
232
233
|
try {
|
|
233
|
-
const
|
|
234
|
-
const path = join(runtimeDir, `cite-recall-${safe}.json`);
|
|
234
|
+
const path = citeRecallPathFor(runtimeDir, project);
|
|
235
235
|
const raw = readFileSync(path, 'utf8');
|
|
236
236
|
const data = JSON.parse(raw);
|
|
237
237
|
const silenceAfter = env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER !== undefined
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lib/cite-recall-path.mjs — the ONE definition of the cite-recall snapshot file's path.
|
|
2
|
+
//
|
|
3
|
+
// Same shape, same failure mode and same fix as lib/cooldown-path.mjs (audit 2026-08-29
|
|
4
|
+
// ARCH-2). `handleStop` writes `runtime/cite-recall-<project>.json` and
|
|
5
|
+
// `buildCiteRecallNudge` reads it back, and each derived the name itself:
|
|
6
|
+
//
|
|
7
|
+
// hook.mjs:1163 join(RUNTIME_DIR, `cite-recall-${project.replace(…).slice(0,64)}.json`)
|
|
8
|
+
// lib/cite-back-hint.mjs const safe = project.replace(…).slice(0,64); join(runtimeDir, `cite-recall-${safe}.json`)
|
|
9
|
+
//
|
|
10
|
+
// A writer and a reader disagreeing about a filename does not throw — the reader opens a
|
|
11
|
+
// file nobody wrote, `readFileSync` misses, the catch swallows it, and the SessionStart
|
|
12
|
+
// nudge is silently gone. That is the whole reason this class keeps getting collapsed:
|
|
13
|
+
// nothing in the system reports it, so the only defence is that the rule has one home.
|
|
14
|
+
// This is the third instance found (cooldown, injected-ids, cite-recall); the sanitize
|
|
15
|
+
// literal is deliberately NOT shared across the three, because they key on different
|
|
16
|
+
// things (session id vs project) and a single "sanitize" helper would invite exactly the
|
|
17
|
+
// cross-family coupling that makes one of them impossible to change later.
|
|
18
|
+
//
|
|
19
|
+
// `hook-shared.mjs`'s marker-GC list is the third consumer: it matches this family by
|
|
20
|
+
// prefix, so the prefix is exported rather than re-typed there.
|
|
21
|
+
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
|
|
24
|
+
/** Filename prefix. Exported so the marker GC can match the family without re-deriving it. */
|
|
25
|
+
export const CITE_RECALL_FILE_PREFIX = 'cite-recall-';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Project name → filesystem-safe key. The 64-char cap and the character class are part of
|
|
29
|
+
* the contract, not defensive hygiene: writer and reader must derive the same name from the
|
|
30
|
+
* same project, so a change here is a change to both at once.
|
|
31
|
+
*
|
|
32
|
+
* Note the cap differs from `project-utils.mjs:sanitizeProject` (100) — these are different
|
|
33
|
+
* keys for different files, and silently unifying them would rename every existing snapshot
|
|
34
|
+
* on upgrade, i.e. drop one nudge per project. Kept separate on purpose.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} project
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function citeRecallProjectKey(project) {
|
|
40
|
+
return String(project).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Absolute path to a project's cite-recall snapshot.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} runtimeDir Absolute RUNTIME_DIR.
|
|
47
|
+
* @param {unknown} project Project name as `inferProject` returns it.
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function citeRecallPathFor(runtimeDir, project) {
|
|
51
|
+
return join(runtimeDir, `${CITE_RECALL_FILE_PREFIX}${citeRecallProjectKey(project)}.json`);
|
|
52
|
+
}
|