From d55e6ee1a4ca9a190f5aee44eeba8e499c2bfda4 Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Fri, 19 Jun 2026 16:29:11 +0200 Subject: [PATCH 1/2] fix: recall-shape doctor probe false-warned on healthy OpenViking 0.4.4 `ov find/search --output json` prints a `cmd: ...` preamble line before the JSON, and the buckets live under the `result` envelope. recallShapeCheck did a naive JSON.parse of the whole stdout and checked top-level buckets, so it always warned ("search output is not JSON; recall may silently return nothing") on a perfectly working server. It now mirrors parseRecallHits: start at the first line beginning with `{` and read result.{memories, resources,skills}. --- src/lifecycle.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 0dffe6b..d45151d 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -749,22 +749,25 @@ async function recallShapeCheck(config: RuntimeConfig): Promise { if (result.exitCode !== 0) { return {name: 'recall shape', status: 'warn', detail: 'search failed; run threadnote repair'}; } - let parsed: unknown; + // Mirror parseRecallHits: `ov find/search --output json` prints a `cmd: ...` + // preamble line before the JSON, and the buckets live under the `result` + // envelope (`{ok, result: {memories, resources, skills}}`). Start at the + // first line beginning with `{`, exactly as recall parsing does — otherwise + // this probe false-warns on a perfectly healthy OpenViking. + const start = result.stdout.search(/^\{/m); + let envelope: unknown; try { - parsed = JSON.parse(result.stdout.trim()); + const parsed: unknown = start >= 0 ? JSON.parse(result.stdout.slice(start)) : undefined; + envelope = isJsonObject(parsed) ? parsed.result : undefined; } catch { - return { - name: 'recall shape', - status: 'warn', - detail: 'search output is not JSON; recall may silently return nothing', - }; + envelope = undefined; } const buckets = ['memories', 'resources', 'skills']; - if (!isJsonObject(parsed) || !buckets.some(key => Array.isArray(parsed[key]))) { + if (!isJsonObject(envelope) || !buckets.some(key => Array.isArray(envelope[key]))) { return { name: 'recall shape', status: 'warn', - detail: `search JSON missing ${buckets.join('/')} buckets; recall parsing is out of sync with this OpenViking`, + detail: `search JSON missing the result.{${buckets.join(',')}} buckets recall parsing depends on`, }; } return {name: 'recall shape', status: 'ok', detail: 'memories/resources/skills buckets present'}; From 7b823e73adef7f5c75aaa8abf4c428596fb49740 Mon Sep 17 00:00:00 2001 From: Denys Kashkovskyi Date: Fri, 19 Jun 2026 16:29:24 +0200 Subject: [PATCH 2/2] feat: nudge to reconnect the MCP server when threadnote was updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `threadnote update` overwrites the package on disk, but the MCP server is a long-lived stdio process owned by the client (Claude Code, etc.), which does not respawn it mid-session — so callers silently keep hitting the old code (e.g. the 0.4.x --agent-id break) until they reconnect. The MCP server captures its startup version and, when a newer threadnote is found on disk, appends a one-line reconnect notice to recall_context/search, remember_context/store, and health results (cached 60s). The pure decision lives in utils.formatStaleVersionNotice; currentPackageVersion moved to utils (re-exported from update.ts) so the MCP bundle doesn't pull in update.ts. --- src/mcp_server.ts | 70 ++++++++++++++++++++++++++++++++--------- src/update.ts | 12 ++----- src/utils.ts | 31 ++++++++++++++++++ test/unit/utils.test.ts | 20 ++++++++++++ 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/src/mcp_server.ts b/src/mcp_server.ts index 41d366f..a08d986 100644 --- a/src/mcp_server.ts +++ b/src/mcp_server.ts @@ -46,8 +46,10 @@ import { import { buildRecallSections, collectExactMatches, + currentPackageVersion, type ExactMatch, errorMessage, + formatStaleVersionNotice, enrichRecallQueryWithWorkspaceContext, enrichRecallQueryWithWorkspaceProjectContext, exactMemoryScopeUris, @@ -120,8 +122,44 @@ type CheckedTextArray = readonly ok: false; }; +// Version this MCP server process started from, captured at startup. A later +// `threadnote update` overwrites the package on disk, but this resident stdio +// process keeps running the old code (clients don't respawn an MCP server on +// update), so we compare against the on-disk version and nudge the caller to +// reconnect — otherwise they silently keep hitting stale code. +let mcpStartupVersion: string | undefined; +let staleNoticeCache: {readonly checkedAtMs: number; readonly notice: string | undefined} | undefined; +const STALE_NOTICE_TTL_MS = 60_000; + +async function staleVersionNotice(): Promise { + if (mcpStartupVersion === undefined) { + return undefined; + } + const nowMs = Date.now(); + if (staleNoticeCache && nowMs - staleNoticeCache.checkedAtMs < STALE_NOTICE_TTL_MS) { + return staleNoticeCache.notice; + } + let notice: string | undefined; + try { + notice = formatStaleVersionNotice(mcpStartupVersion, await currentPackageVersion()); + } catch { + notice = undefined; + } + staleNoticeCache = {checkedAtMs: nowMs, notice}; + return notice; +} + +async function withStaleVersionNotice(result: CallToolResult): Promise { + const notice = await staleVersionNotice(); + if (notice === undefined) { + return result; + } + return {...result, content: [...(result.content ?? []), {type: 'text', text: `⚠ ${notice}`}]}; +} + async function main(): Promise { const config = getRuntimeConfig(); + mcpStartupVersion = await currentPackageVersion().catch(() => undefined); const server = new McpServer( {name: 'threadnote-local-adapter', version: '0.2.0'}, { @@ -336,7 +374,7 @@ function registerTools(server: McpServer, config: RuntimeConfig): void { description: 'Check OpenViking server health through the CLI.', inputSchema: {}, }, - async () => runOpenVikingMcpTool(config, 'health', {}), + async () => withStaleVersionNotice(await runOpenVikingMcpTool(config, 'health', {})), ); registerOpenVikingParityTools(server, config); @@ -894,14 +932,16 @@ function registerSearchTool(server: McpServer, config: RuntimeConfig, name: stri if (!checkedUri.ok) { return checkedUri.error; } - return runRecallTool(config, { - callerCwd, - query: checkedQuery.value, - pinnedUri: checkedUri.value, - nodeLimit, - includeArchived: includeArchived === true, - threshold: threshold === undefined ? undefined : String(threshold), - }); + return withStaleVersionNotice( + await runRecallTool(config, { + callerCwd, + query: checkedQuery.value, + pinnedUri: checkedUri.value, + nodeLimit, + includeArchived: includeArchived === true, + threshold: threshold === undefined ? undefined : String(threshold), + }), + ); }, ); } @@ -1179,11 +1219,13 @@ function registerStoreTool(server: McpServer, config: RuntimeConfig, name: strin timestamp: new Date().toISOString(), topic: normalizeOptionalMetadata(topic), }; - return writeDurableMemory(config, { - bodyText: checkedText.value, - metadata, - replaceUri: checkedReplaceUri.value, - }); + return withStaleVersionNotice( + await writeDurableMemory(config, { + bodyText: checkedText.value, + metadata, + replaceUri: checkedReplaceUri.value, + }), + ); }, ); } diff --git a/src/update.ts b/src/update.ts index cba1eed..f7ccec7 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,5 +1,5 @@ import {constants as fsConstants} from 'node:fs'; -import {access, readFile, writeFile} from 'node:fs/promises'; +import {access, writeFile} from 'node:fs/promises'; import {homedir} from 'node:os'; import {join} from 'node:path'; import {createInterface} from 'node:readline/promises'; @@ -13,6 +13,7 @@ import { ensureDirectory, errorMessage, findExecutable, + currentPackageVersion, findOpenVikingCli, isExecutable, isTcpPortOpen, @@ -420,14 +421,7 @@ async function getUpdateInfo( }; } -export async function currentPackageVersion(): Promise { - const rawPackage = await readFile(join(toolRoot(), 'package.json'), 'utf8'); - const parsed: unknown = JSON.parse(rawPackage); - if (!isJsonObject(parsed) || typeof parsed.version !== 'string') { - throw new Error('Could not read current threadnote package version.'); - } - return parsed.version; -} +export {currentPackageVersion}; export async function fetchLatestVersion(registry: string): Promise { const url = new URL(`${NPM_PACKAGE_NAME}/latest`, normalizeRegistry(registry)); diff --git a/src/utils.ts b/src/utils.ts index 89b53c4..cd5bb65 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -489,6 +489,28 @@ function safeVersionNumber(value: number | undefined): number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0; } +/** + * Returns a reconnect notice when a newer threadnote is installed on disk than + * the version a long-lived process started from — undefined when they match, + * the disk is older, or either version is unknown. Used by the MCP server to + * tell callers their resident stdio server is running stale code. + */ +export function formatStaleVersionNotice( + runningVersion: string | undefined, + diskVersion: string | undefined, +): string | undefined { + if (runningVersion === undefined || diskVersion === undefined) { + return undefined; + } + if (compareVersions(diskVersion, runningVersion) <= 0) { + return undefined; + } + return ( + `threadnote ${diskVersion} is installed but this MCP server is still running ${runningVersion}. ` + + 'Reconnect the threadnote MCP server (e.g. /mcp) to load the update.' + ); +} + export async function readHttpStatus(url: string, timeoutMs: number): Promise { return new Promise(resolvePromise => { const request = httpGet(url, response => { @@ -1428,6 +1450,15 @@ export function toolRoot(): string { return resolve(__dirname, '..'); } +export async function currentPackageVersion(): Promise { + const rawPackage = await readFile(join(toolRoot(), 'package.json'), 'utf8'); + const parsed: unknown = JSON.parse(rawPackage); + if (!isJsonObject(parsed) || typeof parsed.version !== 'string') { + throw new Error('Could not read current threadnote package version.'); + } + return parsed.version; +} + export function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 125c84f..267ebf4 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -17,6 +17,7 @@ import { formatExactMatchPointers, formatRecallHits, formatShellCommand, + formatStaleVersionNotice, getGlobBase, globToRegExp, grepUrisFromJson, @@ -113,6 +114,25 @@ describe('reindexWaitTimeoutMs', () => { }); }); +describe('formatStaleVersionNotice', () => { + it('returns a reconnect notice naming both versions when disk is newer', () => { + const notice = formatStaleVersionNotice('1.4.0', '1.4.1'); + expect(notice).toContain('1.4.1'); + expect(notice).toContain('1.4.0'); + expect(notice).toMatch(/reconnect/i); + }); + + it('returns undefined when versions match or disk is older or equal', () => { + expect(formatStaleVersionNotice('1.4.1', '1.4.1')).toBeUndefined(); + expect(formatStaleVersionNotice('1.4.1', '1.4.0')).toBeUndefined(); + }); + + it('returns undefined when either version is unknown', () => { + expect(formatStaleVersionNotice(undefined, '1.4.1')).toBeUndefined(); + expect(formatStaleVersionNotice('1.4.0', undefined)).toBeUndefined(); + }); +}); + describe('parseJsonConfigObject', () => { it('returns the parsed object for valid JSON objects', () => { expect(parseJsonConfigObject('{"a":1}')).toEqual({a: 1});