diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 3afce68..5af3c55 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,15 +1,17 @@ import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; -import { isConfigured, CONFIG, reloadApiKey } from "../config.js"; +import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js"; import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; +import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js"; const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted"); +const UPDATE_COMMAND = "npx codex-supermemory@latest install"; interface CodexHookPayload { session_id?: string; @@ -31,6 +33,10 @@ function exitWithContext(additionalContext: string): never { process.exit(0); } +function combineContextParts(parts: Array): string { + return parts.map((part) => part?.trim()).filter(Boolean).join("\n\n"); +} + async function main() { let rawInput = ""; try { @@ -77,6 +83,8 @@ async function main() { const cwd = payload.cwd || process.cwd(); const tags = getTags(cwd); const client = new SupermemoryClient(); + const updateCheck = checkNpmUpdate("codex-supermemory", PLUGIN_VERSION, UPDATE_COMMAND) + .then((info) => (info ? formatUpdateNotice(info) : null)); log("session-start: begin", { sessionId, tags }); @@ -97,13 +105,17 @@ async function main() { if (newFacts.length > 0) { addSeenFacts(sessionId, newFacts); - exitWithContext(`[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`); + const updateNotice = await updateCheck; + exitWithContext(combineContextParts([ + `[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`, + updateNotice, + ])); } - exitWithContext(""); + exitWithContext(await updateCheck ?? ""); } catch (error) { log("session-start: error", { error: String(error) }); - exitWithContext(""); + exitWithContext(await updateCheck ?? ""); } } diff --git a/src/services/version-check.ts b/src/services/version-check.ts new file mode 100644 index 0000000..2688de7 --- /dev/null +++ b/src/services/version-check.ts @@ -0,0 +1,73 @@ +const NPM_REGISTRY_URL = "https://registry.npmjs.org"; +const CHECK_TIMEOUT_MS = 3000; + +export interface UpdateInfo { + currentVersion: string; + latestVersion: string; + updateCommand: string; +} + +function parseVersion(version: string): { parts: number[]; prerelease: string | null } | null { + const normalized = version.trim().replace(/^v/i, ""); + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); + if (!match) return null; + + return { + parts: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] ?? null, + }; +} + +function isVersionNewer(latestVersion: string, currentVersion: string): boolean { + const latest = parseVersion(latestVersion); + const current = parseVersion(currentVersion); + if (!latest || !current) return latestVersion !== currentVersion; + + for (let i = 0; i < 3; i++) { + const latestPart = latest.parts[i] ?? 0; + const currentPart = current.parts[i] ?? 0; + if (latestPart > currentPart) return true; + if (latestPart < currentPart) return false; + } + + if (!latest.prerelease && current.prerelease) return true; + if (latest.prerelease && !current.prerelease) return false; + return latest.prerelease !== current.prerelease && latest.prerelease !== null; +} + +export async function checkNpmUpdate( + packageName: string, + currentVersion: string, + updateCommand: string, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); + + try { + const encodedPackage = packageName.startsWith("@") + ? packageName.replace("/", "%2F") + : encodeURIComponent(packageName); + const response = await fetch(`${NPM_REGISTRY_URL}/${encodedPackage}/latest`, { + signal: controller.signal, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { version?: unknown }; + const latestVersion = typeof data.version === "string" ? data.version : null; + if (!latestVersion || !isVersionNewer(latestVersion, currentVersion)) return null; + + return { currentVersion, latestVersion, updateCommand }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export function formatUpdateNotice(info: UpdateInfo): string { + return [ + "[SUPERMEMORY UPDATE]", + `Supermemory update available: v${info.currentVersion} -> v${info.latestVersion}`, + `Run: ${info.updateCommand}`, + ].join("\n"); +}