276 lines · 9.4 KB · TypeScript
RawDownload
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 = "<!-- OPENWIKI:START -->";const OPENWIKI_AGENTS_SNIPPET_END = "<!-- OPENWIKI: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<void> {  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<void> {  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<string | undefined> {  // 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<string | undefined> {  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<void> {  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<void> {  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}`;}