@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-blocking vault (re)indexing.
|
|
3
|
+
*
|
|
4
|
+
* Writing a wiki page (observe / retro / capture / ensure_page) and editing one
|
|
5
|
+
* by hand both require the derived metadata — `meta/registry.json`,
|
|
6
|
+
* `backlinks.json`, `index.md`, `log.md` — to be rebuilt, plus (optionally) the
|
|
7
|
+
* semantic embedding store to be refreshed. That rebuild is O(pages): it
|
|
8
|
+
* rescans every page in the vault. Doing it inline on the tool's / turn's
|
|
9
|
+
* critical path makes every write get slower as the vault grows.
|
|
10
|
+
*
|
|
11
|
+
* `scheduleReindex` moves that work off the caller's stack onto the shared
|
|
12
|
+
* background Runtime (#64) and coalesces a burst of writes into a single pass:
|
|
13
|
+
*
|
|
14
|
+
* - A leading micro-yield guarantees the caller (a tool's `execute`, or the
|
|
15
|
+
* turn_end handler) returns BEFORE the heavy rebuild runs.
|
|
16
|
+
* - A per-vault `dirty` flag + drain loop means writes that land while a pass
|
|
17
|
+
* is in flight are folded into a trailing rebuild instead of being lost —
|
|
18
|
+
* this also covers the async window of the embeddings refresh.
|
|
19
|
+
* - A per-vault `inflight` guard collapses concurrent schedule calls onto the
|
|
20
|
+
* same promise (single-flight), so N writes in a turn cost one rebuild.
|
|
21
|
+
*
|
|
22
|
+
* Errors are isolated by `Runtime.launchTask`; the promise never rejects. The
|
|
23
|
+
* embeddings step is a no-op unless an embedder is configured (#66/#67).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
|
|
27
|
+
import { rebuildMetadataLight } from "./metadata.js";
|
|
28
|
+
import type { LaunchCtx, Runtime } from "./runtime.js";
|
|
29
|
+
import type { VaultPaths } from "./utils.js";
|
|
30
|
+
|
|
31
|
+
/** Promise of the current background pass, keyed by vault root. */
|
|
32
|
+
const inflight = new Map<string, Promise<void>>();
|
|
33
|
+
/** Vault roots with writes awaiting a (re)build. */
|
|
34
|
+
const dirty = new Set<string>();
|
|
35
|
+
|
|
36
|
+
/** Stable single-flight label for a vault's background index pass. */
|
|
37
|
+
export function indexLabel(root: string): string {
|
|
38
|
+
return `index:${root}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Schedule a non-blocking metadata rebuild (+ embeddings refresh) for a vault.
|
|
43
|
+
* Returns the promise of the in-flight pass so callers/tests can await drainage
|
|
44
|
+
* (the agent loop itself never awaits it). Safe to call on every write.
|
|
45
|
+
*/
|
|
46
|
+
export function scheduleReindex(
|
|
47
|
+
runtime: Runtime,
|
|
48
|
+
ctx: LaunchCtx,
|
|
49
|
+
paths: VaultPaths,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const root = paths.root;
|
|
52
|
+
dirty.add(root);
|
|
53
|
+
|
|
54
|
+
const active = inflight.get(root);
|
|
55
|
+
if (active) return active;
|
|
56
|
+
|
|
57
|
+
const pass = runtime.launchTask(ctx, indexLabel(root), async () => {
|
|
58
|
+
// Yield once so the caller returns before the O(pages) rebuild runs. This
|
|
59
|
+
// is what makes the surrounding write non-blocking.
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
try {
|
|
62
|
+
// Drain: keep rebuilding until no new write arrived during the previous
|
|
63
|
+
// pass. The loop re-checks AFTER the awaited embeddings step, so writes
|
|
64
|
+
// that land during embedding are not lost.
|
|
65
|
+
while (dirty.has(root)) {
|
|
66
|
+
dirty.delete(root);
|
|
67
|
+
rebuildMetadataLight(paths);
|
|
68
|
+
|
|
69
|
+
// Refresh embeddings only after metadata is consistent. Stale-aware and
|
|
70
|
+
// a no-op unless an embedder is configured.
|
|
71
|
+
runtime.ensureConfig(root);
|
|
72
|
+
const embedder = resolveEmbedder(runtime.config);
|
|
73
|
+
if (embedder) await reindexEmbeddings(paths, embedder);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
inflight.delete(root);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
inflight.set(root, pass);
|
|
81
|
+
return pass;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Test-only: clear coalescing state between cases. */
|
|
85
|
+
export function __resetIndexingState(): void {
|
|
86
|
+
inflight.clear();
|
|
87
|
+
dirty.clear();
|
|
88
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
4
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import type { Static } from "typebox";
|
|
7
|
+
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
8
|
+
import { runSubAgent } from "./subagent.js";
|
|
9
|
+
import { type VaultPaths, fmtDate, slugify } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Background ingest synthesis (issue #65, part of epic #63).
|
|
13
|
+
*
|
|
14
|
+
* Moves the work the main agent used to do during `wiki_ingest` — reading a
|
|
15
|
+
* captured source's extracted text and writing the source page + entity /
|
|
16
|
+
* concept pages — onto a background sub-agent, so capturing/ingesting never
|
|
17
|
+
* stalls the user.
|
|
18
|
+
*
|
|
19
|
+
* Design: the sub-agent produces ONE structured `commit_synthesis` call; the
|
|
20
|
+
* persistence (`commitSynthesis`) is fully deterministic and unit-testable
|
|
21
|
+
* without an LLM. This mirrors pi-observational-memory's single-structured-tool
|
|
22
|
+
* pattern and keeps the file-writing logic verifiable in isolation.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ── structured synthesis schema ───────────────────────────
|
|
26
|
+
export const CommitSynthesisSchema = Type.Object({
|
|
27
|
+
summary: Type.String({
|
|
28
|
+
minLength: 1,
|
|
29
|
+
description: "2-3 paragraph summary of the source's key content.",
|
|
30
|
+
}),
|
|
31
|
+
key_takeaways: Type.Array(Type.String({ minLength: 1 }), {
|
|
32
|
+
description: "The most important points, one per item.",
|
|
33
|
+
}),
|
|
34
|
+
entities: Type.Array(
|
|
35
|
+
Type.Object({
|
|
36
|
+
title: Type.String({
|
|
37
|
+
minLength: 1,
|
|
38
|
+
description: "Entity name (person, org, tool, product).",
|
|
39
|
+
}),
|
|
40
|
+
description: Type.String({ description: "One-line description of the entity." }),
|
|
41
|
+
}),
|
|
42
|
+
{ description: "Named entities mentioned in the source." },
|
|
43
|
+
),
|
|
44
|
+
concepts: Type.Array(
|
|
45
|
+
Type.Object({
|
|
46
|
+
title: Type.String({ minLength: 1, description: "Concept name (idea, pattern, framework)." }),
|
|
47
|
+
definition: Type.String({ description: "One-line definition of the concept." }),
|
|
48
|
+
}),
|
|
49
|
+
{ description: "Concepts discussed in the source." },
|
|
50
|
+
),
|
|
51
|
+
quotes: Type.Optional(
|
|
52
|
+
Type.Array(
|
|
53
|
+
Type.Object({
|
|
54
|
+
text: Type.String({ minLength: 1 }),
|
|
55
|
+
attribution: Type.Optional(Type.String()),
|
|
56
|
+
}),
|
|
57
|
+
{ description: "Notable verbatim quotes." },
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
contradictions: Type.Optional(
|
|
61
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
62
|
+
description: "Tensions/contradictions with existing wiki content, if any.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type SynthesisData = Static<typeof CommitSynthesisSchema>;
|
|
68
|
+
|
|
69
|
+
export interface CommitResult {
|
|
70
|
+
sourceId: string;
|
|
71
|
+
sourcePage: string;
|
|
72
|
+
entitiesCreated: string[];
|
|
73
|
+
conceptsCreated: string[];
|
|
74
|
+
entitiesLinked: string[];
|
|
75
|
+
conceptsLinked: string[];
|
|
76
|
+
contradictions: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── deterministic persistence (no LLM) ────────────────────
|
|
80
|
+
|
|
81
|
+
function buildEntityPage(
|
|
82
|
+
title: string,
|
|
83
|
+
description: string,
|
|
84
|
+
date: string,
|
|
85
|
+
sourceId: string,
|
|
86
|
+
): string {
|
|
87
|
+
const desc = description.trim() || "One-line description.";
|
|
88
|
+
return `---\ntype: entity\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${desc}\n\n## Overview\n\n[Key facts]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildConceptPage(
|
|
92
|
+
title: string,
|
|
93
|
+
definition: string,
|
|
94
|
+
date: string,
|
|
95
|
+
sourceId: string,
|
|
96
|
+
): string {
|
|
97
|
+
const def = definition.trim() || "One-line definition.";
|
|
98
|
+
return `---\ntype: concept\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${def}\n\n## Definition\n\n[Clear explanation]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Rebuild the source page from synthesis data, marking it ingested. */
|
|
102
|
+
export function buildIngestedSourcePage(
|
|
103
|
+
manifest: Record<string, unknown>,
|
|
104
|
+
data: SynthesisData,
|
|
105
|
+
date: string,
|
|
106
|
+
): string {
|
|
107
|
+
const id = String(manifest.id);
|
|
108
|
+
const title = String(manifest.title || id);
|
|
109
|
+
const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
|
|
110
|
+
const format = String(manifest.format || "unknown");
|
|
111
|
+
const captured = String(manifest.captured || date);
|
|
112
|
+
|
|
113
|
+
const takeaways =
|
|
114
|
+
data.key_takeaways.length > 0
|
|
115
|
+
? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
|
|
116
|
+
: "- [None recorded]";
|
|
117
|
+
const entities =
|
|
118
|
+
data.entities.length > 0
|
|
119
|
+
? data.entities.map((e) => `- [[${slugify(e.title)}]]`).join("\n")
|
|
120
|
+
: "- [None]";
|
|
121
|
+
const concepts =
|
|
122
|
+
data.concepts.length > 0
|
|
123
|
+
? data.concepts.map((c) => `- [[${slugify(c.title)}]]`).join("\n")
|
|
124
|
+
: "- [None]";
|
|
125
|
+
const quotes =
|
|
126
|
+
data.quotes && data.quotes.length > 0
|
|
127
|
+
? data.quotes
|
|
128
|
+
.map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
|
|
129
|
+
.join("\n\n")
|
|
130
|
+
: "> [None recorded]";
|
|
131
|
+
const contradictions =
|
|
132
|
+
data.contradictions && data.contradictions.length > 0
|
|
133
|
+
? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
|
|
134
|
+
: "";
|
|
135
|
+
|
|
136
|
+
return `---\ntype: source\nformat: ${format}\nsource_id: ${id}\nraw_path: raw/sources/${id}/extracted.md\ncaptured: ${captured}\nstatus: ingested\nupdated: ${date}\n---\n\n# ${title}${url}\n\n## Summary\n\n${data.summary.trim()}\n\n## Key Takeaways\n\n${takeaways}\n\n## Entities Mentioned\n\n${entities}\n\n## Concepts Mentioned\n\n${concepts}\n\n## Notable Quotes\n\n${quotes}\n${contradictions}\n## Source Packet\n\n- **ID:** \`[[sources/${id}]]\`\n- **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)\n- **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Persist a synthesis deterministically: rewrite the source page (status →
|
|
141
|
+
* ingested), create missing entity/concept pages (existing pages are linked,
|
|
142
|
+
* never overwritten), and log the event. Pure file I/O — no LLM, no network.
|
|
143
|
+
*/
|
|
144
|
+
export function commitSynthesis(
|
|
145
|
+
paths: VaultPaths,
|
|
146
|
+
sourceId: string,
|
|
147
|
+
manifest: Record<string, unknown>,
|
|
148
|
+
data: SynthesisData,
|
|
149
|
+
date: string = fmtDate(),
|
|
150
|
+
): CommitResult {
|
|
151
|
+
const result: CommitResult = {
|
|
152
|
+
sourceId,
|
|
153
|
+
sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
|
|
154
|
+
entitiesCreated: [],
|
|
155
|
+
conceptsCreated: [],
|
|
156
|
+
entitiesLinked: [],
|
|
157
|
+
conceptsLinked: [],
|
|
158
|
+
contradictions: data.contradictions?.length ?? 0,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Source page (always rewritten from skeleton → ingested).
|
|
162
|
+
mkdirSync(join(paths.wiki, "sources"), { recursive: true });
|
|
163
|
+
writeFileSync(result.sourcePage, buildIngestedSourcePage(manifest, data, date), "utf-8");
|
|
164
|
+
|
|
165
|
+
// Entity pages — create if absent, link if present.
|
|
166
|
+
mkdirSync(join(paths.wiki, "entities"), { recursive: true });
|
|
167
|
+
for (const e of data.entities) {
|
|
168
|
+
const slug = slugify(e.title);
|
|
169
|
+
if (!slug) continue;
|
|
170
|
+
const pagePath = join(paths.wiki, "entities", `${slug}.md`);
|
|
171
|
+
if (existsSync(pagePath)) {
|
|
172
|
+
result.entitiesLinked.push(slug);
|
|
173
|
+
} else {
|
|
174
|
+
writeFileSync(pagePath, buildEntityPage(e.title, e.description, date, sourceId), "utf-8");
|
|
175
|
+
result.entitiesCreated.push(slug);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Concept pages — create if absent, link if present.
|
|
180
|
+
mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
|
|
181
|
+
for (const c of data.concepts) {
|
|
182
|
+
const slug = slugify(c.title);
|
|
183
|
+
if (!slug) continue;
|
|
184
|
+
const pagePath = join(paths.wiki, "concepts", `${slug}.md`);
|
|
185
|
+
if (existsSync(pagePath)) {
|
|
186
|
+
result.conceptsLinked.push(slug);
|
|
187
|
+
} else {
|
|
188
|
+
writeFileSync(pagePath, buildConceptPage(c.title, c.definition, date, sourceId), "utf-8");
|
|
189
|
+
result.conceptsCreated.push(slug);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
appendEvent(paths, {
|
|
194
|
+
kind: "ingest",
|
|
195
|
+
source_id: sourceId,
|
|
196
|
+
entities_created: result.entitiesCreated.length,
|
|
197
|
+
concepts_created: result.conceptsCreated.length,
|
|
198
|
+
contradictions: result.contradictions,
|
|
199
|
+
background: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── sub-agent synthesis (LLM) ─────────────────────────────
|
|
206
|
+
|
|
207
|
+
export const INGEST_SYSTEM = `You are the LLM Wiki ingestion synthesizer. You turn a single captured source's extracted text into structured wiki knowledge.
|
|
208
|
+
|
|
209
|
+
Read the source content, then call \`commit_synthesis\` EXACTLY ONCE with:
|
|
210
|
+
- summary: a faithful 2-3 paragraph summary (no fabrication).
|
|
211
|
+
- key_takeaways: the most important points.
|
|
212
|
+
- entities: named people, organizations, tools, products actually mentioned.
|
|
213
|
+
- concepts: ideas, patterns, frameworks actually discussed.
|
|
214
|
+
- quotes: notable verbatim quotes (optional).
|
|
215
|
+
- contradictions: tensions with general knowledge or noted in the text (optional).
|
|
216
|
+
|
|
217
|
+
Rules:
|
|
218
|
+
- Never fabricate. Only include entities/concepts present in the source.
|
|
219
|
+
- Keep descriptions to one line.
|
|
220
|
+
- After calling commit_synthesis once, reply with a one-line confirmation and stop.`;
|
|
221
|
+
|
|
222
|
+
export interface RunIngestSynthesisArgs {
|
|
223
|
+
model: Model<Api>;
|
|
224
|
+
apiKey: string;
|
|
225
|
+
headers?: Record<string, string>;
|
|
226
|
+
paths: VaultPaths;
|
|
227
|
+
sourceId: string;
|
|
228
|
+
manifest: Record<string, unknown>;
|
|
229
|
+
extracted: string;
|
|
230
|
+
/** Cap on extracted chars fed to the model (avoid huge prompts). Default 24k. */
|
|
231
|
+
maxChars?: number;
|
|
232
|
+
signal?: AbortSignal;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run the synthesis sub-agent for a single source, then commit + rebuild
|
|
237
|
+
* metadata. Returns the commit result, or undefined if the model produced no
|
|
238
|
+
* synthesis.
|
|
239
|
+
*/
|
|
240
|
+
export async function runIngestSynthesis(
|
|
241
|
+
args: RunIngestSynthesisArgs,
|
|
242
|
+
): Promise<CommitResult | undefined> {
|
|
243
|
+
const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
|
|
244
|
+
const content = extracted.slice(0, maxChars ?? 24_000);
|
|
245
|
+
if (!content.trim()) return undefined;
|
|
246
|
+
|
|
247
|
+
let committed: CommitResult | undefined;
|
|
248
|
+
|
|
249
|
+
const commitTool: AgentTool<typeof CommitSynthesisSchema> = {
|
|
250
|
+
name: "commit_synthesis",
|
|
251
|
+
label: "Commit synthesis",
|
|
252
|
+
description:
|
|
253
|
+
"Persist the structured synthesis of this source into wiki pages. Call exactly once.",
|
|
254
|
+
parameters: CommitSynthesisSchema,
|
|
255
|
+
execute: async (_id, params) => {
|
|
256
|
+
committed = commitSynthesis(paths, sourceId, manifest, params);
|
|
257
|
+
const ack = `Committed: source page + ${committed.entitiesCreated.length} new entit${
|
|
258
|
+
committed.entitiesCreated.length === 1 ? "y" : "ies"
|
|
259
|
+
}, ${committed.conceptsCreated.length} new concept${
|
|
260
|
+
committed.conceptsCreated.length === 1 ? "" : "s"
|
|
261
|
+
}. Reply with a one-line confirmation and stop.`;
|
|
262
|
+
return { content: [{ type: "text", text: ack }], details: { sourceId } };
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const title = String(manifest.title || sourceId);
|
|
267
|
+
const userPrompt = `Synthesize this captured source into wiki knowledge by calling commit_synthesis once.\n\nSOURCE: ${title} (${sourceId})\n\nEXTRACTED CONTENT:\n${content}`;
|
|
268
|
+
|
|
269
|
+
await runSubAgent({
|
|
270
|
+
model,
|
|
271
|
+
apiKey,
|
|
272
|
+
headers,
|
|
273
|
+
systemPrompt: INGEST_SYSTEM,
|
|
274
|
+
userPrompt,
|
|
275
|
+
tools: [commitTool as AgentTool],
|
|
276
|
+
signal,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (committed) rebuildMetadataLight(paths);
|
|
280
|
+
return committed;
|
|
281
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { Runtime } from "./runtime.js";
|
|
3
|
+
import { type TaskConfig, parseModelRef, persistTaskModel } from "./task-config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Model selection surface for the wiki background lane (issue #69, epic #63).
|
|
7
|
+
*
|
|
8
|
+
* The `taskModel` config field (read by `Runtime.resolveModel`) already exists;
|
|
9
|
+
* this module adds the user-facing *surface* to view and set it:
|
|
10
|
+
* - the `/wiki-model` slash command (interactive picker + scriptable arg),
|
|
11
|
+
* - a status-bar label of the active task model,
|
|
12
|
+
* - the per-call override is wired on heavy tools (e.g. `wiki_ingest`).
|
|
13
|
+
*
|
|
14
|
+
* The default is always the session model: when no `taskModel` is configured
|
|
15
|
+
* and no override is passed, background work runs on the current session model.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A minimal view of a registry model (provider + id, optional display name). */
|
|
19
|
+
interface ModelLike {
|
|
20
|
+
provider: string;
|
|
21
|
+
id: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Words that clear the override and revert to the session model. */
|
|
26
|
+
const CLEAR_WORDS = new Set(["session", "default", "reset", "clear", "none", "unset"]);
|
|
27
|
+
|
|
28
|
+
/** The status-bar key for the active-model label (so we can update it in place). */
|
|
29
|
+
export const MODEL_STATUS_KEY = "llm-wiki-model";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Human-readable label for the active background task model. Shows the
|
|
33
|
+
* configured `provider/id` when set, otherwise the session model (with its id
|
|
34
|
+
* when known). Pure — safe to unit test and reuse for the status line.
|
|
35
|
+
*/
|
|
36
|
+
export function formatActiveModelLabel(config: TaskConfig, sessionModelId?: string): string {
|
|
37
|
+
if (config.taskModel) return `${config.taskModel.provider}/${config.taskModel.id}`;
|
|
38
|
+
return sessionModelId ? `session model (${sessionModelId})` : "session model";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "provider/id" ref for a model. */
|
|
42
|
+
function modelRef(m: ModelLike): string {
|
|
43
|
+
return `${m.provider}/${m.id}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register the `/wiki-model` slash command. Lets the user view the active
|
|
48
|
+
* background task model and choose another (or revert to the session model).
|
|
49
|
+
* The choice is persisted to project settings and applied immediately.
|
|
50
|
+
*
|
|
51
|
+
* /wiki-model → interactive picker (lists available models)
|
|
52
|
+
* /wiki-model provider/id → set directly (scriptable / no UI needed)
|
|
53
|
+
* /wiki-model session|clear → clear the override, use the session model
|
|
54
|
+
*/
|
|
55
|
+
export function registerWikiModelCommand(pi: ExtensionAPI, runtime: Runtime): void {
|
|
56
|
+
pi.registerCommand("wiki-model", {
|
|
57
|
+
description:
|
|
58
|
+
"View or set the model used for LLM Wiki background tasks (default: session model)",
|
|
59
|
+
handler: async (args, ctx) => {
|
|
60
|
+
runtime.ensureConfig(ctx.cwd);
|
|
61
|
+
const sessionId = (ctx.model as ModelLike | undefined)?.id;
|
|
62
|
+
|
|
63
|
+
const apply = (model: { provider: string; id: string } | undefined): void => {
|
|
64
|
+
persistTaskModel(ctx.cwd, model);
|
|
65
|
+
runtime.config = { ...runtime.config, taskModel: model };
|
|
66
|
+
runtime.configLoaded = true;
|
|
67
|
+
const label = formatActiveModelLabel(runtime.config, sessionId);
|
|
68
|
+
ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${label}`);
|
|
69
|
+
ctx.ui.notify(`LLM Wiki: background tasks now use ${label}`, "info");
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const trimmed = args.trim();
|
|
73
|
+
|
|
74
|
+
// Explicit clear → session model.
|
|
75
|
+
if (trimmed && CLEAR_WORDS.has(trimmed.toLowerCase())) {
|
|
76
|
+
apply(undefined);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Direct "provider/id" set (works without UI).
|
|
81
|
+
if (trimmed) {
|
|
82
|
+
const ref = parseModelRef(trimmed);
|
|
83
|
+
if (!ref) {
|
|
84
|
+
ctx.ui.notify(
|
|
85
|
+
`LLM Wiki: could not parse "${trimmed}". Use provider/id (e.g. anthropic/claude-haiku) or "session".`,
|
|
86
|
+
"error",
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const found = ctx.modelRegistry.find(ref.provider, ref.id) as ModelLike | undefined;
|
|
91
|
+
if (!found) {
|
|
92
|
+
ctx.ui.notify(
|
|
93
|
+
`LLM Wiki: model ${ref.provider}/${ref.id} is not in the registry (run /wiki-model with no argument to pick from available models).`,
|
|
94
|
+
"error",
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
apply({ provider: found.provider, id: found.id });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No argument: interactive picker.
|
|
103
|
+
const current = formatActiveModelLabel(runtime.config, sessionId);
|
|
104
|
+
if (!ctx.hasUI) {
|
|
105
|
+
ctx.ui.notify(
|
|
106
|
+
`LLM Wiki: active background model is ${current}. Pass provider/id to change it (no interactive UI here).`,
|
|
107
|
+
"info",
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const available = (ctx.modelRegistry.getAvailable() as ModelLike[]) ?? [];
|
|
113
|
+
const pool = available.length > 0 ? available : (ctx.modelRegistry.getAll() as ModelLike[]);
|
|
114
|
+
const sessionOption = "↩ Use session model (clear override)";
|
|
115
|
+
const options = [sessionOption, ...pool.map(modelRef)];
|
|
116
|
+
|
|
117
|
+
const picked = await ctx.ui.select(`Wiki background model (current: ${current})`, options);
|
|
118
|
+
if (picked === undefined) return; // cancelled
|
|
119
|
+
|
|
120
|
+
if (picked === sessionOption) {
|
|
121
|
+
apply(undefined);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const ref = parseModelRef(picked);
|
|
125
|
+
if (ref) apply(ref);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
+
import { scheduleReindex } from "./indexing.js";
|
|
5
6
|
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
7
|
+
import type { Runtime } from "./runtime.js";
|
|
6
8
|
import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";
|
|
7
9
|
|
|
8
10
|
// ─── Types ─────────────────────────────────────────────
|
|
@@ -44,7 +46,11 @@ const RELEVANCE_EMOJIS: Record<string, string> = {
|
|
|
44
46
|
* Observations are stored in wiki/sources/ with type: source and
|
|
45
47
|
* status: observation. They are searchable via wiki_recail.
|
|
46
48
|
*/
|
|
47
|
-
export function saveObservation(
|
|
49
|
+
export function saveObservation(
|
|
50
|
+
paths: VaultPaths,
|
|
51
|
+
input: ObservationInput,
|
|
52
|
+
opts?: { rebuild?: boolean },
|
|
53
|
+
): ObservationResult {
|
|
48
54
|
const today = fmtDate();
|
|
49
55
|
const timestamp = new Date().toISOString();
|
|
50
56
|
|
|
@@ -109,8 +115,10 @@ export function saveObservation(paths: VaultPaths, input: ObservationInput): Obs
|
|
|
109
115
|
relevance: input.relevance,
|
|
110
116
|
});
|
|
111
117
|
|
|
112
|
-
// Rebuild metadata so the observation is immediately searchable
|
|
113
|
-
|
|
118
|
+
// Rebuild metadata so the observation is immediately searchable. Callers that
|
|
119
|
+
// background this (the wiki_observe tool) pass { rebuild: false } and schedule
|
|
120
|
+
// a non-blocking reindex instead.
|
|
121
|
+
if (opts?.rebuild !== false) rebuildMetadataLight(paths);
|
|
114
122
|
|
|
115
123
|
return { slug, pagePath };
|
|
116
124
|
}
|
|
@@ -137,7 +145,11 @@ export function createReminderState(): ReminderState {
|
|
|
137
145
|
* The model calls this to record observations during a session.
|
|
138
146
|
* Observations are saved to the wiki and become searchable.
|
|
139
147
|
*/
|
|
140
|
-
export function registerWikiObserve(
|
|
148
|
+
export function registerWikiObserve(
|
|
149
|
+
pi: ExtensionAPI,
|
|
150
|
+
runtime?: Runtime,
|
|
151
|
+
reminderState?: ReminderState,
|
|
152
|
+
): void {
|
|
141
153
|
pi.registerTool({
|
|
142
154
|
name: "wiki_observe",
|
|
143
155
|
label: "Wiki Observe",
|
|
@@ -214,13 +226,24 @@ export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderSt
|
|
|
214
226
|
};
|
|
215
227
|
}
|
|
216
228
|
|
|
217
|
-
const result = saveObservation(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
229
|
+
const result = saveObservation(
|
|
230
|
+
paths,
|
|
231
|
+
{
|
|
232
|
+
title: params.title,
|
|
233
|
+
content: params.content,
|
|
234
|
+
relevance: params.relevance,
|
|
235
|
+
tags: params.tags,
|
|
236
|
+
source_context: params.source_context,
|
|
237
|
+
},
|
|
238
|
+
// When a background runtime is available, write the page synchronously
|
|
239
|
+
// but defer the O(pages) metadata rebuild + embeddings off the tool's
|
|
240
|
+
// critical path. Without a runtime, fall back to the inline rebuild.
|
|
241
|
+
{ rebuild: !runtime },
|
|
242
|
+
);
|
|
243
|
+
if (runtime) {
|
|
244
|
+
const launchCtx = { hasUI: ctx.hasUI, ui: ctx.ui };
|
|
245
|
+
scheduleReindex(runtime, launchCtx, paths);
|
|
246
|
+
}
|
|
224
247
|
|
|
225
248
|
// Signal the reminder to stop nagging this session
|
|
226
249
|
if (reminderState) {
|