@zosmaai/pi-llm-wiki 0.11.3 → 0.11.5
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 +10 -0
- package/README.de.md +8 -0
- package/README.es.md +8 -0
- package/README.fr.md +8 -0
- package/README.hi.md +8 -0
- package/README.ja.md +8 -0
- package/README.ko.md +8 -0
- package/README.md +88 -2
- package/README.pt.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/assets/wiki-dashboard.png +0 -0
- package/commands/wiki-digest.md +28 -0
- package/commands/wiki-discover.md +30 -0
- package/commands/wiki-ingest.md +37 -0
- package/commands/wiki-init.md +30 -0
- package/commands/wiki-lint.md +25 -0
- package/commands/wiki-query.md +37 -0
- package/commands/wiki-record.md +36 -0
- package/commands/wiki-req.md +56 -0
- package/commands/wiki-retro.md +35 -0
- package/commands/wiki-run.md +31 -0
- package/commands/wiki-skills.md +26 -0
- package/commands/wiki-status.md +16 -0
- package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
- package/dist/extensions/llm-wiki/lib/host.js +117 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
- package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
- package/dist/extensions/llm-wiki/lib/observation.js +31 -3
- package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +145 -43
- package/dist/extensions/llm-wiki/lib/utils.js +59 -16
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +62 -11
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
- package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
- package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
- package/extensions/llm-wiki/index.ts +48 -6
- package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
- package/extensions/llm-wiki/lib/dashboard.ts +210 -0
- package/extensions/llm-wiki/lib/guardrails.ts +26 -1
- package/extensions/llm-wiki/lib/host.ts +145 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
- package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
- package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
- package/extensions/llm-wiki/lib/metadata.ts +1 -1
- package/extensions/llm-wiki/lib/observation.ts +37 -4
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +208 -46
- package/extensions/llm-wiki/lib/utils.ts +55 -14
- package/package.json +15 -4
- package/prompts/wiki-ingest.md +1 -0
- package/prompts/wiki-req.md +1 -0
- package/prompts/wiki-retro.md +1 -0
- package/skills/llm-wiki/SKILL.md +11 -1
|
@@ -33,13 +33,17 @@ function diag(
|
|
|
33
33
|
return { severity, code, path, message };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function normalizeWikilinkTarget(target: string): string {
|
|
37
|
+
return target.trim().replace(/\\$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
36
40
|
export function extractKnowledgeLinks(body: string): KnowledgeLinks {
|
|
37
41
|
const markdown: ExtractedLink[] = [];
|
|
38
42
|
const wikilinks: ExtractedLink[] = [];
|
|
39
43
|
|
|
40
|
-
// Extract legacy wikilinks
|
|
44
|
+
// Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
|
|
41
45
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
42
|
-
wikilinks.push({ target: match[1]
|
|
46
|
+
wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
// Parse with CommonMark AST
|
|
@@ -111,7 +115,7 @@ export function extractKnowledgeLinks(body: string): KnowledgeLinks {
|
|
|
111
115
|
export function extractLegacyWikilinks(body: string): ExtractedLink[] {
|
|
112
116
|
const links: ExtractedLink[] = [];
|
|
113
117
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
114
|
-
links.push({ target: match[1]
|
|
118
|
+
links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
115
119
|
}
|
|
116
120
|
return links;
|
|
117
121
|
}
|
|
@@ -508,7 +508,7 @@ export function buildDirectoryIndexes(
|
|
|
508
508
|
lines.push("## Directories");
|
|
509
509
|
lines.push("");
|
|
510
510
|
for (const subDir of [...dirs].sort(compareCodePoint)) {
|
|
511
|
-
const encoded = `${
|
|
511
|
+
const encoded = encodeRelativePath(`${subDir}/index.md`);
|
|
512
512
|
lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
|
|
513
513
|
}
|
|
514
514
|
}
|
|
@@ -322,11 +322,20 @@ export function buildReminderText(): string {
|
|
|
322
322
|
* `options.display` (issue #77) controls whether the reminder is shown to the
|
|
323
323
|
* user (`true`, the default) or injected silently into model context only
|
|
324
324
|
* (`false`). Pass a resolver so the live `notices` config is read at send time.
|
|
325
|
+
*
|
|
326
|
+
* `options.enabled` gates the reminder entirely — note that `display: false`
|
|
327
|
+
* still injects it into model context, so it is NOT a way to switch the
|
|
328
|
+
* reminder off. Callers pass a resolver that answers "does a wiki apply to the
|
|
329
|
+
* current working directory", evaluated per turn because the session can move.
|
|
325
330
|
*/
|
|
326
331
|
export function registerObservationReminder(
|
|
327
332
|
pi: ExtensionAPI,
|
|
328
333
|
reminderState: ReminderState,
|
|
329
|
-
options?: {
|
|
334
|
+
options?: {
|
|
335
|
+
turnsBetweenReminders?: number;
|
|
336
|
+
display?: boolean | (() => boolean);
|
|
337
|
+
enabled?: () => boolean;
|
|
338
|
+
},
|
|
330
339
|
): void {
|
|
331
340
|
const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
|
|
332
341
|
const resolveDisplay = (): boolean => {
|
|
@@ -336,10 +345,12 @@ export function registerObservationReminder(
|
|
|
336
345
|
return true;
|
|
337
346
|
};
|
|
338
347
|
let turnsSinceLastReminder = 0;
|
|
348
|
+
let agentEndsInUserTurn = 0;
|
|
339
349
|
|
|
340
350
|
pi.on("session_start", async () => {
|
|
341
351
|
turnsSinceLastReminder = 0;
|
|
342
352
|
reminderState.observeDoneThisSession = false;
|
|
353
|
+
agentEndsInUserTurn = 0;
|
|
343
354
|
});
|
|
344
355
|
|
|
345
356
|
// After compaction, reset turn counter so reminders resume
|
|
@@ -349,16 +360,34 @@ export function registerObservationReminder(
|
|
|
349
360
|
turnsSinceLastReminder = 0;
|
|
350
361
|
});
|
|
351
362
|
|
|
363
|
+
// A retry re-runs the agent within the same user turn, and pi does not
|
|
364
|
+
// forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
|
|
365
|
+
// user-role message_start separates real user turns from retried agent
|
|
366
|
+
// runs, so the per-turn agent_end count resets there.
|
|
367
|
+
pi.on("message_start", async (event) => {
|
|
368
|
+
if (event.message.role === "user") agentEndsInUserTurn = 0;
|
|
369
|
+
});
|
|
370
|
+
|
|
352
371
|
pi.on("agent_end", async (event, _ctx) => {
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
372
|
+
// Legacy guard: this pi build does not forward `willRetry` to extension
|
|
373
|
+
// events, but keep the check in case a future one does. The dedup that
|
|
374
|
+
// actually works is the per-turn agent_end count below.
|
|
356
375
|
if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
|
|
357
376
|
|
|
377
|
+
// No wiki applies here: never nag, and never accumulate a pending reminder
|
|
378
|
+
// that would fire the moment the session moves into a wiki-bearing project.
|
|
379
|
+
if (options?.enabled && !options.enabled()) return;
|
|
380
|
+
|
|
358
381
|
turnsSinceLastReminder++;
|
|
359
382
|
if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
|
|
360
383
|
if (reminderState.observeDoneThisSession) return;
|
|
361
384
|
|
|
385
|
+
// One agent_end per user turn may queue a reminder. A rate-limit storm
|
|
386
|
+
// re-runs the agent several times within the same turn, each firing
|
|
387
|
+
// agent_end, so count them and let only the first through.
|
|
388
|
+
agentEndsInUserTurn++;
|
|
389
|
+
if (agentEndsInUserTurn > 1) return;
|
|
390
|
+
|
|
362
391
|
pi.sendMessage(
|
|
363
392
|
{
|
|
364
393
|
customType: "wiki-observe-reminder",
|
|
@@ -369,5 +398,9 @@ export function registerObservationReminder(
|
|
|
369
398
|
deliverAs: "nextTurn",
|
|
370
399
|
},
|
|
371
400
|
);
|
|
401
|
+
|
|
402
|
+
// Reset the interval counter after queueing: without it the count stays
|
|
403
|
+
// at/above the threshold and every later agent_end queues a reminder.
|
|
404
|
+
turnsSinceLastReminder = 0;
|
|
372
405
|
});
|
|
373
406
|
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
Input,
|
|
6
|
+
type SettingItem,
|
|
7
|
+
SettingsList,
|
|
8
|
+
type SettingsListTheme,
|
|
9
|
+
Spacer,
|
|
10
|
+
Text,
|
|
11
|
+
} from "@mariozechner/pi-tui";
|
|
12
|
+
import type { Runtime } from "./runtime.js";
|
|
13
|
+
import {
|
|
14
|
+
type SettingScope,
|
|
15
|
+
loadTaskConfig,
|
|
16
|
+
loadTaskConfigSources,
|
|
17
|
+
parseModelRef,
|
|
18
|
+
persistSetting,
|
|
19
|
+
trajectoriesEnabled,
|
|
20
|
+
} from "./task-config.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Settings TUI for LLM Wiki.
|
|
24
|
+
*
|
|
25
|
+
* /wiki-settings renders the whole screen as ONE persistent pi-tui
|
|
26
|
+
* SettingsList (the same component pi's own /settings uses), shown through
|
|
27
|
+
* ui.custom():
|
|
28
|
+
*
|
|
29
|
+
* - booleans cycle OFF/ON in place (Enter/Space) — no screen reset,
|
|
30
|
+
* the cursor stays where it is
|
|
31
|
+
* - strings/numbers/model open an inline input submenu; closing it
|
|
32
|
+
* restores the cursor to the same item
|
|
33
|
+
* - every change persists immediately to the chosen scope
|
|
34
|
+
* (project or global) and the display normalizes in place
|
|
35
|
+
*
|
|
36
|
+
* Flow:
|
|
37
|
+
* 1. cwd inside ~/ → global scope; outside ~/ → scope picker first
|
|
38
|
+
* 2. persistent settings screen (Esc closes)
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface Ui {
|
|
42
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
43
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
44
|
+
/** Hosts that can render a focused custom component (pi ≥ 0.70 interactive). */
|
|
45
|
+
custom?(
|
|
46
|
+
factory: (
|
|
47
|
+
tui: unknown,
|
|
48
|
+
theme: Theme,
|
|
49
|
+
keybindings: unknown,
|
|
50
|
+
done: (result?: unknown) => void,
|
|
51
|
+
) => unknown,
|
|
52
|
+
): Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Setting definition — drives both the menu and its edit menu. */
|
|
56
|
+
interface SettingDef {
|
|
57
|
+
key: string;
|
|
58
|
+
label: string;
|
|
59
|
+
type: "boolean" | "number" | "string" | "model";
|
|
60
|
+
/** Shown with the item when it is selected. */
|
|
61
|
+
hint: string;
|
|
62
|
+
/** Edit-menu title override (defaults to `Set <label>`). */
|
|
63
|
+
menuLabel?: string;
|
|
64
|
+
/** Raw text to prefill the edit input with when the value is unset. */
|
|
65
|
+
defaultText?: string;
|
|
66
|
+
/** Pretty-print a value for display. */
|
|
67
|
+
format: (v: unknown) => string;
|
|
68
|
+
/** Raw text used to prefill the edit input. */
|
|
69
|
+
toEdit: (v: unknown) => string;
|
|
70
|
+
/** Parse user input into a typed value. undefined = invalid. */
|
|
71
|
+
parse: (input: string) => unknown | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SETTINGS: SettingDef[] = [
|
|
75
|
+
{
|
|
76
|
+
key: "taskModel",
|
|
77
|
+
label: "Model",
|
|
78
|
+
type: "model",
|
|
79
|
+
hint: "Model for background wiki tasks (provider/id).",
|
|
80
|
+
menuLabel: "Task model — provider/id, empty = session model",
|
|
81
|
+
format: (v) => {
|
|
82
|
+
const m = v as { provider: string; id: string } | undefined;
|
|
83
|
+
return m ? `${m.provider}/${m.id}` : "(session model)";
|
|
84
|
+
},
|
|
85
|
+
toEdit: (v) => {
|
|
86
|
+
const m = v as { provider: string; id: string } | undefined;
|
|
87
|
+
return m ? `${m.provider}/${m.id}` : "";
|
|
88
|
+
},
|
|
89
|
+
parse: (input) => parseModelRef(input),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "synthesisMaxTokens",
|
|
93
|
+
label: "Synthesis Tokens",
|
|
94
|
+
type: "number",
|
|
95
|
+
hint: "Max output tokens for wiki synthesis runs.",
|
|
96
|
+
defaultText: "16384",
|
|
97
|
+
format: (v) => (v != null ? String(v) : "16384 (default)"),
|
|
98
|
+
toEdit: (v) => (v != null ? String(v) : "16384"),
|
|
99
|
+
parse: (input) => {
|
|
100
|
+
const n = Number(input);
|
|
101
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
102
|
+
return Math.floor(n);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "trajectories",
|
|
107
|
+
label: "Trajectories",
|
|
108
|
+
type: "boolean",
|
|
109
|
+
hint: "Capture agent trajectories into the vault.",
|
|
110
|
+
format: (v) => (v ? "ON" : "OFF"),
|
|
111
|
+
toEdit: () => "",
|
|
112
|
+
parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: "notices",
|
|
116
|
+
label: "Notices",
|
|
117
|
+
type: "boolean",
|
|
118
|
+
hint: "Show wiki recall/observation notice lines in chat.",
|
|
119
|
+
format: (v) => (v != null ? (v ? "ON" : "OFF") : "ON (default)"),
|
|
120
|
+
toEdit: () => "",
|
|
121
|
+
parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "ambientPersonalVault",
|
|
125
|
+
label: "Ambient Personal",
|
|
126
|
+
type: "boolean",
|
|
127
|
+
hint: "Include the personal vault in ambient session context.",
|
|
128
|
+
format: (v) => (v != null ? (v ? "ON" : "OFF") : "host-dependent"),
|
|
129
|
+
toEdit: () => "",
|
|
130
|
+
parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "synthesisLanguage",
|
|
134
|
+
label: "Synthesis Language",
|
|
135
|
+
type: "string",
|
|
136
|
+
hint: "Language for synthesized wiki content.",
|
|
137
|
+
defaultText: "en",
|
|
138
|
+
format: (v) => (v ? String(v) : "en (default)"),
|
|
139
|
+
toEdit: (v) => (v ? String(v) : "en"),
|
|
140
|
+
parse: (input) => {
|
|
141
|
+
const trimmed = input.trim();
|
|
142
|
+
return trimmed || undefined;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: "semanticWeight",
|
|
147
|
+
label: "Semantic Weight",
|
|
148
|
+
type: "number",
|
|
149
|
+
hint: "0–1 weight for embedding (semantic) recall.",
|
|
150
|
+
defaultText: "0.5",
|
|
151
|
+
format: (v) => (v != null ? String(v) : "0.5 (default)"),
|
|
152
|
+
toEdit: (v) => (v != null ? String(v) : "0.5"),
|
|
153
|
+
parse: (input) => {
|
|
154
|
+
const n = Number(input);
|
|
155
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) return undefined;
|
|
156
|
+
return n;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
key: "recallLinksThreshold",
|
|
161
|
+
label: "Recall Links",
|
|
162
|
+
type: "number",
|
|
163
|
+
hint: "Max recall links shown when the vault is large.",
|
|
164
|
+
defaultText: "50",
|
|
165
|
+
format: (v) => (v != null ? String(v) : "50 (default)"),
|
|
166
|
+
toEdit: (v) => (v != null ? String(v) : "50"),
|
|
167
|
+
parse: (input) => {
|
|
168
|
+
const n = Number(input);
|
|
169
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
170
|
+
return Math.floor(n);
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
key: "recallSkillInlineMax",
|
|
175
|
+
label: "Skill Inline Max",
|
|
176
|
+
type: "number",
|
|
177
|
+
hint: "Max characters inlined from recall into skill prompts.",
|
|
178
|
+
defaultText: "1600",
|
|
179
|
+
format: (v) => (v != null ? String(v) : "1600 (default)"),
|
|
180
|
+
toEdit: (v) => (v != null ? String(v) : "1600"),
|
|
181
|
+
parse: (input) => {
|
|
182
|
+
const n = Number(input);
|
|
183
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
184
|
+
return Math.floor(n);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: "embeddingProvider",
|
|
189
|
+
label: "Embedding Provider",
|
|
190
|
+
type: "string",
|
|
191
|
+
hint: "Embedding provider (e.g. openai). Empty = embeddings disabled.",
|
|
192
|
+
format: (v) => (v ? String(v) : "(disabled)"),
|
|
193
|
+
toEdit: (v) => (v ? String(v) : ""),
|
|
194
|
+
parse: (input) => input.trim() || undefined,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: "embeddingModel",
|
|
198
|
+
label: "Embedding Model",
|
|
199
|
+
type: "string",
|
|
200
|
+
hint: "Embedding model id.",
|
|
201
|
+
defaultText: "text-embedding-3-small",
|
|
202
|
+
format: (v) => (v ? String(v) : "text-embedding-3-small (default)"),
|
|
203
|
+
toEdit: (v) => (v ? String(v) : "text-embedding-3-small"),
|
|
204
|
+
parse: (input) => input.trim() || undefined,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: "embeddingBaseUrl",
|
|
208
|
+
label: "Embedding Base URL",
|
|
209
|
+
type: "string",
|
|
210
|
+
hint: "Custom embeddings API base URL (optional).",
|
|
211
|
+
format: (v) => (v ? String(v) : "—"),
|
|
212
|
+
toEdit: (v) => (v ? String(v) : ""),
|
|
213
|
+
parse: (input) => input.trim() || undefined,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
key: "embeddingApiKeyEnv",
|
|
217
|
+
label: "Embedding API Key Env",
|
|
218
|
+
type: "string",
|
|
219
|
+
hint: "Environment variable name holding the embeddings API key.",
|
|
220
|
+
defaultText: "OPENAI_API_KEY",
|
|
221
|
+
format: (v) => (v ? String(v) : "OPENAI_API_KEY (default)"),
|
|
222
|
+
toEdit: (v) => (v ? String(v) : "OPENAI_API_KEY"),
|
|
223
|
+
parse: (input) => input.trim() || undefined,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
function isInsideHome(cwd: string): boolean {
|
|
228
|
+
return cwd.startsWith(homedir());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Map effective settings + sources to pi-tui SettingItems.
|
|
233
|
+
*
|
|
234
|
+
* Exported for tests. `notify` is used by edit menus to report invalid input
|
|
235
|
+
* without closing the menu.
|
|
236
|
+
*/
|
|
237
|
+
export function buildSettingItems(
|
|
238
|
+
sources: Record<string, { value: unknown; source: string }>,
|
|
239
|
+
notify: (message: string, type?: "error") => void,
|
|
240
|
+
): SettingItem[] {
|
|
241
|
+
return SETTINGS.map((def) => {
|
|
242
|
+
const entry = sources[def.key];
|
|
243
|
+
const value = entry?.value;
|
|
244
|
+
const source = entry?.source ?? "default";
|
|
245
|
+
|
|
246
|
+
const item: SettingItem = {
|
|
247
|
+
id: def.key,
|
|
248
|
+
label: def.label,
|
|
249
|
+
currentValue: def.format(value),
|
|
250
|
+
description: describeSetting(def, source),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (def.type === "boolean") {
|
|
254
|
+
item.values = ["OFF", "ON"];
|
|
255
|
+
return item;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
item.submenu = (_display, done) => {
|
|
259
|
+
const sub = new InputSubmenu(def.menuLabel ?? `Set ${def.label}`, def.toEdit(value));
|
|
260
|
+
sub.input.onSubmit = (raw) => {
|
|
261
|
+
const trimmed = raw.trim();
|
|
262
|
+
if (def.type === "model") {
|
|
263
|
+
if (!trimmed) {
|
|
264
|
+
done(""); // clear → back to session model
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const ref = parseModelRef(trimmed);
|
|
268
|
+
if (!ref) {
|
|
269
|
+
notify(`LLM Wiki: could not parse "${trimmed}". Use provider/id.`, "error");
|
|
270
|
+
return; // stay in the menu
|
|
271
|
+
}
|
|
272
|
+
done(`${ref.provider}/${ref.id}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!trimmed) {
|
|
276
|
+
done(); // empty = no change
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const parsed = def.parse(trimmed);
|
|
280
|
+
if (parsed === undefined) {
|
|
281
|
+
notify(`LLM Wiki: invalid value "${trimmed}" for ${def.label}`, "error");
|
|
282
|
+
return; // stay in the menu
|
|
283
|
+
}
|
|
284
|
+
done(String(parsed));
|
|
285
|
+
};
|
|
286
|
+
sub.input.onEscape = () => done();
|
|
287
|
+
return sub;
|
|
288
|
+
};
|
|
289
|
+
return item;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function describeSetting(def: SettingDef, source: string): string {
|
|
294
|
+
const where = source === "default" ? "Not set — using default." : `Set in ${source} settings.`;
|
|
295
|
+
return `${def.hint}\n${where}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Parse the display string a SettingItem cycled/edited into back into the
|
|
300
|
+
* typed value to persist.
|
|
301
|
+
*/
|
|
302
|
+
function parseDisplay(def: SettingDef, display: string): unknown {
|
|
303
|
+
if (def.type === "boolean") return display === "ON";
|
|
304
|
+
if (def.type === "model") return display ? parseModelRef(display) : undefined;
|
|
305
|
+
return def.parse(display);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildSettingsListTheme(theme: Theme): SettingsListTheme {
|
|
309
|
+
return {
|
|
310
|
+
label: (text, selected) => (selected ? theme.fg("accent", text) : text),
|
|
311
|
+
value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
312
|
+
description: (text) => theme.fg("dim", text),
|
|
313
|
+
cursor: theme.fg("accent", "→ "),
|
|
314
|
+
hint: (text) => theme.fg("dim", text),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Title + single-line input. SettingsList forwards all key input here. */
|
|
319
|
+
class InputSubmenu extends Container {
|
|
320
|
+
readonly input: Input;
|
|
321
|
+
|
|
322
|
+
constructor(label: string, initialValue: string) {
|
|
323
|
+
super();
|
|
324
|
+
this.addChild(new Text(label, 0, 0));
|
|
325
|
+
this.input = new Input();
|
|
326
|
+
// Type the prefill instead of setValue(): setValue keeps the cursor at 0,
|
|
327
|
+
// which makes backspace no-op and typed input land before the prefill.
|
|
328
|
+
if (initialValue) this.input.handleInput(initialValue);
|
|
329
|
+
this.addChild(this.input);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
handleInput(data: string): void {
|
|
333
|
+
this.input.handleInput(data);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Header + settings list. ui.custom gives this component focus. */
|
|
338
|
+
class SettingsScreen extends Container {
|
|
339
|
+
private list: SettingsList;
|
|
340
|
+
private titleText: Text;
|
|
341
|
+
|
|
342
|
+
constructor(title: string, list: SettingsList) {
|
|
343
|
+
super();
|
|
344
|
+
this.list = list;
|
|
345
|
+
this.titleText = new Text(title, 0, 0);
|
|
346
|
+
this.addChild(this.titleText);
|
|
347
|
+
this.addChild(new Spacer(1));
|
|
348
|
+
this.addChild(list);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
handleInput(data: string): void {
|
|
352
|
+
this.list.handleInput(data);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Update the header line in place (scope changes re-target it live). */
|
|
356
|
+
setTitle(title: string): void {
|
|
357
|
+
this.titleText.setText(title);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Build the header title for the scope currently being written to. */
|
|
362
|
+
function scopeTitle(scope: SettingScope): string {
|
|
363
|
+
return `\u{1F9E0} LLM Wiki Settings \u2014 ${scope === "global" ? "Global" : "Project"} (Esc to close)`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function showSettingsTui(ui: Ui, cwd: string, scope: SettingScope): Promise<void> {
|
|
367
|
+
if (typeof ui.custom !== "function") {
|
|
368
|
+
ui.notify(
|
|
369
|
+
"LLM Wiki: this host does not support the settings screen. Edit settings.json directly.",
|
|
370
|
+
"warning",
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The editable Scope row re-targets where subsequent writes land; the
|
|
376
|
+
// picker / inside-home heuristic only chooses the starting value.
|
|
377
|
+
let writeScope = scope;
|
|
378
|
+
const scopeItem: SettingItem = {
|
|
379
|
+
id: "scope",
|
|
380
|
+
label: "Scope",
|
|
381
|
+
currentValue: writeScope === "global" ? "Global" : "Project",
|
|
382
|
+
values: ["Global", "Project"],
|
|
383
|
+
description:
|
|
384
|
+
"Where edits are written. Global \u2192 ~/.pi/agent/settings.json \u00b7 Project \u2192 .pi/settings.json (this folder).",
|
|
385
|
+
};
|
|
386
|
+
const items = [scopeItem, ...buildSettingItems(loadTaskConfigSources(cwd), ui.notify.bind(ui))];
|
|
387
|
+
// Tracks the effective value so the reload note fires on actual changes.
|
|
388
|
+
let prevTrajectories = trajectoriesEnabled(loadTaskConfig(cwd));
|
|
389
|
+
|
|
390
|
+
let list: SettingsList | undefined;
|
|
391
|
+
let screenRef: SettingsScreen | undefined;
|
|
392
|
+
|
|
393
|
+
await ui.custom((_tui, theme, _keybindings, close) => {
|
|
394
|
+
list = new SettingsList(
|
|
395
|
+
items,
|
|
396
|
+
items.length,
|
|
397
|
+
buildSettingsListTheme(theme),
|
|
398
|
+
(id, display) => {
|
|
399
|
+
if (!list) return;
|
|
400
|
+
// Scope row: re-target writes; nothing is persisted for it.
|
|
401
|
+
if (id === "scope") {
|
|
402
|
+
writeScope = display === "Global" ? "global" : "project";
|
|
403
|
+
list.updateValue("scope", display);
|
|
404
|
+
screenRef?.setTitle(scopeTitle(writeScope));
|
|
405
|
+
ui.notify(
|
|
406
|
+
`LLM Wiki: writing to ${
|
|
407
|
+
writeScope === "global"
|
|
408
|
+
? "Global (~/.pi/agent/settings.json)"
|
|
409
|
+
: "Project (.pi/settings.json)"
|
|
410
|
+
}`,
|
|
411
|
+
);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const def = SETTINGS.find((d) => d.key === id);
|
|
415
|
+
if (!def) return;
|
|
416
|
+
const value = parseDisplay(def, display);
|
|
417
|
+
// Non-model, non-boolean values are validated before done(); undefined
|
|
418
|
+
// here is only expected for the model-clear case.
|
|
419
|
+
if (value === undefined && def.type !== "model") return;
|
|
420
|
+
try {
|
|
421
|
+
persistSetting(cwd, writeScope, def.key, value);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
ui.notify(
|
|
424
|
+
`LLM Wiki: failed to save ${def.label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
425
|
+
"error",
|
|
426
|
+
);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// Normalize the displayed value in place (e.g. cleared model shows
|
|
430
|
+
// "(session model)"; "0.50" becomes "0.5").
|
|
431
|
+
list.updateValue(id, def.format(value));
|
|
432
|
+
// trajectories gates tool registration at startup, so a live toggle
|
|
433
|
+
// cannot add/remove the 3 tools mid-session \u2014 say so on real changes.
|
|
434
|
+
if (def.key === "trajectories") {
|
|
435
|
+
const next = Boolean(value);
|
|
436
|
+
if (next !== prevTrajectories) {
|
|
437
|
+
prevTrajectories = next;
|
|
438
|
+
ui.notify(
|
|
439
|
+
"LLM Wiki: trajectory tools register at startup \u2014 new value applies after a reload",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
() => close(),
|
|
445
|
+
);
|
|
446
|
+
screenRef = new SettingsScreen(scopeTitle(writeScope), list);
|
|
447
|
+
return screenRef;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Register the /wiki-settings command.
|
|
453
|
+
*/
|
|
454
|
+
export function registerWikiSettingsCommand(pi: ExtensionAPI, runtime: Runtime): void {
|
|
455
|
+
pi.registerCommand("wiki-settings", {
|
|
456
|
+
description: "View and edit LLM Wiki settings (model, tokens, behaviors, embeddings)",
|
|
457
|
+
handler: async (_args: string, ctx: { cwd: string; hasUI: boolean; ui: Ui }) => {
|
|
458
|
+
runtime.ensureConfig(ctx.cwd);
|
|
459
|
+
|
|
460
|
+
if (!ctx.hasUI) {
|
|
461
|
+
ctx.ui.notify(
|
|
462
|
+
"LLM Wiki: /wiki-settings requires an interactive UI. Edit settings.json directly instead.",
|
|
463
|
+
"warning",
|
|
464
|
+
);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let scope: SettingScope;
|
|
469
|
+
if (isInsideHome(ctx.cwd)) {
|
|
470
|
+
scope = "global";
|
|
471
|
+
} else {
|
|
472
|
+
const pick = await ctx.ui.select("LLM Wiki \u2014 Settings scope?", [
|
|
473
|
+
`Project (${ctx.cwd}/.pi/settings.json)`,
|
|
474
|
+
"Global (~/.pi/agent/settings.json)",
|
|
475
|
+
]);
|
|
476
|
+
if (pick === undefined) return;
|
|
477
|
+
scope = pick.startsWith("Project") ? "project" : "global";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
await showSettingsTui(ctx.ui, ctx.cwd, scope);
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|