@zosmaai/pi-llm-wiki 0.11.4 → 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/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 +8 -0
- 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-ingest.md +1 -0
- package/commands/wiki-req.md +1 -0
- package/commands/wiki-retro.md +1 -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 +21 -1
- 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 +22 -3
- package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +78 -1
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +10 -0
- 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 +4 -0
- 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 +21 -1
- 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 +23 -3
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +102 -0
- package/package.json +4 -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
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { bootstrapVault } from "./lib/bootstrap.js";
|
|
5
|
+
import { registerWikiDashboardCommand } from "./lib/dashboard-command.js";
|
|
5
6
|
import { installGuardrails } from "./lib/guardrails.js";
|
|
6
7
|
import { buildAgentStartInjection, normalizeSystemPrompt } from "./lib/inject.js";
|
|
7
8
|
import { registerWikiModelCommand } from "./lib/model-command.js";
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "./lib/recall.js";
|
|
21
22
|
import { registerWikiRetro } from "./lib/retro.js";
|
|
22
23
|
import { registerBackgroundRuntime } from "./lib/runtime.js";
|
|
24
|
+
import { registerWikiSettingsCommand } from "./lib/settings-command.js";
|
|
23
25
|
import {
|
|
24
26
|
loadTaskConfig,
|
|
25
27
|
noticesEnabled,
|
|
@@ -131,6 +133,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
131
133
|
// background task model. The taskModel config field + resolveModel already
|
|
132
134
|
// exist; this exposes them to the user (default stays the session model).
|
|
133
135
|
registerWikiModelCommand(pi, runtime);
|
|
136
|
+
registerWikiSettingsCommand(pi, runtime);
|
|
137
|
+
registerWikiDashboardCommand(pi, runtime);
|
|
134
138
|
const reminderState = createReminderState();
|
|
135
139
|
registerWikiObserve(pi, runtime, reminderState);
|
|
136
140
|
// Visible observe/retro reminder by default (issue #77); silenced when the
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /wiki-dashboard command.
|
|
3
|
+
*
|
|
4
|
+
* Read-only mirror of /wiki-settings: one persistent screen, Esc closes.
|
|
5
|
+
* Unlike settings there is no per-key change — every line is computed once
|
|
6
|
+
* from on-disk vault state (see lib/dashboard.ts) and rendered as plain
|
|
7
|
+
* text through a Container + Text.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { Container, Text, matchesKey } from "@mariozechner/pi-tui";
|
|
11
|
+
import { type DashboardStats, collectDashboardStats } from "./dashboard.js";
|
|
12
|
+
import type { Runtime } from "./runtime.js";
|
|
13
|
+
|
|
14
|
+
type App = ExtensionAPI;
|
|
15
|
+
|
|
16
|
+
const TYPE_ORDER = [
|
|
17
|
+
"concept",
|
|
18
|
+
"entity",
|
|
19
|
+
"source",
|
|
20
|
+
"skill",
|
|
21
|
+
"analysis",
|
|
22
|
+
"synthesis",
|
|
23
|
+
"requirement",
|
|
24
|
+
"short",
|
|
25
|
+
"shared",
|
|
26
|
+
];
|
|
27
|
+
const KIND_ORDER = ["observe", "retro", "synth", "intake"];
|
|
28
|
+
|
|
29
|
+
function orderedPairs(counts: Record<string, number>): string {
|
|
30
|
+
const keys = Object.keys(counts);
|
|
31
|
+
const known = TYPE_ORDER.filter((k) => counts[k] !== undefined);
|
|
32
|
+
const rest = keys
|
|
33
|
+
.filter((k) => !TYPE_ORDER.includes(k))
|
|
34
|
+
.sort()
|
|
35
|
+
.slice(0, 3); // cap the tail: the dashboard is a glance, not a census
|
|
36
|
+
const all = [...known, ...rest];
|
|
37
|
+
if (all.length === 0) return "—";
|
|
38
|
+
return all.map((k) => `${k} ${counts[k]}`).join(" · ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function orderedKinds(counts: Record<string, number>): string {
|
|
42
|
+
const keys = Object.keys(counts);
|
|
43
|
+
const known = KIND_ORDER.filter((k) => counts[k] !== undefined);
|
|
44
|
+
const rest = keys
|
|
45
|
+
.filter((k) => !KIND_ORDER.includes(k))
|
|
46
|
+
.sort()
|
|
47
|
+
.slice(0, 3);
|
|
48
|
+
return [...known, ...rest].map((k) => `${k} ${counts[k]}`).join(" · ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderStatsLines(s: DashboardStats): string[] {
|
|
52
|
+
const emb = s.embEnabled ? `${s.embFiles}/${s.pageCount}` : "—";
|
|
53
|
+
return [
|
|
54
|
+
`Wiki Dashboard · ${s.root}`,
|
|
55
|
+
"",
|
|
56
|
+
`Pages ${s.pageCount} · ${s.sizeKB}KB · ${orderedPairs(s.byType)}`,
|
|
57
|
+
`Fresh last touch ${s.lastTouch || "never"} · stale(>30d) ${s.staleCount}`,
|
|
58
|
+
`Acts 7d ${s.last7dTotal > 0 ? orderedKinds(s.last7dByKind) : "none"} · total ${s.last7dTotal}/${s.totalEvents}`,
|
|
59
|
+
`Queue raw ${s.rawQueue} · emb ${emb}`,
|
|
60
|
+
`Links zero-backlink ${s.zeroBacklinks} · full scan: /wiki-lint`,
|
|
61
|
+
"",
|
|
62
|
+
"Esc to close",
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One-screen read-only TUI; handles Esc and `q`. */
|
|
67
|
+
export class DashboardScreen extends Container {
|
|
68
|
+
private doneFn: (result?: unknown) => void;
|
|
69
|
+
|
|
70
|
+
constructor(lines: string[], close: (result?: unknown) => void) {
|
|
71
|
+
super();
|
|
72
|
+
this.doneFn = close;
|
|
73
|
+
this.addChild(new Text(lines.join("\n"), 1, 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
handleInput(data: string): void {
|
|
77
|
+
// ponytail: matchesKey handles every terminal key shape (raw bytes,
|
|
78
|
+
// kitty CSI-u like \u001b[27u for Esc, ctrl combos) — a raw === "\u001b"
|
|
79
|
+
// check only works in terminals without the kitty keyboard protocol,
|
|
80
|
+
// which is exactly where Ghostty users' Esc went nowhere.
|
|
81
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
82
|
+
this.doneFn();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register the /wiki-dashboard command.
|
|
89
|
+
*/
|
|
90
|
+
export function registerWikiDashboardCommand(pi: ExtensionAPI, runtime: Runtime): void {
|
|
91
|
+
pi.registerCommand("wiki-dashboard", {
|
|
92
|
+
description:
|
|
93
|
+
"Show a read-only LLM Wiki dashboard (pages, freshness, activity, queue, links, emb)",
|
|
94
|
+
handler: async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
95
|
+
runtime.ensureConfig(ctx.cwd);
|
|
96
|
+
if (!ctx.hasUI) {
|
|
97
|
+
ctx.ui.notify("LLM Wiki: /wiki-dashboard requires an interactive UI.", "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const stats = await collectDashboardStats(ctx.cwd);
|
|
101
|
+
await ctx.ui.custom((_tui, _theme, _keybindings, close) => {
|
|
102
|
+
return new DashboardScreen(renderStatsLines(stats), close);
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only vault statistics for the /wiki-dashboard screen.
|
|
3
|
+
*
|
|
4
|
+
* Everything is computed from pre-existing on-disk state:
|
|
5
|
+
* - page tree (paths.wiki + skills dir): counts, mtimes, sizes
|
|
6
|
+
* - meta/registry.json: page types (values; keys can carry legacy prefix noise)
|
|
7
|
+
* - meta/backlinks.json: zero-inbound pages
|
|
8
|
+
* - meta/events.jsonl: activity stream (observes/retros/syntheses...)
|
|
9
|
+
* - raw/sources: pending ingest queue
|
|
10
|
+
* - emb/: embedding coverage
|
|
11
|
+
*
|
|
12
|
+
* No writes, no LLM calls, no new files. Pure reader.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
15
|
+
import type { VaultPaths } from "./utils.js";
|
|
16
|
+
import { readJson, resolveVaultPaths } from "./utils.js";
|
|
17
|
+
|
|
18
|
+
interface PageInfo {
|
|
19
|
+
rel: string;
|
|
20
|
+
mtime: number;
|
|
21
|
+
bytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DashboardStats {
|
|
25
|
+
/** Vault root directory, as shown in the header. */
|
|
26
|
+
root: string;
|
|
27
|
+
/** Total .md pages across wiki + skills dirs (filesystem truth). */
|
|
28
|
+
pageCount: number;
|
|
29
|
+
/** Page count by registry type; unknown types fall back to the folder name. */
|
|
30
|
+
byType: Record<string, number>;
|
|
31
|
+
/** Total page size in KB (rounded up to at least 1). */
|
|
32
|
+
sizeKB: number;
|
|
33
|
+
/** Human age of the most recent page touch: "now" | "Xm" | "Xh" | "Xd" | "". */
|
|
34
|
+
lastTouch: string;
|
|
35
|
+
/** Pages untouched for 30+ days. */
|
|
36
|
+
staleCount: number;
|
|
37
|
+
/** Event kinds (observe/retro/synth/intake/...) seen in the last 7 days. */
|
|
38
|
+
last7dByKind: Record<string, number>;
|
|
39
|
+
/** Total events in the last 7 days. */
|
|
40
|
+
last7dTotal: number;
|
|
41
|
+
/** Total parseable events recorded. */
|
|
42
|
+
totalEvents: number;
|
|
43
|
+
/** Number of raw source packets awaiting ingest (subdirs of raw/sources). */
|
|
44
|
+
rawQueue: number;
|
|
45
|
+
/** Pages with zero backlinks (from backlinks.json). */
|
|
46
|
+
zeroBacklinks: number;
|
|
47
|
+
/** .bin files in emb/ (meaningful when embeddings are enabled). */
|
|
48
|
+
embFiles: number;
|
|
49
|
+
/** Emb dir is populated — embeddings appear enabled. */
|
|
50
|
+
embEnabled: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Directories excluded from the page walk (infrastructure, not pages). */
|
|
54
|
+
const SKIP_DIRS = new Set([
|
|
55
|
+
"templates",
|
|
56
|
+
"cases",
|
|
57
|
+
"outputs",
|
|
58
|
+
".discoveries",
|
|
59
|
+
"raw",
|
|
60
|
+
"meta",
|
|
61
|
+
"emb",
|
|
62
|
+
".git",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
async function walkPages(dir: string, rel: string, out: PageInfo[]): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const childRel = rel === "" ? e.name : `${rel}/${e.name}`;
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
72
|
+
await walkPages(`${dir}/${e.name}`, childRel, out);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!e.name.endsWith(".md")) continue;
|
|
76
|
+
try {
|
|
77
|
+
const st = await stat(`${dir}/${e.name}`);
|
|
78
|
+
out.push({ rel: childRel, mtime: st.mtimeMs, bytes: st.size });
|
|
79
|
+
} catch {
|
|
80
|
+
// unreadable file: skip
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// missing dir (empty vault): nothing to walk
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function humanAge(ms: number): string {
|
|
89
|
+
const mins = Math.floor(ms / 60_000);
|
|
90
|
+
if (mins < 1) return "now";
|
|
91
|
+
if (mins < 60) return `${mins}m`;
|
|
92
|
+
const hrs = Math.floor(mins / 60);
|
|
93
|
+
if (hrs < 48) return `${hrs}h`;
|
|
94
|
+
return `${Math.floor(hrs / 24)}d`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readEvents(metaDir: string) {
|
|
98
|
+
const byKind: Record<string, number> = {};
|
|
99
|
+
let recent = 0;
|
|
100
|
+
let total = 0;
|
|
101
|
+
try {
|
|
102
|
+
const raw = await readFile(`${metaDir}/events.jsonl`, "utf-8");
|
|
103
|
+
const cutoff = Date.now() - 7 * 86400_000;
|
|
104
|
+
for (const line of raw.split("\n")) {
|
|
105
|
+
if (!line.trim()) continue;
|
|
106
|
+
let ev: { timestamp?: unknown; kind?: unknown };
|
|
107
|
+
try {
|
|
108
|
+
ev = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
continue; // corrupted line: skip, not fatal
|
|
111
|
+
}
|
|
112
|
+
const ts = typeof ev.timestamp === "string" ? Date.parse(ev.timestamp) : Number.NaN;
|
|
113
|
+
const kind = typeof ev.kind === "string" && ev.kind ? ev.kind : "event";
|
|
114
|
+
total += 1;
|
|
115
|
+
if (Number.isFinite(ts) && ts >= cutoff) {
|
|
116
|
+
byKind[kind] = (byKind[kind] ?? 0) + 1;
|
|
117
|
+
recent += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// no events file: zeros
|
|
122
|
+
}
|
|
123
|
+
return { byKind, recent, total };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Collect all dashboard stats for the vault reachable from `cwd`.
|
|
128
|
+
* Reuses the extension's own path resolution (same root the tools see).
|
|
129
|
+
*/
|
|
130
|
+
export async function collectDashboardStats(cwd: string): Promise<DashboardStats> {
|
|
131
|
+
const paths: VaultPaths = resolveVaultPaths(cwd);
|
|
132
|
+
|
|
133
|
+
const pages: PageInfo[] = [];
|
|
134
|
+
await walkPages(paths.wiki, "", pages);
|
|
135
|
+
// skill pages register as type:skill and live under <dotWiki>/skills
|
|
136
|
+
try {
|
|
137
|
+
const st = await stat(`${paths.dotWiki}/skills`);
|
|
138
|
+
if (st.isDirectory()) {
|
|
139
|
+
await walkPages(`${paths.dotWiki}/skills`, "skills", pages);
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// no skills dir: fine
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// type map from registry values — keys may carry legacy noise, so normalize
|
|
146
|
+
const registry = readJson<{ pages?: Record<string, { type?: string }> }>(
|
|
147
|
+
`${paths.meta}/registry.json`,
|
|
148
|
+
{ pages: {} },
|
|
149
|
+
);
|
|
150
|
+
const typeByKey: Record<string, string> = {};
|
|
151
|
+
for (const [key, val] of Object.entries(registry.pages ?? {})) {
|
|
152
|
+
const norm = key.startsWith('"') ? key.slice(1) : key;
|
|
153
|
+
if (val?.type) typeByKey[norm] = val.type;
|
|
154
|
+
}
|
|
155
|
+
const byType: Record<string, number> = {};
|
|
156
|
+
for (const p of pages) {
|
|
157
|
+
const slug = p.rel.endsWith(".md") ? p.rel.slice(0, -3) : p.rel;
|
|
158
|
+
const type = typeByKey[slug] ?? p.rel.split("/")[0] ?? "other";
|
|
159
|
+
byType[type] = (byType[type] ?? 0) + 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let sizeBytes = 0;
|
|
163
|
+
let latestMtime = 0;
|
|
164
|
+
const staleCutoff = Date.now() - 30 * 86400_000;
|
|
165
|
+
let stale = 0;
|
|
166
|
+
for (const p of pages) {
|
|
167
|
+
sizeBytes += p.bytes;
|
|
168
|
+
if (p.mtime > latestMtime) latestMtime = p.mtime;
|
|
169
|
+
if (p.mtime < staleCutoff) stale += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const events = await readEvents(paths.meta);
|
|
173
|
+
|
|
174
|
+
let rawQueue = 0;
|
|
175
|
+
try {
|
|
176
|
+
const pkts = await readdir(paths.rawSources, { withFileTypes: true });
|
|
177
|
+
for (const e of pkts) {
|
|
178
|
+
if (e.isDirectory()) rawQueue += 1; // each subdirectory is one packet
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// no raw/sources: empty queue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const backlinks = readJson<Record<string, string[]>>(`${paths.meta}/backlinks.json`, {});
|
|
185
|
+
const zeroBacklinks = Object.values(backlinks).filter((v) => (v ?? []).length === 0).length;
|
|
186
|
+
|
|
187
|
+
let embFiles = 0;
|
|
188
|
+
try {
|
|
189
|
+
const embEntries = await readdir(`${paths.dotWiki}/emb`);
|
|
190
|
+
embFiles = embEntries.filter((f) => f.endsWith(".bin")).length;
|
|
191
|
+
} catch {
|
|
192
|
+
// no emb dir: none
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
root: paths.root,
|
|
197
|
+
pageCount: pages.length,
|
|
198
|
+
byType,
|
|
199
|
+
sizeKB: Math.max(1, Math.round(sizeBytes / 1024)),
|
|
200
|
+
lastTouch: pages.length === 0 ? "" : humanAge(Date.now() - latestMtime),
|
|
201
|
+
staleCount: stale,
|
|
202
|
+
last7dByKind: events.byKind,
|
|
203
|
+
last7dTotal: events.recent,
|
|
204
|
+
totalEvents: events.total,
|
|
205
|
+
rawQueue,
|
|
206
|
+
zeroBacklinks,
|
|
207
|
+
embFiles,
|
|
208
|
+
embEnabled: embFiles > 0,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -79,12 +79,34 @@ function parseMoveDestination(line: string): string | undefined {
|
|
|
79
79
|
function scanPatchString(input: string): MutationScan {
|
|
80
80
|
const paths: string[] = [];
|
|
81
81
|
let sawHeader = false;
|
|
82
|
+
let sawApplyPatchHeader = false;
|
|
83
|
+
let applyPatchComplete = true;
|
|
82
84
|
let sectionHasMove = false;
|
|
83
85
|
let complete = true;
|
|
84
86
|
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
85
87
|
|
|
86
88
|
for (const line of stripped.split("\n")) {
|
|
87
|
-
const
|
|
89
|
+
const raw = line.replace(/\r$/, "");
|
|
90
|
+
// apply_patch envelope headers (#162): matched on the raw (untrimmed)
|
|
91
|
+
// line so body rows (space/+/- prefixed) that quote the envelope shape
|
|
92
|
+
// are never mistaken for headers.
|
|
93
|
+
const fileOp = /^\*{3}\s+(?:update|add|delete)\s+file:\s*(\S.*)$/i.exec(raw);
|
|
94
|
+
if (fileOp) {
|
|
95
|
+
sawApplyPatchHeader = true;
|
|
96
|
+
const path = normalizeMutationPath(fileOp[1]);
|
|
97
|
+
if (path) paths.push(path);
|
|
98
|
+
else applyPatchComplete = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const moveTo = /^\*{3}\s+move\s+to:\s*(\S.*)$/i.exec(raw);
|
|
102
|
+
if (moveTo) {
|
|
103
|
+
sawApplyPatchHeader = true;
|
|
104
|
+
const destination = normalizeMutationPath(moveTo[1]);
|
|
105
|
+
if (destination) paths.push(destination);
|
|
106
|
+
else applyPatchComplete = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const trimmed = raw.trim();
|
|
88
110
|
if (trimmed.startsWith("[")) {
|
|
89
111
|
sawHeader = true;
|
|
90
112
|
sectionHasMove = false;
|
|
@@ -102,6 +124,9 @@ function scanPatchString(input: string): MutationScan {
|
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
if (sawApplyPatchHeader) {
|
|
128
|
+
return { paths, complete: applyPatchComplete && paths.length > 0 };
|
|
129
|
+
}
|
|
105
130
|
return { paths, complete: sawHeader && complete };
|
|
106
131
|
}
|
|
107
132
|
|
|
@@ -63,7 +63,7 @@ export function detectHost(): HostKind {
|
|
|
63
63
|
if (segments.includes(".pi")) return "pi";
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
if (process.env.OMP_PROFILE
|
|
66
|
+
if (process.env.OMP_PROFILE) return "omp";
|
|
67
67
|
return "pi";
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -123,3 +123,23 @@ export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectH
|
|
|
123
123
|
|
|
124
124
|
return join(native, "settings.json");
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The global (user-level) settings file this extension writes to.
|
|
129
|
+
*
|
|
130
|
+
* Always writes to `settings.json` inside the agent dir — both hosts read it.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveGlobalSettingsPath(host: HostKind = detectHost()): string {
|
|
133
|
+
let agentDir = "";
|
|
134
|
+
try {
|
|
135
|
+
agentDir = getAgentDir();
|
|
136
|
+
} catch {
|
|
137
|
+
agentDir = "";
|
|
138
|
+
}
|
|
139
|
+
if (!agentDir) {
|
|
140
|
+
// Fallback: ~/.pi/agent/settings.json
|
|
141
|
+
const home = process.env.HOME || "~";
|
|
142
|
+
return join(home, ".pi", "agent", "settings.json");
|
|
143
|
+
}
|
|
144
|
+
return join(agentDir, "settings.json");
|
|
145
|
+
}
|
|
@@ -475,6 +475,8 @@ export interface RunIngestSynthesisArgs {
|
|
|
475
475
|
signal?: AbortSignal;
|
|
476
476
|
/** BCP 47 language tag for narrative content (issue #124). */
|
|
477
477
|
synthesisLanguage?: string;
|
|
478
|
+
/** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
|
|
479
|
+
synthesisMaxTokens?: number;
|
|
478
480
|
}
|
|
479
481
|
|
|
480
482
|
/**
|
|
@@ -496,6 +498,7 @@ export async function runIngestSynthesis(
|
|
|
496
498
|
maxChars,
|
|
497
499
|
signal,
|
|
498
500
|
synthesisLanguage,
|
|
501
|
+
synthesisMaxTokens,
|
|
499
502
|
} = args;
|
|
500
503
|
const content = extracted.slice(0, maxChars ?? 24_000);
|
|
501
504
|
if (!content.trim()) return undefined;
|
|
@@ -549,6 +552,7 @@ export async function runIngestSynthesis(
|
|
|
549
552
|
systemPrompt,
|
|
550
553
|
userPrompt,
|
|
551
554
|
tools: [commitTool as AgentTool],
|
|
555
|
+
maxTokens: synthesisMaxTokens ?? 16384,
|
|
552
556
|
signal,
|
|
553
557
|
});
|
|
554
558
|
|
|
@@ -588,6 +588,22 @@ export function serializeKnowledgeDocument(document: KnowledgeDocument): string
|
|
|
588
588
|
return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
/** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
|
|
592
|
+
function escapeWikilinkAliasPipes(body: string): string {
|
|
593
|
+
let inFence = false;
|
|
594
|
+
return body
|
|
595
|
+
.split("\n")
|
|
596
|
+
.map((line) => {
|
|
597
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
598
|
+
inFence = !inFence;
|
|
599
|
+
return line;
|
|
600
|
+
}
|
|
601
|
+
if (inFence) return line;
|
|
602
|
+
return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
|
|
603
|
+
})
|
|
604
|
+
.join("\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
591
607
|
export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
|
|
592
608
|
path: string,
|
|
593
609
|
fields: T & NoInfer<KnowledgeCreationFields>,
|
|
@@ -614,7 +630,7 @@ export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
|
|
|
614
630
|
? { kind: "canonical" as const, value: sources }
|
|
615
631
|
: { kind: "absent" as const };
|
|
616
632
|
|
|
617
|
-
const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
|
|
633
|
+
const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
|
|
618
634
|
|
|
619
635
|
return {
|
|
620
636
|
id: path.replace(/\.md$/, ""),
|
|
@@ -643,7 +659,9 @@ export function patchKnowledgeDocument(
|
|
|
643
659
|
}
|
|
644
660
|
}
|
|
645
661
|
|
|
646
|
-
const newBody = patch.body
|
|
662
|
+
const newBody = patch.body
|
|
663
|
+
? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
|
|
664
|
+
: document.body;
|
|
647
665
|
|
|
648
666
|
return {
|
|
649
667
|
...document,
|
|
@@ -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
|
}
|
|
@@ -345,10 +345,12 @@ export function registerObservationReminder(
|
|
|
345
345
|
return true;
|
|
346
346
|
};
|
|
347
347
|
let turnsSinceLastReminder = 0;
|
|
348
|
+
let agentEndsInUserTurn = 0;
|
|
348
349
|
|
|
349
350
|
pi.on("session_start", async () => {
|
|
350
351
|
turnsSinceLastReminder = 0;
|
|
351
352
|
reminderState.observeDoneThisSession = false;
|
|
353
|
+
agentEndsInUserTurn = 0;
|
|
352
354
|
});
|
|
353
355
|
|
|
354
356
|
// After compaction, reset turn counter so reminders resume
|
|
@@ -358,10 +360,18 @@ export function registerObservationReminder(
|
|
|
358
360
|
turnsSinceLastReminder = 0;
|
|
359
361
|
});
|
|
360
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
|
+
|
|
361
371
|
pi.on("agent_end", async (event, _ctx) => {
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
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.
|
|
365
375
|
if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
|
|
366
376
|
|
|
367
377
|
// No wiki applies here: never nag, and never accumulate a pending reminder
|
|
@@ -372,6 +382,12 @@ export function registerObservationReminder(
|
|
|
372
382
|
if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
|
|
373
383
|
if (reminderState.observeDoneThisSession) return;
|
|
374
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
|
+
|
|
375
391
|
pi.sendMessage(
|
|
376
392
|
{
|
|
377
393
|
customType: "wiki-observe-reminder",
|
|
@@ -382,5 +398,9 @@ export function registerObservationReminder(
|
|
|
382
398
|
deliverAs: "nextTurn",
|
|
383
399
|
},
|
|
384
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;
|
|
385
405
|
});
|
|
386
406
|
}
|