@jeffober/thatch 0.1.30 → 0.1.31

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/bin/thatch CHANGED
@@ -48,6 +48,20 @@ async function defaultStore(): Promise<string> {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * Emits the nudge output, prepending a version warning if present. The
53
+ * warning goes first so the LLM sees it before the nudge text. Used by
54
+ * flush-tools to surface version skew and update-available notices on
55
+ * every UserPromptSubmit, regardless of which tier fired.
56
+ */
57
+ function emit(output: string, versionWarning: string | null, isJson: boolean): void {
58
+ if (versionWarning) {
59
+ output = `[thatch] ${versionWarning}\n\n${output}`;
60
+ }
61
+ if (isJson) console.log(JSON.stringify({ additional_context: output }));
62
+ else console.log(output);
63
+ }
64
+
51
65
  /** Resolve the project directory from host-specific env vars or cwd. */
52
66
  function projectDir(): string {
53
67
  return process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
@@ -359,10 +373,22 @@ switch (command) {
359
373
 
360
374
  const { peekQueue, getMissedCount, incrementMissedCount } = await import("../src/extract-queue");
361
375
  const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge, behaviorNudge } = await import("../src/prompts");
376
+ const { detectSkew, readNpmCacheForUpdate } = await import("../src/version-check");
362
377
 
363
378
  let repo = "unknown";
364
379
  try { repo = await detectRepo(); } catch {}
365
380
 
381
+ // Version checks (best-effort, no network, instant):
382
+ // - Skew: the running MCP server's version file differs from this
383
+ // binary's version. Means the user upgraded but didn't restart.
384
+ // - Update: the npm cache file (written by the MCP server's
385
+ // background poll) says a newer version is published.
386
+ // The warning is prepended to whatever nudge the tiers below produce.
387
+ // This is the primary delivery channel for version warnings because
388
+ // flush-tools fires on every prompt, including conversational turns
389
+ // with no tool calls.
390
+ const versionWarning = detectSkew(dbPath) ?? readNpmCacheForUpdate(dbPath);
391
+
366
392
  // Tier 1: extraction nudge. Peek (don't drain) - the queue persists
367
393
  // until the agent calls memory_remember, which triggers consumeQueue.
368
394
  // If the agent ignores the nudge, the payload grows on the next flush.
@@ -373,8 +399,7 @@ switch (command) {
373
399
  const missed = sessionID ? getMissedCount(sessionID) : 0;
374
400
  if (sessionID) incrementMissedCount(sessionID);
375
401
  const output = extractionNudge(interactions.length, missed, "mcp__thatch__memory_remember", sessionID ?? "");
376
- if (isJson) console.log(JSON.stringify({ additional_context: output }));
377
- else console.log(output);
402
+ emit(output, versionWarning, isJson);
378
403
  break;
379
404
  }
380
405
 
@@ -408,16 +433,14 @@ switch (command) {
408
433
 
409
434
  if (parts.length > 0) {
410
435
  const output = parts.join("\n\n");
411
- if (isJson) console.log(JSON.stringify({ additional_context: output }));
412
- else console.log(output);
436
+ emit(output, versionWarning, isJson);
413
437
  break;
414
438
  }
415
439
  }
416
440
 
417
441
  // Tier 3: static write nudge.
418
442
  const output = claudeWriteNudge();
419
- if (isJson) console.log(JSON.stringify({ additional_context: output }));
420
- else console.log(output);
443
+ emit(output, versionWarning, isJson);
421
444
  } catch (err) {
422
445
  console.error(`[thatch flush-tools] failed: ${err}`);
423
446
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -13,12 +13,15 @@ import {
13
13
  extractionDirectPrompt,
14
14
  predictionNudge,
15
15
  behaviorNudge,
16
+ versionWarningNudge,
16
17
  type NudgeMatch,
17
18
  } from "./prompts";
18
19
  import { ExtractionPipeline, type ToolInteraction } from "./extraction";
19
20
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
20
21
  import { hygieneReport } from "./hygiene";
21
22
  import { seedDefaultBehaviors } from "./seed-behaviors";
23
+ import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion } from "./version-check";
24
+ import pkg from "../package.json";
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // V1 server export - tools, prompt injection, session hooks
@@ -65,6 +68,21 @@ export const server: Plugin = async ({ client, worktree }) => {
65
68
  // for the full mechanism.
66
69
  await seedDefaultBehaviors(db, model);
67
70
 
71
+ // Start background npm update polling. The checker caches the latest
72
+ // version in memory and to a file. Never blocks the plugin or tool calls.
73
+ // The opencode plugin path has no hook/server split, so version skew
74
+ // between hooks and server is not a concern here. But two opencode
75
+ // sessions on different versions sharing one DB can cause schema/data
76
+ // corruption. The npm update nudge covers the "please update" case.
77
+ // The on-disk version check (readOnDiskVersion) catches the case where
78
+ // the user ran `npm update` but hasn't restarted opencode yet.
79
+ startVersionChecker(dbPath);
80
+
81
+ // The running version, frozen at module load time. Compared against the
82
+ // on-disk package.json version on each chat.message to detect post-upgrade
83
+ // non-restart.
84
+ const runningVersion = pkg.version;
85
+
68
86
  // Sessions currently being compacted. chat.message nudges are skipped while
69
87
  // a session is in this set - the agent can't call tools during summary
70
88
  // generation, so a recall or extraction nudge would cause a blocked-tool
@@ -370,6 +388,34 @@ export const server: Plugin = async ({ client, worktree }) => {
370
388
  if (isCompactionMsg) return;
371
389
  compacting.delete(input.sessionID);
372
390
  }
391
+
392
+ // Version warning: check if the on-disk package.json version differs
393
+ // from the running version (user upgraded but didn't restart opencode),
394
+ // or if the npm poller found a newer version. Best-effort: any failure
395
+ // silently skips. Injected as a synthetic part so the LLM sees it and
396
+ // can tell the user to restart.
397
+ try {
398
+ const onDisk = readOnDiskVersion();
399
+ const checker = getVersionChecker();
400
+ const npmUpdate = checker?.getUpdateWarning() ?? null;
401
+ const skewWarning = onDisk && onDisk !== runningVersion
402
+ ? `thatch was upgraded to v${onDisk} but this session is running v${runningVersion}. Restart opencode to apply the update.`
403
+ : null;
404
+ const warning = skewWarning ?? npmUpdate;
405
+ if (warning) {
406
+ output.parts.push({
407
+ id: `prt_thatch_ver_${Math.random().toString(36).slice(2)}`,
408
+ sessionID: input.sessionID,
409
+ messageID: input.messageID ?? output.message.id,
410
+ type: "text",
411
+ text: versionWarningNudge(warning),
412
+ synthetic: true,
413
+ });
414
+ }
415
+ } catch {
416
+ // Best-effort. Version check failure must not block nudges.
417
+ }
418
+
373
419
  // Extraction nudge (fallback path). Skipped when the extracting set
374
420
  // is active - that means a direct-extraction child session is running
375
421
  // and the plugin is handling extraction via the SDK. The nudge fires
@@ -678,6 +724,7 @@ export const server: Plugin = async ({ client, worktree }) => {
678
724
  },
679
725
 
680
726
  dispose: async () => {
727
+ stopVersionChecker();
681
728
  db.close();
682
729
  },
683
730
  };
package/src/mcp.ts CHANGED
@@ -9,6 +9,7 @@ import { SidebandServer, sidebandSocketPath } from "./sideband";
9
9
  import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
10
10
  import { buildExtractionPayload } from "./extraction";
11
11
  import { seedDefaultBehaviors } from "./seed-behaviors";
12
+ import { writeVersionFile, removeVersionFile, startVersionChecker, stopVersionChecker, getVersionChecker } from "./version-check";
12
13
  import pkg from "../package.json";
13
14
 
14
15
  // ---------------------------------------------------------------------------
@@ -144,6 +145,19 @@ export async function runMcpServer(): Promise<void> {
144
145
  console.error(`[thatch] ${setupWarning}`);
145
146
  }
146
147
 
148
+ // Version warning: surfaced once on the first tools/call response. The
149
+ // primary delivery channel is flush-tools (fires on every prompt), so this
150
+ // is a backup. Combines npm update-available and version-skew warnings.
151
+ let versionWarning: string | null = null;
152
+ const checker = getVersionChecker();
153
+ if (checker) {
154
+ const updateMsg = checker.getUpdateWarning();
155
+ if (updateMsg) {
156
+ versionWarning = updateMsg;
157
+ console.error(`[thatch] ${updateMsg}`);
158
+ }
159
+ }
160
+
147
161
  // Auto-refresh: if setup was previously run (markers exist), re-run the
148
162
  // install functions to update skills, instructions, and hooks that may have
149
163
  // drifted since the last `thatch setup`. All operations are idempotent --
@@ -182,6 +196,16 @@ export async function runMcpServer(): Promise<void> {
182
196
  console.error(`[thatch] sideband socket failed: ${err}`);
183
197
  }
184
198
 
199
+ // Stamp the running version so hook processes (flush-tools) can detect
200
+ // version skew after an upgrade. The hook reads this file on every
201
+ // UserPromptSubmit and compares it to its own package.json version.
202
+ writeVersionFile(dbPath);
203
+
204
+ // Start background npm update polling. The checker caches the latest
205
+ // version to a file so hook processes can read it without making network
206
+ // requests. Never blocks tool calls.
207
+ startVersionChecker(dbPath);
208
+
185
209
  // Read stdin line by line. Each line is a complete JSON-RPC message.
186
210
  const decoder = new TextDecoder();
187
211
  let buf = "";
@@ -218,11 +242,25 @@ export async function runMcpServer(): Promise<void> {
218
242
  setupWarning = null;
219
243
  }
220
244
 
245
+ // Surface the version warning once per session on the first tools/call.
246
+ // The primary delivery channel is flush-tools (fires on every prompt),
247
+ // so this is a backup for when hooks are missing or fail. Cleared after
248
+ // one surfacing.
249
+ if (versionWarning && req.method === "tools/call" && !res.error) {
250
+ const content = res.result?.content;
251
+ if (Array.isArray(content) && content[0]?.text != null) {
252
+ content[0].text = `[thatch] ${versionWarning}\n\n${content[0].text}`;
253
+ }
254
+ versionWarning = null;
255
+ }
256
+
221
257
  send(res);
222
258
  }
223
259
  }
224
260
 
225
261
  sideband.stop();
262
+ stopVersionChecker();
263
+ removeVersionFile(dbPath);
226
264
  db.close();
227
265
  }
228
266
 
package/src/prompts.ts CHANGED
@@ -458,6 +458,14 @@ export function claudeWriteNudge(): string {
458
458
  return `[thatch] After responding, check: did you learn new project knowledge, user preferences, or corrections worth persisting? If so, save to thatch.`;
459
459
  }
460
460
 
461
+ /**
462
+ * Version warning for the opencode plugin's chat.message hook. Injected as a
463
+ * synthetic part so the LLM sees it and can tell the user to restart.
464
+ */
465
+ export function versionWarningNudge(message: string): string {
466
+ return `[thatch] ${message} This is a system nudge, not user input. After informing the user, continue with their request.`;
467
+ }
468
+
461
469
  /**
462
470
  * Extraction nudge with escalation. Both the opencode plugin (src/index.ts)
463
471
  * and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
@@ -0,0 +1,323 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+ import pkg from "../package.json";
6
+
7
+ /**
8
+ * Version skew detection and npm update checking.
9
+ *
10
+ * Two complementary checks:
11
+ *
12
+ * Check A (npm): A long-running process (MCP server or opencode plugin) polls
13
+ * the npm registry hourly to see if a newer version of @jeffober/thatch is
14
+ * published. The result is cached in memory and written to a file so that
15
+ * one-shot hook processes (flush-tools) can read it without making network
16
+ * requests. The poll is async and never blocks tool calls. If npm is
17
+ * unreachable, the cache stays stale and the check is silently skipped.
18
+ *
19
+ * Check B (skew): The MCP server writes its running version to a file at
20
+ * startup. Hook processes (flush-tools) read that file and compare it to
21
+ * their own version from package.json. If they differ, the hooks were
22
+ * spawned from a newer binary than the long-running server process. This is
23
+ * the exact scenario from issue #6: the user upgraded thatch but did not
24
+ * restart the host, so the MCP server is frozen at the old version while
25
+ * hooks run the new code.
26
+ *
27
+ * Both checks share a file-based cache directory derived from the DB path
28
+ * (same hashing as the sideband socket) so all processes independently
29
+ * arrive at the same location without out-of-band coordination.
30
+ */
31
+
32
+ const NPM_PACKAGE = "@jeffober/thatch";
33
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE}/latest`;
34
+ const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
35
+ const REQUEST_TIMEOUT_MS = 5000;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Cache directory (shared with sideband socket path derivation)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function cacheDir(dbPath: string): string {
42
+ const hash = createHash("sha256").update(dbPath).digest("hex").slice(0, 16);
43
+ return join(tmpdir(), `thatch-${hash}`);
44
+ }
45
+
46
+ /** Path to the file where the running MCP server stamps its version. */
47
+ export function versionFilePath(dbPath: string): string {
48
+ return join(cacheDir(dbPath), "version");
49
+ }
50
+
51
+ /** Path to the file where the latest npm version is cached. */
52
+ export function npmCachePath(dbPath: string): string {
53
+ return join(cacheDir(dbPath), "npm-latest");
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Check B: version skew (local, instant, no network)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Writes the running process's version to the version file. Called once at
62
+ * MCP server or opencode plugin startup. The file is read by hook processes
63
+ * (flush-tools) to detect version skew.
64
+ */
65
+ export function writeVersionFile(dbPath: string): void {
66
+ const path = versionFilePath(dbPath);
67
+ try {
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ writeFileSync(path, pkg.version, "utf-8");
70
+ } catch {
71
+ // Best-effort. If the file can't be written, skew detection silently
72
+ // degrades: flush-tools will see no version file and skip the skew check.
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Reads the running server's version from the version file. Returns null if
78
+ * the file doesn't exist (server never wrote it, or it was cleaned up).
79
+ */
80
+ export function readVersionFile(dbPath: string): string | null {
81
+ try {
82
+ const path = versionFilePath(dbPath);
83
+ if (!existsSync(path)) return null;
84
+ return readFileSync(path, "utf-8").trim();
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Removes the version file. Called on MCP server shutdown so stale version
92
+ * files don't trigger false skew warnings for a different server instance.
93
+ */
94
+ export function removeVersionFile(dbPath: string): void {
95
+ try {
96
+ unlinkSync(versionFilePath(dbPath));
97
+ } catch {
98
+ // Already gone, or never written. Fine either way.
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Reads package.json from disk and returns the version string. Used by the
104
+ * opencode plugin to detect that the installed package was updated while the
105
+ * process is still running the old cached module. Returns null if the file
106
+ * can't be read.
107
+ */
108
+ export function readOnDiskVersion(): string | null {
109
+ try {
110
+ // Resolve package.json relative to this source file: src/version-check.ts
111
+ // -> ../package.json (the repo root or the npm install directory).
112
+ const path = join(dirname(new URL(import.meta.url).pathname), "..", "package.json");
113
+ const raw = readFileSync(path, "utf-8");
114
+ const parsed = JSON.parse(raw) as { version?: string };
115
+ return parsed.version ?? null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Compares the on-disk version (what the running server stamped at startup)
123
+ * against the current binary's version (from package.json). Returns a human-
124
+ * readable skew warning if they differ, or null if they match or the file
125
+ * is absent.
126
+ */
127
+ export function detectSkew(dbPath: string): string | null {
128
+ const serverVersion = readVersionFile(dbPath);
129
+ if (serverVersion === null) return null;
130
+ if (serverVersion === pkg.version) return null;
131
+ return `thatch was upgraded to v${pkg.version} but this session is running v${serverVersion}. ` +
132
+ `Restart your editor (Claude Code or Cursor) to apply the update.`;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Check A: npm update polling (network, cached, async)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ interface NpmCacheEntry {
140
+ version: string;
141
+ checkedAt: number;
142
+ }
143
+
144
+ class NpmVersionChecker {
145
+ #dbPath: string;
146
+ #latestVersion: string | null = null;
147
+ #lastCheck: number = 0;
148
+ #polling: boolean = false;
149
+ #timer: ReturnType<typeof setInterval> | null = null;
150
+
151
+ constructor(dbPath: string) {
152
+ this.#dbPath = dbPath;
153
+ // Load cached result from file so a fresh MCP server startup benefits
154
+ // from the last poll a previous process did.
155
+ const cached = this.#readCacheFile();
156
+ if (cached) {
157
+ this.#latestVersion = cached.version;
158
+ this.#lastCheck = cached.checkedAt;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Starts background polling. The first poll fires after the poll interval
164
+ * has elapsed since the last check (or immediately if no cached result
165
+ * exists). Never blocks the caller.
166
+ */
167
+ start(): void {
168
+ if (this.#timer) return;
169
+ // Fire the first poll after a short delay so server startup is not
170
+ // impacted, and subsequent polls at the configured interval.
171
+ const initialDelay = this.#latestVersion ? POLL_INTERVAL_MS : 10_000;
172
+ this.#timer = setInterval(() => { void this.#poll(); }, POLL_INTERVAL_MS);
173
+ // Fire the first check after initialDelay. If we have a recent cache,
174
+ // we wait for the full interval before re-checking.
175
+ setTimeout(() => { void this.#poll(); }, initialDelay);
176
+ }
177
+
178
+ /** Stops background polling. */
179
+ stop(): void {
180
+ if (this.#timer) {
181
+ clearInterval(this.#timer);
182
+ this.#timer = null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Returns the latest version from npm if known, or null if the cache is
188
+ * empty. This is the fast path: no network, no I/O. The background poll
189
+ * keeps this value fresh.
190
+ */
191
+ getLatestVersion(): string | null {
192
+ return this.#latestVersion;
193
+ }
194
+
195
+ /**
196
+ * Returns true if the running version is older than the latest npm
197
+ * version. Returns false if the cache is empty (no data to compare).
198
+ */
199
+ isOutdated(): boolean {
200
+ if (!this.#latestVersion) return false;
201
+ return this.#latestVersion !== pkg.version;
202
+ }
203
+
204
+ /**
205
+ * Returns a human-readable update-available warning, or null if no update
206
+ * is known or the running version is current.
207
+ */
208
+ getUpdateWarning(): string | null {
209
+ if (!this.isOutdated()) return null;
210
+ return `thatch v${this.#latestVersion} is available. ` +
211
+ `Run \`npm update ${NPM_PACKAGE}\` to update, then restart your editor.`;
212
+ }
213
+
214
+ /**
215
+ * Fetches the latest version from the npm registry. Best-effort: any
216
+ * network error, timeout, or parse failure is silently ignored. The
217
+ * cache is only updated on success.
218
+ */
219
+ async #poll(): Promise<void> {
220
+ if (this.#polling) return;
221
+ this.#polling = true;
222
+ try {
223
+ const controller = new AbortController();
224
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
225
+ const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
226
+ clearTimeout(timeout);
227
+ if (!res.ok) return;
228
+ const data = await res.json() as { version?: string };
229
+ if (!data.version) return;
230
+ this.#latestVersion = data.version;
231
+ this.#lastCheck = Date.now();
232
+ this.#writeCacheFile({ version: data.version, checkedAt: this.#lastCheck });
233
+ } catch {
234
+ // Network error, timeout, or parse failure. Leave the cache as-is.
235
+ // The next poll will retry.
236
+ } finally {
237
+ this.#polling = false;
238
+ }
239
+ }
240
+
241
+ #readCacheFile(): NpmCacheEntry | null {
242
+ try {
243
+ const path = npmCachePath(this.#dbPath);
244
+ if (!existsSync(path)) return null;
245
+ const raw = readFileSync(path, "utf-8");
246
+ const parsed = JSON.parse(raw) as NpmCacheEntry;
247
+ if (!parsed.version || !parsed.checkedAt) return null;
248
+ return parsed;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ #writeCacheFile(entry: NpmCacheEntry): void {
255
+ try {
256
+ const path = npmCachePath(this.#dbPath);
257
+ mkdirSync(dirname(path), { recursive: true });
258
+ writeFileSync(path, JSON.stringify(entry), "utf-8");
259
+ } catch {
260
+ // Best-effort. If the file can't be written, only in-memory cache
261
+ // is used. A different process won't benefit, but functionality
262
+ // is unaffected.
263
+ }
264
+ }
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Factory and standalone helpers for hook processes
269
+ // ---------------------------------------------------------------------------
270
+
271
+ let activeChecker: NpmVersionChecker | null = null;
272
+
273
+ /**
274
+ * Creates and starts an NpmVersionChecker for the long-running process
275
+ * (MCP server or opencode plugin). Stores it in a module-level variable so
276
+ * the caller can retrieve update warnings without holding a reference.
277
+ * Call stopVersionChecker() on shutdown.
278
+ */
279
+ export function startVersionChecker(dbPath: string): NpmVersionChecker {
280
+ if (activeChecker) return activeChecker;
281
+ activeChecker = new NpmVersionChecker(dbPath);
282
+ activeChecker.start();
283
+ return activeChecker;
284
+ }
285
+
286
+ /** Stops the active version checker and clears the module-level reference. */
287
+ export function stopVersionChecker(): void {
288
+ if (activeChecker) {
289
+ activeChecker.stop();
290
+ activeChecker = null;
291
+ }
292
+ }
293
+
294
+ /** Returns the active version checker, or null if none was started. */
295
+ export function getVersionChecker(): NpmVersionChecker | null {
296
+ return activeChecker;
297
+ }
298
+
299
+ /**
300
+ * Reads the npm cache file and returns an update warning if the cached
301
+ * latest version is newer than the running version. Used by one-shot hook
302
+ * processes (flush-tools) that don't have a long-running checker. Returns
303
+ * null if no cache file exists or the running version is current.
304
+ */
305
+ export function readNpmCacheForUpdate(dbPath: string): string | null {
306
+ try {
307
+ const path = npmCachePath(dbPath);
308
+ if (!existsSync(path)) return null;
309
+ const raw = readFileSync(path, "utf-8");
310
+ const parsed = JSON.parse(raw) as NpmCacheEntry;
311
+ if (!parsed.version) return null;
312
+ if (parsed.version === pkg.version) return null;
313
+ return `thatch v${parsed.version} is available. ` +
314
+ `Run \`npm update ${NPM_PACKAGE}\` to update, then restart your editor.`;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ // For testing: reset module-level state.
321
+ export function _resetForTesting(): void {
322
+ stopVersionChecker();
323
+ }