- protected-paths: glob-based block of write/edit to secrets/keys/.git/prod configs - permission-gate: confirm dangerous bash (rm -rf, force push, push to protected branch, prod kubectl, docker prune, sudo) - company-context: loads config/company.json (+ optional remoteConfigUrl w/ 2.5s timeout), injects corporate context via before_agent_start, registers /kit-config - config path resolved via import.meta.url, not cwd; text template in company-context.md
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
/**
|
|
* 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<string, unknown>): 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<CompanyConfig | null> {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) });
|
|
if (!res.ok) return null;
|
|
const raw = (await res.json()) as Record<string, unknown>;
|
|
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");
|
|
}
|
|
},
|
|
});
|
|
}
|