@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.de.md +8 -0
  3. package/README.es.md +8 -0
  4. package/README.fr.md +8 -0
  5. package/README.hi.md +8 -0
  6. package/README.ja.md +8 -0
  7. package/README.ko.md +8 -0
  8. package/README.md +88 -2
  9. package/README.pt.md +8 -0
  10. package/README.ru.md +8 -0
  11. package/README.zh.md +8 -0
  12. package/assets/wiki-dashboard.png +0 -0
  13. package/commands/wiki-digest.md +28 -0
  14. package/commands/wiki-discover.md +30 -0
  15. package/commands/wiki-ingest.md +37 -0
  16. package/commands/wiki-init.md +30 -0
  17. package/commands/wiki-lint.md +25 -0
  18. package/commands/wiki-query.md +37 -0
  19. package/commands/wiki-record.md +36 -0
  20. package/commands/wiki-req.md +56 -0
  21. package/commands/wiki-retro.md +35 -0
  22. package/commands/wiki-run.md +31 -0
  23. package/commands/wiki-skills.md +26 -0
  24. package/commands/wiki-status.md +16 -0
  25. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  26. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  27. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  28. package/dist/extensions/llm-wiki/lib/host.js +117 -0
  29. package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
  30. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  31. package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
  32. package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
  33. package/dist/extensions/llm-wiki/lib/observation.js +31 -3
  34. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  35. package/dist/extensions/llm-wiki/lib/task-config.js +145 -43
  36. package/dist/extensions/llm-wiki/lib/utils.js +59 -16
  37. package/docs/api.md +24 -1
  38. package/docs/commands.md +6 -1
  39. package/docs/configuration.md +62 -11
  40. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  41. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  42. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  43. package/extensions/llm-wiki/index.ts +48 -6
  44. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  45. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  46. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  47. package/extensions/llm-wiki/lib/host.ts +145 -0
  48. package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
  49. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
  50. package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
  51. package/extensions/llm-wiki/lib/metadata.ts +1 -1
  52. package/extensions/llm-wiki/lib/observation.ts +37 -4
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +208 -46
  55. package/extensions/llm-wiki/lib/utils.ts +55 -14
  56. package/package.json +15 -4
  57. package/prompts/wiki-ingest.md +1 -0
  58. package/prompts/wiki-req.md +1 -0
  59. package/prompts/wiki-retro.md +1 -0
  60. package/skills/llm-wiki/SKILL.md +11 -1
@@ -0,0 +1,36 @@
1
+ ---
2
+ description: Capture the just-completed task's tool-call trajectory into the wiki as agent working-memory, then optionally distill it into a reusable skill.
3
+ argument-hint: "<title> [--outcome success|failure|partial]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-record
9
+
10
+ Capture the trajectory of the task you just completed — the sequence of tool calls that solved it — into the wiki's working-memory layer.
11
+
12
+ This is the counterpart to source capture: instead of recording what you *read*, it records what you *did*, so the wiki compounds over your own work.
13
+
14
+ ## User Arguments
15
+
16
+ $ARGUMENTS
17
+
18
+ Read the LLM Wiki skill at `.pi/skills/llm-wiki/SKILL.md` first to understand the wiki conventions.
19
+
20
+ ## Steps
21
+
22
+ 1. Call `wiki_capture_trajectory` with:
23
+ - `title`: short descriptive phrase for the task (≤60 chars, noun phrase)
24
+ - `outcome`: optional — `success` (default), `failure`, or `partial`
25
+ - The extension auto-extracts the tool-call trajectory from the live session, so you usually do **not** pass `steps` manually.
26
+ 2. Open the generated skeleton case page in `wiki/cases/` and flesh out:
27
+ - **Task** — what was requested
28
+ - **Approach** — the key steps and decisions (not every tool call, just the meaningful ones)
29
+ - **Outcome** — the result, and anything worth reusing or avoiding next time
30
+ 3. If the task taught a reusable pattern, run `wiki_distill_skills` and create a `skill` page via `wiki_ensure_page(type="skill")` that cites `[[trajectories/TRJ-...]]`.
31
+ 4. Confirm the case (and any skill) will be surfaced by `wiki_recall` / `wiki_recall_skill` in future sessions.
32
+
33
+ **Rules:**
34
+ - Only record tasks worth learning from — non-trivial debugging, refactors, integrations, multi-step workflows. Skip trivial one-shot answers.
35
+ - The raw trajectory packet under `raw/trajectories/` is immutable. Edit the `case`/`skill` pages, never the packet.
36
+ - One trajectory per `wiki_capture_trajectory` call.
@@ -0,0 +1,56 @@
1
+ ---
2
+ description: Capture and decompose a concept into atomic, traceable wiki requirements. Clarifies ambiguous requirements, splits them into atomic pieces, and persists them as wiki/requirements/ pages with status tracking.
3
+ argument-hint: "<concept description>"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-req
9
+
10
+ Capture a concept and decompose it into atomic, traceable requirements in the wiki.
11
+
12
+ Transforms natural language descriptions into structured `wiki/requirements/` pages, preserving the original clarified concept as an immutable source packet in `raw/sources/`.
13
+
14
+ ## User Arguments
15
+
16
+ $ARGUMENTS
17
+
18
+ Read the LLM Wiki skill at `.pi/skills/llm-wiki/SKILL.md` first to understand the wiki conventions, architecture, and page type rules.
19
+
20
+ ## Steps
21
+
22
+ 1. **Clarify the concept**
23
+ - Discuss with the user: unpack ambiguous terms, surfaces implicit assumptions, identify scope boundaries
24
+ - Ask targeted questions to resolve unknowns (e.g., "Which providers?", "What's the fallback behavior?", "Who are the actors?")
25
+ - Reach mutual clarity before proceeding
26
+
27
+ 2. **Capture the clarified concept**
28
+ - Call `wiki_capture_source(text=...)` with the clarified conversation as markdown
29
+ - This creates an immutable record in `raw/sources/SRC-YYYY-MM-DD-NNN/`
30
+ - The source captures the original intent verbatim — no interpretation, no decomposition
31
+
32
+ 3. **Decompose into atomic requirements**
33
+ - Break the clarified concept into the smallest meaningful units of functionality
34
+ - Each requirement should represent one independently verifiable behavior
35
+ - For each atomic requirement, call `wiki_ensure_page(type="requirement", title="...", content="...")` where content includes:
36
+ - `type: requirement` and `status: draft` in frontmatter
37
+ - A clear `## Description` section
38
+ - `## Acceptance Criteria` as a checkbox list (the threshold for "done")
39
+ - `source_id` linking back to the source capture
40
+ - `depends_on` linking to any prerequisite requirements
41
+ - `[[wikilinks]]` to relevant entities, concepts, and other wiki pages
42
+ - Set priority based on user input: `p0` (blocking), `p1` (critical), `p2` (important), `p3` (nice-to-have)
43
+
44
+ 4. **Cross-link and finalize**
45
+ - Ensure each requirement page has bidirectional wikilinks to related pages
46
+ - Update any existing entity or concept pages that these requirements reference
47
+ - Report the results: how many requirements created, their priorities, and the source capture ID
48
+
49
+ **Rules:**
50
+ - One atomic requirement per `wiki_ensure_page` call — each must be independently testable
51
+ - Always capture the clarified concept first via `wiki_capture_source` before decomposing
52
+ - Requirements live in `wiki/requirements/` — they are editable wiki pages, not immutable sources
53
+ - Use status values: `draft` → `clarified` → `active` → `implemented` → `deferred` → `rejected`
54
+ - Use priority values: `p0` (blocking), `p1` (critical), `p2` (important), `p3` (nice-to-have)
55
+ - Do not create requirements in `raw/` — that layer is for external source artifacts only
56
+ - Inside Markdown table cells, write aliased wikilinks as `[[target\|alias]]`, never `[[target|alias]]`.
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: Save an atomic insight from the current task into the wiki. Creates a single markdown file that layered recall surfaces in future sessions.
3
+ argument-hint: "<title> [--category <category>]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-retro
9
+
10
+ Save an atomic insight from a completed task into the wiki.
11
+
12
+ Captures what you learned as a single markdown file so that layered recall surfaces it in future sessions.
13
+
14
+ ## User Arguments
15
+
16
+ $ARGUMENTS
17
+
18
+ Read the LLM Wiki skill at `.pi/skills/llm-wiki/SKILL.md` first to understand the wiki conventions.
19
+
20
+ ## Steps
21
+
22
+ 1. Identify the key insight(s) from the current task — non-obvious learnings, patterns, or decisions worth preserving
23
+ 2. For each insight, call `wiki_retro` with:
24
+ - `slug`: unique kebab-case identifier (e.g., `jwt-revocation-pattern`)
25
+ - `title`: short descriptive phrase, ≤60 chars, noun phrase not a sentence
26
+ - `body`: markdown explanation with `[[wikilinks]]` to related wiki pages
27
+ - `category`: optional (frontend, architecture, devops, bugfix, design, etc.)
28
+ 3. Confirm the insight was saved and will be surfaced by layered recall in future sessions
29
+ 4. If the insight relates to existing wiki pages, update those pages with cross-references
30
+
31
+ **Rules:**
32
+ - One atomic insight per `wiki_retro` call. Use multiple calls for multiple insights.
33
+ - Don't save obvious things. Save non-obvious patterns, tradeoffs, and design decisions.
34
+ - Always add `[[wikilinks]]` to connect the new insight with existing wiki knowledge.
35
+ - Inside Markdown table cells, write aliased wikilinks as `[[target\|alias]]`, never `[[target|alias]]`.
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Run the full wiki cycle: discover → ingest → lint. Optionally schedule for auto-updates.
3
+ argument-hint: "[--schedule daily|weekly]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-run
9
+
10
+ Run the complete wiki maintenance cycle: discover new sources, ingest them, and lint for health.
11
+
12
+ ## User Arguments
13
+
14
+ $ARGUMENTS
15
+
16
+ ## Steps
17
+
18
+ 1. **Discover:** Use web search to find new sources on the wiki's topic, then capture each with `wiki_capture_source(url=<url>)` (max 5-10).
19
+ 2. **Ingest:** Call `wiki_ingest(batch_size=3)` and process returned sources — read extracted.md, update source pages, create entity/concept pages, add cross-references.
20
+ 3. **Lint:** Call `wiki_lint(auto_fix=false)` to run a health check.
21
+ 4. If critical gaps found → optionally run one more discover+ingest cycle.
22
+ 5. Save summary to `.llm-wiki/outputs/run-YYYY-MM-DD.md` using the `write` tool.
23
+ 6. Report final summary.
24
+
25
+ ### Scheduling
26
+
27
+ If `--schedule` is provided, call `wiki_watch(interval=<daily|weekly|hourly>)`.
28
+
29
+ **Important:** `wiki_watch` does NOT install a schedule. It only prints a `crontab` line.
30
+ Report the printed line to the user verbatim and tell them to install it themselves with
31
+ `crontab -e`. Do not claim the schedule is active until they confirm they have done so.
@@ -0,0 +1,26 @@
1
+ ---
2
+ description: Search the wiki's distilled skills and past cases for patterns relevant to the current task — "have I done something like this before?".
3
+ argument-hint: "[query] [--kind skill|case]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-skills
9
+
10
+ Search the agent working-memory layer of the wiki: reusable **skills** distilled from past trajectories, and specific past **cases**.
11
+
12
+ ## User Arguments
13
+
14
+ $ARGUMENTS
15
+
16
+ ## Steps
17
+
18
+ 1. Call `wiki_recall_skill` with:
19
+ - `query`: the current task description or key terms (defaults to `$ARGUMENTS`)
20
+ - `kind`: optional — `skill`, `case`, or `any` (default)
21
+ - `max_results`: optional (default 5)
22
+ 2. Read the most relevant skill/case pages with `read`.
23
+ 3. Apply the recalled pattern to the current task, citing the source page with `[[skills/...]]` or `[[cases/...]]` where helpful.
24
+ 4. If no relevant skill/case exists, proceed with the task and consider running `/wiki-record` afterward so the next attempt benefits.
25
+
26
+ **Tip:** Skills generalize across many trajectories ("how I do X"); cases are concrete past runs ("the time I did X for project Y"). Search `any` first, then narrow.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Show wiki health overview — source count, page stats, orphan count, last activity dates.
3
+ argument-hint: ""
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-status
9
+
10
+ Show a quick overview of wiki health and statistics.
11
+
12
+ ## Steps
13
+
14
+ 1. Call `wiki_status()` to get the current wiki health report.
15
+ 2. Present the results to the user.
16
+ 3. If health shows warnings (orphans > 5, many gaps), suggest running `/wiki-lint` for a detailed analysis.
@@ -0,0 +1,86 @@
1
+ import { Container, Text, matchesKey } from "@mariozechner/pi-tui";
2
+ import { collectDashboardStats } from "./dashboard.js";
3
+ const TYPE_ORDER = [
4
+ "concept",
5
+ "entity",
6
+ "source",
7
+ "skill",
8
+ "analysis",
9
+ "synthesis",
10
+ "requirement",
11
+ "short",
12
+ "shared",
13
+ ];
14
+ const KIND_ORDER = ["observe", "retro", "synth", "intake"];
15
+ function orderedPairs(counts) {
16
+ const keys = Object.keys(counts);
17
+ const known = TYPE_ORDER.filter((k) => counts[k] !== undefined);
18
+ const rest = keys
19
+ .filter((k) => !TYPE_ORDER.includes(k))
20
+ .sort()
21
+ .slice(0, 3); // cap the tail: the dashboard is a glance, not a census
22
+ const all = [...known, ...rest];
23
+ if (all.length === 0)
24
+ return "—";
25
+ return all.map((k) => `${k} ${counts[k]}`).join(" · ");
26
+ }
27
+ function orderedKinds(counts) {
28
+ const keys = Object.keys(counts);
29
+ const known = KIND_ORDER.filter((k) => counts[k] !== undefined);
30
+ const rest = keys
31
+ .filter((k) => !KIND_ORDER.includes(k))
32
+ .sort()
33
+ .slice(0, 3);
34
+ return [...known, ...rest].map((k) => `${k} ${counts[k]}`).join(" · ");
35
+ }
36
+ function renderStatsLines(s) {
37
+ const emb = s.embEnabled ? `${s.embFiles}/${s.pageCount}` : "—";
38
+ return [
39
+ `Wiki Dashboard · ${s.root}`,
40
+ "",
41
+ `Pages ${s.pageCount} · ${s.sizeKB}KB · ${orderedPairs(s.byType)}`,
42
+ `Fresh last touch ${s.lastTouch || "never"} · stale(>30d) ${s.staleCount}`,
43
+ `Acts 7d ${s.last7dTotal > 0 ? orderedKinds(s.last7dByKind) : "none"} · total ${s.last7dTotal}/${s.totalEvents}`,
44
+ `Queue raw ${s.rawQueue} · emb ${emb}`,
45
+ `Links zero-backlink ${s.zeroBacklinks} · full scan: /wiki-lint`,
46
+ "",
47
+ "Esc to close",
48
+ ];
49
+ }
50
+ /** One-screen read-only TUI; handles Esc and `q`. */
51
+ export class DashboardScreen extends Container {
52
+ doneFn;
53
+ constructor(lines, close) {
54
+ super();
55
+ this.doneFn = close;
56
+ this.addChild(new Text(lines.join("\n"), 1, 1));
57
+ }
58
+ handleInput(data) {
59
+ // ponytail: matchesKey handles every terminal key shape (raw bytes,
60
+ // kitty CSI-u like \u001b[27u for Esc, ctrl combos) — a raw === "\u001b"
61
+ // check only works in terminals without the kitty keyboard protocol,
62
+ // which is exactly where Ghostty users' Esc went nowhere.
63
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
64
+ this.doneFn();
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Register the /wiki-dashboard command.
70
+ */
71
+ export function registerWikiDashboardCommand(pi, runtime) {
72
+ pi.registerCommand("wiki-dashboard", {
73
+ description: "Show a read-only LLM Wiki dashboard (pages, freshness, activity, queue, links, emb)",
74
+ handler: async (_args, ctx) => {
75
+ runtime.ensureConfig(ctx.cwd);
76
+ if (!ctx.hasUI) {
77
+ ctx.ui.notify("LLM Wiki: /wiki-dashboard requires an interactive UI.", "warning");
78
+ return;
79
+ }
80
+ const stats = await collectDashboardStats(ctx.cwd);
81
+ await ctx.ui.custom((_tui, _theme, _keybindings, close) => {
82
+ return new DashboardScreen(renderStatsLines(stats), close);
83
+ });
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,175 @@
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 { readJson, resolveVaultPaths } from "./utils.js";
16
+ /** Directories excluded from the page walk (infrastructure, not pages). */
17
+ const SKIP_DIRS = new Set([
18
+ "templates",
19
+ "cases",
20
+ "outputs",
21
+ ".discoveries",
22
+ "raw",
23
+ "meta",
24
+ "emb",
25
+ ".git",
26
+ ]);
27
+ async function walkPages(dir, rel, out) {
28
+ try {
29
+ const entries = await readdir(dir, { withFileTypes: true });
30
+ for (const e of entries) {
31
+ const childRel = rel === "" ? e.name : `${rel}/${e.name}`;
32
+ if (e.isDirectory()) {
33
+ if (SKIP_DIRS.has(e.name))
34
+ continue;
35
+ await walkPages(`${dir}/${e.name}`, childRel, out);
36
+ continue;
37
+ }
38
+ if (!e.name.endsWith(".md"))
39
+ continue;
40
+ try {
41
+ const st = await stat(`${dir}/${e.name}`);
42
+ out.push({ rel: childRel, mtime: st.mtimeMs, bytes: st.size });
43
+ }
44
+ catch {
45
+ // unreadable file: skip
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ // missing dir (empty vault): nothing to walk
51
+ }
52
+ }
53
+ function humanAge(ms) {
54
+ const mins = Math.floor(ms / 60_000);
55
+ if (mins < 1)
56
+ return "now";
57
+ if (mins < 60)
58
+ return `${mins}m`;
59
+ const hrs = Math.floor(mins / 60);
60
+ if (hrs < 48)
61
+ return `${hrs}h`;
62
+ return `${Math.floor(hrs / 24)}d`;
63
+ }
64
+ async function readEvents(metaDir) {
65
+ const byKind = {};
66
+ let recent = 0;
67
+ let total = 0;
68
+ try {
69
+ const raw = await readFile(`${metaDir}/events.jsonl`, "utf-8");
70
+ const cutoff = Date.now() - 7 * 86400_000;
71
+ for (const line of raw.split("\n")) {
72
+ if (!line.trim())
73
+ continue;
74
+ let ev;
75
+ try {
76
+ ev = JSON.parse(line);
77
+ }
78
+ catch {
79
+ continue; // corrupted line: skip, not fatal
80
+ }
81
+ const ts = typeof ev.timestamp === "string" ? Date.parse(ev.timestamp) : Number.NaN;
82
+ const kind = typeof ev.kind === "string" && ev.kind ? ev.kind : "event";
83
+ total += 1;
84
+ if (Number.isFinite(ts) && ts >= cutoff) {
85
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
86
+ recent += 1;
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // no events file: zeros
92
+ }
93
+ return { byKind, recent, total };
94
+ }
95
+ /**
96
+ * Collect all dashboard stats for the vault reachable from `cwd`.
97
+ * Reuses the extension's own path resolution (same root the tools see).
98
+ */
99
+ export async function collectDashboardStats(cwd) {
100
+ const paths = resolveVaultPaths(cwd);
101
+ const pages = [];
102
+ await walkPages(paths.wiki, "", pages);
103
+ // skill pages register as type:skill and live under <dotWiki>/skills
104
+ try {
105
+ const st = await stat(`${paths.dotWiki}/skills`);
106
+ if (st.isDirectory()) {
107
+ await walkPages(`${paths.dotWiki}/skills`, "skills", pages);
108
+ }
109
+ }
110
+ catch {
111
+ // no skills dir: fine
112
+ }
113
+ // type map from registry values — keys may carry legacy noise, so normalize
114
+ const registry = readJson(`${paths.meta}/registry.json`, { pages: {} });
115
+ const typeByKey = {};
116
+ for (const [key, val] of Object.entries(registry.pages ?? {})) {
117
+ const norm = key.startsWith('"') ? key.slice(1) : key;
118
+ if (val?.type)
119
+ typeByKey[norm] = val.type;
120
+ }
121
+ const byType = {};
122
+ for (const p of pages) {
123
+ const slug = p.rel.endsWith(".md") ? p.rel.slice(0, -3) : p.rel;
124
+ const type = typeByKey[slug] ?? p.rel.split("/")[0] ?? "other";
125
+ byType[type] = (byType[type] ?? 0) + 1;
126
+ }
127
+ let sizeBytes = 0;
128
+ let latestMtime = 0;
129
+ const staleCutoff = Date.now() - 30 * 86400_000;
130
+ let stale = 0;
131
+ for (const p of pages) {
132
+ sizeBytes += p.bytes;
133
+ if (p.mtime > latestMtime)
134
+ latestMtime = p.mtime;
135
+ if (p.mtime < staleCutoff)
136
+ stale += 1;
137
+ }
138
+ const events = await readEvents(paths.meta);
139
+ let rawQueue = 0;
140
+ try {
141
+ const pkts = await readdir(paths.rawSources, { withFileTypes: true });
142
+ for (const e of pkts) {
143
+ if (e.isDirectory())
144
+ rawQueue += 1; // each subdirectory is one packet
145
+ }
146
+ }
147
+ catch {
148
+ // no raw/sources: empty queue
149
+ }
150
+ const backlinks = readJson(`${paths.meta}/backlinks.json`, {});
151
+ const zeroBacklinks = Object.values(backlinks).filter((v) => (v ?? []).length === 0).length;
152
+ let embFiles = 0;
153
+ try {
154
+ const embEntries = await readdir(`${paths.dotWiki}/emb`);
155
+ embFiles = embEntries.filter((f) => f.endsWith(".bin")).length;
156
+ }
157
+ catch {
158
+ // no emb dir: none
159
+ }
160
+ return {
161
+ root: paths.root,
162
+ pageCount: pages.length,
163
+ byType,
164
+ sizeKB: Math.max(1, Math.round(sizeBytes / 1024)),
165
+ lastTouch: pages.length === 0 ? "" : humanAge(Date.now() - latestMtime),
166
+ staleCount: stale,
167
+ last7dByKind: events.byKind,
168
+ last7dTotal: events.recent,
169
+ totalEvents: events.total,
170
+ rawQueue,
171
+ zeroBacklinks,
172
+ embFiles,
173
+ embEnabled: embFiles > 0,
174
+ };
175
+ }
@@ -65,11 +65,37 @@ function parseMoveDestination(line) {
65
65
  function scanPatchString(input) {
66
66
  const paths = [];
67
67
  let sawHeader = false;
68
+ let sawApplyPatchHeader = false;
69
+ let applyPatchComplete = true;
68
70
  let sectionHasMove = false;
69
71
  let complete = true;
70
72
  const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
71
73
  for (const line of stripped.split("\n")) {
72
- const trimmed = line.replace(/\r$/, "").trim();
74
+ const raw = line.replace(/\r$/, "");
75
+ // apply_patch envelope headers (#162): matched on the raw (untrimmed)
76
+ // line so body rows (space/+/- prefixed) that quote the envelope shape
77
+ // are never mistaken for headers.
78
+ const fileOp = /^\*{3}\s+(?:update|add|delete)\s+file:\s*(\S.*)$/i.exec(raw);
79
+ if (fileOp) {
80
+ sawApplyPatchHeader = true;
81
+ const path = normalizeMutationPath(fileOp[1]);
82
+ if (path)
83
+ paths.push(path);
84
+ else
85
+ applyPatchComplete = false;
86
+ continue;
87
+ }
88
+ const moveTo = /^\*{3}\s+move\s+to:\s*(\S.*)$/i.exec(raw);
89
+ if (moveTo) {
90
+ sawApplyPatchHeader = true;
91
+ const destination = normalizeMutationPath(moveTo[1]);
92
+ if (destination)
93
+ paths.push(destination);
94
+ else
95
+ applyPatchComplete = false;
96
+ continue;
97
+ }
98
+ const trimmed = raw.trim();
73
99
  if (trimmed.startsWith("[")) {
74
100
  sawHeader = true;
75
101
  sectionHasMove = false;
@@ -90,6 +116,9 @@ function scanPatchString(input) {
90
116
  sectionHasMove = true;
91
117
  }
92
118
  }
119
+ if (sawApplyPatchHeader) {
120
+ return { paths, complete: applyPatchComplete && paths.length > 0 };
121
+ }
93
122
  return { paths, complete: sawHeader && complete };
94
123
  }
95
124
  function mergeMutationScans(target, source) {
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ /** Project config directory name per host. */
5
+ const CONFIG_DIR = { pi: ".pi", omp: ".omp" };
6
+ /** Settings file names inside a config directory, lowest → highest precedence. */
7
+ const SETTINGS_FILES = ["settings.json", "config.yml", "config.yaml"];
8
+ /**
9
+ * Detect which agent is hosting this extension.
10
+ *
11
+ * Ordered by reliability:
12
+ * 1. `LLM_WIKI_HOST` — explicit escape hatch (tests, exotic embeddings).
13
+ * 2. The agent directory path: pi resolves `~/.pi/agent`, omp `~/.omp/agent`.
14
+ * A `PI_CODING_AGENT_DIR` override that keeps the marker segment still
15
+ * classifies correctly; anything else falls through.
16
+ * 3. `OMP_PROFILE`, which omp sets on itself whenever a profile is active.
17
+ * 4. Default `pi` — the historical behaviour.
18
+ */
19
+ export function detectHost() {
20
+ const forced = process.env.LLM_WIKI_HOST?.trim().toLowerCase();
21
+ if (forced === "omp" || forced === "pi")
22
+ return forced;
23
+ let agentDir = "";
24
+ try {
25
+ agentDir = getAgentDir();
26
+ }
27
+ catch {
28
+ agentDir = "";
29
+ }
30
+ if (agentDir) {
31
+ const segments = agentDir.split(/[\\/]/);
32
+ if (segments.includes(".omp"))
33
+ return "omp";
34
+ if (segments.includes(".pi"))
35
+ return "pi";
36
+ }
37
+ if (process.env.OMP_PROFILE)
38
+ return "omp";
39
+ return "pi";
40
+ }
41
+ /**
42
+ * Every project settings file that may hold `llm-wiki` configuration, ordered
43
+ * from lowest to highest precedence so callers can merge left-to-right.
44
+ *
45
+ * The host's *native* directory is last (wins). The foreign directory is still
46
+ * read so a vault configured under pi keeps working after omp takes over the
47
+ * repository, and vice versa. Within a directory `config.yml` follows
48
+ * `settings.json`, matching omp's own project-settings precedence.
49
+ */
50
+ export function listProjectSettingsFiles(cwd, host = detectHost()) {
51
+ const foreign = host === "omp" ? "pi" : "omp";
52
+ const files = [];
53
+ for (const kind of [foreign, host]) {
54
+ const dir = join(cwd, CONFIG_DIR[kind]);
55
+ for (const name of SETTINGS_FILES)
56
+ files.push(join(dir, name));
57
+ }
58
+ return files;
59
+ }
60
+ /**
61
+ * User-level settings files, lowest → highest precedence.
62
+ *
63
+ * `getAgentDir()` already resolves per host (`~/.pi/agent` vs `~/.omp/agent`),
64
+ * so only the file names differ: omp migrates `settings.json` into `config.yml`
65
+ * on first start, and a migrated install has *only* the YAML file.
66
+ */
67
+ export function listGlobalSettingsFiles() {
68
+ let agentDir = "";
69
+ try {
70
+ agentDir = getAgentDir();
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ if (!agentDir)
76
+ return [];
77
+ return SETTINGS_FILES.map((name) => join(agentDir, name));
78
+ }
79
+ /**
80
+ * The project settings file this extension writes to.
81
+ *
82
+ * Always JSON (`settings.json`) — both hosts read it, and rewriting a user's
83
+ * hand-authored `config.yml` would destroy comments and formatting.
84
+ *
85
+ * Directory choice: an already-existing project config directory wins (so a
86
+ * repo that only has `.pi/` keeps a single settings file), otherwise the
87
+ * detected host's native directory is created.
88
+ */
89
+ export function resolveProjectSettingsPath(cwd, host = detectHost()) {
90
+ const native = join(cwd, CONFIG_DIR[host]);
91
+ if (existsSync(native))
92
+ return join(native, "settings.json");
93
+ const foreign = join(cwd, CONFIG_DIR[host === "omp" ? "pi" : "omp"]);
94
+ if (existsSync(foreign))
95
+ return join(foreign, "settings.json");
96
+ return join(native, "settings.json");
97
+ }
98
+ /**
99
+ * The global (user-level) settings file this extension writes to.
100
+ *
101
+ * Always writes to `settings.json` inside the agent dir — both hosts read it.
102
+ */
103
+ export function resolveGlobalSettingsPath(host = detectHost()) {
104
+ let agentDir = "";
105
+ try {
106
+ agentDir = getAgentDir();
107
+ }
108
+ catch {
109
+ agentDir = "";
110
+ }
111
+ if (!agentDir) {
112
+ // Fallback: ~/.pi/agent/settings.json
113
+ const home = process.env.HOME || "~";
114
+ return join(home, ".pi", "agent", "settings.json");
115
+ }
116
+ return join(agentDir, "settings.json");
117
+ }
@@ -365,7 +365,7 @@ Rules:
365
365
  * synthesis.
366
366
  */
367
367
  export async function runIngestSynthesis(args) {
368
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, } = args;
368
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, synthesisMaxTokens, } = args;
369
369
  const content = extracted.slice(0, maxChars ?? 24_000);
370
370
  if (!content.trim())
371
371
  return undefined;
@@ -402,6 +402,7 @@ export async function runIngestSynthesis(args) {
402
402
  systemPrompt,
403
403
  userPrompt,
404
404
  tools: [commitTool],
405
+ maxTokens: synthesisMaxTokens ?? 16384,
405
406
  signal,
406
407
  });
407
408
  if (committed)