@zosmaai/pi-llm-wiki 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig, noticesEnabled } from "./task-config.js";
3
+
4
+ /**
5
+ * Background-task runtime for the LLM Wiki (issue #64, part of #63).
6
+ *
7
+ * Provides two primitives, ported from pi-observational-memory's proven
8
+ * pattern, that let the extension perform LLM work WITHOUT blocking the main
9
+ * agent turn:
10
+ *
11
+ * - launchTask(): fire-and-forget a detached promise that may outlive the
12
+ * current turn. The in-flight promise is stored so callers can await it at
13
+ * compaction / session exit (so background work is never silently lost),
14
+ * but the agent loop itself never blocks on it. Single-flight per label
15
+ * to avoid pile-ups.
16
+ *
17
+ * - resolveModel(): pick the model for background work — configured
18
+ * `taskModel` → session model fallback → API-key resolution. Returns a
19
+ * discriminated result so callers degrade gracefully (keep the existing
20
+ * synchronous main-agent flow) when no model / API key is available.
21
+ *
22
+ * This module introduces NO user-facing behavior on its own; it is the
23
+ * infrastructure that issues #65 (background ingest), #66 (background
24
+ * embeddings) and #69 (model selection) build upon.
25
+ */
26
+
27
+ export type ResolveResult =
28
+ | { ok: true; model: unknown; apiKey: string; headers?: Record<string, string> }
29
+ | { ok: false; reason: string };
30
+
31
+ type NotifyLevel = "info" | "warning" | "error";
32
+ type Notify = (message: string, type?: NotifyLevel) => void;
33
+
34
+ export interface ResolveCtx {
35
+ /** Current session model (may be undefined when the session has no model). */
36
+ model: unknown;
37
+ modelRegistry: {
38
+ find(provider: string, id: string): unknown;
39
+ getApiKeyAndHeaders(
40
+ model: unknown,
41
+ ): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string> }>;
42
+ };
43
+ hasUI: boolean;
44
+ ui?: { notify: Notify };
45
+ }
46
+
47
+ export interface LaunchCtx {
48
+ hasUI: boolean;
49
+ ui?: { notify: Notify };
50
+ }
51
+
52
+ export class Runtime {
53
+ config: TaskConfig = { ...TASK_DEFAULTS };
54
+ configLoaded = false;
55
+
56
+ /**
57
+ * Extension API handle, attached at registration. Used by `report()` to emit
58
+ * visible completion messages for background actions (issue #77). Optional so
59
+ * the Runtime stays unit-testable without a live `pi`.
60
+ */
61
+ pi?: ExtensionAPI;
62
+
63
+ /** Labels of tasks currently in flight (single-flight guard per label). */
64
+ private inFlightLabels = new Set<string>();
65
+ /** All in-flight task promises, keyed for await-at-exit and dedupe. */
66
+ private inFlight = new Map<string, Promise<void>>();
67
+ /** Whether we've already surfaced a model-resolution failure (avoid spam). */
68
+ resolveFailureNotified = false;
69
+
70
+ ensureConfig(cwd: string): void {
71
+ if (this.configLoaded) return;
72
+ this.config = loadTaskConfig(cwd);
73
+ this.configLoaded = true;
74
+ }
75
+
76
+ /** True if a task with this label is currently running. */
77
+ isInFlight(label: string): boolean {
78
+ return this.inFlightLabels.has(label);
79
+ }
80
+
81
+ /** Number of background tasks currently running. */
82
+ get pendingCount(): number {
83
+ return this.inFlight.size;
84
+ }
85
+
86
+ /**
87
+ * Resolve the model + auth for background work.
88
+ *
89
+ * Precedence (issue #69): per-call `override` → configured `taskModel` →
90
+ * session model. Each configured layer is applied only when the model is
91
+ * found in the registry; a missing layer warns (when UI is available) and
92
+ * falls through to the next. Returns { ok: false } when nothing resolves or
93
+ * no API key exists, so callers can fall back to the synchronous
94
+ * main-agent path.
95
+ */
96
+ async resolveModel(
97
+ ctx: ResolveCtx,
98
+ override?: { provider: string; id: string },
99
+ ): Promise<ResolveResult> {
100
+ let model = ctx.model;
101
+
102
+ // Configured taskModel layer (beats the session model).
103
+ const configured = this.config.taskModel;
104
+ if (configured) {
105
+ const found = ctx.modelRegistry.find(configured.provider, configured.id);
106
+ if (found) {
107
+ model = found;
108
+ } else if (ctx.hasUI && ctx.ui) {
109
+ ctx.ui.notify(
110
+ `LLM Wiki: configured task model ${configured.provider}/${configured.id} not found, using session model`,
111
+ "warning",
112
+ );
113
+ }
114
+ }
115
+
116
+ // Per-call override layer (beats both config and session).
117
+ if (override) {
118
+ const found = ctx.modelRegistry.find(override.provider, override.id);
119
+ if (found) {
120
+ model = found;
121
+ } else if (ctx.hasUI && ctx.ui) {
122
+ ctx.ui.notify(
123
+ `LLM Wiki: model override ${override.provider}/${override.id} not found, using ${
124
+ configured ? "configured/session" : "session"
125
+ } model`,
126
+ "warning",
127
+ );
128
+ }
129
+ }
130
+
131
+ if (!model) {
132
+ return {
133
+ ok: false,
134
+ reason: "no model available (session has no model and no taskModel configured)",
135
+ };
136
+ }
137
+
138
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
139
+ if (!auth.ok || !auth.apiKey) {
140
+ const provider = (model as { provider?: string }).provider ?? "unknown";
141
+ return { ok: false, reason: `no API key for provider "${provider}"` };
142
+ }
143
+ return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
144
+ }
145
+
146
+ /**
147
+ * Fire-and-forget a background task.
148
+ *
149
+ * The work runs in a detached promise so the caller (an agent hook/tool)
150
+ * is never blocked. Errors are caught and surfaced via the UI (when
151
+ * available) instead of crashing the agent. Single-flight per label: if a
152
+ * task with the same label is already running, the new request is dropped
153
+ * and the existing promise is returned.
154
+ *
155
+ * The returned promise resolves when the work completes; hold onto it (or
156
+ * call awaitAll) to drain background work before compaction/exit.
157
+ */
158
+ launchTask(ctx: LaunchCtx, label: string, work: () => Promise<void>): Promise<void> {
159
+ const existing = this.inFlight.get(label);
160
+ if (existing) return existing;
161
+
162
+ // Capture ctx properties synchronously — after `await work()` the extension
163
+ // ctx may be stale (e.g. after newSession/fork/switchSession/reload), and
164
+ // accessing ctx.hasUI or ctx.ui on a stale proxy throws.
165
+ const hasUI = ctx.hasUI;
166
+ const ui = ctx.ui;
167
+
168
+ this.inFlightLabels.add(label);
169
+ // biome-ignore lint/style/useConst: referenced inside its own initializer (finally block)
170
+ let promise!: Promise<void>;
171
+ promise = (async () => {
172
+ try {
173
+ await work();
174
+ } catch (error) {
175
+ const msg = error instanceof Error ? error.message : String(error);
176
+ if (hasUI && ui) ui.notify(`LLM Wiki: ${label} failed: ${msg}`, "warning");
177
+ } finally {
178
+ this.inFlightLabels.delete(label);
179
+ if (this.inFlight.get(label) === promise) this.inFlight.delete(label);
180
+ }
181
+ })();
182
+ this.inFlight.set(label, promise);
183
+ return promise;
184
+ }
185
+
186
+ /**
187
+ * Report a completed background action to the user (issue #77).
188
+ *
189
+ * Every mutating wiki action runs off the agent's critical path; this is how
190
+ * the work becomes visible. Emits a `wiki-action-report` custom message,
191
+ * shown in the UI when notices are enabled (the `notices` config, default
192
+ * on) and otherwise injected silently. Delivered as `nextTurn` so it never
193
+ * interrupts or triggers a turn. Never throws — reporting must not crash the
194
+ * background task that called it.
195
+ */
196
+ report(summary: string, opts?: { display?: boolean }): void {
197
+ if (!this.pi || !summary) return;
198
+ const display = opts?.display ?? noticesEnabled(this.config);
199
+ try {
200
+ this.pi.sendMessage(
201
+ { customType: "wiki-action-report", content: summary, display },
202
+ { deliverAs: "nextTurn" },
203
+ );
204
+ } catch {
205
+ // Reporting is best-effort; a stale/torn-down session must not propagate.
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Run a mutating action in the background and report its result (issue #77).
211
+ *
212
+ * Thin wrapper over `launchTask`: `work` performs the off-thread mutation and
213
+ * returns a one-line human summary (or null to stay silent). On success the
214
+ * summary is surfaced via `report()`. Single-flight, error-isolated, and
215
+ * awaited-at-exit exactly like `launchTask`.
216
+ */
217
+ launchReported(ctx: LaunchCtx, label: string, work: () => Promise<string | null>): Promise<void> {
218
+ return this.launchTask(ctx, label, async () => {
219
+ const summary = await work();
220
+ if (summary) this.report(summary);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Await all in-flight background tasks. Call at compaction / session exit so
226
+ * background work is not lost. Never rejects — task errors are already
227
+ * isolated inside launchTask.
228
+ */
229
+ async awaitAll(): Promise<void> {
230
+ while (this.inFlight.size > 0) {
231
+ await Promise.allSettled([...this.inFlight.values()]);
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Register the shared background runtime and wire it into the extension
238
+ * lifecycle: config is loaded lazily per turn, and in-flight tasks are drained
239
+ * before compaction and on shutdown so background work is never lost.
240
+ *
241
+ * Returns the Runtime instance so concrete background workers (issues #65,
242
+ * #66) can launch tasks on it.
243
+ */
244
+ export function registerBackgroundRuntime(pi: ExtensionAPI): Runtime {
245
+ const runtime = new Runtime();
246
+ // Attach the API so background tasks can emit visible completion reports
247
+ // (issue #77). Done here (not in the constructor) to keep Runtime testable.
248
+ runtime.pi = pi;
249
+
250
+ pi.on("turn_start", (_event, ctx) => {
251
+ runtime.ensureConfig(ctx.cwd);
252
+ });
253
+
254
+ // Drain in-flight background work before the session is compacted or shut
255
+ // down, so nothing is lost mid-flight.
256
+ pi.on("session_before_compact", async () => {
257
+ await runtime.awaitAll();
258
+ });
259
+ pi.on("session_shutdown", async () => {
260
+ await runtime.awaitAll();
261
+ });
262
+
263
+ return runtime;
264
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ type AgentContext,
3
+ type AgentLoopConfig,
4
+ type AgentTool,
5
+ agentLoop,
6
+ } from "@mariozechner/pi-agent-core";
7
+ import type { Api, Message, Model } from "@mariozechner/pi-ai";
8
+
9
+ /**
10
+ * Thin sub-agent runner for the LLM Wiki background lane (issue #64, part of #63).
11
+ *
12
+ * Wraps `agentLoop` so background tasks (ingest synthesis, topic inference,
13
+ * etc.) can run a focused, single-purpose agent on a resolved model with its
14
+ * own system prompt and tools — mirroring pi-observational-memory's
15
+ * `runObserver`. The caller drives behavior entirely through `tools`
16
+ * (tool-side effects accumulate results); this wrapper just drives the loop to
17
+ * completion and drains its event stream.
18
+ *
19
+ * This is infrastructure: it makes no wiki-specific decisions. Concrete
20
+ * background workers (issues #65, #66) supply the prompts and tools.
21
+ */
22
+ export interface RunSubAgentArgs<TApi extends Api = Api> {
23
+ model: Model<TApi>;
24
+ apiKey: string;
25
+ headers?: Record<string, string>;
26
+ /** System prompt that defines the sub-agent's role. */
27
+ systemPrompt: string;
28
+ /** The user-turn instruction/payload to process. */
29
+ userPrompt: string;
30
+ /** Tools the sub-agent may call (side effects accumulate caller-side). */
31
+ tools: AgentTool[];
32
+ /** Max output tokens per model call. Default 4096. */
33
+ maxTokens?: number;
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ /**
38
+ * Run a sub-agent loop to completion.
39
+ *
40
+ * Returns nothing useful directly — by design, results are collected by the
41
+ * `tools` the caller passes (their `execute` accumulates into caller-owned
42
+ * state). This keeps the runner generic across every background task type.
43
+ */
44
+ export async function runSubAgent<TApi extends Api = Api>(
45
+ args: RunSubAgentArgs<TApi>,
46
+ ): Promise<void> {
47
+ const { model, apiKey, headers, systemPrompt, userPrompt, tools, maxTokens, signal } = args;
48
+
49
+ const text = userPrompt.trim();
50
+ if (!text) return;
51
+
52
+ const prompts: Message[] = [
53
+ {
54
+ role: "user",
55
+ content: [{ type: "text", text }],
56
+ timestamp: Date.now(),
57
+ },
58
+ ];
59
+
60
+ const context: AgentContext = {
61
+ systemPrompt,
62
+ messages: [],
63
+ tools,
64
+ };
65
+
66
+ const reasoning = (model as unknown as { reasoning?: unknown }).reasoning;
67
+ const config: AgentLoopConfig = {
68
+ model,
69
+ apiKey,
70
+ headers,
71
+ maxTokens: maxTokens ?? 4096,
72
+ convertToLlm: (msgs) => msgs as Message[],
73
+ toolExecution: "sequential",
74
+ ...(reasoning ? { reasoning: "high" as const } : {}),
75
+ };
76
+
77
+ const stream = agentLoop(prompts, context, config, signal);
78
+ for await (const _event of stream) {
79
+ // Drain events; tool `execute` callbacks collect results caller-side.
80
+ }
81
+ await stream.result();
82
+ }
@@ -0,0 +1,217 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ /**
6
+ * Configuration for the background-task lane (issue #64, part of #63).
7
+ *
8
+ * The wiki's intelligent work (ingest synthesis, embeddings, topic inference)
9
+ * can run off the main agent thread on a model of the user's choosing. This
10
+ * module resolves that configuration from pi's namespaced settings, mirroring
11
+ * the approach used by pi-observational-memory.
12
+ *
13
+ * Resolution order (later wins):
14
+ * 1. built-in DEFAULTS
15
+ * 2. global settings: <agentDir>/settings.json → { "llm-wiki": { ... } }
16
+ * 3. project settings: <cwd>/.pi/settings.json → { "llm-wiki": { ... } }
17
+ *
18
+ * When `taskModel` is unset, the background lane falls back to the session
19
+ * model (see Runtime.resolveModel), so the feature is zero-config by default.
20
+ */
21
+ export interface TaskConfig {
22
+ /**
23
+ * Model used for background wiki tasks. When undefined, the session model
24
+ * is used. The surface for setting this (config field, /command, per-call
25
+ * override) is built in issue #69; this module only reads it.
26
+ */
27
+ taskModel?: { provider: string; id: string };
28
+
29
+ /**
30
+ * Embedding provider for background write-time embeddings (issue #66).
31
+ * Only "openai" / "openai-compatible" are supported. When undefined,
32
+ * embeddings are disabled entirely (silent no-op) — this is the default,
33
+ * so the feature is strictly opt-in.
34
+ */
35
+ embeddingProvider?: string;
36
+ /** Embedding model id (default: text-embedding-3-small). */
37
+ embeddingModel?: string;
38
+ /** OpenAI-compatible base URL (default: https://api.openai.com or OPENAI_BASE_URL). */
39
+ embeddingBaseUrl?: string;
40
+ /**
41
+ * Embedding API key. Prefer `embeddingApiKeyEnv` to avoid storing secrets in
42
+ * settings files; this direct field exists for parity but is discouraged.
43
+ */
44
+ embeddingApiKey?: string;
45
+ /** Env var name to read the embedding API key from (default: OPENAI_API_KEY). */
46
+ embeddingApiKeyEnv?: string;
47
+
48
+ /**
49
+ * Weight of the semantic (cosine) signal when blending with lexical score in
50
+ * hybrid recall (issue #67). 0 = pure lexical, 1 = pure semantic boost.
51
+ * Default 0.5. Only takes effect when embeddings exist AND an embedder is
52
+ * configured; otherwise recall stays 100% lexical.
53
+ */
54
+ semanticWeight?: number;
55
+
56
+ /**
57
+ * Two-stage recall gate (issue #68). When the vault's registered page count
58
+ * is STRICTLY GREATER THAN this threshold, recall switches to "links-first"
59
+ * mode: it returns a ranked list of links (id, title, type, score, 1-line
60
+ * snippet) instead of inline content previews, and the agent expands chosen
61
+ * links on demand via `read`. At or below the threshold, the current
62
+ * preview-inline behavior is preserved (no regression for small vaults).
63
+ *
64
+ * Page-count (not token-budget) was chosen deliberately: it is derived from
65
+ * `meta/registry.json` in O(1) with zero extra file I/O, so the gate itself
66
+ * never reads page bodies — token estimation would require touching every
67
+ * page, defeating the "cheap recall" goal. Default 50. Set to 0 to force
68
+ * links-first for any non-empty vault, or a very large number to always keep
69
+ * previews inline. Clamped to a non-negative integer.
70
+ */
71
+ recallLinksThreshold?: number;
72
+
73
+ /**
74
+ * Surface wiki activity in the UI (issue #77). When enabled (the default),
75
+ * the status line reflects recall hits and the periodic observe/retro
76
+ * reminder is shown to the user (`display: true`) instead of being injected
77
+ * silently. Set to `false` to restore the previous quiet behavior — a static
78
+ * status line and a hidden (`display: false`) reminder — for users who do
79
+ * not want any chat-level wiki notices.
80
+ */
81
+ notices?: boolean;
82
+ }
83
+
84
+ export const TASK_DEFAULTS: TaskConfig = {};
85
+
86
+ /**
87
+ * Resolve whether user-facing wiki notices are enabled (issue #77). Defaults
88
+ * to `true`; only an explicit `notices: false` disables them.
89
+ */
90
+ export function noticesEnabled(config: TaskConfig | undefined): boolean {
91
+ return config?.notices !== false;
92
+ }
93
+
94
+ const SETTINGS_KEY = "llm-wiki";
95
+
96
+ function readModelSpec(value: unknown): { provider: string; id: string } | undefined {
97
+ if (!value || typeof value !== "object") return undefined;
98
+ const v = value as Record<string, unknown>;
99
+ if (typeof v.provider === "string" && typeof v.id === "string" && v.provider && v.id) {
100
+ return { provider: v.provider, id: v.id };
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function readNamespacedConfig(path: string): Partial<TaskConfig> {
106
+ if (!existsSync(path)) return {};
107
+ try {
108
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
109
+ const nested = raw[SETTINGS_KEY];
110
+ if (!nested || typeof nested !== "object") return {};
111
+ const section = nested as Record<string, unknown>;
112
+ const out: Partial<TaskConfig> = {};
113
+ const taskModel = readModelSpec(section.taskModel);
114
+ if (taskModel) out.taskModel = taskModel;
115
+
116
+ for (const key of [
117
+ "embeddingProvider",
118
+ "embeddingModel",
119
+ "embeddingBaseUrl",
120
+ "embeddingApiKey",
121
+ "embeddingApiKeyEnv",
122
+ ] as const) {
123
+ const value = section[key];
124
+ if (typeof value === "string" && value.trim()) out[key] = value.trim();
125
+ }
126
+
127
+ const weight = section.semanticWeight;
128
+ if (typeof weight === "number" && Number.isFinite(weight)) {
129
+ out.semanticWeight = Math.min(1, Math.max(0, weight));
130
+ }
131
+
132
+ const threshold = section.recallLinksThreshold;
133
+ if (typeof threshold === "number" && Number.isFinite(threshold)) {
134
+ out.recallLinksThreshold = Math.max(0, Math.floor(threshold));
135
+ }
136
+
137
+ if (typeof section.notices === "boolean") {
138
+ out.notices = section.notices;
139
+ }
140
+ return out;
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Parse a `"provider/id"` model reference (issue #69). Splits on the FIRST
148
+ * slash so model ids that themselves contain slashes (e.g.
149
+ * `openrouter/meta/llama-3`) are preserved. Returns `undefined` for empty,
150
+ * slashless, or partial (`provider/` / `/id`) refs so callers can reject bad
151
+ * input. Whitespace is trimmed.
152
+ */
153
+ export function parseModelRef(ref: string): { provider: string; id: string } | undefined {
154
+ const trimmed = ref.trim();
155
+ const slash = trimmed.indexOf("/");
156
+ if (slash <= 0) return undefined;
157
+ const provider = trimmed.slice(0, slash).trim();
158
+ const id = trimmed.slice(slash + 1).trim();
159
+ if (!provider || !id) return undefined;
160
+ return { provider, id };
161
+ }
162
+
163
+ /**
164
+ * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
165
+ * file `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue
166
+ * #69). Project settings win over global in `loadTaskConfig`, so this takes
167
+ * effect immediately on the next config load. Other top-level keys and other
168
+ * `llm-wiki` settings are preserved; passing `undefined` removes the key
169
+ * (reverting to the session model).
170
+ */
171
+ export function persistTaskModel(
172
+ cwd: string,
173
+ model: { provider: string; id: string } | undefined,
174
+ ): void {
175
+ const settingsPath = join(cwd, ".pi", "settings.json");
176
+ let raw: Record<string, unknown> = {};
177
+ if (existsSync(settingsPath)) {
178
+ try {
179
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
180
+ if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
181
+ } catch {
182
+ // Corrupt settings file: start from an empty object rather than throw.
183
+ raw = {};
184
+ }
185
+ }
186
+
187
+ const existing = raw[SETTINGS_KEY];
188
+ const section: Record<string, unknown> =
189
+ existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
190
+
191
+ if (model) {
192
+ section.taskModel = { provider: model.provider, id: model.id };
193
+ } else {
194
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
195
+ delete section.taskModel;
196
+ }
197
+ raw[SETTINGS_KEY] = section;
198
+
199
+ mkdirSync(dirname(settingsPath), { recursive: true });
200
+ writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
201
+ }
202
+
203
+ export function loadTaskConfig(cwd: string): TaskConfig {
204
+ let globalPath: string;
205
+ try {
206
+ globalPath = join(getAgentDir(), "settings.json");
207
+ } catch {
208
+ globalPath = "";
209
+ }
210
+ const projectPath = join(cwd, ".pi", "settings.json");
211
+
212
+ return {
213
+ ...TASK_DEFAULTS,
214
+ ...(globalPath ? readNamespacedConfig(globalPath) : {}),
215
+ ...readNamespacedConfig(projectPath),
216
+ };
217
+ }