@zosmaai/pi-llm-wiki 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.de.md +8 -0
- package/README.es.md +8 -0
- package/README.fr.md +8 -0
- package/README.hi.md +8 -0
- package/README.ja.md +8 -0
- package/README.ko.md +8 -0
- package/README.md +88 -2
- package/README.pt.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/assets/wiki-dashboard.png +0 -0
- package/commands/wiki-digest.md +28 -0
- package/commands/wiki-discover.md +30 -0
- package/commands/wiki-ingest.md +37 -0
- package/commands/wiki-init.md +30 -0
- package/commands/wiki-lint.md +25 -0
- package/commands/wiki-query.md +37 -0
- package/commands/wiki-record.md +36 -0
- package/commands/wiki-req.md +56 -0
- package/commands/wiki-retro.md +35 -0
- package/commands/wiki-run.md +31 -0
- package/commands/wiki-skills.md +26 -0
- package/commands/wiki-status.md +16 -0
- package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
- package/dist/extensions/llm-wiki/lib/host.js +117 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
- package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
- package/dist/extensions/llm-wiki/lib/observation.js +31 -3
- package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +145 -43
- package/dist/extensions/llm-wiki/lib/utils.js +59 -16
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +62 -11
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
- package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
- package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
- package/extensions/llm-wiki/index.ts +48 -6
- package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
- package/extensions/llm-wiki/lib/dashboard.ts +210 -0
- package/extensions/llm-wiki/lib/guardrails.ts +26 -1
- package/extensions/llm-wiki/lib/host.ts +145 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
- package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
- package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
- package/extensions/llm-wiki/lib/metadata.ts +1 -1
- package/extensions/llm-wiki/lib/observation.ts +37 -4
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +208 -46
- package/extensions/llm-wiki/lib/utils.ts +55 -14
- package/package.json +15 -4
- package/prompts/wiki-ingest.md +1 -0
- package/prompts/wiki-req.md +1 -0
- package/prompts/wiki-retro.md +1 -0
- package/skills/llm-wiki/SKILL.md +11 -1
|
@@ -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,7 +21,13 @@ import {
|
|
|
20
21
|
} from "./lib/recall.js";
|
|
21
22
|
import { registerWikiRetro } from "./lib/retro.js";
|
|
22
23
|
import { registerBackgroundRuntime } from "./lib/runtime.js";
|
|
23
|
-
import {
|
|
24
|
+
import { registerWikiSettingsCommand } from "./lib/settings-command.js";
|
|
25
|
+
import {
|
|
26
|
+
loadTaskConfig,
|
|
27
|
+
noticesEnabled,
|
|
28
|
+
personalVaultIsAmbient,
|
|
29
|
+
trajectoriesEnabled,
|
|
30
|
+
} from "./lib/task-config.js";
|
|
24
31
|
import {
|
|
25
32
|
registerWikiBootstrap,
|
|
26
33
|
registerWikiCaptureSource,
|
|
@@ -40,7 +47,11 @@ import {
|
|
|
40
47
|
registerWikiDistillSkills,
|
|
41
48
|
registerWikiRecallSkill,
|
|
42
49
|
} from "./lib/trajectory.js";
|
|
43
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
migrateDoubledPersonalVault,
|
|
52
|
+
resolveProjectVaultRoot,
|
|
53
|
+
resolveVaultPaths,
|
|
54
|
+
} from "./lib/utils.js";
|
|
44
55
|
import { inspectWritableVault } from "./lib/vault-format.js";
|
|
45
56
|
import { applySessionStartStatus } from "./lib/visible-status.js";
|
|
46
57
|
|
|
@@ -68,6 +79,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
68
79
|
// work. Created first so tools (e.g. wiki_ingest) can dispatch to it.
|
|
69
80
|
const runtime = registerBackgroundRuntime(pi);
|
|
70
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Does a wiki apply to the directory this session is working in?
|
|
84
|
+
*
|
|
85
|
+
* Gates every AMBIENT surface — the ones that speak without being asked:
|
|
86
|
+
* session bootstrap/notice, the periodic observe/retro reminder, and
|
|
87
|
+
* `before_agent_start` recall injection. Tools and commands are registered
|
|
88
|
+
* regardless, so `/wiki-init` remains the way in.
|
|
89
|
+
*
|
|
90
|
+
* `resolveVaultRoot` falls back to the personal vault when a project has
|
|
91
|
+
* none, which is why the ambient surfaces used to fire in EVERY directory
|
|
92
|
+
* once a personal vault existed — reminders and unrelated cross-project
|
|
93
|
+
* recall hits leaking into repositories that never initialized a wiki.
|
|
94
|
+
* Under omp that fallback is off by default (`llm-wiki.ambientPersonalVault`);
|
|
95
|
+
* under pi it stays on, preserving the historical behavior.
|
|
96
|
+
*
|
|
97
|
+
* Resolved per call, not once at load: `cwd` changes within a session.
|
|
98
|
+
*/
|
|
99
|
+
const wikiAppliesTo = (cwd: string): boolean =>
|
|
100
|
+
resolveProjectVaultRoot(cwd) !== null || personalVaultIsAmbient(runtime.config);
|
|
101
|
+
|
|
71
102
|
registerWikiBootstrap(pi);
|
|
72
103
|
registerWikiCaptureSource(pi, runtime);
|
|
73
104
|
registerWikiIngest(pi, runtime);
|
|
@@ -102,13 +133,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
102
133
|
// background task model. The taskModel config field + resolveModel already
|
|
103
134
|
// exist; this exposes them to the user (default stays the session model).
|
|
104
135
|
registerWikiModelCommand(pi, runtime);
|
|
136
|
+
registerWikiSettingsCommand(pi, runtime);
|
|
137
|
+
registerWikiDashboardCommand(pi, runtime);
|
|
105
138
|
const reminderState = createReminderState();
|
|
106
139
|
registerWikiObserve(pi, runtime, reminderState);
|
|
107
140
|
// Visible observe/retro reminder by default (issue #77); silenced when the
|
|
108
141
|
// user sets `llm-wiki.notices: false`. Resolver reads the live config so the
|
|
109
|
-
// setting takes effect without a restart.
|
|
142
|
+
// setting takes effect without a restart. `display: false` still injects the
|
|
143
|
+
// reminder into model context, so the "no wiki here" case needs its own gate.
|
|
110
144
|
registerObservationReminder(pi, reminderState, {
|
|
111
145
|
display: () => noticesEnabled(runtime.config),
|
|
146
|
+
enabled: () => wikiAppliesTo(process.cwd()),
|
|
112
147
|
});
|
|
113
148
|
|
|
114
149
|
installGuardrails(pi, runtime);
|
|
@@ -137,6 +172,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
137
172
|
console.warn(`[llm-wiki] doubled-dotdir migration skipped: ${(err as Error).message}`);
|
|
138
173
|
}
|
|
139
174
|
|
|
175
|
+
// Ambient gate. `ensureConfig` first so an explicit
|
|
176
|
+
// `llm-wiki.ambientPersonalVault` is honored on the very first session —
|
|
177
|
+
// `runtime.config` is otherwise empty until the first `turn_start`.
|
|
178
|
+
runtime.ensureConfig(process.cwd());
|
|
179
|
+
if (!wikiAppliesTo(process.cwd())) return;
|
|
180
|
+
|
|
140
181
|
const paths = resolveVaultPaths(process.cwd());
|
|
141
182
|
if (!existsSync(join(paths.dotWiki, "config.json"))) {
|
|
142
183
|
// Silently create the wiki vault — no UI prompts. Topic/mode will be
|
|
@@ -168,9 +209,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
209
|
|
|
169
210
|
// Surface the "wiki active" badge and the active background task model
|
|
170
211
|
// (issue #69), both gated by `llm-wiki.notices` (issue #77, regression
|
|
171
|
-
// fixed in #83, helper extracted in #84). `ensureConfig`
|
|
172
|
-
//
|
|
173
|
-
runtime.ensureConfig(process.cwd());
|
|
212
|
+
// fixed in #83, helper extracted in #84). The `ensureConfig` above the
|
|
213
|
+
// ambient gate already loaded the project settings this reads.
|
|
174
214
|
applySessionStartStatus({
|
|
175
215
|
ui: ctx.ui,
|
|
176
216
|
runtime,
|
|
@@ -196,6 +236,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
196
236
|
// from the user's first prompt and update config via wiki_bootstrap.
|
|
197
237
|
// 2. Search both personal + project vaults for relevant pages.
|
|
198
238
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
239
|
+
if (!wikiAppliesTo(process.cwd())) return;
|
|
240
|
+
|
|
199
241
|
const paths = resolveVaultPaths(process.cwd());
|
|
200
242
|
if (!existsSync(join(paths.dotWiki, "config.json"))) {
|
|
201
243
|
return;
|
|
@@ -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
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Host adapter for the two coding agents that can load this extension:
|
|
7
|
+
*
|
|
8
|
+
* - **pi** — `@mariozechner/pi-coding-agent`, config dir `.pi`
|
|
9
|
+
* - **omp** — oh-my-pi (`@oh-my-pi/pi-coding-agent`), config dir `.omp`
|
|
10
|
+
*
|
|
11
|
+
* omp rewrites `@mariozechner/pi-*` (and bare `typebox`) imports onto its own
|
|
12
|
+
* bundled packages at load time (its `legacy-pi-compat.ts`), so the *module
|
|
13
|
+
* graph* needs no changes. What does differ is the on-disk config layout:
|
|
14
|
+
*
|
|
15
|
+
* | | pi | omp |
|
|
16
|
+
* |---|---|---|
|
|
17
|
+
* | user dir | `~/.pi/agent` | `~/.omp/agent` |
|
|
18
|
+
* | project dir | `<cwd>/.pi` | `<cwd>/.omp` |
|
|
19
|
+
* | settings file | `settings.json` | `settings.json`, then `config.yml` |
|
|
20
|
+
*
|
|
21
|
+
* omp explicitly does **not** read `.pi` (its config source order is
|
|
22
|
+
* `.omp` → `.claude` → `.codex` → `.gemini`), so a wiki configured under pi
|
|
23
|
+
* would silently lose its settings after switching hosts. This module keeps
|
|
24
|
+
* both layouts readable and picks a sensible file to write to.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is additive: on pi with only a `.pi/` directory the effective
|
|
27
|
+
* behaviour is identical to the pre-compat code path, which keeps upstream
|
|
28
|
+
* merges clean.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type HostKind = "pi" | "omp";
|
|
32
|
+
|
|
33
|
+
/** Project config directory name per host. */
|
|
34
|
+
const CONFIG_DIR: Record<HostKind, string> = { pi: ".pi", omp: ".omp" };
|
|
35
|
+
|
|
36
|
+
/** Settings file names inside a config directory, lowest → highest precedence. */
|
|
37
|
+
const SETTINGS_FILES = ["settings.json", "config.yml", "config.yaml"] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect which agent is hosting this extension.
|
|
41
|
+
*
|
|
42
|
+
* Ordered by reliability:
|
|
43
|
+
* 1. `LLM_WIKI_HOST` — explicit escape hatch (tests, exotic embeddings).
|
|
44
|
+
* 2. The agent directory path: pi resolves `~/.pi/agent`, omp `~/.omp/agent`.
|
|
45
|
+
* A `PI_CODING_AGENT_DIR` override that keeps the marker segment still
|
|
46
|
+
* classifies correctly; anything else falls through.
|
|
47
|
+
* 3. `OMP_PROFILE`, which omp sets on itself whenever a profile is active.
|
|
48
|
+
* 4. Default `pi` — the historical behaviour.
|
|
49
|
+
*/
|
|
50
|
+
export function detectHost(): HostKind {
|
|
51
|
+
const forced = process.env.LLM_WIKI_HOST?.trim().toLowerCase();
|
|
52
|
+
if (forced === "omp" || forced === "pi") return forced;
|
|
53
|
+
|
|
54
|
+
let agentDir = "";
|
|
55
|
+
try {
|
|
56
|
+
agentDir = getAgentDir();
|
|
57
|
+
} catch {
|
|
58
|
+
agentDir = "";
|
|
59
|
+
}
|
|
60
|
+
if (agentDir) {
|
|
61
|
+
const segments = agentDir.split(/[\\/]/);
|
|
62
|
+
if (segments.includes(".omp")) return "omp";
|
|
63
|
+
if (segments.includes(".pi")) return "pi";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (process.env.OMP_PROFILE) return "omp";
|
|
67
|
+
return "pi";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every project settings file that may hold `llm-wiki` configuration, ordered
|
|
72
|
+
* from lowest to highest precedence so callers can merge left-to-right.
|
|
73
|
+
*
|
|
74
|
+
* The host's *native* directory is last (wins). The foreign directory is still
|
|
75
|
+
* read so a vault configured under pi keeps working after omp takes over the
|
|
76
|
+
* repository, and vice versa. Within a directory `config.yml` follows
|
|
77
|
+
* `settings.json`, matching omp's own project-settings precedence.
|
|
78
|
+
*/
|
|
79
|
+
export function listProjectSettingsFiles(cwd: string, host: HostKind = detectHost()): string[] {
|
|
80
|
+
const foreign: HostKind = host === "omp" ? "pi" : "omp";
|
|
81
|
+
const files: string[] = [];
|
|
82
|
+
for (const kind of [foreign, host]) {
|
|
83
|
+
const dir = join(cwd, CONFIG_DIR[kind]);
|
|
84
|
+
for (const name of SETTINGS_FILES) files.push(join(dir, name));
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* User-level settings files, lowest → highest precedence.
|
|
91
|
+
*
|
|
92
|
+
* `getAgentDir()` already resolves per host (`~/.pi/agent` vs `~/.omp/agent`),
|
|
93
|
+
* so only the file names differ: omp migrates `settings.json` into `config.yml`
|
|
94
|
+
* on first start, and a migrated install has *only* the YAML file.
|
|
95
|
+
*/
|
|
96
|
+
export function listGlobalSettingsFiles(): string[] {
|
|
97
|
+
let agentDir = "";
|
|
98
|
+
try {
|
|
99
|
+
agentDir = getAgentDir();
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
if (!agentDir) return [];
|
|
104
|
+
return SETTINGS_FILES.map((name) => join(agentDir, name));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The project settings file this extension writes to.
|
|
109
|
+
*
|
|
110
|
+
* Always JSON (`settings.json`) — both hosts read it, and rewriting a user's
|
|
111
|
+
* hand-authored `config.yml` would destroy comments and formatting.
|
|
112
|
+
*
|
|
113
|
+
* Directory choice: an already-existing project config directory wins (so a
|
|
114
|
+
* repo that only has `.pi/` keeps a single settings file), otherwise the
|
|
115
|
+
* detected host's native directory is created.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectHost()): string {
|
|
118
|
+
const native = join(cwd, CONFIG_DIR[host]);
|
|
119
|
+
if (existsSync(native)) return join(native, "settings.json");
|
|
120
|
+
|
|
121
|
+
const foreign = join(cwd, CONFIG_DIR[host === "omp" ? "pi" : "omp"]);
|
|
122
|
+
if (existsSync(foreign)) return join(foreign, "settings.json");
|
|
123
|
+
|
|
124
|
+
return join(native, "settings.json");
|
|
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,
|