@zosmaai/pi-llm-wiki 0.8.2 → 0.9.0
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.
- package/CHANGELOG.md +35 -0
- package/extensions/llm-wiki/index.ts +49 -10
- package/extensions/llm-wiki/lib/embeddings.ts +420 -0
- package/extensions/llm-wiki/lib/guardrails.ts +15 -4
- package/extensions/llm-wiki/lib/indexing.ts +88 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +281 -0
- package/extensions/llm-wiki/lib/model-command.ts +128 -0
- package/extensions/llm-wiki/lib/observation.ts +34 -11
- package/extensions/llm-wiki/lib/recall.ts +331 -10
- package/extensions/llm-wiki/lib/retro.ts +13 -4
- package/extensions/llm-wiki/lib/runtime.ts +216 -0
- package/extensions/llm-wiki/lib/subagent.ts +82 -0
- package/extensions/llm-wiki/lib/task-config.ts +195 -0
- package/extensions/llm-wiki/lib/tools.ts +178 -8
- package/package.json +1 -1
- package/prompts/wiki-ingest.md +7 -4
- package/skills/llm-wiki/SKILL.md +30 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig } 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
|
+
/** Labels of tasks currently in flight (single-flight guard per label). */
|
|
57
|
+
private inFlightLabels = new Set<string>();
|
|
58
|
+
/** All in-flight task promises, keyed for await-at-exit and dedupe. */
|
|
59
|
+
private inFlight = new Map<string, Promise<void>>();
|
|
60
|
+
/** Whether we've already surfaced a model-resolution failure (avoid spam). */
|
|
61
|
+
resolveFailureNotified = false;
|
|
62
|
+
|
|
63
|
+
ensureConfig(cwd: string): void {
|
|
64
|
+
if (this.configLoaded) return;
|
|
65
|
+
this.config = loadTaskConfig(cwd);
|
|
66
|
+
this.configLoaded = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True if a task with this label is currently running. */
|
|
70
|
+
isInFlight(label: string): boolean {
|
|
71
|
+
return this.inFlightLabels.has(label);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Number of background tasks currently running. */
|
|
75
|
+
get pendingCount(): number {
|
|
76
|
+
return this.inFlight.size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the model + auth for background work.
|
|
81
|
+
*
|
|
82
|
+
* Precedence (issue #69): per-call `override` → configured `taskModel` →
|
|
83
|
+
* session model. Each configured layer is applied only when the model is
|
|
84
|
+
* found in the registry; a missing layer warns (when UI is available) and
|
|
85
|
+
* falls through to the next. Returns { ok: false } when nothing resolves or
|
|
86
|
+
* no API key exists, so callers can fall back to the synchronous
|
|
87
|
+
* main-agent path.
|
|
88
|
+
*/
|
|
89
|
+
async resolveModel(
|
|
90
|
+
ctx: ResolveCtx,
|
|
91
|
+
override?: { provider: string; id: string },
|
|
92
|
+
): Promise<ResolveResult> {
|
|
93
|
+
let model = ctx.model;
|
|
94
|
+
|
|
95
|
+
// Configured taskModel layer (beats the session model).
|
|
96
|
+
const configured = this.config.taskModel;
|
|
97
|
+
if (configured) {
|
|
98
|
+
const found = ctx.modelRegistry.find(configured.provider, configured.id);
|
|
99
|
+
if (found) {
|
|
100
|
+
model = found;
|
|
101
|
+
} else if (ctx.hasUI && ctx.ui) {
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
`LLM Wiki: configured task model ${configured.provider}/${configured.id} not found, using session model`,
|
|
104
|
+
"warning",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Per-call override layer (beats both config and session).
|
|
110
|
+
if (override) {
|
|
111
|
+
const found = ctx.modelRegistry.find(override.provider, override.id);
|
|
112
|
+
if (found) {
|
|
113
|
+
model = found;
|
|
114
|
+
} else if (ctx.hasUI && ctx.ui) {
|
|
115
|
+
ctx.ui.notify(
|
|
116
|
+
`LLM Wiki: model override ${override.provider}/${override.id} not found, using ${
|
|
117
|
+
configured ? "configured/session" : "session"
|
|
118
|
+
} model`,
|
|
119
|
+
"warning",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!model) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: "no model available (session has no model and no taskModel configured)",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
132
|
+
if (!auth.ok || !auth.apiKey) {
|
|
133
|
+
const provider = (model as { provider?: string }).provider ?? "unknown";
|
|
134
|
+
return { ok: false, reason: `no API key for provider "${provider}"` };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fire-and-forget a background task.
|
|
141
|
+
*
|
|
142
|
+
* The work runs in a detached promise so the caller (an agent hook/tool)
|
|
143
|
+
* is never blocked. Errors are caught and surfaced via the UI (when
|
|
144
|
+
* available) instead of crashing the agent. Single-flight per label: if a
|
|
145
|
+
* task with the same label is already running, the new request is dropped
|
|
146
|
+
* and the existing promise is returned.
|
|
147
|
+
*
|
|
148
|
+
* The returned promise resolves when the work completes; hold onto it (or
|
|
149
|
+
* call awaitAll) to drain background work before compaction/exit.
|
|
150
|
+
*/
|
|
151
|
+
launchTask(ctx: LaunchCtx, label: string, work: () => Promise<void>): Promise<void> {
|
|
152
|
+
const existing = this.inFlight.get(label);
|
|
153
|
+
if (existing) return existing;
|
|
154
|
+
|
|
155
|
+
// Capture ctx properties synchronously — after `await work()` the extension
|
|
156
|
+
// ctx may be stale (e.g. after newSession/fork/switchSession/reload), and
|
|
157
|
+
// accessing ctx.hasUI or ctx.ui on a stale proxy throws.
|
|
158
|
+
const hasUI = ctx.hasUI;
|
|
159
|
+
const ui = ctx.ui;
|
|
160
|
+
|
|
161
|
+
this.inFlightLabels.add(label);
|
|
162
|
+
// biome-ignore lint/style/useConst: referenced inside its own initializer (finally block)
|
|
163
|
+
let promise!: Promise<void>;
|
|
164
|
+
promise = (async () => {
|
|
165
|
+
try {
|
|
166
|
+
await work();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
169
|
+
if (hasUI && ui) ui.notify(`LLM Wiki: ${label} failed: ${msg}`, "warning");
|
|
170
|
+
} finally {
|
|
171
|
+
this.inFlightLabels.delete(label);
|
|
172
|
+
if (this.inFlight.get(label) === promise) this.inFlight.delete(label);
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
this.inFlight.set(label, promise);
|
|
176
|
+
return promise;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Await all in-flight background tasks. Call at compaction / session exit so
|
|
181
|
+
* background work is not lost. Never rejects — task errors are already
|
|
182
|
+
* isolated inside launchTask.
|
|
183
|
+
*/
|
|
184
|
+
async awaitAll(): Promise<void> {
|
|
185
|
+
while (this.inFlight.size > 0) {
|
|
186
|
+
await Promise.allSettled([...this.inFlight.values()]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register the shared background runtime and wire it into the extension
|
|
193
|
+
* lifecycle: config is loaded lazily per turn, and in-flight tasks are drained
|
|
194
|
+
* before compaction and on shutdown so background work is never lost.
|
|
195
|
+
*
|
|
196
|
+
* Returns the Runtime instance so concrete background workers (issues #65,
|
|
197
|
+
* #66) can launch tasks on it.
|
|
198
|
+
*/
|
|
199
|
+
export function registerBackgroundRuntime(pi: ExtensionAPI): Runtime {
|
|
200
|
+
const runtime = new Runtime();
|
|
201
|
+
|
|
202
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
203
|
+
runtime.ensureConfig(ctx.cwd);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Drain in-flight background work before the session is compacted or shut
|
|
207
|
+
// down, so nothing is lost mid-flight.
|
|
208
|
+
pi.on("session_before_compact", async () => {
|
|
209
|
+
await runtime.awaitAll();
|
|
210
|
+
});
|
|
211
|
+
pi.on("session_shutdown", async () => {
|
|
212
|
+
await runtime.awaitAll();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return runtime;
|
|
216
|
+
}
|
|
@@ -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,195 @@
|
|
|
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
|
+
export const TASK_DEFAULTS: TaskConfig = {};
|
|
75
|
+
|
|
76
|
+
const SETTINGS_KEY = "llm-wiki";
|
|
77
|
+
|
|
78
|
+
function readModelSpec(value: unknown): { provider: string; id: string } | undefined {
|
|
79
|
+
if (!value || typeof value !== "object") return undefined;
|
|
80
|
+
const v = value as Record<string, unknown>;
|
|
81
|
+
if (typeof v.provider === "string" && typeof v.id === "string" && v.provider && v.id) {
|
|
82
|
+
return { provider: v.provider, id: v.id };
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readNamespacedConfig(path: string): Partial<TaskConfig> {
|
|
88
|
+
if (!existsSync(path)) return {};
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
|
91
|
+
const nested = raw[SETTINGS_KEY];
|
|
92
|
+
if (!nested || typeof nested !== "object") return {};
|
|
93
|
+
const section = nested as Record<string, unknown>;
|
|
94
|
+
const out: Partial<TaskConfig> = {};
|
|
95
|
+
const taskModel = readModelSpec(section.taskModel);
|
|
96
|
+
if (taskModel) out.taskModel = taskModel;
|
|
97
|
+
|
|
98
|
+
for (const key of [
|
|
99
|
+
"embeddingProvider",
|
|
100
|
+
"embeddingModel",
|
|
101
|
+
"embeddingBaseUrl",
|
|
102
|
+
"embeddingApiKey",
|
|
103
|
+
"embeddingApiKeyEnv",
|
|
104
|
+
] as const) {
|
|
105
|
+
const value = section[key];
|
|
106
|
+
if (typeof value === "string" && value.trim()) out[key] = value.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const weight = section.semanticWeight;
|
|
110
|
+
if (typeof weight === "number" && Number.isFinite(weight)) {
|
|
111
|
+
out.semanticWeight = Math.min(1, Math.max(0, weight));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const threshold = section.recallLinksThreshold;
|
|
115
|
+
if (typeof threshold === "number" && Number.isFinite(threshold)) {
|
|
116
|
+
out.recallLinksThreshold = Math.max(0, Math.floor(threshold));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
} catch {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse a `"provider/id"` model reference (issue #69). Splits on the FIRST
|
|
126
|
+
* slash so model ids that themselves contain slashes (e.g.
|
|
127
|
+
* `openrouter/meta/llama-3`) are preserved. Returns `undefined` for empty,
|
|
128
|
+
* slashless, or partial (`provider/` / `/id`) refs so callers can reject bad
|
|
129
|
+
* input. Whitespace is trimmed.
|
|
130
|
+
*/
|
|
131
|
+
export function parseModelRef(ref: string): { provider: string; id: string } | undefined {
|
|
132
|
+
const trimmed = ref.trim();
|
|
133
|
+
const slash = trimmed.indexOf("/");
|
|
134
|
+
if (slash <= 0) return undefined;
|
|
135
|
+
const provider = trimmed.slice(0, slash).trim();
|
|
136
|
+
const id = trimmed.slice(slash + 1).trim();
|
|
137
|
+
if (!provider || !id) return undefined;
|
|
138
|
+
return { provider, id };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Persist (or clear) the wiki background `taskModel` in the PROJECT settings
|
|
143
|
+
* file `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue
|
|
144
|
+
* #69). Project settings win over global in `loadTaskConfig`, so this takes
|
|
145
|
+
* effect immediately on the next config load. Other top-level keys and other
|
|
146
|
+
* `llm-wiki` settings are preserved; passing `undefined` removes the key
|
|
147
|
+
* (reverting to the session model).
|
|
148
|
+
*/
|
|
149
|
+
export function persistTaskModel(
|
|
150
|
+
cwd: string,
|
|
151
|
+
model: { provider: string; id: string } | undefined,
|
|
152
|
+
): void {
|
|
153
|
+
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
154
|
+
let raw: Record<string, unknown> = {};
|
|
155
|
+
if (existsSync(settingsPath)) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
158
|
+
if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
|
|
159
|
+
} catch {
|
|
160
|
+
// Corrupt settings file: start from an empty object rather than throw.
|
|
161
|
+
raw = {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const existing = raw[SETTINGS_KEY];
|
|
166
|
+
const section: Record<string, unknown> =
|
|
167
|
+
existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
|
|
168
|
+
|
|
169
|
+
if (model) {
|
|
170
|
+
section.taskModel = { provider: model.provider, id: model.id };
|
|
171
|
+
} else {
|
|
172
|
+
// biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
|
|
173
|
+
delete section.taskModel;
|
|
174
|
+
}
|
|
175
|
+
raw[SETTINGS_KEY] = section;
|
|
176
|
+
|
|
177
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
178
|
+
writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function loadTaskConfig(cwd: string): TaskConfig {
|
|
182
|
+
let globalPath: string;
|
|
183
|
+
try {
|
|
184
|
+
globalPath = join(getAgentDir(), "settings.json");
|
|
185
|
+
} catch {
|
|
186
|
+
globalPath = "";
|
|
187
|
+
}
|
|
188
|
+
const projectPath = join(cwd, ".pi", "settings.json");
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...TASK_DEFAULTS,
|
|
192
|
+
...(globalPath ? readNamespacedConfig(globalPath) : {}),
|
|
193
|
+
...readNamespacedConfig(projectPath),
|
|
194
|
+
};
|
|
195
|
+
}
|