import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; /** * Configuration for the background-task lane (issue #64, part of #63). * * The wiki's intelligent work (ingest synthesis, embeddings, topic inference) * can run off the main agent thread on a model of the user's choosing. This * module resolves that configuration from pi's namespaced settings, mirroring * the approach used by pi-observational-memory. * * Resolution order (later wins): * 1. built-in DEFAULTS * 2. global settings: /settings.json → { "llm-wiki": { ... } } * 3. project settings: /.pi/settings.json → { "llm-wiki": { ... } } * * When `taskModel` is unset, the background lane falls back to the session * model (see Runtime.resolveModel), so the feature is zero-config by default. */ export interface TaskConfig { /** * Model used for background wiki tasks. When undefined, the session model * is used. The surface for setting this (config field, /command, per-call * override) is built in issue #69; this module only reads it. */ taskModel?: { provider: string; id: string }; /** * Embedding provider for background write-time embeddings (issue #66). * Only "openai" / "openai-compatible" are supported. When undefined, * embeddings are disabled entirely (silent no-op) — this is the default, * so the feature is strictly opt-in. */ embeddingProvider?: string; /** Embedding model id (default: text-embedding-3-small). */ embeddingModel?: string; /** OpenAI-compatible base URL (default: https://api.openai.com or OPENAI_BASE_URL). */ embeddingBaseUrl?: string; /** * Embedding API key. Prefer `embeddingApiKeyEnv` to avoid storing secrets in * settings files; this direct field exists for parity but is discouraged. */ embeddingApiKey?: string; /** Env var name to read the embedding API key from (default: OPENAI_API_KEY). */ embeddingApiKeyEnv?: string; /** * Weight of the semantic (cosine) signal when blending with lexical score in * hybrid recall (issue #67). 0 = pure lexical, 1 = pure semantic boost. * Default 0.5. Only takes effect when embeddings exist AND an embedder is * configured; otherwise recall stays 100% lexical. */ semanticWeight?: number; /** * Two-stage recall gate (issue #68). When the vault's registered page count * is STRICTLY GREATER THAN this threshold, recall switches to "links-first" * mode: it returns a ranked list of links (id, title, type, score, 1-line * snippet) instead of inline content previews, and the agent expands chosen * links on demand via `read`. At or below the threshold, the current * preview-inline behavior is preserved (no regression for small vaults). * * Page-count (not token-budget) was chosen deliberately: it is derived from * `meta/registry.json` in O(1) with zero extra file I/O, so the gate itself * never reads page bodies — token estimation would require touching every * page, defeating the "cheap recall" goal. Default 50. Set to 0 to force * links-first for any non-empty vault, or a very large number to always keep * previews inline. Clamped to a non-negative integer. */ recallLinksThreshold?: number; /** * Max characters of a distilled `skill`/`case` body inlined directly into a * recall block before truncation (recall-adherence fix). Skills/cases are * meant to be APPLIED immediately, so links-first recall inlines their short * body instead of a bare link the agent often skips. Set to 0 to DISABLE * inlining entirely — skills/cases then fall back to the normal link/preview * path (pure links-first), and no page body is read at format time. Only * relevant when the trajectories feature is on (skill/case pages exist only * then). Default 1600. Clamped to a non-negative integer. Mirrors the * `recallLinksThreshold` knob — the other context-window lever for recall. */ recallSkillInlineMax?: number; /** * Surface wiki activity in the UI (issue #77). When enabled (the default), * the status line reflects recall hits and the periodic observe/retro * reminder is shown to the user (`display: true`) instead of being injected * silently. Set to `false` to restore the previous quiet behavior — a static * status line and a hidden (`display: false`) reminder — for users who do * not want any chat-level wiki notices. */ notices?: boolean; /** * Agent-trajectory working-memory (capture → distill → recall), issue #80. * OPT-IN, default OFF: only an explicit `trajectories: true` enables it. * When off, the trajectory tools are never registered (see index.ts), so * they cost nothing in the system prompt for the ~95% who don't use them. */ trajectories?: boolean; } export const TASK_DEFAULTS: TaskConfig = {}; /** * Resolve whether user-facing wiki notices are enabled (issue #77). Defaults * to `true`; only an explicit `notices: false` disables them. */ export function noticesEnabled(config: TaskConfig | undefined): boolean { return config?.notices !== false; } /** * Resolve whether agent-trajectory working-memory is enabled (issue #80). * INVERSE polarity of `noticesEnabled`: defaults to `false`; only an explicit * `trajectories: true` turns it on. */ export function trajectoriesEnabled(config: TaskConfig | undefined): boolean { return config?.trajectories === true; } const SETTINGS_KEY = "llm-wiki"; function readModelSpec(value: unknown): { provider: string; id: string } | undefined { if (!value || typeof value !== "object") return undefined; const v = value as Record; if (typeof v.provider === "string" && typeof v.id === "string" && v.provider && v.id) { return { provider: v.provider, id: v.id }; } return undefined; } function readNamespacedConfig(path: string): Partial { try { const raw = readSettingsObject(path); const nested = raw[SETTINGS_KEY]; if (!nested || typeof nested !== "object") return {}; const section = nested as Record; const out: Partial = {}; const taskModel = readModelSpec(section.taskModel); if (taskModel) out.taskModel = taskModel; for (const key of [ "embeddingProvider", "embeddingModel", "embeddingBaseUrl", "embeddingApiKey", "embeddingApiKeyEnv", ] as const) { const value = section[key]; if (typeof value === "string" && value.trim()) out[key] = value.trim(); } const weight = section.semanticWeight; if (typeof weight === "number" && Number.isFinite(weight)) { out.semanticWeight = Math.min(1, Math.max(0, weight)); } const threshold = section.recallLinksThreshold; if (typeof threshold === "number" && Number.isFinite(threshold)) { out.recallLinksThreshold = Math.max(0, Math.floor(threshold)); } const inlineMax = section.recallSkillInlineMax; if (typeof inlineMax === "number" && Number.isFinite(inlineMax)) { out.recallSkillInlineMax = Math.max(0, Math.floor(inlineMax)); } if (typeof section.notices === "boolean") { out.notices = section.notices; } if (typeof section.trajectories === "boolean") { out.trajectories = section.trajectories; } return out; } catch { return {}; } } /** * Parse a `"provider/id"` model reference (issue #69). Splits on the FIRST * slash so model ids that themselves contain slashes (e.g. * `openrouter/meta/llama-3`) are preserved. Returns `undefined` for empty, * slashless, or partial (`provider/` / `/id`) refs so callers can reject bad * input. Whitespace is trimmed. */ export function parseModelRef(ref: string): { provider: string; id: string } | undefined { const trimmed = ref.trim(); const slash = trimmed.indexOf("/"); if (slash <= 0) return undefined; const provider = trimmed.slice(0, slash).trim(); const id = trimmed.slice(slash + 1).trim(); if (!provider || !id) return undefined; return { provider, id }; } /** * Read a settings JSON file as a plain object, or `{}` when it is absent or * corrupt. Reads directly (no `existsSync` pre-check) so there is no * check-then-use race: a missing file throws ENOENT, which the catch treats * the same as an empty file. */ function readSettingsObject(path: string): Record { try { const parsed = JSON.parse(readFileSync(path, "utf-8")); if (parsed && typeof parsed === "object") return parsed as Record; } catch { // Missing or corrupt settings file: start from an empty object. } return {}; } /** * Persist (or clear) the wiki background `taskModel` in the PROJECT settings * file `/.pi/settings.json` under the namespaced `llm-wiki` key (issue * #69). Project settings win over global in `loadTaskConfig`, so this takes * effect immediately on the next config load. Other top-level keys and other * `llm-wiki` settings are preserved; passing `undefined` removes the key * (reverting to the session model). */ export function persistTaskModel( cwd: string, model: { provider: string; id: string } | undefined, ): void { const settingsPath = join(cwd, ".pi", "settings.json"); const raw = readSettingsObject(settingsPath); const existing = raw[SETTINGS_KEY]; const section: Record = existing && typeof existing === "object" ? { ...(existing as Record) } : {}; if (model) { section.taskModel = { provider: model.provider, id: model.id }; } else { // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean delete section.taskModel; } raw[SETTINGS_KEY] = section; mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); } /** * Persist the agent-trajectory flag in the PROJECT settings file * `/.pi/settings.json` under the namespaced `llm-wiki` key (issue #80). * Mirrors `persistTaskModel`: project settings win in `loadTaskConfig`, other * keys are preserved. `true` writes `trajectories: true`; `false` removes the * key (reverting to the default-off behavior). */ export function persistTrajectoriesEnabled(cwd: string, enabled: boolean): void { const settingsPath = join(cwd, ".pi", "settings.json"); const raw = readSettingsObject(settingsPath); const existing = raw[SETTINGS_KEY]; const section: Record = existing && typeof existing === "object" ? { ...(existing as Record) } : {}; if (enabled) { section.trajectories = true; } else { // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off) delete section.trajectories; } raw[SETTINGS_KEY] = section; mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8"); } export function loadTaskConfig(cwd: string): TaskConfig { let globalPath: string; try { globalPath = join(getAgentDir(), "settings.json"); } catch { globalPath = ""; } const projectPath = join(cwd, ".pi", "settings.json"); return { ...TASK_DEFAULTS, ...(globalPath ? readNamespacedConfig(globalPath) : {}), ...readNamespacedConfig(projectPath), }; }