engramx 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,207 @@ All notable changes to engram are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [2.1.0] — 2026-04-21 — "Reliability + Zero-Friction Install"
10
+
11
+ First release in the v2.1 / v2.2 / v3.0 elevation trilogy. Design spec
12
+ at `docs/superpowers/specs/2026-04-20-engram-elevation-trilogy-design.md`.
13
+
14
+ Headline: `engram setup` is the new one-command first-run flow. Users
15
+ go from `npm install -g engramx` to a working Sentinel hook + indexed
16
+ graph in under 30 seconds. `engram doctor` reports component health
17
+ with remediation hints. `engram update` ships future hotfixes to every
18
+ install without surprise — passive notify, zero telemetry, one-command
19
+ upgrade. Plus fixes for issue #11 (AST/LSP path bug in flattened
20
+ bundles) and issue #14's Bash-ops half (auto-reindex on `rm`/`mv`/
21
+ `git rm` via an opt-in PostToolUse gate).
22
+
23
+ Contributor credit this release: [@gabiudrescu](https://github.com/gabiudrescu)
24
+ for PR #13 (reindex CLI + `install-hook --auto-reindex`), PR #12
25
+ (watcher prune on delete/rename), and the original v2.0.2 security
26
+ disclosure. [@ttessarolo](https://github.com/ttessarolo) for precise
27
+ forensics + suggested fix on issue #11.
28
+
29
+ ### Added — v2.1 "Reliability + Zero-Friction Install" track
30
+
31
+ - **`engram update`** — one-command self-upgrade.
32
+ Passive notify on every `engram *` invocation when a newer version is
33
+ available (cached, at most one line on stderr, throttled to a 7-day
34
+ registry check). Manual trigger detects the package manager that owns
35
+ the engram install (npm / pnpm / yarn / bun via install-path markers)
36
+ and shells out to its global-upgrade command. `--check` for dry-probe,
37
+ `--force` to bypass the 7-day throttle, `--dry-run` to print the
38
+ upgrade command without executing it, `--manager <mgr>` override.
39
+ Zero telemetry: the only network call is an anonymous GET to
40
+ `registry.npmjs.org/engramx/latest`. `ENGRAM_NO_UPDATE_CHECK=1` and
41
+ `$CI` disable the entire subsystem. Addresses the "1,300 weekly
42
+ downloads, 10/day organic, near-zero hotfix reach" problem.
43
+
44
+ - **`engram doctor`** — component health report with remediation hints.
45
+ Wraps existing probes (HTTP, LSP, AST, IDE adapters) plus four new
46
+ checks: engram version freshness, `.engram/graph.db` presence,
47
+ Sentinel hook installation, IDE adapter count. Each check emits
48
+ severity (ok / warn / fail) + detail + optional remediation. Exit
49
+ code reflects overall severity (0 ok, 1 warn, 2 fail) so `doctor`
50
+ is CI-friendly. `--verbose` shows remediation hints; `--json` /
51
+ `--export` emits redacted JSON for bug-report attachment
52
+ (`projectRoot` intentionally omitted — can contain usernames).
53
+
54
+ - **`engram setup`** — zero-friction first-run wizard. One command for
55
+ "go from cloned repo to working engram in under 30 seconds."
56
+ Runs `init` (if `.engram/graph.db` missing) → `install-hook` (with
57
+ prompted scope, `local` default) → detects IDE adapters (Cursor,
58
+ Windsurf, Continue.dev, Aider) and suggests the matching `gen-*`
59
+ command for each → finishes with a `doctor` summary. Each step is
60
+ idempotent. `--yes` runs with defaults; `--dry-run` prints intent
61
+ without acting; `--scope` controls the install-hook scope. Drops
62
+ install-to-first-value from 4 commands to 1.
63
+
64
+ - **`engram init --with-hook`** — shorthand for `init` followed by
65
+ `install-hook` (local scope, idempotent). The #1 thing every user
66
+ does after `init` was `install-hook`; now it's one step.
67
+
68
+ - **First-run hint.** On any `engram` subcommand invoked in a repo
69
+ lacking `.engram/graph.db`, print one line on stderr:
70
+ `💡 First time in this repo? Run 'engram setup' for a zero-friction install.`
71
+ Throttled via `~/.engram/first-run-shown` (fires once per machine,
72
+ not per repo). Silenced in `$CI`, under `ENGRAM_NO_UPDATE_CHECK=1`,
73
+ and under the JSON-stdout commands (`intercept`, `cursor-intercept`,
74
+ `hud-label`, `setup`, `init`, `update`, `doctor`) so neither
75
+ pollutes the hook protocol.
76
+
77
+ - **Bash PostToolUse parser for auto-reindex** — closes half of
78
+ [#14](https://github.com/NickCirv/engram/issues/14).
79
+ `src/intercept/handlers/bash-postool.ts` parses file-mutating Bash
80
+ commands (`rm`, `mv`, `cp`, `git rm`, `git mv`, single-redirect
81
+ `<cmd> > <dst>`) into `FileOp { action, path }` records. Strict
82
+ parser: globs, pipes, subshells, command-substitution, directory
83
+ ops, and `touch` all pass through untouched. Wired into the
84
+ PostToolUse observer path in `handlers/post-tool.ts` — on Bash
85
+ PostToolUse events, each op is handed to `syncFile()` fire-and-forget.
86
+ Gated by `ENGRAM_AUTO_REINDEX=1` opt-in until
87
+ [#13](https://github.com/NickCirv/engram/pull/13)'s install-hook
88
+ `--auto-reindex` flag lands; that flag will toggle the env gate
89
+ implicitly.
90
+
91
+ ### Fixed — v2.1 reliability
92
+
93
+ - **AST grammar detection in flattened bundles**
94
+ ([#11](https://github.com/NickCirv/engram/issues/11) partial).
95
+ When `tsup`/`esbuild` flattens chunks to `engramx/dist/chunk-*.js`,
96
+ `import.meta.url` resolves to `engramx/dist` and the previous
97
+ candidates (`../grammars` and `../../dist/grammars`) both missed the
98
+ actual grammar dir. Added `join(here, "grammars")` as the first
99
+ candidate; dev-time layout (`src/intercept/`) still works via the
100
+ third candidate. Thanks [@ttessarolo](https://github.com/ttessarolo).
101
+
102
+ - **LSP socket candidate coverage**
103
+ ([#11](https://github.com/NickCirv/engram/issues/11) partial).
104
+ `checkLsp` was looking for two socket names while
105
+ `lsp-connection.ts::candidateSockets()` probes six. Synced the list
106
+ so HUD availability matches actual provider availability. Kept
107
+ `.engram/lsp-available` as an explicit user opt-in marker for
108
+ back-compat.
109
+
110
+ ### Fixed
111
+
112
+ - **Locale-independent number formatting across the codebase.** All 10
113
+ `Number.prototype.toLocaleString()` callsites in `src/cli.ts`,
114
+ `src/serve.ts`, `src/dashboard.ts`, and `src/intercept/stats.ts` have
115
+ been migrated to a shared `formatThousands()` helper in
116
+ `src/graph/render-utils.ts`. Two wins:
117
+
118
+ 1. **Deterministic performance.** First-call ICU init on Windows Node
119
+ has been observed to take multiple seconds in GitHub Actions VMs,
120
+ flaking tests at the 5000ms default timeout (seen on
121
+ `tests/intercept/stats.test.ts > formatStatsSummary` post-merge on
122
+ `9f99f5b`). The regex-based helper runs in microseconds with no
123
+ ICU dependency.
124
+ 2. **Locale independence.** `toLocaleString()` emits `"1,234"` on
125
+ en-US but `"1.234"` on de-DE and `"1 234"` on fr-FR, giving users
126
+ running engram in non-US shells inconsistent output. All CLI +
127
+ MCP server + dashboard numbers now render with commas regardless
128
+ of system locale.
129
+
130
+ Added `tests/render-utils.test.ts > formatThousands` — 6 tests
131
+ covering single-digit, multi-group, negative, and locale-stable cases.
132
+ Also added `vitest.config.ts` with CI-only `retry: 1` +
133
+ `testTimeout: 15000ms` as defense-in-depth against other cold-worker
134
+ flakes.
135
+
136
+ - **`engram watch` now prunes graph nodes when watched files are deleted
137
+ or renamed** ([#9](https://github.com/NickCirv/engram/issues/9),
138
+ [#12](https://github.com/NickCirv/engram/pull/12)). Previously the
139
+ watcher only subscribed to `change` events, silently ignoring the
140
+ `rename` events that `fs.watch` fires for create/unlink across all
141
+ platforms. Deletions left stale nodes in the graph until the next
142
+ `engram init`; renames produced duplicate nodes under the old and new
143
+ `sourceFile` paths. Thanks [@gabiudrescu](https://github.com/gabiudrescu).
144
+
145
+ ### Added
146
+
147
+ - **`syncFile(absPath, root)`** exported from `src/watcher.ts` — the shared
148
+ "exists → reindex; gone (and was indexed) → prune" primitive reused by
149
+ the upcoming `engram reindex` CLI subcommand ([#8](https://github.com/NickCirv/engram/issues/8)).
150
+ Returns a discriminated `SyncResult` (`indexed` | `pruned` | `skipped`).
151
+ - **`GraphStore.countBySourceFile(relPath)`** — noise-reduction gate so
152
+ `onDelete` only fires for files the graph actually indexed.
153
+ - **`onDelete` callback on `WatchOptions`** — fires with `(filePath, prunedCount)`
154
+ when the watcher prunes a deleted file's nodes.
155
+ - **`× <path> pruned (N nodes)`** log line in `engram watch`, distinct from
156
+ the existing green `↻` reindex line.
157
+ - **`gen-cursor --watch`, `gen-aider --watch`, `gen-windsurfrules --watch`**
158
+ now regenerate their output files on source-file delete (not just on
159
+ reindex), so generated artifacts no longer keep stale references to
160
+ deleted sources.
161
+ - **`engram reindex <file>` CLI subcommand**
162
+ ([#8](https://github.com/NickCirv/engram/issues/8)) — re-indexes a
163
+ single file into the knowledge graph. The missing primitive for per-
164
+ edit freshness: Claude Code PostToolUse hooks, editor plugins, and CI
165
+ can now keep the graph in sync without running a long-lived watcher.
166
+ Reuses `syncFile()` so semantics match `engram watch`: exists →
167
+ reindex; missing-but-previously-indexed → prune; unsupported ext or
168
+ ignored directory → silent exit 0 (safe to fire on every edit). On
169
+ success prints a single line `engram: reindexed <file> (<N> nodes)`
170
+ (or `pruned`) using locale-stable `formatThousands`. `--verbose`
171
+ surfaces stack traces; default error output is a single stderr line.
172
+ Missing graph exits 1 with `engram: no graph found at <root>. Run
173
+ 'engram init' first.`, matching `engram watch`.
174
+ - **`formatReindexLine(result, displayPath)`** exported from
175
+ `src/watcher.ts` — pure formatter shared by the new subcommand. Returns
176
+ `null` for skipped results so callers stay silent.
177
+ - **`engram reindex-hook` subcommand + `engram install-hook --auto-reindex`**
178
+ ([#8](https://github.com/NickCirv/engram/issues/8), opt-in auto-wire).
179
+ `reindex-hook` reads Claude Code's PostToolUse payload from stdin and
180
+ re-indexes `tool_input.file_path` via the shared `syncFile()` primitive.
181
+ Contract: ALWAYS exits 0 — malformed JSON, missing fields, non-project
182
+ `cwd`, and all internal errors resolve to a silent no-op so the hook
183
+ can never fail Claude Code's tool cycle. `install-hook --auto-reindex`
184
+ appends a second PostToolUse entry with matcher `Edit|Write|MultiEdit`
185
+ calling `engram reindex-hook`; off by default so existing users aren't
186
+ surprised. The new entry is recognized by `isEngramHookEntry()` so
187
+ `engram uninstall-hook` strips it alongside the primary intercept
188
+ entries. Idempotent — reinstalling with `--auto-reindex` is a no-op
189
+ when the entry already exists.
190
+ - **`runReindexHook(payload)`** exported from `src/watcher.ts` — the
191
+ pure async handler behind the `reindex-hook` subcommand. Validates
192
+ payload shape, resolves project root from `cwd`, delegates to
193
+ `syncFile`. Swallows every error.
194
+ - **`buildReindexHookEntry()` + `ENGRAM_REINDEX_HOOK_MATCHER`
195
+ (`"Edit|Write|MultiEdit"`) + `DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND`
196
+ (`"engram reindex-hook"`)** exported from `src/intercept/installer.ts`
197
+ — the data primitives for the optional entry. Added
198
+ `InstallOptions.autoReindex` and `InstallResult.autoReindexAdded` to
199
+ thread the opt-in through the existing installer surface.
200
+
201
+ ### Notes
202
+
203
+ - Directory deletion (`rm -rf src/foo`) is intentionally not handled by the
204
+ watcher — `fs.watch` fires a single rename event on the directory path
205
+ with no per-file information. A full `engram init` handles that case
206
+ today; per-file directory-prefix pruning is tracked for v2.2.
207
+
7
208
  ## [2.0.2] — 2026-04-18 — Security hotfix: HTTP server auth & CORS
8
209
 
9
210
  **This is a security release. Upgrade immediately if you run `engram server`
package/README.md CHANGED
@@ -2,6 +2,35 @@
2
2
  <img src="assets/banner.png" alt="engram — AI coding memory" width="100%">
3
3
  </p>
4
4
 
5
+ <!-- ============================================================
6
+ 24-second product showcase (Hyperframes-rendered MP4 + WebM).
7
+ Source: docs/demos/showcase.html · scenes drive both the
8
+ live HTML player and this MP4. Edit scene-table.md to change.
9
+ If the MP4 isn't rendered yet, GitHub gracefully shows the
10
+ poster image and links to the live HTML player.
11
+ ============================================================ -->
12
+ <p align="center">
13
+ <video src="https://raw.githubusercontent.com/NickCirv/engram/main/docs/demos/showcase.mp4"
14
+ controls
15
+ muted
16
+ playsinline
17
+ poster="docs/demos/poster.svg"
18
+ width="100%">
19
+ <a href="docs/demos/showcase.html">
20
+ <img src="docs/demos/poster.svg" alt="engram — 24-second showcase (click to open the live HTML player)" width="100%">
21
+ </a>
22
+ </video>
23
+ </p>
24
+
25
+ <p align="center">
26
+ <sub>
27
+ <a href="docs/install.html"><strong>Install Page</strong></a> ·
28
+ <a href="docs/demos/showcase.html"><strong>Live Demo</strong></a> ·
29
+ <a href="docs/demos/scene-table.md"><strong>Scene Table</strong></a> ·
30
+ rendered with <a href="https://github.com/heygen-com/hyperframes">Hyperframes</a>
31
+ </sub>
32
+ </p>
33
+
5
34
  <p align="center">
6
35
  <a href="#install"><strong>Install</strong></a> ·
7
36
  <a href="#quickstart"><strong>Quickstart</strong></a> ·
@@ -175,10 +204,23 @@ npm install -g engramx
175
204
 
176
205
  Requires Node.js 20+. Zero native dependencies. No build tools. Local SQLite via sql.js WASM — no Rust, no Python, no system libs.
177
206
 
207
+ > **Prefer a designed walkthrough?** Open [**docs/install.html**](docs/install.html) — three-step install, benefits matrix, IDE coverage, FAQ. Local file, opens in any browser. Brand-matched terminal-mono aesthetic.
208
+
178
209
  ---
179
210
 
180
211
  ## Quickstart
181
212
 
213
+ **One command, zero friction:**
214
+
215
+ ```bash
216
+ cd ~/my-project
217
+ engram setup # init + install-hook + adapter detect + doctor
218
+ ```
219
+
220
+ `engram setup` runs the whole first-run flow interactively (or pass `-y` for defaults, `--dry-run` to preview). It is idempotent — safe to re-run, and skips any step already done.
221
+
222
+ <sub>Prefer the individual commands?</sub>
223
+
182
224
  ```bash
183
225
  cd ~/my-project
184
226
  engram init # scan codebase → .engram/graph.db (~40ms, 0 tokens)
@@ -186,6 +228,16 @@ engram install-hook # wire the Sentinel into Claude Code
186
228
  engram ui # open the web dashboard in your browser
187
229
  ```
188
230
 
231
+ **Diagnostics + self-update:**
232
+
233
+ ```bash
234
+ engram doctor # component health + remediation hints (0=ok, 1=warn, 2=fail)
235
+ engram update # check + upgrade via detected pkg manager (no telemetry)
236
+ engram update --check # check only, dry-probe the registry
237
+ ```
238
+
239
+ Set `ENGRAM_NO_UPDATE_CHECK=1` to disable the passive "newer version available" hint on every CLI invocation. `$CI` does the same automatically.
240
+
189
241
  Open a Claude Code session. When the agent reads a well-covered file you will see a system-reminder with the structural summary instead of file contents. After the session:
190
242
 
191
243
  ```bash
@@ -275,6 +327,7 @@ engram install-hook # default: .claude/settings.local.json (git
275
327
  engram install-hook --scope project # .claude/settings.json (committed)
276
328
  engram install-hook --scope user # ~/.claude/settings.json (global)
277
329
  engram install-hook --dry-run # preview changes without writing
330
+ engram install-hook --auto-reindex # also keep the graph fresh after every Edit/Write/MultiEdit (#8)
278
331
  ```
279
332
 
280
333
  **Kill switch (if anything goes wrong):**
@@ -336,6 +389,8 @@ engram hook-enable # remove kill switch
336
389
 
337
390
  ```bash
338
391
  engram watch [path] # live file watcher — incremental re-index on save
392
+ engram reindex <file> # re-index one file (editor/hook/CI primitive, issue #8)
393
+ engram reindex-hook # PostToolUse hook entry point (reads JSON from stdin, always exits 0)
339
394
  engram dashboard [path] # live terminal dashboard
340
395
  engram hud-label [path] # JSON label for Claude HUD --extra-cmd integration
341
396
  engram hooks install # install post-commit + post-checkout git hooks
@@ -7,7 +7,7 @@ function buildSection(heading, lines) {
7
7
  return [`## ${heading}`, "", ...lines, ""].join("\n");
8
8
  }
9
9
  async function generateAiderContext(projectRoot) {
10
- const { getStore } = await import("./core-6IY5L6II.js");
10
+ const { getStore } = await import("./core-TSXA5XZH.js");
11
11
  const store = await getStore(projectRoot);
12
12
  try {
13
13
  const allNodes = store.getAllNodes();
@@ -0,0 +1,12 @@
1
+ import {
2
+ cachePath,
3
+ checkForUpdate,
4
+ isNewer,
5
+ optedOut
6
+ } from "./chunk-RM2TBOVW.js";
7
+ export {
8
+ cachePath,
9
+ checkForUpdate,
10
+ isNewer,
11
+ optedOut
12
+ };
@@ -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-6IY5L6II.js");
313
+ const { getStore } = await import("./core-TSXA5XZH.js");
314
314
  const store = await getStore(projectRoot);
315
315
  try {
316
316
  let view = VIEWS.general;
@@ -1,93 +1,3 @@
1
- // src/intercept/stats.ts
2
- var ESTIMATED_TOKENS_PER_READ_DENY = 1200;
3
- function summarizeHookLog(entries) {
4
- const byEvent = {};
5
- const byTool = {};
6
- const byDecision = {};
7
- let readDenyCount = 0;
8
- let firstEntryTs = null;
9
- let lastEntryTs = null;
10
- for (const entry of entries) {
11
- const event = entry.event ?? "unknown";
12
- byEvent[event] = (byEvent[event] ?? 0) + 1;
13
- const tool = entry.tool ?? "unknown";
14
- byTool[tool] = (byTool[tool] ?? 0) + 1;
15
- if (entry.decision) {
16
- byDecision[entry.decision] = (byDecision[entry.decision] ?? 0) + 1;
17
- }
18
- if (event === "PreToolUse" && tool === "Read" && entry.decision === "deny") {
19
- readDenyCount += 1;
20
- }
21
- const ts = entry.ts;
22
- if (typeof ts === "string") {
23
- if (firstEntryTs === null || ts < firstEntryTs) firstEntryTs = ts;
24
- if (lastEntryTs === null || ts > lastEntryTs) lastEntryTs = ts;
25
- }
26
- }
27
- return {
28
- totalInvocations: entries.length,
29
- byEvent: Object.freeze(byEvent),
30
- byTool: Object.freeze(byTool),
31
- byDecision: Object.freeze(byDecision),
32
- readDenyCount,
33
- estimatedTokensSaved: readDenyCount * ESTIMATED_TOKENS_PER_READ_DENY,
34
- firstEntry: firstEntryTs,
35
- lastEntry: lastEntryTs
36
- };
37
- }
38
- function formatStatsSummary(summary) {
39
- if (summary.totalInvocations === 0) {
40
- return "engram hook stats: no log entries yet.\n\nRun engram install-hook in a project, then use Claude Code to see interceptions.";
41
- }
42
- const lines = [];
43
- lines.push(`engram hook stats (${summary.totalInvocations} invocations)`);
44
- lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
45
- if (summary.firstEntry && summary.lastEntry) {
46
- lines.push(`Time range: ${summary.firstEntry} \u2192 ${summary.lastEntry}`);
47
- lines.push("");
48
- }
49
- lines.push("By event:");
50
- const eventEntries = Object.entries(summary.byEvent).sort(
51
- (a, b) => b[1] - a[1]
52
- );
53
- for (const [event, count] of eventEntries) {
54
- const pct = (count / summary.totalInvocations * 100).toFixed(1);
55
- lines.push(` ${event.padEnd(18)} ${String(count).padStart(5)} (${pct}%)`);
56
- }
57
- lines.push("");
58
- lines.push("By tool:");
59
- const toolEntries = Object.entries(summary.byTool).filter(([k]) => k !== "unknown").sort((a, b) => b[1] - a[1]);
60
- for (const [tool, count] of toolEntries) {
61
- lines.push(` ${tool.padEnd(18)} ${String(count).padStart(5)}`);
62
- }
63
- if (toolEntries.length === 0) {
64
- lines.push(" (no tool-tagged entries)");
65
- }
66
- lines.push("");
67
- const decisionEntries = Object.entries(summary.byDecision);
68
- if (decisionEntries.length > 0) {
69
- lines.push("PreToolUse decisions:");
70
- for (const [decision, count] of decisionEntries.sort(
71
- (a, b) => b[1] - a[1]
72
- )) {
73
- lines.push(` ${decision.padEnd(18)} ${String(count).padStart(5)}`);
74
- }
75
- lines.push("");
76
- }
77
- if (summary.readDenyCount > 0) {
78
- lines.push(
79
- `Estimated tokens saved: ~${summary.estimatedTokensSaved.toLocaleString()}`
80
- );
81
- lines.push(
82
- ` (${summary.readDenyCount} Read denies \xD7 ${ESTIMATED_TOKENS_PER_READ_DENY} tok/deny avg)`
83
- );
84
- } else {
85
- lines.push("Estimated tokens saved: 0");
86
- lines.push(" (no PreToolUse:Read denies recorded yet)");
87
- }
88
- return lines.join("\n");
89
- }
90
-
91
1
  // src/intercept/component-status.ts
92
2
  import { existsSync, readFileSync, writeFileSync } from "fs";
93
3
  import { join, dirname } from "path";
@@ -112,10 +22,16 @@ function checkHttp(projectRoot) {
112
22
  }
113
23
  function checkLsp(projectRoot) {
114
24
  if (existsSync(join(projectRoot, ".engram", "lsp-available"))) return true;
25
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
115
26
  const tmp = tmpdir();
116
27
  const candidates = [
117
- join(tmp, "tsserver.sock"),
118
- join(tmp, "typescript-language-server.sock")
28
+ join(tmp, `tsserver-${uid}.sock`),
29
+ join(tmp, "lsp-server.sock"),
30
+ join(tmp, "typescript-language-server.sock"),
31
+ join(tmp, `pyright-${uid}.sock`),
32
+ join(tmp, "rust-analyzer.sock"),
33
+ // Legacy name kept for back-compat with older tsserver installs.
34
+ join(tmp, "tsserver.sock")
119
35
  ];
120
36
  return candidates.some((c) => existsSync(c));
121
37
  }
@@ -123,10 +39,12 @@ function checkAst(projectRoot) {
123
39
  try {
124
40
  const here = dirname(fileURLToPath(import.meta.url));
125
41
  const candidates = [
42
+ join(here, "grammars"),
43
+ // flattened bundle
126
44
  join(here, "..", "grammars"),
127
- // from dist/intercept/
45
+ // nested bundle
128
46
  join(here, "..", "..", "dist", "grammars")
129
- // from src/intercept/ dev
47
+ // dev-time
130
48
  ];
131
49
  for (const dir of candidates) {
132
50
  if (existsSync(dir)) return true;
@@ -212,9 +130,7 @@ function formatHudStatus(report) {
212
130
  }
213
131
 
214
132
  export {
215
- ESTIMATED_TOKENS_PER_READ_DENY,
216
- summarizeHookLog,
217
- formatStatsSummary,
133
+ refreshComponentStatus,
218
134
  getComponentStatus,
219
135
  formatHudStatus
220
136
  };
@@ -0,0 +1,121 @@
1
+ // src/update/check.ts
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ var REGISTRY_URL = "https://registry.npmjs.org/engramx/latest";
6
+ var CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1e3;
7
+ var FETCH_TIMEOUT_MS = 1500;
8
+ function cachePath() {
9
+ return join(homedir(), ".engram", "last-update-check");
10
+ }
11
+ function readCache() {
12
+ const path = cachePath();
13
+ if (!existsSync(path)) return null;
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
16
+ if (typeof parsed?.latest === "string" && typeof parsed?.checkedAt === "number") {
17
+ return parsed;
18
+ }
19
+ return null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function writeCache(entry) {
25
+ try {
26
+ const path = cachePath();
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, JSON.stringify(entry), "utf-8");
29
+ } catch {
30
+ }
31
+ }
32
+ function isNewer(a, b) {
33
+ const parse = (v) => {
34
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
35
+ if (!m) return null;
36
+ return {
37
+ major: Number(m[1]),
38
+ minor: Number(m[2]),
39
+ patch: Number(m[3]),
40
+ pre: m[4] ?? null
41
+ };
42
+ };
43
+ const pa = parse(a);
44
+ const pb = parse(b);
45
+ if (!pa || !pb) return false;
46
+ if (pa.major !== pb.major) return pa.major > pb.major;
47
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor;
48
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch;
49
+ if (pa.pre === null && pb.pre !== null) return true;
50
+ if (pa.pre !== null && pb.pre === null) return false;
51
+ if (pa.pre === null && pb.pre === null) return false;
52
+ return (pa.pre ?? "") > (pb.pre ?? "");
53
+ }
54
+ function optedOut() {
55
+ if (process.env.ENGRAM_NO_UPDATE_CHECK === "1") return true;
56
+ if (process.env.CI) return true;
57
+ return false;
58
+ }
59
+ async function fetchLatestFromRegistry() {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
62
+ try {
63
+ const res = await fetch(REGISTRY_URL, {
64
+ signal: controller.signal,
65
+ headers: { accept: "application/json" }
66
+ });
67
+ if (!res.ok) return null;
68
+ const body = await res.json();
69
+ if (typeof body?.version !== "string") return null;
70
+ return body.version;
71
+ } catch {
72
+ return null;
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ async function checkForUpdate(currentVersion, opts = {}) {
78
+ const base = {
79
+ skipped: false,
80
+ current: currentVersion,
81
+ latest: null,
82
+ updateAvailable: false,
83
+ checkedAt: null,
84
+ fromCache: false
85
+ };
86
+ if (!opts.force && optedOut()) {
87
+ return { ...base, skipped: true };
88
+ }
89
+ if (!opts.force) {
90
+ const cached = readCache();
91
+ if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
92
+ return {
93
+ ...base,
94
+ latest: cached.latest,
95
+ updateAvailable: isNewer(cached.latest, currentVersion),
96
+ checkedAt: cached.checkedAt,
97
+ fromCache: true
98
+ };
99
+ }
100
+ }
101
+ const latest = await fetchLatestFromRegistry();
102
+ if (!latest) {
103
+ return { ...base, skipped: !opts.force };
104
+ }
105
+ const now = Date.now();
106
+ writeCache({ latest, checkedAt: now });
107
+ return {
108
+ ...base,
109
+ latest,
110
+ updateAvailable: isNewer(latest, currentVersion),
111
+ checkedAt: now,
112
+ fromCache: false
113
+ };
114
+ }
115
+
116
+ export {
117
+ cachePath,
118
+ isNewer,
119
+ optedOut,
120
+ checkForUpdate
121
+ };