@jeffober/thatch 0.1.30 → 0.1.32
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 +29 -6
- package/package.json +1 -1
- package/src/index.ts +47 -0
- package/src/mcp.ts +38 -0
- package/src/prompts.ts +8 -0
- package/src/version-check.ts +340 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.32",
|
|
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, compareSemver } 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 && compareSemver(runningVersion, onDisk) < 0
|
|
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,340 @@
|
|
|
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
|
+
* Compares two semver strings (e.g. "0.1.30", "0.1.31"). Returns a negative
|
|
9
|
+
* number if a < b, positive if a > b, 0 if equal. Pre-release tags are not
|
|
10
|
+
* supported because @jeffober/thatch does not use them.
|
|
11
|
+
*/
|
|
12
|
+
export function compareSemver(a: string, b: string): number {
|
|
13
|
+
const pa = a.split(".").map(Number);
|
|
14
|
+
const pb = b.split(".").map(Number);
|
|
15
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
16
|
+
const va = pa[i] ?? 0;
|
|
17
|
+
const vb = pb[i] ?? 0;
|
|
18
|
+
if (va < vb) return -1;
|
|
19
|
+
if (va > vb) return 1;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Version skew detection and npm update checking.
|
|
26
|
+
*
|
|
27
|
+
* Two complementary checks:
|
|
28
|
+
*
|
|
29
|
+
* Check A (npm): A long-running process (MCP server or opencode plugin) polls
|
|
30
|
+
* the npm registry hourly to see if a newer version of @jeffober/thatch is
|
|
31
|
+
* published. The result is cached in memory and written to a file so that
|
|
32
|
+
* one-shot hook processes (flush-tools) can read it without making network
|
|
33
|
+
* requests. The poll is async and never blocks tool calls. If npm is
|
|
34
|
+
* unreachable, the cache stays stale and the check is silently skipped.
|
|
35
|
+
*
|
|
36
|
+
* Check B (skew): The MCP server writes its running version to a file at
|
|
37
|
+
* startup. Hook processes (flush-tools) read that file and compare it to
|
|
38
|
+
* their own version from package.json. If they differ, the hooks were
|
|
39
|
+
* spawned from a newer binary than the long-running server process. This is
|
|
40
|
+
* the exact scenario from issue #6: the user upgraded thatch but did not
|
|
41
|
+
* restart the host, so the MCP server is frozen at the old version while
|
|
42
|
+
* hooks run the new code.
|
|
43
|
+
*
|
|
44
|
+
* Both checks share a file-based cache directory derived from the DB path
|
|
45
|
+
* (same hashing as the sideband socket) so all processes independently
|
|
46
|
+
* arrive at the same location without out-of-band coordination.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const NPM_PACKAGE = "@jeffober/thatch";
|
|
50
|
+
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE}/latest`;
|
|
51
|
+
const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
52
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Cache directory (shared with sideband socket path derivation)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function cacheDir(dbPath: string): string {
|
|
59
|
+
const hash = createHash("sha256").update(dbPath).digest("hex").slice(0, 16);
|
|
60
|
+
return join(tmpdir(), `thatch-${hash}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Path to the file where the running MCP server stamps its version. */
|
|
64
|
+
export function versionFilePath(dbPath: string): string {
|
|
65
|
+
return join(cacheDir(dbPath), "version");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Path to the file where the latest npm version is cached. */
|
|
69
|
+
export function npmCachePath(dbPath: string): string {
|
|
70
|
+
return join(cacheDir(dbPath), "npm-latest");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Check B: version skew (local, instant, no network)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Writes the running process's version to the version file. Called once at
|
|
79
|
+
* MCP server or opencode plugin startup. The file is read by hook processes
|
|
80
|
+
* (flush-tools) to detect version skew.
|
|
81
|
+
*/
|
|
82
|
+
export function writeVersionFile(dbPath: string): void {
|
|
83
|
+
const path = versionFilePath(dbPath);
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
86
|
+
writeFileSync(path, pkg.version, "utf-8");
|
|
87
|
+
} catch {
|
|
88
|
+
// Best-effort. If the file can't be written, skew detection silently
|
|
89
|
+
// degrades: flush-tools will see no version file and skip the skew check.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads the running server's version from the version file. Returns null if
|
|
95
|
+
* the file doesn't exist (server never wrote it, or it was cleaned up).
|
|
96
|
+
*/
|
|
97
|
+
export function readVersionFile(dbPath: string): string | null {
|
|
98
|
+
try {
|
|
99
|
+
const path = versionFilePath(dbPath);
|
|
100
|
+
if (!existsSync(path)) return null;
|
|
101
|
+
return readFileSync(path, "utf-8").trim();
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Removes the version file. Called on MCP server shutdown so stale version
|
|
109
|
+
* files don't trigger false skew warnings for a different server instance.
|
|
110
|
+
*/
|
|
111
|
+
export function removeVersionFile(dbPath: string): void {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(versionFilePath(dbPath));
|
|
114
|
+
} catch {
|
|
115
|
+
// Already gone, or never written. Fine either way.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reads package.json from disk and returns the version string. Used by the
|
|
121
|
+
* opencode plugin to detect that the installed package was updated while the
|
|
122
|
+
* process is still running the old cached module. Returns null if the file
|
|
123
|
+
* can't be read.
|
|
124
|
+
*/
|
|
125
|
+
export function readOnDiskVersion(): string | null {
|
|
126
|
+
try {
|
|
127
|
+
// Resolve package.json relative to this source file: src/version-check.ts
|
|
128
|
+
// -> ../package.json (the repo root or the npm install directory).
|
|
129
|
+
const path = join(dirname(new URL(import.meta.url).pathname), "..", "package.json");
|
|
130
|
+
const raw = readFileSync(path, "utf-8");
|
|
131
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
132
|
+
return parsed.version ?? null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compares the on-disk version (what the running server stamped at startup)
|
|
140
|
+
* against the current binary's version (from package.json). Returns a human-
|
|
141
|
+
* readable skew warning if they differ, or null if they match or the file
|
|
142
|
+
* is absent.
|
|
143
|
+
*/
|
|
144
|
+
export function detectSkew(dbPath: string): string | null {
|
|
145
|
+
const serverVersion = readVersionFile(dbPath);
|
|
146
|
+
if (serverVersion === null) return null;
|
|
147
|
+
if (serverVersion === pkg.version) return null;
|
|
148
|
+
return `thatch was upgraded to v${pkg.version} but this session is running v${serverVersion}. ` +
|
|
149
|
+
`Restart your editor (Claude Code or Cursor) to apply the update.`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Check A: npm update polling (network, cached, async)
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
interface NpmCacheEntry {
|
|
157
|
+
version: string;
|
|
158
|
+
checkedAt: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class NpmVersionChecker {
|
|
162
|
+
#dbPath: string;
|
|
163
|
+
#latestVersion: string | null = null;
|
|
164
|
+
#lastCheck: number = 0;
|
|
165
|
+
#polling: boolean = false;
|
|
166
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
167
|
+
|
|
168
|
+
constructor(dbPath: string) {
|
|
169
|
+
this.#dbPath = dbPath;
|
|
170
|
+
// Load cached result from file so a fresh MCP server startup benefits
|
|
171
|
+
// from the last poll a previous process did.
|
|
172
|
+
const cached = this.#readCacheFile();
|
|
173
|
+
if (cached) {
|
|
174
|
+
this.#latestVersion = cached.version;
|
|
175
|
+
this.#lastCheck = cached.checkedAt;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Starts background polling. The first poll fires after the poll interval
|
|
181
|
+
* has elapsed since the last check (or immediately if no cached result
|
|
182
|
+
* exists). Never blocks the caller.
|
|
183
|
+
*/
|
|
184
|
+
start(): void {
|
|
185
|
+
if (this.#timer) return;
|
|
186
|
+
// Fire the first poll after a short delay so server startup is not
|
|
187
|
+
// impacted, and subsequent polls at the configured interval.
|
|
188
|
+
const initialDelay = this.#latestVersion ? POLL_INTERVAL_MS : 10_000;
|
|
189
|
+
this.#timer = setInterval(() => { void this.#poll(); }, POLL_INTERVAL_MS);
|
|
190
|
+
// Fire the first check after initialDelay. If we have a recent cache,
|
|
191
|
+
// we wait for the full interval before re-checking.
|
|
192
|
+
setTimeout(() => { void this.#poll(); }, initialDelay);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stops background polling. */
|
|
196
|
+
stop(): void {
|
|
197
|
+
if (this.#timer) {
|
|
198
|
+
clearInterval(this.#timer);
|
|
199
|
+
this.#timer = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns the latest version from npm if known, or null if the cache is
|
|
205
|
+
* empty. This is the fast path: no network, no I/O. The background poll
|
|
206
|
+
* keeps this value fresh.
|
|
207
|
+
*/
|
|
208
|
+
getLatestVersion(): string | null {
|
|
209
|
+
return this.#latestVersion;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Returns true if the running version is older than the latest npm
|
|
214
|
+
* version. Returns false if the cache is empty (no data to compare).
|
|
215
|
+
*/
|
|
216
|
+
isOutdated(): boolean {
|
|
217
|
+
if (!this.#latestVersion) return false;
|
|
218
|
+
return compareSemver(pkg.version, this.#latestVersion) < 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Returns a human-readable update-available warning, or null if no update
|
|
223
|
+
* is known or the running version is current.
|
|
224
|
+
*/
|
|
225
|
+
getUpdateWarning(): string | null {
|
|
226
|
+
if (!this.isOutdated()) return null;
|
|
227
|
+
return `thatch v${this.#latestVersion} is available. ` +
|
|
228
|
+
`Run \`npm update ${NPM_PACKAGE}\` to update, then restart your editor.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Fetches the latest version from the npm registry. Best-effort: any
|
|
233
|
+
* network error, timeout, or parse failure is silently ignored. The
|
|
234
|
+
* cache is only updated on success.
|
|
235
|
+
*/
|
|
236
|
+
async #poll(): Promise<void> {
|
|
237
|
+
if (this.#polling) return;
|
|
238
|
+
this.#polling = true;
|
|
239
|
+
try {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
242
|
+
const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
|
|
243
|
+
clearTimeout(timeout);
|
|
244
|
+
if (!res.ok) return;
|
|
245
|
+
const data = await res.json() as { version?: string };
|
|
246
|
+
if (!data.version) return;
|
|
247
|
+
this.#latestVersion = data.version;
|
|
248
|
+
this.#lastCheck = Date.now();
|
|
249
|
+
this.#writeCacheFile({ version: data.version, checkedAt: this.#lastCheck });
|
|
250
|
+
} catch {
|
|
251
|
+
// Network error, timeout, or parse failure. Leave the cache as-is.
|
|
252
|
+
// The next poll will retry.
|
|
253
|
+
} finally {
|
|
254
|
+
this.#polling = false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#readCacheFile(): NpmCacheEntry | null {
|
|
259
|
+
try {
|
|
260
|
+
const path = npmCachePath(this.#dbPath);
|
|
261
|
+
if (!existsSync(path)) return null;
|
|
262
|
+
const raw = readFileSync(path, "utf-8");
|
|
263
|
+
const parsed = JSON.parse(raw) as NpmCacheEntry;
|
|
264
|
+
if (!parsed.version || !parsed.checkedAt) return null;
|
|
265
|
+
return parsed;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#writeCacheFile(entry: NpmCacheEntry): void {
|
|
272
|
+
try {
|
|
273
|
+
const path = npmCachePath(this.#dbPath);
|
|
274
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
275
|
+
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
|
276
|
+
} catch {
|
|
277
|
+
// Best-effort. If the file can't be written, only in-memory cache
|
|
278
|
+
// is used. A different process won't benefit, but functionality
|
|
279
|
+
// is unaffected.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Factory and standalone helpers for hook processes
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
let activeChecker: NpmVersionChecker | null = null;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Creates and starts an NpmVersionChecker for the long-running process
|
|
292
|
+
* (MCP server or opencode plugin). Stores it in a module-level variable so
|
|
293
|
+
* the caller can retrieve update warnings without holding a reference.
|
|
294
|
+
* Call stopVersionChecker() on shutdown.
|
|
295
|
+
*/
|
|
296
|
+
export function startVersionChecker(dbPath: string): NpmVersionChecker {
|
|
297
|
+
if (activeChecker) return activeChecker;
|
|
298
|
+
activeChecker = new NpmVersionChecker(dbPath);
|
|
299
|
+
activeChecker.start();
|
|
300
|
+
return activeChecker;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Stops the active version checker and clears the module-level reference. */
|
|
304
|
+
export function stopVersionChecker(): void {
|
|
305
|
+
if (activeChecker) {
|
|
306
|
+
activeChecker.stop();
|
|
307
|
+
activeChecker = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Returns the active version checker, or null if none was started. */
|
|
312
|
+
export function getVersionChecker(): NpmVersionChecker | null {
|
|
313
|
+
return activeChecker;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Reads the npm cache file and returns an update warning if the cached
|
|
318
|
+
* latest version is newer than the running version. Used by one-shot hook
|
|
319
|
+
* processes (flush-tools) that don't have a long-running checker. Returns
|
|
320
|
+
* null if no cache file exists or the running version is current.
|
|
321
|
+
*/
|
|
322
|
+
export function readNpmCacheForUpdate(dbPath: string): string | null {
|
|
323
|
+
try {
|
|
324
|
+
const path = npmCachePath(dbPath);
|
|
325
|
+
if (!existsSync(path)) return null;
|
|
326
|
+
const raw = readFileSync(path, "utf-8");
|
|
327
|
+
const parsed = JSON.parse(raw) as NpmCacheEntry;
|
|
328
|
+
if (!parsed.version) return null;
|
|
329
|
+
if (compareSemver(pkg.version, parsed.version) >= 0) return null;
|
|
330
|
+
return `thatch v${parsed.version} is available. ` +
|
|
331
|
+
`Run \`npm update ${NPM_PACKAGE}\` to update, then restart your editor.`;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// For testing: reset module-level state.
|
|
338
|
+
export function _resetForTesting(): void {
|
|
339
|
+
stopVersionChecker();
|
|
340
|
+
}
|