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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev adjudication: groundedness, derivability, durability, placement, value.
|
|
3
|
+
*
|
|
4
|
+
* Placement uses a sharded tournament so the wiki can grow past Jev's 255-option
|
|
5
|
+
* choice ceiling: candidates are sharded, each shard nominates a winner (with an
|
|
6
|
+
* `any_fit` gate and an `add_new_page` option), and winners meet in a final call.
|
|
7
|
+
* Probabilities are never compared across shards — only inside a single call.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
choice,
|
|
11
|
+
isChoice,
|
|
12
|
+
isNoul,
|
|
13
|
+
isScore,
|
|
14
|
+
mapLimit,
|
|
15
|
+
noul,
|
|
16
|
+
score,
|
|
17
|
+
JevClient,
|
|
18
|
+
type JevQuestion,
|
|
19
|
+
type JevResponse,
|
|
20
|
+
} from "../jev.ts";
|
|
21
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
22
|
+
|
|
23
|
+
export interface CandidatePage {
|
|
24
|
+
path: string;
|
|
25
|
+
title: string;
|
|
26
|
+
type: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
tags: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CandidateClaim {
|
|
32
|
+
id?: string;
|
|
33
|
+
text: string;
|
|
34
|
+
page?: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClaimInput {
|
|
39
|
+
text: string;
|
|
40
|
+
kind: string;
|
|
41
|
+
quote?: string;
|
|
42
|
+
files?: string[];
|
|
43
|
+
evidenceText?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ADD_NEW_PAGE = "add_new_page";
|
|
47
|
+
export const NONE_OF_THESE = "none_of_these";
|
|
48
|
+
|
|
49
|
+
export interface ClaimVerdicts {
|
|
50
|
+
grounded: number;
|
|
51
|
+
derivable: number;
|
|
52
|
+
durable: number;
|
|
53
|
+
sensitive: number;
|
|
54
|
+
kind: string;
|
|
55
|
+
kindConfidence: number;
|
|
56
|
+
importance: number;
|
|
57
|
+
importanceNorm: number;
|
|
58
|
+
importanceConfidence: number;
|
|
59
|
+
criticality: number;
|
|
60
|
+
criticalityNorm: number;
|
|
61
|
+
criticalityConfidence: number;
|
|
62
|
+
alreadyKnown: number;
|
|
63
|
+
trustTier?: string;
|
|
64
|
+
trustConfidence?: number;
|
|
65
|
+
relation?: string;
|
|
66
|
+
relationConfidence?: number;
|
|
67
|
+
relationAgainst?: string;
|
|
68
|
+
pageType?: string;
|
|
69
|
+
pageTypeConfidence?: number;
|
|
70
|
+
topic?: string;
|
|
71
|
+
topicConfidence?: number;
|
|
72
|
+
target?: string;
|
|
73
|
+
targetConfidence?: number;
|
|
74
|
+
anyFit?: number;
|
|
75
|
+
newPage: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AdjudicationResult {
|
|
79
|
+
verdicts: ClaimVerdicts;
|
|
80
|
+
requestCount: number;
|
|
81
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
82
|
+
raw: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const QUESTION = {
|
|
86
|
+
grounded: noul("The reference passage states or directly implies the claim."),
|
|
87
|
+
derivable: noul("A developer could re-derive this claim from the repository code in under one minute.", {
|
|
88
|
+
true: "It is an implementation detail visible by reading the code",
|
|
89
|
+
false: "It needs synthesis, rationale, history, or cross-file knowledge",
|
|
90
|
+
}),
|
|
91
|
+
durable: noul("This claim will still be true and useful in a month.", {
|
|
92
|
+
true: "Durable knowledge about the system",
|
|
93
|
+
false: "Temporary task state or session-specific detail",
|
|
94
|
+
}),
|
|
95
|
+
sensitive: noul("This claim contains credentials, personal data, or other secrets that must not be stored."),
|
|
96
|
+
kind: choice("What kind of knowledge is this claim?", {
|
|
97
|
+
architecture: "Structure, ownership, boundaries, or data flow",
|
|
98
|
+
invariant: "A rule that must always hold",
|
|
99
|
+
decision: "A choice made with rationale",
|
|
100
|
+
gotcha: "A footgun or non-obvious failure mode",
|
|
101
|
+
procedure: "How to perform a task",
|
|
102
|
+
pattern: "A recurring approach",
|
|
103
|
+
fact: "A plain fact about the project",
|
|
104
|
+
}),
|
|
105
|
+
importance: score("How durable and load-bearing is this knowledge?", ["ephemeral", "contextual", "durable", "canonical"]),
|
|
106
|
+
criticality: score("How costly would acting on this claim incorrectly be?", ["low", "moderate", "high", "critical"]),
|
|
107
|
+
verifiable: choice("What is the strongest basis for this claim?", {
|
|
108
|
+
verified_in_repo: "Confirmed by code, tests, commits, or configuration in the repository",
|
|
109
|
+
source_document: "Supported by a quoted passage from a source document",
|
|
110
|
+
user_stated: "Stated by the user, but not independently verified",
|
|
111
|
+
inference: "Inferred by the agent from partial evidence",
|
|
112
|
+
speculation: "No clear evidence either way",
|
|
113
|
+
}),
|
|
114
|
+
} satisfies Record<string, JevQuestion>;
|
|
115
|
+
|
|
116
|
+
function noulValue(response: JevResponse, key: string, fallback = 0): number {
|
|
117
|
+
const answer = response.answers[key];
|
|
118
|
+
return isNoul(answer) ? answer.noul : fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function choiceValue(response: JevResponse, key: string): { value: string; confidence: number } | undefined {
|
|
122
|
+
const answer = response.answers[key];
|
|
123
|
+
if (!isChoice(answer)) return undefined;
|
|
124
|
+
return { value: answer.choice, confidence: answer.confidence };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scoreValue(response: JevResponse, key: string): { value: number; norm: number; confidence: number } | undefined {
|
|
128
|
+
const answer = response.answers[key];
|
|
129
|
+
if (!isScore(answer)) return undefined;
|
|
130
|
+
const levelCount = Object.keys(answer.legend ?? {}).length || 1;
|
|
131
|
+
const norm = levelCount > 1 ? answer.score / (levelCount - 1) : 0;
|
|
132
|
+
return { value: answer.score, norm, confidence: answer.confidence };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Choose a target page. Single shard when candidates fit; otherwise a tournament.
|
|
137
|
+
*/
|
|
138
|
+
export async function chooseTarget(
|
|
139
|
+
client: JevClient,
|
|
140
|
+
claim: ClaimInput,
|
|
141
|
+
candidates: CandidatePage[],
|
|
142
|
+
config: ResolvedConfig,
|
|
143
|
+
options?: { signal?: AbortSignal; evidenceText?: string },
|
|
144
|
+
): Promise<{ verdicts: Partial<ClaimVerdicts>; requests: number; usage: { input_tokens: number; output_tokens: number }; raw: Record<string, unknown> }> {
|
|
145
|
+
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
146
|
+
const raw: Record<string, unknown> = {};
|
|
147
|
+
const shards = config.routing.shardSize > 0 ? chunkBy(candidates, config.routing.shardSize) : [candidates];
|
|
148
|
+
|
|
149
|
+
const optionsFor = (pages: CandidatePage[], includeNone: boolean): Record<string, string | null> => {
|
|
150
|
+
const criteria: Record<string, string | null> = {};
|
|
151
|
+
for (const page of pages) {
|
|
152
|
+
criteria[page.path] = `${page.title} (${page.type})${page.summary ? ` — ${page.summary}` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
criteria[ADD_NEW_PAGE] = "None of these pages fit; a new page should be created";
|
|
155
|
+
if (includeNone) criteria[NONE_OF_THESE] = "No confident placement at all";
|
|
156
|
+
return criteria;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const stateBase = {
|
|
160
|
+
claim: { text: claim.text, kind: claim.kind, quote: claim.quote ?? null, files: claim.files ?? [] },
|
|
161
|
+
evidence: claim.evidenceText ?? null,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const shardResults = await mapLimit(shards, 4, async (pages) => {
|
|
165
|
+
if (pages.length === 0) return undefined;
|
|
166
|
+
const response = await client.systemOne(
|
|
167
|
+
{ ...stateBase, candidate_pages: pages.map((p) => ({ path: p.path, title: p.title, type: p.type, summary: p.summary })) },
|
|
168
|
+
{
|
|
169
|
+
best: choice("Which page should absorb this claim?", optionsFor(pages, false)),
|
|
170
|
+
any_fit: noul("Does at least one of the candidate pages substantially fit this claim?"),
|
|
171
|
+
},
|
|
172
|
+
{ signal: options?.signal },
|
|
173
|
+
);
|
|
174
|
+
usage.input_tokens += response.usage.input_tokens;
|
|
175
|
+
usage.output_tokens += response.usage.output_tokens;
|
|
176
|
+
raw[`shard:${pages[0]?.path ?? "empty"}`] = response.answers;
|
|
177
|
+
const best = choiceValue(response, "best");
|
|
178
|
+
return { best: best?.value, confidence: best?.confidence ?? 0, anyFit: noulValue(response, "any_fit") };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const valid = shardResults.filter((result): result is NonNullable<typeof result> => Boolean(result));
|
|
182
|
+
const winners = valid.filter((result) => result.best && result.best !== ADD_NEW_PAGE && result.anyFit >= config.routing.minFit);
|
|
183
|
+
const maxAnyFit = valid.reduce((max, result) => Math.max(max, result.anyFit), 0);
|
|
184
|
+
|
|
185
|
+
if (winners.length === 0) {
|
|
186
|
+
return {
|
|
187
|
+
verdicts: { newPage: true, anyFit: maxAnyFit, target: undefined },
|
|
188
|
+
requests: valid.length,
|
|
189
|
+
usage,
|
|
190
|
+
raw,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (winners.length === 1) {
|
|
194
|
+
const only = winners[0];
|
|
195
|
+
const isNew = (only.confidence ?? 0) < config.routing.newPageConfidence && (only.anyFit ?? 0) < config.routing.minFit;
|
|
196
|
+
return {
|
|
197
|
+
verdicts: { target: isNew ? undefined : only.best, targetConfidence: only.confidence, anyFit: only.anyFit, newPage: isNew },
|
|
198
|
+
requests: valid.length,
|
|
199
|
+
usage,
|
|
200
|
+
raw,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const finalPages = winners
|
|
205
|
+
.map((winner) => candidates.find((page) => page.path === winner.best))
|
|
206
|
+
.filter((page): page is CandidatePage => Boolean(page));
|
|
207
|
+
const finalResponse = await client.systemOne(
|
|
208
|
+
{ ...stateBase, candidate_pages: finalPages.map((p) => ({ path: p.path, title: p.title, type: p.type, summary: p.summary })) },
|
|
209
|
+
{
|
|
210
|
+
final: choice("Which page should absorb this claim?", optionsFor(finalPages, true)),
|
|
211
|
+
fit: noul("Does the selected page substantially fit this claim?"),
|
|
212
|
+
},
|
|
213
|
+
{ signal: options?.signal },
|
|
214
|
+
);
|
|
215
|
+
usage.input_tokens += finalResponse.usage.input_tokens;
|
|
216
|
+
usage.output_tokens += finalResponse.usage.output_tokens;
|
|
217
|
+
raw.final = finalResponse.answers;
|
|
218
|
+
|
|
219
|
+
const finalChoice = choiceValue(finalResponse, "final");
|
|
220
|
+
const fit = noulValue(finalResponse, "fit");
|
|
221
|
+
const isNew = !finalChoice || finalChoice.value === ADD_NEW_PAGE || finalChoice.value === NONE_OF_THESE || fit < config.routing.minFit;
|
|
222
|
+
return {
|
|
223
|
+
verdicts: {
|
|
224
|
+
target: isNew ? undefined : finalChoice?.value,
|
|
225
|
+
targetConfidence: finalChoice?.confidence,
|
|
226
|
+
anyFit: Math.max(fit, maxAnyFit),
|
|
227
|
+
newPage: isNew,
|
|
228
|
+
},
|
|
229
|
+
requests: valid.length + 1,
|
|
230
|
+
usage,
|
|
231
|
+
raw,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function chunkBy<T>(items: T[], size: number): T[][] {
|
|
236
|
+
const out: T[][] = [];
|
|
237
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface AdjudicateOptions {
|
|
242
|
+
candidatePages?: CandidatePage[];
|
|
243
|
+
candidateClaims?: CandidateClaim[];
|
|
244
|
+
topics?: string[];
|
|
245
|
+
evidenceText?: string;
|
|
246
|
+
signal?: AbortSignal;
|
|
247
|
+
existingTopics?: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function adjudicateClaim(
|
|
251
|
+
client: JevClient,
|
|
252
|
+
claim: ClaimInput,
|
|
253
|
+
config: ResolvedConfig,
|
|
254
|
+
options?: AdjudicateOptions,
|
|
255
|
+
): Promise<AdjudicationResult> {
|
|
256
|
+
const candidateClaims = (options?.candidateClaims ?? []).slice(0, 12);
|
|
257
|
+
const evidence = claim.evidenceText ?? options?.evidenceText ?? "";
|
|
258
|
+
|
|
259
|
+
const state: Record<string, unknown> = {
|
|
260
|
+
claim: { text: claim.text, kind: claim.kind, quote: claim.quote ?? null, files: claim.files ?? [] },
|
|
261
|
+
reference_passage: evidence || null,
|
|
262
|
+
};
|
|
263
|
+
if (candidateClaims.length > 0) {
|
|
264
|
+
state.existing_claims = candidateClaims.map((candidate) => ({
|
|
265
|
+
id: candidate.id ?? candidate.text.slice(0, 40),
|
|
266
|
+
text: candidate.text,
|
|
267
|
+
page: candidate.page ?? null,
|
|
268
|
+
status: candidate.status ?? null,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const questions: Record<string, JevQuestion> = {
|
|
273
|
+
grounded: QUESTION.grounded,
|
|
274
|
+
derivable: QUESTION.derivable,
|
|
275
|
+
durable: QUESTION.durable,
|
|
276
|
+
sensitive: QUESTION.sensitive,
|
|
277
|
+
kind: QUESTION.kind,
|
|
278
|
+
importance: QUESTION.importance,
|
|
279
|
+
criticality: QUESTION.criticality,
|
|
280
|
+
verifiable: QUESTION.verifiable,
|
|
281
|
+
};
|
|
282
|
+
if (candidateClaims.length > 0) {
|
|
283
|
+
questions.already_known = noul("The existing claims already contain this knowledge.");
|
|
284
|
+
questions.relation = choice("How does the new claim relate to the existing claims?", {
|
|
285
|
+
consistent: "Agrees with them without adding anything new",
|
|
286
|
+
extends: "Adds detail or a new facet on top of them",
|
|
287
|
+
contradicts: "Conflicts with at least one of them",
|
|
288
|
+
supersedes: "Replaces at least one of them with newer or better information",
|
|
289
|
+
unrelated: "Not about the same thing",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const topics = (options?.existingTopics ?? options?.topics ?? []).filter(Boolean);
|
|
294
|
+
questions.page_type = choice("What type of page should hold this claim?", {
|
|
295
|
+
"architecture/module": "Module responsibility, surface, or dependencies",
|
|
296
|
+
"architecture/flow": "End-to-end data or control flow",
|
|
297
|
+
"architecture/layer": "Dependency direction or boundaries",
|
|
298
|
+
invariant: "A rule that must always hold",
|
|
299
|
+
decision: "A decision with rationale",
|
|
300
|
+
gotcha: "A footgun or failure mode",
|
|
301
|
+
concept: "A concept or synthesis",
|
|
302
|
+
source_summary: "A summary of a source document",
|
|
303
|
+
});
|
|
304
|
+
if (topics.length > 0) {
|
|
305
|
+
questions.topic = choice(
|
|
306
|
+
"Which existing topic directory should hold this claim?",
|
|
307
|
+
Object.fromEntries([...topics.slice(0, 100).map((topic) => [topic, null]), ["new_topic", "None fit; create a new topic"]]),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const response = await client.systemOne(state, questions, { signal: options?.signal });
|
|
312
|
+
const kind = choiceValue(response, "kind");
|
|
313
|
+
const importance = scoreValue(response, "importance");
|
|
314
|
+
const criticality = scoreValue(response, "criticality");
|
|
315
|
+
const relation = choiceValue(response, "relation");
|
|
316
|
+
|
|
317
|
+
const verdicts: ClaimVerdicts = {
|
|
318
|
+
grounded: noulValue(response, "grounded"),
|
|
319
|
+
derivable: noulValue(response, "derivable"),
|
|
320
|
+
durable: noulValue(response, "durable"),
|
|
321
|
+
sensitive: noulValue(response, "sensitive"),
|
|
322
|
+
kind: kind?.value ?? claim.kind,
|
|
323
|
+
kindConfidence: kind?.confidence ?? 0,
|
|
324
|
+
importance: importance?.value ?? 0,
|
|
325
|
+
importanceNorm: importance?.norm ?? 0,
|
|
326
|
+
importanceConfidence: importance?.confidence ?? 0,
|
|
327
|
+
criticality: criticality?.value ?? 0,
|
|
328
|
+
criticalityNorm: criticality?.norm ?? 0,
|
|
329
|
+
criticalityConfidence: criticality?.confidence ?? 0,
|
|
330
|
+
alreadyKnown: noulValue(response, "already_known"),
|
|
331
|
+
trustTier: choiceValue(response, "verifiable")?.value,
|
|
332
|
+
trustConfidence: choiceValue(response, "verifiable")?.confidence,
|
|
333
|
+
relation: relation?.value,
|
|
334
|
+
relationConfidence: relation?.confidence,
|
|
335
|
+
pageType: choiceValue(response, "page_type")?.value,
|
|
336
|
+
pageTypeConfidence: choiceValue(response, "page_type")?.confidence,
|
|
337
|
+
topic: choiceValue(response, "topic")?.value,
|
|
338
|
+
topicConfidence: choiceValue(response, "topic")?.confidence,
|
|
339
|
+
newPage: true,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
verdicts,
|
|
344
|
+
requestCount: 1,
|
|
345
|
+
usage: { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens },
|
|
346
|
+
raw: { adjudicate: response.answers },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export type ClaimAction =
|
|
351
|
+
| "file"
|
|
352
|
+
| "file_user_stated"
|
|
353
|
+
| "reinforce"
|
|
354
|
+
| "review"
|
|
355
|
+
| "reject_duplicate"
|
|
356
|
+
| "reject_derivable"
|
|
357
|
+
| "reject_sensitive"
|
|
358
|
+
| "reject_unsupported";
|
|
359
|
+
|
|
360
|
+
export interface ClaimDecision {
|
|
361
|
+
action: ClaimAction;
|
|
362
|
+
score: number;
|
|
363
|
+
reasons: string[];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function decideClaim(verdicts: ClaimVerdicts, config: ResolvedConfig): ClaimDecision {
|
|
367
|
+
const reasons: string[] = [];
|
|
368
|
+
const { thresholds, weights } = config;
|
|
369
|
+
|
|
370
|
+
if (verdicts.sensitive >= 0.9) {
|
|
371
|
+
return { action: "reject_sensitive", score: 0, reasons: ["contains sensitive content"] };
|
|
372
|
+
}
|
|
373
|
+
if (verdicts.derivable >= thresholds.minDerivable) {
|
|
374
|
+
return {
|
|
375
|
+
action: "reject_derivable",
|
|
376
|
+
score: 0,
|
|
377
|
+
reasons: [`derivable from code (${verdicts.derivable.toFixed(2)} ≥ ${thresholds.minDerivable})`],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (verdicts.alreadyKnown >= 0.9) {
|
|
381
|
+
return { action: "reject_duplicate", score: 0, reasons: ["already known"] };
|
|
382
|
+
}
|
|
383
|
+
if (verdicts.relation === "supersedes") {
|
|
384
|
+
reasons.push("supersedes an existing claim");
|
|
385
|
+
}
|
|
386
|
+
if (verdicts.relation === "contradicts") {
|
|
387
|
+
reasons.push("contradicts existing knowledge — dispute");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const score =
|
|
391
|
+
weights.grounded * verdicts.grounded +
|
|
392
|
+
weights.importance * verdicts.importanceNorm +
|
|
393
|
+
weights.nonDerivable * (1 - verdicts.derivable) +
|
|
394
|
+
weights.authority * 0.8;
|
|
395
|
+
|
|
396
|
+
const relationReinforces = verdicts.relation === "extends" || verdicts.relation === "consistent";
|
|
397
|
+
if (verdicts.grounded >= thresholds.autoAccept && verdicts.importance >= thresholds.minImportance && relationReinforces) {
|
|
398
|
+
return { action: "reinforce", score, reasons: [...reasons, "reinforces existing knowledge"] };
|
|
399
|
+
}
|
|
400
|
+
if (verdicts.grounded >= thresholds.autoAccept && verdicts.importance >= thresholds.minImportance) {
|
|
401
|
+
if (verdicts.durable < 0.5 && verdicts.importanceNorm < 0.5) {
|
|
402
|
+
return { action: "review", score, reasons: [...reasons, "low durability"] };
|
|
403
|
+
}
|
|
404
|
+
return { action: "file", score, reasons };
|
|
405
|
+
}
|
|
406
|
+
if (verdicts.grounded >= thresholds.minSupport) {
|
|
407
|
+
return { action: "review", score, reasons: [...reasons, "below auto-accept threshold"] };
|
|
408
|
+
}
|
|
409
|
+
if (verdicts.trustTier === "user_stated" && verdicts.durable >= 0.5 && verdicts.importance >= thresholds.minImportance) {
|
|
410
|
+
return { action: "file_user_stated", score, reasons: [...reasons, "user-stated trust tier (lower confidence)"] };
|
|
411
|
+
}
|
|
412
|
+
if (verdicts.trustTier === "verified_in_repo" && verdicts.durable >= 0.5 && verdicts.importance >= thresholds.minImportance) {
|
|
413
|
+
return { action: "file", score, reasons: [...reasons, "repo-verified evidence"] };
|
|
414
|
+
}
|
|
415
|
+
return { action: "reject_unsupported", score, reasons: [...reasons, "not grounded in evidence"] };
|
|
416
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated session capture: serialize the session, extract candidate insights
|
|
3
|
+
* with the session model, and hand them to the Jev adjudication pipeline.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { parseJsonObject } from "./extract.ts";
|
|
7
|
+
|
|
8
|
+
export interface CapturedInsight {
|
|
9
|
+
text: string;
|
|
10
|
+
kind?: string;
|
|
11
|
+
evidence?: Array<{ kind: string; ref: string; quote?: string }>;
|
|
12
|
+
confidence?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SYSTEM_PROMPT = `You extract durable project knowledge from a coding session for a wiki that helps future agents make better decisions.
|
|
16
|
+
|
|
17
|
+
Return ONLY JSON, no prose, no fences:
|
|
18
|
+
{ "insights": [ { "text": "one atomic, self-contained statement", "kind": "decision|invariant|architecture|gotcha|pattern|procedure|fact", "evidence": [ { "kind": "file|commit|test|command|user", "ref": "path, commit hash, command, or short quote from the user", "quote": "optional exact quote" } ], "confidence": 0.0 } ] }
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
- 0 to 8 insights. Fewer is better. An empty list is a valid answer.
|
|
22
|
+
- Only durable knowledge: decisions with rationale, invariants, architecture/framing, gotchas, patterns, procedures.
|
|
23
|
+
- Never include transient task state ("currently debugging X"), code snippets, or anything a future agent can re-derive by reading the repository in under a minute.
|
|
24
|
+
- Evidence must point at something concrete from the session: a file path, commit, command, or the user's own words.
|
|
25
|
+
- Prefer knowledge the session produced over knowledge the session merely consumed.`;
|
|
26
|
+
|
|
27
|
+
interface ContentPart {
|
|
28
|
+
type?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
arguments?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function textFromContent(content: unknown): string {
|
|
35
|
+
if (typeof content === "string") return content;
|
|
36
|
+
if (!Array.isArray(content)) return "";
|
|
37
|
+
const parts: string[] = [];
|
|
38
|
+
for (const part of content) {
|
|
39
|
+
if (!part || typeof part !== "object") continue;
|
|
40
|
+
const block = part as ContentPart;
|
|
41
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
42
|
+
if (block.type === "toolCall" && typeof block.name === "string") {
|
|
43
|
+
const args = block.arguments ? JSON.stringify(block.arguments).slice(0, 200) : "";
|
|
44
|
+
parts.push(`[tool ${block.name}${args ? ` ${args}` : ""}]`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return parts.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serialize recent session messages into a compact transcript for extraction. */
|
|
51
|
+
export function sessionTextFromEntries(entries: unknown[], maxChars = 24_000): string {
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
for (const raw of entries) {
|
|
54
|
+
if (!raw || typeof raw !== "object") continue;
|
|
55
|
+
const entry = raw as { type?: string; message?: { role?: string; content?: unknown } };
|
|
56
|
+
if (entry.type !== "message" || !entry.message?.role) continue;
|
|
57
|
+
const role = entry.message.role;
|
|
58
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
59
|
+
const text = textFromContent(entry.message.content).trim();
|
|
60
|
+
if (!text) continue;
|
|
61
|
+
lines.push(`${role === "user" ? "User" : "Assistant"}: ${text}`);
|
|
62
|
+
}
|
|
63
|
+
let transcript = lines.join("\n\n");
|
|
64
|
+
if (transcript.length > maxChars) transcript = transcript.slice(-maxChars);
|
|
65
|
+
return transcript;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function extractInsights(
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
sessionText: string,
|
|
71
|
+
options?: { maxTokens?: number },
|
|
72
|
+
): Promise<CapturedInsight[]> {
|
|
73
|
+
if (!sessionText.trim()) return [];
|
|
74
|
+
const model = ctx.model;
|
|
75
|
+
if (!model) return [];
|
|
76
|
+
const response = await ctx.modelRegistry.complete(
|
|
77
|
+
model,
|
|
78
|
+
{
|
|
79
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
80
|
+
messages: [
|
|
81
|
+
{
|
|
82
|
+
role: "user",
|
|
83
|
+
content: [{ type: "text", text: `<session>\n${sessionText}\n</session>` }],
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{ maxTokens: options?.maxTokens ?? 4000, signal: ctx.signal, cacheRetention: "none" },
|
|
89
|
+
);
|
|
90
|
+
const text = response.content
|
|
91
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
92
|
+
.map((part) => part.text)
|
|
93
|
+
.join("\n");
|
|
94
|
+
const parsed = parseJsonObject<{ insights?: CapturedInsight[] }>(text);
|
|
95
|
+
if (!parsed || !Array.isArray(parsed.insights)) return [];
|
|
96
|
+
return parsed.insights
|
|
97
|
+
.filter((insight) => insight && typeof insight.text === "string" && insight.text.trim().length > 0)
|
|
98
|
+
.slice(0, 8)
|
|
99
|
+
.map((insight) => ({
|
|
100
|
+
text: insight.text.trim(),
|
|
101
|
+
kind: typeof insight.kind === "string" ? insight.kind : "fact",
|
|
102
|
+
evidence: Array.isArray(insight.evidence)
|
|
103
|
+
? insight.evidence
|
|
104
|
+
.filter((item) => item && typeof item.kind === "string" && typeof item.ref === "string")
|
|
105
|
+
.map((item) => ({ kind: String(item.kind), ref: String(item.ref), quote: item.quote ? String(item.quote) : undefined }))
|
|
106
|
+
: [],
|
|
107
|
+
confidence: typeof insight.confidence === "number" ? insight.confidence : undefined,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim extraction from a source document using the session's generative model.
|
|
3
|
+
* Extraction is the only generative step at ingest; Jev judges the results.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export interface ExtractedClaim {
|
|
8
|
+
text: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
quote?: string;
|
|
11
|
+
files?: string[];
|
|
12
|
+
confidence?: number;
|
|
13
|
+
quoteVerified?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExtractionResult {
|
|
17
|
+
title?: string;
|
|
18
|
+
summary?: string;
|
|
19
|
+
topics: string[];
|
|
20
|
+
entities: string[];
|
|
21
|
+
claims: ExtractedClaim[];
|
|
22
|
+
raw: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_SOURCE_CHARS = 40_000;
|
|
26
|
+
|
|
27
|
+
const SYSTEM_PROMPT = `You extract durable project knowledge for a wiki that helps coding agents make better decisions on large codebases.
|
|
28
|
+
|
|
29
|
+
Return ONLY a JSON object, no prose, no code fences:
|
|
30
|
+
{
|
|
31
|
+
"title": "short page/source title",
|
|
32
|
+
"summary": "one sentence",
|
|
33
|
+
"topics": ["topic-slug"],
|
|
34
|
+
"entities": ["named modules, services, files, people"],
|
|
35
|
+
"claims": [
|
|
36
|
+
{
|
|
37
|
+
"text": "one atomic, self-contained statement (a single fact, rule, decision, or structure)",
|
|
38
|
+
"kind": "architecture|invariant|decision|fact|gotcha|procedure|pattern",
|
|
39
|
+
"quote": "exact verbatim span from the source that supports this, or empty string",
|
|
40
|
+
"files": ["path/to/file.ts"],
|
|
41
|
+
"confidence": 0.0
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
- Atomic: one idea per claim. Split compound sentences.
|
|
48
|
+
- High-level framing over implementation detail. Prefer ownership, boundaries, data flow, invariants, rationale, contracts, and failure modes.
|
|
49
|
+
- Skip anything trivially visible from reading the code (function bodies, obvious implementation).
|
|
50
|
+
- Include the exact quote when one exists; never paraphrase a quote. If no quote supports it, leave quote empty.
|
|
51
|
+
- Include file paths only when they appear in the source or are explicitly referenced.
|
|
52
|
+
- Maximum 40 claims, ordered by how much they help a future decision.`;
|
|
53
|
+
|
|
54
|
+
function stripFences(text: string): string {
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (trimmed.startsWith("```")) {
|
|
57
|
+
return trimmed.replace(/^```[a-zA-Z]*\s*/, "").replace(/```\s*$/, "");
|
|
58
|
+
}
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseJsonObject<T>(text: string): T | undefined {
|
|
63
|
+
const cleaned = stripFences(text);
|
|
64
|
+
const start = cleaned.indexOf("{");
|
|
65
|
+
const end = cleaned.lastIndexOf("}");
|
|
66
|
+
if (start === -1 || end === -1 || end <= start) return undefined;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(cleaned.slice(start, end + 1)) as T;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeForMatch(text: string): string {
|
|
75
|
+
return text
|
|
76
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
77
|
+
.replace(/[\u201C\u201D]/g, '"')
|
|
78
|
+
.replace(/\s+/g, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function quoteIsPresent(sourceText: string, quote: string | undefined): boolean {
|
|
83
|
+
if (!quote || quote.trim().length < 12) return false;
|
|
84
|
+
return normalizeForMatch(sourceText).includes(normalizeForMatch(quote));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function extractClaims(
|
|
88
|
+
ctx: ExtensionContext,
|
|
89
|
+
sourceText: string,
|
|
90
|
+
options?: { title?: string; maxTokens?: number },
|
|
91
|
+
): Promise<{ result: ExtractionResult; sourceTruncated: boolean }> {
|
|
92
|
+
const truncated = sourceText.length > MAX_SOURCE_CHARS;
|
|
93
|
+
const source = truncated ? `${sourceText.slice(0, MAX_SOURCE_CHARS)}\n\n[... source truncated for extraction ...]` : sourceText;
|
|
94
|
+
const user = `${options?.title ? `Document title: ${options.title}\n\n` : ""}<document>\n${source}\n</document>`;
|
|
95
|
+
|
|
96
|
+
const model = ctx.model;
|
|
97
|
+
if (!model) throw new Error("No active model available for claim extraction.");
|
|
98
|
+
|
|
99
|
+
const response = await ctx.modelRegistry.complete(
|
|
100
|
+
model,
|
|
101
|
+
{
|
|
102
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
103
|
+
messages: [{ role: "user", content: [{ type: "text", text: user }], timestamp: Date.now() }],
|
|
104
|
+
},
|
|
105
|
+
{ maxTokens: options?.maxTokens ?? 8000, signal: ctx.signal, cacheRetention: "none" },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const text = response.content
|
|
109
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
110
|
+
.map((part) => part.text)
|
|
111
|
+
.join("\n");
|
|
112
|
+
|
|
113
|
+
const parsed = parseJsonObject<Partial<ExtractionResult>>(text);
|
|
114
|
+
if (!parsed || !Array.isArray(parsed.claims)) {
|
|
115
|
+
return {
|
|
116
|
+
result: { topics: [], entities: [], claims: [], raw: text },
|
|
117
|
+
sourceTruncated: truncated,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const claims: ExtractedClaim[] = parsed.claims
|
|
122
|
+
.filter((claim) => claim && typeof claim.text === "string" && claim.text.trim().length > 0)
|
|
123
|
+
.slice(0, 40)
|
|
124
|
+
.map((claim) => ({
|
|
125
|
+
text: claim.text.trim(),
|
|
126
|
+
kind: typeof claim.kind === "string" ? claim.kind : "fact",
|
|
127
|
+
quote: typeof claim.quote === "string" && claim.quote.trim() ? claim.quote.trim() : undefined,
|
|
128
|
+
files: Array.isArray(claim.files) ? claim.files.map(String).slice(0, 10) : [],
|
|
129
|
+
confidence: typeof claim.confidence === "number" ? claim.confidence : undefined,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
for (const claim of claims) {
|
|
133
|
+
if (claim.quote) {
|
|
134
|
+
claim.quoteVerified = quoteIsPresent(sourceText, claim.quote);
|
|
135
|
+
if (!claim.quoteVerified) claim.quote = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
result: {
|
|
141
|
+
title: typeof parsed.title === "string" ? parsed.title : options?.title,
|
|
142
|
+
summary: typeof parsed.summary === "string" ? parsed.summary : undefined,
|
|
143
|
+
topics: Array.isArray(parsed.topics) ? parsed.topics.map(String).slice(0, 6) : [],
|
|
144
|
+
entities: Array.isArray(parsed.entities) ? parsed.entities.map(String).slice(0, 20) : [],
|
|
145
|
+
claims,
|
|
146
|
+
raw: text,
|
|
147
|
+
},
|
|
148
|
+
sourceTruncated: truncated,
|
|
149
|
+
};
|
|
150
|
+
}
|