@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
|
@@ -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
|
|
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)
|