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/src/lint.ts ADDED
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Wiki lint: deterministic health checks (TOC, links, orphans, raw backlog,
3
+ * claims with no accepted ledger entry) plus Jev contradiction checks on
4
+ * code-selected candidate claim pairs.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { readFile } from "node:fs/promises";
8
+ import { dirname, join, relative, resolve } from "node:path";
9
+ import type { ResolvedConfig } from "./config.ts";
10
+ import { choice, isChoice, noul, type JevClient } from "./jev.ts";
11
+ import { appendLedger, readLedger } from "./ledger.ts";
12
+ import { enqueueReview } from "./review.ts";
13
+ import { extractMarkdownLinks, isExternalLink, linkTarget } from "./wiki/links.ts";
14
+ import { listMarkdownFiles, readPage, todayISO, writePage, type WikiLayout } from "./wiki/layout.ts";
15
+ import { appendLog, entryFromPage, isWikiMetaFile, readIndex, updateIndex, upsertEntries, writeIndex, type TocEntry } from "./wiki/toc.ts";
16
+
17
+ export interface UnbackedClaim {
18
+ page: string;
19
+ claimId?: string;
20
+ text: string;
21
+ }
22
+
23
+ export interface Contradiction {
24
+ pageA: string;
25
+ textA: string;
26
+ pageB: string;
27
+ textB: string;
28
+ relation: string;
29
+ confidence: number;
30
+ }
31
+
32
+ export interface DuplicateCandidate {
33
+ pageA: string;
34
+ textA: string;
35
+ pageB: string;
36
+ textB: string;
37
+ similarity: number;
38
+ same: number;
39
+ }
40
+
41
+ export interface LintReport {
42
+ pages: number;
43
+ toc: { added: string[]; missingFiles: string[]; updatedFixed: string[] };
44
+ brokenLinks: string[];
45
+ orphans: string[];
46
+ rawBacklog: string[];
47
+ unbackedClaims: UnbackedClaim[];
48
+ contradictions: Contradiction[];
49
+ duplicates: DuplicateCandidate[];
50
+ fixed: string[];
51
+ usage: { input_tokens: number; output_tokens: number };
52
+ }
53
+
54
+ export interface LintOptions {
55
+ autoFix?: boolean;
56
+ checkContradictions?: boolean;
57
+ maxContradictionPairs?: number;
58
+ signal?: AbortSignal;
59
+ }
60
+
61
+ interface PageRecord {
62
+ rel: string;
63
+ abs: string;
64
+ data: Record<string, unknown>;
65
+ body: string;
66
+ text: string;
67
+ }
68
+
69
+ function normalize(text: string): string {
70
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
71
+ }
72
+
73
+ function tokenSet(text: string): Set<string> {
74
+ return new Set(
75
+ text
76
+ .toLowerCase()
77
+ .split(/[^a-z0-9_]+/)
78
+ .filter((token) => token.length > 3),
79
+ );
80
+ }
81
+
82
+ function isActiveClaim(claim: Record<string, unknown>): boolean {
83
+ const status = String(claim.status ?? "verified");
84
+ return status !== "superseded" && status !== "rejected";
85
+ }
86
+
87
+ export async function lintWiki(
88
+ layout: WikiLayout,
89
+ client: JevClient,
90
+ config: ResolvedConfig,
91
+ options?: LintOptions,
92
+ ): Promise<LintReport> {
93
+ const autoFix = options?.autoFix ?? true;
94
+ const usage = { input_tokens: 0, output_tokens: 0 };
95
+ const report: LintReport = {
96
+ pages: 0,
97
+ toc: { added: [], missingFiles: [], updatedFixed: [] },
98
+ brokenLinks: [],
99
+ orphans: [],
100
+ rawBacklog: [],
101
+ unbackedClaims: [],
102
+ contradictions: [],
103
+ duplicates: [],
104
+ fixed: [],
105
+ usage,
106
+ };
107
+
108
+ const files = await listMarkdownFiles(layout.wikiDir);
109
+ const pages: PageRecord[] = [];
110
+ for (const abs of files) {
111
+ const rel = relative(layout.wikiDir, abs).split("\\").join("/");
112
+ if (isWikiMetaFile(rel)) continue;
113
+ try {
114
+ const page = await readPage(abs);
115
+ const text = await readFile(abs, "utf8");
116
+ pages.push({ rel, abs, data: page.data, body: page.body, text });
117
+ } catch {
118
+ /* skip unreadable */
119
+ }
120
+ }
121
+ report.pages = pages.length;
122
+
123
+ // --- TOC reconciliation ---------------------------------------------------
124
+ const entries = await readIndex(layout);
125
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]));
126
+ const updates: TocEntry[] = [];
127
+ for (const page of pages) {
128
+ const entry = byPath.get(page.rel);
129
+ const fresh = entryFromPage(page.rel, page.data);
130
+ if (!entry) {
131
+ report.toc.added.push(page.rel);
132
+ updates.push(fresh);
133
+ } else if (entry.updated !== fresh.updated || entry.summary !== fresh.summary) {
134
+ report.toc.updatedFixed.push(page.rel);
135
+ updates.push(fresh);
136
+ }
137
+ }
138
+ for (const entry of entries) {
139
+ if (!pages.some((page) => page.rel === entry.path)) report.toc.missingFiles.push(entry.path);
140
+ }
141
+ if (autoFix && updates.length > 0) {
142
+ await updateIndex(layout, (current) => upsertEntries(current, updates));
143
+ report.fixed.push(`toc: ${updates.length} entries`);
144
+ }
145
+
146
+ // --- Links and orphans ----------------------------------------------------
147
+ const inbound = new Map<string, number>();
148
+ for (const page of pages) {
149
+ for (const link of extractMarkdownLinks(page.text)) {
150
+ if (isExternalLink(link)) continue;
151
+ const target = resolve(dirname(page.abs), linkTarget(link));
152
+ if (!existsSync(target)) {
153
+ report.brokenLinks.push(`${page.rel} → ${link}`);
154
+ continue;
155
+ }
156
+ const targetRel = relative(layout.wikiDir, target).split("\\").join("/");
157
+ inbound.set(targetRel, (inbound.get(targetRel) ?? 0) + 1);
158
+ }
159
+ }
160
+ for (const page of pages) {
161
+ if ((inbound.get(page.rel) ?? 0) === 0) {
162
+ const updated = String(page.data.updated ?? "");
163
+ const cutoff = new Date(Date.now() - config.lint.orphanMinAgeDays * 86_400_000).toISOString().slice(0, 10);
164
+ if (updated && updated >= cutoff) continue; // young pages are expected to be unlinked
165
+ report.orphans.push(page.rel);
166
+ }
167
+ }
168
+
169
+ // --- Raw backlog ----------------------------------------------------------
170
+ const rawFiles = existsSync(layout.rawDir) ? await listMarkdownFiles(layout.rawDir) : [];
171
+ for (const raw of rawFiles) {
172
+ const rel = relative(layout.root, raw).split("\\").join("/");
173
+ if (rel.startsWith("raw/sessions/")) continue; // session journals are records, not pending sources
174
+ const name = raw.split(/[\\/]/).pop() ?? raw;
175
+ if (!pages.some((page) => page.text.includes(name))) report.rawBacklog.push(rel);
176
+ }
177
+
178
+ // --- Unbacked claims (no accepted ledger entry) ----------------------------
179
+ const ledger = await readLedger(layout);
180
+ const accepted = ledger
181
+ .filter(
182
+ (entry) =>
183
+ entry.actor === "code" &&
184
+ ["file", "reinforce", "file_user_stated"].includes(String(entry.action)) &&
185
+ entry.subject,
186
+ )
187
+ .map((entry) => ({ subject: normalize(String(entry.subject)), tokens: tokenSet(String(entry.subject)) }));
188
+ const referenced = new Set<string>();
189
+ for (const entry of ledger) {
190
+ for (const value of [entry.subject, entry.outcome, entry.reason]) {
191
+ if (typeof value === "string") referenced.add(normalize(value));
192
+ }
193
+ }
194
+ for (const page of pages) {
195
+ const claims = Array.isArray(page.data.claims) ? (page.data.claims as Record<string, unknown>[]) : [];
196
+ for (const claim of claims) {
197
+ if (typeof claim.text !== "string" || !isActiveClaim(claim)) continue;
198
+ const needle = normalize(claim.text);
199
+ const claimTokens = tokenSet(claim.text);
200
+ const backed = accepted.some(({ subject, tokens }) => {
201
+ const head = needle.slice(0, 100);
202
+ if (subject === head || subject.startsWith(head) || head.startsWith(subject)) return true;
203
+ if (tokens.size === 0 || claimTokens.size === 0) return false;
204
+ let shared = 0;
205
+ for (const token of tokens) if (claimTokens.has(token)) shared++;
206
+ return shared / Math.min(tokens.size, claimTokens.size) >= 0.5;
207
+ });
208
+ const reviewed = [...referenced].some((value) => {
209
+ if (!value.includes(normalize(page.rel))) return false;
210
+ return !claim.id || value.includes(String(claim.id)) || value.includes(needle.slice(0, 60));
211
+ });
212
+ if (!backed && !reviewed) {
213
+ report.unbackedClaims.push({
214
+ page: page.rel,
215
+ claimId: typeof claim.id === "string" ? claim.id : undefined,
216
+ text: claim.text,
217
+ });
218
+ if (autoFix) {
219
+ await enqueueReview(layout, {
220
+ kind: "claim_review",
221
+ claimText: claim.text,
222
+ page: page.rel,
223
+ claimId: typeof claim.id === "string" ? claim.id : undefined,
224
+ criticality: 0.45,
225
+ reason: "lint: no accepted ledger entry backs this claim",
226
+ });
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ // --- Contradiction checks --------------------------------------------------
233
+ if (options?.checkContradictions !== false) {
234
+ const claims: Array<{ page: string; pageRef: PageRecord; claim: Record<string, unknown>; files: string[] }> = [];
235
+ for (const page of pages) {
236
+ const pageFiles = Array.isArray(page.data.files) ? page.data.files.map(String) : [];
237
+ const rawClaims = Array.isArray(page.data.claims) ? (page.data.claims as Record<string, unknown>[]) : [];
238
+ for (const claim of rawClaims) {
239
+ if (typeof claim.text !== "string" || !isActiveClaim(claim)) continue;
240
+ const claimFiles = Array.isArray(claim.files) ? claim.files.map(String) : pageFiles;
241
+ claims.push({ page: page.rel, pageRef: page, claim, files: claimFiles });
242
+ }
243
+ }
244
+
245
+ const pairs: Array<{ a: (typeof claims)[number]; b: (typeof claims)[number] }> = [];
246
+ for (let i = 0; i < claims.length; i++) {
247
+ for (let j = i + 1; j < claims.length; j++) {
248
+ if (claims[i].page === claims[j].page) continue;
249
+ const shared = claims[i].files.some((file) => claims[j].files.includes(file));
250
+ if (shared) pairs.push({ a: claims[i], b: claims[j] });
251
+ }
252
+ }
253
+
254
+ const limit = options?.maxContradictionPairs ?? 8;
255
+ const markedPages = new Set<string>();
256
+ for (const pair of pairs.slice(0, limit)) {
257
+ const response = await client.systemOne(
258
+ {
259
+ claim_a: { page: pair.a.page, text: pair.a.claim.text },
260
+ claim_b: { page: pair.b.page, text: pair.b.claim.text },
261
+ },
262
+ {
263
+ relation: choice("How do these two wiki claims relate?", {
264
+ consistent: "They agree and can both be true",
265
+ contradicts: "They cannot both be true",
266
+ supersedes_a: "Claim A replaces claim B with newer or better information",
267
+ supersedes_b: "Claim B replaces claim A with newer or better information",
268
+ independent: "They are about different things",
269
+ }),
270
+ },
271
+ { signal: options?.signal },
272
+ );
273
+ usage.input_tokens += response.usage.input_tokens;
274
+ usage.output_tokens += response.usage.output_tokens;
275
+ const answer = response.answers.relation;
276
+ const relation = isChoice(answer) ? answer.choice : "independent";
277
+ const confidence = isChoice(answer) ? answer.confidence : 0;
278
+ const contradicts = relation === "contradicts";
279
+ report.contradictions.push({
280
+ pageA: pair.a.page,
281
+ textA: String(pair.a.claim.text),
282
+ pageB: pair.b.page,
283
+ textB: String(pair.b.claim.text),
284
+ relation,
285
+ confidence,
286
+ });
287
+ await appendLedger(layout, {
288
+ actor: "jev",
289
+ op: "lint.contradiction",
290
+ subject: `${pair.a.page} vs ${pair.b.page}`,
291
+ verdict: { relation, confidence },
292
+ action: contradicts && confidence >= config.thresholds.autoAccept ? "dispute" : "report",
293
+ usage: { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens },
294
+ });
295
+ if (contradicts && confidence >= config.thresholds.autoAccept && autoFix) {
296
+ for (const side of [pair.a, pair.b]) {
297
+ const claimsList = Array.isArray(side.pageRef.data.claims)
298
+ ? (side.pageRef.data.claims as Record<string, unknown>[])
299
+ : [];
300
+ const index = claimsList.findIndex((claim) => claim.text === side.claim.text);
301
+ if (index >= 0) {
302
+ claimsList[index].status = "disputed";
303
+ side.pageRef.data.claims = claimsList;
304
+ side.pageRef.data.updated = todayISO();
305
+ markedPages.add(side.pageRef.abs);
306
+ }
307
+ await enqueueReview(layout, {
308
+ kind: "dispute",
309
+ claimText: String(side.claim.text),
310
+ page: side.page,
311
+ claimId: typeof side.claim.id === "string" ? side.claim.id : undefined,
312
+ criticality: Math.max(0.7, confidence),
313
+ reason: `lint: contradicts ${side === pair.a ? pair.b.page : pair.a.page}`,
314
+ verdicts: { relation, confidence },
315
+ });
316
+ }
317
+ }
318
+ }
319
+ for (const abs of markedPages) {
320
+ const page = await readPage(abs);
321
+ await writePage(abs, page.data, page.body);
322
+ }
323
+ if (markedPages.size > 0) report.fixed.push(`disputes: ${markedPages.size} page(s)`);
324
+
325
+ // --- Duplicate consolidation candidates ---------------------------------
326
+ const duplicatePairs: Array<{ a: (typeof claims)[number]; b: (typeof claims)[number]; similarity: number }> = [];
327
+ for (let i = 0; i < claims.length; i++) {
328
+ for (let j = i + 1; j < claims.length; j++) {
329
+ if (claims[i].page === claims[j].page) continue;
330
+ const left = tokenSet(String(claims[i].claim.text));
331
+ const right = tokenSet(String(claims[j].claim.text));
332
+ if (left.size === 0 || right.size === 0) continue;
333
+ let shared = 0;
334
+ for (const token of left) if (right.has(token)) shared++;
335
+ const similarity = shared / Math.min(left.size, right.size);
336
+ if (similarity >= config.lint.duplicateSimilarity) duplicatePairs.push({ a: claims[i], b: claims[j], similarity });
337
+ }
338
+ }
339
+ for (const pair of duplicatePairs.slice(0, 6)) {
340
+ const response = await client.systemOne(
341
+ {
342
+ claim_a: { page: pair.a.page, text: pair.a.claim.text },
343
+ claim_b: { page: pair.b.page, text: pair.b.claim.text },
344
+ },
345
+ {
346
+ merge: noul("These two wiki claims state the same knowledge and should be consolidated into one.", {
347
+ true: "Same knowledge; consolidating loses nothing",
348
+ false: "Different enough that both should stay",
349
+ }),
350
+ },
351
+ { signal: options?.signal },
352
+ );
353
+ usage.input_tokens += response.usage.input_tokens;
354
+ usage.output_tokens += response.usage.output_tokens;
355
+ const same = response.answers.merge && response.answers.merge.type === "noul" ? response.answers.merge.noul : 0;
356
+ report.duplicates.push({
357
+ pageA: pair.a.page,
358
+ textA: String(pair.a.claim.text),
359
+ pageB: pair.b.page,
360
+ textB: String(pair.b.claim.text),
361
+ similarity: Number(pair.similarity.toFixed(2)),
362
+ same,
363
+ });
364
+ await appendLedger(layout, {
365
+ actor: "jev",
366
+ op: "lint.duplicate",
367
+ subject: `${pair.a.page} vs ${pair.b.page}`,
368
+ verdict: { similarity: pair.similarity, same },
369
+ action: same >= 0.8 ? "consolidate" : "keep",
370
+ usage: { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens },
371
+ });
372
+ if (same >= 0.8 && autoFix) {
373
+ await enqueueReview(layout, {
374
+ kind: "claim_review",
375
+ claimText: String(pair.a.claim.text),
376
+ page: pair.a.page,
377
+ claimId: typeof pair.a.claim.id === "string" ? pair.a.claim.id : undefined,
378
+ criticality: 0.5,
379
+ reason: `possible duplicate of ${pair.b.page} (${same.toFixed(2)}): ${String(pair.b.claim.text).slice(0, 80)}`,
380
+ verdicts: { similarity: pair.similarity, same },
381
+ });
382
+ }
383
+ }
384
+ }
385
+
386
+ await appendLog(layout, "lint", `${report.toc.added.length} added, ${report.brokenLinks.length} broken links, ${report.unbackedClaims.length} unbacked claims`, [
387
+ `Pages: ${report.pages}`,
388
+ `TOC updated: ${report.toc.updatedFixed.length} · missing files: ${report.toc.missingFiles.length}`,
389
+ `Orphans: ${report.orphans.length} · raw backlog: ${report.rawBacklog.length}`,
390
+ `Contradiction checks: ${report.contradictions.length} · duplicate candidates: ${report.duplicates.length}`,
391
+ ]);
392
+ await appendLedger(layout, {
393
+ actor: "code",
394
+ op: "wiki.lint",
395
+ action: "report",
396
+ verdict: {
397
+ tocAdded: report.toc.added.length,
398
+ brokenLinks: report.brokenLinks.length,
399
+ orphans: report.orphans.length,
400
+ rawBacklog: report.rawBacklog.length,
401
+ unbackedClaims: report.unbackedClaims.length,
402
+ contradictions: report.contradictions.filter((entry) => entry.relation === "contradicts").length,
403
+ duplicates: report.duplicates.filter((entry) => entry.same >= 0.8).length,
404
+ },
405
+ });
406
+ return report;
407
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,61 @@
1
+ /** Consultation metrics: how often the wiki is actually used, and for what. */
2
+ import { existsSync } from "node:fs";
3
+ import { appendFile, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import type { WikiLayout } from "./wiki/layout.ts";
6
+
7
+ export interface MetricEntry {
8
+ ts: string;
9
+ op: "toc" | "ask" | "sync" | "review" | "lint";
10
+ query?: string;
11
+ pages?: string[];
12
+ detail?: unknown;
13
+ }
14
+
15
+ export function metricsPath(layout: WikiLayout): string {
16
+ return join(layout.stateDir, "metrics.jsonl");
17
+ }
18
+
19
+ export async function recordMetric(layout: WikiLayout, entry: Omit<MetricEntry, "ts">): Promise<void> {
20
+ try {
21
+ await appendFile(metricsPath(layout), `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`, "utf8");
22
+ } catch {
23
+ /* metrics are best-effort */
24
+ }
25
+ }
26
+
27
+ export async function readMetrics(layout: WikiLayout): Promise<MetricEntry[]> {
28
+ if (!existsSync(metricsPath(layout))) return [];
29
+ const text = await readFile(metricsPath(layout), "utf8");
30
+ return text
31
+ .split(/\r?\n/)
32
+ .filter(Boolean)
33
+ .map((line) => {
34
+ try {
35
+ return JSON.parse(line) as MetricEntry;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ })
40
+ .filter((entry): entry is MetricEntry => Boolean(entry));
41
+ }
42
+
43
+ export function summarizeMetrics(entries: MetricEntry[]): {
44
+ consultations: number;
45
+ searches: number;
46
+ pagesReturned: Set<string>;
47
+ recentQueries: string[];
48
+ } {
49
+ const pagesReturned = new Set<string>();
50
+ const queries: string[] = [];
51
+ for (const entry of entries) {
52
+ for (const page of entry.pages ?? []) pagesReturned.add(page);
53
+ if (entry.op === "ask" && entry.query) queries.push(entry.query);
54
+ }
55
+ return {
56
+ consultations: entries.filter((entry) => entry.op === "toc" || entry.op === "ask").length,
57
+ searches: entries.filter((entry) => entry.op === "ask").length,
58
+ pagesReturned,
59
+ recentQueries: queries.slice(-5),
60
+ };
61
+ }