@tiagojct/subsub 0.5.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 (54) hide show
  1. package/README.md +90 -0
  2. package/bin/subsub-bench.js +19 -0
  3. package/bin/subsub.js +14 -0
  4. package/dist/args.js +67 -0
  5. package/dist/bench.js +534 -0
  6. package/dist/bridge.js +107 -0
  7. package/dist/cli.js +209 -0
  8. package/dist/config.js +144 -0
  9. package/dist/doctor.js +131 -0
  10. package/dist/init.js +215 -0
  11. package/dist/look.js +107 -0
  12. package/dist/paths.js +86 -0
  13. package/dist/preview.js +94 -0
  14. package/dist/profiles.js +79 -0
  15. package/dist/prompt.js +72 -0
  16. package/dist/roles.js +73 -0
  17. package/dist/subsub.js +414 -0
  18. package/docs/Subsub.md +126 -0
  19. package/extensions/subsub.ts +6 -0
  20. package/package.json +73 -0
  21. package/prompts/alert.md +4 -0
  22. package/prompts/clean-tags.md +4 -0
  23. package/prompts/gaps.md +5 -0
  24. package/prompts/import-queue.md +5 -0
  25. package/prompts/lit-note.md +5 -0
  26. package/prompts/manuscript.md +5 -0
  27. package/prompts/synthesis.md +5 -0
  28. package/prompts/tag-batch.md +5 -0
  29. package/roles/librarian.md +46 -0
  30. package/roles/researcher.md +37 -0
  31. package/scripts/build.mjs +14 -0
  32. package/scripts/make-themes.py +116 -0
  33. package/src/args.ts +64 -0
  34. package/src/bench.ts +580 -0
  35. package/src/bridge.ts +134 -0
  36. package/src/cli.ts +201 -0
  37. package/src/config.ts +191 -0
  38. package/src/doctor.ts +171 -0
  39. package/src/init.ts +252 -0
  40. package/src/look.ts +141 -0
  41. package/src/paths.ts +100 -0
  42. package/src/preview.ts +107 -0
  43. package/src/profiles.ts +99 -0
  44. package/src/prompt.ts +81 -0
  45. package/src/roles.ts +83 -0
  46. package/src/subsub.ts +434 -0
  47. package/templates/Zotero agent.md +87 -0
  48. package/templates/vocabularies/any-field.md +52 -0
  49. package/templates/vocabularies/health-informatics.md +88 -0
  50. package/templates/vocabularies/health-sciences.md +95 -0
  51. package/themes/subsub-glauca-dark.json +91 -0
  52. package/themes/subsub-glauca-light.json +91 -0
  53. package/themes/subsub-try-works-dark.json +91 -0
  54. package/themes/subsub-try-works-light.json +91 -0
package/src/bench.ts ADDED
@@ -0,0 +1,580 @@
1
+ /**
2
+ * subsub-bench: the same tasks for several models, against the real library,
3
+ * with no changes applied. See docs/Subsub.md ("Model test").
4
+ *
5
+ * subsub-bench prepare [--seed 7] pick the tasks (read-only)
6
+ * subsub-bench run [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
7
+ * subsub-bench score objective checks -> results.md
8
+ *
9
+ * Results go to ~/Projects/subsub/bench-results/<date>/ (or --dir). Each model gets
10
+ * an anonymous code; key.json maps codes to models. Library writes are recorded
11
+ * with the server preview and blocked (SUBSUB_BENCH_OUT, see subsub.ts); file
12
+ * writes are allowed only in the run folder.
13
+ */
14
+
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { expand, loadConfig, type Mode } from "./config.ts";
20
+ import { findUv } from "./subsub.ts";
21
+
22
+ export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
+
24
+ export const DEFAULT_MODELS: Record<Mode, string[]> = {
25
+ librarian: ["glm-5.3-flash", "deepseek-v4.1-flash", "qwen3.8-flash", "mimo-v2.6-flash", "minimax-m3", "mimo-v2.6-pro"],
26
+ researcher: ["mimo-v2.6-pro", "deepseek-v4-pro", "kimi-k2.6", "glm-5.3", "qwen3.7-plus", "minimax-m3"],
27
+ };
28
+
29
+ export const TASKS: Record<Mode, string[]> = {
30
+ librarian: ["tagging", "facet_fix", "import"],
31
+ researcher: ["lit_note", "synthesis", "search"],
32
+ };
33
+
34
+ export interface Tasks {
35
+ tagging: { keys: string[] };
36
+ facet_fix: Record<string, string[]>;
37
+ import: { in_library: { id: string; key: string; citekey: string } | null; new: Array<{ id: string; kind: string; title: string }> };
38
+ lit_note: { key: string; citekey: string; title: string } | null;
39
+ synthesis: { topic: string; description: string; keys: string[]; citekeys: string[] } | null;
40
+ search: { topic: string | null; description: string; year_from: number };
41
+ }
42
+
43
+ export interface IndexRow {
44
+ key: string;
45
+ citekey: string | null;
46
+ doi: string | null;
47
+ pmid: string | null;
48
+ title: string;
49
+ year: string;
50
+ }
51
+
52
+ // ---------------------------------------------------------------- prompts
53
+
54
+ function about(t: Tasks): string {
55
+ const s = t.synthesis;
56
+ if (!s) return "";
57
+ const d = s.description.replace(/\.$/, "").trim();
58
+ return d ? `${d} (${s.topic})` : s.topic;
59
+ }
60
+
61
+ export function promptFor(task: string, t: Tasks, code: string, out: string): string | undefined {
62
+ switch (task) {
63
+ case "tagging":
64
+ return (
65
+ `Model test. Do not change the library. Call zotero_bakeoff_items to get ${t.tagging.keys.length} items and the tag vocabulary. ` +
66
+ "For every item, propose topic/, method/ and type/ tags following the tag rules (1 to 4 topic tags, most specific first; " +
67
+ "method/ and type/ only when they apply; vocabulary tags only; no status/ tags). " +
68
+ `Then call zotero_bakeoff_submit once with label "${code}" and all proposals. Do not call other tools.`
69
+ );
70
+ case "facet_fix":
71
+ return "Find the items in my library that have no topic/ tag or no status/ tag, and tag them. Use status/to-read for status.";
72
+ case "import": {
73
+ const ids = [t.import.in_library?.id, ...t.import.new.map((n) => n.id)].filter(Boolean) as string[];
74
+ if (ids.length < 2) return undefined;
75
+ const order = [ids[1], ids[0], ...ids.slice(2)];
76
+ return `Import these into Zotero: ${order.join(", ")}.`;
77
+ }
78
+ case "lit_note":
79
+ return t.lit_note
80
+ ? `Make a literature note for ${t.lit_note.citekey}, following the literature-note format in the shared rules. Read the full text. ` +
81
+ `This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Resources/Zotero/, and do not attach anything to Zotero.`
82
+ : undefined;
83
+ case "synthesis":
84
+ return t.synthesis
85
+ ? `Write a synthesis of what my library says about ${about(t)}, following the synthesis format in the shared rules. ` +
86
+ `This is a model test: save it as ${join(out, "Synthesis.md")}.`
87
+ : undefined;
88
+ case "search":
89
+ return t.synthesis
90
+ ? `Find up to 5 important papers published from ${t.search.year_from} onwards on ${about(t)} that are not yet in my library. ` +
91
+ `This is a model test: do not queue them. Write the list to ${join(out, "Search.md")} with, for each paper, ` +
92
+ "the DOI or PMID, the reference, and one sentence on why it matters for my work."
93
+ : undefined;
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ // ---------------------------------------------------------------- RPC run
99
+
100
+ export interface RunSummary {
101
+ code: string;
102
+ role: Mode;
103
+ task: string;
104
+ seconds: number;
105
+ settled: boolean;
106
+ timedOut: boolean;
107
+ stats?: Record<string, any>;
108
+ finalText: string;
109
+ error?: string;
110
+ }
111
+
112
+ /** Events worth keeping (streaming deltas are dropped). */
113
+ const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end", "entry_appended", "agent_end", "agent_settled", "auto_retry_start", "extension_error"]);
114
+
115
+ export function runOne(opts: {
116
+ role: Mode;
117
+ task: string;
118
+ code: string;
119
+ model: string;
120
+ prompt: string;
121
+ out: string;
122
+ cwd: string;
123
+ configFile: string;
124
+ timeoutSec: number;
125
+ cli?: string;
126
+ extraEnv?: NodeJS.ProcessEnv;
127
+ provider?: string;
128
+ }): Promise<RunSummary> {
129
+ mkdirSync(opts.out, { recursive: true });
130
+ const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
131
+ if (opts.role === "librarian") args.push("--librarian");
132
+ const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
133
+ const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
134
+ const events: any[] = [];
135
+ let stderr = "";
136
+ let buf = "";
137
+ const t0 = Date.now();
138
+ return new Promise((resolveRun) => {
139
+ let settled = false;
140
+ let done = false;
141
+ let statsTimer: NodeJS.Timeout | undefined;
142
+ const send = (o: unknown) => proc.stdin.write(`${JSON.stringify(o)}\n`);
143
+ const finish = (extra: Partial<RunSummary>) => {
144
+ if (done) return;
145
+ done = true;
146
+ clearTimeout(timer);
147
+ if (statsTimer) clearTimeout(statsTimer);
148
+ proc.kill();
149
+ const texts = events
150
+ .filter((e) => e.type === "message_end" && e.message?.role === "assistant")
151
+ .map((e) => (e.message.content ?? []).filter((c: any) => c.type === "text").map((c: any) => c.text).join(""))
152
+ .filter((x: string) => x.trim());
153
+ writeFileSync(join(opts.out, "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n");
154
+ const summary: RunSummary = {
155
+ code: opts.code,
156
+ role: opts.role,
157
+ task: opts.task,
158
+ seconds: Math.round((Date.now() - t0) / 1000),
159
+ settled,
160
+ timedOut: false,
161
+ finalText: texts.at(-1) ?? "",
162
+ ...extra,
163
+ };
164
+ if (stderr.trim()) writeFileSync(join(opts.out, "stderr.txt"), stderr.slice(-20000));
165
+ writeFileSync(join(opts.out, "summary.json"), JSON.stringify(summary, null, 1));
166
+ resolveRun(summary);
167
+ };
168
+ const timer = setTimeout(() => {
169
+ send({ type: "abort" });
170
+ setTimeout(() => finish({ timedOut: true, error: "timeout" }), 3000);
171
+ }, opts.timeoutSec * 1000);
172
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
173
+ proc.on("exit", (code) => finish({ error: `pi exited (${code}) before the task ended` }));
174
+ proc.stdout.on("data", (d) => {
175
+ buf += d.toString();
176
+ let i: number;
177
+ while ((i = buf.indexOf("\n")) >= 0) {
178
+ const line = buf.slice(0, i).replace(/\r$/, "");
179
+ buf = buf.slice(i + 1);
180
+ if (!line.trim()) continue;
181
+ let ev: any;
182
+ try {
183
+ ev = JSON.parse(line);
184
+ } catch {
185
+ continue;
186
+ }
187
+ if (ev.type === "extension_ui_request" && ["confirm", "select", "input", "editor"].includes(ev.method)) {
188
+ send({ type: "extension_ui_response", id: ev.id, ...(ev.method === "confirm" ? { confirmed: false } : { cancelled: true }) });
189
+ events.push({ type: "ui_declined", method: ev.method, title: ev.title, message: ev.message });
190
+ continue;
191
+ }
192
+ if (ev.type === "response" && ev.command === "get_session_stats") {
193
+ finish({ stats: ev.data });
194
+ continue;
195
+ }
196
+ if (ev.type === "response" && ev.command === "prompt" && ev.success === false) {
197
+ finish({ error: ev.error ?? "prompt refused" });
198
+ continue;
199
+ }
200
+ if (KEEP.has(ev.type)) events.push(ev);
201
+ if (ev.type === "agent_settled" && !settled) {
202
+ settled = true;
203
+ send({ id: "stats", type: "get_session_stats" });
204
+ statsTimer = setTimeout(() => finish({}), 10000);
205
+ }
206
+ }
207
+ });
208
+ send({ id: "p1", type: "prompt", message: opts.prompt });
209
+ });
210
+ }
211
+
212
+ // ---------------------------------------------------------------- scoring helpers
213
+
214
+ export function readEvents(dir: string): any[] {
215
+ const f = join(dir, "events.jsonl");
216
+ if (!existsSync(f)) return [];
217
+ return readFileSync(f, "utf8")
218
+ .split("\n")
219
+ .filter((l) => l.trim())
220
+ .map((l) => JSON.parse(l));
221
+ }
222
+
223
+ function resultText(ev: any): string {
224
+ return (ev.result?.content ?? []).map((c: any) => c.text ?? "").join("\n");
225
+ }
226
+
227
+ export function toolCalls(events: any[]): Array<{ name: string; args: any; result: string; isError: boolean }> {
228
+ const starts = new Map<string, any>();
229
+ const out: Array<{ name: string; args: any; result: string; isError: boolean }> = [];
230
+ for (const e of events) {
231
+ if (e.type === "tool_execution_start") starts.set(e.toolCallId, e);
232
+ if (e.type === "tool_execution_end") {
233
+ const s = starts.get(e.toolCallId);
234
+ out.push({ name: e.toolName, args: s?.args ?? {}, result: resultText(e), isError: !!e.isError });
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ export function benchEntries(events: any[]): any[] {
241
+ return events.filter((e) => e.type === "entry_appended" && e.entry?.customType === "subsub-bench").map((e) => e.entry.data);
242
+ }
243
+
244
+ export function prf(pred: Set<string>, gold: Set<string>) {
245
+ let tp = 0;
246
+ for (const t of pred) if (gold.has(t)) tp++;
247
+ const fp = pred.size - tp;
248
+ const fn = gold.size - tp;
249
+ return { tp, fp, fn };
250
+ }
251
+
252
+ function f1(tp: number, fp: number, fn: number) {
253
+ const p = tp + fp ? tp / (tp + fp) : 0;
254
+ const r = tp + fn ? tp / (tp + fn) : 0;
255
+ return { precision: +p.toFixed(3), recall: +r.toFixed(3), f1: p + r ? +((2 * p * r) / (p + r)).toFixed(3) : 0 };
256
+ }
257
+
258
+ const SCORED = /^(topic|method|type)\//;
259
+
260
+ export function scoreTags(proposals: Record<string, string[]>, gold: Record<string, string[]>) {
261
+ let tp = 0,
262
+ fp = 0,
263
+ fn = 0,
264
+ exact = 0,
265
+ missing = 0;
266
+ for (const [key, g] of Object.entries(gold)) {
267
+ const goldSet = new Set(g.filter((t) => SCORED.test(t)));
268
+ const p = proposals[key];
269
+ if (!p) {
270
+ missing++;
271
+ fn += goldSet.size;
272
+ continue;
273
+ }
274
+ const predSet = new Set(p.filter((t) => SCORED.test(t)));
275
+ const s = prf(predSet, goldSet);
276
+ tp += s.tp;
277
+ fp += s.fp;
278
+ fn += s.fn;
279
+ if (s.fp === 0 && s.fn === 0) exact++;
280
+ }
281
+ return { items: Object.keys(gold).length, ...f1(tp, fp, fn), edits: fp + fn, exact, missing };
282
+ }
283
+
284
+ export const DOI_RE = /\b10\.\d{4,9}\/[^\s"'<>\]|,;]+/gi;
285
+ export const PMID_RE = /\bPMID[:\s]*([0-9]{5,9})\b/gi;
286
+
287
+ export function normDoi(d: string): string {
288
+ let x = d.toLowerCase().replace(/[.,;:]+$/, "");
289
+ const count = (c: string) => x.split(c).length - 1;
290
+ while (x.endsWith(")") && count(")") > count("(")) x = x.slice(0, -1).replace(/[.,;:]+$/, "");
291
+ return x;
292
+ }
293
+
294
+ export function citekeysIn(text: string): string[] {
295
+ const out = new Set<string>();
296
+ for (const m of text.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)) out.add(m[1].trim());
297
+ for (const m of text.matchAll(/(?:^|[\s\[(;])@([A-Za-z][\w:-]*\d{4}[a-z]?)/g)) out.add(m[1]);
298
+ return [...out];
299
+ }
300
+
301
+ // ---------------------------------------------------------------- score one run
302
+
303
+ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[], blindRef: Record<string, string[]>): Record<string, any> {
304
+ const events = readEvents(dir);
305
+ const calls = toolCalls(events);
306
+ const entries = benchEntries(events);
307
+ const summary = existsSync(join(dir, "summary.json")) ? JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")) : {};
308
+ const base: Record<string, any> = {
309
+ ok: !summary.error,
310
+ error: summary.error,
311
+ seconds: summary.seconds,
312
+ tool_calls: calls.length,
313
+ tool_errors: calls.filter((c) => c.isError && !/Model test:/.test(c.result)).length,
314
+ wrong_mode: entries.filter((e) => e.kind === "wrong_mode").length,
315
+ path_blocked: entries.filter((e) => e.kind === "path_blocked").length,
316
+ tokens_in: summary.stats?.tokens ? summary.stats.tokens.input + summary.stats.tokens.cacheRead : undefined,
317
+ tokens_out: summary.stats?.tokens?.output,
318
+ cost: summary.stats?.cost,
319
+ };
320
+ const citekeys = new Set(index.map((r) => r.citekey).filter(Boolean) as string[]);
321
+ const libDois = new Set(index.map((r) => (r.doi ? normDoi(r.doi) : "")).filter(Boolean));
322
+ const libPmids = new Set(index.map((r) => r.pmid).filter(Boolean) as string[]);
323
+ const readFile = (name: string) => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), "utf8") : undefined);
324
+
325
+ if (task === "tagging") {
326
+ const sub = calls.filter((c) => c.name === "zotero_bakeoff_submit").at(-1);
327
+ const proposals: Record<string, string[]> = {};
328
+ for (const p of sub?.args?.proposals ?? []) if (p?.key) proposals[p.key] = (p.tags ?? []).map((x: string) => String(x).trim());
329
+ let invalid = 0;
330
+ try {
331
+ invalid = (JSON.parse(sub?.result ?? "{}").tags_not_in_vocabulary ?? []).length;
332
+ } catch {
333
+ invalid = 0;
334
+ }
335
+ const peeked = calls.filter((c) => ["zotero_get_item", "zotero_find_items"].includes(c.name)).length;
336
+ const status = Object.values(proposals).flat().filter((x) => x.startsWith("status/")).length;
337
+ return {
338
+ ...base,
339
+ submitted: !!sub,
340
+ items_proposed: Object.keys(proposals).length,
341
+ invalid_tags: invalid,
342
+ status_tags: status,
343
+ peeked,
344
+ vs_reference: Object.keys(blindRef).length ? scoreTags(proposals, blindRef) : undefined,
345
+ proposals,
346
+ };
347
+ }
348
+ if (task === "facet_fix") {
349
+ const changes = entries.filter((e) => e.kind === "change" && e.tool === "zotero_tag_items");
350
+ const touched = new Map<string, string[]>();
351
+ for (const c of changes) for (const ch of c.input?.changes ?? []) touched.set(ch.key, [...(touched.get(ch.key) ?? []), ...(ch.add ?? [])]);
352
+ const missTopic = new Set(t.facet_fix.missing_topic ?? []);
353
+ const missStatus = new Set(t.facet_fix.missing_status ?? []);
354
+ const expected = new Set([...missTopic, ...missStatus]);
355
+ let fixed = 0;
356
+ for (const k of expected) {
357
+ const add = touched.get(k) ?? [];
358
+ const okTopic = !missTopic.has(k) || add.some((x) => x.startsWith("topic/"));
359
+ const okStatus = !missStatus.has(k) || add.some((x) => x.startsWith("status/"));
360
+ if (okTopic && okStatus) fixed++;
361
+ }
362
+ const previewErrors = changes.map((c) => JSON.stringify(c.preview?.errors ?? c.preview?.error ?? [])).filter((x) => x !== "[]" && x !== '""');
363
+ return {
364
+ ...base,
365
+ expected: expected.size,
366
+ fixed,
367
+ extra_items: [...touched.keys()].filter((k) => !expected.has(k)).length,
368
+ change_calls: changes.length,
369
+ preview_errors: previewErrors.length,
370
+ proposed: Object.fromEntries(touched),
371
+ };
372
+ }
373
+ if (task === "import") {
374
+ const changes = entries.filter((e) => e.kind === "change" && e.tool === "zotero_import_identifiers");
375
+ const want = [t.import.in_library?.id, ...t.import.new.map((n) => n.id)].filter(Boolean).map((x) => normId(x!));
376
+ const sent = new Set(changes.flatMap((c) => (c.input?.identifiers ?? []).map((x: string) => normId(x))));
377
+ const last = changes.at(-1)?.preview ?? {};
378
+ const final = summary.finalText ?? "";
379
+ const dup = t.import.in_library;
380
+ const reported = !!dup && (final.includes(dup.citekey) || final.includes(dup.key) || /already|duplicate|exists/i.test(final));
381
+ return {
382
+ ...base,
383
+ ids_expected: want.length,
384
+ ids_sent: want.filter((w) => sent.has(w)).length,
385
+ extra_ids: [...sent].filter((s) => !want.includes(s)).length,
386
+ change_calls: changes.length,
387
+ would_import: Array.isArray(last.would_import) ? last.would_import.length : undefined,
388
+ duplicate_reported: reported,
389
+ };
390
+ }
391
+ if (task === "lit_note" || task === "synthesis") {
392
+ const file = task === "lit_note" ? `${t.lit_note?.citekey}.md` : "Synthesis.md";
393
+ const text = readFile(file);
394
+ const cited = text ? citekeysIn(text) : [];
395
+ const citedKeys = cited.filter((c) => /\d{4}[a-z]?$/.test(c));
396
+ const res: Record<string, any> = {
397
+ ...base,
398
+ written: !!text,
399
+ words: text ? text.split(/\s+/).filter(Boolean).length : 0,
400
+ citekeys_cited: citedKeys.length,
401
+ citekeys_unknown: citedKeys.filter((c) => !citekeys.has(c)),
402
+ };
403
+ if (task === "lit_note") {
404
+ res.read_fulltext = calls.some((c) => c.name === "zotero_get_fulltext" && !c.isError);
405
+ res.cites_itself = !!t.lit_note && citedKeys.includes(t.lit_note.citekey);
406
+ } else if (t.synthesis) {
407
+ const topicKeys = new Set(t.synthesis.citekeys.filter(Boolean));
408
+ res.topic_items = topicKeys.size;
409
+ res.topic_items_cited = citedKeys.filter((c) => topicKeys.has(c)).length;
410
+ }
411
+ return res;
412
+ }
413
+ if (task === "search") {
414
+ const text = readFile("Search.md") ?? "";
415
+ const seen = calls.map((c) => c.result).join("\n").toLowerCase();
416
+ const dois = [...new Set([...text.matchAll(DOI_RE)].map((m) => normDoi(m[0])))];
417
+ const pmids = [...new Set([...text.matchAll(PMID_RE)].map((m) => m[1]))];
418
+ return {
419
+ ...base,
420
+ written: !!text,
421
+ dois: dois.length,
422
+ pmids: pmids.length,
423
+ ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
424
+ already_in_library: [...dois.filter((d) => libDois.has(d)), ...pmids.filter((p) => libPmids.has(p))],
425
+ queued: entries.filter((e) => e.tool === "scholar_queue_imports").length,
426
+ searches: calls.filter((c) => c.name.startsWith("scholar_search")).length,
427
+ };
428
+ }
429
+ return base;
430
+ }
431
+
432
+ export function normId(x: string): string {
433
+ const s = x.trim().toLowerCase().replace(/^https?:\/\/(dx\.)?doi\.org\//, "").replace(/^doi:\s*/, "");
434
+ const pm = s.match(/^pmid[:\s]*(\d+)$/);
435
+ return pm ? `pmid:${pm[1]}` : s;
436
+ }
437
+
438
+ // ---------------------------------------------------------------- report
439
+
440
+ function cell(v: unknown): string {
441
+ if (v === undefined || v === null) return "n/a";
442
+ if (Array.isArray(v)) return String(v.length);
443
+ if (typeof v === "number") return Number.isInteger(v) ? String(v) : v.toFixed(3);
444
+ return String(v);
445
+ }
446
+
447
+ export function renderResults(rows: Array<{ model: string; role: Mode; task: string; s: Record<string, any> }>): string {
448
+ const out: string[] = [`Model test results (${new Date().toISOString().slice(0, 10)}). Objective checks only; the quality judgement is separate.`, ""];
449
+ const cols: Record<string, string[]> = {
450
+ tagging: ["submitted", "items_proposed", "vs_reference.f1", "vs_reference.exact", "invalid_tags", "status_tags", "peeked"],
451
+ facet_fix: ["expected", "fixed", "extra_items", "change_calls", "preview_errors"],
452
+ import: ["ids_expected", "ids_sent", "extra_ids", "would_import", "duplicate_reported"],
453
+ lit_note: ["written", "words", "read_fulltext", "cites_itself", "citekeys_unknown"],
454
+ synthesis: ["written", "words", "topic_items", "topic_items_cited", "citekeys_unknown"],
455
+ search: ["written", "dois", "pmids", "ungrounded", "already_in_library", "queued", "searches"],
456
+ };
457
+ const common = ["seconds", "tool_calls", "tool_errors", "wrong_mode", "tokens_in", "tokens_out", "cost", "error"];
458
+ for (const task of Object.keys(cols)) {
459
+ const rs = rows.filter((r) => r.task === task);
460
+ if (!rs.length) continue;
461
+ const head = ["model", ...cols[task], ...common];
462
+ out.push(`## ${task}`, "", `| ${head.join(" | ")} |`, `|${head.map(() => "---").join("|")}|`);
463
+ for (const r of rs) {
464
+ const get = (k: string) => k.split(".").reduce((o: any, p) => (o == null ? undefined : o[p]), r.s);
465
+ out.push(`| ${[r.model, ...cols[task].map((c) => cell(get(c))), ...common.map((c) => cell(get(c)))].join(" | ")} |`);
466
+ }
467
+ out.push("");
468
+ }
469
+ return out.join("\n");
470
+ }
471
+
472
+ // ---------------------------------------------------------------- CLI
473
+
474
+ function arg(argv: string[], name: string): string | undefined {
475
+ const i = argv.indexOf(`--${name}`);
476
+ return i >= 0 ? argv[i + 1] : undefined;
477
+ }
478
+
479
+ export function benchDir(argv: string[]): string {
480
+ const d = arg(argv, "dir");
481
+ if (d) return expand(d);
482
+ const root = join(PACKAGE_DIR, "bench-results");
483
+ if (argv[0] !== "prepare" && existsSync(root)) {
484
+ const dates = readdirSync(root).filter((x) => /^\d{4}-\d{2}-\d{2}/.test(x)).sort();
485
+ if (dates.length) return join(root, dates.at(-1)!);
486
+ }
487
+ return join(root, new Date().toISOString().slice(0, 10));
488
+ }
489
+
490
+ function codesFor(models: string[], dir: string): Record<string, string> {
491
+ const keyFile = join(dir, "key.json");
492
+ const key: Record<string, string> = existsSync(keyFile) ? JSON.parse(readFileSync(keyFile, "utf8")) : {};
493
+ const byModel = new Map(Object.entries(key).map(([c, m]) => [m, c]));
494
+ for (const m of models) {
495
+ if (byModel.has(m)) continue;
496
+ let c: string;
497
+ do c = `M${Math.floor(100 + Math.random() * 900)}`;
498
+ while (key[c]);
499
+ key[c] = m;
500
+ byModel.set(m, c);
501
+ }
502
+ writeFileSync(keyFile, JSON.stringify(key, null, 1));
503
+ return Object.fromEntries(byModel);
504
+ }
505
+
506
+ async function pool<T>(items: T[], n: number, fn: (x: T) => Promise<void>): Promise<void> {
507
+ const queue = [...items];
508
+ await Promise.all(Array.from({ length: Math.max(1, n) }, async () => {
509
+ while (queue.length) await fn(queue.shift()!);
510
+ }));
511
+ }
512
+
513
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
514
+ const cmd = argv[0];
515
+ const cfg = loadConfig();
516
+ const dir = benchDir(argv);
517
+ if (cmd === "prepare") {
518
+ mkdirSync(dir, { recursive: true });
519
+ const env = { ...process.env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
520
+ const r = spawnSync(findUv(cfg), ["run", "--directory", cfg.serverDir, "zotero-bench", "prepare", "--out", dir, "--seed", arg(argv, "seed") ?? "7"], { env, stdio: "inherit" });
521
+ process.exitCode = r.status ?? 1;
522
+ return;
523
+ }
524
+ if (cmd === "run") {
525
+ const tasksFile = join(dir, "tasks.json");
526
+ if (!existsSync(tasksFile)) throw new Error(`No tasks in ${dir}. Run: subsub-bench prepare`);
527
+ if (!cfg.vault) throw new Error("No vault configured (ZOTERO_VAULT).");
528
+ const t: Tasks = JSON.parse(readFileSync(tasksFile, "utf8"));
529
+ const lists: Record<Mode, string[]> = {
530
+ librarian: arg(argv, "librarian")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.librarian,
531
+ researcher: arg(argv, "researcher")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.researcher,
532
+ };
533
+ const only = arg(argv, "only")?.split(",");
534
+ const codes = codesFor([...new Set([...lists.librarian, ...lists.researcher])], dir);
535
+ const userCfgFile = expand(process.env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
536
+ const userCfg = existsSync(userCfgFile) ? JSON.parse(readFileSync(userCfgFile, "utf8")) : {};
537
+ const cfgDir = join(dir, ".configs");
538
+ mkdirSync(cfgDir, { recursive: true });
539
+ const jobs: Array<{ role: Mode; task: string; model: string; code: string }> = [];
540
+ for (const role of ["librarian", "researcher"] as Mode[])
541
+ for (const model of lists[role])
542
+ for (const task of TASKS[role]) if (!only || only.includes(task)) jobs.push({ role, task, model, code: codes[model] });
543
+ const timeoutSec = Number(arg(argv, "timeout") ?? 900);
544
+ let n = 0;
545
+ await pool(jobs, Number(arg(argv, "parallel") ?? 3), async (j) => {
546
+ const out = join(dir, "runs", j.code, j.task);
547
+ if (existsSync(join(out, "summary.json")) && !argv.includes("--again")) return;
548
+ const prompt = promptFor(j.task, t, j.code, out);
549
+ if (!prompt) return;
550
+ const cf = join(cfgDir, `${j.code}.json`);
551
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` } }));
552
+ const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
553
+ n++;
554
+ console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
555
+ });
556
+ console.log(`Finished. Results in ${dir}. Next: subsub-bench score`);
557
+ return;
558
+ }
559
+ if (cmd === "score") {
560
+ const t: Tasks = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf8"));
561
+ const index: IndexRow[] = JSON.parse(readFileSync(join(dir, "library-index.json"), "utf8"));
562
+ const key: Record<string, string> = JSON.parse(readFileSync(join(dir, "key.json"), "utf8"));
563
+ const blind = existsSync(join(dir, "reference.json")) ? JSON.parse(readFileSync(join(dir, "reference.json"), "utf8")) : {};
564
+ const rows: Array<{ model: string; role: Mode; task: string; s: Record<string, any> }> = [];
565
+ for (const code of Object.keys(key).sort()) {
566
+ for (const role of ["librarian", "researcher"] as Mode[])
567
+ for (const task of TASKS[role]) {
568
+ const d = join(dir, "runs", code, task);
569
+ if (!existsSync(join(d, "summary.json"))) continue;
570
+ rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, blind) });
571
+ }
572
+ }
573
+ writeFileSync(join(dir, "results.json"), JSON.stringify(rows, null, 1));
574
+ writeFileSync(join(dir, "results.md"), renderResults(rows));
575
+ console.log(`Wrote ${join(dir, "results.md")}`);
576
+ return;
577
+ }
578
+ console.log("Usage: subsub-bench prepare | run [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
579
+ }
580
+