pi-jev-wiki 0.2.0
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 +52 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +63 -0
- package/skills/llm-wiki/SKILL.md +143 -0
- package/skills/llm-wiki/references/decision.md +32 -0
- package/skills/llm-wiki/references/flow.md +32 -0
- package/skills/llm-wiki/references/gotcha.md +16 -0
- package/skills/llm-wiki/references/invariant.md +20 -0
- package/skills/llm-wiki/references/module.md +30 -0
- package/src/config.ts +168 -0
- package/src/doctor.ts +171 -0
- package/src/extension.ts +1674 -0
- package/src/git.ts +69 -0
- package/src/grounding.ts +46 -0
- package/src/jev.ts +207 -0
- package/src/ledger.ts +85 -0
- package/src/lint.ts +407 -0
- package/src/metrics.ts +61 -0
- package/src/pipeline/adjudicate.ts +416 -0
- package/src/pipeline/capture.ts +109 -0
- package/src/pipeline/extract.ts +150 -0
- package/src/pipeline/write.ts +263 -0
- package/src/provenance.ts +137 -0
- package/src/redact.ts +46 -0
- package/src/review.ts +146 -0
- package/src/sessionlog.ts +107 -0
- package/src/structure.ts +215 -0
- package/src/sync.ts +295 -0
- package/src/wiki/frontmatter.ts +164 -0
- package/src/wiki/layout.ts +126 -0
- package/src/wiki/links.ts +17 -0
- package/src/wiki/lock.ts +86 -0
- package/src/wiki/search.ts +263 -0
- package/src/wiki/toc.ts +198 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev-wiki — a project mental-model wiki for pi, maintained with Jev decisions.
|
|
3
|
+
*
|
|
4
|
+
* P0 scope: architecture-first wiki layout + TOC, guided ingest (research channel),
|
|
5
|
+
* agent insight capture (work channel), decision ledger, consultation tools.
|
|
6
|
+
* Pages are written by the agent (guided mode); this extension stages, adjudicates,
|
|
7
|
+
* places, and keeps the TOC/log current.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { loadConfig, type LoadedConfig, type JevWikiConfig, type WriterMode } from "./config.ts";
|
|
16
|
+
import { git, headCommit, isGitRepo } from "./git.ts";
|
|
17
|
+
import { appendLedger, readLedger, summarizeLedger } from "./ledger.ts";
|
|
18
|
+
import { lintWiki } from "./lint.ts";
|
|
19
|
+
import { readMetrics, recordMetric, summarizeMetrics } from "./metrics.ts";
|
|
20
|
+
import { renderDoctor, runDoctor } from "./doctor.ts";
|
|
21
|
+
import { withWikiLock } from "./wiki/lock.ts";
|
|
22
|
+
import { applyReinforcement, applySupersession, bestCandidatePage } from "./provenance.ts";
|
|
23
|
+
import { redact } from "./redact.ts";
|
|
24
|
+
import { appendSessionLog, promoteRecurring } from "./sessionlog.ts";
|
|
25
|
+
import { renderStructure, scanStructure } from "./structure.ts";
|
|
26
|
+
import { extractInsights, sessionTextFromEntries } from "./pipeline/capture.ts";
|
|
27
|
+
import {
|
|
28
|
+
applyReviewResolution,
|
|
29
|
+
enqueueReview,
|
|
30
|
+
listOpenReviews,
|
|
31
|
+
openReviewCount,
|
|
32
|
+
readReviews,
|
|
33
|
+
resolveReview,
|
|
34
|
+
} from "./review.ts";
|
|
35
|
+
import { readSyncState, syncWiki } from "./sync.ts";
|
|
36
|
+
import { createJevClient, noul, type JevClient } from "./jev.ts";
|
|
37
|
+
import { adjudicateClaim, chooseTarget, decideClaim, type CandidateClaim, type CandidatePage } from "./pipeline/adjudicate.ts";
|
|
38
|
+
import { resolveWriterMode, writeAcceptedPages, type WriterClaim } from "./pipeline/write.ts";
|
|
39
|
+
import { extractClaims, quoteIsPresent } from "./pipeline/extract.ts";
|
|
40
|
+
import {
|
|
41
|
+
ensureLayout,
|
|
42
|
+
listMarkdownFiles,
|
|
43
|
+
readPage,
|
|
44
|
+
removeFileIfExists,
|
|
45
|
+
resolveLayout,
|
|
46
|
+
sha256Hex,
|
|
47
|
+
slugify,
|
|
48
|
+
todayISO,
|
|
49
|
+
writePage,
|
|
50
|
+
writeRawSource,
|
|
51
|
+
writeTextAtomic,
|
|
52
|
+
type WikiLayout,
|
|
53
|
+
} from "./wiki/layout.ts";
|
|
54
|
+
import { extractMarkdownLinks } from "./wiki/links.ts";
|
|
55
|
+
import { appendLog, entryFromPage, isWikiMetaFile, parseIndex, readIndex, readRecentLog, renderCompactToc, renderIndex, topicSlug, updateIndex, upsertEntries, writeIndex, type TocEntry } from "./wiki/toc.ts";
|
|
56
|
+
import { createSearchEngine } from "./wiki/search.ts";
|
|
57
|
+
|
|
58
|
+
interface Runtime {
|
|
59
|
+
loaded: LoadedConfig;
|
|
60
|
+
layout: WikiLayout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runtimeFor(ctx: ExtensionContext): Runtime {
|
|
64
|
+
const loaded = loadConfig(ctx.cwd);
|
|
65
|
+
return { loaded, layout: resolveLayout(ctx.cwd, loaded.config.wikiRoot, loaded.config.stateRoot) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requireClient(loaded: LoadedConfig, ctx: ExtensionContext): Promise<JevClient> {
|
|
69
|
+
if (loaded.apiKey) return createJevClient(loaded.config, loaded.apiKey);
|
|
70
|
+
if (loaded.config.provider === "openrouter") {
|
|
71
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider("openrouter").catch(() => undefined);
|
|
72
|
+
if (key) return createJevClient(loaded.config, key);
|
|
73
|
+
}
|
|
74
|
+
throw new Error(
|
|
75
|
+
[
|
|
76
|
+
"No Jev API key configured.",
|
|
77
|
+
`- TypeSafe: put TYPESAFE_API_KEY=... (or JEV_TOKEN=...) in ${loaded.envFilePath}`,
|
|
78
|
+
`- OpenRouter: put OPENROUTER_API_KEY=... in ${loaded.envFilePath}, set provider to "openrouter", or sign in with /login openrouter`,
|
|
79
|
+
"- Ask the agent to run wiki_setup action=guide provider=typesafe|openrouter for exact steps, then wiki_setup action=test",
|
|
80
|
+
].join("\n"),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Optional cross-project vault: resolved against the pi agent dir when relative. */
|
|
85
|
+
function globalLayoutFor(loaded: LoadedConfig): WikiLayout | undefined {
|
|
86
|
+
if (!loaded.config.globalWikiRoot) return undefined;
|
|
87
|
+
const root = isAbsolute(loaded.config.globalWikiRoot) ? loaded.config.globalWikiRoot : join(loaded.agentDir, loaded.config.globalWikiRoot);
|
|
88
|
+
return resolveLayout(root, ".", loaded.config.stateRoot);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function truncate(text: string, maxChars: number): string {
|
|
92
|
+
if (text.length <= maxChars) return text;
|
|
93
|
+
return `${text.slice(0, maxChars)}\n\n[... truncated ...]`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build Jev-readable evidence for an insight: file excerpts, commit messages, quotes. */
|
|
97
|
+
async function buildInsightEvidence(
|
|
98
|
+
insight: { text: string; evidence?: Array<{ kind: string; ref: string; quote?: string }> },
|
|
99
|
+
cwd: string,
|
|
100
|
+
): Promise<{ evidenceText: string; files: string[] }> {
|
|
101
|
+
const files: string[] = [];
|
|
102
|
+
const parts: string[] = [];
|
|
103
|
+
for (const item of insight.evidence ?? []) {
|
|
104
|
+
if (item.kind === "file") {
|
|
105
|
+
files.push(item.ref);
|
|
106
|
+
const absolute = isAbsolute(item.ref) ? item.ref : resolve(cwd, item.ref);
|
|
107
|
+
if (existsSync(absolute)) {
|
|
108
|
+
const content = await readFile(absolute, "utf8");
|
|
109
|
+
parts.push(`file: ${item.ref}\n${excerptAroundTerms(content, insight.text, 3500)}`);
|
|
110
|
+
} else {
|
|
111
|
+
parts.push(`file: ${item.ref} (not found)`);
|
|
112
|
+
}
|
|
113
|
+
} else if (item.kind === "commit") {
|
|
114
|
+
const detail = await git(cwd, ["show", "--no-color", "--stat", "--format=%s%n%b", item.ref, "--"]);
|
|
115
|
+
parts.push(`commit: ${item.ref}\n${truncate(detail.stdout || "(commit not found)", 2000)}`);
|
|
116
|
+
} else if (item.kind === "user") {
|
|
117
|
+
parts.push(`user statement: "${item.quote ?? item.ref}"`);
|
|
118
|
+
} else if (item.kind === "source") {
|
|
119
|
+
parts.push(`source: ${item.ref}${item.quote ? `\nquote: "${item.quote}"` : ""}`);
|
|
120
|
+
} else {
|
|
121
|
+
parts.push(`${item.kind}: ${item.ref}${item.quote ? `\nquote: "${item.quote}"` : ""}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { evidenceText: redact(parts.join("\n\n") || "(no evidence attached)").text, files };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Line excerpts around terms from the claim, so Jev sees the relevant code, not the whole file. */
|
|
128
|
+
function excerptAroundTerms(content: string, claimText: string, maxChars: number): string {
|
|
129
|
+
const lines = content.split(/\r?\n/);
|
|
130
|
+
const terms = [...new Set(claimText.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 3))].slice(0, 12);
|
|
131
|
+
if (terms.length === 0 || lines.length <= 60) return truncate(content, maxChars);
|
|
132
|
+
const scored = lines
|
|
133
|
+
.map((line, index) => ({ index, score: terms.reduce((sum, term) => sum + (line.toLowerCase().includes(term) ? 1 : 0), 0) }))
|
|
134
|
+
.filter((entry) => entry.score > 0)
|
|
135
|
+
.sort((a, b) => b.score - a.score)
|
|
136
|
+
.slice(0, 10);
|
|
137
|
+
if (scored.length === 0) return truncate(content, maxChars);
|
|
138
|
+
const chosen = new Set<number>();
|
|
139
|
+
for (const { index } of scored) {
|
|
140
|
+
for (let i = Math.max(0, index - 4); i <= Math.min(lines.length - 1, index + 4); i++) chosen.add(i);
|
|
141
|
+
}
|
|
142
|
+
const out: string[] = [];
|
|
143
|
+
let last = -1;
|
|
144
|
+
for (const index of [...chosen].sort((a, b) => a - b)) {
|
|
145
|
+
if (last !== -1 && index > last + 1) out.push(" ...");
|
|
146
|
+
out.push(`${String(index + 1).padStart(4)}| ${lines[index]}`);
|
|
147
|
+
last = index;
|
|
148
|
+
}
|
|
149
|
+
return truncate(out.join("\n"), maxChars);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Candidate collection
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async function readRawIndex(layout: WikiLayout): Promise<Record<string, string>> {
|
|
157
|
+
const path = join(layout.stateDir, "raw-index.json");
|
|
158
|
+
if (!existsSync(path)) return {};
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(await readFile(path, "utf8")) as Record<string, string>;
|
|
161
|
+
} catch {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function writeRawIndex(layout: WikiLayout, index: Record<string, string>): Promise<void> {
|
|
167
|
+
await writeTextAtomic(join(layout.stateDir, "raw-index.json"), `${JSON.stringify(index, null, "\t")}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function collectCandidatePages(layout: WikiLayout): Promise<CandidatePage[]> {
|
|
171
|
+
const entries = await readIndex(layout);
|
|
172
|
+
return entries.slice(0, 500).map((entry) => ({
|
|
173
|
+
path: entry.path,
|
|
174
|
+
title: entry.title,
|
|
175
|
+
type: entry.type,
|
|
176
|
+
summary: entry.summary,
|
|
177
|
+
tags: entry.tags,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function collectCandidateClaims(layout: WikiLayout): Promise<CandidateClaim[]> {
|
|
182
|
+
const files = (await listMarkdownFiles(layout.wikiDir)).filter(
|
|
183
|
+
(file) => !isWikiMetaFile(relative(layout.wikiDir, file).split("\\").join("/")),
|
|
184
|
+
);
|
|
185
|
+
const claims: CandidateClaim[] = [];
|
|
186
|
+
for (const file of files.slice(0, 200)) {
|
|
187
|
+
try {
|
|
188
|
+
const page = await readPage(file);
|
|
189
|
+
const pagePath = relative(layout.wikiDir, file).split("\\").join("/");
|
|
190
|
+
const rawClaims = Array.isArray(page.data.claims) ? page.data.claims : [];
|
|
191
|
+
for (const rawClaim of rawClaims) {
|
|
192
|
+
if (rawClaim && typeof rawClaim === "object" && typeof (rawClaim as Record<string, unknown>).text === "string") {
|
|
193
|
+
const record = rawClaim as Record<string, unknown>;
|
|
194
|
+
claims.push({
|
|
195
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
196
|
+
text: record.text as string,
|
|
197
|
+
page: pagePath,
|
|
198
|
+
status: typeof record.status === "string" ? record.status : undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
/* skip unreadable pages */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return claims.slice(0, 400);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function existingTopics(layout: WikiLayout): Promise<string[]> {
|
|
210
|
+
const files = await listMarkdownFiles(layout.wikiDir);
|
|
211
|
+
const topics = new Set<string>();
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const rel = relative(layout.wikiDir, file).split("\\").join("/");
|
|
214
|
+
const topic = rel.split("/")[0];
|
|
215
|
+
if (topic && topic !== rel) topics.add(topic);
|
|
216
|
+
}
|
|
217
|
+
return [...topics].sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Briefs
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
interface ClaimReport {
|
|
225
|
+
text: string;
|
|
226
|
+
quoteVerified: boolean;
|
|
227
|
+
action: string;
|
|
228
|
+
score: number;
|
|
229
|
+
reasons: string[];
|
|
230
|
+
grounded: number;
|
|
231
|
+
derivable: number;
|
|
232
|
+
importanceNorm: number;
|
|
233
|
+
criticalityNorm: number;
|
|
234
|
+
trustTier?: string;
|
|
235
|
+
files: string[];
|
|
236
|
+
pageType?: string;
|
|
237
|
+
topic?: string;
|
|
238
|
+
target?: string;
|
|
239
|
+
newPage: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderBrief(title: string, reports: ClaimReport[], extras: string[] = [], options?: { guided?: boolean }): string {
|
|
243
|
+
const filed = reports.filter((report) => report.action === "file" || report.action === "file_user_stated");
|
|
244
|
+
const reinforced = reports.filter((report) => report.action === "reinforce");
|
|
245
|
+
const review = reports.filter((report) => report.action === "review");
|
|
246
|
+
const rejected = reports.filter((report) => report.action.startsWith("reject"));
|
|
247
|
+
|
|
248
|
+
const lines: string[] = [`## Wiki ingest brief — ${title}`, ""];
|
|
249
|
+
lines.push(
|
|
250
|
+
`Claims: ${reports.length} · file ${filed.length} · reinforce ${reinforced.length} · review ${review.length} · rejected ${rejected.length}`,
|
|
251
|
+
"",
|
|
252
|
+
);
|
|
253
|
+
if (filed.length > 0) {
|
|
254
|
+
lines.push("### File into wiki");
|
|
255
|
+
for (const report of filed) {
|
|
256
|
+
const where = report.target
|
|
257
|
+
? `merge → \`${report.target}\``
|
|
258
|
+
: `new page (${report.pageType ?? "concept"}${report.topic ? `, topic \`${report.topic}\`` : ""})`;
|
|
259
|
+
const trust = report.action === "file_user_stated" ? " [user-stated: use `status: user-stated`]" : "";
|
|
260
|
+
const files = report.files.length > 0 ? ` (files: ${report.files.map((file) => `\`${file}\``).join(", ")})` : "";
|
|
261
|
+
lines.push(`- ${where} — ${report.text}${trust}${files}`);
|
|
262
|
+
lines.push(
|
|
263
|
+
` grounded ${report.grounded.toFixed(2)} · derivable ${report.derivable.toFixed(2)} · importance ${report.importanceNorm.toFixed(2)} · criticality ${report.criticalityNorm.toFixed(2)}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
lines.push("");
|
|
267
|
+
}
|
|
268
|
+
if (reinforced.length > 0) {
|
|
269
|
+
lines.push("### Reinforce existing knowledge");
|
|
270
|
+
for (const report of reinforced) {
|
|
271
|
+
lines.push(`- ${report.target ? `\`${report.target}\`` : "existing page"} — ${report.text}`);
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
}
|
|
275
|
+
if (review.length > 0) {
|
|
276
|
+
lines.push("### Needs review (below threshold)");
|
|
277
|
+
for (const report of review) {
|
|
278
|
+
lines.push(`- ${report.text} — ${report.reasons.join("; ")}`);
|
|
279
|
+
}
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
if (rejected.length > 0) {
|
|
283
|
+
lines.push("### Not filed");
|
|
284
|
+
for (const report of rejected) {
|
|
285
|
+
lines.push(`- [${report.action.replace("reject_", "")}] ${report.text} — ${report.reasons.join("; ")}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push("");
|
|
288
|
+
}
|
|
289
|
+
if (extras.length > 0) lines.push(...extras, "");
|
|
290
|
+
if (options?.guided === false) return lines.join("\n");
|
|
291
|
+
lines.push(
|
|
292
|
+
"### Next steps (guided mode)",
|
|
293
|
+
"1. Write or merge **only the claims listed under File/Reinforce above**. Rejected claims must not be written, even if the user asked for them — report the rejection and its reason instead.",
|
|
294
|
+
"2. Follow the llm-wiki skill; cite the raw source in each page.",
|
|
295
|
+
"3. Include YAML frontmatter (title, type, topic, summary, tags, updated, claims with status/support/evidence).",
|
|
296
|
+
"4. Set page-level `files: [...]` (or per-claim `files`) for claims about code, so `wiki_sync` can detect when the code changes.",
|
|
297
|
+
"5. Call `wiki_finalize` with the touched page paths.",
|
|
298
|
+
);
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Ingest
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async function ingestSource(
|
|
307
|
+
runtime: Runtime,
|
|
308
|
+
ctx: ExtensionContext,
|
|
309
|
+
input: { text: string; title: string; topic: string; source?: string },
|
|
310
|
+
): Promise<{ brief: string; details: Record<string, unknown>; rawPath: string; duplicateOf?: string }> {
|
|
311
|
+
const { loaded, layout } = runtime;
|
|
312
|
+
const client = await requireClient(loaded, ctx);
|
|
313
|
+
const config = loaded.config;
|
|
314
|
+
|
|
315
|
+
await ensureLayout(layout);
|
|
316
|
+
const redaction = redact(input.text);
|
|
317
|
+
if (redaction.findings.length > 0) {
|
|
318
|
+
await appendLedger(layout, {
|
|
319
|
+
actor: "code",
|
|
320
|
+
op: "ingest.redact",
|
|
321
|
+
subject: input.title.slice(0, 80),
|
|
322
|
+
action: "redacted",
|
|
323
|
+
verdict: redaction.findings,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const safeText = redaction.text;
|
|
327
|
+
const hash = await sha256Hex(safeText);
|
|
328
|
+
const duplicateOf = await withWikiLock(layout, async () => (await readRawIndex(layout))[hash]);
|
|
329
|
+
if (duplicateOf) {
|
|
330
|
+
return {
|
|
331
|
+
brief: `Source already ingested (sha256 ${hash.slice(0, 12)}…): \`${duplicateOf}\`. Nothing to do.`,
|
|
332
|
+
details: { duplicateOf, hash },
|
|
333
|
+
rawPath: duplicateOf,
|
|
334
|
+
duplicateOf,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const rawPath = await writeRawSource(
|
|
339
|
+
layout,
|
|
340
|
+
input.topic,
|
|
341
|
+
input.title,
|
|
342
|
+
{
|
|
343
|
+
title: input.title,
|
|
344
|
+
type: "raw-source",
|
|
345
|
+
source: input.source ?? null,
|
|
346
|
+
collected: todayISO(),
|
|
347
|
+
sha256: hash,
|
|
348
|
+
},
|
|
349
|
+
safeText,
|
|
350
|
+
);
|
|
351
|
+
await withWikiLock(layout, async () => {
|
|
352
|
+
const index = await readRawIndex(layout);
|
|
353
|
+
index[hash] = relative(layout.root, rawPath).split("\\").join("/");
|
|
354
|
+
await writeRawIndex(layout, index);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const { result: extraction, sourceTruncated } = await extractClaims(ctx, safeText, { title: input.title });
|
|
358
|
+
const candidates = await collectCandidatePages(layout);
|
|
359
|
+
const candidateClaims = await collectCandidateClaims(layout);
|
|
360
|
+
const topics = await existingTopics(layout);
|
|
361
|
+
|
|
362
|
+
const perClaim = await mapLimitLocal(extraction.claims, 4, async (claim) => {
|
|
363
|
+
const evidenceText = claim.quote ?? safeText.slice(0, 6000);
|
|
364
|
+
const adjudication = await adjudicateClaim(
|
|
365
|
+
client,
|
|
366
|
+
{ text: claim.text, kind: claim.kind, quote: claim.quote, files: claim.files, evidenceText },
|
|
367
|
+
config,
|
|
368
|
+
{ candidateClaims, existingTopics: topics, signal: ctx.signal, evidenceText },
|
|
369
|
+
);
|
|
370
|
+
const decision = decideClaim(adjudication.verdicts, config);
|
|
371
|
+
let placement: Awaited<ReturnType<typeof chooseTarget>> | undefined;
|
|
372
|
+
if (decision.action === "file" || decision.action === "reinforce") {
|
|
373
|
+
placement = await chooseTarget(client, { text: claim.text, kind: claim.kind, quote: claim.quote, files: claim.files, evidenceText }, candidates, config, {
|
|
374
|
+
signal: ctx.signal,
|
|
375
|
+
evidenceText,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const verdicts = { ...adjudication.verdicts, ...(placement?.verdicts ?? {}) };
|
|
379
|
+
const totalUsage = {
|
|
380
|
+
input_tokens: adjudication.usage.input_tokens + (placement?.usage.input_tokens ?? 0),
|
|
381
|
+
output_tokens: adjudication.usage.output_tokens + (placement?.usage.output_tokens ?? 0),
|
|
382
|
+
};
|
|
383
|
+
if (decision.action === "review") {
|
|
384
|
+
await enqueueReview(layout, {
|
|
385
|
+
kind: "claim_review",
|
|
386
|
+
claimText: claim.text,
|
|
387
|
+
page: verdicts.target,
|
|
388
|
+
claimId: undefined,
|
|
389
|
+
criticality: Math.max(0.3, 1 - verdicts.grounded),
|
|
390
|
+
reason: decision.reasons.join("; "),
|
|
391
|
+
verdicts,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (verdicts.relation === "contradicts") {
|
|
395
|
+
await enqueueReview(layout, {
|
|
396
|
+
kind: "dispute",
|
|
397
|
+
claimText: claim.text,
|
|
398
|
+
page: verdicts.target,
|
|
399
|
+
criticality: 0.8,
|
|
400
|
+
reason: "contradicts existing wiki knowledge",
|
|
401
|
+
verdicts,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
await appendLedger(layout, {
|
|
405
|
+
actor: "jev",
|
|
406
|
+
op: "ingest.adjudicate",
|
|
407
|
+
subject: claim.text.slice(0, 120),
|
|
408
|
+
verdict: verdicts,
|
|
409
|
+
thresholds: config.thresholds,
|
|
410
|
+
action: decision.action,
|
|
411
|
+
reason: decision.reasons.join("; "),
|
|
412
|
+
usage: totalUsage,
|
|
413
|
+
});
|
|
414
|
+
await appendLedger(layout, {
|
|
415
|
+
actor: "code",
|
|
416
|
+
op: "ingest.decide",
|
|
417
|
+
subject: claim.text.slice(0, 120),
|
|
418
|
+
action: decision.action,
|
|
419
|
+
reason: decision.reasons.join("; "),
|
|
420
|
+
verdict: { score: decision.score, target: verdicts.target ?? null, newPage: verdicts.newPage },
|
|
421
|
+
});
|
|
422
|
+
return { claim, verdicts, decision, usage: adjudication.usage };
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const reports: ClaimReport[] = perClaim.map(({ claim, verdicts, decision }) => ({
|
|
426
|
+
text: claim.text,
|
|
427
|
+
quoteVerified: Boolean(claim.quoteVerified),
|
|
428
|
+
action: decision.action,
|
|
429
|
+
score: decision.score,
|
|
430
|
+
reasons: decision.reasons,
|
|
431
|
+
grounded: verdicts.grounded,
|
|
432
|
+
derivable: verdicts.derivable,
|
|
433
|
+
importanceNorm: verdicts.importanceNorm,
|
|
434
|
+
criticalityNorm: verdicts.criticalityNorm,
|
|
435
|
+
trustTier: verdicts.trustTier,
|
|
436
|
+
files: claim.files ?? [],
|
|
437
|
+
pageType: verdicts.pageType,
|
|
438
|
+
topic: verdicts.topic,
|
|
439
|
+
target: verdicts.target,
|
|
440
|
+
newPage: verdicts.newPage,
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
await appendLog(layout, "ingest", input.title, [
|
|
444
|
+
`Raw: ${relative(layout.root, rawPath).split("\\").join("/")}`,
|
|
445
|
+
`Claims: ${reports.length} (filed ${reports.filter((r) => r.action === "file").length}, reinforced ${reports.filter((r) => r.action === "reinforce").length})`,
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
const brief = renderBrief(input.title, reports, [
|
|
449
|
+
`Raw source: \`${relative(layout.root, rawPath).split("\\").join("/")}\`${sourceTruncated ? " (truncated during extraction)" : ""}`,
|
|
450
|
+
`Full text kept at the raw path for citation.`,
|
|
451
|
+
]);
|
|
452
|
+
return {
|
|
453
|
+
brief,
|
|
454
|
+
details: {
|
|
455
|
+
hash,
|
|
456
|
+
rawPath,
|
|
457
|
+
claims: reports,
|
|
458
|
+
claimsTotal: reports.length,
|
|
459
|
+
extraction: { topics: extraction.topics, entities: extraction.entities, summary: extraction.summary },
|
|
460
|
+
usage: client.totals,
|
|
461
|
+
},
|
|
462
|
+
rawPath,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function mapLimitLocal<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
467
|
+
const results = new Array<R>(items.length);
|
|
468
|
+
let cursor = 0;
|
|
469
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
470
|
+
while (cursor < items.length) {
|
|
471
|
+
const index = cursor++;
|
|
472
|
+
results[index] = await fn(items[index]);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
await Promise.all(workers);
|
|
476
|
+
return results;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Insight processing (shared by the tool and auto-capture hooks)
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
interface ProcessInsightsOptions {
|
|
484
|
+
source: "tool" | "compact" | "settled";
|
|
485
|
+
mode?: WriterMode;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
interface ProcessInsightsResult {
|
|
489
|
+
brief: string;
|
|
490
|
+
details: Record<string, unknown>;
|
|
491
|
+
accepted: number;
|
|
492
|
+
rawPath: string;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function processInsights(
|
|
496
|
+
runtime: Runtime,
|
|
497
|
+
ctx: ExtensionContext,
|
|
498
|
+
client: JevClient,
|
|
499
|
+
insights: Array<{ text: string; kind?: string; evidence?: Array<{ kind: string; ref: string; quote?: string }>; confidence?: number }>,
|
|
500
|
+
options: ProcessInsightsOptions,
|
|
501
|
+
): Promise<ProcessInsightsResult> {
|
|
502
|
+
const { loaded, layout } = runtime;
|
|
503
|
+
const config = loaded.config;
|
|
504
|
+
const candidates = await collectCandidatePages(layout);
|
|
505
|
+
const candidateClaims = await collectCandidateClaims(layout);
|
|
506
|
+
const topics = await existingTopics(layout);
|
|
507
|
+
const stamp = new Date();
|
|
508
|
+
const slug = `session-${todayISO(stamp)}-${String(stamp.getHours()).padStart(2, "0")}${String(stamp.getMinutes()).padStart(2, "0")}`;
|
|
509
|
+
|
|
510
|
+
const perInsight = await mapLimitLocal(insights, 4, async (insight) => {
|
|
511
|
+
const { evidenceText, files } = await buildInsightEvidence(insight, ctx.cwd);
|
|
512
|
+
const evidenceLines = evidenceText.split("\n\n").slice(0, 8);
|
|
513
|
+
const adjudication = await adjudicateClaim(
|
|
514
|
+
client,
|
|
515
|
+
{ text: insight.text, kind: insight.kind ?? "fact", files, evidenceText },
|
|
516
|
+
config,
|
|
517
|
+
{ candidateClaims, existingTopics: topics, signal: ctx.signal, evidenceText },
|
|
518
|
+
);
|
|
519
|
+
const decision = decideClaim(adjudication.verdicts, config);
|
|
520
|
+
let placement: Awaited<ReturnType<typeof chooseTarget>> | undefined;
|
|
521
|
+
if (decision.action === "file" || decision.action === "reinforce") {
|
|
522
|
+
placement = await chooseTarget(client, { text: insight.text, kind: insight.kind ?? "fact", files, evidenceText }, candidates, config, {
|
|
523
|
+
signal: ctx.signal,
|
|
524
|
+
evidenceText,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const verdicts = { ...adjudication.verdicts, ...(placement?.verdicts ?? {}) };
|
|
528
|
+
const totalUsage = {
|
|
529
|
+
input_tokens: adjudication.usage.input_tokens + (placement?.usage.input_tokens ?? 0),
|
|
530
|
+
output_tokens: adjudication.usage.output_tokens + (placement?.usage.output_tokens ?? 0),
|
|
531
|
+
};
|
|
532
|
+
if (decision.action === "review") {
|
|
533
|
+
await enqueueReview(layout, {
|
|
534
|
+
kind: "claim_review",
|
|
535
|
+
claimText: insight.text,
|
|
536
|
+
page: verdicts.target,
|
|
537
|
+
criticality: Math.max(0.3, 1 - verdicts.grounded),
|
|
538
|
+
reason: decision.reasons.join("; "),
|
|
539
|
+
verdicts,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (verdicts.relation === "contradicts") {
|
|
543
|
+
await enqueueReview(layout, {
|
|
544
|
+
kind: "dispute",
|
|
545
|
+
claimText: insight.text,
|
|
546
|
+
page: verdicts.target,
|
|
547
|
+
criticality: 0.8,
|
|
548
|
+
reason: "contradicts existing wiki knowledge",
|
|
549
|
+
verdicts,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let reinforcement: Awaited<ReturnType<typeof applyReinforcement>>;
|
|
554
|
+
if ((decision.action === "file" || decision.action === "reinforce") && verdicts.target) {
|
|
555
|
+
reinforcement = await applyReinforcement(layout, verdicts.target, insight.text, { evidence: files });
|
|
556
|
+
if (reinforcement) {
|
|
557
|
+
decision.reasons.push(
|
|
558
|
+
`corroborated ${verdicts.target}${reinforcement.claimId ? `#${reinforcement.claimId}` : ""} (${reinforcement.corroborations}×)`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let supersession: Awaited<ReturnType<typeof applySupersession>>;
|
|
563
|
+
if (verdicts.relation === "supersedes") {
|
|
564
|
+
const supersededPage = bestCandidatePage(candidateClaims, insight.text);
|
|
565
|
+
if (supersededPage) supersession = await applySupersession(layout, supersededPage, insight.text);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
await appendLedger(layout, {
|
|
569
|
+
actor: "agent",
|
|
570
|
+
op: "insight.proposed",
|
|
571
|
+
subject: insight.text.slice(0, 120),
|
|
572
|
+
evidence: evidenceLines,
|
|
573
|
+
action: "submitted",
|
|
574
|
+
source: options.source,
|
|
575
|
+
});
|
|
576
|
+
await appendLedger(layout, {
|
|
577
|
+
actor: "jev",
|
|
578
|
+
op: "insight.adjudicate",
|
|
579
|
+
subject: insight.text.slice(0, 120),
|
|
580
|
+
verdict: verdicts,
|
|
581
|
+
thresholds: config.thresholds,
|
|
582
|
+
action: decision.action,
|
|
583
|
+
reason: decision.reasons.join("; "),
|
|
584
|
+
usage: totalUsage,
|
|
585
|
+
});
|
|
586
|
+
await appendLedger(layout, {
|
|
587
|
+
actor: "code",
|
|
588
|
+
op: "insight.decide",
|
|
589
|
+
subject: insight.text.slice(0, 120),
|
|
590
|
+
action: decision.action,
|
|
591
|
+
reason: decision.reasons.join("; "),
|
|
592
|
+
verdict: {
|
|
593
|
+
score: decision.score,
|
|
594
|
+
target: verdicts.target ?? null,
|
|
595
|
+
newPage: verdicts.newPage,
|
|
596
|
+
trustTier: verdicts.trustTier ?? null,
|
|
597
|
+
reinforcement: reinforcement ? { page: reinforcement.page, claimId: reinforcement.claimId, corroborations: reinforcement.corroborations } : null,
|
|
598
|
+
supersession: supersession ? { page: supersession.page, claimId: supersession.claimId } : null,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
await appendSessionLog(layout, {
|
|
602
|
+
text: insight.text,
|
|
603
|
+
kind: insight.kind,
|
|
604
|
+
source: options.source,
|
|
605
|
+
action: decision.action,
|
|
606
|
+
reason: decision.reasons.join("; "),
|
|
607
|
+
grounded: verdicts.grounded,
|
|
608
|
+
derivable: verdicts.derivable,
|
|
609
|
+
importance: verdicts.importanceNorm,
|
|
610
|
+
});
|
|
611
|
+
return { insight, verdicts, decision, files, reinforcement, supersession };
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const promoted = await promoteRecurring(layout);
|
|
615
|
+
const reports: ClaimReport[] = perInsight.map(({ insight, verdicts, decision, files }) => ({
|
|
616
|
+
text: insight.text,
|
|
617
|
+
quoteVerified: false,
|
|
618
|
+
action: decision.action,
|
|
619
|
+
score: decision.score,
|
|
620
|
+
reasons: decision.reasons,
|
|
621
|
+
grounded: verdicts.grounded,
|
|
622
|
+
derivable: verdicts.derivable,
|
|
623
|
+
importanceNorm: verdicts.importanceNorm,
|
|
624
|
+
criticalityNorm: verdicts.criticalityNorm,
|
|
625
|
+
trustTier: verdicts.trustTier,
|
|
626
|
+
files,
|
|
627
|
+
pageType: verdicts.pageType,
|
|
628
|
+
topic: verdicts.topic,
|
|
629
|
+
target: verdicts.target,
|
|
630
|
+
newPage: verdicts.newPage,
|
|
631
|
+
}));
|
|
632
|
+
const reinforcements = perInsight
|
|
633
|
+
.filter((entry) => entry.reinforcement)
|
|
634
|
+
.map(
|
|
635
|
+
(entry) =>
|
|
636
|
+
`${entry.reinforcement!.page}${entry.reinforcement!.claimId ? `#${entry.reinforcement!.claimId}` : ""} (${entry.reinforcement!.corroborations}×)`,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const rawBody = perInsight
|
|
640
|
+
.map(({ insight, verdicts, decision, reinforcement, supersession }) => {
|
|
641
|
+
const evidence = (insight.evidence ?? []).map((item) => `- ${item.kind}: ${item.ref}${item.quote ? ` — "${item.quote}"` : ""}`).join("\n");
|
|
642
|
+
return [
|
|
643
|
+
`### ${insight.text}`,
|
|
644
|
+
insight.kind ? `Kind: ${insight.kind}` : "",
|
|
645
|
+
evidence ? `Evidence:\n${evidence}` : "",
|
|
646
|
+
`Verdict: ${decision.action} (grounded ${verdicts.grounded.toFixed(2)}, derivable ${verdicts.derivable.toFixed(2)}, importance ${verdicts.importanceNorm.toFixed(2)}, criticality ${verdicts.criticalityNorm.toFixed(2)})`,
|
|
647
|
+
verdicts.target ? `Target: ${verdicts.target}` : "",
|
|
648
|
+
reinforcement ? `Reinforced: ${reinforcement.page}${reinforcement.claimId ? `#${reinforcement.claimId}` : ""} (${reinforcement.corroborations}×)` : "",
|
|
649
|
+
supersession ? `Superseded: ${supersession.page}${supersession.claimId ? `#${supersession.claimId}` : ""}` : "",
|
|
650
|
+
]
|
|
651
|
+
.filter(Boolean)
|
|
652
|
+
.join("\n");
|
|
653
|
+
})
|
|
654
|
+
.join("\n\n");
|
|
655
|
+
|
|
656
|
+
const rawPath = await writeRawSource(
|
|
657
|
+
layout,
|
|
658
|
+
"sessions",
|
|
659
|
+
slug,
|
|
660
|
+
{
|
|
661
|
+
title: `Session capture ${todayISO(stamp)} (${options.source})`,
|
|
662
|
+
type: "raw-source",
|
|
663
|
+
source: "session",
|
|
664
|
+
collected: todayISO(stamp),
|
|
665
|
+
sha256: await sha256Hex(redact(rawBody).text),
|
|
666
|
+
},
|
|
667
|
+
redact(rawBody).text,
|
|
668
|
+
);
|
|
669
|
+
const rawPathRel = relative(layout.root, rawPath).split("\\").join("/");
|
|
670
|
+
|
|
671
|
+
const writerClaims: WriterClaim[] = reports
|
|
672
|
+
.filter((report) => report.action === "file" || report.action === "file_user_stated")
|
|
673
|
+
.map((report) => {
|
|
674
|
+
const entry = perInsight.find((candidate) => candidate.insight.text === report.text);
|
|
675
|
+
return {
|
|
676
|
+
text: report.text,
|
|
677
|
+
kind: entry?.insight.kind,
|
|
678
|
+
pageType: report.pageType,
|
|
679
|
+
topic: report.topic,
|
|
680
|
+
target: report.target,
|
|
681
|
+
trustTier: report.trustTier,
|
|
682
|
+
files: report.files,
|
|
683
|
+
evidence: [rawPathRel, ...(entry?.insight.evidence ?? []).map((item) => item.ref)],
|
|
684
|
+
grounded: report.grounded,
|
|
685
|
+
criticality: report.criticalityNorm,
|
|
686
|
+
};
|
|
687
|
+
});
|
|
688
|
+
const writeResult = await writeAcceptedPages(
|
|
689
|
+
ctx,
|
|
690
|
+
layout,
|
|
691
|
+
config,
|
|
692
|
+
{
|
|
693
|
+
title: `Session capture ${todayISO(stamp)}`,
|
|
694
|
+
topic: topics[0] ?? "general",
|
|
695
|
+
sourcePath: rawPathRel,
|
|
696
|
+
claims: writerClaims,
|
|
697
|
+
},
|
|
698
|
+
options.mode ?? config.writer.mode,
|
|
699
|
+
);
|
|
700
|
+
for (const item of writeResult.flagged) {
|
|
701
|
+
await enqueueReview(layout, {
|
|
702
|
+
kind: "claim_review",
|
|
703
|
+
claimText: `Auto-written page ${item.page} contains literals not present in evidence: ${item.missing.join(", ")}`,
|
|
704
|
+
page: item.page,
|
|
705
|
+
criticality: 0.6,
|
|
706
|
+
reason: "writer grounding check failed",
|
|
707
|
+
verdicts: { missing: item.missing },
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
await appendLog(layout, "capture", `${reports.length} insights (${options.source})`, [
|
|
712
|
+
`Raw: ${rawPathRel}`,
|
|
713
|
+
`Filed ${reports.filter((r) => r.action === "file" || r.action === "file_user_stated").length} · reinforced ${reports.filter((r) => r.action === "reinforce").length} · review ${reports.filter((r) => r.action === "review").length} · rejected ${reports.filter((r) => r.action.startsWith("reject")).length}`,
|
|
714
|
+
...((promoted ?? 0) > 0 ? [`Promoted ${promoted} recurring candidate(s) to review`] : []),
|
|
715
|
+
...(writeResult.written.length > 0 ? [`Auto-written: ${writeResult.written.join(", ")}`] : []),
|
|
716
|
+
...(writeResult.drafted.length > 0 ? [`Drafts: ${writeResult.drafted.join(", ")}`] : []),
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
const brief = renderBrief(
|
|
720
|
+
`session ${todayISO(stamp)}`,
|
|
721
|
+
reports,
|
|
722
|
+
[
|
|
723
|
+
`Raw session record: \`${rawPathRel}\``,
|
|
724
|
+
...(reinforcements.length > 0 ? [`Reinforced: ${reinforcements.join(", ")}`] : []),
|
|
725
|
+
...(writeResult.written.length > 0 ? [`Written automatically (${writeResult.mode}): ${writeResult.written.join(", ")}`] : []),
|
|
726
|
+
...(writeResult.drafted.length > 0 ? [`Drafts written (${writeResult.mode}): ${writeResult.drafted.join(", ")}`] : []),
|
|
727
|
+
],
|
|
728
|
+
{ guided: writeResult.mode === "guided" },
|
|
729
|
+
);
|
|
730
|
+
const accepted = reports.filter((report) => ["file", "file_user_stated", "reinforce"].includes(report.action)).length;
|
|
731
|
+
return {
|
|
732
|
+
brief,
|
|
733
|
+
details: {
|
|
734
|
+
rawPath,
|
|
735
|
+
insights: reports,
|
|
736
|
+
reinforcements,
|
|
737
|
+
supersessions: perInsight.filter((entry) => entry.supersession).map((entry) => entry.supersession),
|
|
738
|
+
promoted,
|
|
739
|
+
source: options.source,
|
|
740
|
+
accepted,
|
|
741
|
+
writer: writeResult,
|
|
742
|
+
usage: client.totals,
|
|
743
|
+
},
|
|
744
|
+
accepted,
|
|
745
|
+
rawPath,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Extension entry
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
export default function (pi: ExtensionAPI) {
|
|
754
|
+
pi.registerTool({
|
|
755
|
+
name: "wiki_status",
|
|
756
|
+
label: "Wiki Status",
|
|
757
|
+
description: "Show project wiki status: paths, page/raw counts, TOC size, review queue, recent log, and Jev usage.",
|
|
758
|
+
promptSnippet: "Show project wiki status and Jev usage",
|
|
759
|
+
promptGuidelines: ["Use wiki_status when the user asks about the wiki itself or before maintenance work."],
|
|
760
|
+
parameters: Type.Object({}),
|
|
761
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
762
|
+
const runtime = runtimeFor(ctx);
|
|
763
|
+
const { loaded, layout } = runtime;
|
|
764
|
+
const pages = existsSync(layout.wikiDir) ? await listMarkdownFiles(layout.wikiDir) : [];
|
|
765
|
+
const raws = existsSync(layout.rawDir) ? await listMarkdownFiles(layout.rawDir) : [];
|
|
766
|
+
const entries = await readIndex(layout);
|
|
767
|
+
const ledger = await readLedger(layout);
|
|
768
|
+
const summary = summarizeLedger(ledger);
|
|
769
|
+
const metrics = summarizeMetrics(await readMetrics(layout));
|
|
770
|
+
const recent = await readRecentLog(layout, 5);
|
|
771
|
+
const text = [
|
|
772
|
+
`# jev-wiki status`,
|
|
773
|
+
"",
|
|
774
|
+
`- Wiki root: \`${layout.root}\``,
|
|
775
|
+
`- Pages: ${pages.length} (TOC entries: ${entries.length})`,
|
|
776
|
+
`- Raw sources: ${raws.length}`,
|
|
777
|
+
`- Provider/model: ${loaded.config.provider} · ${loaded.config.model}`,
|
|
778
|
+
`- API key: ${loaded.apiKey ? "configured" : `MISSING (${loaded.envFilePath})`}`,
|
|
779
|
+
`- Writer mode: ${loaded.config.writer.mode} · review: ${loaded.config.review.mode}`,
|
|
780
|
+
`- Review queue: ${await openReviewCount(layout)} open`,
|
|
781
|
+
`- Consultations: ${metrics.consultations} (${metrics.searches} searches, ${metrics.pagesReturned.size} pages surfaced)`,
|
|
782
|
+
`- Ledger: ${summary.total} decisions (${JSON.stringify(summary.byActor)})`,
|
|
783
|
+
`- Jev tokens: ${summary.jevTokensIn} in / ${summary.jevTokensOut} out${summary.jevCost ? ` · $${summary.jevCost.toFixed(6)}` : ""}`,
|
|
784
|
+
"",
|
|
785
|
+
recent.length ? `Recent log:\n${recent.map((line) => `- ${line}`).join("\n")}` : "Recent log: (empty)",
|
|
786
|
+
].join("\n");
|
|
787
|
+
return { content: [{ type: "text", text }], details: { layout, pages: pages.length, raws: raws.length, entries: entries.length, ledger: summary } };
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
pi.registerTool({
|
|
792
|
+
name: "wiki_toc",
|
|
793
|
+
label: "Wiki Table of Contents",
|
|
794
|
+
description: "Read the project wiki table of contents (optionally filtered by topic, tag, or query).",
|
|
795
|
+
promptSnippet: "Read the wiki table of contents",
|
|
796
|
+
promptGuidelines: [
|
|
797
|
+
"Use wiki_toc before architectural or unfamiliar changes, when planning work, or when a project term is unclear.",
|
|
798
|
+
"Consult the wiki proactively: it holds the project's structure, invariants, decisions, and gotchas.",
|
|
799
|
+
],
|
|
800
|
+
parameters: Type.Object({
|
|
801
|
+
topic: Type.Optional(Type.String({ description: "Filter to one topic directory" })),
|
|
802
|
+
tag: Type.Optional(Type.String({ description: "Filter to entries carrying this tag" })),
|
|
803
|
+
query: Type.Optional(Type.String({ description: "Substring match on title or summary" })),
|
|
804
|
+
}),
|
|
805
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
806
|
+
const { loaded, layout } = runtimeFor(ctx);
|
|
807
|
+
const entries = await readIndex(layout);
|
|
808
|
+
let filtered = entries;
|
|
809
|
+
if (params.topic) filtered = filtered.filter((entry) => entry.path.startsWith(`${params.topic}/`));
|
|
810
|
+
if (params.tag) filtered = filtered.filter((entry) => entry.tags.includes(params.tag!));
|
|
811
|
+
if (params.query) {
|
|
812
|
+
const needle = params.query.toLowerCase();
|
|
813
|
+
filtered = filtered.filter(
|
|
814
|
+
(entry) => entry.title.toLowerCase().includes(needle) || entry.summary.toLowerCase().includes(needle),
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (filtered.length === 0) {
|
|
818
|
+
return {
|
|
819
|
+
content: [{ type: "text", text: entries.length === 0 ? "The wiki is empty. Ingest a source or capture session insights first." : "No TOC entries match." }],
|
|
820
|
+
details: { entries: entries.length, filtered: 0 },
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
const maxChars = loaded.config.toc.maxTokens * 4;
|
|
824
|
+
await recordMetric(layout, {
|
|
825
|
+
op: "toc",
|
|
826
|
+
detail: { topic: params.topic, tag: params.tag, query: params.query, filtered: filtered.length },
|
|
827
|
+
});
|
|
828
|
+
if (params.topic && !params.tag && !params.query) {
|
|
829
|
+
const topicPath = join(layout.wikiDir, "toc", `${topicSlug(params.topic)}.md`);
|
|
830
|
+
if (existsSync(topicPath)) {
|
|
831
|
+
return {
|
|
832
|
+
content: [{ type: "text", text: truncate(await readFile(topicPath, "utf8"), maxChars) }],
|
|
833
|
+
details: { entries: entries.length, filtered: filtered.length, topic: params.topic },
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const compact = filtered.length > 60 && !params.tag && !params.query;
|
|
838
|
+
const body = compact ? renderCompactToc(filtered) : renderIndex(filtered);
|
|
839
|
+
return {
|
|
840
|
+
content: [{ type: "text", text: truncate(body, maxChars) }],
|
|
841
|
+
details: { entries: entries.length, filtered: filtered.length, compact },
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
pi.registerTool({
|
|
847
|
+
name: "wiki_ask",
|
|
848
|
+
label: "Ask the Wiki",
|
|
849
|
+
description: "Search the project wiki for pages relevant to a question and return matched excerpts with page paths.",
|
|
850
|
+
promptSnippet: "Search the project wiki for relevant pages and excerpts",
|
|
851
|
+
promptGuidelines: [
|
|
852
|
+
"Use wiki_ask before designing or changing cross-cutting behavior; then read the returned pages with the read tool.",
|
|
853
|
+
"Prefer wiki knowledge over guessing; cite the page paths you used.",
|
|
854
|
+
],
|
|
855
|
+
parameters: Type.Object({
|
|
856
|
+
query: Type.String({ description: "What you need to know" }),
|
|
857
|
+
limit: Type.Optional(Type.Number({ description: "Max pages to return (default 5)" })),
|
|
858
|
+
}),
|
|
859
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
860
|
+
const { loaded, layout } = runtimeFor(ctx);
|
|
861
|
+
const engine = createSearchEngine(loaded.config, layout, globalLayoutFor(loaded));
|
|
862
|
+
const limit = Math.max(1, Math.min(params.limit ?? 5, 10));
|
|
863
|
+
const results = await engine.search({ query: params.query, limit });
|
|
864
|
+
if (results.length === 0) {
|
|
865
|
+
return {
|
|
866
|
+
content: [{ type: "text", text: `No wiki pages match (engine: ${engine.name}). The wiki may not cover this yet.` }],
|
|
867
|
+
details: { matches: 0, engine: engine.name },
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const text = results
|
|
871
|
+
.map((result) => {
|
|
872
|
+
const scope = result.source === "global" ? " [global vault]" : "";
|
|
873
|
+
return [`### ${result.path}${scope} (score ${result.score})`, result.excerpt].join("\n");
|
|
874
|
+
})
|
|
875
|
+
.join("\n\n");
|
|
876
|
+
const pages = results.map((result) => result.path);
|
|
877
|
+
await recordMetric(layout, { op: "ask", query: params.query, pages, detail: { engine: engine.name } });
|
|
878
|
+
return { content: [{ type: "text", text }], details: { matches: results.length, pages, engine: engine.name } };
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
pi.registerTool({
|
|
883
|
+
name: "wiki_ingest",
|
|
884
|
+
label: "Ingest into Wiki",
|
|
885
|
+
description:
|
|
886
|
+
"Ingest a document into the project wiki: stores the immutable raw source, extracts claims, has Jev verify groundedness/derivability/durability and choose placement, and returns a brief. Write or merge only the accepted claims (guided mode), then finalize with wiki_finalize.",
|
|
887
|
+
promptSnippet: "Ingest a document into the project wiki (Jev-verified brief)",
|
|
888
|
+
promptGuidelines: [
|
|
889
|
+
"Use wiki_ingest when the user asks to add a document, URL content, or notes to the wiki.",
|
|
890
|
+
"After wiki_ingest, write or merge the pages it recommends, then call wiki_finalize.",
|
|
891
|
+
],
|
|
892
|
+
parameters: Type.Object({
|
|
893
|
+
path: Type.Optional(Type.String({ description: "Path to a source file (relative to the project)" })),
|
|
894
|
+
text: Type.Optional(Type.String({ description: "Raw source text, when no file is available" })),
|
|
895
|
+
title: Type.Optional(Type.String({ description: "Title override" })),
|
|
896
|
+
topic: Type.Optional(Type.String({ description: "Topic directory override" })),
|
|
897
|
+
source: Type.Optional(Type.String({ description: "Origin URL or description" })),
|
|
898
|
+
mode: Type.Optional(StringEnum(["guided", "draft", "auto"] as const, { description: "Writer mode override; critical claims downgrade automatically" })),
|
|
899
|
+
}),
|
|
900
|
+
async execute(_id, params, _signal, onUpdate, ctx) {
|
|
901
|
+
const runtime = runtimeFor(ctx);
|
|
902
|
+
let text = params.text ?? "";
|
|
903
|
+
let title = params.title;
|
|
904
|
+
if (params.path) {
|
|
905
|
+
const abs = isAbsolute(params.path) ? params.path : resolve(ctx.cwd, params.path);
|
|
906
|
+
text = await readFile(abs, "utf8");
|
|
907
|
+
if (!title) {
|
|
908
|
+
const heading = text.match(/^#\s+(.+)$/m);
|
|
909
|
+
title = heading ? heading[1].trim() : basename(abs).replace(/\.(md|txt|markdown)$/i, "");
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (!text.trim()) throw new Error("wiki_ingest needs `path` or `text`.");
|
|
913
|
+
if (text.length > 2_000_000) throw new Error("Source is larger than the 2MB ingest cap; split it or trim it first.");
|
|
914
|
+
title = title ?? "Untitled source";
|
|
915
|
+
const topic = params.topic ?? slugify(title.split(/\s+/).slice(0, 3).join("-"), 30);
|
|
916
|
+
onUpdate?.({ content: [{ type: "text", text: `Staging "${title}"…` }], details: {} });
|
|
917
|
+
const result = await ingestSource(runtime, ctx, { text, title, topic, source: params.source });
|
|
918
|
+
const requestedMode = params.mode ?? runtime.loaded.config.writer.mode;
|
|
919
|
+
let brief = result.brief;
|
|
920
|
+
if (requestedMode !== "guided") {
|
|
921
|
+
const reports = (result.details.claims ?? []) as ClaimReport[];
|
|
922
|
+
const rawPathRel = relative(runtime.layout.root, result.rawPath).split("\\").join("/");
|
|
923
|
+
const writerClaims: WriterClaim[] = reports
|
|
924
|
+
.filter((report) => report.action === "file" || report.action === "file_user_stated")
|
|
925
|
+
.map((report) => ({
|
|
926
|
+
text: report.text,
|
|
927
|
+
pageType: report.pageType,
|
|
928
|
+
topic: report.topic ?? topic,
|
|
929
|
+
target: report.target,
|
|
930
|
+
trustTier: report.trustTier,
|
|
931
|
+
files: report.files,
|
|
932
|
+
evidence: [rawPathRel],
|
|
933
|
+
grounded: report.grounded,
|
|
934
|
+
criticality: report.criticalityNorm,
|
|
935
|
+
}));
|
|
936
|
+
const writeResult = await writeAcceptedPages(
|
|
937
|
+
ctx,
|
|
938
|
+
runtime.layout,
|
|
939
|
+
runtime.loaded.config,
|
|
940
|
+
{ title, topic, sourcePath: rawPathRel, claims: writerClaims },
|
|
941
|
+
requestedMode,
|
|
942
|
+
);
|
|
943
|
+
for (const item of writeResult.flagged) {
|
|
944
|
+
await enqueueReview(runtime.layout, {
|
|
945
|
+
kind: "claim_review",
|
|
946
|
+
claimText: `Auto-written page ${item.page} contains literals not present in evidence: ${item.missing.join(", ")}`,
|
|
947
|
+
page: item.page,
|
|
948
|
+
criticality: 0.6,
|
|
949
|
+
reason: "writer grounding check failed",
|
|
950
|
+
verdicts: { missing: item.missing },
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
if (writeResult.written.length > 0) brief += `\n\nWritten automatically (${writeResult.mode}): ${writeResult.written.join(", ")}`;
|
|
954
|
+
if (writeResult.drafted.length > 0) brief += `\n\nDrafts written (${writeResult.mode}): ${writeResult.drafted.join(", ")}`;
|
|
955
|
+
(result.details as Record<string, unknown>).writer = writeResult;
|
|
956
|
+
}
|
|
957
|
+
return { content: [{ type: "text", text: brief }], details: result.details };
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
pi.registerTool({
|
|
962
|
+
name: "wiki_insights",
|
|
963
|
+
label: "Capture Agent Insights",
|
|
964
|
+
description:
|
|
965
|
+
"Submit a list of key insights from the current work session. Jev filters them (derivable/durable/sensitive), relates them to existing knowledge, and chooses placement into existing pages or new ones. Returns a brief; write or merge only the accepted claims, then call wiki_finalize.",
|
|
966
|
+
promptSnippet: "Capture durable project insights from this session into the wiki",
|
|
967
|
+
promptGuidelines: [
|
|
968
|
+
"Use wiki_insights at the end of substantive work to capture durable, non-derivable knowledge (decisions, invariants, architecture, gotchas) with evidence pointers.",
|
|
969
|
+
"Do not capture transient task state, code snippets, or anything derivable by reading the repo.",
|
|
970
|
+
"After wiki_insights, write or merge the recommended pages, then call wiki_finalize.",
|
|
971
|
+
],
|
|
972
|
+
parameters: Type.Object({
|
|
973
|
+
insights: Type.Array(
|
|
974
|
+
Type.Object({
|
|
975
|
+
text: Type.String({ description: "One atomic, self-contained insight" }),
|
|
976
|
+
kind: Type.Optional(
|
|
977
|
+
Type.String({ description: "decision | invariant | architecture | gotcha | pattern | procedure | fact | preference" }),
|
|
978
|
+
),
|
|
979
|
+
evidence: Type.Optional(
|
|
980
|
+
Type.Array(
|
|
981
|
+
Type.Object({
|
|
982
|
+
kind: Type.String({ description: "file | commit | test | command | user | source" }),
|
|
983
|
+
ref: Type.String({ description: "Path, commit hash, command, or quote" }),
|
|
984
|
+
quote: Type.Optional(Type.String()),
|
|
985
|
+
}),
|
|
986
|
+
),
|
|
987
|
+
),
|
|
988
|
+
confidence: Type.Optional(Type.Number()),
|
|
989
|
+
}),
|
|
990
|
+
),
|
|
991
|
+
mode: Type.Optional(StringEnum(["guided", "draft", "auto"] as const, { description: "Writer mode override; critical claims downgrade automatically" })),
|
|
992
|
+
}),
|
|
993
|
+
async execute(_id, params, _signal, onUpdate, ctx) {
|
|
994
|
+
const runtime = runtimeFor(ctx);
|
|
995
|
+
const { loaded, layout } = runtime;
|
|
996
|
+
const client = await requireClient(loaded, ctx);
|
|
997
|
+
await ensureLayout(layout);
|
|
998
|
+
if (params.insights.length === 0) {
|
|
999
|
+
return { content: [{ type: "text", text: "No insights submitted." }], details: { count: 0 } };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
onUpdate?.({ content: [{ type: "text", text: `Adjudicating ${params.insights.length} insights…` }], details: {} });
|
|
1003
|
+
const result = await processInsights(runtime, ctx, client, params.insights, { source: "tool", mode: params.mode });
|
|
1004
|
+
return { content: [{ type: "text", text: result.brief }], details: result.details };
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
pi.registerTool({
|
|
1009
|
+
name: "wiki_finalize",
|
|
1010
|
+
label: "Finalize Wiki Pages",
|
|
1011
|
+
description: "Update the wiki table of contents and log for pages you wrote or edited, and report broken internal links.",
|
|
1012
|
+
promptSnippet: "Update wiki TOC/log after writing pages and check links",
|
|
1013
|
+
promptGuidelines: ["Call wiki_finalize with every page you created or edited in the wiki, before ending the turn."],
|
|
1014
|
+
parameters: Type.Object({
|
|
1015
|
+
pages: Type.Array(Type.String({ description: "Page paths (relative to the project or the wiki root)" })),
|
|
1016
|
+
note: Type.Optional(Type.String({ description: "Short note for the log" })),
|
|
1017
|
+
}),
|
|
1018
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1019
|
+
const { layout } = runtimeFor(ctx);
|
|
1020
|
+
const updates: TocEntry[] = [];
|
|
1021
|
+
const broken: string[] = [];
|
|
1022
|
+
for (const page of params.pages) {
|
|
1023
|
+
const absolute = await resolvePagePath(layout, ctx.cwd, page);
|
|
1024
|
+
if (!absolute) {
|
|
1025
|
+
broken.push(`${page} (missing)`);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const rel = relative(layout.wikiDir, absolute).split("\\").join("/");
|
|
1029
|
+
if (rel.startsWith("..")) {
|
|
1030
|
+
broken.push(`${page} (outside the wiki)`);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const parsed = await readPage(absolute);
|
|
1034
|
+
updates.push(entryFromPage(rel, parsed.data));
|
|
1035
|
+
for (const link of extractMarkdownLinks(parsed.body)) {
|
|
1036
|
+
if (/^[a-z]+:/i.test(link) || link.startsWith("#")) continue;
|
|
1037
|
+
const target = resolve(dirname(absolute), link.split("#")[0]);
|
|
1038
|
+
if (!existsSync(target)) broken.push(`${rel} → ${link}`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
await updateIndex(layout, (current) => upsertEntries(current, updates));
|
|
1042
|
+
await appendLog(layout, "finalize", params.note ?? `${updates.length} page(s)`, updates.map((entry) => `Updated: ${entry.path}`));
|
|
1043
|
+
await removeFileIfExists(join(layout.stateDir, "pending-capture.md"));
|
|
1044
|
+
await appendLedger(layout, {
|
|
1045
|
+
actor: "agent",
|
|
1046
|
+
op: "wiki.finalize",
|
|
1047
|
+
action: "updated",
|
|
1048
|
+
subject: updates.map((entry) => entry.path).join(", ").slice(0, 200),
|
|
1049
|
+
outcome: broken.length ? `broken links: ${broken.length}` : "ok",
|
|
1050
|
+
});
|
|
1051
|
+
const lines = [
|
|
1052
|
+
`Updated TOC for ${updates.length} page(s): ${updates.map((entry) => `\`${entry.path}\``).join(", ") || "(none)"}`,
|
|
1053
|
+
broken.length ? `Broken links:\n${broken.map((item) => `- ${item}`).join("\n")}` : "No broken links found.",
|
|
1054
|
+
];
|
|
1055
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { updated: updates.map((entry) => entry.path), broken } };
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
pi.registerTool({
|
|
1060
|
+
name: "wiki_sync",
|
|
1061
|
+
label: "Sync Wiki with Code",
|
|
1062
|
+
description:
|
|
1063
|
+
"Diff the repository since the last synced commit, ask Jev which file-linked claims are affected, and update them (no_impact / needs_recheck / supersede / dispute). Affected claims are queued for wiki_review. The first run initializes the sync baseline.",
|
|
1064
|
+
promptSnippet: "Re-verify the wiki after code changes (diff since last sync)",
|
|
1065
|
+
promptGuidelines: [
|
|
1066
|
+
"Run wiki_sync after pulling, rebasing, or before relying on wiki claims about recently changed files.",
|
|
1067
|
+
"The first wiki_sync initializes the baseline; later runs check all commits since then.",
|
|
1068
|
+
],
|
|
1069
|
+
parameters: Type.Object({
|
|
1070
|
+
baseline: Type.Optional(Type.String({ description: "Git ref to diff from (default: last synced commit)" })),
|
|
1071
|
+
dryRun: Type.Optional(Type.Boolean({ description: "Report impacts without changing pages or the baseline" })),
|
|
1072
|
+
}),
|
|
1073
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1074
|
+
const { loaded, layout } = runtimeFor(ctx);
|
|
1075
|
+
const client = await requireClient(loaded, ctx);
|
|
1076
|
+
onUpdate?.({ content: [{ type: "text", text: "Checking code changes since the last wiki sync…" }], details: {} });
|
|
1077
|
+
const report = await syncWiki(layout, client, loaded.config, ctx.cwd, {
|
|
1078
|
+
baseline: params.baseline,
|
|
1079
|
+
dryRun: params.dryRun,
|
|
1080
|
+
signal: ctx.signal,
|
|
1081
|
+
});
|
|
1082
|
+
if (!report.repo) {
|
|
1083
|
+
return { content: [{ type: "text", text: "Not a git repository; change-driven sync is unavailable." }], details: report };
|
|
1084
|
+
}
|
|
1085
|
+
if (report.baselineInitialized) {
|
|
1086
|
+
return {
|
|
1087
|
+
content: [{ type: "text", text: `Sync baseline initialized at ${report.head?.slice(0, 7)}. Future runs will check commits after this point.` }],
|
|
1088
|
+
details: report,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const lines = [
|
|
1092
|
+
`## Wiki sync — ${report.baseline?.slice(0, 7)} → ${report.head?.slice(0, 7)}${report.dryRun ? " (dry run)" : ""}`,
|
|
1093
|
+
"",
|
|
1094
|
+
`Changed files: ${report.changedFiles.length} · file-linked claims matched: ${report.matchedClaims}`,
|
|
1095
|
+
];
|
|
1096
|
+
if (report.impacts.length === 0) {
|
|
1097
|
+
lines.push("", "No file-linked claims were affected. The wiki is current for this diff.");
|
|
1098
|
+
} else {
|
|
1099
|
+
lines.push("", "| Page | Claim | Impact | Still true |", "|------|-------|--------|------------|");
|
|
1100
|
+
for (const impact of report.impacts) {
|
|
1101
|
+
lines.push(
|
|
1102
|
+
`| \`${impact.page}\` | ${impact.text.slice(0, 70)} | ${impact.impact} (${impact.confidence.toFixed(2)}) | ${impact.stillTrue.toFixed(2)} |`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (report.applied.length > 0) lines.push("", "Applied:", ...report.applied.map((line) => `- ${line}`));
|
|
1106
|
+
lines.push("", "Run `wiki_review` to resolve any queued needs-recheck or dispute items.");
|
|
1107
|
+
}
|
|
1108
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
pi.registerTool({
|
|
1113
|
+
name: "wiki_review",
|
|
1114
|
+
label: "Work the Wiki Review Queue",
|
|
1115
|
+
description:
|
|
1116
|
+
"List open wiki review items (disputes, needs-recheck claims, low-confidence claims) or resolve one. Critical items require user confirmation before they are applied.",
|
|
1117
|
+
promptSnippet: "List or resolve wiki review items",
|
|
1118
|
+
promptGuidelines: [
|
|
1119
|
+
"When the user asks to review the wiki, call wiki_review with action=list, read the referenced pages to gather evidence, then resolve each item.",
|
|
1120
|
+
"Resolve items with accept (claim confirmed), reject (claim wrong), supersede (newer knowledge exists), or defer (leave open).",
|
|
1121
|
+
],
|
|
1122
|
+
parameters: Type.Object({
|
|
1123
|
+
action: StringEnum(["list", "resolve"] as const),
|
|
1124
|
+
id: Type.Optional(Type.String({ description: "Review item id (for resolve)" })),
|
|
1125
|
+
resolution: Type.Optional(StringEnum(["accept", "reject", "supersede", "defer"] as const)),
|
|
1126
|
+
note: Type.Optional(Type.String({ description: "Reasoning or evidence for the resolution" })),
|
|
1127
|
+
}),
|
|
1128
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1129
|
+
const { loaded, layout } = runtimeFor(ctx);
|
|
1130
|
+
if (params.action === "list") {
|
|
1131
|
+
const open = await listOpenReviews(layout, loaded.config.review.maxPerSession);
|
|
1132
|
+
if (open.length === 0) {
|
|
1133
|
+
return { content: [{ type: "text", text: "Review queue is empty." }], details: { items: [] } };
|
|
1134
|
+
}
|
|
1135
|
+
const lines = [`## Wiki review queue (${open.length} open)`, ""];
|
|
1136
|
+
for (const item of open) {
|
|
1137
|
+
lines.push(
|
|
1138
|
+
`- \`${item.id}\` · **${item.kind}** · criticality ${item.criticality.toFixed(2)}`,
|
|
1139
|
+
` claim: ${item.claimText}`,
|
|
1140
|
+
item.page ? ` page: \`${item.page}\`${item.claimId ? ` (${item.claimId})` : ""}` : " page: (not attached)",
|
|
1141
|
+
item.reason ? ` reason: ${item.reason}` : "",
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
lines.push("", "Read the referenced pages, then resolve each with action=resolve, id=<id>, resolution=accept|reject|supersede|defer.");
|
|
1145
|
+
return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], details: { items: open } };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (!params.id || !params.resolution) throw new Error("wiki_review resolve needs `id` and `resolution`.");
|
|
1149
|
+
const all = await readReviews(layout);
|
|
1150
|
+
const item = all.find((candidate) => candidate.id === params.id);
|
|
1151
|
+
if (!item) throw new Error(`Review item not found: ${params.id}`);
|
|
1152
|
+
|
|
1153
|
+
const critical = item.criticality >= loaded.config.review.escalateCriticality;
|
|
1154
|
+
if (critical && params.resolution !== "defer") {
|
|
1155
|
+
if (!ctx.hasUI) {
|
|
1156
|
+
await resolveReview(layout, item.id, "defer", "critical item requires user confirmation");
|
|
1157
|
+
return {
|
|
1158
|
+
content: [{ type: "text", text: `Item \`${item.id}\` is critical (${item.criticality.toFixed(2)}); deferred for user confirmation.` }],
|
|
1159
|
+
details: { deferred: true, item },
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
const approved = await ctx.ui.confirm(
|
|
1163
|
+
"Critical wiki claim",
|
|
1164
|
+
`${item.claimText}\n\nResolve as "${params.resolution}"?`,
|
|
1165
|
+
);
|
|
1166
|
+
if (!approved) {
|
|
1167
|
+
await resolveReview(layout, item.id, "defer", "user declined at escalation");
|
|
1168
|
+
return { content: [{ type: "text", text: `User declined; item \`${item.id}\` deferred.` }], details: { deferred: true, item } };
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const applied = await applyReviewResolution(layout, item, params.resolution);
|
|
1173
|
+
await resolveReview(layout, item.id, params.resolution, params.note);
|
|
1174
|
+
if (params.resolution === "accept") {
|
|
1175
|
+
await appendLedger(layout, {
|
|
1176
|
+
actor: "code",
|
|
1177
|
+
op: "wiki.review.accept",
|
|
1178
|
+
action: "file",
|
|
1179
|
+
subject: item.claimText.slice(0, 120),
|
|
1180
|
+
reason: params.note ?? `reviewed as ${item.kind}`,
|
|
1181
|
+
verdict: { reviewId: item.id, page: item.page ?? null, criticality: item.criticality },
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
await appendLedger(layout, {
|
|
1185
|
+
actor: "agent",
|
|
1186
|
+
op: "wiki.review",
|
|
1187
|
+
subject: item.id,
|
|
1188
|
+
action: params.resolution,
|
|
1189
|
+
reason: params.note ?? item.reason,
|
|
1190
|
+
outcome: applied,
|
|
1191
|
+
verdict: { kind: item.kind, criticality: item.criticality, escalated: critical },
|
|
1192
|
+
});
|
|
1193
|
+
return {
|
|
1194
|
+
content: [{ type: "text", text: `Resolved \`${item.id}\` as ${params.resolution}. ${applied}` }],
|
|
1195
|
+
details: { id: item.id, resolution: params.resolution, applied },
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
pi.registerTool({
|
|
1201
|
+
name: "wiki_lint",
|
|
1202
|
+
label: "Lint the Wiki",
|
|
1203
|
+
description:
|
|
1204
|
+
"Health-check the wiki: TOC reconciliation, broken links, orphans, raw backlog, claims with no accepted ledger entry, and Jev contradiction checks on code-selected claim pairs. Safe issues are auto-fixed; judgment issues are reported and queued.",
|
|
1205
|
+
promptSnippet: "Health-check the wiki and auto-fix safe issues",
|
|
1206
|
+
promptGuidelines: [
|
|
1207
|
+
"Run wiki_lint periodically or when the user asks about wiki health; then work reported judgment issues with wiki_review.",
|
|
1208
|
+
],
|
|
1209
|
+
parameters: Type.Object({
|
|
1210
|
+
autoFix: Type.Optional(Type.Boolean({ description: "Apply safe fixes (TOC entries, dispute marking); default true" })),
|
|
1211
|
+
contradictions: Type.Optional(Type.Boolean({ description: "Run Jev contradiction checks; default true" })),
|
|
1212
|
+
}),
|
|
1213
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1214
|
+
const { loaded, layout } = runtimeFor(ctx);
|
|
1215
|
+
const client = await requireClient(loaded, ctx);
|
|
1216
|
+
onUpdate?.({ content: [{ type: "text", text: "Linting the wiki…" }], details: {} });
|
|
1217
|
+
const report = await lintWiki(layout, client, loaded.config, {
|
|
1218
|
+
autoFix: params.autoFix,
|
|
1219
|
+
checkContradictions: params.contradictions,
|
|
1220
|
+
signal: ctx.signal,
|
|
1221
|
+
});
|
|
1222
|
+
const contradicting = report.contradictions.filter((entry) => entry.relation === "contradicts").length;
|
|
1223
|
+
const lines = [
|
|
1224
|
+
`## Wiki lint — ${report.pages} page(s)`,
|
|
1225
|
+
"",
|
|
1226
|
+
`- TOC: ${report.toc.added.length} added · ${report.toc.updatedFixed.length} refreshed · ${report.toc.missingFiles.length} entries point to missing files`,
|
|
1227
|
+
`- Broken links: ${report.brokenLinks.length}`,
|
|
1228
|
+
`- Orphans: ${report.orphans.length}${report.orphans.length ? ` (${report.orphans.slice(0, 5).join(", ")}${report.orphans.length > 5 ? "…" : ""})` : ""}`,
|
|
1229
|
+
`- Raw backlog: ${report.rawBacklog.length}`,
|
|
1230
|
+
`- Unbacked claims (not in the accepted ledger): ${report.unbackedClaims.length}`,
|
|
1231
|
+
`- Contradictions: ${contradicting} of ${report.contradictions.length} checked pairs`,
|
|
1232
|
+
];
|
|
1233
|
+
if (report.unbackedClaims.length > 0) {
|
|
1234
|
+
lines.push(
|
|
1235
|
+
"",
|
|
1236
|
+
"### Unbacked claims",
|
|
1237
|
+
...report.unbackedClaims.slice(0, 10).map((claim) => `- \`${claim.page}\`${claim.claimId ? `#${claim.claimId}` : ""}: ${claim.text.slice(0, 100)}`),
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
if (report.brokenLinks.length > 0) {
|
|
1241
|
+
lines.push("", "### Broken links", ...report.brokenLinks.slice(0, 10).map((link) => `- ${link}`));
|
|
1242
|
+
}
|
|
1243
|
+
if (report.fixed.length > 0) lines.push("", `Auto-fixed: ${report.fixed.join("; ")}`);
|
|
1244
|
+
lines.push("", "Judgment items were queued for wiki_review where applicable.");
|
|
1245
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
pi.registerTool({
|
|
1250
|
+
name: "wiki_remove",
|
|
1251
|
+
label: "Remove Wiki Pages",
|
|
1252
|
+
description:
|
|
1253
|
+
"Remove pages from the wiki and their TOC entries. Use for pages that violate the quality bar (derivable/duplicate) or are obsolete. Raw sources are never removed.",
|
|
1254
|
+
promptSnippet: "Remove obsolete or invalid wiki pages",
|
|
1255
|
+
promptGuidelines: [
|
|
1256
|
+
"Use wiki_remove to delete pages that lint flags as derivable, duplicate, or obsolete; never remove raw sources.",
|
|
1257
|
+
],
|
|
1258
|
+
parameters: Type.Object({
|
|
1259
|
+
pages: Type.Array(Type.String({ description: "Page paths relative to the project or wiki root" })),
|
|
1260
|
+
reason: Type.String({ description: "Why these pages are being removed" }),
|
|
1261
|
+
}),
|
|
1262
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1263
|
+
const { layout } = runtimeFor(ctx);
|
|
1264
|
+
const removed: string[] = [];
|
|
1265
|
+
const refused: string[] = [];
|
|
1266
|
+
for (const page of params.pages) {
|
|
1267
|
+
const absolute = await resolvePagePath(layout, ctx.cwd, page);
|
|
1268
|
+
if (!absolute) {
|
|
1269
|
+
refused.push(`${page} (missing)`);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const rel = relative(layout.wikiDir, absolute).split("\\").join("/");
|
|
1273
|
+
if (rel.startsWith("..")) {
|
|
1274
|
+
refused.push(`${page} (outside the wiki)`);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
await removeFileIfExists(absolute);
|
|
1278
|
+
removed.push(rel);
|
|
1279
|
+
}
|
|
1280
|
+
await updateIndex(layout, (entries) => entries.filter((entry) => !removed.includes(entry.path)));
|
|
1281
|
+
await appendLog(layout, "remove", `${removed.length} page(s)`, [
|
|
1282
|
+
`Reason: ${params.reason}`,
|
|
1283
|
+
...removed.map((page) => `Removed: ${page}`),
|
|
1284
|
+
]);
|
|
1285
|
+
await appendLedger(layout, {
|
|
1286
|
+
actor: "agent",
|
|
1287
|
+
op: "wiki.remove",
|
|
1288
|
+
action: "removed",
|
|
1289
|
+
subject: removed.join(", ").slice(0, 200),
|
|
1290
|
+
reason: params.reason,
|
|
1291
|
+
});
|
|
1292
|
+
const lines = [`Removed ${removed.length} page(s): ${removed.join(", ") || "(none)"}`];
|
|
1293
|
+
if (refused.length > 0) lines.push(`Refused: ${refused.join(", ")}`);
|
|
1294
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { removed, refused } };
|
|
1295
|
+
},
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
pi.registerTool({
|
|
1299
|
+
name: "wiki_structure",
|
|
1300
|
+
label: "Scan Repository Structure",
|
|
1301
|
+
description:
|
|
1302
|
+
"Deterministic module and dependency map: manifests, entry points, module import edges, test surface, and which modules lack an architecture page. No model calls.",
|
|
1303
|
+
promptSnippet: "Scan repository structure and wiki architecture coverage",
|
|
1304
|
+
promptGuidelines: [
|
|
1305
|
+
"Use wiki_structure before large refactors or when architecture pages may be stale, then capture or update architecture knowledge via wiki_insights.",
|
|
1306
|
+
],
|
|
1307
|
+
parameters: Type.Object({}),
|
|
1308
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
1309
|
+
const { layout } = runtimeFor(ctx);
|
|
1310
|
+
const report = await scanStructure(ctx.cwd, layout);
|
|
1311
|
+
return { content: [{ type: "text", text: renderStructure(report) }], details: report };
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
pi.registerTool({
|
|
1316
|
+
name: "wiki_doctor",
|
|
1317
|
+
label: "Wiki Doctor",
|
|
1318
|
+
description:
|
|
1319
|
+
"Cheap deterministic health checks: config values, endpoint, API key, .env gitignore, layout, lock, ledger, review queue, git/sync state, and search engine availability. No model calls.",
|
|
1320
|
+
promptSnippet: "Run wiki health checks",
|
|
1321
|
+
promptGuidelines: [
|
|
1322
|
+
"Use wiki_doctor when the wiki behaves unexpectedly, before maintenance, or when starting in a new project.",
|
|
1323
|
+
],
|
|
1324
|
+
parameters: Type.Object({}),
|
|
1325
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
1326
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1327
|
+
const report = await runDoctor(loaded);
|
|
1328
|
+
return { content: [{ type: "text", text: renderDoctor(report) }], details: report };
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
pi.registerTool({
|
|
1333
|
+
name: "wiki_setup",
|
|
1334
|
+
label: "Configure Jev API Key",
|
|
1335
|
+
description:
|
|
1336
|
+
"Check or configure the Jev API key: status, guide (exact steps for TypeSafe or OpenRouter), test (one tiny live call), or write-env (write the key into the project .env after verifying it is gitignored). The key value is never echoed.",
|
|
1337
|
+
promptSnippet: "Check or configure the Jev API key (TypeSafe or OpenRouter)",
|
|
1338
|
+
promptGuidelines: [
|
|
1339
|
+
"Use wiki_setup when no Jev key is configured, when the user asks how to connect TypeSafe or OpenRouter, or when Jev calls fail with authentication errors.",
|
|
1340
|
+
"Never echo the key value back to the user; wiki_setup reports only where the key came from.",
|
|
1341
|
+
],
|
|
1342
|
+
parameters: Type.Object({
|
|
1343
|
+
action: StringEnum(["status", "guide", "test", "write-env"] as const),
|
|
1344
|
+
provider: Type.Optional(StringEnum(["typesafe", "openrouter", "aimlapi"] as const, { description: "Defaults to the configured provider" })),
|
|
1345
|
+
apiKey: Type.Optional(Type.String({ description: "Only used by write-env; never echoed" })),
|
|
1346
|
+
}),
|
|
1347
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1348
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1349
|
+
const provider = params.provider ?? loaded.config.provider;
|
|
1350
|
+
|
|
1351
|
+
if (params.action === "status") {
|
|
1352
|
+
const piKey =
|
|
1353
|
+
!loaded.apiKey && provider === "openrouter"
|
|
1354
|
+
? await ctx.modelRegistry.getApiKeyForProvider("openrouter").catch(() => undefined)
|
|
1355
|
+
: undefined;
|
|
1356
|
+
const source = loaded.apiKey
|
|
1357
|
+
? `found via environment/.env (${loaded.envFilePath})`
|
|
1358
|
+
: piKey
|
|
1359
|
+
? "found via pi's OpenRouter login"
|
|
1360
|
+
: "missing";
|
|
1361
|
+
const text = [
|
|
1362
|
+
"# Jev key status",
|
|
1363
|
+
`- provider: ${loaded.config.provider}`,
|
|
1364
|
+
`- endpoint: ${loaded.config.baseUrl}`,
|
|
1365
|
+
`- model: ${loaded.config.model}`,
|
|
1366
|
+
`- key: ${source}`,
|
|
1367
|
+
`- env file: ${loaded.envFilePath}${existsSync(loaded.envFilePath) ? " (present)" : " (missing)"}`,
|
|
1368
|
+
loaded.apiKey || piKey ? "" : "- next: wiki_setup action=guide provider=typesafe|openrouter",
|
|
1369
|
+
]
|
|
1370
|
+
.filter(Boolean)
|
|
1371
|
+
.join("\n");
|
|
1372
|
+
return { content: [{ type: "text", text }], details: { provider: loaded.config.provider, keySource: source } };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (params.action === "guide") {
|
|
1376
|
+
const guide =
|
|
1377
|
+
provider === "openrouter"
|
|
1378
|
+
? [
|
|
1379
|
+
"## OpenRouter (Decisions API)",
|
|
1380
|
+
"1. Create a key at https://openrouter.ai/keys.",
|
|
1381
|
+
`2. Add it to the project .env (gitignored): OPENROUTER_API_KEY=...`,
|
|
1382
|
+
'3. Set { "provider": "openrouter", "model": "~typesafe/jev-latest" } in .pi/jev-wiki.json or ~/.pi/agent/jev-wiki.json. The endpoint preset is https://openrouter.ai/api/alpha/decisions.',
|
|
1383
|
+
"4. Alternative: sign in with /login openrouter; the plugin uses pi's credential when provider is openrouter.",
|
|
1384
|
+
"5. Run wiki_setup action=test.",
|
|
1385
|
+
"Notes: 32k advertised context; pin with model typesafe/jev-1.13 if needed.",
|
|
1386
|
+
].join("\n")
|
|
1387
|
+
: [
|
|
1388
|
+
"## TypeSafe (official API, recommended)",
|
|
1389
|
+
"1. Get a token from https://typesafe.ai (early access).",
|
|
1390
|
+
`2. Add it to the project .env (gitignored): TYPESAFE_API_KEY=... (JEV_TOKEN also works).`,
|
|
1391
|
+
'3. Or write { "provider": "typesafe", "apiKey": "$TYPESAFE_API_KEY" } to .pi/jev-wiki.json or ~/.pi/agent/jev-wiki.json.',
|
|
1392
|
+
"4. Run wiki_setup action=test.",
|
|
1393
|
+
"Notes: 64k context (32k state + longest question), $0.042/Mtok input, output free.",
|
|
1394
|
+
].join("\n");
|
|
1395
|
+
return { content: [{ type: "text", text: guide }], details: { provider } };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (params.action === "write-env") {
|
|
1399
|
+
if (!params.apiKey) throw new Error("wiki_setup write-env needs `apiKey`.");
|
|
1400
|
+
const varName = provider === "openrouter" ? "OPENROUTER_API_KEY" : provider === "aimlapi" ? "AIMLAPI_API_KEY" : "TYPESAFE_API_KEY";
|
|
1401
|
+
if (await isGitRepo(ctx.cwd)) {
|
|
1402
|
+
const ignored = await git(ctx.cwd, ["check-ignore", "-q", loaded.envFilePath]);
|
|
1403
|
+
if (ignored.code !== 0) {
|
|
1404
|
+
throw new Error(`${loaded.envFilePath} is not gitignored. Add it to .gitignore before writing a key.`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const existing = existsSync(loaded.envFilePath) ? await readFile(loaded.envFilePath, "utf8") : "";
|
|
1408
|
+
const pattern = new RegExp(`^\\s*${varName}\\s*=`);
|
|
1409
|
+
const lines = existing.split(/\r?\n/).filter((line) => line.trim() && !pattern.test(line));
|
|
1410
|
+
lines.push(`${varName}=${params.apiKey}`);
|
|
1411
|
+
await writeTextAtomic(loaded.envFilePath, `${lines.join("\n")}\n`);
|
|
1412
|
+
return {
|
|
1413
|
+
content: [{ type: "text", text: `Wrote ${varName} to ${loaded.envFilePath} (value not echoed). Run wiki_setup action=test to verify.` }],
|
|
1414
|
+
details: { varName, path: loaded.envFilePath },
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const started = Date.now();
|
|
1419
|
+
try {
|
|
1420
|
+
const client = await requireClient(loaded, ctx);
|
|
1421
|
+
const response = await client.systemOne(
|
|
1422
|
+
{ probe: "connectivity test" },
|
|
1423
|
+
{ ok: noul("This is a connectivity test. The correct answer is yes.") },
|
|
1424
|
+
{ signal: ctx.signal, timeoutMs: 20_000 },
|
|
1425
|
+
);
|
|
1426
|
+
return {
|
|
1427
|
+
content: [
|
|
1428
|
+
{
|
|
1429
|
+
type: "text",
|
|
1430
|
+
text: `Jev reachable in ${Date.now() - started}ms · model ${response.model} · tokens ${response.usage.input_tokens}/${response.usage.output_tokens}`,
|
|
1431
|
+
},
|
|
1432
|
+
],
|
|
1433
|
+
details: { model: response.model, usage: response.usage },
|
|
1434
|
+
};
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
return {
|
|
1437
|
+
content: [
|
|
1438
|
+
{
|
|
1439
|
+
type: "text",
|
|
1440
|
+
text: `Jev test failed: ${(error as Error).message}\n\nRun wiki_setup action=guide provider=${provider} for setup steps.`,
|
|
1441
|
+
},
|
|
1442
|
+
],
|
|
1443
|
+
details: { error: String((error as Error).message) },
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// Commands -----------------------------------------------------------------
|
|
1450
|
+
|
|
1451
|
+
pi.registerCommand("wiki:status", {
|
|
1452
|
+
description: "Show project wiki status",
|
|
1453
|
+
handler: async (_args, ctx) => {
|
|
1454
|
+
const runtime = runtimeFor(ctx);
|
|
1455
|
+
const pages = existsSync(runtime.layout.wikiDir) ? (await listMarkdownFiles(runtime.layout.wikiDir)).length : 0;
|
|
1456
|
+
const entries = await readIndex(runtime.layout);
|
|
1457
|
+
ctx.ui.notify(`jev-wiki: ${pages} pages · ${entries.length} TOC entries · root ${runtime.layout.root}`, "info");
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
pi.registerCommand("wiki:capture", {
|
|
1462
|
+
description: "Compose key insights from this session and capture them into the wiki",
|
|
1463
|
+
handler: async (_args, ctx) => {
|
|
1464
|
+
if (!ctx.hasUI) return;
|
|
1465
|
+
pi.sendUserMessage(
|
|
1466
|
+
"Capture this session's durable knowledge into the project wiki: compose a list of atomic key insights (decisions, invariants, architecture, gotchas, patterns) with evidence pointers to files/commits/tests, then call wiki_insights. Follow the llm-wiki skill to write or merge the recommended pages, then call wiki_finalize.",
|
|
1467
|
+
);
|
|
1468
|
+
},
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
pi.registerCommand("wiki:ingest", {
|
|
1472
|
+
description: "Ingest a document into the wiki (usage: /wiki:ingest <path>)",
|
|
1473
|
+
handler: async (args, ctx) => {
|
|
1474
|
+
if (!ctx.hasUI) return;
|
|
1475
|
+
const path = args.trim();
|
|
1476
|
+
if (!path) {
|
|
1477
|
+
ctx.ui.notify("Usage: /wiki:ingest <path>", "warning");
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
pi.sendUserMessage(
|
|
1481
|
+
`Ingest \`${path}\` into the project wiki: call wiki_ingest with that path, then follow the llm-wiki skill to write or merge the recommended pages, then call wiki_finalize.`,
|
|
1482
|
+
);
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
pi.registerCommand("wiki:sync", {
|
|
1487
|
+
description: "Re-verify the wiki against commits since the last sync",
|
|
1488
|
+
handler: async (_args, ctx) => {
|
|
1489
|
+
if (!ctx.hasUI) return;
|
|
1490
|
+
pi.sendUserMessage(
|
|
1491
|
+
"Sync the project wiki with the code: call wiki_sync, then use wiki_review to resolve any affected claims (read the referenced pages first).",
|
|
1492
|
+
);
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
pi.registerCommand("wiki:review", {
|
|
1497
|
+
description: "Work the wiki review queue (disputes, needs-recheck, low-confidence claims)",
|
|
1498
|
+
handler: async (_args, ctx) => {
|
|
1499
|
+
if (!ctx.hasUI) return;
|
|
1500
|
+
pi.sendUserMessage(
|
|
1501
|
+
"Review the project wiki: call wiki_review with action=list, read the referenced pages for evidence, then resolve each item with wiki_review action=resolve. Defer anything you cannot decide; critical items will be escalated to the user.",
|
|
1502
|
+
);
|
|
1503
|
+
},
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
pi.registerCommand("wiki:lint", {
|
|
1507
|
+
description: "Health-check the wiki and auto-fix safe issues",
|
|
1508
|
+
handler: async (_args, ctx) => {
|
|
1509
|
+
if (!ctx.hasUI) return;
|
|
1510
|
+
pi.sendUserMessage(
|
|
1511
|
+
"Lint the project wiki: call wiki_lint, report the findings, then work any queued judgment items with wiki_review (read the referenced pages first).",
|
|
1512
|
+
);
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1517
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1518
|
+
if (!loaded.apiKey) {
|
|
1519
|
+
ctx.ui.notify(`jev-wiki: no Jev token found (expected JEV_TOKEN in ${loaded.envFilePath})`, "warning");
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
if (loaded.config.sync.onSessionStart !== "check") return;
|
|
1523
|
+
const layout = resolveLayout(ctx.cwd, loaded.config.wikiRoot, loaded.config.stateRoot);
|
|
1524
|
+
if (!existsSync(layout.stateDir) || !(await isGitRepo(ctx.cwd))) return;
|
|
1525
|
+
const state = await readSyncState(layout);
|
|
1526
|
+
const head = await headCommit(ctx.cwd);
|
|
1527
|
+
if (state.lastSyncCommit && head && state.lastSyncCommit !== head) {
|
|
1528
|
+
ctx.ui.notify("jev-wiki: the wiki may be out of date with code changes — run /wiki:sync", "warning");
|
|
1529
|
+
}
|
|
1530
|
+
if (existsSync(join(layout.stateDir, "pending-capture.md"))) {
|
|
1531
|
+
ctx.ui.notify("jev-wiki: accepted insights are waiting to be written — run /wiki:review or ask to file pending captures", "info");
|
|
1532
|
+
}
|
|
1533
|
+
} catch {
|
|
1534
|
+
/* sync check is best-effort */
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// --- automatic capture ------------------------------------------------------
|
|
1539
|
+
|
|
1540
|
+
let autoCaptureInFlight = false;
|
|
1541
|
+
let lastAutoCaptureAt = 0;
|
|
1542
|
+
let lastAutoCaptureMessageCount = 0;
|
|
1543
|
+
|
|
1544
|
+
async function autoCapture(
|
|
1545
|
+
ctx: ExtensionContext,
|
|
1546
|
+
source: "compact" | "settled",
|
|
1547
|
+
entries?: unknown[],
|
|
1548
|
+
): Promise<{ accepted: number; brief: string } | undefined> {
|
|
1549
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1550
|
+
if (!loaded.apiKey) return undefined;
|
|
1551
|
+
const branch = entries ?? ctx.sessionManager.getBranch();
|
|
1552
|
+
const messageCount = branch.filter((entry) => {
|
|
1553
|
+
const candidate = entry as { type?: string; message?: { role?: string } };
|
|
1554
|
+
return candidate?.type === "message" && (candidate.message?.role === "user" || candidate.message?.role === "assistant");
|
|
1555
|
+
}).length;
|
|
1556
|
+
if (messageCount < 3 || messageCount === lastAutoCaptureMessageCount) return undefined;
|
|
1557
|
+
if (Date.now() - lastAutoCaptureAt < 10 * 60_000) return undefined;
|
|
1558
|
+
if (autoCaptureInFlight) return undefined;
|
|
1559
|
+
autoCaptureInFlight = true;
|
|
1560
|
+
try {
|
|
1561
|
+
const transcript = sessionTextFromEntries(branch);
|
|
1562
|
+
const runtime: Runtime = {
|
|
1563
|
+
loaded,
|
|
1564
|
+
layout: resolveLayout(ctx.cwd, loaded.config.wikiRoot, loaded.config.stateRoot),
|
|
1565
|
+
};
|
|
1566
|
+
await ensureLayout(runtime.layout);
|
|
1567
|
+
const client = await requireClient(loaded, ctx);
|
|
1568
|
+
|
|
1569
|
+
// Cheap Jev pre-screen: only pay for extraction when the session likely holds durable knowledge.
|
|
1570
|
+
const screen = await client.systemOne(
|
|
1571
|
+
{ session_excerpt: transcript.slice(-6000) },
|
|
1572
|
+
{
|
|
1573
|
+
worth_capturing: noul(
|
|
1574
|
+
"This session contains a durable decision, invariant, architecture insight, or user-stated policy worth capturing in the project wiki.",
|
|
1575
|
+
{ true: "Contains durable, non-derivable knowledge", false: "Only transient work, implementation detail, or nothing durable" },
|
|
1576
|
+
),
|
|
1577
|
+
},
|
|
1578
|
+
{ signal: ctx.signal },
|
|
1579
|
+
);
|
|
1580
|
+
const worth = screen.answers.worth_capturing?.type === "noul" ? screen.answers.worth_capturing.noul : 0;
|
|
1581
|
+
await appendLedger(runtime.layout, {
|
|
1582
|
+
actor: "jev",
|
|
1583
|
+
op: "capture.screen",
|
|
1584
|
+
subject: source,
|
|
1585
|
+
verdict: { worth_capturing: worth },
|
|
1586
|
+
action: worth >= 0.6 ? "extract" : "skip",
|
|
1587
|
+
usage: { input_tokens: screen.usage.input_tokens, output_tokens: screen.usage.output_tokens },
|
|
1588
|
+
});
|
|
1589
|
+
if (worth < 0.6) {
|
|
1590
|
+
lastAutoCaptureAt = Date.now();
|
|
1591
|
+
lastAutoCaptureMessageCount = messageCount;
|
|
1592
|
+
return { accepted: 0, brief: `Pre-screen skipped extraction (worth capturing ${worth.toFixed(2)}).` };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const insights = await extractInsights(ctx, transcript);
|
|
1596
|
+
if (insights.length === 0) {
|
|
1597
|
+
lastAutoCaptureAt = Date.now();
|
|
1598
|
+
lastAutoCaptureMessageCount = messageCount;
|
|
1599
|
+
return { accepted: 0, brief: "No durable insights found." };
|
|
1600
|
+
}
|
|
1601
|
+
const result = await processInsights(runtime, ctx, client, insights, { source, mode: loaded.config.writer.mode });
|
|
1602
|
+
lastAutoCaptureAt = Date.now();
|
|
1603
|
+
lastAutoCaptureMessageCount = messageCount;
|
|
1604
|
+
return { accepted: result.accepted, brief: result.brief };
|
|
1605
|
+
} catch {
|
|
1606
|
+
return undefined;
|
|
1607
|
+
} finally {
|
|
1608
|
+
autoCaptureInFlight = false;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
1613
|
+
try {
|
|
1614
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1615
|
+
if (!loaded.config.capture.onSettle) return;
|
|
1616
|
+
const result = await autoCapture(ctx, "settled");
|
|
1617
|
+
if (!result || result.accepted === 0) return;
|
|
1618
|
+
const layout = resolveLayout(ctx.cwd, loaded.config.wikiRoot, loaded.config.stateRoot);
|
|
1619
|
+
await writeTextAtomic(join(layout.stateDir, "pending-capture.md"), `# Pending capture\n\n${result.brief}\n\nWrite or merge the accepted pages following the llm-wiki skill, then call wiki_finalize.\n`);
|
|
1620
|
+
if (ctx.hasUI) {
|
|
1621
|
+
pi.sendMessage(
|
|
1622
|
+
{
|
|
1623
|
+
customType: "jev-wiki",
|
|
1624
|
+
content: `${result.brief}\n\nWrite or merge the accepted pages following the llm-wiki skill, then call wiki_finalize.`,
|
|
1625
|
+
display: true,
|
|
1626
|
+
},
|
|
1627
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
} catch {
|
|
1631
|
+
/* auto-capture is best-effort and must never break the session */
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
1636
|
+
try {
|
|
1637
|
+
const loaded = loadConfig(ctx.cwd);
|
|
1638
|
+
if (!loaded.config.capture.onCompact) return;
|
|
1639
|
+
const preparation = (event as { preparation?: { messagesToSummarize?: unknown[] } }).preparation;
|
|
1640
|
+
const result = await autoCapture(ctx, "compact", preparation?.messagesToSummarize);
|
|
1641
|
+
if (!result || result.accepted === 0) return;
|
|
1642
|
+
const layout = resolveLayout(ctx.cwd, loaded.config.wikiRoot, loaded.config.stateRoot);
|
|
1643
|
+
await writeTextAtomic(join(layout.stateDir, "pending-capture.md"), `# Pending capture (pre-compaction)\n\n${result.brief}\n\nWrite or merge the accepted pages following the llm-wiki skill, then call wiki_finalize.\n`);
|
|
1644
|
+
if (ctx.hasUI) {
|
|
1645
|
+
pi.sendMessage(
|
|
1646
|
+
{
|
|
1647
|
+
customType: "jev-wiki",
|
|
1648
|
+
content: `Captured before compaction:\n\n${result.brief}\n\nWrite or merge the accepted pages following the llm-wiki skill, then call wiki_finalize.`,
|
|
1649
|
+
display: true,
|
|
1650
|
+
},
|
|
1651
|
+
{ deliverAs: "nextTurn" },
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
} catch {
|
|
1655
|
+
/* auto-capture is best-effort and must never break the session */
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ---------------------------------------------------------------------------
|
|
1661
|
+
// Helpers
|
|
1662
|
+
// ---------------------------------------------------------------------------
|
|
1663
|
+
|
|
1664
|
+
async function resolvePagePath(layout: WikiLayout, cwd: string, page: string): Promise<string | undefined> {
|
|
1665
|
+
const candidates = [
|
|
1666
|
+
isAbsolute(page) ? page : resolve(cwd, page),
|
|
1667
|
+
resolve(layout.wikiDir, page),
|
|
1668
|
+
resolve(layout.root, page),
|
|
1669
|
+
];
|
|
1670
|
+
for (const candidate of candidates) {
|
|
1671
|
+
if (existsSync(candidate)) return candidate;
|
|
1672
|
+
}
|
|
1673
|
+
return undefined;
|
|
1674
|
+
}
|