/** * Company Context Extension * * WHAT: On session start, loads config/company.json (the single source of * truth). If `remoteConfigUrl` is set, tries to fetch a fresh config * from there with a short timeout and falls back silently to the local * file on any error. Renders a short corporate context from * `company-context.md` and injects it into the system prompt. Also * registers the `/kit-config` command for debugging effective values. * * CONFIGURE: Edit config/company.json (gitHost, trackerUrl, docsUrl, repoMap, * rules, remoteConfigUrl). Edit the wording in company-context.md — * non-programmers can safely change text between the {{placeholders}}. * * DISABLE: Remove/rename this file from the package `extensions/` folder, * or exclude it via the package manifest / settings. */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; interface CompanyConfig { gitHost: string; trackerUrl: string; docsUrl: string; repoMap: string; rules: string[]; remoteConfigUrl: string | null; } const HERE = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = path.join(HERE, "..", "config", "company.json"); const TEMPLATE_PATH = path.join(HERE, "company-context.md"); const REMOTE_TIMEOUT_MS = 2500; const MAX_CONTEXT_LINES = 40; const DEFAULT_CONFIG: CompanyConfig = { gitHost: "TODO", trackerUrl: "TODO", docsUrl: "TODO", repoMap: "TODO", rules: [], remoteConfigUrl: null, }; /** Read and validate the local config file, falling back to defaults. */ function readLocalConfig(): CompanyConfig { try { const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); return normalize(raw); } catch { return { ...DEFAULT_CONFIG }; } } /** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */ function normalize(raw: Record): CompanyConfig { const str = (v: unknown, d: string) => (typeof v === "string" ? v : d); return { gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost), trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl), docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl), repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap), rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [], remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null, }; } /** Try to fetch a fresh config from a URL. Returns null on any failure. */ async function fetchRemoteConfig(url: string): Promise { try { const res = await fetch(url, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) }); if (!res.ok) return null; const raw = (await res.json()) as Record; return normalize(raw); } catch { return null; // network error, timeout, bad JSON — fall back silently } } /** Load the context template once; fall back to a minimal inline template. */ function loadTemplate(): string { try { return fs.readFileSync(TEMPLATE_PATH, "utf8"); } catch { return [ "# Корпоративный контекст", "git-хост: {{gitHost}}", "трекер: {{trackerUrl}}", "документация: {{docsUrl}}", "репозитории: {{repoMap}}", "правила:", "{{rules}}", ].join("\n"); } } /** Render the corporate context from the template + effective config. */ function renderContext(template: string, cfg: CompanyConfig): string { const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)"; const rendered = template .replaceAll("{{gitHost}}", cfg.gitHost) .replaceAll("{{trackerUrl}}", cfg.trackerUrl) .replaceAll("{{docsUrl}}", cfg.docsUrl) .replaceAll("{{repoMap}}", cfg.repoMap) .replaceAll("{{rules}}", rulesBlock); // Safety cap: never inject more than MAX_CONTEXT_LINES lines. return rendered.split("\n").slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd(); } export default function companyContextExtension(pi: ExtensionAPI) { const template = loadTemplate(); // Effective config and where it came from. Refreshed on every session start. let effective: CompanyConfig = readLocalConfig(); let source: "local" | "remote" = "local"; pi.on("session_start", async (_event, ctx) => { effective = readLocalConfig(); source = "local"; if (effective.remoteConfigUrl) { const remote = await fetchRemoteConfig(effective.remoteConfigUrl); if (remote) { // Remote wins, but keep the local remoteConfigUrl so a bad remote // value can't orphan future refreshes. effective = { ...remote, remoteConfigUrl: effective.remoteConfigUrl }; source = "remote"; } } if (ctx.hasUI) { ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info"); } }); pi.on("before_agent_start", async (event) => { const rendered = renderContext(template, effective); if (!rendered) return undefined; return { systemPrompt: `${event.systemPrompt}\n\n${rendered}` }; }); pi.registerCommand("kit-config", { description: "Показать действующие корпоративные значения pi-kit и их источник", handler: async (_args, ctx) => { const origin = source === "remote" ? `remote (${effective.remoteConfigUrl})` : `local (${CONFIG_PATH})`; const lines = [ `Источник: ${origin}`, "", `gitHost: ${effective.gitHost}`, `trackerUrl: ${effective.trackerUrl}`, `docsUrl: ${effective.docsUrl}`, `repoMap: ${effective.repoMap}`, `remoteConfigUrl: ${effective.remoteConfigUrl ?? "(не задан)"}`, "rules:", ...(effective.rules.length > 0 ? effective.rules.map((r) => ` - ${r}`) : [" (нет)"]), ]; if (ctx.hasUI) { await ctx.ui.select("Действующая конфигурация pi-kit", lines); } else { ctx.ui.notify(lines.join("\n"), "info"); } }, }); }