import { existsSync, readFileSync } from "node:fs"; import { basename, isAbsolute, join, resolve } from "node:path"; import { homedir } from "node:os"; import { CONFIG_DIR_NAME, getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { normalizeRecipientConfig, type RecipientConfig } from "./target.ts"; export interface AnonNotifyConfig { enabled: boolean; endpoint: string; token?: string; tokenEnv: string; tokenFile: string; source: string; projectConfig: boolean; mainAgentOnly: boolean; recipient: RecipientConfig; notify: { started: boolean; progress: boolean; completed: boolean; failed: boolean; cancelled: boolean; toolErrors: boolean; }; progress: { intervalMs: number; minRunDurationMs: number; minCompletedTools: number; toolErrorCooldownMs: number; }; delivery: { timeoutMs: number; maxAttempts: number; retryBaseDelayMs: number; minIntervalMs: number; }; command: { enabled: boolean; journalFile: string; pollIntervalMs: number; maxBatchSize: number; deliveryWhenBusy: "steer"; }; presence: { // 本地 heartbeat 发送间隔;服务端使用自己的 sessionHeartbeatTimeoutMs 作为权威 TTL。 heartbeatIntervalMs: number; heartbeatTimeoutMs: number; }; content: { maxTaskChars: number; maxSummaryChars: number; includeProject: boolean; // Deprecated compatibility keys. They stay boolean-validated so old configs // still load, but they are always normalized to false and can no longer // re-enable automatic model/duration/tool-count summary decorations. includeModel: boolean; includeDuration: boolean; includeToolCounts: boolean; includeLastAssistantMessage: boolean; }; } export interface LoadedConfig { config: AnonNotifyConfig; token: string; commandJournalPath: string; globalConfigPath: string; projectConfigPath?: string; } const DEFAULT_CONFIG: AnonNotifyConfig = { enabled: true, endpoint: "http://127.0.0.1:5140/api/agent-notify/v1/events", tokenEnv: "ANON_NOTIFY_TOKEN", tokenFile: "~/.pi/agent/extension-data/anon-notify/token", source: "Pi Agent · {project}", projectConfig: true, mainAgentOnly: true, recipient: { qq: "", anonQq: "", }, notify: { started: false, progress: false, completed: true, failed: true, cancelled: false, toolErrors: false, }, progress: { intervalMs: 120_000, minRunDurationMs: 60_000, minCompletedTools: 3, toolErrorCooldownMs: 60_000, }, delivery: { timeoutMs: 12_000, maxAttempts: 3, retryBaseDelayMs: 1_000, minIntervalMs: 300_000, }, command: { enabled: true, journalFile: "~/.pi/agent/extension-data/anon-notify/commands.jsonl", pollIntervalMs: 5_000, maxBatchSize: 10, deliveryWhenBusy: "steer", }, presence: { heartbeatIntervalMs: 10_000, heartbeatTimeoutMs: 30_000, }, content: { maxTaskChars: 200, maxSummaryChars: 800, includeProject: true, includeModel: false, includeDuration: false, includeToolCounts: false, includeLastAssistantMessage: true, }, }; function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function mergeDeep(base: T, override: unknown): T { if (!isObject(base) || !isObject(override)) return (override === undefined ? base : override) as T; const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { const current = result[key]; result[key] = isObject(current) && isObject(value) ? mergeDeep(current, value) : value; } return result as T; } function readObject(path: string): Record { if (!existsSync(path)) return {}; const value = JSON.parse(readFileSync(path, "utf8")) as unknown; if (!isObject(value)) throw new Error(`配置文件必须是 JSON 对象:${path}`); return value; } function expandPath(value: string, baseDir = getAgentDir()): string { if (value === "~") return homedir(); if (value.startsWith("~/") || value.startsWith("~\\")) return join(homedir(), value.slice(2)); return isAbsolute(value) ? value : resolve(baseDir, value); } function boolFromEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; return undefined; } function objectValue(value: unknown, path: string): Record { if (value === undefined) return {}; if (!isObject(value)) throw new Error(`配置项 ${path} 必须是 JSON 对象`); return value; } function assertKnownKeys(value: Record, allowed: readonly string[], path: string): void { const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); if (unknown.length) throw new Error(`配置项 ${path} 包含未知字段:${unknown.join(", ")}`); } function stringValue(value: unknown, fallback: string, path: string): string { if (value === undefined) return fallback; if (typeof value !== "string") throw new Error(`配置项 ${path} 必须是字符串`); return value || fallback; } function booleanValue(value: unknown, fallback: boolean, path: string): boolean { if (value === undefined) return fallback; if (typeof value !== "boolean") throw new Error(`配置项 ${path} 必须是布尔值`); return value; } function integerValue(value: unknown, fallback: number, min: number, max: number, path: string): number { if (value === undefined) return fallback; if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`配置项 ${path} 必须是整数`); if (value < min || value > max) throw new Error(`配置项 ${path} 必须位于 ${min} 到 ${max} 之间`); return value; } function choiceValue( value: unknown, fallback: T, choices: readonly T[], path: string, ): T { if (value === undefined) return fallback; if (typeof value !== "string" || !choices.includes(value as T)) { throw new Error(`配置项 ${path} 必须是 ${choices.join("、")} 之一`); } return value as T; } function endpointValue(value: unknown): string { const endpoint = stringValue(value, DEFAULT_CONFIG.endpoint, "endpoint").replace(/\/+$/, ""); let url: URL; try { url = new URL(endpoint); } catch { throw new Error("配置项 endpoint 必须是有效 URL"); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("配置项 endpoint 只支持 http 或 https URL"); } return endpoint; } function normalize(config: AnonNotifyConfig): AnonNotifyConfig { const root = config as unknown as Record; const notify = objectValue(config.notify, "notify"); const progress = objectValue(config.progress, "progress"); const delivery = objectValue(config.delivery, "delivery"); const command = objectValue(config.command, "command"); const presence = objectValue(config.presence, "presence"); const content = objectValue(config.content, "content"); const recipient = normalizeRecipientConfig(config.recipient, DEFAULT_CONFIG.recipient); assertKnownKeys(root, ["enabled", "endpoint", "token", "tokenEnv", "tokenFile", "source", "projectConfig", "mainAgentOnly", "recipient", "notify", "progress", "delivery", "command", "presence", "content"], "根配置"); assertKnownKeys(notify, ["started", "progress", "completed", "failed", "cancelled", "toolErrors"], "notify"); assertKnownKeys(progress, ["intervalMs", "minRunDurationMs", "minCompletedTools", "toolErrorCooldownMs"], "progress"); assertKnownKeys(delivery, ["timeoutMs", "maxAttempts", "retryBaseDelayMs", "minIntervalMs"], "delivery"); assertKnownKeys(command, ["enabled", "journalFile", "pollIntervalMs", "maxBatchSize", "deliveryWhenBusy"], "command"); assertKnownKeys(presence, ["heartbeatIntervalMs", "heartbeatTimeoutMs"], "presence"); assertKnownKeys(content, ["maxTaskChars", "maxSummaryChars", "includeProject", "includeModel", "includeDuration", "includeToolCounts", "includeLastAssistantMessage"], "content"); const token = config.token === undefined ? undefined : stringValue(config.token, "", "token"); // Accept the old setting during rollout, but never restore idle-only delivery. choiceValue(command.deliveryWhenBusy, DEFAULT_CONFIG.command.deliveryWhenBusy, ["steer", "followUp"] as const, "command.deliveryWhenBusy"); // Deprecated summary telemetry keys: validate the old boolean type so a typo // is still rejected, but ignore the value. A stale `true` must not restore // the removed automatic metadata. booleanValue(content.includeModel, DEFAULT_CONFIG.content.includeModel, "content.includeModel"); booleanValue(content.includeDuration, DEFAULT_CONFIG.content.includeDuration, "content.includeDuration"); booleanValue(content.includeToolCounts, DEFAULT_CONFIG.content.includeToolCounts, "content.includeToolCounts"); return { ...config, token, enabled: booleanValue(config.enabled, DEFAULT_CONFIG.enabled, "enabled"), endpoint: endpointValue(config.endpoint), tokenEnv: stringValue(config.tokenEnv, DEFAULT_CONFIG.tokenEnv, "tokenEnv"), tokenFile: stringValue(config.tokenFile, DEFAULT_CONFIG.tokenFile, "tokenFile"), source: stringValue(config.source, DEFAULT_CONFIG.source, "source"), projectConfig: booleanValue(config.projectConfig, DEFAULT_CONFIG.projectConfig, "projectConfig"), mainAgentOnly: booleanValue(config.mainAgentOnly, DEFAULT_CONFIG.mainAgentOnly, "mainAgentOnly"), recipient, notify: { started: booleanValue(notify.started, DEFAULT_CONFIG.notify.started, "notify.started"), progress: booleanValue(notify.progress, DEFAULT_CONFIG.notify.progress, "notify.progress"), completed: booleanValue(notify.completed, DEFAULT_CONFIG.notify.completed, "notify.completed"), failed: booleanValue(notify.failed, DEFAULT_CONFIG.notify.failed, "notify.failed"), cancelled: booleanValue(notify.cancelled, DEFAULT_CONFIG.notify.cancelled, "notify.cancelled"), toolErrors: booleanValue(notify.toolErrors, DEFAULT_CONFIG.notify.toolErrors, "notify.toolErrors"), }, progress: { intervalMs: integerValue(progress.intervalMs, DEFAULT_CONFIG.progress.intervalMs, 10_000, 86_400_000, "progress.intervalMs"), minRunDurationMs: integerValue(progress.minRunDurationMs, DEFAULT_CONFIG.progress.minRunDurationMs, 0, 86_400_000, "progress.minRunDurationMs"), minCompletedTools: integerValue(progress.minCompletedTools, DEFAULT_CONFIG.progress.minCompletedTools, 0, 10_000, "progress.minCompletedTools"), toolErrorCooldownMs: integerValue(progress.toolErrorCooldownMs, DEFAULT_CONFIG.progress.toolErrorCooldownMs, 0, 86_400_000, "progress.toolErrorCooldownMs"), }, delivery: { timeoutMs: integerValue(delivery.timeoutMs, DEFAULT_CONFIG.delivery.timeoutMs, 1_000, 120_000, "delivery.timeoutMs"), maxAttempts: integerValue(delivery.maxAttempts, DEFAULT_CONFIG.delivery.maxAttempts, 1, 10, "delivery.maxAttempts"), retryBaseDelayMs: integerValue(delivery.retryBaseDelayMs, DEFAULT_CONFIG.delivery.retryBaseDelayMs, 100, 60_000, "delivery.retryBaseDelayMs"), minIntervalMs: integerValue(delivery.minIntervalMs, DEFAULT_CONFIG.delivery.minIntervalMs, 0, 86_400_000, "delivery.minIntervalMs"), }, command: { enabled: booleanValue(command.enabled, DEFAULT_CONFIG.command.enabled, "command.enabled"), journalFile: stringValue(command.journalFile, DEFAULT_CONFIG.command.journalFile, "command.journalFile"), pollIntervalMs: integerValue(command.pollIntervalMs, DEFAULT_CONFIG.command.pollIntervalMs, 1_000, 300_000, "command.pollIntervalMs"), maxBatchSize: integerValue(command.maxBatchSize, DEFAULT_CONFIG.command.maxBatchSize, 1, 50, "command.maxBatchSize"), deliveryWhenBusy: "steer", }, presence: { heartbeatIntervalMs: integerValue(presence.heartbeatIntervalMs, DEFAULT_CONFIG.presence.heartbeatIntervalMs, 1_000, 300_000, "presence.heartbeatIntervalMs"), heartbeatTimeoutMs: integerValue(presence.heartbeatTimeoutMs, DEFAULT_CONFIG.presence.heartbeatTimeoutMs, 10_000, 600_000, "presence.heartbeatTimeoutMs"), }, content: { // 与 YesImBot Webhook 的字段上限保持一致,避免本地配置生成服务端必然拒绝的请求。 maxTaskChars: integerValue(content.maxTaskChars, DEFAULT_CONFIG.content.maxTaskChars, 40, 200, "content.maxTaskChars"), maxSummaryChars: integerValue(content.maxSummaryChars, DEFAULT_CONFIG.content.maxSummaryChars, 80, 1_200, "content.maxSummaryChars"), includeProject: booleanValue(content.includeProject, DEFAULT_CONFIG.content.includeProject, "content.includeProject"), includeModel: false, includeDuration: false, includeToolCounts: false, includeLastAssistantMessage: booleanValue( content.includeLastAssistantMessage, DEFAULT_CONFIG.content.includeLastAssistantMessage, "content.includeLastAssistantMessage", ), }, }; } export function loadConfig(ctx: ExtensionContext, extensionDir: string): LoadedConfig { // User configuration lives outside the npm package so upgrades cannot erase // targets or credentials. Keep the old source-directory config as a fallback. const legacyConfigPath = join(extensionDir, "config.json"); const globalConfigPath = join(getAgentDir(), "extension-data", "anon-notify", "config.json"); const globalConfig = normalize(mergeDeep( mergeDeep(DEFAULT_CONFIG, readObject(legacyConfigPath)), readObject(globalConfigPath), )); let merged = globalConfig; let projectConfigPath: string | undefined; if (globalConfig.projectConfig && ctx.isProjectTrusted()) { projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "anon-notify.json"); merged = mergeDeep(globalConfig, readObject(projectConfigPath)); } const envEnabled = boolFromEnv(process.env.ANON_NOTIFY_ENABLED); if (envEnabled !== undefined) merged.enabled = envEnabled; if (process.env.ANON_NOTIFY_ENDPOINT) merged.endpoint = process.env.ANON_NOTIFY_ENDPOINT; if (process.env.ANON_NOTIFY_SOURCE) merged.source = process.env.ANON_NOTIFY_SOURCE; if (process.env.ANON_NOTIFY_RECIPIENT_QQ || process.env.ANON_NOTIFY_ANON_QQ) { merged.recipient = { qq: process.env.ANON_NOTIFY_RECIPIENT_QQ || merged.recipient.qq, anonQq: process.env.ANON_NOTIFY_ANON_QQ || merged.recipient.anonQq, }; } const config = normalize(merged); const tokenPath = expandPath(config.tokenFile, extensionDir); const commandJournalPath = expandPath(config.command.journalFile, extensionDir); const token = process.env[config.tokenEnv]?.trim() || process.env.ANON_NOTIFY_TOKEN?.trim() || config.token?.trim() || (existsSync(tokenPath) ? readFileSync(tokenPath, "utf8").trim() : ""); return { config, token, commandJournalPath, globalConfigPath, projectConfigPath, }; } export function renderSource(template: string, ctx: ExtensionContext): string { const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown"; const values: Record = { project: basename(ctx.cwd) || ctx.cwd, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), model, mode: ctx.mode, }; return template.replace(/\{(project|cwd|sessionId|model|mode)\}/g, (_match, key: string) => values[key] || ""); }