engramx 2.1.0 → 3.0.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +114 -17
  3. package/dist/{aider-context-J557IHIP.js → aider-context-6IDE3R7U.js} +1 -1
  4. package/dist/{chunk-PEH54LYC.js → chunk-645NBY6L.js} +42 -5
  5. package/dist/chunk-73IBCRFI.js +215 -0
  6. package/dist/{chunk-ZVWRIVWQ.js → chunk-B4UOE64J.js} +29 -11
  7. package/dist/{chunk-XFE6ZANP.js → chunk-FKY6HIT2.js} +1 -1
  8. package/dist/chunk-RJC6RNXJ.js +1405 -0
  9. package/dist/{chunk-4XA6ENNL.js → chunk-VLTWBTQ7.js} +14 -15
  10. package/dist/chunk-ZUC6OXSL.js +178 -0
  11. package/dist/cli.js +277 -1259
  12. package/dist/{core-TSXA5XZH.js → core-77F2BVYV.js} +2 -2
  13. package/dist/{cursor-mdc-VEOFFDVO.js → cursor-mdc-EEO7PYZ3.js} +1 -1
  14. package/dist/{exporter-AWXS34AS.js → exporter-ZYJ4WM2F.js} +1 -1
  15. package/dist/{importer-3Q5M6QBL.js → importer-4UWQDH4W.js} +1 -1
  16. package/dist/index.js +3 -3
  17. package/dist/mcp-client-ROOJF76V.js +9 -0
  18. package/dist/mcp-config-QD4NPVXB.js +12 -0
  19. package/dist/{migrate-UKCO6BUU.js → migrate-KJ5K5NWO.js} +1 -1
  20. package/dist/{plugin-loader-STTGYIL5.js → plugin-loader-SQQB6V74.js} +69 -23
  21. package/dist/resolver-H7GXVP73.js +21 -0
  22. package/dist/serve.js +2 -2
  23. package/dist/{server-A6MUVKQK.js → server-2ZQKXJ5M.js} +74 -6
  24. package/dist/{windsurf-rules-RWPKBHRD.js → windsurf-rules-XF7MYF6J.js} +1 -1
  25. package/dist/{wizard-AOXWMSXW.js → wizard-UH27IO4I.js} +2 -2
  26. package/package.json +8 -3
  27. package/scripts/postinstall.mjs +32 -0
  28. package/scripts/preuninstall.mjs +200 -0
  29. package/dist/{tuner-KFNNGKG3.js → tuner-Y2YENAZC.js} +3 -3
@@ -11,8 +11,8 @@ import {
11
11
  path,
12
12
  query,
13
13
  stats
14
- } from "./chunk-ZVWRIVWQ.js";
15
- import "./chunk-PEH54LYC.js";
14
+ } from "./chunk-B4UOE64J.js";
15
+ import "./chunk-645NBY6L.js";
16
16
  export {
17
17
  benchmark,
18
18
  computeKeywordIDF,
@@ -33,7 +33,7 @@ function buildSection(heading, bullets) {
33
33
  return [`## ${heading}`, "", ...bullets, ""].join("\n");
34
34
  }
35
35
  async function generateCursorMdc(projectPath) {
36
- const { getStore } = await import("./core-TSXA5XZH.js");
36
+ const { getStore } = await import("./core-77F2BVYV.js");
37
37
  const store = await getStore(projectPath);
38
38
  try {
39
39
  const allNodes = store.getAllNodes();
@@ -12,7 +12,7 @@ function buildSection(heading, nodes) {
12
12
  return [`## ${heading}`, "", ...bullets, ""].join("\n");
13
13
  }
14
14
  async function exportCcs(projectRoot) {
15
- const { getStore } = await import("./core-TSXA5XZH.js");
15
+ const { getStore } = await import("./core-77F2BVYV.js");
16
16
  const store = await getStore(projectRoot);
17
17
  try {
18
18
  const allNodes = store.getAllNodes();
@@ -25,7 +25,7 @@ async function importCcs(projectRoot) {
25
25
  if (!existsSync(filePath)) {
26
26
  return { nodesCreated: 0, sectionsFound: 0 };
27
27
  }
28
- const { getStore } = await import("./core-TSXA5XZH.js");
28
+ const { getStore } = await import("./core-77F2BVYV.js");
29
29
  const store = await getStore(projectRoot);
30
30
  try {
31
31
  const raw = readFileSync(filePath, "utf-8");
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  generateSummary,
5
5
  install,
6
6
  uninstall
7
- } from "./chunk-4XA6ENNL.js";
7
+ } from "./chunk-VLTWBTQ7.js";
8
8
  import {
9
9
  GraphStore,
10
10
  SUPPORTED_EXTENSIONS,
@@ -23,8 +23,8 @@ import {
23
23
  sliceGraphemeSafe,
24
24
  stats,
25
25
  truncateGraphemeSafe
26
- } from "./chunk-ZVWRIVWQ.js";
27
- import "./chunk-PEH54LYC.js";
26
+ } from "./chunk-B4UOE64J.js";
27
+ import "./chunk-645NBY6L.js";
28
28
  export {
29
29
  GraphStore,
30
30
  SUPPORTED_EXTENSIONS,
@@ -0,0 +1,9 @@
1
+ import {
2
+ __internalsForTesting,
3
+ createMcpProvider
4
+ } from "./chunk-73IBCRFI.js";
5
+ import "./chunk-ZUC6OXSL.js";
6
+ export {
7
+ __internalsForTesting,
8
+ createMcpProvider
9
+ };
@@ -0,0 +1,12 @@
1
+ import {
2
+ applyArgTemplate,
3
+ getMcpConfigPath,
4
+ loadMcpConfigs,
5
+ validateProviderConfig
6
+ } from "./chunk-ZUC6OXSL.js";
7
+ export {
8
+ applyArgTemplate,
9
+ getMcpConfigPath,
10
+ loadMcpConfigs,
11
+ validateProviderConfig
12
+ };
@@ -3,7 +3,7 @@ import {
3
3
  getSchemaVersion,
4
4
  rollback,
5
5
  runMigrations
6
- } from "./chunk-PEH54LYC.js";
6
+ } from "./chunk-645NBY6L.js";
7
7
  export {
8
8
  CURRENT_SCHEMA_VERSION,
9
9
  getSchemaVersion,
@@ -1,3 +1,10 @@
1
+ import {
2
+ createMcpProvider
3
+ } from "./chunk-73IBCRFI.js";
4
+ import {
5
+ validateProviderConfig
6
+ } from "./chunk-ZUC6OXSL.js";
7
+
1
8
  // src/providers/plugin-loader.ts
2
9
  import { existsSync, readdirSync, mkdirSync } from "fs";
3
10
  import { join } from "path";
@@ -23,34 +30,73 @@ function validatePlugin(mod) {
23
30
  return { plugin: null, reason: "default export is not an object" };
24
31
  }
25
32
  const p = candidate;
26
- const required = [
27
- "name",
28
- "label",
29
- "tier",
30
- "tokenBudget",
31
- "timeoutMs",
32
- "version",
33
- "resolve",
34
- "isAvailable"
35
- ];
36
- for (const field of required) {
37
- if (p[field] === void 0 || p[field] === null) {
38
- return { plugin: null, reason: `missing required field: ${field}` };
39
- }
33
+ if (typeof p.name !== "string" || p.name.length === 0) {
34
+ return { plugin: null, reason: "name must be a non-empty string" };
40
35
  }
41
- if (typeof p.resolve !== "function") {
42
- return { plugin: null, reason: "resolve must be a function" };
36
+ if (typeof p.label !== "string" || p.label.length === 0) {
37
+ return { plugin: null, reason: `[${p.name}] label must be a non-empty string` };
43
38
  }
44
- if (typeof p.isAvailable !== "function") {
45
- return { plugin: null, reason: "isAvailable must be a function" };
39
+ if (typeof p.version !== "string" || p.version.length === 0) {
40
+ return { plugin: null, reason: `[${p.name}] version must be a non-empty string` };
46
41
  }
47
- if (p.tier !== 1 && p.tier !== 2) {
48
- return { plugin: null, reason: `tier must be 1 or 2 (got ${String(p.tier)})` };
42
+ const hasMcpConfig = p.mcpConfig !== void 0 && p.mcpConfig !== null;
43
+ const hasResolve = typeof p.resolve === "function";
44
+ if (!hasMcpConfig && !hasResolve) {
45
+ return {
46
+ plugin: null,
47
+ reason: `[${p.name}] plugin needs either a resolve() function or an mcpConfig declaration`
48
+ };
49
49
  }
50
- if (typeof p.name !== "string" || p.name.length === 0) {
51
- return { plugin: null, reason: "name must be a non-empty string" };
50
+ if (hasResolve) {
51
+ const classicRequired = [
52
+ "tier",
53
+ "tokenBudget",
54
+ "timeoutMs",
55
+ "isAvailable"
56
+ ];
57
+ for (const field of classicRequired) {
58
+ if (p[field] === void 0 || p[field] === null) {
59
+ return { plugin: null, reason: `[${p.name}] missing required field: ${field}` };
60
+ }
61
+ }
62
+ if (typeof p.isAvailable !== "function") {
63
+ return { plugin: null, reason: `[${p.name}] isAvailable must be a function` };
64
+ }
65
+ if (p.tier !== 1 && p.tier !== 2) {
66
+ return { plugin: null, reason: `[${p.name}] tier must be 1 or 2 (got ${String(p.tier)})` };
67
+ }
68
+ return { plugin: candidate, reason: "" };
69
+ }
70
+ const rawConfig = p.mcpConfig;
71
+ const normalizedConfig = {
72
+ name: p.name,
73
+ label: p.label,
74
+ ...rawConfig
75
+ };
76
+ const validation = validateProviderConfig(normalizedConfig);
77
+ if (!validation.ok) {
78
+ return {
79
+ plugin: null,
80
+ reason: `[${p.name}] invalid mcpConfig: ${validation.reason}`
81
+ };
52
82
  }
53
- return { plugin: candidate, reason: "" };
83
+ const mcpProvider = createMcpProvider(
84
+ validation.value
85
+ );
86
+ const merged = {
87
+ name: p.name,
88
+ label: p.label,
89
+ version: p.version,
90
+ description: p.description,
91
+ author: p.author,
92
+ mcpConfig: p.mcpConfig,
93
+ tier: p.tier ?? mcpProvider.tier,
94
+ tokenBudget: p.tokenBudget ?? mcpProvider.tokenBudget,
95
+ timeoutMs: p.timeoutMs ?? mcpProvider.timeoutMs,
96
+ resolve: mcpProvider.resolve.bind(mcpProvider),
97
+ isAvailable: mcpProvider.isAvailable.bind(mcpProvider)
98
+ };
99
+ return { plugin: merged, reason: "" };
54
100
  }
55
101
  async function loadPlugins(dir) {
56
102
  const pluginsDir = dir ?? getPluginsDir();
@@ -0,0 +1,21 @@
1
+ import {
2
+ _resetAvailabilityCache,
3
+ _resetMcpProvidersCache,
4
+ boostByMistakes,
5
+ enforcePerProviderBudget,
6
+ resolveRichPacket,
7
+ resolveRichPacketStreaming,
8
+ warmAllProviders
9
+ } from "./chunk-RJC6RNXJ.js";
10
+ import "./chunk-22INHMKB.js";
11
+ import "./chunk-B4UOE64J.js";
12
+ import "./chunk-645NBY6L.js";
13
+ export {
14
+ _resetAvailabilityCache,
15
+ _resetMcpProvidersCache,
16
+ boostByMistakes,
17
+ enforcePerProviderBudget,
18
+ resolveRichPacket,
19
+ resolveRichPacketStreaming,
20
+ warmAllProviders
21
+ };
package/dist/serve.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  query,
10
10
  stats,
11
11
  truncateGraphemeSafe
12
- } from "./chunk-ZVWRIVWQ.js";
13
- import "./chunk-PEH54LYC.js";
12
+ } from "./chunk-B4UOE64J.js";
13
+ import "./chunk-645NBY6L.js";
14
14
 
15
15
  // src/serve.ts
16
16
  function clampInt(value, defaultValue, min, max) {
@@ -11,20 +11,20 @@ import {
11
11
  } from "./chunk-N6PPKOPK.js";
12
12
  import {
13
13
  summarizeHookLog
14
- } from "./chunk-XFE6ZANP.js";
15
- import {
16
- getComponentStatus
17
- } from "./chunk-G4U3QOOW.js";
14
+ } from "./chunk-FKY6HIT2.js";
18
15
  import {
19
16
  readHookLog
20
17
  } from "./chunk-KL6NSPVA.js";
18
+ import {
19
+ getComponentStatus
20
+ } from "./chunk-G4U3QOOW.js";
21
21
  import {
22
22
  getStore,
23
23
  learn,
24
24
  query,
25
25
  stats
26
- } from "./chunk-ZVWRIVWQ.js";
27
- import "./chunk-PEH54LYC.js";
26
+ } from "./chunk-B4UOE64J.js";
27
+ import "./chunk-645NBY6L.js";
28
28
 
29
29
  // src/server/http.ts
30
30
  import { createServer } from "http";
@@ -1093,6 +1093,72 @@ async function handleQuery(req, res, projectRoot) {
1093
1093
  json(res, 500, { error: "Query failed", detail: String(err) });
1094
1094
  }
1095
1095
  }
1096
+ async function handleContextStream(req, res, projectRoot) {
1097
+ const url = parseUrl(req);
1098
+ const filePath = url.searchParams.get("file");
1099
+ if (!filePath) {
1100
+ json(res, 400, { error: "Missing required query parameter 'file'" });
1101
+ return;
1102
+ }
1103
+ const lastEventIdHeader = req.headers["last-event-id"];
1104
+ const resumeAfter = (() => {
1105
+ if (typeof lastEventIdHeader !== "string") return -1;
1106
+ const n = parseInt(lastEventIdHeader, 10);
1107
+ return isNaN(n) ? -1 : n;
1108
+ })();
1109
+ res.writeHead(200, {
1110
+ "Content-Type": "text/event-stream",
1111
+ "Cache-Control": "no-cache, no-transform",
1112
+ Connection: "keep-alive",
1113
+ "X-Accel-Buffering": "no",
1114
+ ...corsHeaders(req)
1115
+ });
1116
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
1117
+ const context = {
1118
+ filePath,
1119
+ projectRoot,
1120
+ nodeIds: [],
1121
+ imports: [],
1122
+ hasTests: false,
1123
+ churnRate: 0
1124
+ };
1125
+ const { resolveRichPacketStreaming } = await import("./resolver-H7GXVP73.js");
1126
+ let eventId = 0;
1127
+ let disconnected = false;
1128
+ req.on("close", () => {
1129
+ disconnected = true;
1130
+ });
1131
+ try {
1132
+ for await (const event of resolveRichPacketStreaming(
1133
+ filePath,
1134
+ context
1135
+ )) {
1136
+ if (disconnected) break;
1137
+ if (eventId <= resumeAfter) {
1138
+ eventId++;
1139
+ continue;
1140
+ }
1141
+ const frame = `id: ${eventId}
1142
+ event: ${event.type}
1143
+ data: ${JSON.stringify(
1144
+ event.type === "provider" ? event.result : { providerCount: event.providerCount, durationMs: event.durationMs }
1145
+ )}
1146
+
1147
+ `;
1148
+ try {
1149
+ res.write(frame);
1150
+ } catch {
1151
+ return;
1152
+ }
1153
+ eventId++;
1154
+ }
1155
+ } finally {
1156
+ try {
1157
+ res.end();
1158
+ } catch {
1159
+ }
1160
+ }
1161
+ }
1096
1162
  async function handleStats(_req, res, projectRoot) {
1097
1163
  try {
1098
1164
  const result = await stats(projectRoot);
@@ -1405,6 +1471,8 @@ function createHttpServer(projectRoot, port) {
1405
1471
  await handleGraphGodNodes(req, res, projectRoot);
1406
1472
  } else if (req.method === "GET" && path === "/api/sse") {
1407
1473
  handleSSE(req, res, projectRoot);
1474
+ } else if (req.method === "GET" && path === "/context/stream") {
1475
+ await handleContextStream(req, res, projectRoot);
1408
1476
  } else if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1409
1477
  res.writeHead(200, {
1410
1478
  "Content-Type": "text/html; charset=utf-8",
@@ -7,7 +7,7 @@ function buildSection(heading, lines) {
7
7
  return [`## ${heading}`, "", ...lines, ""].join("\n");
8
8
  }
9
9
  async function generateWindsurfRules(projectRoot) {
10
- const { getStore } = await import("./core-TSXA5XZH.js");
10
+ const { getStore } = await import("./core-77F2BVYV.js");
11
11
  const store = await getStore(projectRoot);
12
12
  try {
13
13
  const allNodes = store.getAllNodes();
@@ -9,8 +9,8 @@ import {
9
9
  import "./chunk-G4U3QOOW.js";
10
10
  import {
11
11
  init
12
- } from "./chunk-ZVWRIVWQ.js";
13
- import "./chunk-PEH54LYC.js";
12
+ } from "./chunk-B4UOE64J.js";
13
+ import "./chunk-645NBY6L.js";
14
14
 
15
15
  // src/setup/wizard.ts
16
16
  import chalk from "chalk";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "engramx",
3
- "version": "2.1.0",
4
- "description": "The context spine for AI coding agents. 8 providers + pluggable context sources, 3-layer memory cache, web dashboard, multi-IDE support (Claude Code, Cursor, Continue, Zed, Aider, Windsurf, Neovim, Emacs). 88.1% measured session-level token savings. Local SQLite, zero native deps, zero cloud.",
3
+ "version": "3.0.1",
4
+ "description": "The context spine for AI coding agents. 9 built-in providers + mcpConfig plugin contract (wrap any MCP server in 10 lines), generic MCP-client aggregator (stdio), pre-mortem mistake-guard, bi-temporal mistake memory, Anthropic Auto-Memory bridge, SSE streaming context packets, dual-emit AGENTS.md+CLAUDE.md. 90.8% measured real-world token savings (reproducible bench included). Local SQLite, zero cloud.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/NickCirv/engram.git"
@@ -25,7 +25,9 @@
25
25
  "lint": "tsc --noEmit",
26
26
  "prepublishOnly": "npm run build",
27
27
  "bench": "tsx bench/runner.ts",
28
- "stress": "tsx bench/stress-test.ts"
28
+ "stress": "tsx bench/stress-test.ts",
29
+ "postinstall": "node scripts/postinstall.mjs",
30
+ "preuninstall": "node scripts/preuninstall.mjs"
29
31
  },
30
32
  "keywords": [
31
33
  "structural-code-graph",
@@ -51,11 +53,14 @@
51
53
  },
52
54
  "files": [
53
55
  "dist",
56
+ "scripts/preuninstall.mjs",
57
+ "scripts/postinstall.mjs",
54
58
  "LICENSE",
55
59
  "README.md",
56
60
  "CHANGELOG.md"
57
61
  ],
58
62
  "dependencies": {
63
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
64
  "chalk": "^5.6.2",
60
65
  "commander": "^14.0.3",
61
66
  "sql.js": "^1.14.1",
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall — one-time info banner on `npm install -g engramx`.
4
+ * Prints the 'what to do next' hint + the clean-uninstall flow so users
5
+ * don't end up with orphaned hooks (see CHANGELOG v3.0.1 context).
6
+ *
7
+ * Contract:
8
+ * - Never fails the install. Always exit 0.
9
+ * - Respects $CI (quiet in CI environments).
10
+ * - Respects $ENGRAM_NO_POSTINSTALL=1 (ops lever for automated rollouts).
11
+ */
12
+ if (process.env.CI || process.env.ENGRAM_NO_POSTINSTALL === "1") {
13
+ process.exit(0);
14
+ }
15
+
16
+ const lines = [
17
+ "",
18
+ " ✅ engramx installed.",
19
+ "",
20
+ " Get started:",
21
+ " cd <your-project> && engram setup",
22
+ "",
23
+ " To remove cleanly later (avoids orphaned Claude Code hooks):",
24
+ " engram uninstall-hook && npm uninstall -g engramx",
25
+ " (npm uninstall -g engramx by itself also works now — preuninstall",
26
+ " hook-cleanup is automatic in 3.0.1+)",
27
+ "",
28
+ " Docs: https://github.com/NickCirv/engram",
29
+ "",
30
+ ];
31
+ process.stdout.write(lines.join("\n"));
32
+ process.exit(0);
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * preuninstall — cleans up engramx's hook entries in the user's
4
+ * Claude Code settings BEFORE the binary is removed by npm.
5
+ *
6
+ * Why this file exists: without it, `npm uninstall -g engramx` leaves
7
+ * stale hook entries in ~/.claude/settings.json pointing at a binary
8
+ * that no longer exists. Claude Code then fires those hooks on every
9
+ * tool call, exec fails with ENOENT, and user-visible behavior is
10
+ * "Claude Code stopped executing anything." Reported by @freenow82 in
11
+ * 3.0.0's post-launch window — see CHANGELOG v3.0.1.
12
+ *
13
+ * Contract (critical):
14
+ * - NEVER fail the uninstall. We always exit 0. If cleanup hits any
15
+ * problem, we print a one-line hint and move on. The user's goal is
16
+ * to uninstall; we will not be the thing that blocks them.
17
+ * - Self-contained: this script must work even if `engram` is not on
18
+ * PATH at script time (npm's script env usually has it, but we're
19
+ * defensive — edge cases exist).
20
+ * - Scoped conservatively: only touch ~/.claude/settings.json (the
21
+ * USER scope, which is what a global install writes to). Do not
22
+ * walk arbitrary project directories.
23
+ * - Back up before edit. Atomic rename on write.
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { homedir } from "node:os";
28
+
29
+ const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
30
+
31
+ // ── safe helpers ────────────────────────────────────────────────────
32
+
33
+ function parseJsonSafe(text) {
34
+ try {
35
+ return text.trim() ? JSON.parse(text) : {};
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * A hook entry is "engram-owned" if its command references the engram
43
+ * binary or any engram-related shell. We match conservatively: the
44
+ * substring "engram" (case-insensitive) anywhere in the command string.
45
+ * This is aggressive but safe on uninstall — if a user has a hook
46
+ * unrelated to engramx that happens to contain the word "engram", they
47
+ * wrote that themselves and can re-add it. On uninstall, err toward
48
+ * cleaning more rather than leaving orphans.
49
+ */
50
+ function isEngramHook(entry) {
51
+ if (!entry || typeof entry !== "object") return false;
52
+ const cmd = typeof entry.command === "string" ? entry.command : "";
53
+ return /engram/i.test(cmd);
54
+ }
55
+
56
+ /**
57
+ * Walk the entire hooks structure. `hooks` may be:
58
+ * hooks[event] = [{ matcher, hooks: [{ command, ... }, ...] }, ...]
59
+ * We rebuild each inner `hooks` array without engram entries, drop
60
+ * matchers whose inner array is now empty, drop event keys whose list
61
+ * is now empty.
62
+ */
63
+ function stripEngramHooks(settings) {
64
+ const changes = { hooksRemoved: 0, eventsAffected: new Set() };
65
+ if (!settings || typeof settings !== "object") return { settings, changes };
66
+ const { hooks } = settings;
67
+ if (!hooks || typeof hooks !== "object") return { settings, changes };
68
+
69
+ for (const event of Object.keys(hooks)) {
70
+ const list = Array.isArray(hooks[event]) ? hooks[event] : null;
71
+ if (!list) continue;
72
+ const kept = [];
73
+ for (const matcher of list) {
74
+ if (!matcher || typeof matcher !== "object") {
75
+ kept.push(matcher);
76
+ continue;
77
+ }
78
+ const innerHooks = Array.isArray(matcher.hooks) ? matcher.hooks : null;
79
+ if (!innerHooks) {
80
+ kept.push(matcher);
81
+ continue;
82
+ }
83
+ const innerKept = innerHooks.filter((h) => {
84
+ if (isEngramHook(h)) {
85
+ changes.hooksRemoved++;
86
+ changes.eventsAffected.add(event);
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ if (innerKept.length > 0) {
92
+ kept.push({ ...matcher, hooks: innerKept });
93
+ } else {
94
+ // entire matcher was engram-only — drop it
95
+ changes.eventsAffected.add(event);
96
+ }
97
+ }
98
+ if (kept.length > 0) {
99
+ hooks[event] = kept;
100
+ } else {
101
+ delete hooks[event];
102
+ }
103
+ }
104
+
105
+ // If hooks is now empty, drop the key entirely
106
+ if (hooks && Object.keys(hooks).length === 0) {
107
+ delete settings.hooks;
108
+ }
109
+
110
+ // Also drop engram statusLine (HUD)
111
+ if (
112
+ settings.statusLine &&
113
+ typeof settings.statusLine === "object" &&
114
+ typeof settings.statusLine.command === "string" &&
115
+ /engram/i.test(settings.statusLine.command)
116
+ ) {
117
+ delete settings.statusLine;
118
+ changes.eventsAffected.add("statusLine");
119
+ }
120
+
121
+ return { settings, changes };
122
+ }
123
+
124
+ // ── main ────────────────────────────────────────────────────────────
125
+
126
+ function main() {
127
+ // If no settings file, nothing to clean. Silent exit.
128
+ if (!existsSync(SETTINGS_PATH)) {
129
+ return;
130
+ }
131
+
132
+ let raw;
133
+ try {
134
+ raw = readFileSync(SETTINGS_PATH, "utf-8");
135
+ } catch {
136
+ return; // unreadable — leave alone, user will handle
137
+ }
138
+
139
+ const parsed = parseJsonSafe(raw);
140
+ if (parsed === null) {
141
+ console.log(
142
+ "[engramx preuninstall] skipped: could not parse " +
143
+ SETTINGS_PATH +
144
+ " (settings unchanged)."
145
+ );
146
+ return;
147
+ }
148
+
149
+ const { settings, changes } = stripEngramHooks(parsed);
150
+ if (changes.hooksRemoved === 0 && changes.eventsAffected.size === 0) {
151
+ return; // nothing to do
152
+ }
153
+
154
+ // Back up before any write.
155
+ const backupPath = `${SETTINGS_PATH}.engramx-preuninstall-${new Date()
156
+ .toISOString()
157
+ .replace(/[:.]/g, "-")}.bak`;
158
+ try {
159
+ copyFileSync(SETTINGS_PATH, backupPath);
160
+ } catch {
161
+ // if we can't back up, don't write — safety first
162
+ console.log(
163
+ "[engramx preuninstall] skipped: could not write backup next to " +
164
+ SETTINGS_PATH +
165
+ " (settings unchanged)."
166
+ );
167
+ return;
168
+ }
169
+
170
+ // Atomic write via rename.
171
+ try {
172
+ const tmp = `${SETTINGS_PATH}.engramx-preuninstall-tmp`;
173
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
174
+ renameSync(tmp, SETTINGS_PATH);
175
+ } catch (err) {
176
+ console.log(
177
+ "[engramx preuninstall] skipped: " + String(err) + " (settings unchanged)."
178
+ );
179
+ return;
180
+ }
181
+
182
+ console.log(
183
+ `[engramx] cleaned up ${changes.hooksRemoved} hook entr${changes.hooksRemoved === 1 ? "y" : "ies"} from ${SETTINGS_PATH}`
184
+ );
185
+ console.log(`[engramx] backup saved: ${backupPath}`);
186
+ console.log(
187
+ "[engramx] if anything looks off, restore with: cp " +
188
+ backupPath +
189
+ " " +
190
+ SETTINGS_PATH
191
+ );
192
+ }
193
+
194
+ try {
195
+ main();
196
+ } catch (err) {
197
+ // HARD REQUIREMENT: never fail uninstall. Swallow anything.
198
+ console.log("[engramx preuninstall] error (ignored): " + String(err));
199
+ }
200
+ process.exit(0);
@@ -1,10 +1,10 @@
1
+ import {
2
+ readHookLog
3
+ } from "./chunk-KL6NSPVA.js";
1
4
  import {
2
5
  readConfig,
3
6
  writeConfig
4
7
  } from "./chunk-22INHMKB.js";
5
- import {
6
- readHookLog
7
- } from "./chunk-KL6NSPVA.js";
8
8
 
9
9
  // src/tuner/index.ts
10
10
  var MIN_CONFIDENCE_SAMPLES = 10;