import { execFile } from "node:child_process";import { createHash } from "node:crypto";import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";import path from "node:path";import { promisify } from "node:util";import { OPEN_WIKI_DIR, UPDATE_METADATA_PATH } from "../constants.js";import { isExpectedSnapshotRaceError, isFileNotFoundError,} from "../fs-errors.js";import { resolveLanguage } from "../language.js";import { readOpenWikiOnboardingConfig, readRepositoryWikiInstructions,} from "../onboarding.js";import { OpenWikiIgnore } from "./openwiki-ignore.js";import type { OpenWikiCommand, OpenWikiOutputMode, OpenWikiRunOptions, RunContext, UpdateMetadata, UpdateRunStatus,} from "./types.js";import type { Dirent } from "node:fs"; const execFileAsync = promisify(execFile);const LOCAL_WIKI_METADATA_PATH = ".last-update.json";const TEMPORARY_PLAN_FILE = "_plan.md"; export type OpenWikiContentSnapshot = string; export type UpdateNoopStatus = | { shouldSkip: true; gitHead: string; model: string; } | { shouldSkip: false; reason: string; }; /** * Builds the per-run context the prompt uses to reason about prior docs and git changes. * * Paths excluded by `openWikiIgnore` are stripped from the git evidence so the * agent never sees changes under an ignored path. */export async function createRunContext( command: OpenWikiCommand, cwd: string, outputMode: OpenWikiOutputMode = "repository", language?: string | null, openWikiIgnore = new OpenWikiIgnore([]),): Promise<RunContext> { const lastUpdate = await readLastUpdate(cwd, outputMode); // A validated flag wins; otherwise inherit the wiki's persisted language so an // update without --language keeps the existing wiki consistent instead of // producing a mix of the old and new language. const requestedLanguage = resolveLanguage(language).language; // English is materialized as "en" rather than encoded by an absent key, so the // wiki's language is always explicit in metadata and every run inherits a // concrete value. const effectiveLanguage = requestedLanguage ?? lastUpdate?.language ?? "en"; const languageContext = { language: effectiveLanguage }; const wikiGoal = await readRunWikiGoal(cwd, outputMode); if (command === "chat") { return { lastUpdate, gitSummary: "Not applicable for chat.", ...languageContext, wikiGoal, }; } if (outputMode === "local-wiki") { return { lastUpdate, gitSummary: "Local wiki mode: connector source evidence is provided through raw data paths and OpenWiki connector tools. Git repository diff context is not used for this run.", ...languageContext, wikiGoal, }; } return { lastUpdate, gitSummary: await createGitSummary( command, cwd, lastUpdate, openWikiIgnore, ), ...languageContext, wikiGoal, };} async function readRunWikiGoal( cwd: string, outputMode: OpenWikiOutputMode,): Promise<string | undefined> { if (outputMode === "repository") { return readRepositoryWikiInstructions(cwd); } return (await readOpenWikiOnboardingConfig()).wikiGoal;} /** * Decides whether an `update` run can be skipped because nothing meaningful changed. * * Working-tree and committed changes that only touch `openwiki/` or paths * excluded by `openWikiIgnore` do not count as meaningful, so an ignored path * changing on its own never forces a rebuild. */export async function getUpdateNoopStatus( cwd: string, openWikiIgnore = new OpenWikiIgnore([]),): Promise<UpdateNoopStatus> { const lastUpdate = await readLastUpdate(cwd, "repository"); if (!lastUpdate?.gitHead) { return { shouldSkip: false, reason: "missing previous update git head" }; } if (lastUpdate.status === "interrupted") { return { shouldSkip: false, reason: "previous update was interrupted" }; } const head = await getGitHead(cwd); if (!head) { return { shouldSkip: false, reason: "missing current git head" }; } const status = await runGit(cwd, [ "status", "--short", "--untracked-files=all", ]); const meaningfulStatus = status .split("\n") .map((line) => line.trimEnd()) .filter(Boolean) .filter((line) => !isUpdateMetadataStatusLine(line)) .filter((line) => !lineReferencesIgnoredPath(line, openWikiIgnore)); if (meaningfulStatus.length > 0) { return { shouldSkip: false, reason: "worktree has changes" }; } if (head !== lastUpdate.gitHead) { const committedPaths = await getChangedPathsSinceLastUpdate( cwd, lastUpdate.gitHead, ); if ( committedPaths.length === 0 || committedPaths.some( (changedPath) => !isOpenWikiPath(changedPath) && !openWikiIgnore.ignores(changedPath), ) ) { return { shouldSkip: false, reason: "git head changed" }; } } return { shouldSkip: true, gitHead: head, model: lastUpdate.model, };} export function shouldCheckUpdateNoop(options: OpenWikiRunOptions): boolean { return !options.userMessage?.trim();} /** * Records an init/update run so future updates can diff from this git head. * Interrupted runs are recorded with status "interrupted" so the update * no-op check knows the wiki may be partial and does not skip the retry. */export async function writeLastUpdateMetadata( command: OpenWikiCommand, cwd: string, modelId: string, outputMode: OpenWikiOutputMode = "repository", status: UpdateRunStatus = "complete", language?: string,): Promise<void> { const metadataFile = getMetadataFilePath(cwd, outputMode); const metadata: UpdateMetadata = { updatedAt: new Date().toISOString(), command, gitHead: outputMode === "repository" ? await getGitHead(cwd) : undefined, model: modelId, status, ...(language ? { language } : {}), }; await mkdir(path.dirname(metadataFile), { recursive: true }); await writeFile( metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8", );} /** * Persists run metadata when OpenWiki content changed since the given snapshot. * Returns whether metadata was written. Used after both successful and failed * runs so already-generated content stays diffable by future updates. */export async function persistRunMetadataIfChanged( command: OpenWikiCommand, cwd: string, modelId: string, outputMode: OpenWikiOutputMode, snapshotBefore: OpenWikiContentSnapshot | null, status: UpdateRunStatus = "complete", language?: string,): Promise<boolean> { if (command === "chat" || snapshotBefore === null) { return false; } if ( snapshotBefore === (await createOpenWikiContentSnapshot(cwd, outputMode)) ) { // A completed run clears a previous interrupted status even when the // content did not change, so the update no-op check can skip again. const lastUpdate = await readLastUpdate(cwd, outputMode); if (status !== "complete" || lastUpdate?.status !== "interrupted") { return false; } } await writeLastUpdateMetadata( command, cwd, modelId, outputMode, status, language, ); return true;} /** * Removes the temporary planning file the agent creates during init/update runs. */export async function removeTemporaryPlanFile( cwd: string, outputMode: OpenWikiOutputMode,): Promise<boolean> { const planFile = getTemporaryPlanFilePath(cwd, outputMode); try { await rm(planFile); return true; } catch (error) { if (isFileNotFoundError(error)) { return false; } throw error; }} /** * Hashes OpenWiki content, excluding run metadata, to detect real documentation changes. */export async function createOpenWikiContentSnapshot( cwd: string, outputMode: OpenWikiOutputMode = "repository",): Promise<OpenWikiContentSnapshot> { const openWikiDir = getWikiContentRoot(cwd, outputMode); const hash = createHash("sha256"); await addDirectoryToSnapshot(hash, openWikiDir, ""); return hash.digest("hex");} /** * Reads prior run metadata if it exists and is structurally valid. */async function readLastUpdate( cwd: string, outputMode: OpenWikiOutputMode,): Promise<UpdateMetadata | null> { const metadataFile = getMetadataFilePath(cwd, outputMode); try { const rawMetadata = await readFile(metadataFile, "utf8"); const parsedMetadata = JSON.parse(rawMetadata) as Partial<UpdateMetadata>; if ( typeof parsedMetadata.updatedAt === "string" && typeof parsedMetadata.command === "string" && typeof parsedMetadata.model === "string" ) { return { updatedAt: parsedMetadata.updatedAt, command: parsedMetadata.command === "init" ? "init" : "update", gitHead: typeof parsedMetadata.gitHead === "string" ? parsedMetadata.gitHead : undefined, model: parsedMetadata.model, // Metadata written before the status field existed is treated as // complete so upgrades do not force a spurious re-run. status: parsedMetadata.status === "interrupted" ? "interrupted" : "complete", language: typeof parsedMetadata.language === "string" ? parsedMetadata.language : undefined, }; } return null; } catch (error) { if (isFileNotFoundError(error) || error instanceof SyntaxError) { return null; } throw error; }} /** * Recursively adds stable file paths and bytes to the OpenWiki content snapshot. */async function addDirectoryToSnapshot( hash: ReturnType<typeof createHash>, directory: string, relativeDirectory: string,): Promise<void> { let entries: Dirent[]; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { if (isExpectedSnapshotRaceError(error)) { hash.update("missing"); return; } throw error; } for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name), )) { const entryPath = path.join(directory, entry.name); const relativePath = path.join(relativeDirectory, entry.name); if (isIgnoredSnapshotPath(relativePath)) { continue; } if (entry.isDirectory()) { hash.update(`dir:${relativePath}\0`); await addDirectoryToSnapshot(hash, entryPath, relativePath); continue; } if (!entry.isFile()) { continue; } const fileContent = await readSnapshotFile(entryPath); if (fileContent === null) { continue; } hash.update(`file:${relativePath}\0`); hash.update(fileContent); hash.update("\0"); }} function getWikiContentRoot( cwd: string, outputMode: OpenWikiOutputMode,): string { return outputMode === "local-wiki" ? cwd : path.join(cwd, OPEN_WIKI_DIR);} function getTemporaryPlanFilePath( cwd: string, outputMode: OpenWikiOutputMode,): string { return path.join(getWikiContentRoot(cwd, outputMode), TEMPORARY_PLAN_FILE);} function getMetadataFilePath( cwd: string, outputMode: OpenWikiOutputMode,): string { return outputMode === "local-wiki" ? path.join(cwd, LOCAL_WIKI_METADATA_PATH) : path.join(cwd, UPDATE_METADATA_PATH);} function isIgnoredSnapshotPath(relativePath: string): boolean { return ( relativePath === path.basename(UPDATE_METADATA_PATH) || relativePath === LOCAL_WIKI_METADATA_PATH || relativePath === TEMPORARY_PLAN_FILE );} /** * Reads snapshot bytes while tolerating files that move mid-scan. */async function readSnapshotFile(filePath: string): Promise<Buffer | null> { try { return await readFile(filePath); } catch (error) { if (isExpectedSnapshotRaceError(error)) { return null; } throw error; }} /** * Produces the git evidence block passed to init/update prompts. * * Lines that reference a path excluded by `openWikiIgnore` are filtered out of * every git section (status, log, diff) before the block is assembled. */async function createGitSummary( command: OpenWikiCommand, cwd: string, lastUpdate: UpdateMetadata | null, openWikiIgnore: OpenWikiIgnore,): Promise<string> { const sections: string[] = []; const status = filterGitOutputForIgnore( await runGit(cwd, ["status", "--short"]), openWikiIgnore, ); const head = await getGitHead(cwd); sections.push(formatGitSection("git status --short", status)); sections.push(formatGitSection("git rev-parse HEAD", head ?? "(unknown)")); if (command === "update" && lastUpdate?.gitHead) { const logSinceLastHead = filterGitOutputForIgnore( await runGit(cwd, [ "log", `${lastUpdate.gitHead}..HEAD`, "--name-status", "--oneline", ]), openWikiIgnore, ); sections.push( formatGitSection( `git log ${lastUpdate.gitHead}..HEAD --name-status --oneline`, logSinceLastHead, ), ); } else if (command === "update" && lastUpdate?.updatedAt) { const logSinceLastUpdate = filterGitOutputForIgnore( await runGit(cwd, [ "log", "--since", lastUpdate.updatedAt, "--name-status", "--oneline", ]), openWikiIgnore, ); sections.push( formatGitSection( `git log --since ${lastUpdate.updatedAt} --name-status --oneline`, logSinceLastUpdate, ), ); } else { const recentLog = filterGitOutputForIgnore( await runGit(cwd, [ "log", "--max-count=20", "--name-status", "--oneline",Lines 1–500 of 686. Load more
import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { OPEN_WIKI_DIR, UPDATE_METADATA_PATH } from "../constants.js"; import { isExpectedSnapshotRaceError, isFileNotFoundError, } from "../fs-errors.js"; import { resolveLanguage } from "../language.js"; import { readOpenWikiOnboardingConfig, readRepositoryWikiInstructions, } from "../onboarding.js"; import { OpenWikiIgnore } from "./openwiki-ignore.js"; import type { OpenWikiCommand, OpenWikiOutputMode, OpenWikiRunOptions, RunContext, UpdateMetadata, UpdateRunStatus, } from "./types.js"; import type { Dirent } from "node:fs"; const execFileAsync = promisify(execFile); const LOCAL_WIKI_METADATA_PATH = ".last-update.json"; const TEMPORARY_PLAN_FILE = "_plan.md"; export type OpenWikiContentSnapshot = string; export type UpdateNoopStatus = | { shouldSkip: true; gitHead: string; model: string; } | { shouldSkip: false; reason: string; }; /** * Builds the per-run context the prompt uses to reason about prior docs and git changes. * * Paths excluded by `openWikiIgnore` are stripped from the git evidence so the * agent never sees changes under an ignored path. */ export async function createRunContext( command: OpenWikiCommand, cwd: string, outputMode: OpenWikiOutputMode = "repository", language?: string | null, openWikiIgnore = new OpenWikiIgnore([]), ): Promise<RunContext> { const lastUpdate = await readLastUpdate(cwd, outputMode); // A validated flag wins; otherwise inherit the wiki's persisted language so an // update without --language keeps the existing wiki consistent instead of // producing a mix of the old and new language. const requestedLanguage = resolveLanguage(language).language; // English is materialized as "en" rather than encoded by an absent key, so the // wiki's language is always explicit in metadata and every run inherits a // concrete value. const effectiveLanguage = requestedLanguage ?? lastUpdate?.language ?? "en"; const languageContext = { language: effectiveLanguage }; const wikiGoal = await readRunWikiGoal(cwd, outputMode); if (command === "chat") { return { lastUpdate, gitSummary: "Not applicable for chat.", ...languageContext, wikiGoal, }; } if (outputMode === "local-wiki") { return { lastUpdate, gitSummary: "Local wiki mode: connector source evidence is provided through raw data paths and OpenWiki connector tools. Git repository diff context is not used for this run.", ...languageContext, wikiGoal, }; } return { lastUpdate, gitSummary: await createGitSummary( command, cwd, lastUpdate, openWikiIgnore, ), ...languageContext, wikiGoal, }; } async function readRunWikiGoal( cwd: string, outputMode: OpenWikiOutputMode, ): Promise<string | undefined> { if (outputMode === "repository") { return readRepositoryWikiInstructions(cwd); } return (await readOpenWikiOnboardingConfig()).wikiGoal; } /** * Decides whether an `update` run can be skipped because nothing meaningful changed. * * Working-tree and committed changes that only touch `openwiki/` or paths * excluded by `openWikiIgnore` do not count as meaningful, so an ignored path * changing on its own never forces a rebuild. */ export async function getUpdateNoopStatus( cwd: string, openWikiIgnore = new OpenWikiIgnore([]), ): Promise<UpdateNoopStatus> { const lastUpdate = await readLastUpdate(cwd, "repository"); if (!lastUpdate?.gitHead) { return { shouldSkip: false, reason: "missing previous update git head" }; } if (lastUpdate.status === "interrupted") { return { shouldSkip: false, reason: "previous update was interrupted" }; } const head = await getGitHead(cwd); if (!head) { return { shouldSkip: false, reason: "missing current git head" }; } const status = await runGit(cwd, [ "status", "--short", "--untracked-files=all", ]); const meaningfulStatus = status .split("\n") .map((line) => line.trimEnd()) .filter(Boolean) .filter((line) => !isUpdateMetadataStatusLine(line)) .filter((line) => !lineReferencesIgnoredPath(line, openWikiIgnore)); if (meaningfulStatus.length > 0) { return { shouldSkip: false, reason: "worktree has changes" }; } if (head !== lastUpdate.gitHead) { const committedPaths = await getChangedPathsSinceLastUpdate( cwd, lastUpdate.gitHead, ); if ( committedPaths.length === 0 || committedPaths.some( (changedPath) => !isOpenWikiPath(changedPath) && !openWikiIgnore.ignores(changedPath), ) ) { return { shouldSkip: false, reason: "git head changed" }; } } return { shouldSkip: true, gitHead: head, model: lastUpdate.model, }; } export function shouldCheckUpdateNoop(options: OpenWikiRunOptions): boolean { return !options.userMessage?.trim(); } /** * Records an init/update run so future updates can diff from this git head. * Interrupted runs are recorded with status "interrupted" so the update * no-op check knows the wiki may be partial and does not skip the retry. */ export async function writeLastUpdateMetadata( command: OpenWikiCommand, cwd: string, modelId: string, outputMode: OpenWikiOutputMode = "repository", status: UpdateRunStatus = "complete", language?: string, ): Promise<void> { const metadataFile = getMetadataFilePath(cwd, outputMode); const metadata: UpdateMetadata = { updatedAt: new Date().toISOString(), command, gitHead: outputMode === "repository" ? await getGitHead(cwd) : undefined, model: modelId, status, ...(language ? { language } : {}), }; await mkdir(path.dirname(metadataFile), { recursive: true }); await writeFile( metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8", ); } /** * Persists run metadata when OpenWiki content changed since the given snapshot. * Returns whether metadata was written. Used after both successful and failed * runs so already-generated content stays diffable by future updates. */ export async function persistRunMetadataIfChanged( command: OpenWikiCommand, cwd: string, modelId: string, outputMode: OpenWikiOutputMode, snapshotBefore: OpenWikiContentSnapshot | null, status: UpdateRunStatus = "complete", language?: string, ): Promise<boolean> { if (command === "chat" || snapshotBefore === null) { return false; } if ( snapshotBefore === (await createOpenWikiContentSnapshot(cwd, outputMode)) ) { // A completed run clears a previous interrupted status even when the // content did not change, so the update no-op check can skip again. const lastUpdate = await readLastUpdate(cwd, outputMode); if (status !== "complete" || lastUpdate?.status !== "interrupted") { return false; } } await writeLastUpdateMetadata( command, cwd, modelId, outputMode, status, language, ); return true; } /** * Removes the temporary planning file the agent creates during init/update runs. */ export async function removeTemporaryPlanFile( cwd: string, outputMode: OpenWikiOutputMode, ): Promise<boolean> { const planFile = getTemporaryPlanFilePath(cwd, outputMode); try { await rm(planFile); return true; } catch (error) { if (isFileNotFoundError(error)) { return false; } throw error; } } /** * Hashes OpenWiki content, excluding run metadata, to detect real documentation changes. */ export async function createOpenWikiContentSnapshot( cwd: string, outputMode: OpenWikiOutputMode = "repository", ): Promise<OpenWikiContentSnapshot> { const openWikiDir = getWikiContentRoot(cwd, outputMode); const hash = createHash("sha256"); await addDirectoryToSnapshot(hash, openWikiDir, ""); return hash.digest("hex"); } /** * Reads prior run metadata if it exists and is structurally valid. */ async function readLastUpdate( cwd: string, outputMode: OpenWikiOutputMode, ): Promise<UpdateMetadata | null> { const metadataFile = getMetadataFilePath(cwd, outputMode); try { const rawMetadata = await readFile(metadataFile, "utf8"); const parsedMetadata = JSON.parse(rawMetadata) as Partial<UpdateMetadata>; if ( typeof parsedMetadata.updatedAt === "string" && typeof parsedMetadata.command === "string" && typeof parsedMetadata.model === "string" ) { return { updatedAt: parsedMetadata.updatedAt, command: parsedMetadata.command === "init" ? "init" : "update", gitHead: typeof parsedMetadata.gitHead === "string" ? parsedMetadata.gitHead : undefined, model: parsedMetadata.model, // Metadata written before the status field existed is treated as // complete so upgrades do not force a spurious re-run. status: parsedMetadata.status === "interrupted" ? "interrupted" : "complete", language: typeof parsedMetadata.language === "string" ? parsedMetadata.language : undefined, }; } return null; } catch (error) { if (isFileNotFoundError(error) || error instanceof SyntaxError) { return null; } throw error; } } /** * Recursively adds stable file paths and bytes to the OpenWiki content snapshot. */ async function addDirectoryToSnapshot( hash: ReturnType<typeof createHash>, directory: string, relativeDirectory: string, ): Promise<void> { let entries: Dirent[]; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { if (isExpectedSnapshotRaceError(error)) { hash.update("missing"); return; } throw error; } for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name), )) { const entryPath = path.join(directory, entry.name); const relativePath = path.join(relativeDirectory, entry.name); if (isIgnoredSnapshotPath(relativePath)) { continue; } if (entry.isDirectory()) { hash.update(`dir:${relativePath}\0`); await addDirectoryToSnapshot(hash, entryPath, relativePath); continue; } if (!entry.isFile()) { continue; } const fileContent = await readSnapshotFile(entryPath); if (fileContent === null) { continue; } hash.update(`file:${relativePath}\0`); hash.update(fileContent); hash.update("\0"); } } function getWikiContentRoot( cwd: string, outputMode: OpenWikiOutputMode, ): string { return outputMode === "local-wiki" ? cwd : path.join(cwd, OPEN_WIKI_DIR); } function getTemporaryPlanFilePath( cwd: string, outputMode: OpenWikiOutputMode, ): string { return path.join(getWikiContentRoot(cwd, outputMode), TEMPORARY_PLAN_FILE); } function getMetadataFilePath( cwd: string, outputMode: OpenWikiOutputMode, ): string { return outputMode === "local-wiki" ? path.join(cwd, LOCAL_WIKI_METADATA_PATH) : path.join(cwd, UPDATE_METADATA_PATH); } function isIgnoredSnapshotPath(relativePath: string): boolean { return ( relativePath === path.basename(UPDATE_METADATA_PATH) || relativePath === LOCAL_WIKI_METADATA_PATH || relativePath === TEMPORARY_PLAN_FILE ); } /** * Reads snapshot bytes while tolerating files that move mid-scan. */ async function readSnapshotFile(filePath: string): Promise<Buffer | null> { try { return await readFile(filePath); } catch (error) { if (isExpectedSnapshotRaceError(error)) { return null; } throw error; } } /** * Produces the git evidence block passed to init/update prompts. * * Lines that reference a path excluded by `openWikiIgnore` are filtered out of * every git section (status, log, diff) before the block is assembled. */ async function createGitSummary( command: OpenWikiCommand, cwd: string, lastUpdate: UpdateMetadata | null, openWikiIgnore: OpenWikiIgnore, ): Promise<string> { const sections: string[] = []; const status = filterGitOutputForIgnore( await runGit(cwd, ["status", "--short"]), openWikiIgnore, ); const head = await getGitHead(cwd); sections.push(formatGitSection("git status --short", status)); sections.push(formatGitSection("git rev-parse HEAD", head ?? "(unknown)")); if (command === "update" && lastUpdate?.gitHead) { const logSinceLastHead = filterGitOutputForIgnore( await runGit(cwd, [ "log", `${lastUpdate.gitHead}..HEAD`, "--name-status", "--oneline", ]), openWikiIgnore, ); sections.push( formatGitSection( `git log ${lastUpdate.gitHead}..HEAD --name-status --oneline`, logSinceLastHead, ), ); } else if (command === "update" && lastUpdate?.updatedAt) { const logSinceLastUpdate = filterGitOutputForIgnore( await runGit(cwd, [ "log", "--since", lastUpdate.updatedAt, "--name-status", "--oneline", ]), openWikiIgnore, ); sections.push( formatGitSection( `git log --since ${lastUpdate.updatedAt} --name-status --oneline`, logSinceLastUpdate, ), ); } else { const recentLog = filterGitOutputForIgnore( await runGit(cwd, [ "log", "--max-count=20", "--name-status", "--oneline",