engramx 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ // src/update/check.ts
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ var REGISTRY_URL = "https://registry.npmjs.org/engramx/latest";
6
+ var CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1e3;
7
+ var FETCH_TIMEOUT_MS = 1500;
8
+ function cachePath() {
9
+ return join(homedir(), ".engram", "last-update-check");
10
+ }
11
+ function readCache() {
12
+ const path = cachePath();
13
+ if (!existsSync(path)) return null;
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
16
+ if (typeof parsed?.latest === "string" && typeof parsed?.checkedAt === "number") {
17
+ return parsed;
18
+ }
19
+ return null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function writeCache(entry) {
25
+ try {
26
+ const path = cachePath();
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, JSON.stringify(entry), "utf-8");
29
+ } catch {
30
+ }
31
+ }
32
+ function isNewer(a, b) {
33
+ const parse = (v) => {
34
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
35
+ if (!m) return null;
36
+ return {
37
+ major: Number(m[1]),
38
+ minor: Number(m[2]),
39
+ patch: Number(m[3]),
40
+ pre: m[4] ?? null
41
+ };
42
+ };
43
+ const pa = parse(a);
44
+ const pb = parse(b);
45
+ if (!pa || !pb) return false;
46
+ if (pa.major !== pb.major) return pa.major > pb.major;
47
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor;
48
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch;
49
+ if (pa.pre === null && pb.pre !== null) return true;
50
+ if (pa.pre !== null && pb.pre === null) return false;
51
+ if (pa.pre === null && pb.pre === null) return false;
52
+ return (pa.pre ?? "") > (pb.pre ?? "");
53
+ }
54
+ function optedOut() {
55
+ if (process.env.ENGRAM_NO_UPDATE_CHECK === "1") return true;
56
+ if (process.env.CI) return true;
57
+ return false;
58
+ }
59
+ async function fetchLatestFromRegistry() {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
62
+ try {
63
+ const res = await fetch(REGISTRY_URL, {
64
+ signal: controller.signal,
65
+ headers: { accept: "application/json" }
66
+ });
67
+ if (!res.ok) return null;
68
+ const body = await res.json();
69
+ if (typeof body?.version !== "string") return null;
70
+ return body.version;
71
+ } catch {
72
+ return null;
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ async function checkForUpdate(currentVersion, opts = {}) {
78
+ const base = {
79
+ skipped: false,
80
+ current: currentVersion,
81
+ latest: null,
82
+ updateAvailable: false,
83
+ checkedAt: null,
84
+ fromCache: false
85
+ };
86
+ if (!opts.force && optedOut()) {
87
+ return { ...base, skipped: true };
88
+ }
89
+ if (!opts.force) {
90
+ const cached = readCache();
91
+ if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
92
+ return {
93
+ ...base,
94
+ latest: cached.latest,
95
+ updateAvailable: isNewer(cached.latest, currentVersion),
96
+ checkedAt: cached.checkedAt,
97
+ fromCache: true
98
+ };
99
+ }
100
+ }
101
+ const latest = await fetchLatestFromRegistry();
102
+ if (!latest) {
103
+ return { ...base, skipped: !opts.force };
104
+ }
105
+ const now = Date.now();
106
+ writeCache({ latest, checkedAt: now });
107
+ return {
108
+ ...base,
109
+ latest,
110
+ updateAvailable: isNewer(latest, currentVersion),
111
+ checkedAt: now,
112
+ fromCache: false
113
+ };
114
+ }
115
+
116
+ export {
117
+ cachePath,
118
+ isNewer,
119
+ optedOut,
120
+ checkForUpdate
121
+ };
@@ -0,0 +1,187 @@
1
+ // src/intercept/installer.ts
2
+ var ENGRAM_HOOK_EVENTS = [
3
+ "PreToolUse",
4
+ "PostToolUse",
5
+ "SessionStart",
6
+ "UserPromptSubmit",
7
+ "PreCompact",
8
+ "CwdChanged"
9
+ ];
10
+ var ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
11
+ var DEFAULT_ENGRAM_COMMAND = "engram intercept";
12
+ var ENGRAM_REINDEX_HOOK_MATCHER = "Edit|Write|MultiEdit";
13
+ var DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND = "engram reindex-hook";
14
+ var DEFAULT_HOOK_TIMEOUT_SEC = 5;
15
+ var DEFAULT_STATUSLINE_COMMAND = "engram hud-label";
16
+ function buildEngramHookEntries(command = DEFAULT_ENGRAM_COMMAND, timeout = DEFAULT_HOOK_TIMEOUT_SEC) {
17
+ const baseCmd = {
18
+ type: "command",
19
+ command,
20
+ timeout
21
+ };
22
+ return {
23
+ PreToolUse: {
24
+ matcher: ENGRAM_PRETOOL_MATCHER,
25
+ hooks: [baseCmd]
26
+ },
27
+ PostToolUse: {
28
+ // Match all tools — PostToolUse is an observer for any completion.
29
+ matcher: ".*",
30
+ hooks: [baseCmd]
31
+ },
32
+ SessionStart: {
33
+ // No matcher — SessionStart has no tool name.
34
+ hooks: [baseCmd]
35
+ },
36
+ UserPromptSubmit: {
37
+ // No matcher — UserPromptSubmit has no tool name.
38
+ hooks: [baseCmd]
39
+ },
40
+ PreCompact: {
41
+ // No matcher — PreCompact has no tool name.
42
+ hooks: [baseCmd]
43
+ },
44
+ CwdChanged: {
45
+ // No matcher — CwdChanged has no tool name.
46
+ hooks: [baseCmd]
47
+ }
48
+ };
49
+ }
50
+ function buildReindexHookEntry(command = DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND, timeout = DEFAULT_HOOK_TIMEOUT_SEC) {
51
+ return {
52
+ matcher: ENGRAM_REINDEX_HOOK_MATCHER,
53
+ hooks: [{ type: "command", command, timeout }]
54
+ };
55
+ }
56
+ function isEngramHookEntry(entry) {
57
+ if (entry === null || typeof entry !== "object") return false;
58
+ const e = entry;
59
+ if (!Array.isArray(e.hooks)) return false;
60
+ for (const h of e.hooks) {
61
+ if (h === null || typeof h !== "object") continue;
62
+ const cmd = h.command;
63
+ if (typeof cmd !== "string") continue;
64
+ if (cmd.includes("engram intercept") || cmd.includes("engram reindex-hook")) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+ function installEngramHooks(settings, command = DEFAULT_ENGRAM_COMMAND, options = {}) {
71
+ const entries = buildEngramHookEntries(command);
72
+ const added = [];
73
+ const alreadyPresent = [];
74
+ const hooksClone = {};
75
+ const existingHooks = settings.hooks ?? {};
76
+ for (const [key, value] of Object.entries(existingHooks)) {
77
+ if (Array.isArray(value)) {
78
+ hooksClone[key] = value.map((entry) => ({ ...entry }));
79
+ }
80
+ }
81
+ for (const event of ENGRAM_HOOK_EVENTS) {
82
+ const eventArr = hooksClone[event] ?? [];
83
+ const hasIntercept = eventArr.some(
84
+ (e) => entryContainsCommand(e, "engram intercept")
85
+ );
86
+ if (hasIntercept) {
87
+ alreadyPresent.push(event);
88
+ hooksClone[event] = eventArr;
89
+ continue;
90
+ }
91
+ hooksClone[event] = [...eventArr, entries[event]];
92
+ added.push(event);
93
+ }
94
+ let autoReindexAdded = false;
95
+ if (options.autoReindex) {
96
+ const postToolArr = hooksClone.PostToolUse ?? [];
97
+ const hasReindexHook = postToolArr.some(
98
+ (e) => entryContainsCommand(e, "engram reindex-hook")
99
+ );
100
+ if (!hasReindexHook) {
101
+ hooksClone.PostToolUse = [...postToolArr, buildReindexHookEntry()];
102
+ autoReindexAdded = true;
103
+ }
104
+ }
105
+ const hasStatusLine = settings.statusLine && typeof settings.statusLine === "object" && typeof settings.statusLine.command === "string" && settings.statusLine.command.length > 0;
106
+ const statusLineAdded = !hasStatusLine;
107
+ const statusLine = hasStatusLine ? settings.statusLine : { type: "command", command: DEFAULT_STATUSLINE_COMMAND };
108
+ return {
109
+ updated: { ...settings, hooks: hooksClone, statusLine },
110
+ added,
111
+ alreadyPresent,
112
+ statusLineAdded,
113
+ autoReindexAdded
114
+ };
115
+ }
116
+ function entryContainsCommand(entry, substring) {
117
+ if (!Array.isArray(entry.hooks)) return false;
118
+ for (const h of entry.hooks) {
119
+ if (h === null || typeof h !== "object") continue;
120
+ const cmd = h.command;
121
+ if (typeof cmd === "string" && cmd.includes(substring)) return true;
122
+ }
123
+ return false;
124
+ }
125
+ function uninstallEngramHooks(settings) {
126
+ const removed = [];
127
+ const existingHooks = settings.hooks ?? {};
128
+ const hooksClone = {};
129
+ for (const [event, arr] of Object.entries(existingHooks)) {
130
+ if (!Array.isArray(arr)) continue;
131
+ const filtered = arr.filter((entry) => !isEngramHookEntry(entry));
132
+ if (filtered.length !== arr.length && isKnownEngramEvent(event)) {
133
+ removed.push(event);
134
+ }
135
+ if (filtered.length > 0) {
136
+ hooksClone[event] = filtered;
137
+ }
138
+ }
139
+ const updatedSettings = { ...settings };
140
+ if (Object.keys(hooksClone).length === 0) {
141
+ delete updatedSettings.hooks;
142
+ } else {
143
+ updatedSettings.hooks = hooksClone;
144
+ }
145
+ const statusLineRemoved = typeof updatedSettings.statusLine?.command === "string" && updatedSettings.statusLine.command.includes("engram hud-label");
146
+ if (statusLineRemoved) {
147
+ delete updatedSettings.statusLine;
148
+ }
149
+ return { updated: updatedSettings, removed, statusLineRemoved };
150
+ }
151
+ function isKnownEngramEvent(event) {
152
+ return ENGRAM_HOOK_EVENTS.includes(event);
153
+ }
154
+ function formatInstallDiff(before, after) {
155
+ const lines = [];
156
+ const beforeHooks = before.hooks ?? {};
157
+ const afterHooks = after.hooks ?? {};
158
+ for (const event of ENGRAM_HOOK_EVENTS) {
159
+ const beforeArr = beforeHooks[event] ?? [];
160
+ const afterArr = afterHooks[event] ?? [];
161
+ if (beforeArr.length === afterArr.length) continue;
162
+ lines.push(`+ ${event}: ${beforeArr.length} \u2192 ${afterArr.length} entries`);
163
+ const added = afterArr.filter((entry) => isEngramHookEntry(entry));
164
+ const beforeHasEngram = beforeArr.some((entry) => isEngramHookEntry(entry));
165
+ if (!beforeHasEngram && added.length > 0) {
166
+ for (const entry of added) {
167
+ const matcher = entry.matcher ? ` matcher=${JSON.stringify(entry.matcher)}` : "";
168
+ const cmds = entry.hooks.map((h) => h.command).join(", ");
169
+ lines.push(` + {${matcher} command="${cmds}"}`);
170
+ }
171
+ }
172
+ }
173
+ const hadStatusLine = before.statusLine?.command;
174
+ const hasStatusLineNow = after.statusLine?.command;
175
+ if (!hadStatusLine && hasStatusLineNow?.includes("engram hud-label")) {
176
+ lines.push(`+ statusLine: engram hud-label (HUD enabled)`);
177
+ } else if (hadStatusLine?.includes("engram hud-label") && !hasStatusLineNow) {
178
+ lines.push(`- statusLine: engram hud-label (HUD removed)`);
179
+ }
180
+ return lines.length > 0 ? lines.join("\n") : "(no changes)";
181
+ }
182
+
183
+ export {
184
+ installEngramHooks,
185
+ uninstallEngramHooks,
186
+ formatInstallDiff
187
+ };
@@ -0,0 +1,99 @@
1
+ import {
2
+ formatThousands
3
+ } from "./chunk-ZVWRIVWQ.js";
4
+
5
+ // src/intercept/stats.ts
6
+ var ESTIMATED_TOKENS_PER_READ_DENY = 1200;
7
+ function summarizeHookLog(entries) {
8
+ const byEvent = {};
9
+ const byTool = {};
10
+ const byDecision = {};
11
+ let readDenyCount = 0;
12
+ let firstEntryTs = null;
13
+ let lastEntryTs = null;
14
+ for (const entry of entries) {
15
+ const event = entry.event ?? "unknown";
16
+ byEvent[event] = (byEvent[event] ?? 0) + 1;
17
+ const tool = entry.tool ?? "unknown";
18
+ byTool[tool] = (byTool[tool] ?? 0) + 1;
19
+ if (entry.decision) {
20
+ byDecision[entry.decision] = (byDecision[entry.decision] ?? 0) + 1;
21
+ }
22
+ if (event === "PreToolUse" && tool === "Read" && entry.decision === "deny") {
23
+ readDenyCount += 1;
24
+ }
25
+ const ts = entry.ts;
26
+ if (typeof ts === "string") {
27
+ if (firstEntryTs === null || ts < firstEntryTs) firstEntryTs = ts;
28
+ if (lastEntryTs === null || ts > lastEntryTs) lastEntryTs = ts;
29
+ }
30
+ }
31
+ return {
32
+ totalInvocations: entries.length,
33
+ byEvent: Object.freeze(byEvent),
34
+ byTool: Object.freeze(byTool),
35
+ byDecision: Object.freeze(byDecision),
36
+ readDenyCount,
37
+ estimatedTokensSaved: readDenyCount * ESTIMATED_TOKENS_PER_READ_DENY,
38
+ firstEntry: firstEntryTs,
39
+ lastEntry: lastEntryTs
40
+ };
41
+ }
42
+ function formatStatsSummary(summary) {
43
+ if (summary.totalInvocations === 0) {
44
+ return "engram hook stats: no log entries yet.\n\nRun engram install-hook in a project, then use Claude Code to see interceptions.";
45
+ }
46
+ const lines = [];
47
+ lines.push(`engram hook stats (${summary.totalInvocations} invocations)`);
48
+ lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
49
+ if (summary.firstEntry && summary.lastEntry) {
50
+ lines.push(`Time range: ${summary.firstEntry} \u2192 ${summary.lastEntry}`);
51
+ lines.push("");
52
+ }
53
+ lines.push("By event:");
54
+ const eventEntries = Object.entries(summary.byEvent).sort(
55
+ (a, b) => b[1] - a[1]
56
+ );
57
+ for (const [event, count] of eventEntries) {
58
+ const pct = (count / summary.totalInvocations * 100).toFixed(1);
59
+ lines.push(` ${event.padEnd(18)} ${String(count).padStart(5)} (${pct}%)`);
60
+ }
61
+ lines.push("");
62
+ lines.push("By tool:");
63
+ const toolEntries = Object.entries(summary.byTool).filter(([k]) => k !== "unknown").sort((a, b) => b[1] - a[1]);
64
+ for (const [tool, count] of toolEntries) {
65
+ lines.push(` ${tool.padEnd(18)} ${String(count).padStart(5)}`);
66
+ }
67
+ if (toolEntries.length === 0) {
68
+ lines.push(" (no tool-tagged entries)");
69
+ }
70
+ lines.push("");
71
+ const decisionEntries = Object.entries(summary.byDecision);
72
+ if (decisionEntries.length > 0) {
73
+ lines.push("PreToolUse decisions:");
74
+ for (const [decision, count] of decisionEntries.sort(
75
+ (a, b) => b[1] - a[1]
76
+ )) {
77
+ lines.push(` ${decision.padEnd(18)} ${String(count).padStart(5)}`);
78
+ }
79
+ lines.push("");
80
+ }
81
+ if (summary.readDenyCount > 0) {
82
+ lines.push(
83
+ `Estimated tokens saved: ~${formatThousands(summary.estimatedTokensSaved)}`
84
+ );
85
+ lines.push(
86
+ ` (${summary.readDenyCount} Read denies \xD7 ${ESTIMATED_TOKENS_PER_READ_DENY} tok/deny avg)`
87
+ );
88
+ } else {
89
+ lines.push("Estimated tokens saved: 0");
90
+ lines.push(" (no PreToolUse:Read denies recorded yet)");
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+
95
+ export {
96
+ ESTIMATED_TOKENS_PER_READ_DENY,
97
+ summarizeHookLog,
98
+ formatStatsSummary
99
+ };
@@ -0,0 +1,232 @@
1
+ import {
2
+ cachePath,
3
+ isNewer
4
+ } from "./chunk-RM2TBOVW.js";
5
+ import {
6
+ refreshComponentStatus
7
+ } from "./chunk-G4U3QOOW.js";
8
+
9
+ // src/doctor/report.ts
10
+ import chalk from "chalk";
11
+ import { existsSync, readFileSync, statSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir, platform, release } from "os";
14
+ function checkGraphDb(projectRoot) {
15
+ const path = join(projectRoot, ".engram", "graph.db");
16
+ if (!existsSync(path)) {
17
+ return {
18
+ name: "graph",
19
+ severity: "fail",
20
+ detail: "No graph at .engram/graph.db",
21
+ remediation: "Run `engram init` (or `engram setup` for the wizard)."
22
+ };
23
+ }
24
+ try {
25
+ const size = statSync(path).size;
26
+ const sizeMb = (size / 1024 / 1024).toFixed(2);
27
+ return {
28
+ name: "graph",
29
+ severity: "ok",
30
+ detail: `.engram/graph.db present (${sizeMb} MB)`
31
+ };
32
+ } catch {
33
+ return {
34
+ name: "graph",
35
+ severity: "warn",
36
+ detail: "graph.db exists but stat() failed",
37
+ remediation: "Check file permissions on .engram/graph.db"
38
+ };
39
+ }
40
+ }
41
+ function checkHook(projectRoot) {
42
+ const candidates = [
43
+ join(projectRoot, ".claude", "settings.local.json"),
44
+ join(projectRoot, ".claude", "settings.json"),
45
+ join(homedir(), ".claude", "settings.json")
46
+ ];
47
+ for (const path of candidates) {
48
+ if (!existsSync(path)) continue;
49
+ try {
50
+ const content = readFileSync(path, "utf-8");
51
+ if (content.includes("engram intercept")) {
52
+ return {
53
+ name: "hook",
54
+ severity: "ok",
55
+ detail: `Sentinel hook active (via ${path.replace(homedir(), "~")})`
56
+ };
57
+ }
58
+ } catch {
59
+ }
60
+ }
61
+ return {
62
+ name: "hook",
63
+ severity: "warn",
64
+ detail: "Sentinel hook not found in any .claude/settings*.json",
65
+ remediation: "Run `engram install-hook` to enable automatic Read interception."
66
+ };
67
+ }
68
+ function componentToCheck(c) {
69
+ if (c.available) {
70
+ return {
71
+ name: c.name,
72
+ severity: "ok",
73
+ detail: `${c.name.toUpperCase()} provider reachable`
74
+ };
75
+ }
76
+ const remediationByName = {
77
+ http: "Run `engram server --http` to start the local API.",
78
+ lsp: "LSP is best-effort \u2014 install a language server (typescript-language-server, pyright, rust-analyzer).",
79
+ ast: "Tree-sitter grammars missing. Reinstall engram: `engram update` or `npm install -g engramx@latest`."
80
+ };
81
+ return {
82
+ name: c.name,
83
+ severity: c.name === "ast" ? "fail" : "warn",
84
+ detail: `${c.name.toUpperCase()} provider unavailable`,
85
+ remediation: remediationByName[c.name]
86
+ };
87
+ }
88
+ function checkVersion(engramVersion) {
89
+ try {
90
+ const path = cachePath();
91
+ if (!existsSync(path)) {
92
+ return {
93
+ name: "version",
94
+ severity: "ok",
95
+ detail: `engram v${engramVersion} (no update check cached yet)`
96
+ };
97
+ }
98
+ const cached = JSON.parse(readFileSync(path, "utf-8"));
99
+ if (typeof cached?.latest === "string" && cached.latest !== engramVersion && isNewer(cached.latest, engramVersion)) {
100
+ return {
101
+ name: "version",
102
+ severity: "warn",
103
+ detail: `engram v${engramVersion} \u2014 v${cached.latest} is available`,
104
+ remediation: "Run `engram update` to upgrade."
105
+ };
106
+ }
107
+ return {
108
+ name: "version",
109
+ severity: "ok",
110
+ detail: `engram v${engramVersion} (latest)`
111
+ };
112
+ } catch {
113
+ return {
114
+ name: "version",
115
+ severity: "ok",
116
+ detail: `engram v${engramVersion}`
117
+ };
118
+ }
119
+ }
120
+ function checkIdes(ideCount) {
121
+ if (ideCount === 0) {
122
+ return {
123
+ name: "ides",
124
+ severity: "warn",
125
+ detail: "No IDE adapters detected",
126
+ remediation: "Run `engram gen-mdc` (Cursor), `gen-windsurfrules` (Windsurf), or `gen-aider` (Aider) to add IDE adapters."
127
+ };
128
+ }
129
+ return {
130
+ name: "ides",
131
+ severity: "ok",
132
+ detail: `${ideCount} IDE adapter${ideCount > 1 ? "s" : ""} configured`
133
+ };
134
+ }
135
+ function aggregate(checks) {
136
+ if (checks.some((c) => c.severity === "fail")) return "fail";
137
+ if (checks.some((c) => c.severity === "warn")) return "warn";
138
+ return "ok";
139
+ }
140
+ function buildReport(projectRoot, engramVersion) {
141
+ const components = refreshComponentStatus(projectRoot);
142
+ const checks = [
143
+ checkVersion(engramVersion),
144
+ checkGraphDb(projectRoot),
145
+ checkHook(projectRoot),
146
+ ...components.components.map(componentToCheck),
147
+ checkIdes(components.ideCount)
148
+ ];
149
+ return {
150
+ projectRoot,
151
+ engramVersion,
152
+ nodeVersion: process.version,
153
+ os: `${platform()} ${release()}`,
154
+ checks,
155
+ overallSeverity: aggregate(checks),
156
+ generatedAt: Date.now()
157
+ };
158
+ }
159
+ function icon(sev) {
160
+ switch (sev) {
161
+ case "ok":
162
+ return chalk.green("\u2713");
163
+ case "warn":
164
+ return chalk.yellow("\u26A0");
165
+ case "fail":
166
+ return chalk.red("\u2717");
167
+ }
168
+ }
169
+ function formatReport(report, verbose) {
170
+ const lines = [];
171
+ lines.push("");
172
+ lines.push(chalk.bold(`\u{1FA7A} engram doctor \u2014 ${report.projectRoot}`));
173
+ lines.push(
174
+ chalk.dim(
175
+ ` engram v${report.engramVersion} \xB7 Node ${report.nodeVersion} \xB7 ${report.os}`
176
+ )
177
+ );
178
+ lines.push("");
179
+ for (const c of report.checks) {
180
+ lines.push(` ${icon(c.severity)} ${chalk.bold(c.name.padEnd(8))} ${c.detail}`);
181
+ if (verbose && c.remediation && c.severity !== "ok") {
182
+ lines.push(` ${chalk.dim("\u2192 " + c.remediation)}`);
183
+ }
184
+ }
185
+ lines.push("");
186
+ switch (report.overallSeverity) {
187
+ case "ok":
188
+ lines.push(chalk.green(" All systems green."));
189
+ break;
190
+ case "warn":
191
+ lines.push(
192
+ chalk.yellow(
193
+ " Working, with warnings. Run `engram doctor --verbose` for remediation."
194
+ )
195
+ );
196
+ break;
197
+ case "fail":
198
+ lines.push(
199
+ chalk.red(
200
+ " Critical components missing. Run `engram doctor --verbose` for fixes."
201
+ )
202
+ );
203
+ break;
204
+ }
205
+ lines.push("");
206
+ return lines.join("\n");
207
+ }
208
+ function exportReport(report) {
209
+ return JSON.stringify(
210
+ {
211
+ engramVersion: report.engramVersion,
212
+ nodeVersion: report.nodeVersion,
213
+ os: report.os,
214
+ overallSeverity: report.overallSeverity,
215
+ checks: report.checks.map((c) => ({
216
+ name: c.name,
217
+ severity: c.severity,
218
+ detail: c.detail
219
+ })),
220
+ generatedAt: new Date(report.generatedAt).toISOString()
221
+ // NOTE: projectRoot intentionally omitted — can contain usernames.
222
+ },
223
+ null,
224
+ 2
225
+ );
226
+ }
227
+
228
+ export {
229
+ buildReport,
230
+ formatReport,
231
+ exportReport
232
+ };
@@ -147,6 +147,19 @@ var GraphStore = class _GraphStore {
147
147
  throw e;
148
148
  }
149
149
  }
150
+ countBySourceFile(sourceFile) {
151
+ const stmt = this.db.prepare(
152
+ "SELECT COUNT(*) AS n FROM nodes WHERE source_file = ?"
153
+ );
154
+ stmt.bind([sourceFile]);
155
+ let count = 0;
156
+ if (stmt.step()) {
157
+ const row = stmt.getAsObject();
158
+ count = Number(row.n) || 0;
159
+ }
160
+ stmt.free();
161
+ return count;
162
+ }
150
163
  bulkUpsert(nodes, edges) {
151
164
  this.db.run("BEGIN TRANSACTION");
152
165
  for (const node of nodes) this.upsertNode(node);
@@ -535,6 +548,9 @@ function truncateGraphemeSafe(s, max) {
535
548
  if (code >= 55296 && code <= 56319) cut--;
536
549
  return s.slice(0, cut) + "\u2026";
537
550
  }
551
+ function formatThousands(n) {
552
+ return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
553
+ }
538
554
 
539
555
  // src/graph/query.ts
540
556
  var MISTAKE_SCORE_BOOST = 2.5;
@@ -2163,6 +2179,7 @@ export {
2163
2179
  GraphStore,
2164
2180
  sliceGraphemeSafe,
2165
2181
  truncateGraphemeSafe,
2182
+ formatThousands,
2166
2183
  MAX_MISTAKE_LABEL_CHARS,
2167
2184
  queryGraph,
2168
2185
  shortestPath,