claude-mem-lite 3.34.0 → 3.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli.mjs +4 -2
- package/format-utils.mjs +8 -4
- package/hook-handoff.mjs +6 -3
- package/hook-llm.mjs +16 -0
- package/hook.mjs +10 -1
- package/install.mjs +2 -1
- package/lib/citation-tracker.mjs +10 -1
- package/lib/maintain-core.mjs +23 -0
- package/lib/metrics.mjs +28 -1
- package/lib/resolve-data-dir.mjs +35 -0
- package/lib/startup-dashboard.mjs +18 -7
- package/mem-cli.mjs +5 -1
- package/package.json +2 -1
- package/registry-recommend.mjs +2 -2
- package/schema.mjs +2 -1
- package/scripts/hook-launcher.mjs +9 -4
- package/scripts/post-tool-recall.js +12 -3
- package/scripts/pre-skill-bridge.js +2 -1
- package/scripts/pre-tool-recall.js +2 -2
- package/scripts/user-prompt-search.js +13 -5
- package/server.mjs +5 -1
- package/source-files.mjs +6 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.35.1",
|
|
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.35.1",
|
|
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/cli.mjs
CHANGED
|
@@ -33,8 +33,10 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
33
33
|
const { existsSync } = await import('fs');
|
|
34
34
|
const { join } = await import('path');
|
|
35
35
|
// D#29: honor CLAUDE_MEM_DIR so the install-vs-CLI help routing is correct on
|
|
36
|
-
// relocated installs (matches schema.mjs DB_DIR
|
|
37
|
-
|
|
36
|
+
// relocated installs (matches schema.mjs DB_DIR via the shared resolver, which
|
|
37
|
+
// also fixes the HOME-unset relative-path fallback this branch used to have).
|
|
38
|
+
const { resolveDataDir } = await import('./lib/resolve-data-dir.mjs');
|
|
39
|
+
const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
38
40
|
const dbPath = join(dataDir, 'claude-mem-lite.db');
|
|
39
41
|
if (existsSync(dbPath)) {
|
|
40
42
|
const { run } = await import('./mem-cli.mjs');
|
package/format-utils.mjs
CHANGED
|
@@ -37,12 +37,16 @@ export function truncate(str, max = 80) {
|
|
|
37
37
|
// block would smuggle a privileged-channel instruction / fake tool-call narrative
|
|
38
38
|
// into the model's context. It can't escape its wrapper (class 1 closers are
|
|
39
39
|
// defanged), but a nested forged authority/tool tag is still an indirect-prompt-
|
|
40
|
-
// injection vector; strip the brackets so it reads as inert text.
|
|
41
|
-
// (<
|
|
42
|
-
//
|
|
40
|
+
// injection vector; strip the brackets so it reads as inert text. Tool-call tags
|
|
41
|
+
// (<invoke \u2026>/<parameter \u2026>, bare + antml:-namespaced) are included: a prior turn's
|
|
42
|
+
// malformed tool-XML replayed through a handoff corrupted the continuation surface
|
|
43
|
+
// (mid-token truncation + model confusion), so replayed tool-XML must defang too.
|
|
44
|
+
// These carry attributes, so the match allows an optional attribute tail before the
|
|
45
|
+
// closing `>` \u2014 which also catches an attribute-bearing forgery of an authority tag
|
|
46
|
+
// (<system-reminder foo="\u2026">). Unrelated tags (<other-tag>) are left intact.
|
|
43
47
|
// Reachable by editing files that contain these tokens \u2014 e.g. developing claude-mem-lite
|
|
44
48
|
// itself, where source/observations carry the delimiter names.
|
|
45
|
-
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results)
|
|
49
|
+
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
package/hook-handoff.mjs
CHANGED
|
@@ -478,9 +478,12 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
478
478
|
if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
|
|
479
479
|
lines.push('');
|
|
480
480
|
lines.push('<session-summary source="haiku">');
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
481
|
+
// Defang: these come from session_summaries, populated by Haiku OR by
|
|
482
|
+
// extractStructuredSummary over the assistant transcript tail — replayed text that can
|
|
483
|
+
// carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
|
|
484
|
+
if (summary.completed) lines.push(neutralizeContextDelimiters(summary.completed));
|
|
485
|
+
if (summary.remaining_items) lines.push(`Remaining: ${neutralizeContextDelimiters(summary.remaining_items)}`);
|
|
486
|
+
if (summary.next_steps) lines.push(`Next steps: ${neutralizeContextDelimiters(summary.next_steps)}`);
|
|
484
487
|
lines.push('</session-summary>');
|
|
485
488
|
}
|
|
486
489
|
} catch {}
|
package/hook-llm.mjs
CHANGED
|
@@ -146,6 +146,22 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Paired-gate DROP at the auto-capture choke-point (v3.35). isLowYieldChangeObs
|
|
150
|
+
// (type=change + imp<2 + no real lesson — the substantive-title band isNoiseObservation
|
|
151
|
+
// misses) previously ran ONLY on the LLM-success path (handleLLMEpisode). The pre-save
|
|
152
|
+
// write here and the LLM-failure fallback (which keeps the pre-saved row) both bypassed
|
|
153
|
+
// it, so template change-rows survived on LLM failure. Gate covers all three write paths.
|
|
154
|
+
// Runs BEFORE capNoiseImportance: the pre-assigned importance IS the rule signal for a
|
|
155
|
+
// provisional pre-save — an imp>=2 rule-triggered episode (config/error change) must land
|
|
156
|
+
// so it stays visible immediately, survives a total LLM failure (the keep-pre-saved
|
|
157
|
+
// branch), and keeps its original created_at for the later in-place upgrade. (A dropped
|
|
158
|
+
// pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
|
|
159
|
+
// those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
|
|
160
|
+
if (isLowYieldChangeObs(obs)) {
|
|
161
|
+
debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
// v2.47 P0-3: importance cap for LOW_SIGNAL titles that kept the drop gate
|
|
150
166
|
// open via importance>=2 but carry no lesson/facts signal. 341 rows in live
|
|
151
167
|
// DB had imp=3 under these conditions (99.4% noise). Cap to 1 so they
|
package/hook.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
|
|
|
47
47
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
48
48
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
49
49
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
50
|
-
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren } from './lib/maintain-core.mjs';
|
|
50
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons } from './lib/maintain-core.mjs';
|
|
51
51
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
52
52
|
import {
|
|
53
53
|
extractCitationsFromTranscript,
|
|
@@ -62,6 +62,7 @@ import { extractTailAssistantText, extractStructuredSummary } from './lib/summar
|
|
|
62
62
|
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
63
63
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
64
64
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
65
|
+
import { gcOldMetricShards } from './lib/metrics.mjs';
|
|
65
66
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
66
67
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
67
68
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
@@ -795,6 +796,11 @@ function runSessionStartAutoMaintain(db) {
|
|
|
795
796
|
const orphansRecovered = recoverOrphanedChildren(db, mctx);
|
|
796
797
|
if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
|
|
797
798
|
|
|
799
|
+
// Heal lesson rows citation-decay buried at importance 0 under the old floor=0.
|
|
800
|
+
// Non-destructive (0→1 on lesson-bearing rows only); idempotent no-op once none remain.
|
|
801
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
802
|
+
if (lessonsHealed > 0) debugLog('DEBUG', 'auto-maintain', `healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
803
|
+
|
|
798
804
|
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
799
805
|
if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
|
|
800
806
|
if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
|
|
@@ -1098,6 +1104,9 @@ async function handleSessionStart() {
|
|
|
1098
1104
|
gcStalePreRecallCooldowns();
|
|
1099
1105
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1100
1106
|
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1107
|
+
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
1108
|
+
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
1109
|
+
try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
|
|
1101
1110
|
|
|
1102
1111
|
// Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
|
|
1103
1112
|
// re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
|
package/install.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { join, resolve, dirname, isAbsolute } 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
11
|
|
|
11
12
|
const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
|
|
12
13
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
@@ -20,7 +21,7 @@ const DATA_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
20
21
|
// the runtime/data layer READS it (pre-fix: installer wrote homedir, runtime read
|
|
21
22
|
// the relocated dir → preinstalled skills silently vanished, doctor read the wrong
|
|
22
23
|
// DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
|
|
23
|
-
const MEM_DATA_DIR = process.env.CLAUDE_MEM_DIR
|
|
24
|
+
const MEM_DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
24
25
|
const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
25
26
|
const OLD_DATA_DIR = join(homedir(), '.claude-mem');
|
|
26
27
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -472,7 +472,16 @@ export function hasMainThreadAssistantText(transcriptPath) {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
const IMPORTANCE_CAP = 3;
|
|
475
|
-
|
|
475
|
+
// Demote floor = 1, NOT 0. Both passive injection surfaces exclude importance 0
|
|
476
|
+
// (pre-tool-recall.js requires >=2, user-prompt-search.js requires >=1), so a row
|
|
477
|
+
// demoted to 0 can never be re-injected → never re-cited → never recovers: a one-way
|
|
478
|
+
// burial that silently hides lesson-bearing rows the "lessons never auto-GC" guards
|
|
479
|
+
// (maintain decayAndMarkIdle + compress-core) protect on every other path. Floor 1
|
|
480
|
+
// keeps a decayed row on the >=1 surface with a citation-recovery path. Genuine noise
|
|
481
|
+
// still sinks via maintain's PENDING_PURGE pipeline, which keys on compressed_into
|
|
482
|
+
// (not importance) over injection_count=0 rows — a disjoint population from these
|
|
483
|
+
// injected-but-uncited rows — so noise GC is unaffected.
|
|
484
|
+
const IMPORTANCE_FLOOR = 1;
|
|
476
485
|
const UNCITED_STREAK_THRESHOLD = 3;
|
|
477
486
|
|
|
478
487
|
// Adoption-rate gate (P5 ②). A project's cite-rate is SUM(cited_count) /
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -117,6 +117,29 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
|
|
|
117
117
|
`).run(...baseParams).changes;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Heal lesson-bearing rows that citation-decay buried at importance 0 under the old
|
|
121
|
+
// IMPORTANCE_FLOOR=0 (fixed in citation-tracker.mjs → floor 1). All passive injection
|
|
122
|
+
// surfaces exclude importance 0 (pre-tool-recall >=2, user-prompt-search >=1, memory-context
|
|
123
|
+
// >=1), so a lesson demoted there is invisible AND — being injection_count>0 by construction
|
|
124
|
+
// — sits in no GC queue either (decayAndMarkIdle only marks injection_count=0 rows): stranded
|
|
125
|
+
// out of reach with its distilled lesson. Lifting to 1 restores >=1-surface visibility + a
|
|
126
|
+
// citation-recovery path. NON-DESTRUCTIVE (only 0→1 on lesson-bearing rows, never
|
|
127
|
+
// deletes/hides), idempotent (a no-op once no imp-0 lesson rows remain), so safe to run
|
|
128
|
+
// unconditionally alongside recoverOrphanedChildren. `superseded_at IS NULL` mirrors the
|
|
129
|
+
// injection surfaces' own filter (pre-tool-recall:368, memory-context:217) — a de-dup loser
|
|
130
|
+
// (auto-dedup sets superseded_at but leaves compressed_into=0) must NOT be lifted back into
|
|
131
|
+
// injectability. Non-lesson imp-0 rows are left buried (low-value, not worth resurfacing).
|
|
132
|
+
export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] } = {}) {
|
|
133
|
+
return db.prepare(`
|
|
134
|
+
UPDATE observations SET importance = 1
|
|
135
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
136
|
+
AND COALESCE(importance, 1) = 0
|
|
137
|
+
AND superseded_at IS NULL
|
|
138
|
+
AND lesson_learned IS NOT NULL AND lesson_learned <> '' AND lower(lesson_learned) <> 'none'
|
|
139
|
+
${projectFilter}
|
|
140
|
+
`).run(...baseParams).changes;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
121
144
|
const doomed = db.prepare(`
|
|
122
145
|
SELECT id FROM observations
|
package/lib/metrics.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// skip malformed lines silently. The module imports nothing heavy so hook
|
|
17
17
|
// cold-start pays near-zero when metrics are disabled.
|
|
18
18
|
|
|
19
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
20
20
|
import { join } from 'path';
|
|
21
21
|
|
|
22
22
|
const DAY_MS = 86400000;
|
|
@@ -68,6 +68,33 @@ export function timed(dbDir, event, fn, extra = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Prune metric daily shards older than `retainDays`. recordMetric writes one
|
|
73
|
+
* YYYY-MM-DD.jsonl per day with no GC, so a long-lived CLAUDE_MEM_METRICS=1 install
|
|
74
|
+
* grows the dir unbounded. 90d keeps a full quarter for aggregate windows while
|
|
75
|
+
* bounding the dir. Runs regardless of the enable flag so a user who toggles metrics
|
|
76
|
+
* OFF still gets old shards cleaned. Shard date read from the filename (ISO dates sort
|
|
77
|
+
* lexicographically = chronologically). Best-effort, never throws — called from the
|
|
78
|
+
* SessionStart GC sweep (mirrors registry-recommend.gcOldShadowShards).
|
|
79
|
+
* @returns {number} shards removed
|
|
80
|
+
*/
|
|
81
|
+
export function gcOldMetricShards(dbDir, retainDays = 90) {
|
|
82
|
+
try {
|
|
83
|
+
if (!dbDir) return 0;
|
|
84
|
+
const dir = join(dbDir, 'metrics');
|
|
85
|
+
if (!existsSync(dir)) return 0;
|
|
86
|
+
const cutoff = new Date(Date.now() - retainDays * DAY_MS).toISOString().slice(0, 10);
|
|
87
|
+
let removed = 0;
|
|
88
|
+
for (const name of readdirSync(dir)) {
|
|
89
|
+
const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
|
90
|
+
if (m && m[1] < cutoff) {
|
|
91
|
+
try { unlinkSync(join(dir, name)); removed++; } catch { /* per-entry, silent */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
95
|
+
} catch { return 0; }
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
/** Count of days to aggregate by default in readMetrics / aggregate. */
|
|
72
99
|
export const DEFAULT_WINDOW_DAYS = 7;
|
|
73
100
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single source of truth for resolving the CLAUDE_MEM_DIR data directory.
|
|
2
|
+
// Zero runtime deps (node:path + node:os only) so hot-path hook scripts can
|
|
3
|
+
// import it without pulling in better-sqlite3.
|
|
4
|
+
//
|
|
5
|
+
// The env var is a RELOCATION knob: unset → ~/.claude-mem-lite (the common,
|
|
6
|
+
// non-relocated case); set → an absolute path on a larger/faster volume.
|
|
7
|
+
// Anything else is a mistake we must reject LOUDLY rather than turn into a
|
|
8
|
+
// stray directory:
|
|
9
|
+
// - The literal strings "undefined"/"null" arise when a caller interpolates
|
|
10
|
+
// a JS nullish value into a shell env string (e.g. `CLAUDE_MEM_DIR='${x}'`
|
|
11
|
+
// with x === undefined). They are truthy, so `env || default` accepts them
|
|
12
|
+
// and better-sqlite3 then creates a relative `undefined/` dir at cwd.
|
|
13
|
+
// - A relative path silently resolves against each process's cwd, scattering
|
|
14
|
+
// state across directories.
|
|
15
|
+
// Falsy (unset/empty) is the only non-absolute value we treat as "use default".
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join, isAbsolute } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string|undefined|null} raw Typically process.env.CLAUDE_MEM_DIR.
|
|
21
|
+
* @returns {string} An absolute data directory.
|
|
22
|
+
* @throws if `raw` is a non-empty, non-absolute value (incl. "undefined"/"null").
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDataDir(raw) {
|
|
25
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
26
|
+
return join(homedir(), '.claude-mem-lite');
|
|
27
|
+
}
|
|
28
|
+
if (typeof raw !== 'string' || raw === 'undefined' || raw === 'null' || !isAbsolute(raw)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`CLAUDE_MEM_DIR must be an absolute path; got ${JSON.stringify(raw)}. ` +
|
|
31
|
+
`Leave it unset to use ~/.claude-mem-lite.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { readGitState } from './git-state.mjs';
|
|
9
9
|
import { readProjectTasks } from './task-reader.mjs';
|
|
10
10
|
import { recentPlans } from './plan-reader.mjs';
|
|
11
|
-
import { isAdoptedHere } from '../hook-shared.mjs';
|
|
11
|
+
import { isAdoptedHere, HANDOFF_EXPIRY_EXIT } from '../hook-shared.mjs';
|
|
12
12
|
|
|
13
13
|
function ageStr(ms) {
|
|
14
14
|
const diff = Date.now() - ms;
|
|
@@ -22,11 +22,16 @@ function ageStr(ms) {
|
|
|
22
22
|
|
|
23
23
|
function readRecentHandoff(db, project) {
|
|
24
24
|
try {
|
|
25
|
+
// Only surface a handoff the UserPromptSubmit injection could actually deliver: apply
|
|
26
|
+
// the SAME expiry the injection's pickHandoffToInject enforces (HANDOFF_EXPIRY_EXIT).
|
|
27
|
+
// Without this the dashboard promised "context injects on your next message" for an
|
|
28
|
+
// exit handoff >7d old that pickHandoffToInject filters as expired → the promise could
|
|
29
|
+
// never be kept (batch2-reviewer Finding 1).
|
|
25
30
|
return db.prepare(`
|
|
26
31
|
SELECT created_at_epoch, working_on FROM session_handoffs
|
|
27
|
-
WHERE project = ? AND type = 'exit'
|
|
32
|
+
WHERE project = ? AND type = 'exit' AND created_at_epoch > ?
|
|
28
33
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
29
|
-
`).get(project);
|
|
34
|
+
`).get(project, Date.now() - HANDOFF_EXPIRY_EXIT);
|
|
30
35
|
} catch { return null; }
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -82,10 +87,16 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
if (handoff) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
// Continuation POINTER only. The working_on CONTENT is delivered once — by the
|
|
91
|
+
// UserPromptSubmit <session-handoff> block on a continuation-intent prompt
|
|
92
|
+
// (renderHandoffInjection). Emitting the teaser here too double-injected working_on
|
|
93
|
+
// into the model's context on every resume (post-defang: redundant, not unsafe).
|
|
94
|
+
// Wording is CONDITIONAL ("resume it to restore"), not a promise of automatic
|
|
95
|
+
// injection: the injection is gated on continuation-intent, so on a genuinely new
|
|
96
|
+
// first task the gate stays silent (stale working_on correctly not injected) and the
|
|
97
|
+
// pointer must not claim otherwise. readRecentHandoff already drops handoffs older than
|
|
98
|
+
// the injection's own expiry, so a shown pointer is always deliverable on resume.
|
|
99
|
+
parts.push(`🔄 Continuation available (/exit ${ageStr(handoff.created_at_epoch)}) — resume it to restore the full context.`);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
if (eventCount > 0) {
|
package/mem-cli.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
|
|
|
18
18
|
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
19
19
|
import {
|
|
20
20
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
|
-
recoverOrphanedChildren,
|
|
21
|
+
recoverOrphanedChildren, recoverBuriedLessons,
|
|
22
22
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
23
23
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
24
24
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
@@ -1972,6 +1972,10 @@ function cmdMaintain(db, args) {
|
|
|
1972
1972
|
// unreachable children. Non-destructive — un-hide only, no delete.
|
|
1973
1973
|
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1974
1974
|
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1975
|
+
// Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
|
|
1976
|
+
// lesson-bearing rows only; idempotent no-op once none remain.
|
|
1977
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
1978
|
+
if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
1975
1979
|
}
|
|
1976
1980
|
|
|
1977
1981
|
if (ops.includes('decay')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"lib/id-routing.mjs",
|
|
68
68
|
"lib/err-sampler.mjs",
|
|
69
69
|
"lib/hook-telemetry.mjs",
|
|
70
|
+
"lib/resolve-data-dir.mjs",
|
|
70
71
|
"lib/file-intel.mjs",
|
|
71
72
|
"lib/reread-guard.mjs",
|
|
72
73
|
"lib/metrics.mjs",
|
package/registry-recommend.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// invocations/recommend_count. Live injection is Phase 2. `off` skips all work.
|
|
6
6
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
9
9
|
import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
|
|
10
10
|
|
|
11
11
|
const VALID_MODES = new Set(['shadow', 'live', 'off']);
|
|
@@ -26,7 +26,7 @@ const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS
|
|
|
26
26
|
// DB_DIR formula) so tests sandbox via env without ESM-cache gymnastics, and prod reads
|
|
27
27
|
// the same dir as the rest of the app.
|
|
28
28
|
function recoRuntimeDir() {
|
|
29
|
-
return join(process.env.CLAUDE_MEM_DIR
|
|
29
|
+
return join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime');
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
|
package/schema.mjs
CHANGED
|
@@ -7,10 +7,11 @@ import { homedir } from 'os';
|
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
|
|
9
9
|
import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
|
|
10
|
+
import { resolveDataDir } from './lib/resolve-data-dir.mjs';
|
|
10
11
|
|
|
11
12
|
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
12
13
|
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
13
|
-
export const DB_DIR = process.env.CLAUDE_MEM_DIR
|
|
14
|
+
export const DB_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
14
15
|
export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
|
|
15
16
|
export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
16
17
|
// CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
|
|
@@ -26,15 +26,20 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync, mkdirSync, writeFileSync, statSync, unlinkSync, readFileSync } from 'node:fs';
|
|
28
28
|
import { spawnSync } from 'node:child_process';
|
|
29
|
-
import { dirname, join } from 'node:path';
|
|
29
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
|
|
33
33
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
34
|
const INSTALL_DIR = join(__dirname, '..');
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// A bogus CLAUDE_MEM_DIR ("undefined"/"null"/relative from a mis-quoted env
|
|
36
|
+
// interpolation) must degrade to the default here, not become a stray dir. The
|
|
37
|
+
// launcher's pure-node charter (above) forbids importing lib/resolve-data-dir.mjs,
|
|
38
|
+
// and its fail-open duty forbids throwing, so mirror that guard inline + lenient:
|
|
39
|
+
// non-absolute → default. Data-writing paths import that module and throw instead.
|
|
40
|
+
const MEM_DIR = process.env.CLAUDE_MEM_DIR;
|
|
41
|
+
const DATA_DIR = MEM_DIR && isAbsolute(MEM_DIR) ? MEM_DIR : join(homedir(), '.claude-mem-lite');
|
|
42
|
+
const RUNTIME_DIR = join(DATA_DIR, 'runtime');
|
|
38
43
|
const HEAL_MARKER = join(RUNTIME_DIR, 'hook-launcher-lastheal');
|
|
39
44
|
const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
40
45
|
// Observable breakage state: written when the launcher degrades a broken install
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from 'fs';
|
|
17
17
|
import { basename, join } from 'path';
|
|
18
|
-
import {
|
|
18
|
+
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
19
19
|
|
|
20
20
|
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
21
21
|
|
|
22
|
-
const DATA_DIR = process.env.CLAUDE_MEM_DIR
|
|
22
|
+
const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
23
23
|
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
|
|
24
24
|
const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
|
|
25
25
|
|
|
@@ -68,4 +68,13 @@ async function main() {
|
|
|
68
68
|
}));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
|
|
72
|
+
// touching it) and holds no open handles (no DB, no timers), so the event loop drains
|
|
73
|
+
// and the process exits 0 on its own — which FLUSHES the stdout nudge above. A forced
|
|
74
|
+
// exit could drop that pending async write on a piped stdout (the v3.33.1 gotcha the
|
|
75
|
+
// payload-bearing sibling hooks avoid). catch() keeps the exit code 0.
|
|
76
|
+
// Swallow EPIPE: if Claude Code closes the read end before the async write drains, the
|
|
77
|
+
// stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
|
|
78
|
+
// surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
|
|
79
|
+
process.stdout.on('error', () => {});
|
|
80
|
+
main().catch(() => {});
|
|
@@ -7,9 +7,10 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
7
7
|
import { join, resolve, sep } from 'path';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
10
|
+
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
10
11
|
|
|
11
12
|
// CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
|
|
12
|
-
const DATA_DIR = process.env.CLAUDE_MEM_DIR
|
|
13
|
+
const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
13
14
|
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
|
|
14
15
|
// D#29: all data artifacts follow DATA_DIR (CLAUDE_MEM_DIR-aware), not a hardcoded
|
|
15
16
|
// homedir — previously REGISTRY_DB_PATH/MANAGED_BASE/MARKER pinned homedir while line 12
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
|
-
import {
|
|
9
|
+
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
10
10
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
11
11
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
12
12
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
@@ -18,7 +18,7 @@ import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
|
18
18
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
19
19
|
// whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
|
|
20
20
|
// per-component overrides for tests that mix isolated + real paths.
|
|
21
|
-
const DATA_DIR = process.env.CLAUDE_MEM_DIR
|
|
21
|
+
const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
22
22
|
const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
|
|
23
23
|
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
|
|
24
24
|
// A3 (v2.83): cross-hook dedup window — must mirror DEDUP_STALE_MS in
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate } from '../utils.mjs';
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate, neutralizeContextDelimiters } from '../utils.mjs';
|
|
8
8
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
9
9
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
10
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
@@ -265,7 +265,7 @@ export function rowMatchesIdentifier(row, idsLower) {
|
|
|
265
265
|
// Each row includes `bm25_raw` (pre-multiplier bm25 magnitude) alongside the
|
|
266
266
|
// composite `relevance`, so callers can distinguish raw-match strength from
|
|
267
267
|
// importance/type/decay inflation.
|
|
268
|
-
function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
268
|
+
export function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
269
269
|
const ftsQuery = sanitizeFtsQuery(queryText);
|
|
270
270
|
if (!ftsQuery) return { rows: [], mode: null };
|
|
271
271
|
|
|
@@ -299,6 +299,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
|
299
299
|
AND o.importance >= 1
|
|
300
300
|
AND o.created_at_epoch > ?
|
|
301
301
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
302
|
+
AND o.superseded_at IS NULL
|
|
302
303
|
AND ${notLowSignalTitleClause('o')}
|
|
303
304
|
${typeClause}
|
|
304
305
|
ORDER BY relevance
|
|
@@ -345,6 +346,7 @@ function searchByFile(db, files, project, limit) {
|
|
|
345
346
|
WHERE o.project = ?
|
|
346
347
|
AND o.importance >= 1
|
|
347
348
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
349
|
+
AND o.superseded_at IS NULL
|
|
348
350
|
AND o.created_at_epoch > ?
|
|
349
351
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
350
352
|
AND ${notLowSignalTitleClause('o')}
|
|
@@ -420,6 +422,7 @@ function searchRecent(db, project, limit) {
|
|
|
420
422
|
WHERE project = ?
|
|
421
423
|
AND importance >= 1
|
|
422
424
|
AND COALESCE(compressed_into, 0) = 0
|
|
425
|
+
AND superseded_at IS NULL
|
|
423
426
|
AND created_at_epoch > ?
|
|
424
427
|
AND ${notLowSignalTitleClause('')}
|
|
425
428
|
ORDER BY created_at_epoch DESC
|
|
@@ -464,8 +467,10 @@ function formatResults(rows) {
|
|
|
464
467
|
const lines = ['[mem] FYI — Related memories (continue your task):'];
|
|
465
468
|
for (const r of rows) {
|
|
466
469
|
const icon = typeIcon(r.type);
|
|
467
|
-
|
|
468
|
-
|
|
470
|
+
// Defang replayed obs text before truncation: a poisoned title/lesson carrying tool-XML
|
|
471
|
+
// or a forged authority tag must not render as a live delimiter in this injected block.
|
|
472
|
+
const title = truncate(neutralizeContextDelimiters(r.title || ''), 70);
|
|
473
|
+
const lesson = !QUIET_HOOKS && r.lesson_learned ? ` — ${truncate(neutralizeContextDelimiters(r.lesson_learned), 50)}` : '';
|
|
469
474
|
lines.push(`#${r.id} ${icon} ${title}${lesson}`);
|
|
470
475
|
}
|
|
471
476
|
return lines.join('\n');
|
|
@@ -479,7 +484,10 @@ function formatPromptResults(rows) {
|
|
|
479
484
|
if (!rows || rows.length === 0) return null;
|
|
480
485
|
const lines = ['[mem] FYI — Past similar questions (continue your task):'];
|
|
481
486
|
for (const r of rows) {
|
|
482
|
-
|
|
487
|
+
// prompt_text is a raw prior USER prompt — the highest-risk replayed class (this is
|
|
488
|
+
// exactly the column that carried malformed tool-XML into the handoff bug). Defang the
|
|
489
|
+
// delimiters, then collapse whitespace + truncate.
|
|
490
|
+
const text = truncate(neutralizeContextDelimiters(r.prompt_text || '').replace(/\s+/g, ' '), 80);
|
|
483
491
|
lines.push(`P#${r.id} 💬 ${text}`);
|
|
484
492
|
}
|
|
485
493
|
return lines.join('\n');
|
package/server.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
17
17
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
18
18
|
import {
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
|
-
recoverOrphanedChildren,
|
|
20
|
+
recoverOrphanedChildren, recoverBuriedLessons,
|
|
21
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
22
22
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
23
23
|
OP_CAP, STALE_AGE_MS,
|
|
@@ -1139,6 +1139,10 @@ server.registerTool(
|
|
|
1139
1139
|
// resurface unreachable children. Non-destructive — un-hide only, no delete.
|
|
1140
1140
|
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1141
1141
|
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1142
|
+
// Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
|
|
1143
|
+
// lesson-bearing rows only; idempotent no-op once none remain.
|
|
1144
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
1145
|
+
if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
1142
1146
|
}
|
|
1143
1147
|
|
|
1144
1148
|
if (ops.includes('decay')) {
|
package/source-files.mjs
CHANGED
|
@@ -29,6 +29,12 @@ export const SOURCE_FILES = [
|
|
|
29
29
|
'tier.mjs', 'tfidf.mjs',
|
|
30
30
|
'nlp.mjs', 'synonyms.mjs', 'scoring-sql.mjs', 'stop-words.mjs', 'project-utils.mjs',
|
|
31
31
|
'secret-scrub.mjs', 'format-utils.mjs', 'hash-utils.mjs', 'bash-utils.mjs',
|
|
32
|
+
// Single source of truth for the CLAUDE_MEM_DIR → data-dir resolver (rejects a
|
|
33
|
+
// stringified "undefined"/"null"/relative env instead of creating a stray dir).
|
|
34
|
+
// Statically imported by schema.mjs / cli.mjs / install.mjs / registry-recommend.mjs
|
|
35
|
+
// AND hook scripts (pre-tool-recall / post-tool-recall / pre-skill-bridge) — ship it
|
|
36
|
+
// or auto-update leaves schema + every hook with ERR_MODULE_NOT_FOUND on each fire.
|
|
37
|
+
'lib/resolve-data-dir.mjs',
|
|
32
38
|
// lib/ — statically imported by hook-llm.mjs (activity) + hook-handoff.mjs (git-state, task-reader);
|
|
33
39
|
// dynamically imported by hook.mjs (startup-dashboard) + mem-cli.mjs (doctor-benchmark, plan-reader).
|
|
34
40
|
'lib/activity.mjs',
|