claude-mem-lite 3.91.0 → 3.92.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 +1 -0
- package/README.zh-CN.md +1 -0
- package/claudemd.mjs +9 -6
- package/hook-context.mjs +9 -8
- package/hook-llm.mjs +56 -28
- package/hook-optimize.mjs +47 -28
- package/hook-shared.mjs +56 -11
- package/hook.mjs +94 -25
- package/install.mjs +9 -2
- 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/frontmatter.mjs +67 -0
- package/lib/injected-ids.mjs +93 -4
- package/lib/maintain-core.mjs +191 -5
- package/lib/observation-write.mjs +71 -13
- package/lib/registry-core.mjs +117 -1
- 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 +75 -193
- package/memdir.mjs +6 -6
- package/npm-shrinkwrap.json +20 -2
- package/package.json +9 -1
- package/registry-importer.mjs +4 -34
- package/registry.mjs +2 -1
- package/scripts/post-tool-recall.js +12 -4
- package/scripts/pre-tool-recall.js +9 -33
- package/scripts/user-prompt-search.js +13 -36
- package/search-scoring.mjs +10 -0
- package/server.mjs +68 -170
- package/source-files.mjs +2 -0
package/hook.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
|
47
47
|
import {
|
|
48
48
|
RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
|
|
49
49
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
|
|
50
|
+
STALE_EPISODE_BUFFER_AGE_MS,
|
|
50
51
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
51
52
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
52
53
|
spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
|
|
@@ -80,20 +81,15 @@ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from
|
|
|
80
81
|
import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
81
82
|
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
82
83
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
83
|
-
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
84
84
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
85
85
|
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';
|
|
86
|
+
import { injectedIdsFileName, keyContextIdsFileName, readInjectedMarker } from './lib/injected-ids.mjs';
|
|
88
87
|
import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
|
|
89
88
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
90
89
|
import { selectErrorRecall } from './lib/error-recall-core.mjs';
|
|
91
90
|
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
91
|
import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
92
|
+
import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
|
|
97
93
|
import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
|
|
98
94
|
// plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
|
|
99
95
|
// from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
|
|
@@ -105,6 +101,31 @@ async function loadCacheGuard() {
|
|
|
105
101
|
catch { _cacheGuardCache = {}; }
|
|
106
102
|
return _cacheGuardCache;
|
|
107
103
|
}
|
|
104
|
+
// Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
|
|
105
|
+
// import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
|
|
106
|
+
// modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
|
|
107
|
+
// that needs them instead: registry-recommend, patha-exclude-meter, hook-update,
|
|
108
|
+
// hook-optimize, adopt-cli, upgrade-banner. Measured marginal cost of the four largest,
|
|
109
|
+
// same process: hook-update 9.4 ms, registry-recommend 5.1, hook-optimize 5.9, the rest
|
|
110
|
+
// 0.5-2.4 each.
|
|
111
|
+
//
|
|
112
|
+
// A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
|
|
113
|
+
// same place a failing static import would have landed the whole process; each site keeps
|
|
114
|
+
// whatever local try/catch it already had, so a missing module degrades exactly one
|
|
115
|
+
// feature rather than the event.
|
|
116
|
+
//
|
|
117
|
+
// Three named by the audit are deliberately NOT converted, because the reason they are
|
|
118
|
+
// loaded is not that nobody looked:
|
|
119
|
+
// * hook-llm.mjs (+16.3 ms, the single largest) — `flushEpisode` is SYNCHRONOUS and on
|
|
120
|
+
// the PostToolUse path, and it calls `saveEpisodeImmediate` from that module. Lazy
|
|
121
|
+
// loading needs either an async rewrite of the flush path or extracting the function,
|
|
122
|
+
// and extraction does not pay: `saveEpisodeImmediate` reaches `saveObservation`, which
|
|
123
|
+
// pulls tfidf / observation-write / activity / maintain-core anyway — only
|
|
124
|
+
// haiku-client would actually stop loading.
|
|
125
|
+
// * lib/db-backup.mjs and lib/compress-core.mjs (<=2.4 ms each) — their call sites sit
|
|
126
|
+
// in `runSessionStartAutoMaintain` and `handleAutoCompress`, both synchronous. Turning
|
|
127
|
+
// two functions and their callers async across a background-worker path costs more
|
|
128
|
+
// risk than the milliseconds are worth.
|
|
108
129
|
import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
|
|
109
130
|
import { getVocabulary } from './tfidf.mjs';
|
|
110
131
|
|
|
@@ -523,7 +544,12 @@ async function handlePostToolUse() {
|
|
|
523
544
|
const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
|
|
524
545
|
// hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
|
|
525
546
|
// UserPromptSubmit hook earlier in the same session (matched precision, B1).
|
|
526
|
-
|
|
547
|
+
// Lazy: only a `Skill` tool call needs this module, which is a small fraction of
|
|
548
|
+
// PostToolUse fires.
|
|
549
|
+
try {
|
|
550
|
+
const { recordSkillAdoption } = await import('./registry-recommend.mjs');
|
|
551
|
+
recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
|
|
552
|
+
} catch { /* telemetry only — never blocks capture */ }
|
|
527
553
|
}
|
|
528
554
|
|
|
529
555
|
const resp = normalizeToolResponse(tool_response);
|
|
@@ -1160,7 +1186,7 @@ async function handleStop() {
|
|
|
1160
1186
|
// alongside cite-recall. Same scan target (transcript already in OS
|
|
1161
1187
|
// cache); same persistence file; one extra line in buildCiteRecallNudge.
|
|
1162
1188
|
const bugfixStats = countUnsavedBugfixShape(transcriptPath);
|
|
1163
|
-
const dest =
|
|
1189
|
+
const dest = citeRecallPathFor(RUNTIME_DIR, project);
|
|
1164
1190
|
// Carry the consecutive-low-cite streak forward so the SessionStart
|
|
1165
1191
|
// nag can self-silence after the project has ignored it N times.
|
|
1166
1192
|
let priorStreak = 0;
|
|
@@ -1526,8 +1552,18 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1526
1552
|
// the file behind, and the doctor "Stale temp files" warning then
|
|
1527
1553
|
// accumulates indefinitely. fs-only; runs inside the 24h gate so it
|
|
1528
1554
|
// shares cadence with the rest of auto-maintain.
|
|
1555
|
+
//
|
|
1556
|
+
// Since audit P1-12 this also reclaims abandoned per-project episode buffers at 7d.
|
|
1557
|
+
// That one is named individually in the log: every other family it sweeps is residue
|
|
1558
|
+
// or a re-derivable tracker, while `ep-<project>.json` holds unflushed observations —
|
|
1559
|
+
// and the alternative to deleting it is worse (SessionStart flushes whatever it finds
|
|
1560
|
+
// with no staleness gate, so a revisit stamps months-old activity with today's date).
|
|
1529
1561
|
try {
|
|
1530
|
-
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR
|
|
1562
|
+
const swept = sweepOrphanEpisodeFiles(RUNTIME_DIR, {
|
|
1563
|
+
onSweep: (name, kind) => {
|
|
1564
|
+
if (kind === 'buffer') debugLog('DEBUG', 'auto-maintain', `discarding abandoned episode buffer ${name} (>7d; would otherwise flush mis-dated on revisit)`);
|
|
1565
|
+
},
|
|
1566
|
+
});
|
|
1531
1567
|
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
1532
1568
|
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
1533
1569
|
|
|
@@ -1798,7 +1834,7 @@ async function handleSessionStart() {
|
|
|
1798
1834
|
// SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
|
|
1799
1835
|
try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
|
|
1800
1836
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1801
|
-
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1837
|
+
try { const { gcOldShadowShards } = await import('./registry-recommend.mjs'); gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1802
1838
|
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
1803
1839
|
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
1804
1840
|
try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
|
|
@@ -1842,6 +1878,7 @@ async function handleSessionStart() {
|
|
|
1842
1878
|
if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
|
|
1843
1879
|
const project = inferProject();
|
|
1844
1880
|
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1881
|
+
const { silentAutoAdopt } = await import('./adopt-cli.mjs');
|
|
1845
1882
|
const r = silentAutoAdopt({ cwd, markerDir: RUNTIME_DIR, markerKey: project });
|
|
1846
1883
|
if (r.ok) {
|
|
1847
1884
|
debugLog('DEBUG', 'session-start-auto-adopt', `action=${r.action} project=${project}`);
|
|
@@ -1865,12 +1902,30 @@ async function handleSessionStart() {
|
|
|
1865
1902
|
// Snapshot episode BEFORE flush for handoff extraction
|
|
1866
1903
|
const episodeSnapshot = readEpisodeRaw();
|
|
1867
1904
|
|
|
1868
|
-
// Flush any leftover episode buffer from previous session (e.g. after /clear)
|
|
1905
|
+
// Flush any leftover episode buffer from previous session (e.g. after /clear).
|
|
1906
|
+
//
|
|
1907
|
+
// A buffer older than STALE_EPISODE_BUFFER_AGE_MS is DISCARDED, not flushed. This is the
|
|
1908
|
+
// half of P1-12 the orphan sweep cannot reach: the sweep runs inside the detached
|
|
1909
|
+
// auto-maintain worker scheduled further down this same function, so on the revisit that
|
|
1910
|
+
// matters the flush below has already happened and the sweeper finds nothing. Without this
|
|
1911
|
+
// gate, returning to a project abandoned months ago injects its months-old tool activity
|
|
1912
|
+
// stamped with today's date — the exact harm hook-shared.mjs's threshold docblock names.
|
|
1913
|
+
// Same constant, because it is the same question ("did this buffer outlive its session by
|
|
1914
|
+
// an order of magnitude?"), asked at the other end.
|
|
1869
1915
|
if (acquireLock()) {
|
|
1870
1916
|
try {
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1917
|
+
let stale = false;
|
|
1918
|
+
try {
|
|
1919
|
+
stale = Date.now() - statSync(episodeFile()).mtimeMs > STALE_EPISODE_BUFFER_AGE_MS;
|
|
1920
|
+
} catch { /* no buffer file — readEpisode() returns null below */ }
|
|
1921
|
+
if (stale) {
|
|
1922
|
+
debugLog('INFO', 'session-start', `discarding stale episode buffer (>${STALE_EPISODE_BUFFER_AGE_MS}ms): ${episodeFile()}`);
|
|
1923
|
+
try { unlinkSync(episodeFile()); } catch { /* best-effort */ }
|
|
1924
|
+
} else {
|
|
1925
|
+
const prevEpisode = readEpisode();
|
|
1926
|
+
if (prevEpisode && prevEpisode.entries && prevEpisode.entries.length > 0) {
|
|
1927
|
+
flushEpisode(prevEpisode, 'SessionStart');
|
|
1928
|
+
}
|
|
1874
1929
|
}
|
|
1875
1930
|
} finally {
|
|
1876
1931
|
releaseLock();
|
|
@@ -1949,6 +2004,7 @@ async function handleSessionStart() {
|
|
|
1949
2004
|
// the single envelope; the spawn stays a side effect and is fired below.
|
|
1950
2005
|
let updateCheckDue = false;
|
|
1951
2006
|
try {
|
|
2007
|
+
const { getCachedUpdateBanner, isUpdateCheckDue } = await import('./hook-update.mjs');
|
|
1952
2008
|
const banner = getCachedUpdateBanner();
|
|
1953
2009
|
// The human channel, not additionalContext: "vX available" is a notice for the
|
|
1954
2010
|
// USER. Folding it into additionalContext under suppressOutput:true kept its
|
|
@@ -1998,6 +2054,7 @@ async function handleSessionStart() {
|
|
|
1998
2054
|
// "Any observations at all" still misfired for someone who installed today
|
|
1999
2055
|
// and saved a few memories before their first SessionStart — age is what
|
|
2000
2056
|
// actually identifies an upgrader (see lib/upgrade-banner.mjs).
|
|
2057
|
+
const { emitV270UpgradeBanner, hasPreV270Data } = await import('./lib/upgrade-banner.mjs');
|
|
2001
2058
|
emitV270UpgradeBanner({
|
|
2002
2059
|
project,
|
|
2003
2060
|
runtimeDir: RUNTIME_DIR,
|
|
@@ -2175,13 +2232,16 @@ async function handleUserPrompt() {
|
|
|
2175
2232
|
// project-keyed name), so a concurrent session's write can no longer
|
|
2176
2233
|
// replace this session's payload between the UPS write and this read.
|
|
2177
2234
|
const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
|
|
2184
|
-
|
|
2235
|
+
// The freshness + same-session gate is lib/injected-ids.mjs's (audit 2026-09-02
|
|
2236
|
+
// P1-2); this was the third hand-typed copy of it. THE 10 s WINDOW STAYS HERE and
|
|
2237
|
+
// is passed in: the two writers gate on DEDUP_STALE_MS (5 min) and this reader on
|
|
2238
|
+
// 10 s ("same prompt cycle"), and that disagreement is a real open question
|
|
2239
|
+
// (P1-2's second half — the 10 s window still accepts the PREVIOUS prompt's
|
|
2240
|
+
// marker), not a copy-paste slip to be normalised away by the consolidation.
|
|
2241
|
+
// Legacy payloads without `session` keep the old time-window-only behaviour.
|
|
2242
|
+
const { ids, fresh } = readInjectedMarker(injectedFile,
|
|
2243
|
+
{ sessionId: ccSessionId, maxAgeMs: 10000 });
|
|
2244
|
+
if (fresh) {
|
|
2185
2245
|
// D#193, DELIBERATELY NOT NUMERICALISED — read this before "fixing" it.
|
|
2186
2246
|
//
|
|
2187
2247
|
// Ids arrive here as written. `user-prompt-search.js` writes plain numbers, but
|
|
@@ -2258,7 +2318,16 @@ async function handleUserPrompt() {
|
|
|
2258
2318
|
// Arm B also carries its OWN imperative pick. Reusing arm A's put a pick the
|
|
2259
2319
|
// repaired system would not have made into arm B's exclude, so on any prompt where
|
|
2260
2320
|
// the pick changed, the delta described a system that does not exist.
|
|
2261
|
-
|
|
2321
|
+
// Lazy on "the marker carried ids", NOT on the metrics env. Gating the import on
|
|
2322
|
+
// `CLAUDE_MEM_METRICS === '1'` would read cheaper still, and would put a second copy
|
|
2323
|
+
// of `pathAMeterEnabled`'s own predicate here — the twin shape this meter's tests
|
|
2324
|
+
// exist to pin. `pathAMeterEnabled()` stays the only place that predicate lives; the
|
|
2325
|
+
// module still stops loading on every OTHER event, which is what P1-8 is about.
|
|
2326
|
+
let pathAMeterEnabled, coerceMarkerIds, recordPathAExclude;
|
|
2327
|
+
if (pathAInjectedIds.length > 0) {
|
|
2328
|
+
({ pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } = await import('./lib/patha-exclude-meter.mjs'));
|
|
2329
|
+
}
|
|
2330
|
+
const meterCoerced = (pathAMeterEnabled && pathAMeterEnabled())
|
|
2262
2331
|
? [...coerceMarkerIds(pathAInjectedIds)]
|
|
2263
2332
|
: null;
|
|
2264
2333
|
let meterArmB = null;
|
|
@@ -2493,7 +2562,7 @@ try {
|
|
|
2493
2562
|
case 'auto-compress': handleAutoCompress(); break;
|
|
2494
2563
|
case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
|
|
2495
2564
|
case 'auto-maintain': handleAutoMaintain(process.argv[3]); break;
|
|
2496
|
-
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
2565
|
+
case 'llm-optimize': { const { handleLLMOptimize } = await import('./hook-optimize.mjs'); await handleLLMOptimize(); break; }
|
|
2497
2566
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
2498
2567
|
// GitHub fetch off the SessionStart critical path, writing update-state.json so
|
|
2499
2568
|
// the NEXT session's cached banner is fresh.
|
|
@@ -2507,7 +2576,7 @@ try {
|
|
|
2507
2576
|
// self-installer back on in the same release that resurrects the worker. The
|
|
2508
2577
|
// module default and the installer's own guards are unchanged — install.mjs
|
|
2509
2578
|
// still passes allowInstall:true for the explicit, user-invoked update.
|
|
2510
|
-
case 'update-check': await checkForUpdate({ allowInstall: false }); break;
|
|
2579
|
+
case 'update-check': { const { checkForUpdate } = await import('./hook-update.mjs'); await checkForUpdate({ allowInstall: false }); break; }
|
|
2511
2580
|
}
|
|
2512
2581
|
} catch (err) {
|
|
2513
2582
|
// Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
|
package/install.mjs
CHANGED
|
@@ -614,7 +614,11 @@ if (existsSync(pluginDir)) {
|
|
|
614
614
|
if (existsSync(pluginHooksPath)) {
|
|
615
615
|
const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
|
|
616
616
|
if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
|
|
617
|
-
|
|
617
|
+
// Atomic (audit 2026-09-02 P1-10): a torn hooks.json is not a fail-open marker —
|
|
618
|
+
// Claude Code parses it at plugin load, so half a file disables the plugin's hooks
|
|
619
|
+
// for that install until the next successful write. Same writer settings.json
|
|
620
|
+
// already uses 1600 lines down.
|
|
621
|
+
atomicWriteFileSync(pluginHooksPath, JSON.stringify({
|
|
618
622
|
description: pluginHooks.description || 'claude-mem-lite hooks',
|
|
619
623
|
_note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
|
|
620
624
|
hooks: {}
|
|
@@ -653,7 +657,10 @@ if (existsSync(pluginDir)) {
|
|
|
653
657
|
try {
|
|
654
658
|
const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
|
|
655
659
|
if (h.hooks && Object.keys(h.hooks).length > 0) {
|
|
656
|
-
|
|
660
|
+
// Atomic, same reason as the marketplace-source copy above (P1-10). This one
|
|
661
|
+
// is the higher-cost of the two: it runs once PER CACHED VERSION, so a tear
|
|
662
|
+
// here disables hooks for whichever version Claude Code happens to load.
|
|
663
|
+
atomicWriteFileSync(cachedHooksPath, JSON.stringify({
|
|
657
664
|
description: h.description || 'claude-mem-lite hooks',
|
|
658
665
|
_note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
|
|
659
666
|
hooks: {}
|
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
|
+
}
|
package/lib/compress-core.mjs
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
// smart-compress path already wrote vectors, so the deterministic path was the
|
|
18
18
|
// sole gap (audit P6).
|
|
19
19
|
|
|
20
|
-
import { isoWeekKey, COMPRESSED_AUTO
|
|
21
|
-
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
20
|
+
import { isoWeekKey, COMPRESSED_AUTO } from '../utils.mjs';
|
|
22
21
|
import { scrubRecord } from './scrub-record.mjs';
|
|
22
|
+
import { upsertObservationVector } from './observation-write.mjs';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Low-value compression candidates: importance<=1, never accessed, older than
|
|
@@ -44,6 +44,13 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
|
|
|
44
44
|
-- weekly summary discards the lesson (the distilled value of a lessons store).
|
|
45
45
|
-- Mirrors the hook auto-compress lesson guard so neither path buries a lesson.
|
|
46
46
|
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
47
|
+
-- Tombstones stay out (audit 2026-09-02 P0-3). A superseded row keeps
|
|
48
|
+
-- compressed_into NULL, so compressedFilter alone admitted retracted content into a
|
|
49
|
+
-- weekly summary — and the summary is a LIVE importance-2 row, so the retraction is
|
|
50
|
+
-- undone by the compression. Spelled out rather than reusing liveObsFilterSql because
|
|
51
|
+
-- the compressed_into half is deliberately looser here (includeAutoMarked folds in
|
|
52
|
+
-- COMPRESSED_AUTO rows for re-summarization).
|
|
53
|
+
AND superseded_at IS NULL
|
|
47
54
|
AND created_at_epoch < ?
|
|
48
55
|
${compressedFilter}
|
|
49
56
|
${projectFilter}
|
|
@@ -105,21 +112,30 @@ export function compressGroup(db, proj, obs) {
|
|
|
105
112
|
// with save-observation.mjs and the LLM smart-compress path). Best-effort:
|
|
106
113
|
// vocab may be uninitialized on a fresh DB — a failure here must not abort the
|
|
107
114
|
// compression the caller is transacting.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
).run(summaryId, Buffer.from(vec.buffer), vocab.version, medianEpoch);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
} catch (e) { debugCatch(e, 'compress-vector'); }
|
|
115
|
+
// SQL + text derivation are lib/observation-write.mjs's (audit 2026-09-02 P1-4).
|
|
116
|
+
// `at: medianEpoch` is this site's one real difference: the summary is stamped with the
|
|
117
|
+
// median date of the rows it compresses, not with now. `gate: false` preserves the prior
|
|
118
|
+
// behaviour, which never consulted vectorsEnabled().
|
|
119
|
+
upsertObservationVector(db, summaryId, `${safe.title} ${safe.narrative}`, {
|
|
120
|
+
gate: false, at: medianEpoch, scope: 'compress-vector',
|
|
121
|
+
});
|
|
119
122
|
|
|
120
123
|
const obsIds = obs.map((o) => o.id);
|
|
121
124
|
const obsPh = obsIds.map(() => '?').join(',');
|
|
122
|
-
|
|
125
|
+
// Re-assert the candidate predicate at write time (audit 2026-09-02 P0-3): the hook path
|
|
126
|
+
// transacts per group, so a row can be superseded or absorbed by another summary between
|
|
127
|
+
// selection and here, and re-pointing it would drop it out of that summary's child set.
|
|
128
|
+
// Deliberately NOT liveObsFilterSql: COMPRESSED_AUTO rows are legitimate inputs
|
|
129
|
+
// (includeAutoMarked), so only the superseded half plus "no positive keeper yet" applies.
|
|
130
|
+
const compressed = db.prepare(`
|
|
131
|
+
UPDATE observations SET compressed_into = ?
|
|
132
|
+
WHERE id IN (${obsPh})
|
|
133
|
+
AND superseded_at IS NULL
|
|
134
|
+
AND (compressed_into IS NULL OR compressed_into = ${COMPRESSED_AUTO})
|
|
135
|
+
`).run(summaryId, ...obsIds).changes;
|
|
123
136
|
|
|
124
|
-
|
|
137
|
+
// `changes`, not obs.length: with the guard above those differ exactly when a row lost
|
|
138
|
+
// eligibility between selection and write, and reporting the intended count would make
|
|
139
|
+
// the guard invisible in every caller's totals.
|
|
140
|
+
return { summaryId, compressed };
|
|
125
141
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/frontmatter.mjs — the ONE YAML-frontmatter parser for skill/agent markdown.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-09-02 P1-16. There were three, and they were not all the same:
|
|
4
|
+
//
|
|
5
|
+
// registry-importer.mjs the shipped one, full
|
|
6
|
+
// scripts/index-managed.mjs byte-identical to it apart from the `export` keyword —
|
|
7
|
+
// 30 lines, the largest duplicate block in the tree
|
|
8
|
+
// scripts/convert-commands.mjs a SIMPLIFIED cut with no `|` / `>` block support and no
|
|
9
|
+
// JSON-array handling, i.e. already diverged
|
|
10
|
+
//
|
|
11
|
+
// The divergence is the part that matters: `description:` in a Claude Code SKILL.md is
|
|
12
|
+
// routinely a `|` block, and the simplified parser returned the literal `|` for it. Two
|
|
13
|
+
// scripts reading the same files with different parsers produce different registry rows
|
|
14
|
+
// depending on which one last ran.
|
|
15
|
+
//
|
|
16
|
+
// Zero dependencies on purpose — it is imported by a shipped module (registry-importer)
|
|
17
|
+
// and by two dev scripts, so it must not drag anything into either.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Split `---`-delimited YAML frontmatter from a markdown body.
|
|
21
|
+
*
|
|
22
|
+
* Not a YAML parser, and deliberately so: it handles the subset Claude Code skill and
|
|
23
|
+
* agent files actually use — scalars, quoted scalars, JSON-ish arrays, and `|` / `>`
|
|
24
|
+
* blocks — and leaves anything else as the raw string rather than guessing.
|
|
25
|
+
*
|
|
26
|
+
* The `description` special case is load-bearing rather than defensive: a description
|
|
27
|
+
* written as a plain scalar is very often CONTINUED on the following indented lines
|
|
28
|
+
* without a block marker, and dropping the continuation silently truncates the one field
|
|
29
|
+
* the recommendation gate reads.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} content full file text
|
|
32
|
+
* @returns {{frontmatter: Record<string, unknown>, body: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function parseFrontmatter(content) {
|
|
35
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
36
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
37
|
+
|
|
38
|
+
const raw = match[1];
|
|
39
|
+
const body = content.slice(match[0].length).trim();
|
|
40
|
+
const fm = {};
|
|
41
|
+
let currentKey = null, currentValue = '', inMultiline = false;
|
|
42
|
+
|
|
43
|
+
for (const line of raw.split('\n')) {
|
|
44
|
+
if (inMultiline && (line.startsWith(' ') || line.startsWith('\t') || line.trim() === '')) {
|
|
45
|
+
currentValue += ' ' + line.trim();
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (inMultiline && currentKey) { fm[currentKey] = currentValue.trim(); inMultiline = false; }
|
|
49
|
+
|
|
50
|
+
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.*)/);
|
|
51
|
+
if (kv) {
|
|
52
|
+
currentKey = kv[1];
|
|
53
|
+
let val = kv[2].trim();
|
|
54
|
+
if (val === '|' || val === '>') { inMultiline = true; currentValue = ''; continue; }
|
|
55
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
56
|
+
try { fm[currentKey] = JSON.parse(val); } catch { fm[currentKey] = val; }
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
|
|
60
|
+
val = val.slice(1, -1);
|
|
61
|
+
if (currentKey === 'description' && val) { inMultiline = true; currentValue = val; continue; }
|
|
62
|
+
fm[currentKey] = val;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (inMultiline && currentKey) fm[currentKey] = currentValue.trim();
|
|
66
|
+
return { frontmatter: fm, body };
|
|
67
|
+
}
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// lib/injected-ids.mjs —
|
|
2
|
-
//
|
|
3
|
-
// pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
|
|
4
|
-
// derive the same name or cross-hook dedup silently
|
|
1
|
+
// lib/injected-ids.mjs — the cross-hook injected-ids dedup marker: file name, freshness +
|
|
2
|
+
// same-session gate, and payload shape. Single source of truth for user-prompt-search.js
|
|
3
|
+
// (writer), pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
|
|
4
|
+
// derive the same name AND agree on when a payload counts, or cross-hook dedup silently
|
|
5
|
+
// goes blind — it does not error, it reads a marker nobody wrote.
|
|
6
|
+
//
|
|
7
|
+
// Scope widened 2026-09-03 (audit P1-2): this module used to own only the FILE NAME while
|
|
8
|
+
// the gate and the write were hand-typed in five places across three files.
|
|
5
9
|
//
|
|
6
10
|
// D#120: M-6 session-keyed the marker's PAYLOAD but kept ONE file per project,
|
|
7
11
|
// so two concurrent CC windows full-replaced each other's marker — no dedup
|
|
@@ -14,6 +18,9 @@
|
|
|
14
18
|
// colliding with the scripts/ directory rename in installExtractedRelease —
|
|
15
19
|
// same constraint as lib/mem-override.mjs.
|
|
16
20
|
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { atomicWriteFileSync } from './atomic-write.mjs';
|
|
23
|
+
|
|
17
24
|
/**
|
|
18
25
|
* Runtime-dir FILE NAME for the injected-ids marker (no directory component).
|
|
19
26
|
* No sessionId → legacy project-keyed name (env-less harnesses, old callers).
|
|
@@ -28,6 +35,88 @@ export function injectedIdsFileName(project, sessionId) {
|
|
|
28
35
|
return `${base}-${safe}`;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Read a marker file, applying the freshness + same-session gate.
|
|
40
|
+
*
|
|
41
|
+
* That gate had THREE byte-identical hand-typed copies (audit 2026-09-02 P1-2):
|
|
42
|
+
* `hook.mjs handleUserPrompt`, `readCrossHookInjected` and `mergeCrossHookInjected` in
|
|
43
|
+
* scripts/pre-tool-recall.js, and both legs of scripts/user-prompt-search.js — five sites
|
|
44
|
+
* spelling out the same `ts && Date.now() - ts < W && !(session && mine && session !== mine)`.
|
|
45
|
+
* The lib had collapsed only the FILE NAME. Every new writer added a sixth copy, and a
|
|
46
|
+
* writer/reader disagreement here does not error: it reads a marker nobody wrote.
|
|
47
|
+
*
|
|
48
|
+
* THE WINDOW STAYS A PARAMETER, because the callers genuinely disagree and that
|
|
49
|
+
* disagreement is a live open question, not an accident to be tidied away: the two writers
|
|
50
|
+
* use DEDUP_STALE_MS (5 min) and the hook.mjs reader uses 10 s. Hard-coding either one here
|
|
51
|
+
* would silently decide it. Legacy payloads with no `session` keep the old
|
|
52
|
+
* window-only behaviour, as before.
|
|
53
|
+
*
|
|
54
|
+
* IDS COME BACK EXACTLY AS WRITTEN — no Number(), no String(). The marker holds a mix of
|
|
55
|
+
* raw numbers and strings and the consumers test `Set.has()` against numbers from SQLite,
|
|
56
|
+
* so coercing here would turn an inert exclude live. That is D#213/D#216's decision to
|
|
57
|
+
* make, on its ruler (lib/patha-exclude-meter.mjs), not a drive-by of this consolidation.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} file Absolute path to the marker.
|
|
60
|
+
* @param {object} opts
|
|
61
|
+
* @param {string} [opts.sessionId] CC session id; a payload from another session is rejected.
|
|
62
|
+
* @param {number} opts.maxAgeMs Freshness window.
|
|
63
|
+
* @returns {{ids: Array<number|string>, count: number, fresh: boolean}} `fresh:false` ⇒ ids [] and count 0.
|
|
64
|
+
*
|
|
65
|
+
* ONE deliberate non-identity in the consolidation, stated rather than glossed: the copies
|
|
66
|
+
* disagreed at the boundary by 1 ms. pre-tool-recall used `age > W` (stale only when
|
|
67
|
+
* strictly greater), hook.mjs used `age < W` (fresh only when strictly less), so a payload
|
|
68
|
+
* exactly W old was fresh to one and stale to the other. This adopts the former for all
|
|
69
|
+
* callers. Nothing selects on a 1 ms boundary, but "identical behaviour" would have been
|
|
70
|
+
* false and the next person to diff the two would have had to rediscover why.
|
|
71
|
+
*/
|
|
72
|
+
export function readInjectedMarker(file, { sessionId, maxAgeMs } = {}) {
|
|
73
|
+
const empty = { ids: [], count: 0, fresh: false };
|
|
74
|
+
try {
|
|
75
|
+
const { ids, ts, count, session } = JSON.parse(readFileSync(file, 'utf8'));
|
|
76
|
+
if (session && sessionId && session !== sessionId) return empty;
|
|
77
|
+
if (!ts || Date.now() - ts > maxAgeMs) return empty;
|
|
78
|
+
if (!Array.isArray(ids)) return empty;
|
|
79
|
+
return { ids, count: count || 0, fresh: true };
|
|
80
|
+
} catch { return empty; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Write `newIds` into a marker, unioning with a fresh same-session payload or replacing it.
|
|
85
|
+
*
|
|
86
|
+
* `mode` is not a convenience — the two modes have different id-typing behaviour and BOTH
|
|
87
|
+
* are load-bearing:
|
|
88
|
+
* - `union` stringifies the whole result. Both union callers already did
|
|
89
|
+
* (`prev.ids.map(String)` plus new ids that are `D<id>` strings anyway), so
|
|
90
|
+
* this is their behaviour, not a new normalisation.
|
|
91
|
+
* - `replace` writes `newIds` verbatim. The UPS main leg passes `candidateIds`, a MIX of
|
|
92
|
+
* raw observation numbers and `P<id>` strings, and writing those unchanged is
|
|
93
|
+
* exactly the state D#213 measures. Stringifying here would change it.
|
|
94
|
+
* Keeping both under one function is the point: the next writer picks a mode instead of
|
|
95
|
+
* copying a fifth predicate and inventing a fifth typing rule.
|
|
96
|
+
*
|
|
97
|
+
* The write is atomic for the reason M-6 recorded: a plain write torn by a concurrent hook
|
|
98
|
+
* left the shared marker as invalid JSON, silently disabling cross-hook dedup for the window.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} file
|
|
101
|
+
* @param {Array<number|string>} newIds
|
|
102
|
+
* @param {object} opts
|
|
103
|
+
* @param {string} [opts.sessionId]
|
|
104
|
+
* @param {number} opts.maxAgeMs
|
|
105
|
+
* @param {'union'|'replace'} opts.mode
|
|
106
|
+
*/
|
|
107
|
+
export function mergeInjectedMarker(file, newIds, { sessionId, maxAgeMs, mode } = {}) {
|
|
108
|
+
const prev = readInjectedMarker(file, { sessionId, maxAgeMs });
|
|
109
|
+
const ids = mode === 'union'
|
|
110
|
+
? [...new Set([...prev.ids.map(String), ...newIds.map(String)])]
|
|
111
|
+
: newIds;
|
|
112
|
+
atomicWriteFileSync(file, JSON.stringify({
|
|
113
|
+
ids,
|
|
114
|
+
ts: Date.now(),
|
|
115
|
+
count: prev.count + 1,
|
|
116
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
31
120
|
/**
|
|
32
121
|
* Namespace prefix for an id written into the shared injected-ids marker.
|
|
33
122
|
*
|