engramx 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,79 +15,171 @@
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-132%20passing-brightgreen" alt="Tests">
18
+ <img src="https://img.shields.io/badge/tests-439%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
- **Your AI coding assistant forgets everything. We fixed that.**
26
+ # The structural code graph your AI agent can't forget to use.
26
27
 
27
- engram gives AI coding tools persistent memory. One command scans your codebase, builds a knowledge graph, and makes every session start where the last one left off.
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
- Zero LLM cost. Zero cloud. Works with Claude Code, Cursor, Codex, aider, and any MCP client.
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.
31
+
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 |
30
41
 
31
42
  ```bash
32
- npx engram init
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
33
47
  ```
34
48
 
49
+ ```bash
50
+ npm install -g engramx
51
+ cd ~/my-project
52
+ engram init # scan codebase → .engram/graph.db
53
+ engram install-hook # wire into Claude Code (project-local)
35
54
  ```
36
- 🔍 Scanning codebase...
37
- 🌳 AST extraction complete (42ms, 0 tokens used)
38
- 60 nodes, 96 edges from 14 files (2,155 lines)
39
55
 
40
- 📊 Token savings: 11x fewer tokens vs relevant files
41
- Full corpus: ~15,445 tokens | Graph query: ~285 tokens
56
+ That's it. The next Claude Code session in that directory automatically:
42
57
 
43
- Ready. Your AI now has persistent memory.
44
- ```
58
+ - **Replaces file reads with graph summaries** (Read intercept, deny+reason)
59
+ - **Warns before edits that hit known mistakes** (Edit landmine injection)
60
+ - **Pre-loads relevant context when you ask a question** (UserPromptSubmit pre-query)
61
+ - **Injects a project brief at session start** (SessionStart additionalContext)
62
+ - **Logs every decision for `engram hook-stats`** (PostToolUse observer)
45
63
 
46
- ## Why
64
+ ## Architecture Diagram
47
65
 
48
- Every AI coding session starts from zero. Claude Code re-reads your files. Cursor reindexes. Copilot has no memory. CLAUDE.md is a sticky note you write by hand.
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.
49
67
 
50
- engram fixes this with five things no other tool combines:
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.
51
70
 
52
- 1. **Persistent knowledge graph** — survives across sessions, stored in `.engram/graph.db`
53
- 2. **Learns from every session** — decisions, patterns, mistakes are extracted and remembered
54
- 3. **Universal protocol** — MCP server + CLI + auto-generates CLAUDE.md, .cursorrules, AGENTS.md
55
- 4. **Skill-aware** (v0.2) — indexes your `~/.claude/skills/` directory into the graph so queries return code *and* the skill to apply
56
- 5. **Regret buffer** (v0.2) — surfaces past mistakes at the top of query results so your AI stops re-making the same wrong turns
71
+ ## The Problem
57
72
 
58
- ## Install
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.
59
74
 
60
- ```bash
61
- npx engramx init
75
+ The ceiling isn't the graph's accuracy. It's that the agent has to *remember* to ask. v0.2 of engram was a tool the agent queried ~5 times per session. The other 25 Reads happened uninterrupted.
76
+
77
+ v0.3 flips this. The hook intercepts at the tool-call boundary, not at the agent's discretion.
78
+
79
+ ```
80
+ v0.2: agent → (remembers to call query_graph) → engram returns summary
81
+ v0.3: agent → Read → Claude Code hook → engram intercepts → summary delivered
62
82
  ```
63
83
 
64
- Or install globally:
84
+ **Projected savings: -42,500 tokens per session** (~80% reduction vs v0.2.1 baseline).
85
+ Every number is arithmetic on empirically verified hook mechanisms — not estimates.
86
+
87
+ ## Install
65
88
 
66
89
  ```bash
67
90
  npm install -g engramx
68
- engram init
69
91
  ```
70
92
 
71
93
  Requires Node.js 20+. Zero native dependencies. No build tools needed.
72
94
 
73
- ## Usage
95
+ ## Quickstart (v0.3 Sentinel)
96
+
97
+ ```bash
98
+ cd ~/my-project
99
+ engram init # scan codebase, build knowledge graph
100
+ engram install-hook # install Sentinel hooks into .claude/settings.local.json
101
+ engram hook-preview src/auth.ts # dry-run: see what the hook would do
102
+ ```
103
+
104
+ Open a Claude Code session in that project. When it reads a well-covered file, you'll see a system-reminder with engram's structural summary instead of the full file contents. Run `engram hook-stats` afterwards to see how many reads were intercepted.
105
+
106
+ ```bash
107
+ engram hook-stats # summarize hook-log.jsonl
108
+ engram hook-disable # kill switch (keeps install, disables intercepts)
109
+ engram hook-enable # re-enable
110
+ engram uninstall-hook # surgical removal, preserves other hooks
111
+ ```
112
+
113
+ ## All Commands
114
+
115
+ ### Core (v0.1/v0.2 — unchanged)
74
116
 
75
117
  ```bash
76
118
  engram init [path] # Scan codebase, build knowledge graph
77
119
  engram init --with-skills # Also index ~/.claude/skills/ (v0.2)
78
120
  engram query "how does auth" # Query the graph (BFS, token-budgeted)
79
- engram query "auth" --dfs # DFS traversal (trace specific paths)
121
+ engram query "auth" --dfs # DFS traversal
80
122
  engram gods # Show most connected entities
81
123
  engram stats # Node/edge counts, token savings
82
124
  engram bench # Token reduction benchmark
83
125
  engram path "auth" "database" # Shortest path between concepts
84
126
  engram learn "chose JWT..." # Teach a decision or pattern
85
- engram mistakes # List known mistakes (v0.2)
127
+ engram mistakes # List known landmines
86
128
  engram gen # Generate CLAUDE.md section from graph
87
- engram gen --task bug-fix # Task-aware view (v0.2: general|bug-fix|feature|refactor)
88
- engram hooks install # Auto-rebuild on git commit
129
+ engram gen --task bug-fix # Task-aware view (general|bug-fix|feature|refactor)
130
+ engram hooks install # Auto-rebuild graph on git commit
131
+ ```
132
+
133
+ ### Sentinel (v0.3 — new)
134
+
135
+ ```bash
136
+ engram intercept # Hook entry point (called by Claude Code, reads stdin)
137
+ engram install-hook [--scope <s>] # Install hooks into Claude Code settings
138
+ # --scope local (default, gitignored)
139
+ # --scope project (committed)
140
+ # --scope user (global ~/.claude/settings.json)
141
+ engram install-hook --dry-run # Preview changes without writing
142
+ engram uninstall-hook # Remove engram entries (preserves other hooks)
143
+ engram hook-stats # Summarize .engram/hook-log.jsonl
144
+ engram hook-stats --json # Machine-readable output
145
+ engram hook-preview <file> # Dry-run Read handler for a specific file
146
+ engram hook-disable # Kill switch (touch .engram/hook-disabled)
147
+ engram hook-enable # Remove kill switch
89
148
  ```
90
149
 
150
+ ## How the Sentinel Layer Works
151
+
152
+ Seven hook handlers compose the interception stack:
153
+
154
+ | Hook | Mechanism | What it does |
155
+ |---|---|---|
156
+ | **`PreToolUse:Read`** | `deny + permissionDecisionReason` | If the file is in the graph with ≥0.7 confidence, blocks the Read and delivers a ~300-token structural summary as the block reason. Claude sees the reason as a system-reminder and uses it as context. The file is never actually read. |
157
+ | **`PreToolUse:Edit`** | `allow + additionalContext` | Never blocks writes. If the file has known past mistakes, injects them as a landmine warning alongside the edit. |
158
+ | **`PreToolUse:Write`** | Same as Edit | Advisory landmine injection. |
159
+ | **`PreToolUse:Bash`** | Parse + delegate | Detects `cat|head|tail|less|more <single-file>` invocations (strict parser, rejects any shell metacharacter) and delegates to the Read handler. Closes the Bash workaround loophole. |
160
+ | **`SessionStart`** | `additionalContext` | Injects a compact project brief (god nodes + graph stats + top landmines + git branch) on source=startup/clear/compact. Passes through on resume. |
161
+ | **`UserPromptSubmit`** | `additionalContext` | Extracts keywords from the user's message, runs a ≤500-token pre-query, injects results. Skipped for short or generic prompts. Raw prompt content is never logged. |
162
+ | **`PostToolUse`** | Observer | Pure logger. Writes tool/path/outputSize/success/decision to `.engram/hook-log.jsonl` for `hook-stats` and v0.3.1 self-tuning. |
163
+
164
+ ### Ten safety invariants, enforced at runtime
165
+
166
+ 1. Any handler error → passthrough (never block Claude Code)
167
+ 2. 2-second per-handler timeout
168
+ 3. Kill switch (`.engram/hook-disabled`) respected by every handler
169
+ 4. Atomic settings.json writes with timestamped backups
170
+ 5. Never intercept outside the project root
171
+ 6. Never intercept binary files or secrets (.env, .pem, .key, credentials, id_rsa, ...)
172
+ 7. Never log user prompt content (privacy invariant asserted in tests)
173
+ 8. Never inject >8000 chars per hook response
174
+ 9. Stale graph detection (file mtime > graph mtime → passthrough)
175
+ 10. Partial-read bypass (Read with explicit `offset` or `limit` → passthrough)
176
+
177
+ ### What you can safely install
178
+
179
+ Default scope is `.claude/settings.local.json` — gitignored, project-local, zero risk of committing hook config to a shared repo. Idempotent install. Non-destructive uninstall. `--dry-run` shows the diff before writing.
180
+
181
+ If anything goes wrong, `engram hook-disable` flips the kill switch without uninstalling.
182
+
91
183
  ## How It Works
92
184
 
93
185
  engram runs three miners on your codebase. None of them use an LLM.
@@ -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-HWOM7GSU.js");
313
+ const { getStore } = await import("./core-2TWPNHRQ.js");
314
314
  const store = await getStore(projectRoot);
315
315
  try {
316
316
  let view = VIEWS.general;
@@ -1,6 +1,6 @@
1
1
  // src/core.ts
2
- import { join as join4, resolve as resolve2 } from "path";
3
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2, unlinkSync } from "fs";
2
+ import { join as join4, resolve as resolve2, relative as relative2 } from "path";
3
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2, unlinkSync, statSync as statSync2 } from "fs";
4
4
  import { homedir } from "os";
5
5
 
6
6
  // src/graph/store.ts
@@ -526,6 +526,106 @@ function renderPath(nodes, edges) {
526
526
  }
527
527
  return `Path (${edges.length} hops): ${segments.join(" ")}`;
528
528
  }
529
+ function renderFileStructure(store, relativeFilePath, tokenBudget = 600) {
530
+ const allNodes = store.getAllNodes();
531
+ const fileNodes = allNodes.filter(
532
+ (n) => n.sourceFile === relativeFilePath && !isHiddenKeyword(n)
533
+ );
534
+ if (fileNodes.length === 0) {
535
+ return {
536
+ text: "",
537
+ nodeCount: 0,
538
+ codeNodeCount: 0,
539
+ avgConfidence: 0,
540
+ estimatedTokens: 0
541
+ };
542
+ }
543
+ const codeNodeCount = fileNodes.filter(
544
+ (n) => n.kind !== "file" && n.kind !== "module"
545
+ ).length;
546
+ const avgConfidence = fileNodes.reduce((s, n) => s + n.confidenceScore, 0) / fileNodes.length;
547
+ const allEdges = store.getAllEdges();
548
+ const fileNodeIds = new Set(fileNodes.map((n) => n.id));
549
+ const degreeMap = /* @__PURE__ */ new Map();
550
+ for (const e of allEdges) {
551
+ if (fileNodeIds.has(e.source)) {
552
+ degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1);
553
+ }
554
+ if (fileNodeIds.has(e.target)) {
555
+ degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1);
556
+ }
557
+ }
558
+ const byKind = /* @__PURE__ */ new Map();
559
+ for (const n of fileNodes) {
560
+ const list = byKind.get(n.kind) ?? [];
561
+ list.push(n);
562
+ byKind.set(n.kind, list);
563
+ }
564
+ for (const list of byKind.values()) {
565
+ list.sort(
566
+ (a, b) => (degreeMap.get(b.id) ?? 0) - (degreeMap.get(a.id) ?? 0)
567
+ );
568
+ }
569
+ const kindOrder = [
570
+ "class",
571
+ "interface",
572
+ "type",
573
+ "function",
574
+ "method",
575
+ "variable",
576
+ "import",
577
+ "module",
578
+ "file",
579
+ "decision",
580
+ "pattern",
581
+ "mistake",
582
+ "concept"
583
+ ];
584
+ const lines = [];
585
+ lines.push(`[engram] Structural summary for ${relativeFilePath}`);
586
+ lines.push(
587
+ `Nodes: ${fileNodes.length} | avg extraction confidence: ${avgConfidence.toFixed(2)}`
588
+ );
589
+ lines.push("");
590
+ for (const kind of kindOrder) {
591
+ const group = byKind.get(kind);
592
+ if (!group || group.length === 0) continue;
593
+ for (const n of group) {
594
+ const loc = n.sourceLocation ?? "";
595
+ lines.push(`NODE ${n.label} [${n.kind}] ${loc}`.trim());
596
+ }
597
+ }
598
+ const relevantEdges = allEdges.filter(
599
+ (e) => fileNodeIds.has(e.source) || fileNodeIds.has(e.target)
600
+ ).slice(0, 10);
601
+ if (relevantEdges.length > 0) {
602
+ lines.push("");
603
+ lines.push("Key relationships:");
604
+ for (const e of relevantEdges) {
605
+ const src = allNodes.find((n) => n.id === e.source);
606
+ const tgt = allNodes.find((n) => n.id === e.target);
607
+ if (src && tgt) {
608
+ lines.push(`EDGE ${src.label} --${e.relation}--> ${tgt.label}`);
609
+ }
610
+ }
611
+ }
612
+ lines.push("");
613
+ lines.push(
614
+ "Note: engram replaced a full-file read with this structural view to save tokens. If you need specific lines, Read this file again with explicit offset/limit parameters \u2014 engram passes partial reads through unchanged."
615
+ );
616
+ let text = lines.join("\n");
617
+ const charBudget = tokenBudget * CHARS_PER_TOKEN;
618
+ if (text.length > charBudget) {
619
+ text = sliceGraphemeSafe(text, charBudget) + "\n... (truncated to fit summary budget)";
620
+ }
621
+ return {
622
+ text,
623
+ nodeCount: fileNodes.length,
624
+ codeNodeCount,
625
+ avgConfidence,
626
+ estimatedTokens: Math.ceil(text.length / CHARS_PER_TOKEN)
627
+ };
628
+ }
529
629
 
530
630
  // src/miners/ast-miner.ts
531
631
  import { readFileSync as readFileSync2, readdirSync, realpathSync } from "fs";
@@ -1490,6 +1590,111 @@ async function stats(projectRoot) {
1490
1590
  store.close();
1491
1591
  }
1492
1592
  }
1593
+ var FILE_CONTEXT_COVERAGE_CEILING = 3;
1594
+ async function getFileContext(projectRoot, absFilePath) {
1595
+ const empty = {
1596
+ found: false,
1597
+ confidence: 0,
1598
+ summary: "",
1599
+ nodeCount: 0,
1600
+ codeNodeCount: 0,
1601
+ avgNodeConfidence: 0,
1602
+ graphMtimeMs: 0,
1603
+ fileMtimeMs: null,
1604
+ isStale: false
1605
+ };
1606
+ try {
1607
+ const root = resolve2(projectRoot);
1608
+ const abs = resolve2(absFilePath);
1609
+ const relPath = relative2(root, abs);
1610
+ if (relPath.startsWith("..") || relPath === "") {
1611
+ return empty;
1612
+ }
1613
+ const dbPath = getDbPath(root);
1614
+ let graphMtimeMs = 0;
1615
+ try {
1616
+ graphMtimeMs = statSync2(dbPath).mtimeMs;
1617
+ } catch {
1618
+ return empty;
1619
+ }
1620
+ let fileMtimeMs = null;
1621
+ try {
1622
+ fileMtimeMs = statSync2(abs).mtimeMs;
1623
+ } catch {
1624
+ fileMtimeMs = null;
1625
+ }
1626
+ const isStale = fileMtimeMs !== null && fileMtimeMs > graphMtimeMs;
1627
+ const store = await getStore(root);
1628
+ try {
1629
+ const summary = renderFileStructure(store, relPath);
1630
+ if (summary.codeNodeCount === 0) {
1631
+ return {
1632
+ ...empty,
1633
+ nodeCount: summary.nodeCount,
1634
+ codeNodeCount: 0,
1635
+ graphMtimeMs,
1636
+ fileMtimeMs,
1637
+ isStale
1638
+ };
1639
+ }
1640
+ const coverageScore = Math.min(
1641
+ summary.codeNodeCount / FILE_CONTEXT_COVERAGE_CEILING,
1642
+ 1
1643
+ );
1644
+ const confidence = coverageScore * summary.avgConfidence;
1645
+ return {
1646
+ found: true,
1647
+ confidence,
1648
+ summary: summary.text,
1649
+ nodeCount: summary.nodeCount,
1650
+ codeNodeCount: summary.codeNodeCount,
1651
+ avgNodeConfidence: summary.avgConfidence,
1652
+ graphMtimeMs,
1653
+ fileMtimeMs,
1654
+ isStale
1655
+ };
1656
+ } finally {
1657
+ store.close();
1658
+ }
1659
+ } catch {
1660
+ return empty;
1661
+ }
1662
+ }
1663
+ async function computeKeywordIDF(projectRoot, keywords) {
1664
+ if (keywords.length === 0) return [];
1665
+ try {
1666
+ const root = resolve2(projectRoot);
1667
+ const dbPath = getDbPath(root);
1668
+ if (!existsSync5(dbPath)) return [];
1669
+ const store = await getStore(root);
1670
+ try {
1671
+ const allNodes = store.getAllNodes();
1672
+ const total = allNodes.length;
1673
+ if (total === 0) return [];
1674
+ const labels = allNodes.map((n) => n.label.toLowerCase());
1675
+ const results = [];
1676
+ for (const kw of keywords) {
1677
+ const kwLower = kw.toLowerCase();
1678
+ let df = 0;
1679
+ for (const label of labels) {
1680
+ if (label.includes(kwLower)) df += 1;
1681
+ }
1682
+ const idf = df === 0 ? 0 : Math.log(total / df);
1683
+ results.push({
1684
+ keyword: kw,
1685
+ documentFrequency: df,
1686
+ idf
1687
+ });
1688
+ }
1689
+ results.sort((a, b) => b.idf - a.idf);
1690
+ return results;
1691
+ } finally {
1692
+ store.close();
1693
+ }
1694
+ } catch {
1695
+ return [];
1696
+ }
1697
+ }
1493
1698
  async function learn(projectRoot, text, sourceLabel = "manual") {
1494
1699
  const { nodes, edges } = learnFromSession(text, sourceLabel);
1495
1700
  if (nodes.length === 0 && edges.length === 0) return { nodesAdded: 0 };
@@ -1505,6 +1710,10 @@ async function mistakes(projectRoot, options = {}) {
1505
1710
  const store = await getStore(projectRoot);
1506
1711
  try {
1507
1712
  let items = store.getAllNodes().filter((n) => n.kind === "mistake");
1713
+ if (options.sourceFile !== void 0) {
1714
+ const target = options.sourceFile;
1715
+ items = items.filter((m) => m.sourceFile === target);
1716
+ }
1508
1717
  if (options.sinceDays !== void 0) {
1509
1718
  const cutoff = Date.now() - options.sinceDays * 24 * 60 * 60 * 1e3;
1510
1719
  items = items.filter((m) => m.lastVerified >= cutoff);
@@ -1603,6 +1812,8 @@ export {
1603
1812
  path,
1604
1813
  godNodes,
1605
1814
  stats,
1815
+ getFileContext,
1816
+ computeKeywordIDF,
1606
1817
  learn,
1607
1818
  mistakes,
1608
1819
  benchmark