engramx 0.3.1 → 0.4.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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <a href="https://github.com/NickCirv/engram/actions"><img src="https://github.com/NickCirv/engram/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
16
  <img src="https://img.shields.io/badge/license-Apache%202.0-blue" alt="License">
17
17
  <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node">
18
- <img src="https://img.shields.io/badge/tests-439%20passing-brightgreen" alt="Tests">
18
+ <img src="https://img.shields.io/badge/tests-486%20passing-brightgreen" alt="Tests">
19
19
  <img src="https://img.shields.io/badge/LLM%20cost-$0-green" alt="Zero LLM cost">
20
20
  <img src="https://img.shields.io/badge/native%20deps-zero-green" alt="Zero native deps">
21
21
  <img src="https://img.shields.io/badge/token%20reduction-82%25-orange" alt="82% token reduction">
@@ -110,6 +110,37 @@ engram hook-enable # re-enable
110
110
  engram uninstall-hook # surgical removal, preserves other hooks
111
111
  ```
112
112
 
113
+ ## Experience Tiers
114
+
115
+ Each tier builds on the previous. You can stop at any level — each one works standalone.
116
+
117
+ | Tier | What you run | What you get | Token savings |
118
+ |---|---|---|---|
119
+ | **1. Graph only** | `engram init` | CLI queries, MCP server, `engram gen` for CLAUDE.md | ~6x per query vs reading files |
120
+ | **2. + Sentinel hooks** | `engram install-hook` | Automatic Read interception, Edit landmine warnings, session-start briefs, prompt pre-query | ~82% per session (measured) |
121
+ | **3. + Skills index** | `engram init --with-skills` | Graph includes your `~/.claude/skills/` — queries surface relevant skills alongside code | ~23% overhead on graph size |
122
+ | **4. + Git hooks** | `engram hooks install` | Auto-rebuild graph on every `git commit` — graph never goes stale | Zero token cost |
123
+
124
+ **Recommended full setup** (one-time, per project):
125
+
126
+ ```bash
127
+ npm install -g engramx # install globally
128
+ cd ~/my-project
129
+ engram init --with-skills # build graph + index skills
130
+ engram install-hook # wire Sentinel into Claude Code
131
+ engram hooks install # auto-rebuild on commit
132
+ ```
133
+
134
+ After this, every Claude Code session in the project automatically gets structural context, landmine warnings, and session briefs — with no manual queries needed.
135
+
136
+ **Optional — MEMORY.md integration** (v0.3.1+):
137
+
138
+ ```bash
139
+ engram gen --memory-md # write structural facts into Claude's native MEMORY.md
140
+ ```
141
+
142
+ This writes a marker-bounded block into `~/.claude/projects/.../memory/MEMORY.md` with your project's core entities and structure. Claude's Auto-Dream owns the prose; engram owns the structure. They complement each other — engram never touches content outside its markers.
143
+
113
144
  ## All Commands
114
145
 
115
146
  ### Core (v0.1/v0.2 — unchanged)
@@ -310,7 +310,7 @@ function writeToFile(filePath, summary) {
310
310
  writeFileSync2(filePath, newContent);
311
311
  }
312
312
  async function autogen(projectRoot, target, task) {
313
- const { getStore } = await import("./core-2TWPNHRQ.js");
313
+ const { getStore } = await import("./core-WTKXDUDO.js");
314
314
  const store = await getStore(projectRoot);
315
315
  try {
316
316
  let view = VIEWS.general;
@@ -65,7 +65,8 @@ var GraphStore = class _GraphStore {
65
65
  "CREATE INDEX IF NOT EXISTS idx_nodes_source_file ON nodes(source_file)",
66
66
  "CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source)",
67
67
  "CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target)",
68
- "CREATE INDEX IF NOT EXISTS idx_edges_relation ON edges(relation)"
68
+ "CREATE INDEX IF NOT EXISTS idx_edges_relation ON edges(relation)",
69
+ "CREATE INDEX IF NOT EXISTS idx_edges_source_file ON edges(source_file)"
69
70
  ];
70
71
  for (const sql of indexes) {
71
72
  try {
@@ -113,6 +114,22 @@ var GraphStore = class _GraphStore {
113
114
  ]
114
115
  );
115
116
  }
117
+ /**
118
+ * Remove all nodes and edges associated with a specific source file.
119
+ * Used by the file watcher for incremental re-indexing — old nodes for
120
+ * a changed file are cleared before re-extracting.
121
+ */
122
+ deleteBySourceFile(sourceFile) {
123
+ this.db.run("BEGIN TRANSACTION");
124
+ try {
125
+ this.db.run("DELETE FROM edges WHERE source_file = ?", [sourceFile]);
126
+ this.db.run("DELETE FROM nodes WHERE source_file = ?", [sourceFile]);
127
+ this.db.run("COMMIT");
128
+ } catch (e) {
129
+ this.db.run("ROLLBACK");
130
+ throw e;
131
+ }
132
+ }
116
133
  bulkUpsert(nodes, edges) {
117
134
  this.db.run("BEGIN TRANSACTION");
118
135
  for (const node of nodes) this.upsertNode(node);
@@ -627,6 +644,12 @@ function renderFileStructure(store, relativeFilePath, tokenBudget = 600) {
627
644
  };
628
645
  }
629
646
 
647
+ // src/graph/path-utils.ts
648
+ function toPosixPath(p) {
649
+ if (!p) return p;
650
+ return p.replace(/\\/g, "/");
651
+ }
652
+
630
653
  // src/miners/ast-miner.ts
631
654
  import { readFileSync as readFileSync2, readdirSync, realpathSync } from "fs";
632
655
  import { basename, extname, join, relative } from "path";
@@ -716,7 +739,7 @@ function extractFile(filePath, rootDir) {
716
739
  if (!lang) return { nodes: [], edges: [] };
717
740
  const content = readFileSync2(filePath, "utf-8");
718
741
  const lines = content.split("\n");
719
- const relPath = relative(rootDir, filePath);
742
+ const relPath = toPosixPath(relative(rootDir, filePath));
720
743
  const stem = basename(filePath, ext);
721
744
  const now = Date.now();
722
745
  const nodes = [];
@@ -1230,7 +1253,7 @@ function parseFrontmatter(content) {
1230
1253
  }
1231
1254
  function parseYaml(block) {
1232
1255
  const data = {};
1233
- const lines = block.split("\n");
1256
+ const lines = block.replace(/\r/g, "").split("\n");
1234
1257
  let i = 0;
1235
1258
  while (i < lines.length) {
1236
1259
  const line = lines[i];
@@ -1606,7 +1629,7 @@ async function getFileContext(projectRoot, absFilePath) {
1606
1629
  try {
1607
1630
  const root = resolve2(projectRoot);
1608
1631
  const abs = resolve2(absFilePath);
1609
- const relPath = relative2(root, abs);
1632
+ const relPath = toPosixPath(relative2(root, abs));
1610
1633
  if (relPath.startsWith("..") || relPath === "") {
1611
1634
  return empty;
1612
1635
  }
@@ -1801,6 +1824,7 @@ export {
1801
1824
  MAX_MISTAKE_LABEL_CHARS,
1802
1825
  queryGraph,
1803
1826
  shortestPath,
1827
+ toPosixPath,
1804
1828
  SUPPORTED_EXTENSIONS,
1805
1829
  extractFile,
1806
1830
  extractDirectory,
package/dist/cli.js CHANGED
@@ -4,25 +4,29 @@ import {
4
4
  install,
5
5
  status,
6
6
  uninstall
7
- } from "./chunk-IYO4HETA.js";
7
+ } from "./chunk-ESPAWLH6.js";
8
8
  import {
9
9
  benchmark,
10
10
  computeKeywordIDF,
11
+ extractFile,
12
+ getDbPath,
11
13
  getFileContext,
14
+ getStore,
12
15
  godNodes,
13
16
  init,
14
17
  learn,
15
18
  mistakes,
16
19
  path,
17
20
  query,
18
- stats
19
- } from "./chunk-RGDHLGWQ.js";
21
+ stats,
22
+ toPosixPath
23
+ } from "./chunk-R46DNLNR.js";
20
24
 
21
25
  // src/cli.ts
22
26
  import { Command } from "commander";
23
27
  import chalk from "chalk";
24
28
  import {
25
- existsSync as existsSync6,
29
+ existsSync as existsSync7,
26
30
  readFileSync as readFileSync4,
27
31
  writeFileSync as writeFileSync2,
28
32
  mkdirSync,
@@ -30,7 +34,7 @@ import {
30
34
  copyFileSync,
31
35
  renameSync as renameSync3
32
36
  } from "fs";
33
- import { dirname as dirname3, join as join6, resolve as pathResolve } from "path";
37
+ import { dirname as dirname3, join as join7, resolve as pathResolve } from "path";
34
38
  import { homedir } from "os";
35
39
 
36
40
  // src/intercept/safety.ts
@@ -40,8 +44,8 @@ var PASSTHROUGH = null;
40
44
  var DEFAULT_HANDLER_TIMEOUT_MS = 2e3;
41
45
  async function withTimeout(promise, ms = DEFAULT_HANDLER_TIMEOUT_MS) {
42
46
  let timer;
43
- const timeout = new Promise((resolve3) => {
44
- timer = setTimeout(() => resolve3(PASSTHROUGH), ms);
47
+ const timeout = new Promise((resolve6) => {
48
+ timer = setTimeout(() => resolve6(PASSTHROUGH), ms);
45
49
  });
46
50
  try {
47
51
  return await Promise.race([promise, timeout]);
@@ -104,6 +108,10 @@ function isHardSystemPath(absPath) {
104
108
  const p = absPath.replaceAll(sep, "/");
105
109
  if (p === "/" || p.startsWith("/dev/") || p.startsWith("/proc/")) return true;
106
110
  if (p.startsWith("/sys/")) return true;
111
+ const upper = p.toUpperCase();
112
+ if (upper.startsWith("//./") || upper.startsWith("//?/")) return true;
113
+ if (/^[A-Z]:\/WINDOWS(\/|$)/.test(upper)) return true;
114
+ if (/^[A-Z]:\/(PROGRAM FILES|PROGRAMDATA)(\/|$)/.test(upper)) return true;
107
115
  return false;
108
116
  }
109
117
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -257,6 +265,9 @@ function isValidCwd(cwd) {
257
265
  }
258
266
  function resolveInterceptContext(filePath, cwd) {
259
267
  if (!filePath) return { proceed: false, reason: "empty-path" };
268
+ if (isHardSystemPath(filePath)) {
269
+ return { proceed: false, reason: "system-path" };
270
+ }
260
271
  const absPath = normalizePath(filePath, cwd);
261
272
  if (!absPath) return { proceed: false, reason: "normalize-failed" };
262
273
  if (isHardSystemPath(absPath)) {
@@ -371,7 +382,9 @@ async function handleEditOrWrite(payload) {
371
382
  if (!ctx.proceed) return PASSTHROUGH;
372
383
  if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
373
384
  if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
374
- const relPath = relative(resolvePath(ctx.projectRoot), ctx.absPath);
385
+ const relPath = toPosixPath(
386
+ relative(resolvePath(ctx.projectRoot), ctx.absPath)
387
+ );
375
388
  if (!relPath || relPath.startsWith("..")) return PASSTHROUGH;
376
389
  let found;
377
390
  try {
@@ -430,7 +443,10 @@ async function handleBash(payload) {
430
443
 
431
444
  // src/intercept/handlers/session-start.ts
432
445
  import { existsSync as existsSync3, readFileSync } from "fs";
446
+ import { execFile } from "child_process";
447
+ import { promisify } from "util";
433
448
  import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "path";
449
+ var execFileAsync = promisify(execFile);
434
450
  var MAX_GOD_NODES = 10;
435
451
  var MAX_LANDMINES_IN_BRIEF = 3;
436
452
  function readGitBranch(projectRoot) {
@@ -484,6 +500,37 @@ function formatBrief(args) {
484
500
  );
485
501
  return lines.join("\n");
486
502
  }
503
+ async function queryMempalace(projectName) {
504
+ try {
505
+ const { stdout } = await execFileAsync(
506
+ "mcp-mempalace",
507
+ ["mempalace-search", "--query", projectName],
508
+ { timeout: 1500, encoding: "utf-8" }
509
+ );
510
+ const trimmed = stdout.trim();
511
+ if (!trimmed || trimmed.length < 20) return null;
512
+ try {
513
+ const parsed = JSON.parse(trimmed);
514
+ const results = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : [];
515
+ if (results.length === 0) return null;
516
+ const lines = ["[mempalace] Recent context:"];
517
+ for (const r of results.slice(0, 3)) {
518
+ const content = typeof r === "string" ? r : typeof r?.content === "string" ? r.content : typeof r?.document === "string" ? r.document : null;
519
+ if (content) {
520
+ const short = content.length > 120 ? content.slice(0, 117) + "..." : content;
521
+ lines.push(` - ${short}`);
522
+ }
523
+ }
524
+ return lines.length > 1 ? lines.join("\n") : null;
525
+ } catch {
526
+ const maxLen = 400;
527
+ const capped = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 3) + "..." : trimmed;
528
+ return `[mempalace] ${capped}`;
529
+ }
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
487
534
  function describeAgo(ms) {
488
535
  if (ms < 0) return "just now";
489
536
  const s = Math.floor(ms / 1e3);
@@ -505,7 +552,9 @@ async function handleSessionStart(payload) {
505
552
  if (projectRoot === null) return PASSTHROUGH;
506
553
  if (isHookDisabled(projectRoot)) return PASSTHROUGH;
507
554
  try {
508
- const [gods, mistakeList, graphStats] = await Promise.all([
555
+ const branch = readGitBranch(projectRoot);
556
+ const projectName = basename(projectRoot);
557
+ const [gods, mistakeList, graphStats, mempalaceContext] = await Promise.all([
509
558
  godNodes(projectRoot, MAX_GOD_NODES).catch(() => []),
510
559
  mistakes(projectRoot, { limit: MAX_LANDMINES_IN_BRIEF }).catch(
511
560
  () => []
@@ -519,11 +568,10 @@ async function handleSessionStart(payload) {
519
568
  ambiguousPct: 0,
520
569
  lastMined: 0,
521
570
  totalQueryTokensSaved: 0
522
- }))
571
+ })),
572
+ queryMempalace(projectName)
523
573
  ]);
524
574
  if (graphStats.nodes === 0 && gods.length === 0) return PASSTHROUGH;
525
- const branch = readGitBranch(projectRoot);
526
- const projectName = basename(projectRoot);
527
575
  const text = formatBrief({
528
576
  projectName,
529
577
  branch,
@@ -539,7 +587,8 @@ async function handleSessionStart(payload) {
539
587
  sourceFile: m.sourceFile
540
588
  }))
541
589
  });
542
- return buildSessionContextResponse("SessionStart", text);
590
+ const fullText = mempalaceContext ? text + "\n\n" + mempalaceContext : text;
591
+ return buildSessionContextResponse("SessionStart", fullText);
543
592
  } catch {
544
593
  return PASSTHROUGH;
545
594
  }
@@ -799,6 +848,123 @@ async function handlePostTool(payload) {
799
848
  return PASSTHROUGH;
800
849
  }
801
850
 
851
+ // src/intercept/handlers/pre-compact.ts
852
+ import { basename as basename2, resolve as resolve3 } from "path";
853
+ var MAX_GOD_NODES_COMPACT = 5;
854
+ var MAX_LANDMINES_COMPACT = 3;
855
+ function formatCompactBrief(args) {
856
+ const lines = [];
857
+ lines.push(
858
+ `[engram] Compaction survival \u2014 ${args.projectName} (${args.nodeCount} nodes, ${args.edgeCount} edges)`
859
+ );
860
+ if (args.godNodes.length > 0) {
861
+ lines.push("Key entities:");
862
+ for (const g of args.godNodes) {
863
+ lines.push(` - ${g.label} [${g.kind}] \u2014 ${g.sourceFile}`);
864
+ }
865
+ }
866
+ if (args.landmines.length > 0) {
867
+ lines.push("Active landmines:");
868
+ for (const m of args.landmines) {
869
+ lines.push(` - ${m.sourceFile}: ${m.label}`);
870
+ }
871
+ }
872
+ lines.push(
873
+ "engram is active \u2014 Read/Edit/Write interception continues after compaction."
874
+ );
875
+ return lines.join("\n");
876
+ }
877
+ async function handlePreCompact(payload) {
878
+ if (payload.hook_event_name !== "PreCompact") return PASSTHROUGH;
879
+ const cwd = payload.cwd;
880
+ if (!isValidCwd(cwd)) return PASSTHROUGH;
881
+ const projectRoot = findProjectRoot(cwd);
882
+ if (projectRoot === null) return PASSTHROUGH;
883
+ if (isHookDisabled(projectRoot)) return PASSTHROUGH;
884
+ try {
885
+ const [gods, mistakeList, graphStats] = await Promise.all([
886
+ godNodes(projectRoot, MAX_GOD_NODES_COMPACT).catch(() => []),
887
+ mistakes(projectRoot, { limit: MAX_LANDMINES_COMPACT }).catch(
888
+ () => []
889
+ ),
890
+ stats(projectRoot).catch(() => ({
891
+ nodes: 0,
892
+ edges: 0,
893
+ communities: 0,
894
+ extractedPct: 0,
895
+ inferredPct: 0,
896
+ ambiguousPct: 0,
897
+ lastMined: 0,
898
+ totalQueryTokensSaved: 0
899
+ }))
900
+ ]);
901
+ if (graphStats.nodes === 0 && gods.length === 0) return PASSTHROUGH;
902
+ const projectName = basename2(resolve3(projectRoot));
903
+ const text = formatCompactBrief({
904
+ projectName,
905
+ nodeCount: graphStats.nodes,
906
+ edgeCount: graphStats.edges,
907
+ godNodes: gods.map((g) => ({
908
+ label: g.label,
909
+ kind: g.kind,
910
+ sourceFile: g.sourceFile
911
+ })),
912
+ landmines: mistakeList.map((m) => ({
913
+ label: m.label,
914
+ sourceFile: m.sourceFile
915
+ }))
916
+ });
917
+ return buildSessionContextResponse("SessionStart", text);
918
+ } catch {
919
+ return PASSTHROUGH;
920
+ }
921
+ }
922
+
923
+ // src/intercept/handlers/cwd-changed.ts
924
+ import { basename as basename3, resolve as resolve4 } from "path";
925
+ var MAX_GOD_NODES_SWITCH = 5;
926
+ async function handleCwdChanged(payload) {
927
+ if (payload.hook_event_name !== "CwdChanged") return PASSTHROUGH;
928
+ const cwd = payload.cwd;
929
+ if (!isValidCwd(cwd)) return PASSTHROUGH;
930
+ const projectRoot = findProjectRoot(cwd);
931
+ if (projectRoot === null) return PASSTHROUGH;
932
+ if (isHookDisabled(projectRoot)) return PASSTHROUGH;
933
+ try {
934
+ const [gods, graphStats] = await Promise.all([
935
+ godNodes(projectRoot, MAX_GOD_NODES_SWITCH).catch(() => []),
936
+ stats(projectRoot).catch(() => ({
937
+ nodes: 0,
938
+ edges: 0,
939
+ communities: 0,
940
+ extractedPct: 0,
941
+ inferredPct: 0,
942
+ ambiguousPct: 0,
943
+ lastMined: 0,
944
+ totalQueryTokensSaved: 0
945
+ }))
946
+ ]);
947
+ if (graphStats.nodes === 0) return PASSTHROUGH;
948
+ const projectName = basename3(resolve4(projectRoot));
949
+ const lines = [];
950
+ lines.push(
951
+ `[engram] Project switched to ${projectName} (${graphStats.nodes} nodes, ${graphStats.edges} edges)`
952
+ );
953
+ if (gods.length > 0) {
954
+ lines.push("Core entities:");
955
+ for (const g of gods.slice(0, MAX_GOD_NODES_SWITCH)) {
956
+ lines.push(` - ${g.label} [${g.kind}] \u2014 ${g.sourceFile}`);
957
+ }
958
+ }
959
+ lines.push(
960
+ "engram interception is active for this project."
961
+ );
962
+ return buildSessionContextResponse("SessionStart", lines.join("\n"));
963
+ } catch {
964
+ return PASSTHROUGH;
965
+ }
966
+ }
967
+
802
968
  // src/intercept/dispatch.ts
803
969
  function validatePayload(raw) {
804
970
  if (raw === null || typeof raw !== "object") return null;
@@ -829,6 +995,14 @@ async function dispatchHook(rawPayload) {
829
995
  return runHandler(
830
996
  () => handlePostTool(payload)
831
997
  );
998
+ case "PreCompact":
999
+ return runHandler(
1000
+ () => handlePreCompact(payload)
1001
+ );
1002
+ case "CwdChanged":
1003
+ return runHandler(
1004
+ () => handleCwdChanged(payload)
1005
+ );
832
1006
  default:
833
1007
  return PASSTHROUGH;
834
1008
  }
@@ -888,6 +1062,106 @@ function extractPreToolDecision(result) {
888
1062
  return "passthrough";
889
1063
  }
890
1064
 
1065
+ // src/watcher.ts
1066
+ import { watch, existsSync as existsSync5, statSync as statSync3 } from "fs";
1067
+ import { resolve as resolve5, relative as relative2, extname } from "path";
1068
+ var WATCHABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1069
+ ".ts",
1070
+ ".tsx",
1071
+ ".js",
1072
+ ".jsx",
1073
+ ".py",
1074
+ ".go",
1075
+ ".rs",
1076
+ ".java",
1077
+ ".c",
1078
+ ".cpp",
1079
+ ".cs",
1080
+ ".rb"
1081
+ ]);
1082
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1083
+ ".engram",
1084
+ "node_modules",
1085
+ ".git",
1086
+ "dist",
1087
+ "build",
1088
+ ".next",
1089
+ "__pycache__",
1090
+ ".venv",
1091
+ "target",
1092
+ "vendor"
1093
+ ]);
1094
+ var DEBOUNCE_MS = 300;
1095
+ function shouldIgnore(relPath) {
1096
+ const parts = relPath.split(/[/\\]/);
1097
+ return parts.some((p) => IGNORED_DIRS.has(p));
1098
+ }
1099
+ async function reindexFile(absPath, projectRoot) {
1100
+ const ext = extname(absPath).toLowerCase();
1101
+ if (!WATCHABLE_EXTENSIONS.has(ext)) return 0;
1102
+ if (!existsSync5(absPath)) return 0;
1103
+ try {
1104
+ if (statSync3(absPath).isDirectory()) return 0;
1105
+ } catch {
1106
+ return 0;
1107
+ }
1108
+ const relPath = toPosixPath(relative2(projectRoot, absPath));
1109
+ if (shouldIgnore(relPath)) return 0;
1110
+ const store = await getStore(projectRoot);
1111
+ try {
1112
+ store.deleteBySourceFile(relPath);
1113
+ const { nodes, edges } = extractFile(absPath, projectRoot);
1114
+ if (nodes.length > 0 || edges.length > 0) {
1115
+ store.bulkUpsert(nodes, edges);
1116
+ }
1117
+ return nodes.length;
1118
+ } finally {
1119
+ store.close();
1120
+ }
1121
+ }
1122
+ function watchProject(projectRoot, options = {}) {
1123
+ const root = resolve5(projectRoot);
1124
+ const controller = new AbortController();
1125
+ if (!existsSync5(getDbPath(root))) {
1126
+ throw new Error(
1127
+ `engram: no graph found at ${root}. Run 'engram init' first.`
1128
+ );
1129
+ }
1130
+ const debounceTimers = /* @__PURE__ */ new Map();
1131
+ const watcher = watch(root, { recursive: true, signal: controller.signal });
1132
+ watcher.on("change", (_eventType, filename) => {
1133
+ if (typeof filename !== "string") return;
1134
+ const absPath = resolve5(root, filename);
1135
+ const relPath = toPosixPath(relative2(root, absPath));
1136
+ if (shouldIgnore(relPath)) return;
1137
+ const ext = extname(filename).toLowerCase();
1138
+ if (!WATCHABLE_EXTENSIONS.has(ext)) return;
1139
+ const existing = debounceTimers.get(absPath);
1140
+ if (existing) clearTimeout(existing);
1141
+ debounceTimers.set(
1142
+ absPath,
1143
+ setTimeout(async () => {
1144
+ debounceTimers.delete(absPath);
1145
+ try {
1146
+ const count = await reindexFile(absPath, root);
1147
+ if (count > 0) {
1148
+ options.onReindex?.(relPath, count);
1149
+ }
1150
+ } catch (err) {
1151
+ options.onError?.(
1152
+ err instanceof Error ? err : new Error(String(err))
1153
+ );
1154
+ }
1155
+ }, DEBOUNCE_MS)
1156
+ );
1157
+ });
1158
+ watcher.on("error", (err) => {
1159
+ options.onError?.(err instanceof Error ? err : new Error(String(err)));
1160
+ });
1161
+ options.onReady?.();
1162
+ return controller;
1163
+ }
1164
+
891
1165
  // src/intercept/cursor-adapter.ts
892
1166
  var ALLOW = { permission: "allow" };
893
1167
  function toClaudeReadPayload(cursorPayload) {
@@ -931,7 +1205,9 @@ var ENGRAM_HOOK_EVENTS = [
931
1205
  "PreToolUse",
932
1206
  "PostToolUse",
933
1207
  "SessionStart",
934
- "UserPromptSubmit"
1208
+ "UserPromptSubmit",
1209
+ "PreCompact",
1210
+ "CwdChanged"
935
1211
  ];
936
1212
  var ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
937
1213
  var DEFAULT_ENGRAM_COMMAND = "engram intercept";
@@ -959,6 +1235,14 @@ function buildEngramHookEntries(command = DEFAULT_ENGRAM_COMMAND, timeout = DEFA
959
1235
  UserPromptSubmit: {
960
1236
  // No matcher — UserPromptSubmit has no tool name.
961
1237
  hooks: [baseCmd]
1238
+ },
1239
+ PreCompact: {
1240
+ // No matcher — PreCompact has no tool name.
1241
+ hooks: [baseCmd]
1242
+ },
1243
+ CwdChanged: {
1244
+ // No matcher — CwdChanged has no tool name.
1245
+ hooks: [baseCmd]
962
1246
  }
963
1247
  };
964
1248
  }
@@ -1142,13 +1426,13 @@ function formatStatsSummary(summary) {
1142
1426
 
1143
1427
  // src/intercept/memory-md.ts
1144
1428
  import {
1145
- existsSync as existsSync5,
1429
+ existsSync as existsSync6,
1146
1430
  readFileSync as readFileSync3,
1147
1431
  writeFileSync,
1148
1432
  renameSync as renameSync2,
1149
- statSync as statSync3
1433
+ statSync as statSync4
1150
1434
  } from "fs";
1151
- import { join as join5 } from "path";
1435
+ import { join as join6 } from "path";
1152
1436
  var ENGRAM_MARKER_START = "<!-- engram:structural-facts:start -->";
1153
1437
  var ENGRAM_MARKER_END = "<!-- engram:structural-facts:end -->";
1154
1438
  var MAX_MEMORY_FILE_BYTES = 1e6;
@@ -1219,11 +1503,11 @@ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1219
1503
  if (engramSection.length > MAX_ENGRAM_SECTION_BYTES) {
1220
1504
  return false;
1221
1505
  }
1222
- const memoryPath = join5(projectRoot, "MEMORY.md");
1506
+ const memoryPath = join6(projectRoot, "MEMORY.md");
1223
1507
  try {
1224
1508
  let existing = "";
1225
- if (existsSync5(memoryPath)) {
1226
- const st = statSync3(memoryPath);
1509
+ if (existsSync6(memoryPath)) {
1510
+ const st = statSync4(memoryPath);
1227
1511
  if (st.size > MAX_MEMORY_FILE_BYTES) {
1228
1512
  return false;
1229
1513
  }
@@ -1240,7 +1524,7 @@ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1240
1524
  }
1241
1525
 
1242
1526
  // src/cli.ts
1243
- import { basename as basename2 } from "path";
1527
+ import { basename as basename4 } from "path";
1244
1528
  var program = new Command();
1245
1529
  program.name("engram").description(
1246
1530
  "Context as infra for AI coding tools \u2014 hook-based Read/Edit interception + structural graph summaries"
@@ -1276,6 +1560,48 @@ program.command("init").description("Scan codebase and build knowledge graph (ze
1276
1560
  }
1277
1561
  console.log(chalk.green("\n\u2705 Ready. Your AI now has persistent memory."));
1278
1562
  console.log(chalk.dim(" Graph stored in .engram/graph.db"));
1563
+ const resolvedProject = pathResolve(projectPath);
1564
+ const localSettings = join7(resolvedProject, ".claude", "settings.local.json");
1565
+ const projectSettings = join7(resolvedProject, ".claude", "settings.json");
1566
+ const hasHooks = existsSync7(localSettings) && readFileSync4(localSettings, "utf-8").includes("engram intercept") || existsSync7(projectSettings) && readFileSync4(projectSettings, "utf-8").includes("engram intercept");
1567
+ if (!hasHooks) {
1568
+ console.log(
1569
+ chalk.yellow("\n\u{1F4A1} Next step: ") + chalk.white("engram install-hook") + chalk.dim(
1570
+ " \u2014 enables automatic Read interception (82% token savings)"
1571
+ )
1572
+ );
1573
+ console.log(
1574
+ chalk.dim(
1575
+ " Also recommended: " + chalk.white("engram hooks install") + " \u2014 auto-rebuild graph on git commit"
1576
+ )
1577
+ );
1578
+ }
1579
+ });
1580
+ program.command("watch").description("Watch project for file changes and re-index incrementally").argument("[path]", "Project directory", ".").action(async (projectPath) => {
1581
+ const resolvedPath = pathResolve(projectPath);
1582
+ console.log(
1583
+ chalk.dim("\u{1F441} Watching ") + chalk.white(resolvedPath) + chalk.dim(" for changes...")
1584
+ );
1585
+ const controller = watchProject(resolvedPath, {
1586
+ onReindex: (filePath, nodeCount) => {
1587
+ console.log(
1588
+ chalk.green(" \u21BB ") + chalk.white(filePath) + chalk.dim(` (${nodeCount} nodes)`)
1589
+ );
1590
+ },
1591
+ onError: (err) => {
1592
+ console.error(chalk.red(" \u2717 ") + err.message);
1593
+ },
1594
+ onReady: () => {
1595
+ console.log(chalk.green(" \u2713 Watcher active.") + chalk.dim(" Press Ctrl+C to stop."));
1596
+ }
1597
+ });
1598
+ process.on("SIGINT", () => {
1599
+ controller.abort();
1600
+ console.log(chalk.dim("\n Watcher stopped."));
1601
+ process.exit(0);
1602
+ });
1603
+ await new Promise(() => {
1604
+ });
1279
1605
  });
1280
1606
  program.command("query").description("Query the knowledge graph").argument("<question>", "Natural language question or keywords").option("--dfs", "Use DFS traversal", false).option("-d, --depth <n>", "Traversal depth", "3").option("-b, --budget <n>", "Token budget", "2000").option("-p, --project <path>", "Project directory", ".").action(async (question, opts) => {
1281
1607
  const result = await query(opts.project, question, {
@@ -1402,11 +1728,11 @@ function resolveSettingsPath(scope, projectPath) {
1402
1728
  const absProject = pathResolve(projectPath);
1403
1729
  switch (scope) {
1404
1730
  case "local":
1405
- return join6(absProject, ".claude", "settings.local.json");
1731
+ return join7(absProject, ".claude", "settings.local.json");
1406
1732
  case "project":
1407
- return join6(absProject, ".claude", "settings.json");
1733
+ return join7(absProject, ".claude", "settings.json");
1408
1734
  case "user":
1409
- return join6(homedir(), ".claude", "settings.json");
1735
+ return join7(homedir(), ".claude", "settings.json");
1410
1736
  default:
1411
1737
  return null;
1412
1738
  }
@@ -1417,23 +1743,28 @@ program.command("intercept").description(
1417
1743
  const stdinTimeout = setTimeout(() => {
1418
1744
  process.exit(0);
1419
1745
  }, 3e3);
1746
+ stdinTimeout.unref();
1420
1747
  let input = "";
1748
+ let stdinFailed = false;
1421
1749
  try {
1422
1750
  for await (const chunk of process.stdin) {
1423
1751
  input += chunk;
1424
1752
  if (input.length > 1e6) break;
1425
1753
  }
1426
1754
  } catch {
1427
- clearTimeout(stdinTimeout);
1428
- process.exit(0);
1755
+ stdinFailed = true;
1429
1756
  }
1430
1757
  clearTimeout(stdinTimeout);
1431
- if (!input.trim()) process.exit(0);
1758
+ if (stdinFailed || !input.trim()) {
1759
+ process.exitCode = 0;
1760
+ return;
1761
+ }
1432
1762
  let payload;
1433
1763
  try {
1434
1764
  payload = JSON.parse(input);
1435
1765
  } catch {
1436
- process.exit(0);
1766
+ process.exitCode = 0;
1767
+ return;
1437
1768
  }
1438
1769
  try {
1439
1770
  const result = await dispatchHook(payload);
@@ -1442,7 +1773,7 @@ program.command("intercept").description(
1442
1773
  }
1443
1774
  } catch {
1444
1775
  }
1445
- process.exit(0);
1776
+ process.exitCode = 0;
1446
1777
  });
1447
1778
  program.command("cursor-intercept").description(
1448
1779
  "Cursor beforeReadFile hook entry point (experimental). Reads JSON from stdin, writes Cursor-shaped response JSON to stdout."
@@ -1495,7 +1826,7 @@ program.command("install-hook").description("Install engram hook entries into Cl
1495
1826
  process.exit(1);
1496
1827
  }
1497
1828
  let existing = {};
1498
- if (existsSync6(settingsPath)) {
1829
+ if (existsSync7(settingsPath)) {
1499
1830
  try {
1500
1831
  const raw = readFileSync4(settingsPath, "utf-8");
1501
1832
  existing = raw.trim() ? JSON.parse(raw) : {};
@@ -1543,7 +1874,7 @@ program.command("install-hook").description("Install engram hook entries into Cl
1543
1874
  }
1544
1875
  try {
1545
1876
  mkdirSync(dirname3(settingsPath), { recursive: true });
1546
- if (existsSync6(settingsPath)) {
1877
+ if (existsSync7(settingsPath)) {
1547
1878
  const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1548
1879
  copyFileSync(settingsPath, backupPath);
1549
1880
  console.log(chalk.dim(` Backup: ${backupPath}`));
@@ -1587,7 +1918,7 @@ program.command("uninstall-hook").description("Remove engram hook entries from C
1587
1918
  console.error(chalk.red(`Unknown scope: ${opts.scope}`));
1588
1919
  process.exit(1);
1589
1920
  }
1590
- if (!existsSync6(settingsPath)) {
1921
+ if (!existsSync7(settingsPath)) {
1591
1922
  console.log(
1592
1923
  chalk.yellow(`No settings file at ${settingsPath} \u2014 nothing to remove.`)
1593
1924
  );
@@ -1703,7 +2034,7 @@ program.command("hook-disable").description("Disable engram hooks via kill switc
1703
2034
  console.error(chalk.dim("Run 'engram init' first."));
1704
2035
  process.exit(1);
1705
2036
  }
1706
- const flagPath = join6(projectRoot, ".engram", "hook-disabled");
2037
+ const flagPath = join7(projectRoot, ".engram", "hook-disabled");
1707
2038
  try {
1708
2039
  writeFileSync2(flagPath, (/* @__PURE__ */ new Date()).toISOString());
1709
2040
  console.log(
@@ -1727,8 +2058,8 @@ program.command("hook-enable").description("Re-enable engram hooks (remove kill
1727
2058
  console.error(chalk.red(`Not an engram project: ${absProject}`));
1728
2059
  process.exit(1);
1729
2060
  }
1730
- const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1731
- if (!existsSync6(flagPath)) {
2061
+ const flagPath = join7(projectRoot, ".engram", "hook-disabled");
2062
+ if (!existsSync7(flagPath)) {
1732
2063
  console.log(
1733
2064
  chalk.yellow(`engram hooks already enabled for ${projectRoot}`)
1734
2065
  );
@@ -1770,8 +2101,8 @@ program.command("memory-sync").description(
1770
2101
  }
1771
2102
  let branch = null;
1772
2103
  try {
1773
- const headPath = join6(projectRoot, ".git", "HEAD");
1774
- if (existsSync6(headPath)) {
2104
+ const headPath = join7(projectRoot, ".git", "HEAD");
2105
+ if (existsSync7(headPath)) {
1775
2106
  const content = readFileSync4(headPath, "utf-8").trim();
1776
2107
  const m = content.match(/^ref:\s+refs\/heads\/(.+)$/);
1777
2108
  if (m) branch = m[1];
@@ -1779,7 +2110,7 @@ program.command("memory-sync").description(
1779
2110
  } catch {
1780
2111
  }
1781
2112
  const section = buildEngramSection({
1782
- projectName: basename2(projectRoot),
2113
+ projectName: basename4(projectRoot),
1783
2114
  branch,
1784
2115
  stats: {
1785
2116
  nodes: graphStats.nodes,
@@ -1798,7 +2129,7 @@ program.command("memory-sync").description(
1798
2129
  \u{1F4DD} engram memory-sync`)
1799
2130
  );
1800
2131
  console.log(
1801
- chalk.dim(` Target: ${join6(projectRoot, "MEMORY.md")}`)
2132
+ chalk.dim(` Target: ${join7(projectRoot, "MEMORY.md")}`)
1802
2133
  );
1803
2134
  if (opts.dryRun) {
1804
2135
  console.log(chalk.cyan("\n Section to write (dry-run):\n"));
@@ -11,7 +11,7 @@ import {
11
11
  path,
12
12
  query,
13
13
  stats
14
- } from "./chunk-RGDHLGWQ.js";
14
+ } from "./chunk-R46DNLNR.js";
15
15
  export {
16
16
  benchmark,
17
17
  computeKeywordIDF,
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  generateSummary,
5
5
  install,
6
6
  uninstall
7
- } from "./chunk-IYO4HETA.js";
7
+ } from "./chunk-ESPAWLH6.js";
8
8
  import {
9
9
  GraphStore,
10
10
  SUPPORTED_EXTENSIONS,
@@ -23,7 +23,7 @@ import {
23
23
  sliceGraphemeSafe,
24
24
  stats,
25
25
  truncateGraphemeSafe
26
- } from "./chunk-RGDHLGWQ.js";
26
+ } from "./chunk-R46DNLNR.js";
27
27
  export {
28
28
  GraphStore,
29
29
  SUPPORTED_EXTENSIONS,
package/dist/serve.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  query,
9
9
  stats,
10
10
  truncateGraphemeSafe
11
- } from "./chunk-RGDHLGWQ.js";
11
+ } from "./chunk-R46DNLNR.js";
12
12
 
13
13
  // src/serve.ts
14
14
  function clampInt(value, defaultValue, min, max) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engramx",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "The structural code graph your AI agent can't forget to use. A Claude Code hook layer that intercepts Read/Edit/Write/Bash and replaces file contents with ~300-token structural graph summaries. 82% measured token reduction. Context rot is empirically solved — cite Chroma. Local SQLite, zero LLM cost, zero cloud, zero native deps.",
5
5
  "type": "module",
6
6
  "bin": {