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.
- package/CHANGELOG.md +288 -0
- package/README.md +55 -0
- package/dist/{aider-context-BC5R2ZTA.js → aider-context-J557IHIP.js} +1 -1
- package/dist/auth-KB2ZRMS3.js +14 -0
- package/dist/check-2Z3MPZEJ.js +12 -0
- package/dist/{chunk-C6GBUOAL.js → chunk-4XA6ENNL.js} +1 -1
- package/dist/{chunk-533LR4I7.js → chunk-G4U3QOOW.js} +13 -97
- package/dist/chunk-N6PPKOPK.js +105 -0
- package/dist/chunk-RM2TBOVW.js +121 -0
- package/dist/chunk-SMU4WR3D.js +187 -0
- package/dist/chunk-XFE6ZANP.js +99 -0
- package/dist/chunk-XVYE4OX2.js +232 -0
- package/dist/{chunk-SJT7VS2G.js → chunk-ZVWRIVWQ.js} +17 -0
- package/dist/cli.js +593 -322
- package/dist/{core-6IY5L6II.js → core-TSXA5XZH.js} +1 -1
- package/dist/{cursor-mdc-GJ7E5LDD.js → cursor-mdc-VEOFFDVO.js} +1 -1
- package/dist/{exporter-GWU2GF23.js → exporter-AWXS34AS.js} +1 -1
- package/dist/{importer-V62NGZRK.js → importer-3Q5M6QBL.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/install-YVMVCFQW.js +121 -0
- package/dist/notify-5POGKMRX.js +36 -0
- package/dist/report-C3GTM3HY.js +12 -0
- package/dist/serve.js +4 -3
- package/dist/{server-6AOI7NQP.js → server-A6MUVKQK.js} +127 -31
- package/dist/{windsurf-rules-C7SVDHBL.js → windsurf-rules-RWPKBHRD.js} +1 -1
- package/dist/wizard-AOXWMSXW.js +274 -0
- package/package.json +1 -1
|
@@ -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,
|