pgroast 0.0.0-stage → 0.1.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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +173 -2
  3. package/bin/pgroast.mjs +19 -0
  4. package/data/precedents.sql.gz +0 -0
  5. package/docs/corpus.md +352 -0
  6. package/docs/design.md +540 -0
  7. package/docs/eval.md +245 -0
  8. package/docs/haas-paranoia-notes.md +223 -0
  9. package/invariants/README.md +15 -0
  10. package/invariants/nbtree.md +14 -0
  11. package/package.json +51 -4
  12. package/prompts/brief.md +32 -0
  13. package/prompts/committer-paranoia.md +149 -0
  14. package/prompts/critic.md +56 -0
  15. package/prompts/enrich.md +35 -0
  16. package/prompts/pushback-map.md +58 -0
  17. package/prompts/pushback-reduce.md +72 -0
  18. package/prompts/reflect.md +20 -0
  19. package/prompts/relevance.md +20 -0
  20. package/prompts/roaster-already-exists.md +23 -0
  21. package/prompts/roaster-common.md +88 -0
  22. package/prompts/roaster-demand.md +28 -0
  23. package/prompts/roaster-maintenance-fit.md +22 -0
  24. package/prompts/roaster-prior-attempts.md +29 -0
  25. package/prompts/roaster-replication-ondisk.md +24 -0
  26. package/prompts/roaster-security.md +21 -0
  27. package/prompts/roaster-standards-compat.md +21 -0
  28. package/prompts/supervisor.md +31 -0
  29. package/prompts/synth-self.md +19 -0
  30. package/prompts/synth.md +140 -0
  31. package/prompts/tone.md +21 -0
  32. package/prompts/writing.md +98 -0
  33. package/sql/schema.sql +45 -0
  34. package/src/cli.ts +391 -0
  35. package/src/corpus/cf.ts +220 -0
  36. package/src/corpus/cli.ts +189 -0
  37. package/src/corpus/db.ts +119 -0
  38. package/src/corpus/fetch.ts +104 -0
  39. package/src/corpus/reverts.ts +133 -0
  40. package/src/corpus/search.ts +201 -0
  41. package/src/corpus/util.ts +133 -0
  42. package/src/corpus/wiki.ts +80 -0
  43. package/src/eval/cases.ts +118 -0
  44. package/src/eval/cli.ts +105 -0
  45. package/src/eval/judge.ts +136 -0
  46. package/src/eval/report.ts +268 -0
  47. package/src/eval/run.ts +153 -0
  48. package/src/eval/score.ts +275 -0
  49. package/src/roast/doctor.ts +109 -0
  50. package/src/roast/enrich.ts +335 -0
  51. package/src/roast/invariants.ts +65 -0
  52. package/src/roast/llm.ts +601 -0
  53. package/src/roast/material.ts +133 -0
  54. package/src/roast/page.ts +266 -0
  55. package/src/roast/pipeline.ts +981 -0
  56. package/src/roast/precedents.ts +308 -0
  57. package/src/roast/pushback.ts +664 -0
  58. package/src/roast/render.ts +386 -0
  59. package/src/roast/sanitize.ts +140 -0
  60. package/src/roast/schema.ts +714 -0
  61. package/src/roast/tone.ts +216 -0
  62. package/src/thread/fetch.ts +172 -0
  63. package/src/thread/load.ts +227 -0
  64. package/src/thread/local.ts +73 -0
  65. package/src/thread/parse.ts +243 -0
  66. package/src/thread/patchprose.ts +143 -0
  67. package/src/thread/types.ts +120 -0
@@ -0,0 +1,308 @@
1
+ // Thin adapter over the precedent corpus (owned by src/corpus). Retrieval is code, not LLM:
2
+ // the model writes search queries, this module runs them and hands back rows from the DB.
3
+ // Every link in a Precedent comes from the corpus; nothing here is generated.
4
+ //
5
+ // Backends, tried in order:
6
+ // 1. src/corpus/search.ts -> searchPrecedents({query, before?, kinds?, limit?, includeUndated?})
7
+ // (Postgres database `pgroast`, PGROAST_DSN)
8
+ // 2. none (module or database missing): no precedents, with a clear warning
9
+ //
10
+ // Leak guards: `before` is passed to the backend AND re-checked here; the proposal's own
11
+ // CommitFest entry / thread is removed from the results.
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { REPO_ROOT } from "../thread/fetch.ts";
16
+ import type { Precedent } from "./schema.ts";
17
+
18
+ export interface SearchArgs {
19
+ query: string;
20
+ /** YYYY-MM-DD: only precedents whose dates are strictly before it. */
21
+ before?: string;
22
+ kinds?: string[];
23
+ limit?: number;
24
+ includeUndated?: boolean;
25
+ }
26
+
27
+ export type RawPrecedent = Record<string, unknown>;
28
+ export type SearchFn = (args: SearchArgs) => Promise<RawPrecedent[]>;
29
+
30
+ export interface RetrieveArgs {
31
+ queries: string[];
32
+ /** Directed lookup, e.g. "find the 2018 suggestion of X". */
33
+ hint?: string | null;
34
+ /** YYYY-MM-DD cutoff. */
35
+ before: string;
36
+ /** Keep undated precedents (wiki "do not want" items). Off in evaluation runs. */
37
+ includeUndated?: boolean;
38
+ /** The proposal itself, to keep it out of its own precedent list. */
39
+ self?: { cfId?: number | null; messageIds?: string[] };
40
+ limit?: number;
41
+ perQuery?: number;
42
+ /** Injected for tests; default = auto-detected backend. */
43
+ search?: SearchFn;
44
+ log?: (msg: string) => void;
45
+ }
46
+
47
+ export interface RetrieveResult {
48
+ precedents: Precedent[];
49
+ backend: string;
50
+ warnings: string[];
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------------------------
54
+ // Normalisation
55
+
56
+ function pick(r: RawPrecedent, ...keys: string[]): unknown {
57
+ for (const k of keys) if (r[k] != null) return r[k];
58
+ return undefined;
59
+ }
60
+
61
+ function asString(v: unknown): string {
62
+ if (v == null) return "";
63
+ if (Array.isArray(v)) return v.map(asString).filter(Boolean).join(", ");
64
+ if (v instanceof Date) return v.toISOString();
65
+ return String(v);
66
+ }
67
+
68
+ function asList(v: unknown): string[] {
69
+ if (v == null) return [];
70
+ if (Array.isArray(v)) return v.map(asString).filter(Boolean);
71
+ if (typeof v === "string") {
72
+ const s = v.trim();
73
+ if (s.startsWith("[")) {
74
+ try {
75
+ return asList(JSON.parse(s));
76
+ } catch {
77
+ // fall through
78
+ }
79
+ }
80
+ return s ? [s] : [];
81
+ }
82
+ return [];
83
+ }
84
+
85
+ function asDate(v: unknown): string | null {
86
+ const s = asString(v).trim();
87
+ return /^\d{4}-\d{2}-\d{2}/.test(s) ? s : null;
88
+ }
89
+
90
+ const isHttpUrl = (s: string) => /^https?:\/\/\S+$/.test(s);
91
+
92
+ /** Map a backend row (snake_case or camelCase) to a Precedent without the run-local id. */
93
+ export function normalisePrecedent(
94
+ r: RawPrecedent,
95
+ ): Omit<Precedent, "id" | "matched_queries" | "from_hint" | "score" | "enriched" | "key_quote" | "quote_message_url" | "relevant"> | null {
96
+ const url = asString(pick(r, "url", "link")).trim();
97
+ const title = asString(pick(r, "title")).trim();
98
+ // A precedent without a real link is useless here: we never show one.
99
+ if (!isHttpUrl(url) || !title) return null;
100
+ return {
101
+ source_id: asString(pick(r, "id", "source_id", "sourceId")) || url,
102
+ kind: asString(pick(r, "kind", "type")),
103
+ title,
104
+ status: asString(pick(r, "status", "outcome")),
105
+ date: asDate(pick(r, "date", "decided_at", "decidedAt", "decision_date")),
106
+ first_date: asDate(pick(r, "first_date", "firstDate", "started_at", "startedAt")),
107
+ url,
108
+ thread_urls: asList(pick(r, "thread_urls", "threadUrls", "threads")).filter(isHttpUrl),
109
+ authors: asString(pick(r, "authors", "author")),
110
+ objectors: asString(pick(r, "objectors", "who_objected", "whoObjected")),
111
+ summary: asString(pick(r, "summary", "proposal_summary", "proposalSummary")),
112
+ objection: asString(pick(r, "objection", "objection_summary", "objectionSummary", "reason")),
113
+ touch_points: asList(pick(r, "touch_points", "touchPoints")),
114
+ };
115
+ }
116
+
117
+ /** The message-id at the end of an archive URL, decoded; "" when the URL is not one. */
118
+ export function messageIdOfUrl(url: string): string {
119
+ const m = url.match(/\/message-id\/(?:flat\/)?([^?#]+)/) ?? url.match(/postgr\.es\/m\/([^?#]+)/);
120
+ if (!m) return "";
121
+ try {
122
+ return decodeURIComponent(m[1]!).replace(/\/$/, "");
123
+ } catch {
124
+ return m[1]!;
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------------------------
129
+ // Backends
130
+
131
+ let cachedBackend: { name: string; fn: SearchFn | null; warning?: string } | null = null;
132
+ let closeFn: (() => Promise<void>) | null = null;
133
+
134
+ /** Close the corpus connection pool, so the process can exit. Safe to call at any time. */
135
+ export async function closePrecedents(): Promise<void> {
136
+ try {
137
+ await closeFn?.();
138
+ } catch {
139
+ // nothing to do
140
+ }
141
+ }
142
+
143
+ /** Find a working search backend. Never throws. */
144
+ export async function detectBackend(root: string = REPO_ROOT): Promise<{ name: string; fn: SearchFn | null; warning?: string }> {
145
+ if (cachedBackend && root === REPO_ROOT) return cachedBackend;
146
+ const problems: string[] = [];
147
+ let found: { name: string; fn: SearchFn | null; warning?: string } | null = null;
148
+
149
+ const tsPath = join(root, "src", "corpus", "search.ts");
150
+ if (existsSync(tsPath)) {
151
+ try {
152
+ const mod = (await import(tsPath)) as { searchPrecedents?: unknown; closeSearch?: unknown };
153
+ if (typeof mod.searchPrecedents === "function") {
154
+ const fn = mod.searchPrecedents as (a: SearchArgs) => Promise<unknown>;
155
+ // Probe once: the module may load while its database is not there.
156
+ await Promise.race([
157
+ fn({ query: "postgres", limit: 1 }),
158
+ new Promise((_, rej) => setTimeout(() => rej(new Error("probe timed out after 8 s")), 8000)),
159
+ ]);
160
+ closeFn = typeof mod.closeSearch === "function" ? (mod.closeSearch as () => Promise<void>) : null;
161
+ found = {
162
+ name: "src/corpus/search.ts",
163
+ fn: async (a) => {
164
+ const res = await fn(a);
165
+ return (Array.isArray(res) ? res : ((res as { results?: unknown[] })?.results ?? [])) as RawPrecedent[];
166
+ },
167
+ };
168
+ } else {
169
+ problems.push("src/corpus/search.ts does not export searchPrecedents");
170
+ }
171
+ } catch (e) {
172
+ problems.push(`src/corpus/search.ts is not usable: ${(e as Error).message.split("\n")[0]}`);
173
+ }
174
+ }
175
+
176
+ const result = found
177
+ ? { ...found, ...(problems.length ? { warning: problems.join("; ") } : {}) }
178
+ : {
179
+ name: "none",
180
+ fn: null,
181
+ warning: `precedent corpus unavailable (${problems.join("; ") || "src/corpus/search.ts not found"}): prior-attempt search was NOT done`,
182
+ };
183
+ if (root === REPO_ROOT) cachedBackend = result;
184
+ return result;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------------------------
188
+ // Retrieval
189
+
190
+ const RRF_K = 10;
191
+
192
+ /** Recency factor 1.0-1.5: newer precedents first by default ("don't dig too deep into history"). */
193
+ export function recencyBoost(dateIso: string | null, cutoff: string): number {
194
+ if (!dateIso) return 1;
195
+ const years = (Date.parse(cutoff) - Date.parse(dateIso.slice(0, 10))) / (365.25 * 86400e3);
196
+ if (!Number.isFinite(years)) return 1;
197
+ return 1 + 0.5 * Math.max(0, 1 - Math.max(0, years) / 15);
198
+ }
199
+
200
+ export async function retrievePrecedents(args: RetrieveArgs): Promise<RetrieveResult> {
201
+ const warnings: string[] = [];
202
+ const log = args.log ?? (() => undefined);
203
+ let search = args.search;
204
+ let backendName = "injected";
205
+ if (!search) {
206
+ const b = await detectBackend();
207
+ backendName = b.name;
208
+ if (b.warning) warnings.push(b.warning);
209
+ if (!b.fn) return { precedents: [], backend: b.name, warnings };
210
+ search = b.fn;
211
+ }
212
+
213
+ const limit = args.limit ?? 15;
214
+ const perQuery = args.perQuery ?? 8;
215
+ const selfIds = new Set((args.self?.messageIds ?? []).filter(Boolean));
216
+ const selfCf = args.self?.cfId != null ? String(args.self.cfId) : null;
217
+ const hintYears = new Set(args.hint?.match(/\b(?:19|20)\d{2}\b/g) ?? []);
218
+
219
+ type Acc = Omit<Precedent, "id" | "enriched" | "key_quote" | "quote_message_url" | "relevant">;
220
+ const byKey = new Map<string, Acc>();
221
+ const queries: { q: string; hint: boolean }[] = [
222
+ ...(args.hint ? [{ q: args.hint, hint: true }] : []),
223
+ ...[...new Set(args.queries.map((q) => q.trim()).filter(Boolean))].map((q) => ({ q, hint: false })),
224
+ ];
225
+
226
+ let failures = 0;
227
+ for (const { q, hint } of queries) {
228
+ let rows: RawPrecedent[];
229
+ try {
230
+ rows = await search({ query: q, before: args.before, limit: hint ? perQuery * 2 : perQuery, includeUndated: args.includeUndated });
231
+ } catch (e) {
232
+ failures++;
233
+ warnings.push(`precedent search failed for "${q}": ${(e as Error).message.split("\n")[0]}`);
234
+ continue;
235
+ }
236
+ log(` precedents: "${q}" -> ${rows.length}`);
237
+ rows.forEach((raw, rank) => {
238
+ const p = normalisePrecedent(raw);
239
+ if (!p) return;
240
+ // Leak guard 1: nothing dated on or after the cutoff, whatever the backend did.
241
+ for (const d of [p.date, p.first_date]) if (d && d.slice(0, 10) >= args.before) return;
242
+ if (!p.date && !p.first_date && !args.includeUndated) return;
243
+ // Leak guard 2: not the proposal itself.
244
+ if (selfCf && (p.source_id === `cf:${selfCf}` || new RegExp(`/patch/${selfCf}/?$`).test(p.url))) return;
245
+ if ([p.url, ...p.thread_urls].some((u) => selfIds.has(messageIdOfUrl(u)))) return;
246
+
247
+ const date = p.date ?? p.first_date;
248
+ let s = (1 / (RRF_K + rank)) * recencyBoost(date, args.before);
249
+ if (hint) {
250
+ s *= 3;
251
+ if (date && hintYears.has(date.slice(0, 4))) s *= 2;
252
+ if (p.first_date && hintYears.has(p.first_date.slice(0, 4))) s *= 2;
253
+ }
254
+ const key = p.source_id;
255
+ const cur = byKey.get(key);
256
+ if (cur) {
257
+ cur.score += s;
258
+ if (!cur.matched_queries.includes(q)) cur.matched_queries.push(q);
259
+ cur.from_hint ||= hint;
260
+ } else {
261
+ byKey.set(key, { ...p, matched_queries: [q], from_hint: hint, score: s });
262
+ }
263
+ });
264
+ }
265
+ if (failures === queries.length && queries.length > 0) {
266
+ warnings.push("every precedent search failed: prior-attempt search was NOT done");
267
+ }
268
+
269
+ const precedents = [...byKey.values()]
270
+ .sort((a, b) => b.score - a.score || a.source_id.localeCompare(b.source_id))
271
+ .slice(0, limit)
272
+ .map((p, i) => ({
273
+ id: `P${i + 1}`,
274
+ ...p,
275
+ score: Math.round(p.score * 1000) / 1000,
276
+ enriched: false,
277
+ key_quote: null,
278
+ quote_message_url: null,
279
+ relevant: null,
280
+ }));
281
+ return { precedents, backend: backendName, warnings };
282
+ }
283
+
284
+ const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n) + " [...]" : s);
285
+
286
+ /** Render the retrieved list for a prompt. Links are left out on purpose: the model cites ids. */
287
+ export function renderPrecedentsForPrompt(precedents: Precedent[]): string {
288
+ if (precedents.length === 0) {
289
+ return "No precedent found in the corpus. Do not cite any earlier thread, commit or decision as a precedent.";
290
+ }
291
+ return precedents
292
+ .map((p) => {
293
+ const lines = [
294
+ `[${p.id}] ${p.title}`,
295
+ ` kind: ${p.kind || "?"}; outcome: ${p.status || "?"}; decided: ${p.date?.slice(0, 10) ?? "unknown"}; started: ${p.first_date?.slice(0, 10) ?? "unknown"}${p.from_hint ? "; found by the user's hint" : ""}`,
296
+ ];
297
+ if (p.authors) lines.push(` authors: ${p.authors}`);
298
+ if (p.objectors) lines.push(` who objected: ${p.objectors}`);
299
+ if (p.touch_points.length) lines.push(` touch points: ${p.touch_points.join(", ")}`);
300
+ if (p.summary) lines.push(` proposal: ${clip(p.summary.replace(/\s+/g, " "), 800)}`);
301
+ lines.push(
302
+ ` ${p.enriched ? "why it was not accepted (AI summary of its thread)" : "objection / reason recorded"}: ${p.objection ? clip(p.objection.replace(/\s+/g, " "), 1500) : "(not recorded in the corpus)"}`,
303
+ );
304
+ if (p.key_quote) lines.push(` key sentence, verbatim from its thread: "${p.key_quote.replace(/\s+/g, " ")}"`);
305
+ return lines.join("\n");
306
+ })
307
+ .join("\n\n");
308
+ }