@zosmaai/pi-llm-wiki 0.8.1 → 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/README.md +33 -3
- 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/source-extractors.ts +120 -11
- package/extensions/llm-wiki/lib/source-packet.ts +19 -1
- 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 +3 -2
- 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
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
1
2
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
2
4
|
import { exec } from "./utils.js";
|
|
3
5
|
|
|
4
6
|
export type ExtractionStatus = "success" | "failed" | "unsupported";
|
|
@@ -38,6 +40,67 @@ interface UrlExtractArgs {
|
|
|
38
40
|
signal?: AbortSignal;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Binary magic byte detection
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const BINARY_SIGNATURES: Array<{ bytes: number[]; format: string }> = [
|
|
48
|
+
// Archives & documents
|
|
49
|
+
{ bytes: [0x50, 0x4b, 0x03, 0x04], format: "zip" }, // ZIP / DOCX / XLSX / PPTX / JAR
|
|
50
|
+
{ bytes: [0x25, 0x50, 0x44, 0x46], format: "pdf" }, // %PDF
|
|
51
|
+
{ bytes: [0x37, 0x7a, 0xbc, 0xaf], format: "7z" }, // 7-Zip
|
|
52
|
+
{ bytes: [0x1f, 0x8b], format: "gzip" }, // gzip / .tar.gz
|
|
53
|
+
// Images
|
|
54
|
+
{ bytes: [0x89, 0x50, 0x4e, 0x47], format: "png" }, // PNG
|
|
55
|
+
{ bytes: [0xff, 0xd8, 0xff], format: "jpeg" }, // JPEG
|
|
56
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38], format: "gif" }, // GIF8
|
|
57
|
+
{ bytes: [0x42, 0x4d], format: "bmp" }, // BMP
|
|
58
|
+
{ bytes: [0x49, 0x49, 0x2a, 0x00], format: "tiff" }, // TIFF (little-endian)
|
|
59
|
+
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], format: "tiff" }, // TIFF (big-endian)
|
|
60
|
+
{ bytes: [0x52, 0x49, 0x46, 0x46], format: "riff" }, // RIFF (WAV / AVI / WebP)
|
|
61
|
+
// Executables & binaries
|
|
62
|
+
{ bytes: [0x4d, 0x5a], format: "exe" }, // Windows PE (EXE / DLL)
|
|
63
|
+
{ bytes: [0xcf, 0xfa, 0xed, 0xfe], format: "macho" }, // Mach-O 64-bit LE
|
|
64
|
+
{ bytes: [0xce, 0xfa, 0xed, 0xfe], format: "macho" }, // Mach-O 32-bit LE
|
|
65
|
+
{ bytes: [0xfe, 0xed, 0xfa, 0xcf], format: "macho" }, // Mach-O 64-bit BE
|
|
66
|
+
{ bytes: [0xfe, 0xed, 0xfa, 0xce], format: "macho" }, // Mach-O 32-bit BE
|
|
67
|
+
{ bytes: [0xca, 0xfe, 0xba, 0xbe], format: "class" }, // Java .class / Mach-O FAT
|
|
68
|
+
{ bytes: [0x7f, 0x45, 0x4c, 0x46], format: "elf" }, // ELF binary
|
|
69
|
+
{ bytes: [0x00, 0x61, 0x73, 0x6d], format: "wasm" }, // WebAssembly
|
|
70
|
+
// Data & media
|
|
71
|
+
{ bytes: [0x53, 0x51, 0x4c, 0x69], format: "sqlite" }, // SQLite
|
|
72
|
+
{ bytes: [0x49, 0x44, 0x33], format: "mp3" }, // MP3 (ID3 tag)
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads the first 8 bytes of `filePath` and checks them against known binary
|
|
77
|
+
* magic byte signatures. Returns the detected format name or `null` for text.
|
|
78
|
+
*/
|
|
79
|
+
export async function detectBinaryMagicBytes(filePath: string): Promise<string | null> {
|
|
80
|
+
let handle: import("node:fs/promises").FileHandle | undefined;
|
|
81
|
+
try {
|
|
82
|
+
handle = await open(filePath, "r");
|
|
83
|
+
const buf = Buffer.alloc(8);
|
|
84
|
+
const { bytesRead } = await handle.read(buf, 0, 8, 0);
|
|
85
|
+
const header = buf.subarray(0, bytesRead);
|
|
86
|
+
|
|
87
|
+
for (const { bytes, format } of BINARY_SIGNATURES) {
|
|
88
|
+
if (bytes.every((b, i) => header[i] === b)) return format;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null; // Unreadable file — let the extractor deal with it
|
|
93
|
+
} finally {
|
|
94
|
+
await handle?.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function binaryExtractionFailureMessage(format: string): string {
|
|
99
|
+
return `_Binary file could not be converted to markdown (detected format: ${format}).\nCapture a text-based version or a URL pointing to readable content instead._\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
41
104
|
const DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000;
|
|
42
105
|
const DEFAULT_CURL_TIMEOUT_SECONDS = 30;
|
|
43
106
|
|
|
@@ -193,10 +256,11 @@ async function extractTextUrl(
|
|
|
193
256
|
content_type: "application/pdf",
|
|
194
257
|
};
|
|
195
258
|
}
|
|
259
|
+
const normalized = htmlToMarkdown(curlExtracted);
|
|
196
260
|
return {
|
|
197
|
-
extracted:
|
|
198
|
-
title: titleFromHtml(curlExtracted),
|
|
199
|
-
extractor: "
|
|
261
|
+
extracted: normalized,
|
|
262
|
+
title: titleFromMarkdown(normalized) ?? titleFromHtml(curlExtracted),
|
|
263
|
+
extractor: "htmlToMarkdown",
|
|
200
264
|
extraction_status: "success",
|
|
201
265
|
};
|
|
202
266
|
}
|
|
@@ -279,6 +343,23 @@ function titleFromHtml(html: string): string | undefined {
|
|
|
279
343
|
return html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim();
|
|
280
344
|
}
|
|
281
345
|
|
|
346
|
+
/** Decode common HTML/XML entities. Shared by xmlToMarkdown and htmlToMarkdown. */
|
|
347
|
+
function decodeHtmlEntities(text: string): string {
|
|
348
|
+
return text.replace(/&(?:amp|lt|gt|quot|apos|#\d+);/gi, (entity) => {
|
|
349
|
+
const map: Record<string, string> = {
|
|
350
|
+
"&": "&",
|
|
351
|
+
"<": "<",
|
|
352
|
+
">": ">",
|
|
353
|
+
""": '"',
|
|
354
|
+
"'": "'",
|
|
355
|
+
};
|
|
356
|
+
const lower = entity.toLowerCase();
|
|
357
|
+
if (map[lower]) return map[lower];
|
|
358
|
+
if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
|
|
359
|
+
return entity;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
282
363
|
/** Basic XML to markdown conversion: strip tags while preserving text structure. */
|
|
283
364
|
function xmlToMarkdown(xml: string): string {
|
|
284
365
|
let title = "";
|
|
@@ -297,14 +378,7 @@ function xmlToMarkdown(xml: string): string {
|
|
|
297
378
|
}
|
|
298
379
|
text = text.replace(/</g, "");
|
|
299
380
|
|
|
300
|
-
text = text
|
|
301
|
-
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"' };
|
|
302
|
-
const lower = entity.toLowerCase();
|
|
303
|
-
if (map[lower]) return map[lower];
|
|
304
|
-
if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
|
|
305
|
-
return entity;
|
|
306
|
-
});
|
|
307
|
-
|
|
381
|
+
text = decodeHtmlEntities(text);
|
|
308
382
|
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
309
383
|
if (!text) return xml;
|
|
310
384
|
|
|
@@ -314,6 +388,41 @@ function xmlToMarkdown(xml: string): string {
|
|
|
314
388
|
return lines.join("\n\n");
|
|
315
389
|
}
|
|
316
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Lightweight HTML-to-markdown normalizer for the curl fallback path.
|
|
393
|
+
*
|
|
394
|
+
* Pre-strips page chrome (nav, header, footer, script, style) that
|
|
395
|
+
* node-html-markdown does not remove, then delegates full conversion —
|
|
396
|
+
* bold, italic, code blocks, tables, ordered lists, image alt text — to
|
|
397
|
+
* node-html-markdown. Prepends the <title> as a # heading when the body
|
|
398
|
+
* has no <h1> of its own.
|
|
399
|
+
*
|
|
400
|
+
* Falls back to the original HTML if conversion yields an empty string.
|
|
401
|
+
*/
|
|
402
|
+
export function htmlToMarkdown(input: string): string {
|
|
403
|
+
// 1. Extract <title> from original before stripping head
|
|
404
|
+
const title = input.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? "";
|
|
405
|
+
|
|
406
|
+
// 2. Strip <head> and noise blocks that node-html-markdown won't remove
|
|
407
|
+
let html = input.replace(/<head[\s\S]*?<\/head>/gi, "");
|
|
408
|
+
let previousHtml = "";
|
|
409
|
+
while (previousHtml !== html) {
|
|
410
|
+
previousHtml = html;
|
|
411
|
+
html = html.replace(/<(script|style|nav|header|footer|noscript)[\s\S]*?<\/\1>/gi, "");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 3. Delegate to node-html-markdown for full semantic conversion
|
|
415
|
+
const converted = NodeHtmlMarkdown.translate(html).trim();
|
|
416
|
+
if (!converted) return input;
|
|
417
|
+
|
|
418
|
+
// 4. Prepend <title> as # heading only if body has no <h1> of its own
|
|
419
|
+
const hasBodyH1 = /<h1[^>]*>[\s\S]*?<\/h1>/i.test(html);
|
|
420
|
+
const lines: string[] = [];
|
|
421
|
+
if (title && !hasBodyH1) lines.push(`# ${title}\n`);
|
|
422
|
+
lines.push(converted);
|
|
423
|
+
return lines.join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
317
426
|
function jsonToMarkdown(json: string): string {
|
|
318
427
|
let value: unknown;
|
|
319
428
|
try {
|
|
@@ -2,7 +2,13 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { extname, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { appendEvent } from "./metadata.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ExtractedContent,
|
|
7
|
+
binaryExtractionFailureMessage,
|
|
8
|
+
detectBinaryMagicBytes,
|
|
9
|
+
extractUrlContent,
|
|
10
|
+
fileExtractorFor,
|
|
11
|
+
} from "./source-extractors.js";
|
|
6
12
|
import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -107,6 +113,18 @@ function fileCaptureSource(
|
|
|
107
113
|
preserveOriginal: (packetPath) =>
|
|
108
114
|
preserveFileOriginal(pi, packetPath, filePath, fileName, content, signal),
|
|
109
115
|
extract: async () => {
|
|
116
|
+
// Guard: if we hit the generic catch-all extractor, check for binary magic bytes first
|
|
117
|
+
if (extractor.format === "file") {
|
|
118
|
+
const binaryFormat = await detectBinaryMagicBytes(filePath);
|
|
119
|
+
if (binaryFormat) {
|
|
120
|
+
return {
|
|
121
|
+
extracted: binaryExtractionFailureMessage(binaryFormat),
|
|
122
|
+
extractor: "magicBytes",
|
|
123
|
+
extraction_status: "unsupported" as const,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
const extractedStr = await extractor.extract({ pi, filePath, content, signal });
|
|
111
129
|
const failed = extractedStr.includes("could not be converted");
|
|
112
130
|
return {
|
|
@@ -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
|
+
}
|