engramx 0.2.1 → 0.3.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/dist/cli.js CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  install,
5
5
  status,
6
6
  uninstall
7
- } from "./chunk-YQ3FPGPC.js";
7
+ } from "./chunk-IYO4HETA.js";
8
8
  import {
9
9
  benchmark,
10
+ computeKeywordIDF,
11
+ getFileContext,
10
12
  godNodes,
11
13
  init,
12
14
  learn,
@@ -14,13 +16,1235 @@ import {
14
16
  path,
15
17
  query,
16
18
  stats
17
- } from "./chunk-22ADJYQ5.js";
19
+ } from "./chunk-RGDHLGWQ.js";
18
20
 
19
21
  // src/cli.ts
20
22
  import { Command } from "commander";
21
23
  import chalk from "chalk";
24
+ import {
25
+ existsSync as existsSync6,
26
+ readFileSync as readFileSync4,
27
+ writeFileSync as writeFileSync2,
28
+ mkdirSync,
29
+ unlinkSync,
30
+ copyFileSync,
31
+ renameSync as renameSync3
32
+ } from "fs";
33
+ import { dirname as dirname3, join as join6, resolve as pathResolve } from "path";
34
+ import { homedir } from "os";
35
+
36
+ // src/intercept/safety.ts
37
+ import { existsSync } from "fs";
38
+ import { join } from "path";
39
+ var PASSTHROUGH = null;
40
+ var DEFAULT_HANDLER_TIMEOUT_MS = 2e3;
41
+ async function withTimeout(promise, ms = DEFAULT_HANDLER_TIMEOUT_MS) {
42
+ let timer;
43
+ const timeout = new Promise((resolve3) => {
44
+ timer = setTimeout(() => resolve3(PASSTHROUGH), ms);
45
+ });
46
+ try {
47
+ return await Promise.race([promise, timeout]);
48
+ } finally {
49
+ if (timer !== void 0) clearTimeout(timer);
50
+ }
51
+ }
52
+ async function wrapSafely(handler, onError) {
53
+ try {
54
+ return await handler();
55
+ } catch (err) {
56
+ if (onError) {
57
+ try {
58
+ onError(err);
59
+ } catch {
60
+ }
61
+ }
62
+ return PASSTHROUGH;
63
+ }
64
+ }
65
+ async function runHandler(handler, opts = {}) {
66
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS;
67
+ return wrapSafely(() => withTimeout(handler(), timeoutMs), opts.onError);
68
+ }
69
+ function isHookDisabled(projectRoot) {
70
+ if (projectRoot === null) return true;
71
+ try {
72
+ return existsSync(join(projectRoot, ".engram", "hook-disabled"));
73
+ } catch {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ // src/intercept/context.ts
79
+ import { existsSync as existsSync2, realpathSync, statSync } from "fs";
80
+ import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
81
+ var ENGRAM_DIR = ".engram";
82
+ var GRAPH_FILE = "graph.db";
83
+ var MAX_WALK_DEPTH = 40;
84
+ var projectRootCache = /* @__PURE__ */ new Map();
85
+ function normalizePath(filePath, cwd) {
86
+ if (!filePath) return "";
87
+ try {
88
+ const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
89
+ return resolve(abs);
90
+ } catch {
91
+ return "";
92
+ }
93
+ }
94
+ function isExemptPath(absPath) {
95
+ if (!absPath) return true;
96
+ const p = absPath.replaceAll(sep, "/");
97
+ if (p.includes("/.engram/cache/")) return true;
98
+ if (p.includes("/node_modules/")) return true;
99
+ if (p.includes("/.git/")) return true;
100
+ return false;
101
+ }
102
+ function isHardSystemPath(absPath) {
103
+ if (!absPath) return true;
104
+ const p = absPath.replaceAll(sep, "/");
105
+ if (p === "/" || p.startsWith("/dev/") || p.startsWith("/proc/")) return true;
106
+ if (p.startsWith("/sys/")) return true;
107
+ return false;
108
+ }
109
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
110
+ // Images
111
+ ".png",
112
+ ".jpg",
113
+ ".jpeg",
114
+ ".gif",
115
+ ".webp",
116
+ ".ico",
117
+ ".bmp",
118
+ ".tiff",
119
+ ".svg",
120
+ // Documents
121
+ ".pdf",
122
+ ".doc",
123
+ ".docx",
124
+ ".xls",
125
+ ".xlsx",
126
+ ".ppt",
127
+ ".pptx",
128
+ // Archives
129
+ ".zip",
130
+ ".tar",
131
+ ".gz",
132
+ ".tgz",
133
+ ".bz2",
134
+ ".xz",
135
+ ".7z",
136
+ ".rar",
137
+ // Compiled / binary code
138
+ ".exe",
139
+ ".dll",
140
+ ".so",
141
+ ".dylib",
142
+ ".o",
143
+ ".a",
144
+ ".class",
145
+ ".wasm",
146
+ // Audio / video
147
+ ".mp3",
148
+ ".mp4",
149
+ ".m4a",
150
+ ".wav",
151
+ ".flac",
152
+ ".ogg",
153
+ ".avi",
154
+ ".mov",
155
+ ".mkv",
156
+ ".webm",
157
+ // Data blobs
158
+ ".bin",
159
+ ".dat",
160
+ ".db",
161
+ ".sqlite",
162
+ ".parquet",
163
+ // Fonts
164
+ ".ttf",
165
+ ".otf",
166
+ ".woff",
167
+ ".woff2",
168
+ ".eot"
169
+ ]);
170
+ function isBinaryFile(absPath) {
171
+ if (!absPath) return false;
172
+ const dot = absPath.lastIndexOf(".");
173
+ if (dot === -1) return false;
174
+ const ext = absPath.slice(dot).toLowerCase();
175
+ return BINARY_EXTENSIONS.has(ext);
176
+ }
177
+ function isSecretFile(absPath) {
178
+ if (!absPath) return false;
179
+ const idx = Math.max(absPath.lastIndexOf("/"), absPath.lastIndexOf(sep));
180
+ const base = idx === -1 ? absPath : absPath.slice(idx + 1);
181
+ const lower = base.toLowerCase();
182
+ if (lower === ".env") return true;
183
+ if (lower.startsWith(".env.")) return true;
184
+ if (lower === "secrets.json" || lower === "secrets.yaml" || lower === "secrets.yml") return true;
185
+ if (lower === "credentials" || lower === "credentials.json") return true;
186
+ if (lower === "config.secret.json") return true;
187
+ if (lower.endsWith(".pem")) return true;
188
+ if (lower.endsWith(".key")) return true;
189
+ if (lower.endsWith(".p12")) return true;
190
+ if (lower.endsWith(".pfx")) return true;
191
+ if (lower.endsWith(".keystore")) return true;
192
+ if (lower === "id_rsa" || lower === "id_ed25519" || lower === "id_ecdsa" || lower === "id_dsa") {
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ function isContentUnsafeForIntercept(absPath) {
198
+ return isBinaryFile(absPath) || isSecretFile(absPath);
199
+ }
200
+ function findProjectRoot(filePath) {
201
+ if (!filePath) return null;
202
+ let startDir;
203
+ try {
204
+ const st = statSync(filePath);
205
+ startDir = st.isDirectory() ? filePath : dirname(filePath);
206
+ } catch {
207
+ startDir = dirname(filePath);
208
+ }
209
+ if (projectRootCache.has(startDir)) {
210
+ return projectRootCache.get(startDir) ?? null;
211
+ }
212
+ let current = startDir;
213
+ let depth = 0;
214
+ while (depth < MAX_WALK_DEPTH) {
215
+ const candidate = join2(current, ENGRAM_DIR, GRAPH_FILE);
216
+ try {
217
+ if (existsSync2(candidate)) {
218
+ projectRootCache.set(startDir, current);
219
+ return current;
220
+ }
221
+ } catch {
222
+ }
223
+ const parent = dirname(current);
224
+ if (parent === current) {
225
+ break;
226
+ }
227
+ current = parent;
228
+ depth += 1;
229
+ }
230
+ projectRootCache.set(startDir, null);
231
+ return null;
232
+ }
233
+ function isInsideProject(filePath, projectRoot) {
234
+ if (!filePath || !projectRoot) return false;
235
+ try {
236
+ const realRoot = realpathSync(projectRoot);
237
+ let realFile;
238
+ try {
239
+ realFile = realpathSync(filePath);
240
+ } catch {
241
+ realFile = resolve(filePath);
242
+ }
243
+ const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
244
+ return realFile === realRoot || realFile.startsWith(rootWithSep);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ function isValidCwd(cwd) {
250
+ if (!cwd || typeof cwd !== "string") return false;
251
+ if (!isAbsolute(cwd)) return false;
252
+ try {
253
+ return statSync(cwd).isDirectory();
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+ function resolveInterceptContext(filePath, cwd) {
259
+ if (!filePath) return { proceed: false, reason: "empty-path" };
260
+ const absPath = normalizePath(filePath, cwd);
261
+ if (!absPath) return { proceed: false, reason: "normalize-failed" };
262
+ if (isHardSystemPath(absPath)) {
263
+ return { proceed: false, reason: "system-path" };
264
+ }
265
+ const projectRoot = findProjectRoot(absPath);
266
+ if (projectRoot === null) {
267
+ return { proceed: false, reason: "no-project-root" };
268
+ }
269
+ if (!isInsideProject(absPath, projectRoot)) {
270
+ return { proceed: false, reason: "outside-project" };
271
+ }
272
+ if (isExemptPath(absPath)) {
273
+ return { proceed: false, reason: "exempt-path" };
274
+ }
275
+ return { proceed: true, absPath, projectRoot };
276
+ }
277
+
278
+ // src/intercept/formatter.ts
279
+ var MAX_RESPONSE_CHARS = 8e3;
280
+ var TRUNCATION_MARKER = "\n\n[... engram summary truncated to fit hook response limit ...]";
281
+ function truncateForHook(text) {
282
+ if (!text) return "";
283
+ if (text.length <= MAX_RESPONSE_CHARS) return text;
284
+ const budget = MAX_RESPONSE_CHARS - TRUNCATION_MARKER.length;
285
+ let cut = budget;
286
+ const code = text.charCodeAt(cut - 1);
287
+ if (code >= 55296 && code <= 56319) cut -= 1;
288
+ return text.slice(0, cut) + TRUNCATION_MARKER;
289
+ }
290
+ function buildDenyResponse(reason) {
291
+ return {
292
+ hookSpecificOutput: {
293
+ hookEventName: "PreToolUse",
294
+ permissionDecision: "deny",
295
+ permissionDecisionReason: truncateForHook(reason)
296
+ }
297
+ };
298
+ }
299
+ function buildAllowWithContextResponse(additionalContext) {
300
+ const trimmed = additionalContext?.trim() ?? "";
301
+ if (trimmed.length === 0) {
302
+ return {
303
+ hookSpecificOutput: {
304
+ hookEventName: "PreToolUse",
305
+ permissionDecision: "allow"
306
+ }
307
+ };
308
+ }
309
+ return {
310
+ hookSpecificOutput: {
311
+ hookEventName: "PreToolUse",
312
+ permissionDecision: "allow",
313
+ additionalContext: truncateForHook(trimmed)
314
+ }
315
+ };
316
+ }
317
+ function buildSessionContextResponse(eventName, additionalContext) {
318
+ const trimmed = additionalContext?.trim() ?? "";
319
+ if (trimmed.length === 0) return null;
320
+ return {
321
+ hookSpecificOutput: {
322
+ hookEventName: eventName,
323
+ additionalContext: truncateForHook(trimmed)
324
+ }
325
+ };
326
+ }
327
+
328
+ // src/intercept/handlers/read.ts
329
+ var READ_CONFIDENCE_THRESHOLD = 0.7;
330
+ async function handleRead(payload) {
331
+ if (payload.tool_name !== "Read") return PASSTHROUGH;
332
+ const filePath = payload.tool_input?.file_path;
333
+ if (!filePath || typeof filePath !== "string") return PASSTHROUGH;
334
+ const offset = payload.tool_input.offset;
335
+ const limit = payload.tool_input.limit;
336
+ if (typeof offset === "number" && offset > 0 || typeof limit === "number" && limit > 0) {
337
+ return PASSTHROUGH;
338
+ }
339
+ if (isContentUnsafeForIntercept(filePath)) return PASSTHROUGH;
340
+ const ctx = resolveInterceptContext(filePath, payload.cwd);
341
+ if (!ctx.proceed) return PASSTHROUGH;
342
+ if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
343
+ if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
344
+ const fileCtx = await getFileContext(ctx.projectRoot, ctx.absPath);
345
+ if (!fileCtx.found || fileCtx.codeNodeCount === 0) return PASSTHROUGH;
346
+ if (fileCtx.isStale) return PASSTHROUGH;
347
+ if (fileCtx.confidence < READ_CONFIDENCE_THRESHOLD) return PASSTHROUGH;
348
+ return buildDenyResponse(fileCtx.summary);
349
+ }
350
+
351
+ // src/intercept/handlers/edit-write.ts
352
+ import { relative, resolve as resolvePath } from "path";
353
+ var MAX_LANDMINES_IN_WARNING = 5;
354
+ function formatLandmineWarning(projectRelativeFile, mistakeList) {
355
+ const header = `[engram landmines] ${mistakeList.length} past mistake${mistakeList.length === 1 ? "" : "s"} recorded for ${projectRelativeFile}:`;
356
+ const items = mistakeList.map((m) => {
357
+ const conf = m.confidence === "EXTRACTED" ? "" : ` [${m.confidence.toLowerCase()}]`;
358
+ return ` - ${m.label}${conf}`;
359
+ });
360
+ const footer = "Review these before editing to avoid re-introducing a known failure mode. engram recorded these from past session notes (bug:/fix: lines in your CLAUDE.md).";
361
+ return [header, ...items, "", footer].join("\n");
362
+ }
363
+ async function handleEditOrWrite(payload) {
364
+ if (payload.tool_name !== "Edit" && payload.tool_name !== "Write") {
365
+ return PASSTHROUGH;
366
+ }
367
+ const filePath = payload.tool_input?.file_path;
368
+ if (!filePath || typeof filePath !== "string") return PASSTHROUGH;
369
+ if (isContentUnsafeForIntercept(filePath)) return PASSTHROUGH;
370
+ const ctx = resolveInterceptContext(filePath, payload.cwd);
371
+ if (!ctx.proceed) return PASSTHROUGH;
372
+ if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
373
+ if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
374
+ const relPath = relative(resolvePath(ctx.projectRoot), ctx.absPath);
375
+ if (!relPath || relPath.startsWith("..")) return PASSTHROUGH;
376
+ let found;
377
+ try {
378
+ found = await mistakes(ctx.projectRoot, {
379
+ sourceFile: relPath,
380
+ limit: MAX_LANDMINES_IN_WARNING
381
+ });
382
+ } catch {
383
+ return PASSTHROUGH;
384
+ }
385
+ if (found.length === 0) return PASSTHROUGH;
386
+ const warning = formatLandmineWarning(relPath, found);
387
+ return buildAllowWithContextResponse(warning);
388
+ }
389
+
390
+ // src/intercept/handlers/bash.ts
391
+ var READ_LIKE_COMMANDS = /* @__PURE__ */ new Set([
392
+ "cat",
393
+ "head",
394
+ "tail",
395
+ "less",
396
+ "more"
397
+ ]);
398
+ var UNSAFE_SHELL_CHARS = /[|&;<>()$`\\*?[\]{}"']/;
399
+ function parseReadLikeBashCommand(command) {
400
+ if (!command || typeof command !== "string") return null;
401
+ if (command.length > 200) return null;
402
+ const trimmed = command.trim();
403
+ if (trimmed !== command) {
404
+ return null;
405
+ }
406
+ if (UNSAFE_SHELL_CHARS.test(trimmed)) return null;
407
+ const tokens = trimmed.split(/\s+/);
408
+ if (tokens.length !== 2) return null;
409
+ const [cmd, path2] = tokens;
410
+ if (!READ_LIKE_COMMANDS.has(cmd)) return null;
411
+ if (path2.startsWith("-")) return null;
412
+ if (path2.length === 0) return null;
413
+ if (path2.includes("\0")) return null;
414
+ return path2;
415
+ }
416
+ async function handleBash(payload) {
417
+ if (payload.tool_name !== "Bash") return PASSTHROUGH;
418
+ const command = payload.tool_input?.command;
419
+ if (!command || typeof command !== "string") return PASSTHROUGH;
420
+ const filePath = parseReadLikeBashCommand(command);
421
+ if (filePath === null) return PASSTHROUGH;
422
+ return handleRead({
423
+ tool_name: "Read",
424
+ cwd: payload.cwd,
425
+ tool_input: {
426
+ file_path: filePath
427
+ }
428
+ });
429
+ }
430
+
431
+ // src/intercept/handlers/session-start.ts
432
+ import { existsSync as existsSync3, readFileSync } from "fs";
433
+ import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "path";
434
+ var MAX_GOD_NODES = 10;
435
+ var MAX_LANDMINES_IN_BRIEF = 3;
436
+ function readGitBranch(projectRoot) {
437
+ try {
438
+ let current = resolve2(projectRoot);
439
+ for (let depth = 0; depth < 10; depth++) {
440
+ const headPath = join3(current, ".git", "HEAD");
441
+ if (existsSync3(headPath)) {
442
+ const content = readFileSync(headPath, "utf-8").trim();
443
+ const refMatch = content.match(/^ref:\s+refs\/heads\/(.+)$/);
444
+ if (refMatch) return refMatch[1];
445
+ if (/^[0-9a-f]{7,40}$/i.test(content)) return "detached";
446
+ return null;
447
+ }
448
+ const parent = dirname2(current);
449
+ if (parent === current) return null;
450
+ current = parent;
451
+ }
452
+ return null;
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+ function formatBrief(args) {
458
+ const lines = [];
459
+ const minedAgo = args.stats.lastMined > 0 ? describeAgo(Date.now() - args.stats.lastMined) : "unknown";
460
+ const branchStr = args.branch ? ` (branch: ${args.branch})` : "";
461
+ lines.push(`[engram] Project brief for ${args.projectName}${branchStr}`);
462
+ lines.push(
463
+ `Graph: ${args.stats.nodes} nodes, ${args.stats.edges} edges, ${args.stats.extractedPct}% extracted. Last mined: ${minedAgo}.`
464
+ );
465
+ lines.push("");
466
+ if (args.godNodes.length > 0) {
467
+ lines.push("Core entities (most connected):");
468
+ for (const g of args.godNodes) {
469
+ lines.push(
470
+ ` - ${g.label} [${g.kind}] (${g.degree} conn) \u2014 ${g.sourceFile}`
471
+ );
472
+ }
473
+ lines.push("");
474
+ }
475
+ if (args.landmines.length > 0) {
476
+ lines.push("Known landmines (past issues to watch for):");
477
+ for (const m of args.landmines) {
478
+ lines.push(` - ${m.sourceFile}: ${m.label}`);
479
+ }
480
+ lines.push("");
481
+ }
482
+ lines.push(
483
+ "Tip: engram intercepts Read/Edit/Write/Bash tool calls. Code structure comes through the graph automatically; you don't need to explore files to understand layout. Use explicit offset/limit on Read if you need raw lines."
484
+ );
485
+ return lines.join("\n");
486
+ }
487
+ function describeAgo(ms) {
488
+ if (ms < 0) return "just now";
489
+ const s = Math.floor(ms / 1e3);
490
+ if (s < 60) return `${s}s ago`;
491
+ const m = Math.floor(s / 60);
492
+ if (m < 60) return `${m}m ago`;
493
+ const h = Math.floor(m / 60);
494
+ if (h < 24) return `${h}h ago`;
495
+ const d = Math.floor(h / 24);
496
+ return `${d}d ago`;
497
+ }
498
+ async function handleSessionStart(payload) {
499
+ if (payload.hook_event_name !== "SessionStart") return PASSTHROUGH;
500
+ const source = payload.source ?? "startup";
501
+ if (source === "resume") return PASSTHROUGH;
502
+ const cwd = payload.cwd;
503
+ if (!isValidCwd(cwd)) return PASSTHROUGH;
504
+ const projectRoot = findProjectRoot(cwd);
505
+ if (projectRoot === null) return PASSTHROUGH;
506
+ if (isHookDisabled(projectRoot)) return PASSTHROUGH;
507
+ try {
508
+ const [gods, mistakeList, graphStats] = await Promise.all([
509
+ godNodes(projectRoot, MAX_GOD_NODES).catch(() => []),
510
+ mistakes(projectRoot, { limit: MAX_LANDMINES_IN_BRIEF }).catch(
511
+ () => []
512
+ ),
513
+ stats(projectRoot).catch(() => ({
514
+ nodes: 0,
515
+ edges: 0,
516
+ communities: 0,
517
+ extractedPct: 0,
518
+ inferredPct: 0,
519
+ ambiguousPct: 0,
520
+ lastMined: 0,
521
+ totalQueryTokensSaved: 0
522
+ }))
523
+ ]);
524
+ if (graphStats.nodes === 0 && gods.length === 0) return PASSTHROUGH;
525
+ const branch = readGitBranch(projectRoot);
526
+ const projectName = basename(projectRoot);
527
+ const text = formatBrief({
528
+ projectName,
529
+ branch,
530
+ stats: {
531
+ nodes: graphStats.nodes,
532
+ edges: graphStats.edges,
533
+ extractedPct: graphStats.extractedPct,
534
+ lastMined: graphStats.lastMined
535
+ },
536
+ godNodes: gods,
537
+ landmines: mistakeList.map((m) => ({
538
+ label: m.label,
539
+ sourceFile: m.sourceFile
540
+ }))
541
+ });
542
+ return buildSessionContextResponse("SessionStart", text);
543
+ } catch {
544
+ return PASSTHROUGH;
545
+ }
546
+ }
547
+
548
+ // src/intercept/handlers/user-prompt.ts
549
+ var MIN_SIGNIFICANT_TERMS = 2;
550
+ var MIN_MATCHED_NODES = 3;
551
+ var PROMPT_INJECTION_TOKEN_BUDGET = 500;
552
+ var MIN_IDF_THRESHOLD = 1.386;
553
+ var MAX_SEED_KEYWORDS = 5;
554
+ var STOPWORDS = /* @__PURE__ */ new Set([
555
+ "a",
556
+ "an",
557
+ "the",
558
+ "is",
559
+ "are",
560
+ "was",
561
+ "were",
562
+ "be",
563
+ "been",
564
+ "being",
565
+ "have",
566
+ "has",
567
+ "had",
568
+ "do",
569
+ "does",
570
+ "did",
571
+ "will",
572
+ "would",
573
+ "could",
574
+ "should",
575
+ "may",
576
+ "might",
577
+ "must",
578
+ "can",
579
+ "shall",
580
+ "to",
581
+ "of",
582
+ "for",
583
+ "in",
584
+ "on",
585
+ "at",
586
+ "by",
587
+ "with",
588
+ "from",
589
+ "and",
590
+ "or",
591
+ "but",
592
+ "not",
593
+ "no",
594
+ "this",
595
+ "that",
596
+ "these",
597
+ "those",
598
+ "i",
599
+ "you",
600
+ "he",
601
+ "she",
602
+ "it",
603
+ "we",
604
+ "they",
605
+ "my",
606
+ "your",
607
+ "his",
608
+ "her",
609
+ "its",
610
+ "our",
611
+ "their",
612
+ "me",
613
+ "him",
614
+ "us",
615
+ "them",
616
+ "as",
617
+ "if",
618
+ "when",
619
+ "where",
620
+ "why",
621
+ "how",
622
+ "what",
623
+ "which",
624
+ "who",
625
+ "whom",
626
+ "so",
627
+ "than",
628
+ "then",
629
+ "just"
630
+ ]);
631
+ function extractKeywords(prompt) {
632
+ if (!prompt || typeof prompt !== "string") return [];
633
+ const tokens = prompt.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length >= 3).filter((t) => !STOPWORDS.has(t));
634
+ const seen = /* @__PURE__ */ new Set();
635
+ const result = [];
636
+ for (const t of tokens) {
637
+ if (seen.has(t)) continue;
638
+ seen.add(t);
639
+ result.push(t);
640
+ }
641
+ return result;
642
+ }
643
+ async function handleUserPromptSubmit(payload) {
644
+ if (payload.hook_event_name !== "UserPromptSubmit") return PASSTHROUGH;
645
+ const prompt = payload.prompt;
646
+ if (!prompt || typeof prompt !== "string") return PASSTHROUGH;
647
+ if (prompt.length > 8e3) return PASSTHROUGH;
648
+ const rawKeywords = extractKeywords(prompt);
649
+ if (rawKeywords.length < MIN_SIGNIFICANT_TERMS) return PASSTHROUGH;
650
+ const cwd = payload.cwd;
651
+ if (!isValidCwd(cwd)) return PASSTHROUGH;
652
+ const projectRoot = findProjectRoot(cwd);
653
+ if (projectRoot === null) return PASSTHROUGH;
654
+ if (isHookDisabled(projectRoot)) return PASSTHROUGH;
655
+ let keywords;
656
+ try {
657
+ const scored = await computeKeywordIDF(projectRoot, rawKeywords);
658
+ if (scored.length === 0) {
659
+ keywords = rawKeywords;
660
+ } else {
661
+ const discriminative = scored.filter((s) => s.idf >= MIN_IDF_THRESHOLD);
662
+ if (discriminative.length === 0) {
663
+ return PASSTHROUGH;
664
+ }
665
+ keywords = scored.filter((s) => s.idf > 0).slice(0, MAX_SEED_KEYWORDS).map((s) => s.keyword);
666
+ if (keywords.length === 0) {
667
+ keywords = rawKeywords;
668
+ }
669
+ }
670
+ } catch {
671
+ keywords = rawKeywords;
672
+ }
673
+ if (keywords.length === 0) return PASSTHROUGH;
674
+ let result;
675
+ try {
676
+ result = await query(projectRoot, keywords.join(" "), {
677
+ tokenBudget: PROMPT_INJECTION_TOKEN_BUDGET,
678
+ depth: 2
679
+ // Shallower than default (3) to keep injection focused.
680
+ });
681
+ } catch {
682
+ return PASSTHROUGH;
683
+ }
684
+ if (result.nodesFound < MIN_MATCHED_NODES) return PASSTHROUGH;
685
+ const header = `[engram] Pre-query context for this message (matched ${result.nodesFound} graph nodes):`;
686
+ const text = `${header}
687
+
688
+ ${result.text}`;
689
+ return buildSessionContextResponse("UserPromptSubmit", text);
690
+ }
691
+
692
+ // src/intelligence/hook-log.ts
693
+ import {
694
+ appendFileSync,
695
+ existsSync as existsSync4,
696
+ renameSync,
697
+ statSync as statSync2,
698
+ readFileSync as readFileSync2
699
+ } from "fs";
700
+ import { join as join4 } from "path";
701
+ var HOOK_LOG_MAX_BYTES = 10 * 1024 * 1024;
702
+ var LOG_FILENAME = "hook-log.jsonl";
703
+ var LOG_ROTATED_FILENAME = "hook-log.jsonl.1";
704
+ function logHookEvent(projectRoot, entry) {
705
+ if (!projectRoot) return;
706
+ try {
707
+ const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
708
+ rotateIfNeeded(projectRoot);
709
+ const line = JSON.stringify({
710
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
711
+ ...entry
712
+ }) + "\n";
713
+ appendFileSync(logPath, line);
714
+ } catch {
715
+ }
716
+ }
717
+ function rotateIfNeeded(projectRoot) {
718
+ try {
719
+ const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
720
+ if (!existsSync4(logPath)) return;
721
+ const size = statSync2(logPath).size;
722
+ if (size < HOOK_LOG_MAX_BYTES) return;
723
+ const rotatedPath = join4(projectRoot, ".engram", LOG_ROTATED_FILENAME);
724
+ renameSync(logPath, rotatedPath);
725
+ } catch {
726
+ }
727
+ }
728
+ function readHookLog(projectRoot) {
729
+ try {
730
+ const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
731
+ if (!existsSync4(logPath)) return [];
732
+ const raw = readFileSync2(logPath, "utf-8");
733
+ const entries = [];
734
+ for (const line of raw.split("\n")) {
735
+ if (!line.trim()) continue;
736
+ try {
737
+ entries.push(JSON.parse(line));
738
+ } catch {
739
+ }
740
+ }
741
+ return entries;
742
+ } catch {
743
+ return [];
744
+ }
745
+ }
746
+
747
+ // src/intercept/handlers/post-tool.ts
748
+ function extractFilePath(toolName, toolInput) {
749
+ if (!toolInput) return void 0;
750
+ if (toolName === "Read" || toolName === "Edit" || toolName === "Write") {
751
+ const fp = toolInput.file_path;
752
+ return typeof fp === "string" ? fp : void 0;
753
+ }
754
+ return void 0;
755
+ }
756
+ function estimateOutputSize(toolResponse) {
757
+ if (toolResponse === null || toolResponse === void 0) return 0;
758
+ if (typeof toolResponse === "string") return toolResponse.length;
759
+ if (typeof toolResponse === "object") {
760
+ const resp = toolResponse;
761
+ if (typeof resp.output === "string") return resp.output.length;
762
+ try {
763
+ return JSON.stringify(toolResponse).length;
764
+ } catch {
765
+ return 0;
766
+ }
767
+ }
768
+ return 0;
769
+ }
770
+ function detectError(toolResponse) {
771
+ if (toolResponse === null || toolResponse === void 0) return false;
772
+ if (typeof toolResponse === "object") {
773
+ const resp = toolResponse;
774
+ if (resp.error !== void 0 && resp.error !== null) return true;
775
+ }
776
+ return false;
777
+ }
778
+ async function handlePostTool(payload) {
779
+ if (payload.hook_event_name !== "PostToolUse") return PASSTHROUGH;
780
+ try {
781
+ const cwd = payload.cwd;
782
+ if (!isValidCwd(cwd)) return PASSTHROUGH;
783
+ const projectRoot = findProjectRoot(cwd);
784
+ if (projectRoot === null) return PASSTHROUGH;
785
+ if (isHookDisabled(projectRoot)) return PASSTHROUGH;
786
+ const toolName = payload.tool_name;
787
+ const filePath = extractFilePath(toolName, payload.tool_input);
788
+ const outputSize = estimateOutputSize(payload.tool_response);
789
+ const hasError = detectError(payload.tool_response);
790
+ logHookEvent(projectRoot, {
791
+ event: "PostToolUse",
792
+ tool: typeof toolName === "string" ? toolName : "unknown",
793
+ path: filePath,
794
+ outputSize,
795
+ success: !hasError
796
+ });
797
+ } catch {
798
+ }
799
+ return PASSTHROUGH;
800
+ }
801
+
802
+ // src/intercept/dispatch.ts
803
+ function validatePayload(raw) {
804
+ if (raw === null || typeof raw !== "object") return null;
805
+ const p = raw;
806
+ if (typeof p.hook_event_name !== "string") return null;
807
+ if (typeof p.cwd !== "string") return null;
808
+ if (p.tool_input !== void 0 && (p.tool_input === null || typeof p.tool_input !== "object")) {
809
+ return null;
810
+ }
811
+ return p;
812
+ }
813
+ async function dispatchHook(rawPayload) {
814
+ const payload = validatePayload(rawPayload);
815
+ if (payload === null) return PASSTHROUGH;
816
+ const event = payload.hook_event_name;
817
+ switch (event) {
818
+ case "PreToolUse":
819
+ return dispatchPreToolUse(payload);
820
+ case "SessionStart":
821
+ return runHandler(
822
+ () => handleSessionStart(payload)
823
+ );
824
+ case "UserPromptSubmit":
825
+ return runHandler(
826
+ () => handleUserPromptSubmit(payload)
827
+ );
828
+ case "PostToolUse":
829
+ return runHandler(
830
+ () => handlePostTool(payload)
831
+ );
832
+ default:
833
+ return PASSTHROUGH;
834
+ }
835
+ }
836
+ async function dispatchPreToolUse(payload) {
837
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
838
+ const handlerPayload = payload;
839
+ let result;
840
+ switch (tool) {
841
+ case "Read":
842
+ result = await runHandler(
843
+ () => handleRead(handlerPayload)
844
+ );
845
+ break;
846
+ case "Edit":
847
+ case "Write":
848
+ result = await runHandler(
849
+ () => handleEditOrWrite(handlerPayload)
850
+ );
851
+ break;
852
+ case "Bash":
853
+ result = await runHandler(
854
+ () => handleBash(handlerPayload)
855
+ );
856
+ break;
857
+ default:
858
+ return PASSTHROUGH;
859
+ }
860
+ try {
861
+ const cwd = handlerPayload.cwd;
862
+ if (isValidCwd(cwd)) {
863
+ const projectRoot = findProjectRoot(cwd);
864
+ if (projectRoot) {
865
+ const decision = extractPreToolDecision(result);
866
+ const filePath = typeof handlerPayload.tool_input?.file_path === "string" ? handlerPayload.tool_input.file_path : void 0;
867
+ logHookEvent(projectRoot, {
868
+ event: "PreToolUse",
869
+ tool,
870
+ path: filePath,
871
+ decision
872
+ });
873
+ }
874
+ }
875
+ } catch {
876
+ }
877
+ return result;
878
+ }
879
+ function extractPreToolDecision(result) {
880
+ if (result === null || result === void 0) return "passthrough";
881
+ try {
882
+ const r = result;
883
+ const d = r.hookSpecificOutput?.permissionDecision;
884
+ if (d === "deny") return "deny";
885
+ if (d === "allow") return "allow";
886
+ } catch {
887
+ }
888
+ return "passthrough";
889
+ }
890
+
891
+ // src/intercept/cursor-adapter.ts
892
+ var ALLOW = { permission: "allow" };
893
+ function toClaudeReadPayload(cursorPayload) {
894
+ const filePath = cursorPayload.file_path;
895
+ if (!filePath || typeof filePath !== "string") return null;
896
+ const workspaceRoot = Array.isArray(cursorPayload.workspace_roots) && cursorPayload.workspace_roots.length > 0 ? cursorPayload.workspace_roots[0] : process.cwd();
897
+ return {
898
+ tool_name: "Read",
899
+ tool_input: { file_path: filePath },
900
+ cwd: workspaceRoot
901
+ };
902
+ }
903
+ function extractSummaryFromClaudeResult(result) {
904
+ if (result === PASSTHROUGH || result === null) return null;
905
+ if (typeof result !== "object") return null;
906
+ const hookSpecific = result.hookSpecificOutput;
907
+ if (!hookSpecific || typeof hookSpecific !== "object") return null;
908
+ const reason = hookSpecific.permissionDecisionReason;
909
+ if (typeof reason !== "string" || reason.length === 0) return null;
910
+ return reason;
911
+ }
912
+ async function handleCursorBeforeReadFile(payload) {
913
+ try {
914
+ if (!payload || typeof payload !== "object") return ALLOW;
915
+ const claudePayload = toClaudeReadPayload(payload);
916
+ if (claudePayload === null) return ALLOW;
917
+ const result = await handleRead(claudePayload);
918
+ const summary = extractSummaryFromClaudeResult(result);
919
+ if (summary === null) return ALLOW;
920
+ return {
921
+ permission: "deny",
922
+ user_message: summary
923
+ };
924
+ } catch {
925
+ return ALLOW;
926
+ }
927
+ }
928
+
929
+ // src/intercept/installer.ts
930
+ var ENGRAM_HOOK_EVENTS = [
931
+ "PreToolUse",
932
+ "PostToolUse",
933
+ "SessionStart",
934
+ "UserPromptSubmit"
935
+ ];
936
+ var ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
937
+ var DEFAULT_ENGRAM_COMMAND = "engram intercept";
938
+ var DEFAULT_HOOK_TIMEOUT_SEC = 5;
939
+ function buildEngramHookEntries(command = DEFAULT_ENGRAM_COMMAND, timeout = DEFAULT_HOOK_TIMEOUT_SEC) {
940
+ const baseCmd = {
941
+ type: "command",
942
+ command,
943
+ timeout
944
+ };
945
+ return {
946
+ PreToolUse: {
947
+ matcher: ENGRAM_PRETOOL_MATCHER,
948
+ hooks: [baseCmd]
949
+ },
950
+ PostToolUse: {
951
+ // Match all tools — PostToolUse is an observer for any completion.
952
+ matcher: ".*",
953
+ hooks: [baseCmd]
954
+ },
955
+ SessionStart: {
956
+ // No matcher — SessionStart has no tool name.
957
+ hooks: [baseCmd]
958
+ },
959
+ UserPromptSubmit: {
960
+ // No matcher — UserPromptSubmit has no tool name.
961
+ hooks: [baseCmd]
962
+ }
963
+ };
964
+ }
965
+ function isEngramHookEntry(entry) {
966
+ if (entry === null || typeof entry !== "object") return false;
967
+ const e = entry;
968
+ if (!Array.isArray(e.hooks)) return false;
969
+ for (const h of e.hooks) {
970
+ if (h === null || typeof h !== "object") continue;
971
+ const cmd = h.command;
972
+ if (typeof cmd === "string" && cmd.includes("engram intercept")) {
973
+ return true;
974
+ }
975
+ }
976
+ return false;
977
+ }
978
+ function installEngramHooks(settings, command = DEFAULT_ENGRAM_COMMAND) {
979
+ const entries = buildEngramHookEntries(command);
980
+ const added = [];
981
+ const alreadyPresent = [];
982
+ const hooksClone = {};
983
+ const existingHooks = settings.hooks ?? {};
984
+ for (const [key, value] of Object.entries(existingHooks)) {
985
+ if (Array.isArray(value)) {
986
+ hooksClone[key] = value.map((entry) => ({ ...entry }));
987
+ }
988
+ }
989
+ for (const event of ENGRAM_HOOK_EVENTS) {
990
+ const eventArr = hooksClone[event] ?? [];
991
+ const hasEngram = eventArr.some((e) => isEngramHookEntry(e));
992
+ if (hasEngram) {
993
+ alreadyPresent.push(event);
994
+ hooksClone[event] = eventArr;
995
+ continue;
996
+ }
997
+ hooksClone[event] = [...eventArr, entries[event]];
998
+ added.push(event);
999
+ }
1000
+ return {
1001
+ updated: { ...settings, hooks: hooksClone },
1002
+ added,
1003
+ alreadyPresent
1004
+ };
1005
+ }
1006
+ function uninstallEngramHooks(settings) {
1007
+ const removed = [];
1008
+ const existingHooks = settings.hooks ?? {};
1009
+ const hooksClone = {};
1010
+ for (const [event, arr] of Object.entries(existingHooks)) {
1011
+ if (!Array.isArray(arr)) continue;
1012
+ const filtered = arr.filter((entry) => !isEngramHookEntry(entry));
1013
+ if (filtered.length !== arr.length && isKnownEngramEvent(event)) {
1014
+ removed.push(event);
1015
+ }
1016
+ if (filtered.length > 0) {
1017
+ hooksClone[event] = filtered;
1018
+ }
1019
+ }
1020
+ const updatedSettings = { ...settings };
1021
+ if (Object.keys(hooksClone).length === 0) {
1022
+ delete updatedSettings.hooks;
1023
+ } else {
1024
+ updatedSettings.hooks = hooksClone;
1025
+ }
1026
+ return { updated: updatedSettings, removed };
1027
+ }
1028
+ function isKnownEngramEvent(event) {
1029
+ return ENGRAM_HOOK_EVENTS.includes(event);
1030
+ }
1031
+ function formatInstallDiff(before, after) {
1032
+ const lines = [];
1033
+ const beforeHooks = before.hooks ?? {};
1034
+ const afterHooks = after.hooks ?? {};
1035
+ for (const event of ENGRAM_HOOK_EVENTS) {
1036
+ const beforeArr = beforeHooks[event] ?? [];
1037
+ const afterArr = afterHooks[event] ?? [];
1038
+ if (beforeArr.length === afterArr.length) continue;
1039
+ lines.push(`+ ${event}: ${beforeArr.length} \u2192 ${afterArr.length} entries`);
1040
+ const added = afterArr.filter((entry) => isEngramHookEntry(entry));
1041
+ const beforeHasEngram = beforeArr.some((entry) => isEngramHookEntry(entry));
1042
+ if (!beforeHasEngram && added.length > 0) {
1043
+ for (const entry of added) {
1044
+ const matcher = entry.matcher ? ` matcher=${JSON.stringify(entry.matcher)}` : "";
1045
+ const cmds = entry.hooks.map((h) => h.command).join(", ");
1046
+ lines.push(` + {${matcher} command="${cmds}"}`);
1047
+ }
1048
+ }
1049
+ }
1050
+ return lines.length > 0 ? lines.join("\n") : "(no changes)";
1051
+ }
1052
+
1053
+ // src/intercept/stats.ts
1054
+ var ESTIMATED_TOKENS_PER_READ_DENY = 1200;
1055
+ function summarizeHookLog(entries) {
1056
+ const byEvent = {};
1057
+ const byTool = {};
1058
+ const byDecision = {};
1059
+ let readDenyCount = 0;
1060
+ let firstEntryTs = null;
1061
+ let lastEntryTs = null;
1062
+ for (const entry of entries) {
1063
+ const event = entry.event ?? "unknown";
1064
+ byEvent[event] = (byEvent[event] ?? 0) + 1;
1065
+ const tool = entry.tool ?? "unknown";
1066
+ byTool[tool] = (byTool[tool] ?? 0) + 1;
1067
+ if (entry.decision) {
1068
+ byDecision[entry.decision] = (byDecision[entry.decision] ?? 0) + 1;
1069
+ }
1070
+ if (event === "PreToolUse" && tool === "Read" && entry.decision === "deny") {
1071
+ readDenyCount += 1;
1072
+ }
1073
+ const ts = entry.ts;
1074
+ if (typeof ts === "string") {
1075
+ if (firstEntryTs === null || ts < firstEntryTs) firstEntryTs = ts;
1076
+ if (lastEntryTs === null || ts > lastEntryTs) lastEntryTs = ts;
1077
+ }
1078
+ }
1079
+ return {
1080
+ totalInvocations: entries.length,
1081
+ byEvent: Object.freeze(byEvent),
1082
+ byTool: Object.freeze(byTool),
1083
+ byDecision: Object.freeze(byDecision),
1084
+ readDenyCount,
1085
+ estimatedTokensSaved: readDenyCount * ESTIMATED_TOKENS_PER_READ_DENY,
1086
+ firstEntry: firstEntryTs,
1087
+ lastEntry: lastEntryTs
1088
+ };
1089
+ }
1090
+ function formatStatsSummary(summary) {
1091
+ if (summary.totalInvocations === 0) {
1092
+ return "engram hook stats: no log entries yet.\n\nRun engram install-hook in a project, then use Claude Code to see interceptions.";
1093
+ }
1094
+ const lines = [];
1095
+ lines.push(`engram hook stats (${summary.totalInvocations} invocations)`);
1096
+ 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");
1097
+ if (summary.firstEntry && summary.lastEntry) {
1098
+ lines.push(`Time range: ${summary.firstEntry} \u2192 ${summary.lastEntry}`);
1099
+ lines.push("");
1100
+ }
1101
+ lines.push("By event:");
1102
+ const eventEntries = Object.entries(summary.byEvent).sort(
1103
+ (a, b) => b[1] - a[1]
1104
+ );
1105
+ for (const [event, count] of eventEntries) {
1106
+ const pct = (count / summary.totalInvocations * 100).toFixed(1);
1107
+ lines.push(` ${event.padEnd(18)} ${String(count).padStart(5)} (${pct}%)`);
1108
+ }
1109
+ lines.push("");
1110
+ lines.push("By tool:");
1111
+ const toolEntries = Object.entries(summary.byTool).filter(([k]) => k !== "unknown").sort((a, b) => b[1] - a[1]);
1112
+ for (const [tool, count] of toolEntries) {
1113
+ lines.push(` ${tool.padEnd(18)} ${String(count).padStart(5)}`);
1114
+ }
1115
+ if (toolEntries.length === 0) {
1116
+ lines.push(" (no tool-tagged entries)");
1117
+ }
1118
+ lines.push("");
1119
+ const decisionEntries = Object.entries(summary.byDecision);
1120
+ if (decisionEntries.length > 0) {
1121
+ lines.push("PreToolUse decisions:");
1122
+ for (const [decision, count] of decisionEntries.sort(
1123
+ (a, b) => b[1] - a[1]
1124
+ )) {
1125
+ lines.push(` ${decision.padEnd(18)} ${String(count).padStart(5)}`);
1126
+ }
1127
+ lines.push("");
1128
+ }
1129
+ if (summary.readDenyCount > 0) {
1130
+ lines.push(
1131
+ `Estimated tokens saved: ~${summary.estimatedTokensSaved.toLocaleString()}`
1132
+ );
1133
+ lines.push(
1134
+ ` (${summary.readDenyCount} Read denies \xD7 ${ESTIMATED_TOKENS_PER_READ_DENY} tok/deny avg)`
1135
+ );
1136
+ } else {
1137
+ lines.push("Estimated tokens saved: 0");
1138
+ lines.push(" (no PreToolUse:Read denies recorded yet)");
1139
+ }
1140
+ return lines.join("\n");
1141
+ }
1142
+
1143
+ // src/intercept/memory-md.ts
1144
+ import {
1145
+ existsSync as existsSync5,
1146
+ readFileSync as readFileSync3,
1147
+ writeFileSync,
1148
+ renameSync as renameSync2,
1149
+ statSync as statSync3
1150
+ } from "fs";
1151
+ import { join as join5 } from "path";
1152
+ var ENGRAM_MARKER_START = "<!-- engram:structural-facts:start -->";
1153
+ var ENGRAM_MARKER_END = "<!-- engram:structural-facts:end -->";
1154
+ var MAX_MEMORY_FILE_BYTES = 1e6;
1155
+ var MAX_ENGRAM_SECTION_BYTES = 8e3;
1156
+ function buildEngramSection(facts) {
1157
+ const lines = [];
1158
+ lines.push("## engram \u2014 structural facts");
1159
+ lines.push("");
1160
+ lines.push(
1161
+ `_Auto-maintained by engram. Do not edit inside the marker block \u2014 the next \`engram memory-sync\` overwrites it. This section complements Auto-Dream: Auto-Dream owns prose memory, engram owns the code graph._`
1162
+ );
1163
+ lines.push("");
1164
+ lines.push(`**Project:** ${facts.projectName}`);
1165
+ if (facts.branch) lines.push(`**Branch:** ${facts.branch}`);
1166
+ lines.push(
1167
+ `**Graph:** ${facts.stats.nodes} nodes, ${facts.stats.edges} edges, ${facts.stats.extractedPct}% extracted`
1168
+ );
1169
+ if (facts.lastMined > 0) {
1170
+ lines.push(
1171
+ `**Last mined:** ${new Date(facts.lastMined).toISOString()}`
1172
+ );
1173
+ }
1174
+ lines.push("");
1175
+ if (facts.godNodes.length > 0) {
1176
+ lines.push("### Core entities");
1177
+ for (const g of facts.godNodes.slice(0, 10)) {
1178
+ lines.push(`- \`${g.label}\` [${g.kind}] \u2014 ${g.sourceFile}`);
1179
+ }
1180
+ lines.push("");
1181
+ }
1182
+ if (facts.landmines.length > 0) {
1183
+ lines.push("### Known landmines");
1184
+ for (const m of facts.landmines.slice(0, 5)) {
1185
+ lines.push(`- **${m.sourceFile}** \u2014 ${m.label}`);
1186
+ }
1187
+ lines.push("");
1188
+ }
1189
+ lines.push(
1190
+ '_For the full graph, run `engram query "..."` or `engram gods`._'
1191
+ );
1192
+ return lines.join("\n");
1193
+ }
1194
+ function upsertEngramSection(existingContent, engramSection) {
1195
+ const block = `${ENGRAM_MARKER_START}
1196
+ ${engramSection}
1197
+ ${ENGRAM_MARKER_END}`;
1198
+ if (!existingContent) {
1199
+ return `# MEMORY.md
1200
+
1201
+ ${block}
1202
+ `;
1203
+ }
1204
+ const startIdx = existingContent.indexOf(ENGRAM_MARKER_START);
1205
+ const endIdx = existingContent.indexOf(ENGRAM_MARKER_END);
1206
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
1207
+ const trimmed = existingContent.trimEnd();
1208
+ return `${trimmed}
1209
+
1210
+ ${block}
1211
+ `;
1212
+ }
1213
+ const before = existingContent.slice(0, startIdx);
1214
+ const after = existingContent.slice(endIdx + ENGRAM_MARKER_END.length);
1215
+ return `${before}${block}${after}`;
1216
+ }
1217
+ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1218
+ if (!projectRoot || typeof projectRoot !== "string") return false;
1219
+ if (engramSection.length > MAX_ENGRAM_SECTION_BYTES) {
1220
+ return false;
1221
+ }
1222
+ const memoryPath = join5(projectRoot, "MEMORY.md");
1223
+ try {
1224
+ let existing = "";
1225
+ if (existsSync5(memoryPath)) {
1226
+ const st = statSync3(memoryPath);
1227
+ if (st.size > MAX_MEMORY_FILE_BYTES) {
1228
+ return false;
1229
+ }
1230
+ existing = readFileSync3(memoryPath, "utf-8");
1231
+ }
1232
+ const updated = upsertEngramSection(existing, engramSection);
1233
+ const tmpPath = memoryPath + ".engram-tmp";
1234
+ writeFileSync(tmpPath, updated);
1235
+ renameSync2(tmpPath, memoryPath);
1236
+ return true;
1237
+ } catch {
1238
+ return false;
1239
+ }
1240
+ }
1241
+
1242
+ // src/cli.ts
1243
+ import { basename as basename2 } from "path";
22
1244
  var program = new Command();
23
- program.name("engram").description("AI coding memory that learns from every session").version("0.2.1");
1245
+ program.name("engram").description(
1246
+ "Context as infra for AI coding tools \u2014 hook-based Read/Edit interception + structural graph summaries"
1247
+ ).version("0.3.0");
24
1248
  program.command("init").description("Scan codebase and build knowledge graph (zero LLM cost)").argument("[path]", "Project directory", ".").option(
25
1249
  "--with-skills [dir]",
26
1250
  "Also index Claude Code skills from ~/.claude/skills/ or a given path"
@@ -174,4 +1398,438 @@ program.command("gen").description("Generate CLAUDE.md / .cursorrules section fr
174
1398
  );
175
1399
  }
176
1400
  );
1401
+ function resolveSettingsPath(scope, projectPath) {
1402
+ const absProject = pathResolve(projectPath);
1403
+ switch (scope) {
1404
+ case "local":
1405
+ return join6(absProject, ".claude", "settings.local.json");
1406
+ case "project":
1407
+ return join6(absProject, ".claude", "settings.json");
1408
+ case "user":
1409
+ return join6(homedir(), ".claude", "settings.json");
1410
+ default:
1411
+ return null;
1412
+ }
1413
+ }
1414
+ program.command("intercept").description(
1415
+ "Hook entry point. Reads JSON from stdin, writes response JSON to stdout. Called by Claude Code."
1416
+ ).action(async () => {
1417
+ const stdinTimeout = setTimeout(() => {
1418
+ process.exit(0);
1419
+ }, 3e3);
1420
+ let input = "";
1421
+ try {
1422
+ for await (const chunk of process.stdin) {
1423
+ input += chunk;
1424
+ if (input.length > 1e6) break;
1425
+ }
1426
+ } catch {
1427
+ clearTimeout(stdinTimeout);
1428
+ process.exit(0);
1429
+ }
1430
+ clearTimeout(stdinTimeout);
1431
+ if (!input.trim()) process.exit(0);
1432
+ let payload;
1433
+ try {
1434
+ payload = JSON.parse(input);
1435
+ } catch {
1436
+ process.exit(0);
1437
+ }
1438
+ try {
1439
+ const result = await dispatchHook(payload);
1440
+ if (result && typeof result === "object") {
1441
+ process.stdout.write(JSON.stringify(result));
1442
+ }
1443
+ } catch {
1444
+ }
1445
+ process.exit(0);
1446
+ });
1447
+ program.command("cursor-intercept").description(
1448
+ "Cursor beforeReadFile hook entry point (experimental). Reads JSON from stdin, writes Cursor-shaped response JSON to stdout."
1449
+ ).action(async () => {
1450
+ const ALLOW_JSON = '{"permission":"allow"}';
1451
+ const stdinTimeout = setTimeout(() => {
1452
+ process.stdout.write(ALLOW_JSON);
1453
+ process.exit(0);
1454
+ }, 3e3);
1455
+ let input = "";
1456
+ try {
1457
+ for await (const chunk of process.stdin) {
1458
+ input += chunk;
1459
+ if (input.length > 1e6) break;
1460
+ }
1461
+ } catch {
1462
+ clearTimeout(stdinTimeout);
1463
+ process.stdout.write(ALLOW_JSON);
1464
+ process.exit(0);
1465
+ }
1466
+ clearTimeout(stdinTimeout);
1467
+ if (!input.trim()) {
1468
+ process.stdout.write(ALLOW_JSON);
1469
+ process.exit(0);
1470
+ }
1471
+ let payload;
1472
+ try {
1473
+ payload = JSON.parse(input);
1474
+ } catch {
1475
+ process.stdout.write(ALLOW_JSON);
1476
+ process.exit(0);
1477
+ }
1478
+ try {
1479
+ const result = await handleCursorBeforeReadFile(payload);
1480
+ process.stdout.write(JSON.stringify(result));
1481
+ } catch {
1482
+ process.stdout.write(ALLOW_JSON);
1483
+ }
1484
+ process.exit(0);
1485
+ });
1486
+ program.command("install-hook").description("Install engram hook entries into Claude Code settings").option("--scope <scope>", "local | project | user", "local").option("--dry-run", "Show diff without writing", false).option("-p, --project <path>", "Project directory", ".").action(
1487
+ async (opts) => {
1488
+ const settingsPath = resolveSettingsPath(opts.scope, opts.project);
1489
+ if (!settingsPath) {
1490
+ console.error(
1491
+ chalk.red(
1492
+ `Unknown scope: ${opts.scope} (expected: local | project | user)`
1493
+ )
1494
+ );
1495
+ process.exit(1);
1496
+ }
1497
+ let existing = {};
1498
+ if (existsSync6(settingsPath)) {
1499
+ try {
1500
+ const raw = readFileSync4(settingsPath, "utf-8");
1501
+ existing = raw.trim() ? JSON.parse(raw) : {};
1502
+ } catch (err) {
1503
+ console.error(
1504
+ chalk.red(
1505
+ `Failed to parse ${settingsPath}: ${err.message}`
1506
+ )
1507
+ );
1508
+ console.error(
1509
+ chalk.dim(
1510
+ "Fix the JSON syntax and re-run install-hook, or remove the file and start fresh."
1511
+ )
1512
+ );
1513
+ process.exit(1);
1514
+ }
1515
+ }
1516
+ const result = installEngramHooks(existing);
1517
+ console.log(
1518
+ chalk.bold(`
1519
+ \u{1F4CC} engram install-hook (scope: ${opts.scope})`)
1520
+ );
1521
+ console.log(chalk.dim(` Target: ${settingsPath}`));
1522
+ if (result.added.length === 0) {
1523
+ console.log(
1524
+ chalk.yellow(
1525
+ `
1526
+ All engram hooks already installed (${result.alreadyPresent.join(", ")}).`
1527
+ )
1528
+ );
1529
+ console.log(
1530
+ chalk.dim(
1531
+ " Run 'engram uninstall-hook' first if you want to reinstall."
1532
+ )
1533
+ );
1534
+ return;
1535
+ }
1536
+ console.log(chalk.cyan("\n Changes:"));
1537
+ console.log(
1538
+ formatInstallDiff(existing, result.updated).split("\n").map((l) => " " + l).join("\n")
1539
+ );
1540
+ if (opts.dryRun) {
1541
+ console.log(chalk.dim("\n (dry-run \u2014 no changes written)"));
1542
+ return;
1543
+ }
1544
+ try {
1545
+ mkdirSync(dirname3(settingsPath), { recursive: true });
1546
+ if (existsSync6(settingsPath)) {
1547
+ const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1548
+ copyFileSync(settingsPath, backupPath);
1549
+ console.log(chalk.dim(` Backup: ${backupPath}`));
1550
+ }
1551
+ const tmpPath = settingsPath + ".engram-tmp";
1552
+ writeFileSync2(
1553
+ tmpPath,
1554
+ JSON.stringify(result.updated, null, 2) + "\n"
1555
+ );
1556
+ renameSync3(tmpPath, settingsPath);
1557
+ } catch (err) {
1558
+ console.error(
1559
+ chalk.red(`
1560
+ \u274C Write failed: ${err.message}`)
1561
+ );
1562
+ process.exit(1);
1563
+ }
1564
+ console.log(
1565
+ chalk.green(
1566
+ `
1567
+ \u2705 Installed ${result.added.length} hook event${result.added.length === 1 ? "" : "s"}: ${result.added.join(", ")}`
1568
+ )
1569
+ );
1570
+ if (result.alreadyPresent.length > 0) {
1571
+ console.log(
1572
+ chalk.dim(
1573
+ ` Already present: ${result.alreadyPresent.join(", ")}`
1574
+ )
1575
+ );
1576
+ }
1577
+ console.log(
1578
+ chalk.dim(
1579
+ "\n Next: open a Claude Code session and engram will start intercepting tool calls."
1580
+ )
1581
+ );
1582
+ }
1583
+ );
1584
+ program.command("uninstall-hook").description("Remove engram hook entries from Claude Code settings").option("--scope <scope>", "local | project | user", "local").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
1585
+ const settingsPath = resolveSettingsPath(opts.scope, opts.project);
1586
+ if (!settingsPath) {
1587
+ console.error(chalk.red(`Unknown scope: ${opts.scope}`));
1588
+ process.exit(1);
1589
+ }
1590
+ if (!existsSync6(settingsPath)) {
1591
+ console.log(
1592
+ chalk.yellow(`No settings file at ${settingsPath} \u2014 nothing to remove.`)
1593
+ );
1594
+ return;
1595
+ }
1596
+ let existing;
1597
+ try {
1598
+ const raw = readFileSync4(settingsPath, "utf-8");
1599
+ existing = raw.trim() ? JSON.parse(raw) : {};
1600
+ } catch (err) {
1601
+ console.error(
1602
+ chalk.red(`Failed to parse ${settingsPath}: ${err.message}`)
1603
+ );
1604
+ process.exit(1);
1605
+ }
1606
+ const result = uninstallEngramHooks(existing);
1607
+ if (result.removed.length === 0) {
1608
+ console.log(
1609
+ chalk.yellow(`
1610
+ No engram hooks found in ${settingsPath}.`)
1611
+ );
1612
+ return;
1613
+ }
1614
+ try {
1615
+ const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1616
+ copyFileSync(settingsPath, backupPath);
1617
+ const tmpPath = settingsPath + ".engram-tmp";
1618
+ writeFileSync2(tmpPath, JSON.stringify(result.updated, null, 2) + "\n");
1619
+ renameSync3(tmpPath, settingsPath);
1620
+ console.log(
1621
+ chalk.green(
1622
+ `
1623
+ \u2705 Removed engram hooks from ${result.removed.length} event${result.removed.length === 1 ? "" : "s"}: ${result.removed.join(", ")}`
1624
+ )
1625
+ );
1626
+ console.log(chalk.dim(` Backup: ${backupPath}`));
1627
+ } catch (err) {
1628
+ console.error(
1629
+ chalk.red(`
1630
+ \u274C Write failed: ${err.message}`)
1631
+ );
1632
+ process.exit(1);
1633
+ }
1634
+ });
1635
+ program.command("hook-stats").description("Summarize hook-log.jsonl for a project").option("-p, --project <path>", "Project directory", ".").option("--json", "Output as JSON", false).action(async (opts) => {
1636
+ const absProject = pathResolve(opts.project);
1637
+ const projectRoot = findProjectRoot(absProject) ?? absProject;
1638
+ const entries = readHookLog(projectRoot);
1639
+ const summary = summarizeHookLog(entries);
1640
+ if (opts.json) {
1641
+ console.log(JSON.stringify(summary, null, 2));
1642
+ return;
1643
+ }
1644
+ console.log(formatStatsSummary(summary));
1645
+ });
1646
+ program.command("hook-preview").description("Show what the Read handler would do for a file (dry-run)").argument("<file>", "Target file path").option("-p, --project <path>", "Project directory", ".").action(async (file, opts) => {
1647
+ const absProject = pathResolve(opts.project);
1648
+ const absFile = pathResolve(absProject, file);
1649
+ const payload = {
1650
+ hook_event_name: "PreToolUse",
1651
+ tool_name: "Read",
1652
+ cwd: absProject,
1653
+ tool_input: { file_path: absFile }
1654
+ };
1655
+ const result = await dispatchHook(payload);
1656
+ console.log(chalk.bold(`
1657
+ \u{1F4CB} Hook preview: ${absFile}`));
1658
+ console.log(chalk.dim(` Project: ${absProject}`));
1659
+ console.log();
1660
+ if (result === null || result === void 0) {
1661
+ console.log(
1662
+ chalk.yellow(" Decision: PASSTHROUGH (Read would execute normally)")
1663
+ );
1664
+ console.log(
1665
+ chalk.dim(
1666
+ " Possible reasons: file not in graph, confidence below threshold, content unsafe, outside project, stale graph."
1667
+ )
1668
+ );
1669
+ return;
1670
+ }
1671
+ const wrapped = result;
1672
+ const decision = wrapped.hookSpecificOutput?.permissionDecision;
1673
+ if (decision === "deny") {
1674
+ console.log(chalk.green(" Decision: DENY (Read would be replaced)"));
1675
+ console.log(chalk.dim(" Summary (would be delivered to Claude):"));
1676
+ console.log();
1677
+ const reason = wrapped.hookSpecificOutput?.permissionDecisionReason ?? "";
1678
+ console.log(
1679
+ reason.split("\n").map((l) => " " + l).join("\n")
1680
+ );
1681
+ return;
1682
+ }
1683
+ if (decision === "allow") {
1684
+ console.log(chalk.cyan(" Decision: ALLOW (with additionalContext)"));
1685
+ const ctx = wrapped.hookSpecificOutput?.additionalContext ?? "";
1686
+ if (ctx) {
1687
+ console.log(chalk.dim(" Additional context that would be injected:"));
1688
+ console.log(
1689
+ ctx.split("\n").map((l) => " " + l).join("\n")
1690
+ );
1691
+ }
1692
+ return;
1693
+ }
1694
+ console.log(chalk.yellow(` Decision: ${decision ?? "unknown"}`));
1695
+ });
1696
+ program.command("hook-disable").description("Disable engram hooks via kill switch (does not uninstall)").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
1697
+ const absProject = pathResolve(opts.project);
1698
+ const projectRoot = findProjectRoot(absProject);
1699
+ if (!projectRoot) {
1700
+ console.error(
1701
+ chalk.red(`Not an engram project: ${absProject}`)
1702
+ );
1703
+ console.error(chalk.dim("Run 'engram init' first."));
1704
+ process.exit(1);
1705
+ }
1706
+ const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1707
+ try {
1708
+ writeFileSync2(flagPath, (/* @__PURE__ */ new Date()).toISOString());
1709
+ console.log(
1710
+ chalk.green(`\u2705 engram hooks disabled for ${projectRoot}`)
1711
+ );
1712
+ console.log(chalk.dim(` Flag: ${flagPath}`));
1713
+ console.log(
1714
+ chalk.dim(" Run 'engram hook-enable' to re-enable.")
1715
+ );
1716
+ } catch (err) {
1717
+ console.error(
1718
+ chalk.red(`Failed to create flag: ${err.message}`)
1719
+ );
1720
+ process.exit(1);
1721
+ }
1722
+ });
1723
+ program.command("hook-enable").description("Re-enable engram hooks (remove kill switch flag)").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
1724
+ const absProject = pathResolve(opts.project);
1725
+ const projectRoot = findProjectRoot(absProject);
1726
+ if (!projectRoot) {
1727
+ console.error(chalk.red(`Not an engram project: ${absProject}`));
1728
+ process.exit(1);
1729
+ }
1730
+ const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1731
+ if (!existsSync6(flagPath)) {
1732
+ console.log(
1733
+ chalk.yellow(`engram hooks already enabled for ${projectRoot}`)
1734
+ );
1735
+ return;
1736
+ }
1737
+ try {
1738
+ unlinkSync(flagPath);
1739
+ console.log(
1740
+ chalk.green(`\u2705 engram hooks re-enabled for ${projectRoot}`)
1741
+ );
1742
+ } catch (err) {
1743
+ console.error(
1744
+ chalk.red(`Failed to remove flag: ${err.message}`)
1745
+ );
1746
+ process.exit(1);
1747
+ }
1748
+ });
1749
+ program.command("memory-sync").description(
1750
+ "Write engram's structural facts into MEMORY.md (complementary to Anthropic Auto-Dream)"
1751
+ ).option("-p, --project <path>", "Project directory", ".").option("--dry-run", "Print what would be written without writing", false).action(
1752
+ async (opts) => {
1753
+ const absProject = pathResolve(opts.project);
1754
+ const projectRoot = findProjectRoot(absProject);
1755
+ if (!projectRoot) {
1756
+ console.error(
1757
+ chalk.red(`Not an engram project: ${absProject}`)
1758
+ );
1759
+ console.error(chalk.dim("Run 'engram init' first."));
1760
+ process.exit(1);
1761
+ }
1762
+ const [gods, mistakeList, graphStats] = await Promise.all([
1763
+ godNodes(projectRoot, 10).catch(() => []),
1764
+ mistakes(projectRoot, { limit: 5 }).catch(() => []),
1765
+ stats(projectRoot).catch(() => null)
1766
+ ]);
1767
+ if (!graphStats) {
1768
+ console.error(chalk.red("Failed to read graph stats."));
1769
+ process.exit(1);
1770
+ }
1771
+ let branch = null;
1772
+ try {
1773
+ const headPath = join6(projectRoot, ".git", "HEAD");
1774
+ if (existsSync6(headPath)) {
1775
+ const content = readFileSync4(headPath, "utf-8").trim();
1776
+ const m = content.match(/^ref:\s+refs\/heads\/(.+)$/);
1777
+ if (m) branch = m[1];
1778
+ }
1779
+ } catch {
1780
+ }
1781
+ const section = buildEngramSection({
1782
+ projectName: basename2(projectRoot),
1783
+ branch,
1784
+ stats: {
1785
+ nodes: graphStats.nodes,
1786
+ edges: graphStats.edges,
1787
+ extractedPct: graphStats.extractedPct
1788
+ },
1789
+ godNodes: gods,
1790
+ landmines: mistakeList.map((m) => ({
1791
+ label: m.label,
1792
+ sourceFile: m.sourceFile
1793
+ })),
1794
+ lastMined: graphStats.lastMined
1795
+ });
1796
+ console.log(
1797
+ chalk.bold(`
1798
+ \u{1F4DD} engram memory-sync`)
1799
+ );
1800
+ console.log(
1801
+ chalk.dim(` Target: ${join6(projectRoot, "MEMORY.md")}`)
1802
+ );
1803
+ if (opts.dryRun) {
1804
+ console.log(chalk.cyan("\n Section to write (dry-run):\n"));
1805
+ console.log(
1806
+ section.split("\n").map((l) => " " + l).join("\n")
1807
+ );
1808
+ console.log(chalk.dim("\n (dry-run \u2014 no changes written)"));
1809
+ return;
1810
+ }
1811
+ const ok = writeEngramSectionToMemoryMd(projectRoot, section);
1812
+ if (!ok) {
1813
+ console.error(
1814
+ chalk.red(
1815
+ "\n \u274C Write failed. MEMORY.md may be too large, or the engram section exceeded its size cap."
1816
+ )
1817
+ );
1818
+ process.exit(1);
1819
+ }
1820
+ console.log(
1821
+ chalk.green(
1822
+ `
1823
+ \u2705 Synced ${gods.length} god nodes${mistakeList.length > 0 ? ` and ${mistakeList.length} landmines` : ""} to MEMORY.md`
1824
+ )
1825
+ );
1826
+ console.log(
1827
+ chalk.dim(
1828
+ `
1829
+ Next: Anthropic's Auto-Dream will consolidate this alongside its prose entries.
1830
+ `
1831
+ )
1832
+ );
1833
+ }
1834
+ );
177
1835
  program.parse();