@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6
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 +44 -20
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
- package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
- package/dist/extensions/llm-wiki/lib/observation.js +22 -3
- package/dist/extensions/llm-wiki/lib/retro.js +38 -4
- package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
- package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
- package/dist/extensions/llm-wiki/lib/tools.js +47 -8
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/operations.js +21 -2
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +11 -0
- package/docs/obsidian.md +6 -6
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
- package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -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 +64 -27
- package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
- package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
- package/extensions/llm-wiki/lib/metadata.ts +10 -6
- package/extensions/llm-wiki/lib/observation.ts +23 -3
- package/extensions/llm-wiki/lib/retro.ts +48 -4
- package/extensions/llm-wiki/lib/runtime.ts +2 -2
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +138 -0
- package/extensions/llm-wiki/lib/tools.ts +62 -8
- package/mcp/index.ts +12 -1
- package/mcp/operations.ts +32 -2
- 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
|
+
}
|
|
@@ -13,9 +13,15 @@ import {
|
|
|
13
13
|
serializeKnowledgeDocument,
|
|
14
14
|
writeKnowledgeDocumentFile,
|
|
15
15
|
} from "./knowledge-document.js";
|
|
16
|
+
import {
|
|
17
|
+
type WikilinkValidationMode,
|
|
18
|
+
auditWikilinks,
|
|
19
|
+
buildWikilinkIndex,
|
|
20
|
+
} from "./knowledge-links.js";
|
|
16
21
|
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
17
22
|
import { runSubAgent } from "./subagent.js";
|
|
18
|
-
import {
|
|
23
|
+
import { resolveWikilinkValidation } from "./task-config.js";
|
|
24
|
+
import { type VaultPaths, fmtDate, readJson, slugify } from "./utils.js";
|
|
19
25
|
import { VaultWriteError, assertWritableVault } from "./vault-format.js";
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -84,6 +90,8 @@ export interface CommitResult {
|
|
|
84
90
|
entitiesLinked: string[];
|
|
85
91
|
conceptsLinked: string[];
|
|
86
92
|
contradictions: number;
|
|
93
|
+
/** Wikilink gate diagnostics from `commitSynthesis` (warn/normalize modes). */
|
|
94
|
+
wikilinkDiagnostics?: KnowledgeDiagnostic[];
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export type CommitSynthesisOutcome =
|
|
@@ -186,43 +194,23 @@ function getHeadings(lang?: string): Record<string, string> {
|
|
|
186
194
|
};
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
function buildEntityPageBody(
|
|
190
|
-
title: string,
|
|
191
|
-
description: string,
|
|
192
|
-
sourceId: string,
|
|
193
|
-
lang?: string,
|
|
194
|
-
): string {
|
|
197
|
+
function buildEntityPageBody(title: string, description: string, sourceId: string): string {
|
|
195
198
|
const desc = description.trim() || "One-line description.";
|
|
196
|
-
const h = getHeadings(lang);
|
|
197
199
|
return `# ${title}
|
|
198
200
|
|
|
199
201
|
${desc}
|
|
200
202
|
|
|
201
|
-
## ${h.overview}
|
|
202
|
-
|
|
203
|
-
[Key facts]
|
|
204
|
-
|
|
205
203
|
## Links
|
|
206
204
|
|
|
207
205
|
- [${sourceId}](/sources/${sourceId}.md)`;
|
|
208
206
|
}
|
|
209
207
|
|
|
210
|
-
function buildConceptPageBody(
|
|
211
|
-
title: string,
|
|
212
|
-
definition: string,
|
|
213
|
-
sourceId: string,
|
|
214
|
-
lang?: string,
|
|
215
|
-
): string {
|
|
208
|
+
function buildConceptPageBody(title: string, definition: string, sourceId: string): string {
|
|
216
209
|
const def = definition.trim() || "One-line definition.";
|
|
217
|
-
const h = getHeadings(lang);
|
|
218
210
|
return `# ${title}
|
|
219
211
|
|
|
220
212
|
${def}
|
|
221
213
|
|
|
222
|
-
## ${h.definition}
|
|
223
|
-
|
|
224
|
-
[Clear explanation]
|
|
225
|
-
|
|
226
214
|
## Links
|
|
227
215
|
|
|
228
216
|
- [${sourceId}](/sources/${sourceId}.md)`;
|
|
@@ -321,6 +309,28 @@ export function buildIngestedSourcePage(
|
|
|
321
309
|
return serializeKnowledgeDocument(doc);
|
|
322
310
|
}
|
|
323
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Build the wikilink index used by the pre-write gate: every existing page id
|
|
314
|
+
* (from the registry) plus the page ids this commit is about to create, so a
|
|
315
|
+
* link to a sibling created in the same ingest resolves instead of
|
|
316
|
+
* false-positiving as missing.
|
|
317
|
+
*/
|
|
318
|
+
function buildIngestAuditIndex(
|
|
319
|
+
paths: VaultPaths,
|
|
320
|
+
sourceId: string,
|
|
321
|
+
data: SynthesisData,
|
|
322
|
+
): ReturnType<typeof buildWikilinkIndex> {
|
|
323
|
+
const registry = readJson<{ pages: Record<string, unknown> }>(join(paths.meta, "registry.json"), {
|
|
324
|
+
pages: {},
|
|
325
|
+
});
|
|
326
|
+
const newIds = [
|
|
327
|
+
`sources/${sourceId}`,
|
|
328
|
+
...data.entities.filter((e) => slugify(e.title)).map((e) => `entities/${slugify(e.title)}`),
|
|
329
|
+
...data.concepts.filter((c) => slugify(c.title)).map((c) => `concepts/${slugify(c.title)}`),
|
|
330
|
+
];
|
|
331
|
+
return buildWikilinkIndex([...Object.keys(registry.pages), ...newIds]);
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
/**
|
|
325
335
|
* Persist a synthesis deterministically: rewrite the source page (status →
|
|
326
336
|
* ingested), create missing entity/concept pages (existing pages are linked,
|
|
@@ -333,6 +343,7 @@ export function commitSynthesis(
|
|
|
333
343
|
data: SynthesisData,
|
|
334
344
|
date: string = fmtDate(),
|
|
335
345
|
lang?: string,
|
|
346
|
+
wikilinkValidation?: WikilinkValidationMode,
|
|
336
347
|
): CommitSynthesisOutcome {
|
|
337
348
|
const result: CommitResult = {
|
|
338
349
|
sourceId,
|
|
@@ -353,6 +364,24 @@ export function commitSynthesis(
|
|
|
353
364
|
throw error;
|
|
354
365
|
}
|
|
355
366
|
|
|
367
|
+
// Pre-write wikilink gate (issue #172, Layer 2). Applies only to the
|
|
368
|
+
// model-authored source body; entity/concept pages are generated templates.
|
|
369
|
+
const mode = resolveWikilinkValidation({ wikilinkValidation });
|
|
370
|
+
let sourceBody = buildIngestedSourcePageBody(manifest, data, date, lang);
|
|
371
|
+
if (mode !== "off") {
|
|
372
|
+
const audit = auditWikilinks(
|
|
373
|
+
sourceBody,
|
|
374
|
+
buildIngestAuditIndex(paths, sourceId, data),
|
|
375
|
+
sourceId,
|
|
376
|
+
mode,
|
|
377
|
+
);
|
|
378
|
+
if (mode === "strict" && audit.diagnostics.length > 0) {
|
|
379
|
+
return { ok: false, sourceId, diagnostics: audit.diagnostics };
|
|
380
|
+
}
|
|
381
|
+
if (mode === "normalize") sourceBody = audit.body;
|
|
382
|
+
if (audit.diagnostics.length > 0) result.wikilinkDiagnostics = audit.diagnostics;
|
|
383
|
+
}
|
|
384
|
+
|
|
356
385
|
// Patch existing documents so unknown fields, legacy sources, and titles survive.
|
|
357
386
|
let sourceDocument: KnowledgeDocument;
|
|
358
387
|
if (existsSync(result.sourcePage)) {
|
|
@@ -360,7 +389,7 @@ export function commitSynthesis(
|
|
|
360
389
|
if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
|
|
361
390
|
sourceDocument = patchKnowledgeDocument(parsed.document, {
|
|
362
391
|
fields: { status: "ingested", updated: date },
|
|
363
|
-
body:
|
|
392
|
+
body: sourceBody,
|
|
364
393
|
});
|
|
365
394
|
} else {
|
|
366
395
|
sourceDocument = createKnowledgeDocument(
|
|
@@ -375,7 +404,7 @@ export function commitSynthesis(
|
|
|
375
404
|
status: "ingested",
|
|
376
405
|
updated: date,
|
|
377
406
|
},
|
|
378
|
-
|
|
407
|
+
sourceBody,
|
|
379
408
|
);
|
|
380
409
|
}
|
|
381
410
|
mkdirSync(join(paths.wiki, "sources"), { recursive: true });
|
|
@@ -399,7 +428,7 @@ export function commitSynthesis(
|
|
|
399
428
|
created: date,
|
|
400
429
|
updated: date,
|
|
401
430
|
},
|
|
402
|
-
buildEntityPageBody(e.title, e.description, sourceId
|
|
431
|
+
buildEntityPageBody(e.title, e.description, sourceId),
|
|
403
432
|
[{ id: sourceId, resource: `/sources/${sourceId}.md` }],
|
|
404
433
|
);
|
|
405
434
|
writeKnowledgeDocumentFile(pagePath, entityDoc);
|
|
@@ -425,7 +454,7 @@ export function commitSynthesis(
|
|
|
425
454
|
created: date,
|
|
426
455
|
updated: date,
|
|
427
456
|
},
|
|
428
|
-
buildConceptPageBody(c.title, c.definition, sourceId
|
|
457
|
+
buildConceptPageBody(c.title, c.definition, sourceId),
|
|
429
458
|
[{ id: sourceId, resource: `/sources/${sourceId}.md` }],
|
|
430
459
|
);
|
|
431
460
|
writeKnowledgeDocumentFile(pagePath, conceptDoc);
|
|
@@ -475,6 +504,10 @@ export interface RunIngestSynthesisArgs {
|
|
|
475
504
|
signal?: AbortSignal;
|
|
476
505
|
/** BCP 47 language tag for narrative content (issue #124). */
|
|
477
506
|
synthesisLanguage?: string;
|
|
507
|
+
/** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
|
|
508
|
+
synthesisMaxTokens?: number;
|
|
509
|
+
/** Wikilink write-gate mode for the ingested source body (issue #172). */
|
|
510
|
+
wikilinkValidation?: WikilinkValidationMode;
|
|
478
511
|
}
|
|
479
512
|
|
|
480
513
|
/**
|
|
@@ -496,6 +529,8 @@ export async function runIngestSynthesis(
|
|
|
496
529
|
maxChars,
|
|
497
530
|
signal,
|
|
498
531
|
synthesisLanguage,
|
|
532
|
+
synthesisMaxTokens,
|
|
533
|
+
wikilinkValidation,
|
|
499
534
|
} = args;
|
|
500
535
|
const content = extracted.slice(0, maxChars ?? 24_000);
|
|
501
536
|
if (!content.trim()) return undefined;
|
|
@@ -521,6 +556,7 @@ export async function runIngestSynthesis(
|
|
|
521
556
|
params,
|
|
522
557
|
undefined,
|
|
523
558
|
synthesisLanguage,
|
|
559
|
+
wikilinkValidation,
|
|
524
560
|
);
|
|
525
561
|
if (!outcome.ok) {
|
|
526
562
|
return {
|
|
@@ -549,6 +585,7 @@ export async function runIngestSynthesis(
|
|
|
549
585
|
systemPrompt,
|
|
550
586
|
userPrompt,
|
|
551
587
|
tools: [commitTool as AgentTool],
|
|
588
|
+
maxTokens: synthesisMaxTokens ?? 16384,
|
|
552
589
|
signal,
|
|
553
590
|
});
|
|
554
591
|
|
|
@@ -21,6 +21,7 @@ export type DiagnosticCode =
|
|
|
21
21
|
| "okf_version_mismatch"
|
|
22
22
|
| "link_path_escape"
|
|
23
23
|
| "link_unresolved"
|
|
24
|
+
| "link_ambiguous"
|
|
24
25
|
| "event_source_missing"
|
|
25
26
|
| "event_source_unreadable"
|
|
26
27
|
| "event_invalid_json"
|
|
@@ -588,6 +589,22 @@ export function serializeKnowledgeDocument(document: KnowledgeDocument): string
|
|
|
588
589
|
return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
/** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
|
|
593
|
+
function escapeWikilinkAliasPipes(body: string): string {
|
|
594
|
+
let inFence = false;
|
|
595
|
+
return body
|
|
596
|
+
.split("\n")
|
|
597
|
+
.map((line) => {
|
|
598
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
599
|
+
inFence = !inFence;
|
|
600
|
+
return line;
|
|
601
|
+
}
|
|
602
|
+
if (inFence) return line;
|
|
603
|
+
return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
|
|
604
|
+
})
|
|
605
|
+
.join("\n");
|
|
606
|
+
}
|
|
607
|
+
|
|
591
608
|
export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
|
|
592
609
|
path: string,
|
|
593
610
|
fields: T & NoInfer<KnowledgeCreationFields>,
|
|
@@ -614,7 +631,7 @@ export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
|
|
|
614
631
|
? { kind: "canonical" as const, value: sources }
|
|
615
632
|
: { kind: "absent" as const };
|
|
616
633
|
|
|
617
|
-
const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
|
|
634
|
+
const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
|
|
618
635
|
|
|
619
636
|
return {
|
|
620
637
|
id: path.replace(/\.md$/, ""),
|
|
@@ -643,7 +660,9 @@ export function patchKnowledgeDocument(
|
|
|
643
660
|
}
|
|
644
661
|
}
|
|
645
662
|
|
|
646
|
-
const newBody = patch.body
|
|
663
|
+
const newBody = patch.body
|
|
664
|
+
? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
|
|
665
|
+
: document.body;
|
|
647
666
|
|
|
648
667
|
return {
|
|
649
668
|
...document,
|