import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { OPENWIKI_VERSION } from "./constants.js"; import { isFileNotFoundError } from "./fs-errors.js"; import { createConnectorRegistry } from "./connectors/registry.js"; import { UPDATE_METADATA_PATH } from "./constants.js"; import { createConnectorSynthesisGuidance } from "./ingestion.js"; import type { OpenWikiRunEvent } from "./agent/types.js"; const OPENWIKI_AGENTS_SNIPPET_START = ""; const OPENWIKI_AGENTS_SNIPPET_END = ""; const DEFAULT_CODE_MODE_CRON = "0 8 * * *"; // Root agent-instruction files OpenWiki keeps pointed at the generated wiki. // Each is created when missing and refreshed in place when already present. const CODE_MODE_AGENT_FILES = ["AGENTS.md", "CLAUDE.md"]; /** Controls which parts of the repo OpenWiki sets up for code mode. */ export interface CodeModeRepoSetupOptions { /** * Write the scheduled-update workflow file. Only `openwiki code --init` * should create it; `--update` and chat runs leave an existing file alone so * operator customizations (fork guards, pinned actions, custom steps) are * never silently overwritten. */ createWorkflow?: boolean; /** Cron expression for a freshly created workflow. Defaults to {@link DEFAULT_CODE_MODE_CRON}. */ cronExpression?: string; } /** * Ensure the repo is set up for code mode: refresh the managed agent-instruction * snippets, and, when `options.createWorkflow` is set, create the scheduled-update * workflow if it does not already exist. */ export async function ensureCodeModeRepoSetup( cwd: string, options: CodeModeRepoSetupOptions = {}, ): Promise { if (options.createWorkflow) { await ensureCodeModeWorkflow( cwd, options.cronExpression ?? DEFAULT_CODE_MODE_CRON, ); } await writeCodeModeAgentSnippets(cwd); } /** * Create the scheduled-update workflow file only when it is missing. An existing * file is preserved verbatim so repo-specific customizations survive repeated * runs; a plain overwrite would silently strip them. */ async function ensureCodeModeWorkflow( cwd: string, cronExpression: string, ): Promise { const workflowPath = path.join( cwd, ".github", "workflows", "openwiki-update.yml", ); try { await readFile(workflowPath, "utf8"); return; } catch (error) { if (!isFileNotFoundError(error)) { throw error; } } await mkdir(path.dirname(workflowPath), { recursive: true }); await writeFile(workflowPath, createCodeModeWorkflow(cronExpression), "utf8"); } /** * Runs every configured code-mode connector for a code-mode agent run and appends * their guidance to the agent's message. Returns the base message unchanged when * nothing contributes, so an unconfigured repo still noop-skips. Fail-open: a * connector that throws is skipped, never allowed to break the update. */ export async function runCodeModeConnectors( repoRoot: string, baseMessage: string | undefined, onEvent?: (event: OpenWikiRunEvent) => void, ): Promise { // The natural window: what has happened since we last documented this repo. const windowHours = windowHoursSince(await readLastUpdatedAt(repoRoot)); const blocks: string[] = []; for (const connector of Object.values(createConnectorRegistry())) { if (connector.mode !== "code") { continue; } // Surface the pull so the otherwise-silent gap before the agent reads as // progress ("Ingesting from LangSmith…") instead of a hang. emitText(onEvent, `Ingesting from ${connector.displayName}…\n`); let pull; try { // Code connectors read their committed repo config from repoRoot; a repo // that has not configured the connector skips, so nothing is appended. pull = await connector.ingest({ repoRoot, windowHours }); } catch { // The connector documents software; it must never break the run it feeds. emitText(onEvent, `${connector.displayName} ingestion skipped.\n`); continue; } emitText(onEvent, `${pull.message}\n`); if (pull.status !== "success" || pull.rawFiles.length === 0) { continue; } const guidance = createConnectorSynthesisGuidance(connector); if (guidance) { blocks.push(guidance); } } if (blocks.length === 0) { return baseMessage; } const base = baseMessage?.trim(); const joined = blocks.join("\n\n"); return base ? `${base}\n\n${joined}` : joined; } /** * Emits a plain progress line to the run log, matching the agent's text events so * connector progress renders in the same stream. */ function emitText( onEvent: ((event: OpenWikiRunEvent) => void) | undefined, text: string, ): void { onEvent?.({ source: "main", text, type: "text" }); } /** * Hours elapsed since the last-update timestamp, the code-mode ingestion window. * Undefined when since is absent or unparseable (first run), meaning "no floor" * so the connector bootstraps with its most recent traces. */ function windowHoursSince(since: string | undefined): number | undefined { const sinceMs = since !== undefined ? Date.parse(since) : Number.NaN; return Number.isNaN(sinceMs) ? undefined : Math.max(0, (Date.now() - sinceMs) / (60 * 60 * 1000)); } /** * The last-update timestamp from openwiki/.last-update.json, or undefined when it * is absent (first run) or unreadable. */ async function readLastUpdatedAt( repoRoot: string, ): Promise { try { const text = await readFile( path.join(repoRoot, UPDATE_METADATA_PATH), "utf8", ); const parsed = JSON.parse(text) as { updatedAt?: unknown }; return typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined; } catch { return undefined; } } async function writeCodeModeAgentSnippets(cwd: string): Promise { const snippet = createCodeModeAgentsSnippet(); await Promise.all( CODE_MODE_AGENT_FILES.map((fileName) => writeCodeModeAgentSnippet(path.join(cwd, fileName), snippet), ), ); } async function writeCodeModeAgentSnippet( agentsPath: string, snippet: string, ): Promise { let currentContent = ""; try { currentContent = await readFile(agentsPath, "utf8"); } catch (error) { if (!isFileNotFoundError(error)) { throw error; } } const startIndex = currentContent.indexOf(OPENWIKI_AGENTS_SNIPPET_START); const endIndex = currentContent.indexOf(OPENWIKI_AGENTS_SNIPPET_END); const nextContent = startIndex !== -1 && endIndex !== -1 && endIndex > startIndex ? `${currentContent.slice(0, startIndex)}${snippet}${currentContent.slice(endIndex + OPENWIKI_AGENTS_SNIPPET_END.length)}` : `${currentContent.trimEnd()}${currentContent.trim().length > 0 ? "\n\n" : ""}${snippet}\n`; await writeFile(agentsPath, nextContent, "utf8"); } function createCodeModeWorkflow(cronExpression: string): string { return `name: OpenWiki Update on: workflow_dispatch: schedule: - cron: "${cronExpression}" permissions: contents: write pull-requests: write jobs: update: runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" - name: Install OpenWiki # mermaid + jsdom are optional; they add high-fidelity validation of Mermaid diagrams. Remove if your wiki has none. run: npm install --global openwiki@${OPENWIKI_VERSION} mermaid@11.16.0 jsdom@29.1.1 - name: Run OpenWiki run: openwiki code --update --print env: OPENWIKI_PROVIDER: openrouter OPENROUTER_API_KEY: \${{ secrets.OPENROUTER_API_KEY }} OPENWIKI_MODEL_ID: z-ai/glm-5.2 # Required for the LangSmith connector's code-mode pull to authenticate. # For extra workspaces, add OPENWIKI_LANGSMITH_API_KEY_2, _3, ... as repo # secrets and env entries here. OPENWIKI_LANGSMITH_API_KEY: \${{ secrets.OPENWIKI_LANGSMITH_API_KEY }} # Optional: also trace this workflow's own OpenWiki run to LangSmith. LANGSMITH_API_KEY: \${{ secrets.LANGSMITH_API_KEY }} LANGCHAIN_PROJECT: openwiki LANGCHAIN_TRACING_V2: "true" - name: Create OpenWiki update pull request uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 with: add-paths: | openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml branch: openwiki/update commit-message: "docs: update OpenWiki" title: "docs: update OpenWiki" body: | Automated OpenWiki documentation update. This PR was generated by the scheduled OpenWiki workflow. `; } function createCodeModeAgentsSnippet(): string { return `${OPENWIKI_AGENTS_SNIPPET_START} ## OpenWiki This repository uses OpenWiki for recurring code documentation. Start with \`openwiki/quickstart.md\`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps. The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate. ${OPENWIKI_AGENTS_SNIPPET_END}`; }