@tiagojct/subsub 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/web.ts CHANGED
@@ -22,7 +22,7 @@ import { homedir } from "node:os";
22
22
  import { extname, join, normalize, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
25
+ import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
27
  import { obsidianRoot } from "./layout.ts";
28
28
  import { realish, within } from "./paths.ts";
@@ -336,7 +336,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
336
336
  env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
337
337
 
338
338
  const childEnv: NodeJS.ProcessEnv = { ...env, SUBSUB_WEB: "1" };
339
- const baseArgs = [bin, "--mode", "rpc", "--here", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
339
+ const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
340
340
  const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
341
341
 
342
342
  // ---- state shared with the pages
@@ -346,8 +346,20 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
346
346
  let busy = false;
347
347
  let lastSeen = Date.now();
348
348
  let restarting = false;
349
+ let switching = false; // a restart for a new model, add-on or key is under way
350
+ let lastSession: string | undefined;
349
351
  let refreshAfterRun = false;
350
352
 
353
+ /** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
354
+ function modeArgs(): string[] {
355
+ const shown = (subsubState as { mode?: string } | null)?.mode;
356
+ if (shown === "librarian" || shown === "researcher") return [`--${shown}`];
357
+ return opts.librarian ? ["--librarian"] : [];
358
+ }
359
+ function startAgent(): void {
360
+ agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
361
+ }
362
+
351
363
  function broadcast(ev: unknown): void {
352
364
  const data = `data: ${JSON.stringify(ev)}\n\n`;
353
365
  for (const c of clients) c.write(data);
@@ -384,12 +396,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
384
396
  agent.onExit = (code) => {
385
397
  if (restarting) return;
386
398
  busy = false;
399
+ for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
387
400
  dialogs.clear();
388
401
  broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
389
402
  log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
390
403
  };
391
404
 
392
- agent.start();
405
+ startAgent();
393
406
 
394
407
  async function snapshot(): Promise<Record<string, unknown>> {
395
408
  const out: Record<string, unknown> = {
@@ -403,6 +416,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
403
416
  };
404
417
  try {
405
418
  out.session = await agent.request({ type: "get_state" });
419
+ lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
406
420
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
407
421
  out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
408
422
  } catch (err) {
@@ -422,11 +436,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
422
436
  }
423
437
 
424
438
  async function restartAgent(): Promise<void> {
425
- let sessionFile: string | undefined;
426
439
  try {
427
- sessionFile = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
440
+ lastSession = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile ?? lastSession;
428
441
  } catch {
429
- /* start fresh */
442
+ /* keep the last one known */
430
443
  }
431
444
  restarting = true;
432
445
  for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
@@ -434,10 +447,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
434
447
  await agent.stop();
435
448
  restarting = false;
436
449
  busy = false;
437
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
450
+ startAgent();
438
451
  broadcast(await snapshot());
439
452
  }
440
453
 
454
+ /** Run a change that restarts pi, one at a time. */
455
+ async function withRestart(res: ServerResponse, change: () => Promise<void> | void): Promise<void> {
456
+ if (busy || switching) return send(res, 409, { error: "busy" });
457
+ switching = true;
458
+ try {
459
+ await change();
460
+ await restartAgent();
461
+ } finally {
462
+ switching = false;
463
+ }
464
+ return send(res, 200, { ok: true });
465
+ }
466
+
441
467
  // ---- idle stop
442
468
  let closing = false;
443
469
  const idleTimer = setInterval(() => {
@@ -565,40 +591,43 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
565
591
  return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
566
592
  }
567
593
  case "POST /api/model": {
568
- if (busy) return send(res, 409, { error: "busy" });
594
+ if (busy || switching) return send(res, 409, { error: "busy" });
569
595
  const body = await readJson(req);
570
596
  const spec = `${String(body.provider)}/${String(body.id)}`;
571
597
  // Remember the model for the current mode (or both), then restart pi so the extension reads it.
572
598
  const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
573
- const models: Record<string, string> = { ...(loadConfig(env).models ?? {}), [mode]: spec };
599
+ // Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
600
+ const raw = readConfigFile(configPath(env));
601
+ const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
602
+ const models: Record<string, string> = { ...saved, [mode]: spec };
574
603
  if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
575
604
  saveConfig({ models, model: undefined }, env);
576
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
577
- await restartAgent();
578
- return send(res, 200, { ok: true });
605
+ return withRestart(res, async () => {
606
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
607
+ });
579
608
  }
580
609
  case "POST /api/addons": {
581
610
  // Turn an add-on on or off, then restart pi so the extension starts or stops its server.
582
- if (busy) return send(res, 409, { error: "busy" });
611
+ if (busy || switching) return send(res, 409, { error: "busy" });
583
612
  const body = await readJson(req);
584
613
  if (typeof body.starbuck !== "boolean") return send(res, 400, { error: "starbuck must be true or false" });
585
- saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
586
- await restartAgent();
587
- return send(res, 200, { ok: true });
614
+ return withRestart(res, () => {
615
+ saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
616
+ });
588
617
  }
589
618
  case "POST /api/restart":
590
- if (agent.proc) return send(res, 200, { ok: true });
591
- agent.start();
619
+ if (agent.proc || switching) return send(res, 200, { ok: true });
620
+ startAgent();
592
621
  broadcast(await snapshot());
593
622
  return send(res, 200, { ok: true });
594
623
  case "POST /api/login": {
595
- if (busy) return send(res, 409, { error: "busy" });
624
+ if (busy || switching) return send(res, 409, { error: "busy" });
596
625
  const body = await readJson(req);
597
- saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
598
- const current = loadConfig(env);
599
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
600
- await restartAgent();
601
- return send(res, 200, { ok: true });
626
+ return withRestart(res, () => {
627
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
628
+ const current = loadConfig(env);
629
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
630
+ });
602
631
  }
603
632
  case "POST /api/open": {
604
633
  const body = await readJson(req);
package/web/app.js CHANGED
@@ -650,6 +650,7 @@ function onEvent(ev) {
650
650
  setBusy(!!ev.busy);
651
651
  dialogs.length = 0;
652
652
  for (const d of ev.dialogs ?? []) dialogs.push(d);
653
+ dropStaleDialog();
653
654
  nextDialog();
654
655
  loadSessions();
655
656
  break;
@@ -743,6 +744,8 @@ function onEvent(ev) {
743
744
  break;
744
745
  case "agent_exit":
745
746
  setBusy(false);
747
+ dialogs.length = 0;
748
+ dropStaleDialog();
746
749
  log.append(
747
750
  el(
748
751
  "div",
@@ -776,6 +779,7 @@ function dialogTitle(d) {
776
779
  if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
777
780
  if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
778
781
  if (raw === "undo?") return t("undo");
782
+ if ((m = raw.match(/^write the Starbuck report to (.+)\?$/))) return t("starbuckReport", { x: m[1] });
779
783
  if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
780
784
  return raw;
781
785
  }
@@ -797,6 +801,14 @@ function paintPreview(pre, text) {
797
801
  }
798
802
  }
799
803
 
804
+ /** Close the dialog on screen if Sub-Sub no longer waits for it (pi restarted, or another tab answered). */
805
+ function dropStaleDialog() {
806
+ if (shownDialog && !dialogs.some((d) => d.id === shownDialog.id)) {
807
+ shownDialog = null;
808
+ if ($("dialog").open) $("dialog").close();
809
+ }
810
+ }
811
+
800
812
  function nextDialog() {
801
813
  if (shownDialog || !dialogs.length) return;
802
814
  const d = dialogs[0];
@@ -821,6 +833,7 @@ function nextDialog() {
821
833
  note(err.message, "error");
822
834
  }
823
835
  };
836
+ $("dialog-form").onsubmit = (e) => e.preventDefault();
824
837
  if (d.method === "confirm") {
825
838
  buttons.append(
826
839
  el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
@@ -834,14 +847,25 @@ function nextDialog() {
834
847
  const field = d.method === "editor" ? el("textarea", {}) : el("input", { type: "text", placeholder: d.placeholder ?? "" });
835
848
  field.value = d.prefill ?? "";
836
849
  body.append(field);
850
+ $("dialog-form").onsubmit = (e) => {
851
+ e.preventDefault();
852
+ answer({ value: field.value });
853
+ };
837
854
  buttons.append(
838
855
  el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }),
839
856
  el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
840
857
  );
841
858
  }
859
+ // Keys typed just before the dialog opened (the user was writing a message) must not answer it.
860
+ const openedAt = performance.now();
861
+ const yesKeys = lang === "pt" ? ["s", "S", "y", "Y"] : ["y", "Y"];
842
862
  dlg.onkeydown = (e) => {
843
863
  if (d.method === "confirm") {
844
- if (e.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
864
+ if (e.repeat || performance.now() - openedAt < 700) {
865
+ if (!e.metaKey && !e.ctrlKey && e.key.length === 1) e.preventDefault();
866
+ return;
867
+ }
868
+ if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
845
869
  e.preventDefault();
846
870
  answer({ confirmed: true });
847
871
  } else if (e.key === "n" || e.key === "N") {
@@ -867,6 +891,9 @@ function showModels() {
867
891
 
868
892
  async function openModels() {
869
893
  const dlg = $("model-dialog");
894
+ // Name the mode the choice is saved for; "both modes" is a choice for this time only.
895
+ dlg.querySelector("h2").textContent = t(mode() === "librarian" ? "modelTitleLibrarian" : "modelTitleResearcher");
896
+ $("model-both").checked = false;
870
897
  const ul = $("models");
871
898
  ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
872
899
  const sel = $("login-provider");
package/web/i18n.js CHANGED
@@ -21,6 +21,7 @@ export const STRINGS = {
21
21
  run: "Run {x}?",
22
22
  undo: "Undo this change?",
23
23
  write: "Allow writing to {x}?",
24
+ starbuckReport: "Write the Starbuck report to {x}?",
24
25
  approveHint: "Nothing changes until you select Yes.",
25
26
  zoteroChecking: "Zotero: checking",
26
27
  zoteroDown: "Zotero is not running",
@@ -39,6 +40,8 @@ export const STRINGS = {
39
40
  connectModel: "Connect a model to start.",
40
41
  connect: "Connect",
41
42
  modelTitle: "Model",
43
+ modelTitleResearcher: "Model for the Researcher",
44
+ modelTitleLibrarian: "Model for the Librarian",
42
45
  chooseModel: "Choose a model",
43
46
  noModels: "No models yet. Connect a provider below.",
44
47
  connectProvider: "Connect a provider",
@@ -110,6 +113,7 @@ export const STRINGS = {
110
113
  run: "Executar {x}?",
111
114
  undo: "Desfazer esta alteração?",
112
115
  write: "Permitir escrever em {x}?",
116
+ starbuckReport: "Escrever o relatório do Starbuck em {x}?",
113
117
  approveHint: "Nada muda até selecionar Sim.",
114
118
  zoteroChecking: "Zotero: a verificar",
115
119
  zoteroDown: "O Zotero não está aberto",
@@ -128,6 +132,8 @@ export const STRINGS = {
128
132
  connectModel: "Ligue um modelo para começar.",
129
133
  connect: "Ligar",
130
134
  modelTitle: "Modelo",
135
+ modelTitleResearcher: "Modelo do Investigador",
136
+ modelTitleLibrarian: "Modelo do Bibliotecário",
131
137
  chooseModel: "Escolha um modelo",
132
138
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
133
139
  connectProvider: "Ligar um fornecedor",
@@ -161,7 +167,7 @@ export const STRINGS = {
161
167
  searchConversations: "Procurar conversas",
162
168
  export: "Exportar",
163
169
  exportTitle: "Exportar conversa",
164
- confirmShortcutHint: "Y para aprovar, N para recusar",
170
+ confirmShortcutHint: "S para aprovar, N para recusar",
165
171
  starbuck: "Verificação de referências (Starbuck)",
166
172
  starbuckHint: "Verifica se as obras citadas existem, correspondem à citação e não foram retratadas. O Sub-Sub reinicia quando muda esta opção.",
167
173
  starbuckOn: "A verificação de referências está ligada. Use /verify, ou peça ao investigador que verifique as referências de um manuscrito.",
package/docs/Subsub.md DELETED
@@ -1,140 +0,0 @@
1
- Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses the Python servers of zotero-local-mcp for the library: previews, version checks, journal and undo, the tag vocabulary.
2
-
3
- - Code: ~/Projects/subsub (git). Servers: ~/Projects/zotero-local-mcp
4
- - Shared rules: [[Zotero agent]]. Tag vocabulary: [[Zotero tags]]
5
- - Settings: ~/.config/subsub/config.json (written by `subsub init`) and ~/.config/zotero-local-mcp/env (also read by the weekly alerts in launchd)
6
- - Sub-Sub folder: ~/Obsidian/Powerslave/Sub-Sub, with Inbox/, Literature/, Syntheses/, Research/ and Zotero/ (tag list, note formats, alert searches). Sub-Sub writes only there without asking.
7
- - Models: Librarian opencode-go/mimo-v2.6-flash, Researcher opencode-go/mimo-v2.6-pro
8
- - Public docs: the README in ~/Projects/subsub (source for subsub.tiagojacinto.eu)
9
-
10
- ## Safety
11
-
12
- - Approval is enforced in code. Before any library change, Sub-Sub asks the server for the preview and shows it in a dialog. The change runs only after you select Yes. The model cannot skip this.
13
- - The model does not need to do a dry run first. You see one dialog per change.
14
- - Library tools run one at a time, so each preview includes the effect of earlier changes in the same step.
15
- - The Librarian has no search tools. The Researcher has all tools; every library change still needs a Yes.
16
- - bash is off. File writes outside the Sub-Sub folder, and writes to the rule files, config folders and code folders, need a Yes.
17
-
18
- ## Install
19
-
20
- 1. Make sure that Node is version 22.19 or later: type `node --version`.
21
- 2. Type `cd ~/Projects/subsub && npm install --omit=dev`.
22
- 3. Type `npm link`. This adds the `subsub` command.
23
- 4. Optional: type `pi remove ~/Projects/subsub`. Then plain `pi` stays a plain coding agent without Sub-Sub.
24
- 5. Type `subsub`. If pi has no OpenCode Go key yet, type `/login`, select OpenCode Zen and Go, and paste the key.
25
- 6. Type `/subsub`. Make sure that the result shows "Zotero: reachable" and the model for the mode (mimo-v2.6-pro in the Researcher).
26
- 7. Type `subsub init` once. Keep the Sub-Sub folder, the tag list, the email and the models. For the profile, select Editor.
27
- 8. Type `subsub doctor`. Make sure that no line shows FIX.
28
-
29
- If you change the Node version with fnm, do step 3 again.
30
-
31
- ## Use
32
-
33
- 1. Start Zotero.
34
- 2. Type `subsub`. From the home folder, Sub-Sub starts in the Sub-Sub folder. From a different folder (for example a manuscript folder), it starts in that folder. To stay in the home folder, type `subsub --here`.
35
- 3. Sub-Sub starts in the Researcher. The Researcher reads, searches, writes notes and changes the library. The Librarian only manages the library. To start in the Librarian, type `subsub --librarian`.
36
- 4. To change the mode, type `/librarian` or `/researcher`. The mode also changes the model. After research, ask the Researcher to import the works you choose. It imports them in the same conversation, with one preview, and offers to tag them.
37
- 5. When a dialog shows a preview, read it. Select Yes to apply the change, or No to stop it. After No, tell Sub-Sub what to change.
38
- 6. To continue the last session, type `subsub -c`. To select an older session, type `subsub -r`.
39
-
40
- Sub-Sub always replies in English and writes its notes in English. Quotations and titles of works stay in their original language.
41
-
42
- Sub-Sub keeps its pi settings, sessions and packages in ~/.subsub/agent, separate from plain pi (~/.pi/agent).
43
-
44
- Commands:
45
-
46
- - `/subsub`: Zotero connection and library overview
47
- - `/profile`: shows or changes the profile
48
- - `/history`: recent library changes
49
- - `/undo`: shows the last change, asks, and reverts it. `/undo <id>` reverts a specific change from `/history`.
50
-
51
- Templates (type the name, then the argument):
52
-
53
- - Library (both modes): `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
54
- - Research (Researcher): `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
55
-
56
- ## Web view
57
-
58
- 1. Type `subsub web`. Sub-Sub opens in the browser, with the same profiles, tools and approvals. The mode buttons are at the top. The side panel shows the actions of the mode. The approval dialog shows the server preview; the change runs only after Yes.
59
- 2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
60
- 3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
61
- 4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
62
- 5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
63
- 6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
64
- 7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
65
- 8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`). It saves the model for the current mode. To use the model in both modes, select "Use it in both modes".
66
-
67
- The log is in ~/.subsub/web.log.
68
-
69
- ## Profiles
70
-
71
- The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To change it, type `/profile reader` (or scholar, author, editor). The change is saved.
72
-
73
- - Reader: explains each step. Reading notes are quotes with page numbers, plus questions. No summaries or syntheses. No bulk library changes.
74
- - Scholar: literature notes, syntheses, searches and alerts. All library changes.
75
- - Author: Scholar plus manuscripts: citation check, bibliography, comments on the argument. It does not write the paragraphs.
76
- - Editor: everything, including drafted manuscript text when you ask.
77
-
78
- ## Tag review notes
79
-
80
- Sub-Sub writes proposed tags in review notes in Inbox/ ("Zotero tag review NN.md"). Edit the Proposed tags column. Write skip to leave an item as it is.
81
-
82
- To apply one note, in either mode, type: apply the tag review Inbox/Zotero tag review NN.md.
83
-
84
- To apply many notes:
85
-
86
- 1. Start Zotero.
87
- 2. Type `subsub review preview 13-31` (the numbers of the notes). This shows what each note will change. Nothing changes.
88
- 3. Type `subsub review apply 13-31`. Read the preview, then type y.
89
- 4. If Zotero asks for permission, select Always Allow.
90
-
91
- Use note numbers, not a pattern such as "*.md": a pattern also takes older notes that have no Applied line, and applying an old note again replaces later tag changes.
92
-
93
- Notes that were already applied are left out. Each applied note gets an "Applied" line with the journal ID. To revert a note, type `/undo <journal ID>` in Sub-Sub. If a note shows "changed since the note was written", the tags of those items changed in Zotero after the note was made; applying the note replaces those changes.
94
-
95
- ## Look
96
-
97
- When you start Sub-Sub with the `subsub` command, it shows a banner, a status line (mode, model, Zotero items, items to review) and a line from Moby-Dick. The theme is Glauca, in the dark or light version that matches your terminal (set "themes": "subsub-try-works" for the other one). The theme changes for the session only; your pi theme setting stays.
98
-
99
- To change this, add to ~/.config/subsub/config.json:
100
-
101
- - `"quotes": false`: no Moby-Dick line.
102
- - `"themes": false`: keep your own pi theme.
103
- - `"look": false`: no banner and no themes.
104
-
105
- The themes are in ~/Projects/subsub/themes. To make them again after a change in Gam, see scripts/make-themes.py.
106
-
107
- ## Model test
108
-
109
- The model test runs several models on the same tasks against your library and compares them. Nothing in the library changes: each library change is recorded with its preview and then blocked. Notes go to ~/Projects/subsub/bench-results/<date>/, not to the Sub-Sub folder.
110
-
111
- Tasks:
112
-
113
- - Library tasks: tag 20 items against a blind reference (reference.md shows the items without their tags; the tags go in reference.json); fix the items without topic or status; import identifiers, one of them already in the library.
114
- - Research tasks: a literature note on one item with full text; a synthesis on one topic; a search for recent papers that are not in the library.
115
-
116
- Procedure:
117
-
118
- 1. Start Zotero.
119
- 2. Type `subsub-bench prepare`. This picks the tasks.
120
- 3. Type `caffeinate -i subsub-bench run`. This takes 1 to 2 hours. caffeinate keeps the Mac awake. If the run stops, type the same command again: finished runs are not repeated.
121
- 4. Type `subsub-bench score`. The result is results.md in the results folder.
122
-
123
- Options for `run`: `--models a,b` for all tasks, or `--librarian a,b` and `--researcher c,d` for each group of tasks (OpenCode Go model ids), `--only tagging,import`, `--parallel 3`, `--timeout 900`. Library tasks run in the Librarian. Research tasks run in the Researcher.
124
-
125
- Each model gets a code (for example M417), so the notes can be judged blind. key.json has the codes.
126
-
127
- ## Problems
128
-
129
- - To find a setup problem, type `subsub doctor`. Each line marked FIX says what to do.
130
- - "the zotero server did not start": run `cd ~/Projects/zotero-local-mcp && uv sync`. If pi does not find uv, add `"uvPath": "/path/to/uv"` to ~/.config/subsub/config.json (type `which uv` to see the path).
131
- - "cannot use opencode-go/...": the OpenCode Go key is missing. In Sub-Sub, type `/login` again.
132
- - "command not found: subsub": type `cd ~/Projects/subsub && npm link`.
133
- - To update Sub-Sub after new code arrives, type `cd ~/Projects/subsub && npm install --omit=dev`. Do not use `subsub update`.
134
- - A tool call is refused with "part of the Researcher": type `/researcher`.
135
- - `subsub review` says "No note matches": check the note numbers in Inbox/. A path is written from the Sub-Sub folder, in quotes (for example "Inbox/Zotero tag review 13.md").
136
-
137
- ## Tests
138
-
139
- - `cd ~/Projects/subsub && npm install && npm test`: unit tests.
140
- - `npm run test:e2e`: real pi and the `subsub` command with a scripted model and a fake Zotero.