engramx 0.3.2 → 0.4.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/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-467%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">
@@ -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-AJD3SS6U.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);
package/dist/cli.js CHANGED
@@ -4,11 +4,14 @@ import {
4
4
  install,
5
5
  status,
6
6
  uninstall
7
- } from "./chunk-JXJNXQUM.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,
@@ -17,13 +20,13 @@ import {
17
20
  query,
18
21
  stats,
19
22
  toPosixPath
20
- } from "./chunk-3NUHMLRV.js";
23
+ } from "./chunk-R46DNLNR.js";
21
24
 
22
25
  // src/cli.ts
23
26
  import { Command } from "commander";
24
27
  import chalk from "chalk";
25
28
  import {
26
- existsSync as existsSync6,
29
+ existsSync as existsSync7,
27
30
  readFileSync as readFileSync4,
28
31
  writeFileSync as writeFileSync2,
29
32
  mkdirSync,
@@ -31,7 +34,7 @@ import {
31
34
  copyFileSync,
32
35
  renameSync as renameSync3
33
36
  } from "fs";
34
- 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";
35
38
  import { homedir } from "os";
36
39
 
37
40
  // src/intercept/safety.ts
@@ -41,8 +44,8 @@ var PASSTHROUGH = null;
41
44
  var DEFAULT_HANDLER_TIMEOUT_MS = 2e3;
42
45
  async function withTimeout(promise, ms = DEFAULT_HANDLER_TIMEOUT_MS) {
43
46
  let timer;
44
- const timeout = new Promise((resolve3) => {
45
- timer = setTimeout(() => resolve3(PASSTHROUGH), ms);
47
+ const timeout = new Promise((resolve6) => {
48
+ timer = setTimeout(() => resolve6(PASSTHROUGH), ms);
46
49
  });
47
50
  try {
48
51
  return await Promise.race([promise, timeout]);
@@ -440,7 +443,10 @@ async function handleBash(payload) {
440
443
 
441
444
  // src/intercept/handlers/session-start.ts
442
445
  import { existsSync as existsSync3, readFileSync } from "fs";
446
+ import { execFile } from "child_process";
447
+ import { promisify } from "util";
443
448
  import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "path";
449
+ var execFileAsync = promisify(execFile);
444
450
  var MAX_GOD_NODES = 10;
445
451
  var MAX_LANDMINES_IN_BRIEF = 3;
446
452
  function readGitBranch(projectRoot) {
@@ -494,6 +500,37 @@ function formatBrief(args) {
494
500
  );
495
501
  return lines.join("\n");
496
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
+ }
497
534
  function describeAgo(ms) {
498
535
  if (ms < 0) return "just now";
499
536
  const s = Math.floor(ms / 1e3);
@@ -515,7 +552,9 @@ async function handleSessionStart(payload) {
515
552
  if (projectRoot === null) return PASSTHROUGH;
516
553
  if (isHookDisabled(projectRoot)) return PASSTHROUGH;
517
554
  try {
518
- 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([
519
558
  godNodes(projectRoot, MAX_GOD_NODES).catch(() => []),
520
559
  mistakes(projectRoot, { limit: MAX_LANDMINES_IN_BRIEF }).catch(
521
560
  () => []
@@ -529,11 +568,10 @@ async function handleSessionStart(payload) {
529
568
  ambiguousPct: 0,
530
569
  lastMined: 0,
531
570
  totalQueryTokensSaved: 0
532
- }))
571
+ })),
572
+ queryMempalace(projectName)
533
573
  ]);
534
574
  if (graphStats.nodes === 0 && gods.length === 0) return PASSTHROUGH;
535
- const branch = readGitBranch(projectRoot);
536
- const projectName = basename(projectRoot);
537
575
  const text = formatBrief({
538
576
  projectName,
539
577
  branch,
@@ -549,7 +587,8 @@ async function handleSessionStart(payload) {
549
587
  sourceFile: m.sourceFile
550
588
  }))
551
589
  });
552
- return buildSessionContextResponse("SessionStart", text);
590
+ const fullText = mempalaceContext ? text + "\n\n" + mempalaceContext : text;
591
+ return buildSessionContextResponse("SessionStart", fullText);
553
592
  } catch {
554
593
  return PASSTHROUGH;
555
594
  }
@@ -809,6 +848,123 @@ async function handlePostTool(payload) {
809
848
  return PASSTHROUGH;
810
849
  }
811
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
+
812
968
  // src/intercept/dispatch.ts
813
969
  function validatePayload(raw) {
814
970
  if (raw === null || typeof raw !== "object") return null;
@@ -839,6 +995,14 @@ async function dispatchHook(rawPayload) {
839
995
  return runHandler(
840
996
  () => handlePostTool(payload)
841
997
  );
998
+ case "PreCompact":
999
+ return runHandler(
1000
+ () => handlePreCompact(payload)
1001
+ );
1002
+ case "CwdChanged":
1003
+ return runHandler(
1004
+ () => handleCwdChanged(payload)
1005
+ );
842
1006
  default:
843
1007
  return PASSTHROUGH;
844
1008
  }
@@ -898,6 +1062,106 @@ function extractPreToolDecision(result) {
898
1062
  return "passthrough";
899
1063
  }
900
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
+
901
1165
  // src/intercept/cursor-adapter.ts
902
1166
  var ALLOW = { permission: "allow" };
903
1167
  function toClaudeReadPayload(cursorPayload) {
@@ -941,7 +1205,9 @@ var ENGRAM_HOOK_EVENTS = [
941
1205
  "PreToolUse",
942
1206
  "PostToolUse",
943
1207
  "SessionStart",
944
- "UserPromptSubmit"
1208
+ "UserPromptSubmit",
1209
+ "PreCompact",
1210
+ "CwdChanged"
945
1211
  ];
946
1212
  var ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
947
1213
  var DEFAULT_ENGRAM_COMMAND = "engram intercept";
@@ -969,6 +1235,14 @@ function buildEngramHookEntries(command = DEFAULT_ENGRAM_COMMAND, timeout = DEFA
969
1235
  UserPromptSubmit: {
970
1236
  // No matcher — UserPromptSubmit has no tool name.
971
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]
972
1246
  }
973
1247
  };
974
1248
  }
@@ -1152,13 +1426,13 @@ function formatStatsSummary(summary) {
1152
1426
 
1153
1427
  // src/intercept/memory-md.ts
1154
1428
  import {
1155
- existsSync as existsSync5,
1429
+ existsSync as existsSync6,
1156
1430
  readFileSync as readFileSync3,
1157
1431
  writeFileSync,
1158
1432
  renameSync as renameSync2,
1159
- statSync as statSync3
1433
+ statSync as statSync4
1160
1434
  } from "fs";
1161
- import { join as join5 } from "path";
1435
+ import { join as join6 } from "path";
1162
1436
  var ENGRAM_MARKER_START = "<!-- engram:structural-facts:start -->";
1163
1437
  var ENGRAM_MARKER_END = "<!-- engram:structural-facts:end -->";
1164
1438
  var MAX_MEMORY_FILE_BYTES = 1e6;
@@ -1229,11 +1503,11 @@ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1229
1503
  if (engramSection.length > MAX_ENGRAM_SECTION_BYTES) {
1230
1504
  return false;
1231
1505
  }
1232
- const memoryPath = join5(projectRoot, "MEMORY.md");
1506
+ const memoryPath = join6(projectRoot, "MEMORY.md");
1233
1507
  try {
1234
1508
  let existing = "";
1235
- if (existsSync5(memoryPath)) {
1236
- const st = statSync3(memoryPath);
1509
+ if (existsSync6(memoryPath)) {
1510
+ const st = statSync4(memoryPath);
1237
1511
  if (st.size > MAX_MEMORY_FILE_BYTES) {
1238
1512
  return false;
1239
1513
  }
@@ -1250,11 +1524,14 @@ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1250
1524
  }
1251
1525
 
1252
1526
  // src/cli.ts
1253
- import { basename as basename2 } from "path";
1527
+ import { basename as basename4 } from "path";
1528
+ import { createRequire } from "module";
1529
+ var require2 = createRequire(import.meta.url);
1530
+ var { version: PKG_VERSION } = require2("../package.json");
1254
1531
  var program = new Command();
1255
1532
  program.name("engram").description(
1256
1533
  "Context as infra for AI coding tools \u2014 hook-based Read/Edit interception + structural graph summaries"
1257
- ).version("0.3.0");
1534
+ ).version(PKG_VERSION);
1258
1535
  program.command("init").description("Scan codebase and build knowledge graph (zero LLM cost)").argument("[path]", "Project directory", ".").option(
1259
1536
  "--with-skills [dir]",
1260
1537
  "Also index Claude Code skills from ~/.claude/skills/ or a given path"
@@ -1287,9 +1564,9 @@ program.command("init").description("Scan codebase and build knowledge graph (ze
1287
1564
  console.log(chalk.green("\n\u2705 Ready. Your AI now has persistent memory."));
1288
1565
  console.log(chalk.dim(" Graph stored in .engram/graph.db"));
1289
1566
  const resolvedProject = pathResolve(projectPath);
1290
- const localSettings = join6(resolvedProject, ".claude", "settings.local.json");
1291
- const projectSettings = join6(resolvedProject, ".claude", "settings.json");
1292
- const hasHooks = existsSync6(localSettings) && readFileSync4(localSettings, "utf-8").includes("engram intercept") || existsSync6(projectSettings) && readFileSync4(projectSettings, "utf-8").includes("engram intercept");
1567
+ const localSettings = join7(resolvedProject, ".claude", "settings.local.json");
1568
+ const projectSettings = join7(resolvedProject, ".claude", "settings.json");
1569
+ const hasHooks = existsSync7(localSettings) && readFileSync4(localSettings, "utf-8").includes("engram intercept") || existsSync7(projectSettings) && readFileSync4(projectSettings, "utf-8").includes("engram intercept");
1293
1570
  if (!hasHooks) {
1294
1571
  console.log(
1295
1572
  chalk.yellow("\n\u{1F4A1} Next step: ") + chalk.white("engram install-hook") + chalk.dim(
@@ -1303,6 +1580,32 @@ program.command("init").description("Scan codebase and build knowledge graph (ze
1303
1580
  );
1304
1581
  }
1305
1582
  });
1583
+ program.command("watch").description("Watch project for file changes and re-index incrementally").argument("[path]", "Project directory", ".").action(async (projectPath) => {
1584
+ const resolvedPath = pathResolve(projectPath);
1585
+ console.log(
1586
+ chalk.dim("\u{1F441} Watching ") + chalk.white(resolvedPath) + chalk.dim(" for changes...")
1587
+ );
1588
+ const controller = watchProject(resolvedPath, {
1589
+ onReindex: (filePath, nodeCount) => {
1590
+ console.log(
1591
+ chalk.green(" \u21BB ") + chalk.white(filePath) + chalk.dim(` (${nodeCount} nodes)`)
1592
+ );
1593
+ },
1594
+ onError: (err) => {
1595
+ console.error(chalk.red(" \u2717 ") + err.message);
1596
+ },
1597
+ onReady: () => {
1598
+ console.log(chalk.green(" \u2713 Watcher active.") + chalk.dim(" Press Ctrl+C to stop."));
1599
+ }
1600
+ });
1601
+ process.on("SIGINT", () => {
1602
+ controller.abort();
1603
+ console.log(chalk.dim("\n Watcher stopped."));
1604
+ process.exit(0);
1605
+ });
1606
+ await new Promise(() => {
1607
+ });
1608
+ });
1306
1609
  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) => {
1307
1610
  const result = await query(opts.project, question, {
1308
1611
  mode: opts.dfs ? "dfs" : "bfs",
@@ -1428,11 +1731,11 @@ function resolveSettingsPath(scope, projectPath) {
1428
1731
  const absProject = pathResolve(projectPath);
1429
1732
  switch (scope) {
1430
1733
  case "local":
1431
- return join6(absProject, ".claude", "settings.local.json");
1734
+ return join7(absProject, ".claude", "settings.local.json");
1432
1735
  case "project":
1433
- return join6(absProject, ".claude", "settings.json");
1736
+ return join7(absProject, ".claude", "settings.json");
1434
1737
  case "user":
1435
- return join6(homedir(), ".claude", "settings.json");
1738
+ return join7(homedir(), ".claude", "settings.json");
1436
1739
  default:
1437
1740
  return null;
1438
1741
  }
@@ -1526,7 +1829,7 @@ program.command("install-hook").description("Install engram hook entries into Cl
1526
1829
  process.exit(1);
1527
1830
  }
1528
1831
  let existing = {};
1529
- if (existsSync6(settingsPath)) {
1832
+ if (existsSync7(settingsPath)) {
1530
1833
  try {
1531
1834
  const raw = readFileSync4(settingsPath, "utf-8");
1532
1835
  existing = raw.trim() ? JSON.parse(raw) : {};
@@ -1574,7 +1877,7 @@ program.command("install-hook").description("Install engram hook entries into Cl
1574
1877
  }
1575
1878
  try {
1576
1879
  mkdirSync(dirname3(settingsPath), { recursive: true });
1577
- if (existsSync6(settingsPath)) {
1880
+ if (existsSync7(settingsPath)) {
1578
1881
  const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1579
1882
  copyFileSync(settingsPath, backupPath);
1580
1883
  console.log(chalk.dim(` Backup: ${backupPath}`));
@@ -1618,7 +1921,7 @@ program.command("uninstall-hook").description("Remove engram hook entries from C
1618
1921
  console.error(chalk.red(`Unknown scope: ${opts.scope}`));
1619
1922
  process.exit(1);
1620
1923
  }
1621
- if (!existsSync6(settingsPath)) {
1924
+ if (!existsSync7(settingsPath)) {
1622
1925
  console.log(
1623
1926
  chalk.yellow(`No settings file at ${settingsPath} \u2014 nothing to remove.`)
1624
1927
  );
@@ -1734,7 +2037,7 @@ program.command("hook-disable").description("Disable engram hooks via kill switc
1734
2037
  console.error(chalk.dim("Run 'engram init' first."));
1735
2038
  process.exit(1);
1736
2039
  }
1737
- const flagPath = join6(projectRoot, ".engram", "hook-disabled");
2040
+ const flagPath = join7(projectRoot, ".engram", "hook-disabled");
1738
2041
  try {
1739
2042
  writeFileSync2(flagPath, (/* @__PURE__ */ new Date()).toISOString());
1740
2043
  console.log(
@@ -1758,8 +2061,8 @@ program.command("hook-enable").description("Re-enable engram hooks (remove kill
1758
2061
  console.error(chalk.red(`Not an engram project: ${absProject}`));
1759
2062
  process.exit(1);
1760
2063
  }
1761
- const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1762
- if (!existsSync6(flagPath)) {
2064
+ const flagPath = join7(projectRoot, ".engram", "hook-disabled");
2065
+ if (!existsSync7(flagPath)) {
1763
2066
  console.log(
1764
2067
  chalk.yellow(`engram hooks already enabled for ${projectRoot}`)
1765
2068
  );
@@ -1801,8 +2104,8 @@ program.command("memory-sync").description(
1801
2104
  }
1802
2105
  let branch = null;
1803
2106
  try {
1804
- const headPath = join6(projectRoot, ".git", "HEAD");
1805
- if (existsSync6(headPath)) {
2107
+ const headPath = join7(projectRoot, ".git", "HEAD");
2108
+ if (existsSync7(headPath)) {
1806
2109
  const content = readFileSync4(headPath, "utf-8").trim();
1807
2110
  const m = content.match(/^ref:\s+refs\/heads\/(.+)$/);
1808
2111
  if (m) branch = m[1];
@@ -1810,7 +2113,7 @@ program.command("memory-sync").description(
1810
2113
  } catch {
1811
2114
  }
1812
2115
  const section = buildEngramSection({
1813
- projectName: basename2(projectRoot),
2116
+ projectName: basename4(projectRoot),
1814
2117
  branch,
1815
2118
  stats: {
1816
2119
  nodes: graphStats.nodes,
@@ -1829,7 +2132,7 @@ program.command("memory-sync").description(
1829
2132
  \u{1F4DD} engram memory-sync`)
1830
2133
  );
1831
2134
  console.log(
1832
- chalk.dim(` Target: ${join6(projectRoot, "MEMORY.md")}`)
2135
+ chalk.dim(` Target: ${join7(projectRoot, "MEMORY.md")}`)
1833
2136
  );
1834
2137
  if (opts.dryRun) {
1835
2138
  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-3NUHMLRV.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-JXJNXQUM.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-3NUHMLRV.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-3NUHMLRV.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.2",
3
+ "version": "0.4.1",
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": {