UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

312 lines (311 loc) • 14.7 kB
import { execFileSync } from "node:child_process"; import { readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { findPhrenPath, runtimeFile } from "../phren-paths.js"; import { kindFromDomain, parseRemoteUrl } from "../bridge/git-hosts.js"; import { resolveHostToken } from "../bridge/git-host-auth.js"; const MINUTE = 60_000; // Refresh path only. Process spawns on Windows runners often exceed 200 ms. const GIT_TIMEOUT_MS = 1000; const RETENTION = 7 * 24 * 60 * MINUTE; const RESOLUTION_TTL = 24 * 60 * MINUTE; const cacheMemo = new Map(); const isChanged = (entry) => !!entry && "state" in entry && entry.state === "changed"; const resolutionKey = (citation, opts) => `repo:${citation.repo ?? ""}\0${citation.projectRemote ?? opts.projectRemote ?? ""}`; const pending = new Map(); const off = () => process.env.PHREN_CITATION_FRESHNESS?.trim().toLowerCase() === "off"; function git(root, timeoutMs, ...args) { if (timeoutMs <= 0) return ""; try { return execFileSync("git", [...(root ? ["-C", root] : []), ...args], { encoding: "utf8", timeout: Math.max(1, Math.ceil(timeoutMs)), stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return ""; } } function checkoutRoot(repo, deadline) { if (!repo || !path.isAbsolute(repo)) return; try { const root = realpathSync(repo); const toplevel = git(root, Math.min(GIT_TIMEOUT_MS, deadline - Date.now()), "rev-parse", "--show-toplevel"); if (!toplevel || !path.isAbsolute(toplevel)) return; // Git prints forward slashes and long names on Windows; compare canonical forms. const canonical = (value) => { // The JS realpath keeps 8.3 short names (RUNNER~1); the native one expands them. const resolved = path.resolve((realpathSync.native ?? realpathSync)(value)); return process.platform === "win32" ? resolved.toLowerCase() : resolved; }; return canonical(toplevel) === canonical(root) ? root : undefined; } catch { return undefined; } } function validDomain(domain) { const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*)(?::([0-9]+))?$/.exec(domain); return !!match && (!match[2] || (Number(match[2]) >= 1 && Number(match[2]) <= 65535)); } function repository(citation, opts, entries, deadline) { const fallback = citation.projectRemote ?? opts.projectRemote; const key = resolutionKey(citation, opts); const cached = entries[key]; if (cached && "value" in cached && fresh(cached)) return cached.value; const local = checkoutRoot(citation.repo, deadline); const read = (...args) => git(local, Math.min(GIT_TIMEOUT_MS, deadline - Date.now()), ...args); const remote = local ? read("remote", "get-url", "origin") : ""; const parsed = parseRemoteUrl(remote || fallback || ""); let value = null; if (parsed && validDomain(parsed.domain) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(parsed.path) && !parsed.path.split("/").some(part => part === "." || part === "..")) { const override = (remote ? read("config", "--get", "remote.origin.phrenHost") : "") || read("config", ...(local ? [] : ["--global"]), "--get", `phren.${parsed.domain}.host`); const named = override.trim().toLowerCase(); const kind = ["github", "gitlab", "gitboy"].includes(named) ? named : kindFromDomain(parsed.domain); if (kind === "gitboy") value = { ...parsed, root: remote ? local : undefined }; } // A budget-limited git lookup must not become a day-long negative resolution. if (Date.now() < deadline) entries[key] = { checkedAt: new Date().toISOString(), value }; return value; } function target(citation, repo) { if (!citation.file || !citation.commit || !/^[a-f0-9]{7,64}$/i.test(citation.commit)) return; let file = citation.file.split(path.sep).join("/"); if (file.includes("\\") || file.includes("\0") || file.split("/").includes("..")) return; if (!repo || !validDomain(repo.domain) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo.path) || repo.path.split("/").some(part => part === "." || part === "..")) return; if (path.isAbsolute(file)) { if (!repo.root || !path.isAbsolute(repo.root)) return; // git canonicalizes macOS /var and other checkout aliases; citations may retain the alias. const absolute = citation.repo && path.isAbsolute(citation.repo) ? path.resolve(repo.root, path.relative(citation.repo, file)) : file; file = path.relative(repo.root, absolute).split(path.sep).join("/"); if (file === ".." || file.startsWith("../") || path.isAbsolute(file)) return; } file = path.posix.normalize(file); if (!file || file === "." || file.startsWith("../")) return; const result = { domain: repo.domain, repo: repo.path, commit: citation.commit, file }; return { ...result, key: JSON.stringify([result.domain, result.repo, result.commit, file]) }; } function cacheFile(opts) { const root = opts.phrenPath ?? findPhrenPath(); return root ? runtimeFile(root, "citation-freshness.json") : undefined; } function readCache(file) { if (!file) return {}; try { const info = statSync(file); const memo = cacheMemo.get(file); if (memo && memo.mtimeMs === info.mtimeMs && memo.size === info.size) return memo.entries; const raw = JSON.parse(readFileSync(file, "utf8")); const entries = Object.fromEntries(Object.entries(raw).filter(([key, value]) => value && Number.isFinite(Date.parse(value.checkedAt)) && (key.startsWith("repo:") ? "value" in value : "state" in value && ["current", "changed", "unknown"].includes(value.state)))); rememberCache(file, entries, info); return entries; } catch { cacheMemo.delete(file); return {}; } } function rememberCache(file, entries, info = statSync(file)) { const { mtimeMs, size } = info; if (cacheMemo.size >= 64) cacheMemo.delete(cacheMemo.keys().next().value); cacheMemo.set(file, { mtimeMs, size, entries }); } function ttl(value) { return "value" in value ? RESOLUTION_TTL : (value.state === "changed" ? 60 : value.state === "current" ? 15 : 5) * MINUTE; } function fresh(value) { return !!value && Date.now() - Date.parse(value.checkedAt) < ttl(value); } function save(file, entries) { if (!file) return; const temp = `${file}.${randomUUID()}.tmp`; try { const previous = readCache(file); const merged = { ...previous, ...entries }; for (const [key, value] of Object.entries(previous)) { const incoming = entries[key]; if (Date.now() - Date.parse(value.checkedAt) > ("value" in value ? RESOLUTION_TTL : RETENTION)) continue; if (!incoming || (isChanged(value) && !isChanged(incoming)) || (isChanged(value) === isChanged(incoming) && Date.parse(value.checkedAt) > Date.parse(incoming.checkedAt))) merged[key] = value; } const sorted = Object.entries(merged).filter(([, value]) => Date.now() - Date.parse(value.checkedAt) <= ("value" in value ? RESOLUTION_TTL : RETENTION)).sort((a, b) => Date.parse(b[1].checkedAt) - Date.parse(a[1].checkedAt)).slice(0, 5000); const saved = Object.fromEntries(sorted); writeFileSync(temp, JSON.stringify(saved), { mode: 0o600 }); renameSync(temp, file); rememberCache(file, saved); } catch { /* Cache failures never fail retrieval. */ } finally { try { unlinkSync(temp); } catch { /* Already renamed. */ } } } /** Reads persisted resolutions and checks only; never runs git or accesses the network. Changed evidence survives refresh failures for seven days. */ export function cachedCitationFreshness(citation, opts = {}) { if (off()) return; try { const entries = readCache(cacheFile(opts)); const resolution = entries[resolutionKey(citation, opts)]; if (!resolution || !("value" in resolution) || !fresh(resolution)) return; const resolved = target(citation, resolution.value); const value = resolved ? entries[resolved.key] : undefined; return value && "state" in value && Date.now() - Date.parse(value.checkedAt) <= RETENTION && (isChanged(value) || fresh(value)) ? value : undefined; } catch { return undefined; } } function answer(raw, head, checkedAt) { const row = raw; if (typeof row?.changed !== "boolean") return { state: "unknown", checkedAt }; const last = row.last_commit; return { state: row.changed ? "changed" : "current", checkedAt, ...(typeof (row.head ?? head) === "string" ? { head: (row.head ?? head) } : {}), ...(row.changed ? { lastCommit: last && typeof last.sha === "string" && typeof last.summary === "string" && typeof last.authored_at === "string" ? { sha: last.sha, summary: last.summary, authoredAt: last.authored_at } : null } : {}) }; } async function request(batch, opts, remaining, entries) { const controller = new AbortController(); const abort = () => controller.abort(); opts.signal?.addEventListener("abort", abort, { once: true }); if (opts.signal?.aborted) abort(); let timer; const work = async () => { const first = batch[0]; if (!validDomain(first.domain)) throw new Error("Invalid git host"); const url = new URL("https://" + first.domain); url.pathname = `/api/v1/repos/${first.repo.split("/").map(encodeURIComponent).join("/")}/changed`; const token = await resolveHostToken("gitboy", first.domain); if (controller.signal.aborted) throw new Error("Aborted"); url.searchParams.set("since", first.commit); for (const item of batch) url.searchParams.append("path", item.file); const response = await (opts.fetchImpl ?? fetch)(url, { redirect: "manual", signal: controller.signal, headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token.token}` } : {}) } }); if (!response.ok) throw new Error("Host could not answer"); return await response.json(); }; try { const expired = new Promise((_, reject) => { timer = setTimeout(() => { abort(); reject(new Error("Deadline")); }, Math.max(1, Math.min(remaining, 5000))); controller.signal.addEventListener("abort", () => reject(new Error("Aborted")), { once: true }); }); const body = await Promise.race([work(), expired]); if (controller.signal.aborted) throw new Error("Aborted"); const checkedAt = new Date().toISOString(); for (const item of batch) { const value = answer(Array.isArray(body.paths) ? body.paths.find(row => row.path === item.file) : undefined, body.head, checkedAt); if (!isChanged(entries[item.key]) || value.state === "changed") entries[item.key] = value; } } catch { for (const item of batch) if (!isChanged(entries[item.key])) entries[item.key] = { state: "unknown", checkedAt: new Date().toISOString() }; } finally { clearTimeout(timer); opts.signal?.removeEventListener("abort", abort); } } /** Best effort, bounded, and deduplicated per cache key, including background Hook polls. */ export async function refreshCitationFreshness(citations, opts) { const result = new Map(); if (off()) return result; try { const deadline = Date.now() + Math.max(0, opts.budgetMs); const file = cacheFile(opts), entries = { ...readCache(file) }; const resolved = new Map(); const groups = new Map(); for (const citation of citations) { const item = target(citation, repository(citation, opts, entries, deadline)); if (!item) continue; resolved.set(citation, item); if (fresh(entries[item.key])) continue; const groupKey = JSON.stringify([item.domain, item.repo, item.commit]); if (!groups.has(groupKey)) groups.set(groupKey, new Map()); groups.get(groupKey).set(item.key, item); } for (const group of groups.values()) { const items = [...group.values()]; for (let i = 0; i < items.length; i += 100) { const batch = items.slice(i, i + 100).filter(item => !pending.has(`${file}:${item.key}`)); const remaining = deadline - Date.now(); if (remaining <= 0 || opts.signal?.aborted) { for (const item of items.slice(i)) if (!isChanged(entries[item.key])) entries[item.key] = { state: "unknown", checkedAt: new Date().toISOString() }; break; } if (!batch.length) continue; const work = request(batch, opts, remaining, entries).then(() => save(file, entries)); for (const item of batch) pending.set(`${file}:${item.key}`, work); try { await work; } finally { for (const item of batch) pending.delete(`${file}:${item.key}`); } } } save(file, entries); const latest = { ...entries, ...readCache(file) }; for (const [citation, item] of resolved) { const value = latest[item.key]; if (value && "state" in value) result.set(citation, value); } } catch { /* Offline, invalid citations and disk failures must never fail retrieval. */ } return result; } export function citationFreshnessAnnotation(citation, value) { if (value?.state !== "changed") return ""; const clean = (text) => text.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 300); const detail = value.lastCommit ? `changed in ${clean(value.lastCommit.sha).slice(0, 7)} "${clean(value.lastCommit.summary)}"` : "changed since cited"; return ` [may be out of date: ${clean(citation.file ?? "file")} ${detail}]`; }