engramx 0.3.0 → 0.3.2

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,20 +15,36 @@
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-467%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
+ <img src="https://img.shields.io/badge/token%20reduction-82%25-orange" alt="82% token reduction">
21
22
  </p>
22
23
 
23
24
  ---
24
25
 
25
- **Context as infra for your AI coding tools.**
26
+ # The structural code graph your AI agent can't forget to use.
26
27
 
27
- engram installs a Claude Code hook layer that intercepts every `Read`, `Edit`, `Write`, and `Bash cat` replacing full file reads with ~300-token structural graph summaries *before the agent even sees them*. No more re-exploring the codebase every session. No more agents forgetting to use the tool you gave them.
28
+ **Context rot is empirically solved.** Chroma's July 2025 research proved that even Claude Opus 4.6 scores only 76% on MRCR v2 8-needle at 1M tokens. Long context windows don't save you they drown you. engram is the answer: a **structural graph** of your codebase that replaces file reads with ~300-token summaries *before the agent sees them*.
28
29
 
29
- **v0.3 "Sentinel":** the agent can't forget to use engram because engram sits between the agent and the filesystem.
30
+ engram installs a Claude Code hook layer at the tool-call boundary. Every `Read`, `Edit`, `Write`, and `Bash cat` gets intercepted. When the graph has confident coverage of a file, the raw read never happens — the agent sees a structural summary instead.
30
31
 
31
- Zero LLM cost. Zero cloud. Zero native deps. Works today in Claude Code.
32
+ Not a memory tool. Not a RAG layer. Not a context manager. **A structural code graph with a Claude Code hook layer that turns it into the memory your agent can't forget to exist.**
33
+
34
+ | What it is | What it isn't |
35
+ |---|---|
36
+ | Structural code graph (AST + git + session miners) | Prose memory like Anthropic's MEMORY.md |
37
+ | Local SQLite, zero cloud, zero native deps | Vector RAG that phones home |
38
+ | Hook-based interception at the tool boundary | A tool the agent has to remember to call |
39
+ | 82% measured token reduction on real code | Another LongMemEval chatbot benchmark |
40
+ | Complements native Claude memory | Competes with native Claude memory |
41
+
42
+ ```bash
43
+ npm install -g engramx
44
+ cd ~/my-project
45
+ engram init # scan codebase → .engram/graph.db (~40 ms, 0 tokens)
46
+ engram install-hook # wire the Sentinel into Claude Code
47
+ ```
32
48
 
33
49
  ```bash
34
50
  npm install -g engramx
@@ -45,6 +61,13 @@ That's it. The next Claude Code session in that directory automatically:
45
61
  - **Injects a project brief at session start** (SessionStart additionalContext)
46
62
  - **Logs every decision for `engram hook-stats`** (PostToolUse observer)
47
63
 
64
+ ## Architecture Diagram
65
+
66
+ An 11-page visual walkthrough of the full lifecycle — the four hook events, the Read handler's 9-branch decision tree (with a real JSON response), the six-layer ecosystem substrate, and measured numbers from engram's own code.
67
+
68
+ - 📄 **[View the PDF](docs/engram-sentinel-ecosystem.pdf)** — A3 landscape, 11 pages, 1 MB. GitHub renders it inline when clicked.
69
+ - 🌐 **[HTML source](docs/engram-sentinel-ecosystem.html)** — single self-contained file. Download raw and open in any browser for the interactive scroll-reveal version.
70
+
48
71
  ## The Problem
49
72
 
50
73
  Every Claude Code session burns ~52,500 tokens on things you already told the agent yesterday. Reading the same files, re-exploring the same modules, re-discovering the same patterns. Even with a great CLAUDE.md, the agent still falls back to `Read` because `Read` is what it knows.
@@ -87,6 +110,37 @@ engram hook-enable # re-enable
87
110
  engram uninstall-hook # surgical removal, preserves other hooks
88
111
  ```
89
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
+
90
144
  ## All Commands
91
145
 
92
146
  ### Core (v0.1/v0.2 — unchanged)
@@ -627,6 +627,12 @@ function renderFileStructure(store, relativeFilePath, tokenBudget = 600) {
627
627
  };
628
628
  }
629
629
 
630
+ // src/graph/path-utils.ts
631
+ function toPosixPath(p) {
632
+ if (!p) return p;
633
+ return p.replace(/\\/g, "/");
634
+ }
635
+
630
636
  // src/miners/ast-miner.ts
631
637
  import { readFileSync as readFileSync2, readdirSync, realpathSync } from "fs";
632
638
  import { basename, extname, join, relative } from "path";
@@ -716,7 +722,7 @@ function extractFile(filePath, rootDir) {
716
722
  if (!lang) return { nodes: [], edges: [] };
717
723
  const content = readFileSync2(filePath, "utf-8");
718
724
  const lines = content.split("\n");
719
- const relPath = relative(rootDir, filePath);
725
+ const relPath = toPosixPath(relative(rootDir, filePath));
720
726
  const stem = basename(filePath, ext);
721
727
  const now = Date.now();
722
728
  const nodes = [];
@@ -1230,7 +1236,7 @@ function parseFrontmatter(content) {
1230
1236
  }
1231
1237
  function parseYaml(block) {
1232
1238
  const data = {};
1233
- const lines = block.split("\n");
1239
+ const lines = block.replace(/\r/g, "").split("\n");
1234
1240
  let i = 0;
1235
1241
  while (i < lines.length) {
1236
1242
  const line = lines[i];
@@ -1606,7 +1612,7 @@ async function getFileContext(projectRoot, absFilePath) {
1606
1612
  try {
1607
1613
  const root = resolve2(projectRoot);
1608
1614
  const abs = resolve2(absFilePath);
1609
- const relPath = relative2(root, abs);
1615
+ const relPath = toPosixPath(relative2(root, abs));
1610
1616
  if (relPath.startsWith("..") || relPath === "") {
1611
1617
  return empty;
1612
1618
  }
@@ -1660,6 +1666,41 @@ async function getFileContext(projectRoot, absFilePath) {
1660
1666
  return empty;
1661
1667
  }
1662
1668
  }
1669
+ async function computeKeywordIDF(projectRoot, keywords) {
1670
+ if (keywords.length === 0) return [];
1671
+ try {
1672
+ const root = resolve2(projectRoot);
1673
+ const dbPath = getDbPath(root);
1674
+ if (!existsSync5(dbPath)) return [];
1675
+ const store = await getStore(root);
1676
+ try {
1677
+ const allNodes = store.getAllNodes();
1678
+ const total = allNodes.length;
1679
+ if (total === 0) return [];
1680
+ const labels = allNodes.map((n) => n.label.toLowerCase());
1681
+ const results = [];
1682
+ for (const kw of keywords) {
1683
+ const kwLower = kw.toLowerCase();
1684
+ let df = 0;
1685
+ for (const label of labels) {
1686
+ if (label.includes(kwLower)) df += 1;
1687
+ }
1688
+ const idf = df === 0 ? 0 : Math.log(total / df);
1689
+ results.push({
1690
+ keyword: kw,
1691
+ documentFrequency: df,
1692
+ idf
1693
+ });
1694
+ }
1695
+ results.sort((a, b) => b.idf - a.idf);
1696
+ return results;
1697
+ } finally {
1698
+ store.close();
1699
+ }
1700
+ } catch {
1701
+ return [];
1702
+ }
1703
+ }
1663
1704
  async function learn(projectRoot, text, sourceLabel = "manual") {
1664
1705
  const { nodes, edges } = learnFromSession(text, sourceLabel);
1665
1706
  if (nodes.length === 0 && edges.length === 0) return { nodesAdded: 0 };
@@ -1766,6 +1807,7 @@ export {
1766
1807
  MAX_MISTAKE_LABEL_CHARS,
1767
1808
  queryGraph,
1768
1809
  shortestPath,
1810
+ toPosixPath,
1769
1811
  SUPPORTED_EXTENSIONS,
1770
1812
  extractFile,
1771
1813
  extractDirectory,
@@ -1778,6 +1820,7 @@ export {
1778
1820
  godNodes,
1779
1821
  stats,
1780
1822
  getFileContext,
1823
+ computeKeywordIDF,
1781
1824
  learn,
1782
1825
  mistakes,
1783
1826
  benchmark
@@ -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-MPNNCPFW.js");
313
+ const { getStore } = await import("./core-AJD3SS6U.js");
314
314
  const store = await getStore(projectRoot);
315
315
  try {
316
316
  let view = VIEWS.general;
package/dist/cli.js CHANGED
@@ -4,9 +4,10 @@ import {
4
4
  install,
5
5
  status,
6
6
  uninstall
7
- } from "./chunk-7KYR4SPZ.js";
7
+ } from "./chunk-JXJNXQUM.js";
8
8
  import {
9
9
  benchmark,
10
+ computeKeywordIDF,
10
11
  getFileContext,
11
12
  godNodes,
12
13
  init,
@@ -14,22 +15,23 @@ import {
14
15
  mistakes,
15
16
  path,
16
17
  query,
17
- stats
18
- } from "./chunk-NYFDM4FR.js";
18
+ stats,
19
+ toPosixPath
20
+ } from "./chunk-3NUHMLRV.js";
19
21
 
20
22
  // src/cli.ts
21
23
  import { Command } from "commander";
22
24
  import chalk from "chalk";
23
25
  import {
24
- existsSync as existsSync5,
25
- readFileSync as readFileSync3,
26
- writeFileSync,
26
+ existsSync as existsSync6,
27
+ readFileSync as readFileSync4,
28
+ writeFileSync as writeFileSync2,
27
29
  mkdirSync,
28
30
  unlinkSync,
29
31
  copyFileSync,
30
- renameSync as renameSync2
32
+ renameSync as renameSync3
31
33
  } from "fs";
32
- import { dirname as dirname3, join as join5, resolve as pathResolve } from "path";
34
+ import { dirname as dirname3, join as join6, resolve as pathResolve } from "path";
33
35
  import { homedir } from "os";
34
36
 
35
37
  // src/intercept/safety.ts
@@ -103,6 +105,10 @@ function isHardSystemPath(absPath) {
103
105
  const p = absPath.replaceAll(sep, "/");
104
106
  if (p === "/" || p.startsWith("/dev/") || p.startsWith("/proc/")) return true;
105
107
  if (p.startsWith("/sys/")) return true;
108
+ const upper = p.toUpperCase();
109
+ if (upper.startsWith("//./") || upper.startsWith("//?/")) return true;
110
+ if (/^[A-Z]:\/WINDOWS(\/|$)/.test(upper)) return true;
111
+ if (/^[A-Z]:\/(PROGRAM FILES|PROGRAMDATA)(\/|$)/.test(upper)) return true;
106
112
  return false;
107
113
  }
108
114
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -256,6 +262,9 @@ function isValidCwd(cwd) {
256
262
  }
257
263
  function resolveInterceptContext(filePath, cwd) {
258
264
  if (!filePath) return { proceed: false, reason: "empty-path" };
265
+ if (isHardSystemPath(filePath)) {
266
+ return { proceed: false, reason: "system-path" };
267
+ }
259
268
  const absPath = normalizePath(filePath, cwd);
260
269
  if (!absPath) return { proceed: false, reason: "normalize-failed" };
261
270
  if (isHardSystemPath(absPath)) {
@@ -370,7 +379,9 @@ async function handleEditOrWrite(payload) {
370
379
  if (!ctx.proceed) return PASSTHROUGH;
371
380
  if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
372
381
  if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
373
- const relPath = relative(resolvePath(ctx.projectRoot), ctx.absPath);
382
+ const relPath = toPosixPath(
383
+ relative(resolvePath(ctx.projectRoot), ctx.absPath)
384
+ );
374
385
  if (!relPath || relPath.startsWith("..")) return PASSTHROUGH;
375
386
  let found;
376
387
  try {
@@ -548,6 +559,8 @@ async function handleSessionStart(payload) {
548
559
  var MIN_SIGNIFICANT_TERMS = 2;
549
560
  var MIN_MATCHED_NODES = 3;
550
561
  var PROMPT_INJECTION_TOKEN_BUDGET = 500;
562
+ var MIN_IDF_THRESHOLD = 1.386;
563
+ var MAX_SEED_KEYWORDS = 5;
551
564
  var STOPWORDS = /* @__PURE__ */ new Set([
552
565
  "a",
553
566
  "an",
@@ -642,13 +655,32 @@ async function handleUserPromptSubmit(payload) {
642
655
  const prompt = payload.prompt;
643
656
  if (!prompt || typeof prompt !== "string") return PASSTHROUGH;
644
657
  if (prompt.length > 8e3) return PASSTHROUGH;
645
- const keywords = extractKeywords(prompt);
646
- if (keywords.length < MIN_SIGNIFICANT_TERMS) return PASSTHROUGH;
658
+ const rawKeywords = extractKeywords(prompt);
659
+ if (rawKeywords.length < MIN_SIGNIFICANT_TERMS) return PASSTHROUGH;
647
660
  const cwd = payload.cwd;
648
661
  if (!isValidCwd(cwd)) return PASSTHROUGH;
649
662
  const projectRoot = findProjectRoot(cwd);
650
663
  if (projectRoot === null) return PASSTHROUGH;
651
664
  if (isHookDisabled(projectRoot)) return PASSTHROUGH;
665
+ let keywords;
666
+ try {
667
+ const scored = await computeKeywordIDF(projectRoot, rawKeywords);
668
+ if (scored.length === 0) {
669
+ keywords = rawKeywords;
670
+ } else {
671
+ const discriminative = scored.filter((s) => s.idf >= MIN_IDF_THRESHOLD);
672
+ if (discriminative.length === 0) {
673
+ return PASSTHROUGH;
674
+ }
675
+ keywords = scored.filter((s) => s.idf > 0).slice(0, MAX_SEED_KEYWORDS).map((s) => s.keyword);
676
+ if (keywords.length === 0) {
677
+ keywords = rawKeywords;
678
+ }
679
+ }
680
+ } catch {
681
+ keywords = rawKeywords;
682
+ }
683
+ if (keywords.length === 0) return PASSTHROUGH;
652
684
  let result;
653
685
  try {
654
686
  result = await query(projectRoot, keywords.join(" "), {
@@ -866,6 +898,44 @@ function extractPreToolDecision(result) {
866
898
  return "passthrough";
867
899
  }
868
900
 
901
+ // src/intercept/cursor-adapter.ts
902
+ var ALLOW = { permission: "allow" };
903
+ function toClaudeReadPayload(cursorPayload) {
904
+ const filePath = cursorPayload.file_path;
905
+ if (!filePath || typeof filePath !== "string") return null;
906
+ const workspaceRoot = Array.isArray(cursorPayload.workspace_roots) && cursorPayload.workspace_roots.length > 0 ? cursorPayload.workspace_roots[0] : process.cwd();
907
+ return {
908
+ tool_name: "Read",
909
+ tool_input: { file_path: filePath },
910
+ cwd: workspaceRoot
911
+ };
912
+ }
913
+ function extractSummaryFromClaudeResult(result) {
914
+ if (result === PASSTHROUGH || result === null) return null;
915
+ if (typeof result !== "object") return null;
916
+ const hookSpecific = result.hookSpecificOutput;
917
+ if (!hookSpecific || typeof hookSpecific !== "object") return null;
918
+ const reason = hookSpecific.permissionDecisionReason;
919
+ if (typeof reason !== "string" || reason.length === 0) return null;
920
+ return reason;
921
+ }
922
+ async function handleCursorBeforeReadFile(payload) {
923
+ try {
924
+ if (!payload || typeof payload !== "object") return ALLOW;
925
+ const claudePayload = toClaudeReadPayload(payload);
926
+ if (claudePayload === null) return ALLOW;
927
+ const result = await handleRead(claudePayload);
928
+ const summary = extractSummaryFromClaudeResult(result);
929
+ if (summary === null) return ALLOW;
930
+ return {
931
+ permission: "deny",
932
+ user_message: summary
933
+ };
934
+ } catch {
935
+ return ALLOW;
936
+ }
937
+ }
938
+
869
939
  // src/intercept/installer.ts
870
940
  var ENGRAM_HOOK_EVENTS = [
871
941
  "PreToolUse",
@@ -1080,7 +1150,107 @@ function formatStatsSummary(summary) {
1080
1150
  return lines.join("\n");
1081
1151
  }
1082
1152
 
1153
+ // src/intercept/memory-md.ts
1154
+ import {
1155
+ existsSync as existsSync5,
1156
+ readFileSync as readFileSync3,
1157
+ writeFileSync,
1158
+ renameSync as renameSync2,
1159
+ statSync as statSync3
1160
+ } from "fs";
1161
+ import { join as join5 } from "path";
1162
+ var ENGRAM_MARKER_START = "<!-- engram:structural-facts:start -->";
1163
+ var ENGRAM_MARKER_END = "<!-- engram:structural-facts:end -->";
1164
+ var MAX_MEMORY_FILE_BYTES = 1e6;
1165
+ var MAX_ENGRAM_SECTION_BYTES = 8e3;
1166
+ function buildEngramSection(facts) {
1167
+ const lines = [];
1168
+ lines.push("## engram \u2014 structural facts");
1169
+ lines.push("");
1170
+ lines.push(
1171
+ `_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._`
1172
+ );
1173
+ lines.push("");
1174
+ lines.push(`**Project:** ${facts.projectName}`);
1175
+ if (facts.branch) lines.push(`**Branch:** ${facts.branch}`);
1176
+ lines.push(
1177
+ `**Graph:** ${facts.stats.nodes} nodes, ${facts.stats.edges} edges, ${facts.stats.extractedPct}% extracted`
1178
+ );
1179
+ if (facts.lastMined > 0) {
1180
+ lines.push(
1181
+ `**Last mined:** ${new Date(facts.lastMined).toISOString()}`
1182
+ );
1183
+ }
1184
+ lines.push("");
1185
+ if (facts.godNodes.length > 0) {
1186
+ lines.push("### Core entities");
1187
+ for (const g of facts.godNodes.slice(0, 10)) {
1188
+ lines.push(`- \`${g.label}\` [${g.kind}] \u2014 ${g.sourceFile}`);
1189
+ }
1190
+ lines.push("");
1191
+ }
1192
+ if (facts.landmines.length > 0) {
1193
+ lines.push("### Known landmines");
1194
+ for (const m of facts.landmines.slice(0, 5)) {
1195
+ lines.push(`- **${m.sourceFile}** \u2014 ${m.label}`);
1196
+ }
1197
+ lines.push("");
1198
+ }
1199
+ lines.push(
1200
+ '_For the full graph, run `engram query "..."` or `engram gods`._'
1201
+ );
1202
+ return lines.join("\n");
1203
+ }
1204
+ function upsertEngramSection(existingContent, engramSection) {
1205
+ const block = `${ENGRAM_MARKER_START}
1206
+ ${engramSection}
1207
+ ${ENGRAM_MARKER_END}`;
1208
+ if (!existingContent) {
1209
+ return `# MEMORY.md
1210
+
1211
+ ${block}
1212
+ `;
1213
+ }
1214
+ const startIdx = existingContent.indexOf(ENGRAM_MARKER_START);
1215
+ const endIdx = existingContent.indexOf(ENGRAM_MARKER_END);
1216
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
1217
+ const trimmed = existingContent.trimEnd();
1218
+ return `${trimmed}
1219
+
1220
+ ${block}
1221
+ `;
1222
+ }
1223
+ const before = existingContent.slice(0, startIdx);
1224
+ const after = existingContent.slice(endIdx + ENGRAM_MARKER_END.length);
1225
+ return `${before}${block}${after}`;
1226
+ }
1227
+ function writeEngramSectionToMemoryMd(projectRoot, engramSection) {
1228
+ if (!projectRoot || typeof projectRoot !== "string") return false;
1229
+ if (engramSection.length > MAX_ENGRAM_SECTION_BYTES) {
1230
+ return false;
1231
+ }
1232
+ const memoryPath = join5(projectRoot, "MEMORY.md");
1233
+ try {
1234
+ let existing = "";
1235
+ if (existsSync5(memoryPath)) {
1236
+ const st = statSync3(memoryPath);
1237
+ if (st.size > MAX_MEMORY_FILE_BYTES) {
1238
+ return false;
1239
+ }
1240
+ existing = readFileSync3(memoryPath, "utf-8");
1241
+ }
1242
+ const updated = upsertEngramSection(existing, engramSection);
1243
+ const tmpPath = memoryPath + ".engram-tmp";
1244
+ writeFileSync(tmpPath, updated);
1245
+ renameSync2(tmpPath, memoryPath);
1246
+ return true;
1247
+ } catch {
1248
+ return false;
1249
+ }
1250
+ }
1251
+
1083
1252
  // src/cli.ts
1253
+ import { basename as basename2 } from "path";
1084
1254
  var program = new Command();
1085
1255
  program.name("engram").description(
1086
1256
  "Context as infra for AI coding tools \u2014 hook-based Read/Edit interception + structural graph summaries"
@@ -1116,6 +1286,22 @@ program.command("init").description("Scan codebase and build knowledge graph (ze
1116
1286
  }
1117
1287
  console.log(chalk.green("\n\u2705 Ready. Your AI now has persistent memory."));
1118
1288
  console.log(chalk.dim(" Graph stored in .engram/graph.db"));
1289
+ 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");
1293
+ if (!hasHooks) {
1294
+ console.log(
1295
+ chalk.yellow("\n\u{1F4A1} Next step: ") + chalk.white("engram install-hook") + chalk.dim(
1296
+ " \u2014 enables automatic Read interception (82% token savings)"
1297
+ )
1298
+ );
1299
+ console.log(
1300
+ chalk.dim(
1301
+ " Also recommended: " + chalk.white("engram hooks install") + " \u2014 auto-rebuild graph on git commit"
1302
+ )
1303
+ );
1304
+ }
1119
1305
  });
1120
1306
  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) => {
1121
1307
  const result = await query(opts.project, question, {
@@ -1242,11 +1428,11 @@ function resolveSettingsPath(scope, projectPath) {
1242
1428
  const absProject = pathResolve(projectPath);
1243
1429
  switch (scope) {
1244
1430
  case "local":
1245
- return join5(absProject, ".claude", "settings.local.json");
1431
+ return join6(absProject, ".claude", "settings.local.json");
1246
1432
  case "project":
1247
- return join5(absProject, ".claude", "settings.json");
1433
+ return join6(absProject, ".claude", "settings.json");
1248
1434
  case "user":
1249
- return join5(homedir(), ".claude", "settings.json");
1435
+ return join6(homedir(), ".claude", "settings.json");
1250
1436
  default:
1251
1437
  return null;
1252
1438
  }
@@ -1257,23 +1443,28 @@ program.command("intercept").description(
1257
1443
  const stdinTimeout = setTimeout(() => {
1258
1444
  process.exit(0);
1259
1445
  }, 3e3);
1446
+ stdinTimeout.unref();
1260
1447
  let input = "";
1448
+ let stdinFailed = false;
1261
1449
  try {
1262
1450
  for await (const chunk of process.stdin) {
1263
1451
  input += chunk;
1264
1452
  if (input.length > 1e6) break;
1265
1453
  }
1266
1454
  } catch {
1267
- clearTimeout(stdinTimeout);
1268
- process.exit(0);
1455
+ stdinFailed = true;
1269
1456
  }
1270
1457
  clearTimeout(stdinTimeout);
1271
- if (!input.trim()) process.exit(0);
1458
+ if (stdinFailed || !input.trim()) {
1459
+ process.exitCode = 0;
1460
+ return;
1461
+ }
1272
1462
  let payload;
1273
1463
  try {
1274
1464
  payload = JSON.parse(input);
1275
1465
  } catch {
1276
- process.exit(0);
1466
+ process.exitCode = 0;
1467
+ return;
1277
1468
  }
1278
1469
  try {
1279
1470
  const result = await dispatchHook(payload);
@@ -1282,6 +1473,45 @@ program.command("intercept").description(
1282
1473
  }
1283
1474
  } catch {
1284
1475
  }
1476
+ process.exitCode = 0;
1477
+ });
1478
+ program.command("cursor-intercept").description(
1479
+ "Cursor beforeReadFile hook entry point (experimental). Reads JSON from stdin, writes Cursor-shaped response JSON to stdout."
1480
+ ).action(async () => {
1481
+ const ALLOW_JSON = '{"permission":"allow"}';
1482
+ const stdinTimeout = setTimeout(() => {
1483
+ process.stdout.write(ALLOW_JSON);
1484
+ process.exit(0);
1485
+ }, 3e3);
1486
+ let input = "";
1487
+ try {
1488
+ for await (const chunk of process.stdin) {
1489
+ input += chunk;
1490
+ if (input.length > 1e6) break;
1491
+ }
1492
+ } catch {
1493
+ clearTimeout(stdinTimeout);
1494
+ process.stdout.write(ALLOW_JSON);
1495
+ process.exit(0);
1496
+ }
1497
+ clearTimeout(stdinTimeout);
1498
+ if (!input.trim()) {
1499
+ process.stdout.write(ALLOW_JSON);
1500
+ process.exit(0);
1501
+ }
1502
+ let payload;
1503
+ try {
1504
+ payload = JSON.parse(input);
1505
+ } catch {
1506
+ process.stdout.write(ALLOW_JSON);
1507
+ process.exit(0);
1508
+ }
1509
+ try {
1510
+ const result = await handleCursorBeforeReadFile(payload);
1511
+ process.stdout.write(JSON.stringify(result));
1512
+ } catch {
1513
+ process.stdout.write(ALLOW_JSON);
1514
+ }
1285
1515
  process.exit(0);
1286
1516
  });
1287
1517
  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(
@@ -1296,9 +1526,9 @@ program.command("install-hook").description("Install engram hook entries into Cl
1296
1526
  process.exit(1);
1297
1527
  }
1298
1528
  let existing = {};
1299
- if (existsSync5(settingsPath)) {
1529
+ if (existsSync6(settingsPath)) {
1300
1530
  try {
1301
- const raw = readFileSync3(settingsPath, "utf-8");
1531
+ const raw = readFileSync4(settingsPath, "utf-8");
1302
1532
  existing = raw.trim() ? JSON.parse(raw) : {};
1303
1533
  } catch (err) {
1304
1534
  console.error(
@@ -1344,17 +1574,17 @@ program.command("install-hook").description("Install engram hook entries into Cl
1344
1574
  }
1345
1575
  try {
1346
1576
  mkdirSync(dirname3(settingsPath), { recursive: true });
1347
- if (existsSync5(settingsPath)) {
1577
+ if (existsSync6(settingsPath)) {
1348
1578
  const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1349
1579
  copyFileSync(settingsPath, backupPath);
1350
1580
  console.log(chalk.dim(` Backup: ${backupPath}`));
1351
1581
  }
1352
1582
  const tmpPath = settingsPath + ".engram-tmp";
1353
- writeFileSync(
1583
+ writeFileSync2(
1354
1584
  tmpPath,
1355
1585
  JSON.stringify(result.updated, null, 2) + "\n"
1356
1586
  );
1357
- renameSync2(tmpPath, settingsPath);
1587
+ renameSync3(tmpPath, settingsPath);
1358
1588
  } catch (err) {
1359
1589
  console.error(
1360
1590
  chalk.red(`
@@ -1388,7 +1618,7 @@ program.command("uninstall-hook").description("Remove engram hook entries from C
1388
1618
  console.error(chalk.red(`Unknown scope: ${opts.scope}`));
1389
1619
  process.exit(1);
1390
1620
  }
1391
- if (!existsSync5(settingsPath)) {
1621
+ if (!existsSync6(settingsPath)) {
1392
1622
  console.log(
1393
1623
  chalk.yellow(`No settings file at ${settingsPath} \u2014 nothing to remove.`)
1394
1624
  );
@@ -1396,7 +1626,7 @@ program.command("uninstall-hook").description("Remove engram hook entries from C
1396
1626
  }
1397
1627
  let existing;
1398
1628
  try {
1399
- const raw = readFileSync3(settingsPath, "utf-8");
1629
+ const raw = readFileSync4(settingsPath, "utf-8");
1400
1630
  existing = raw.trim() ? JSON.parse(raw) : {};
1401
1631
  } catch (err) {
1402
1632
  console.error(
@@ -1416,8 +1646,8 @@ program.command("uninstall-hook").description("Remove engram hook entries from C
1416
1646
  const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
1417
1647
  copyFileSync(settingsPath, backupPath);
1418
1648
  const tmpPath = settingsPath + ".engram-tmp";
1419
- writeFileSync(tmpPath, JSON.stringify(result.updated, null, 2) + "\n");
1420
- renameSync2(tmpPath, settingsPath);
1649
+ writeFileSync2(tmpPath, JSON.stringify(result.updated, null, 2) + "\n");
1650
+ renameSync3(tmpPath, settingsPath);
1421
1651
  console.log(
1422
1652
  chalk.green(
1423
1653
  `
@@ -1504,9 +1734,9 @@ program.command("hook-disable").description("Disable engram hooks via kill switc
1504
1734
  console.error(chalk.dim("Run 'engram init' first."));
1505
1735
  process.exit(1);
1506
1736
  }
1507
- const flagPath = join5(projectRoot, ".engram", "hook-disabled");
1737
+ const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1508
1738
  try {
1509
- writeFileSync(flagPath, (/* @__PURE__ */ new Date()).toISOString());
1739
+ writeFileSync2(flagPath, (/* @__PURE__ */ new Date()).toISOString());
1510
1740
  console.log(
1511
1741
  chalk.green(`\u2705 engram hooks disabled for ${projectRoot}`)
1512
1742
  );
@@ -1528,8 +1758,8 @@ program.command("hook-enable").description("Re-enable engram hooks (remove kill
1528
1758
  console.error(chalk.red(`Not an engram project: ${absProject}`));
1529
1759
  process.exit(1);
1530
1760
  }
1531
- const flagPath = join5(projectRoot, ".engram", "hook-disabled");
1532
- if (!existsSync5(flagPath)) {
1761
+ const flagPath = join6(projectRoot, ".engram", "hook-disabled");
1762
+ if (!existsSync6(flagPath)) {
1533
1763
  console.log(
1534
1764
  chalk.yellow(`engram hooks already enabled for ${projectRoot}`)
1535
1765
  );
@@ -1547,4 +1777,90 @@ program.command("hook-enable").description("Re-enable engram hooks (remove kill
1547
1777
  process.exit(1);
1548
1778
  }
1549
1779
  });
1780
+ program.command("memory-sync").description(
1781
+ "Write engram's structural facts into MEMORY.md (complementary to Anthropic Auto-Dream)"
1782
+ ).option("-p, --project <path>", "Project directory", ".").option("--dry-run", "Print what would be written without writing", false).action(
1783
+ async (opts) => {
1784
+ const absProject = pathResolve(opts.project);
1785
+ const projectRoot = findProjectRoot(absProject);
1786
+ if (!projectRoot) {
1787
+ console.error(
1788
+ chalk.red(`Not an engram project: ${absProject}`)
1789
+ );
1790
+ console.error(chalk.dim("Run 'engram init' first."));
1791
+ process.exit(1);
1792
+ }
1793
+ const [gods, mistakeList, graphStats] = await Promise.all([
1794
+ godNodes(projectRoot, 10).catch(() => []),
1795
+ mistakes(projectRoot, { limit: 5 }).catch(() => []),
1796
+ stats(projectRoot).catch(() => null)
1797
+ ]);
1798
+ if (!graphStats) {
1799
+ console.error(chalk.red("Failed to read graph stats."));
1800
+ process.exit(1);
1801
+ }
1802
+ let branch = null;
1803
+ try {
1804
+ const headPath = join6(projectRoot, ".git", "HEAD");
1805
+ if (existsSync6(headPath)) {
1806
+ const content = readFileSync4(headPath, "utf-8").trim();
1807
+ const m = content.match(/^ref:\s+refs\/heads\/(.+)$/);
1808
+ if (m) branch = m[1];
1809
+ }
1810
+ } catch {
1811
+ }
1812
+ const section = buildEngramSection({
1813
+ projectName: basename2(projectRoot),
1814
+ branch,
1815
+ stats: {
1816
+ nodes: graphStats.nodes,
1817
+ edges: graphStats.edges,
1818
+ extractedPct: graphStats.extractedPct
1819
+ },
1820
+ godNodes: gods,
1821
+ landmines: mistakeList.map((m) => ({
1822
+ label: m.label,
1823
+ sourceFile: m.sourceFile
1824
+ })),
1825
+ lastMined: graphStats.lastMined
1826
+ });
1827
+ console.log(
1828
+ chalk.bold(`
1829
+ \u{1F4DD} engram memory-sync`)
1830
+ );
1831
+ console.log(
1832
+ chalk.dim(` Target: ${join6(projectRoot, "MEMORY.md")}`)
1833
+ );
1834
+ if (opts.dryRun) {
1835
+ console.log(chalk.cyan("\n Section to write (dry-run):\n"));
1836
+ console.log(
1837
+ section.split("\n").map((l) => " " + l).join("\n")
1838
+ );
1839
+ console.log(chalk.dim("\n (dry-run \u2014 no changes written)"));
1840
+ return;
1841
+ }
1842
+ const ok = writeEngramSectionToMemoryMd(projectRoot, section);
1843
+ if (!ok) {
1844
+ console.error(
1845
+ chalk.red(
1846
+ "\n \u274C Write failed. MEMORY.md may be too large, or the engram section exceeded its size cap."
1847
+ )
1848
+ );
1849
+ process.exit(1);
1850
+ }
1851
+ console.log(
1852
+ chalk.green(
1853
+ `
1854
+ \u2705 Synced ${gods.length} god nodes${mistakeList.length > 0 ? ` and ${mistakeList.length} landmines` : ""} to MEMORY.md`
1855
+ )
1856
+ );
1857
+ console.log(
1858
+ chalk.dim(
1859
+ `
1860
+ Next: Anthropic's Auto-Dream will consolidate this alongside its prose entries.
1861
+ `
1862
+ )
1863
+ );
1864
+ }
1865
+ );
1550
1866
  program.parse();
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  benchmark,
3
+ computeKeywordIDF,
3
4
  getDbPath,
4
5
  getFileContext,
5
6
  getStore,
@@ -10,9 +11,10 @@ import {
10
11
  path,
11
12
  query,
12
13
  stats
13
- } from "./chunk-NYFDM4FR.js";
14
+ } from "./chunk-3NUHMLRV.js";
14
15
  export {
15
16
  benchmark,
17
+ computeKeywordIDF,
16
18
  getDbPath,
17
19
  getFileContext,
18
20
  getStore,
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  generateSummary,
5
5
  install,
6
6
  uninstall
7
- } from "./chunk-7KYR4SPZ.js";
7
+ } from "./chunk-JXJNXQUM.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-NYFDM4FR.js";
26
+ } from "./chunk-3NUHMLRV.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-NYFDM4FR.js";
11
+ } from "./chunk-3NUHMLRV.js";
12
12
 
13
13
  // src/serve.ts
14
14
  function clampInt(value, defaultValue, min, max) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "engramx",
3
- "version": "0.3.0",
4
- "description": "Context as infra for AI coding tools a Claude Code hook layer that intercepts Read/Edit/Grep and replaces file reads with structural graph summaries. Persistent, local, zero LLM cost.",
3
+ "version": "0.3.2",
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": {
7
7
  "engram": "dist/cli.js",
@@ -17,16 +17,20 @@
17
17
  "prepublishOnly": "npm run build"
18
18
  },
19
19
  "keywords": [
20
- "ai",
21
- "coding",
22
- "memory",
23
- "context",
24
- "tree-sitter",
20
+ "structural-code-graph",
21
+ "claude-code-hooks",
22
+ "ai-coding",
23
+ "context-engineering",
25
24
  "knowledge-graph",
25
+ "ast",
26
26
  "mcp",
27
- "claude",
27
+ "claude-code",
28
28
  "cursor",
29
- "token-savings"
29
+ "token-reduction",
30
+ "context-rot",
31
+ "local-first",
32
+ "agent-memory",
33
+ "hook-interception"
30
34
  ],
31
35
  "author": "Nicholas Ashkar",
32
36
  "license": "Apache-2.0",