@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.
- package/README.md +90 -0
- package/bin/subsub-bench.js +19 -0
- package/bin/subsub.js +14 -0
- package/dist/args.js +67 -0
- package/dist/bench.js +534 -0
- package/dist/bridge.js +107 -0
- package/dist/cli.js +209 -0
- package/dist/config.js +144 -0
- package/dist/doctor.js +131 -0
- package/dist/init.js +215 -0
- package/dist/look.js +107 -0
- package/dist/paths.js +86 -0
- package/dist/preview.js +94 -0
- package/dist/profiles.js +79 -0
- package/dist/prompt.js +72 -0
- package/dist/roles.js +73 -0
- package/dist/subsub.js +414 -0
- package/docs/Subsub.md +126 -0
- package/extensions/subsub.ts +6 -0
- package/package.json +73 -0
- package/prompts/alert.md +4 -0
- package/prompts/clean-tags.md +4 -0
- package/prompts/gaps.md +5 -0
- package/prompts/import-queue.md +5 -0
- package/prompts/lit-note.md +5 -0
- package/prompts/manuscript.md +5 -0
- package/prompts/synthesis.md +5 -0
- package/prompts/tag-batch.md +5 -0
- package/roles/librarian.md +46 -0
- package/roles/researcher.md +37 -0
- package/scripts/build.mjs +14 -0
- package/scripts/make-themes.py +116 -0
- package/src/args.ts +64 -0
- package/src/bench.ts +580 -0
- package/src/bridge.ts +134 -0
- package/src/cli.ts +201 -0
- package/src/config.ts +191 -0
- package/src/doctor.ts +171 -0
- package/src/init.ts +252 -0
- package/src/look.ts +141 -0
- package/src/paths.ts +100 -0
- package/src/preview.ts +107 -0
- package/src/profiles.ts +99 -0
- package/src/prompt.ts +81 -0
- package/src/roles.ts +83 -0
- package/src/subsub.ts +434 -0
- package/templates/Zotero agent.md +87 -0
- package/templates/vocabularies/any-field.md +52 -0
- package/templates/vocabularies/health-informatics.md +88 -0
- package/templates/vocabularies/health-sciences.md +95 -0
- package/themes/subsub-glauca-dark.json +91 -0
- package/themes/subsub-glauca-light.json +91 -0
- package/themes/subsub-try-works-dark.json +91 -0
- 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
|
+
|