@tiagojct/subsub 0.10.0 → 0.11.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/dist/web.js CHANGED
@@ -22,7 +22,7 @@ import { extname, join, normalize, resolve, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
24
  import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
25
- import { TESTED_MODEL } from "./init.js";
25
+ import { TESTED_MODELS } from "./init.js";
26
26
  import { obsidianRoot } from "./layout.js";
27
27
  import { realish, within } from "./paths.js";
28
28
  export function parseWebArgs(args) {
@@ -549,9 +549,12 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
549
549
  return send(res, 409, { error: "busy" });
550
550
  const body = await readJson(req);
551
551
  const spec = `${String(body.provider)}/${String(body.id)}`;
552
- // Remember the model (and drop the per-mode models of versions before 0.10), then restart pi
553
- // so the extension reads the new setting.
554
- saveConfig({ model: spec, models: undefined }, env);
552
+ // Remember the model for the current mode (or both), then restart pi so the extension reads it.
553
+ const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
554
+ const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
555
+ if (body.both)
556
+ models[mode === "librarian" ? "researcher" : "librarian"] = spec;
557
+ saveConfig({ models, model: undefined }, env);
555
558
  await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
556
559
  await restartAgent();
557
560
  return send(res, 200, { ok: true });
@@ -579,8 +582,8 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
579
582
  const body = await readJson(req);
580
583
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
581
584
  const current = loadConfig(env);
582
- if (body.provider === "opencode-go" && !current.model)
583
- saveConfig({ model: TESTED_MODEL, models: undefined }, env);
585
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
586
+ saveConfig({ models: TESTED_MODELS, model: undefined }, env);
584
587
  await restartAgent();
585
588
  return send(res, 200, { ok: true });
586
589
  }
package/docs/Subsub.md CHANGED
@@ -4,7 +4,7 @@ Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses th
4
4
  - Shared rules: [[Zotero agent]]. Tag vocabulary: [[Zotero tags]]
5
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
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
- - Model: opencode-go/mimo-v2.6-pro (one model since 0.10)
7
+ - Models: Librarian opencode-go/mimo-v2.6-flash, Researcher opencode-go/mimo-v2.6-pro
8
8
  - Public docs: the README in ~/Projects/subsub (source for subsub.tiagojacinto.eu)
9
9
 
10
10
  ## Safety
@@ -12,7 +12,7 @@ Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses th
12
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
13
  - The model does not need to do a dry run first. You see one dialog per change.
14
14
  - Library tools run one at a time, so each preview includes the effect of earlier changes in the same step.
15
- - With library changes off (`/library off`), Sub-Sub has no library write tools. It can only add a linked note (with a dialog) and add works to the import queue.
15
+ - The Librarian has no search tools. The Researcher has all tools; every library change still needs a Yes.
16
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
17
 
18
18
  ## Install
@@ -22,7 +22,7 @@ Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses th
22
22
  3. Type `npm link`. This adds the `subsub` command.
23
23
  4. Optional: type `pi remove ~/Projects/subsub`. Then plain `pi` stays a plain coding agent without Sub-Sub.
24
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 mimo-v2.6-pro.
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
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
27
  8. Type `subsub doctor`. Make sure that no line shows FIX.
28
28
 
@@ -32,8 +32,8 @@ If you change the Node version with fnm, do step 3 again.
32
32
 
33
33
  1. Start Zotero.
34
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 is one assistant: it reads, searches, writes notes and, with library changes on, changes the library. To turn library changes off or on, type `/library off` or `/library on` (saved). To start with them off for one session, type `subsub --read-only`. `/librarian` and `/researcher` still work: on and off.
36
- 4. After research, ask Sub-Sub to import the works you choose. It imports them in the same conversation, with one preview, and offers to tag them.
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
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
38
  6. To continue the last session, type `subsub -c`. To select an older session, type `subsub -r`.
39
39
 
@@ -50,19 +50,19 @@ Commands:
50
50
 
51
51
  Templates (type the name, then the argument):
52
52
 
53
- - Library (need library changes on): `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
54
- - Research: `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
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
55
 
56
56
  ## Web view
57
57
 
58
- 1. Type `subsub web`. Sub-Sub opens in the browser, with the same profiles, tools and approvals. The switch Library changes is at the top. The approval dialog shows the server preview; the change runs only after Yes.
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
59
  2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
60
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
61
  4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
62
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
63
  6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
64
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`).
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
66
 
67
67
  The log is in ~/.subsub/web.log.
68
68
 
@@ -79,7 +79,7 @@ The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To
79
79
 
80
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
81
 
82
- To apply one note, with library changes on, type: apply the tag review Inbox/Zotero tag review NN.md.
82
+ To apply one note, in either mode, type: apply the tag review Inbox/Zotero tag review NN.md.
83
83
 
84
84
  To apply many notes:
85
85
 
@@ -94,7 +94,7 @@ Notes that were already applied are left out. Each applied note gets an "Applied
94
94
 
95
95
  ## Look
96
96
 
97
- When you start Sub-Sub with the `subsub` command, it shows a banner, a status line (library changes on or read only, 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.
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
98
 
99
99
  To change this, add to ~/.config/subsub/config.json:
100
100
 
@@ -120,7 +120,7 @@ Procedure:
120
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
121
  4. Type `subsub-bench score`. The result is results.md in the results folder.
122
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`. Every task runs with library changes on.
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
124
 
125
125
  Each model gets a code (for example M417), so the notes can be judged blind. key.json has the codes.
126
126
 
@@ -131,7 +131,7 @@ Each model gets a code (for example M417), so the notes can be judged blind. key
131
131
  - "cannot use opencode-go/...": the OpenCode Go key is missing. In Sub-Sub, type `/login` again.
132
132
  - "command not found: subsub": type `cd ~/Projects/subsub && npm link`.
133
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 "library changes are off": type `/library on`.
134
+ - A tool call is refused with "part of the Researcher": type `/researcher`.
135
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
136
 
137
137
  ## Tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,46 @@
1
+ You are Sub-Sub, the librarian for {{user}}'s Zotero library. {{about}} Reply briefly. No emojis.
2
+
3
+ You change the library through the zotero_ tools. You have no web access; outside metadata reaches you only through the import and repair tools. For literature searches and notes, {{user}} switches to the Researcher (/researcher), which also does everything you do.
4
+
5
+ ## How changes work
6
+
7
+ - Call a write tool with dry_run=false when you intend the change. Sub-Sub shows {{user}} the server's preview and applies it only on approval.
8
+ - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
9
+ - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
10
+ - Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
11
+
12
+ ## How to tag
13
+
14
+ - A tag must be supported by the item itself: title and abstract, or the full text. If you are unsure, leave the tag out. Fewer correct tags are better than many loose ones.
15
+ - type/ says what the document is, not what it is about. A review of trials is type/narrative-review or type/systematic-review, not type/rct. A paper about guidelines is not type/guideline.
16
+ - method/ says how the work was done. A paper or book about statistics gets topic/statistics, not method/statistical-modelling.
17
+ - When an item already has tags, judge each existing topic/, method/ and type/ tag. Remove the ones the item does not support: call zotero_tag_items with replace=["topic","method","type"] and the complete set in add.
18
+ - The server refuses more tags than max_per_facet allows (topic 4, type 2). Do not work around it; choose.
19
+ - Do not change status/ tags unless {{user}} says so.
20
+
21
+ ## A tag list without topics
22
+
23
+ If zotero_get_vocabulary has no topic/ tags, propose topics before tagging: read the titles of 100 to 200 items (zotero_find_items, several pages), write Inbox/Zotero topic proposal.md with 15 to 30 topics (tag, one-line description, how many items it would fit), and ask {{user}} to copy the chosen ones into the tag list file. You cannot edit that file.
24
+
25
+ ## Tagging untagged items
26
+
27
+ 1. zotero_find_items with missing_facet="topic", detail=true, limit={{batch}}, offset=0.
28
+ 2. Call zotero_write_tag_review with the name "Zotero tag review NN" (NN: the next free number; ls Inbox/ first) and one row per item: key, the complete topic/, method/ and type/ set, and a short reason. The tool fills in the citekey, item and current tags and checks every row. Do not write the table by hand.
29
+ 3. Tell {{user}} the file name. {{User}} edits the Proposed tags column.
30
+ 4. When {{user}} approves, call zotero_apply_tag_review with the note path. It applies the column exactly as {{user}} left it and removes the review marker. Do not copy rows into zotero_tag_items. If the preview shows changed_since_note or already_applied, tell {{user}} before you apply. For many notes at once, {{user}} can run `subsub review apply NN-MM` (note numbers) in a terminal.
31
+ 5. Report the missing_facet counts from zotero_library_overview.
32
+
33
+ ## Re-tagging and cleaning
34
+
35
+ 1. zotero_tag_audit: items over the limits, pairs of tags that almost always appear together (batch tagging), items without the review marker.
36
+ 2. For the flagged items, write review notes with zotero_write_tag_review as above ({{batch}} items each, grouped by subject), with a complete new set for each item.
37
+ 3. After {{user}}'s edits, zotero_apply_tag_review, one note at a time. Run zotero_tag_audit again at the end.
38
+ 4. Old tags outside the vocabulary: zotero_list_tags with outside_vocabulary=true, then Inbox/Zotero tag mapping.md (old tag, item count, proposed action). Check that the meaning fits: a subject tag such as "rct" or "guidelines" is not a document type, and tags that were put on whole groups of items should be removed, not renamed. After approval: zotero_rename_tags and zotero_remove_tags.
39
+
40
+ ## Imports, metadata, PDFs
41
+
42
+ - Import the ticked lines of the queue or an alert note with zotero_import_queue; single works with zotero_import_identifiers.
43
+ - zotero_audit_metadata, then zotero_repair_metadata in batches of 25. Mention matches with confidence below 1.0. Journal articles of 1 or 2 pages are often letters, editorials or book reviews: check their type/ tag.
44
+ - zotero_find_duplicates: give {{user}} each group with its evidence (identifiers, fields, files, citekeys) and the fullest record. Do not call items duplicates on a similar title alone. When the item types differ, say that Zotero's Duplicate Items view will not show the group. {{User}} merges in Zotero.
45
+ - zotero_check_retractions: call again while still_unchecked > 0. Propose the tag _retracted for retracted items.
46
+ - zotero_missing_pdfs, then zotero_attach_oa_pdfs in batches of 25. Say when a file is an accepted manuscript.
@@ -1,6 +1,6 @@
1
- You are Sub-Sub, the librarian and research assistant for {{user}}'s Zotero library. {{about}} Reply briefly. No emojis.
1
+ You are Sub-Sub, the researcher for {{user}}'s Zotero library. You also do the librarian's work. {{about}} Reply briefly. No emojis.
2
2
 
3
- You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools), write notes in the Sub-Sub folder and, when library changes are on, change the library through the zotero_ write tools. Every library change goes through Sub-Sub's preview and {{user}}'s approval.
3
+ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools), write notes in the Sub-Sub folder and change the library through the zotero_ write tools. Every library change goes through Sub-Sub's preview and {{user}}'s approval.
4
4
 
5
5
  ## Always
6
6
 
@@ -22,8 +22,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
22
22
  - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
23
23
  - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
24
24
  - Start work on the library with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
25
- - If library changes are off, a write tool is not available: say what you would change and ask {{user}} to turn library changes on.
26
- - Older shared rules may speak of a librarian and a researcher, and of handing works over between them. Those were two modes of Sub-Sub before version 0.10; you do both.
25
+ - Older shared rules may say that the researcher cannot change the library and hands works over to the librarian. That was before version 0.10: you do both.
27
26
 
28
27
  ## Questions about the library
29
28
 
@@ -35,7 +34,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
35
34
  2. Search with scholar_search_multi (three or four phrasings, all sources in one call). Use scholar_search_pubmed, scholar_search_europepmc or scholar_search_openalex alone only for a precise query in that service's syntax. Report total hits and how many are already in the library.
36
35
  3. Read open-access full text with scholar_read_oa_fulltext: first the section list, then only the sections you need. When a work you need is not open access, say that it may open through {{user}}'s institution (the DOI link, opened on the institution's network), and offer /request-copy to ask the author for a copy. Never point to unlicensed copies (Sci-Hub, LibGen and similar).
37
36
  4. List the most relevant works not in the library, one line each on why it matters.
38
- 5. Ask which to add. Import the chosen works with zotero_import_identifiers (one preview for all), then offer to tag them. When {{user}} wants to choose later, or library changes are off, put them in the import queue with scholar_queue_imports; {{user}} ticks the lines and you import them with zotero_import_queue.
37
+ 5. Ask which to add. Import the chosen works with zotero_import_identifiers (one preview for all), then offer to tag them. When {{user}} wants to choose later, put them in the import queue with scholar_queue_imports; {{user}} ticks the lines and you import them with zotero_import_queue.
39
38
 
40
39
  ## Imports, metadata, PDFs
41
40
 
package/src/bench.ts CHANGED
@@ -17,17 +17,14 @@ import { spawn, spawnSync } from "node:child_process";
17
17
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { expand, loadConfig, starbuckCommand, starbuckEnabled } from "./config.ts";
21
-
22
- /** Task groups: library work and research. Every run has library changes on (each change is recorded, not applied). */
23
- export type Mode = "librarian" | "researcher";
20
+ import { expand, loadConfig, type Mode, starbuckCommand, starbuckEnabled } from "./config.ts";
24
21
  import { findUv } from "./subsub.ts";
25
22
 
26
23
  export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
24
 
28
25
  export const DEFAULT_MODELS: Record<Mode, string[]> = {
29
- librarian: ["glm-5.3-flash", "deepseek-v4.1-flash", "qwen3.8-flash", "mimo-v2.6-flash", "minimax-m3", "mimo-v2.6-pro"],
30
- researcher: ["mimo-v2.6-pro", "deepseek-v4-pro", "kimi-k2.6", "glm-5.3", "qwen3.7-plus", "minimax-m3"],
26
+ librarian: ["mimo-v2.6-flash", "glm-5.3-flash", "qwen3.8-flash", "gpt-5.6-luna", "hy3", "mimo-v2.6-pro"],
27
+ researcher: ["mimo-v2.6-pro", "qwen3.8-flash", "kimi-k3", "glm-5.3", "minimax-m3", "gpt-5.6-luna"],
31
28
  };
32
29
 
33
30
  export const TASKS: Record<Mode, string[]> = {
@@ -138,7 +135,7 @@ export function runOne(opts: {
138
135
  }): Promise<RunSummary> {
139
136
  mkdirSync(opts.out, { recursive: true });
140
137
  const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
141
- args.push("--librarian"); // library changes on for every task
138
+ if (opts.role === "librarian") args.push("--librarian");
142
139
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
143
140
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
144
141
  const events: any[] = [];
@@ -611,7 +608,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
611
608
  const prompt = promptFor(j.task, t, j.code, out);
612
609
  if (!prompt) return;
613
610
  const cf = join(cfgDir, `${j.code}.json`);
614
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", model: `opencode-go/${j.model}`, models: undefined, libraryChanges: true }));
611
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
615
612
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
616
613
  n++;
617
614
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/src/config.ts CHANGED
@@ -6,13 +6,13 @@
6
6
  * "profile": "scholar",
7
7
  * "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
8
8
  * "vault": "~/Documents/Sub-Sub",
9
- * "model": "opencode-go/mimo-v2.6-pro",
10
- * "libraryChanges": true
9
+ * "models": { "librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
10
+ * "defaultMode": "researcher"
11
11
  * }
12
12
  *
13
- * Before 0.10 there were two modes, each with a model ("models": {"librarian",
14
- * "researcher"}) and "defaultMode". Those keys are still read: the researcher's
15
- * model becomes the model.
13
+ * Two modes: the Librarian manages the library; the Researcher does that too,
14
+ * and searches and writes notes. "model" (0.10) sets one model for both;
15
+ * "libraryChanges" (0.10) is no longer used.
16
16
  *
17
17
  * `subsub init` writes this file. Without "serverDir" (or when that folder does
18
18
  * not exist), the Zotero servers run from PyPI with uv.
@@ -28,8 +28,13 @@ import { homedir } from "node:os";
28
28
  import { dirname, join, resolve } from "node:path";
29
29
  import { isObsidianVault, settingsFile } from "./layout.ts";
30
30
 
31
- /** The tested model (September 2026 model test). */
32
- export const DEFAULT_MODEL = "opencode-go/mimo-v2.6-pro";
31
+ export type Mode = "librarian" | "researcher";
32
+
33
+ /** The tested models, one per mode (model test, see bench-results). */
34
+ export const DEFAULT_MODELS: Record<Mode, string> = {
35
+ librarian: "opencode-go/mimo-v2.6-flash",
36
+ researcher: "opencode-go/mimo-v2.6-pro",
37
+ };
33
38
  export type Profile = "reader" | "scholar" | "author" | "editor";
34
39
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
35
40
 
@@ -56,10 +61,9 @@ export interface SubsubConfig {
56
61
  serverDir: string;
57
62
  envFile?: string;
58
63
  vault?: string;
59
- /** The model, as provider/id. Undefined: pi's own choice (the user picks with /model). */
60
- model?: string;
61
- /** Library changes on: the Zotero write tools are offered, each with a preview. */
62
- libraryChanges: boolean;
64
+ /** The model of each mode, as provider/id. Missing: pi's own choice (the user picks with /model). */
65
+ models: Partial<Record<Mode, string>>;
66
+ defaultMode: Mode;
63
67
  /** Extra context files added to the system prompt when pi runs inside the vault. */
64
68
  vaultContext: string[];
65
69
  /** Shared rules file (tag system, citekeys, note formats). */
@@ -152,10 +156,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
152
156
  const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
153
157
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
154
158
  const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
155
- // Before 0.10: a model per mode, and themes per mode.
156
- const legacyModels = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
157
- // "model": "" means: the user chooses in pi (/login, /model).
158
- const model = "model" in user ? (typeof user.model === "string" && user.model) || undefined : legacyModels ? legacyModels.researcher || legacyModels.librarian || undefined : DEFAULT_MODEL;
159
+ // "models" per mode; "model" (0.10) for both; {} or "" means: the user chooses in pi (/login, /model).
160
+ const one = typeof user.model === "string" ? user.model : undefined;
161
+ const given = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
162
+ const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : { ...DEFAULT_MODELS });
163
+ for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
159
164
  const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
160
165
  return {
161
166
  configFile: file,
@@ -167,8 +172,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
167
172
  serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
168
173
  envFile,
169
174
  vault,
170
- model,
171
- libraryChanges: user.libraryChanges !== false,
175
+ models,
176
+ defaultMode: user.defaultMode === "librarian" ? "librarian" : "researcher",
172
177
  // A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
173
178
  vaultContext: (
174
179
  user.vaultContext ??
package/src/doctor.ts CHANGED
@@ -148,7 +148,7 @@ export function runChecks(
148
148
  const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
149
149
  out.push(
150
150
  hasLogin(agentDir)
151
- ? { name: "Model login", ok: true, detail: cfg.model ?? "pi's default model" }
151
+ ? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
152
152
  : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
153
153
  );
154
154
  return out;
package/src/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Writes:
6
6
  * - the settings file (~/.config/subsub/config.json): profile, name, language,
7
- * Sub-Sub folder, library changes, model;
7
+ * Sub-Sub folder, models;
8
8
  * - the server settings file (~/.config/zotero-local-mcp/env, or the one that
9
9
  * already exists): the Sub-Sub folder, tag list, alerts file, contact email.
10
10
  * Other lines stay;
@@ -19,7 +19,7 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --library on|off, --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go.
23
23
  */
24
24
 
25
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -29,7 +29,7 @@ import { createInterface } from "node:readline/promises";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
- DEFAULT_MODEL,
32
+ DEFAULT_MODELS,
33
33
  expand,
34
34
  isProfile,
35
35
  loadConfig,
@@ -53,7 +53,7 @@ export const STARTER_TAGS: Record<string, string> = {
53
53
  "any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
54
54
  };
55
55
 
56
- export const TESTED_MODEL = DEFAULT_MODEL;
56
+ export const TESTED_MODELS = DEFAULT_MODELS;
57
57
 
58
58
  export interface InitIO {
59
59
  ask(question: string, def: string): Promise<string>;
@@ -185,19 +185,6 @@ export async function runInit(
185
185
  isProfile(profileDefault) ? profileDefault : "scholar",
186
186
  );
187
187
 
188
- explain(
189
- "Library changes",
190
- "With library changes on, Sub-Sub can import, tag and repair items in Zotero. It shows a preview of every change, and nothing changes until you select Yes. With them off, it only reads, searches and writes notes. Switch at any time with /library on or /library off.",
191
- );
192
- const library = await io.choose(
193
- "Library changes",
194
- [
195
- { value: "on", label: "On (recommended): every change shows a preview first" },
196
- { value: "off", label: "Off: read, search and write notes only" },
197
- ],
198
- flags.library ?? (existing.libraryChanges === false ? "off" : "on"),
199
- );
200
-
201
188
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
202
189
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
203
190
  const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
@@ -233,16 +220,20 @@ export async function runInit(
233
220
  flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
234
221
  );
235
222
 
236
- explain("Models", "Sub-Sub needs an account with a model provider. You can connect one later in Sub-Sub with the model button (or /login in the terminal).");
223
+ explain(
224
+ "Models",
225
+ "Sub-Sub needs an account with a model provider. Each mode can use its own model: the Librarian a fast, cheap one for tags and imports, the Researcher a stronger one for reading. You can connect a provider later in Sub-Sub with the model button (or /login in the terminal).",
226
+ );
237
227
  const hadModel = "model" in existing || "models" in existing;
228
+ const show = (m: Partial<Record<string, string>>) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
238
229
  const modelOptions = [
239
- ...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
230
+ ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
240
231
  { value: "later", label: "Choose later in Sub-Sub" },
241
- { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
232
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
242
233
  ];
243
234
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
244
235
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
245
- const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
236
+ const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
246
237
 
247
238
  // ---- write
248
239
  if (move === "yes") applyMoves(moves);
@@ -278,15 +269,14 @@ export async function runInit(
278
269
  setup: fmup ? "fmup" : undefined,
279
270
  vault: notes,
280
271
  envFile,
281
- libraryChanges: library === "on",
282
- defaultMode: undefined,
272
+ libraryChanges: undefined,
283
273
  addons: withStarbuck(current.addons, starbuck === "on"),
284
274
  };
285
- // One model since 0.10: the per-mode models are replaced.
286
- patch.models = undefined;
287
- if (models === "keep") patch.model = current.model ?? "";
288
- if (models === "later") patch.model = "";
289
- if (models === "opencode-go") patch.model = TESTED_MODEL;
275
+ // A model per mode; "model" (0.10, one for both) is replaced.
276
+ patch.model = undefined;
277
+ if (models === "keep") patch.models = current.models;
278
+ if (models === "later") patch.models = {};
279
+ if (models === "opencode-go") patch.models = TESTED_MODELS;
290
280
  saveConfig(patch, initEnv);
291
281
 
292
282
  const zotero = await zoteroReachable(deps.fetch);
package/src/look.ts CHANGED
@@ -83,8 +83,7 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
83
83
  /** Header lines. Each line is cut to the width before it is coloured. */
84
84
  export function headerLines(opts: {
85
85
  version: string;
86
- /** Library changes on (false: read only). */
87
- libraryChanges: boolean;
86
+ mode: "librarian" | "researcher";
88
87
  /** Profile label, e.g. "Scholar". */
89
88
  profile?: string;
90
89
  model: string;
@@ -95,7 +94,7 @@ export function headerLines(opts: {
95
94
  }): string[] {
96
95
  const p = opts.paint ?? plain;
97
96
  const w = Math.max(10, opts.width);
98
- const modeLabel = `Assistant${opts.profile ? `, ${opts.profile}` : ""}${opts.libraryChanges ? "" : ", read only"}`;
97
+ const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
99
98
  const lib = libraryLine(opts.library);
100
99
  if (w < BANNER[0].length + 2) {
101
100
  return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
@@ -108,7 +107,7 @@ export function headerLines(opts: {
108
107
  if ([...one].length <= w) out.push(p.dim(one));
109
108
  else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
110
109
  }
111
- out.push(p.dim(cut("/library /profile /history /undo | / for all commands", w)), "");
110
+ out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
112
111
  return out;
113
112
  }
114
113
 
package/src/prompt.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { type SubsubConfig, who } from "./config.ts";
13
+ import { type Mode, type SubsubConfig, who } from "./config.ts";
14
14
  import { profileSpec } from "./profiles.ts";
15
15
 
16
16
  const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
@@ -52,19 +52,14 @@ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
52
52
  .replace(/[ \t]+\n/g, "\n");
53
53
  }
54
54
 
55
- export function roleText(cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
56
- const raw = readIf(join(ROLE_DIR, "assistant.md")) ?? "You are Sub-Sub, a Zotero librarian and research assistant.";
55
+ export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
56
+ const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
57
57
  return cfg ? fill(raw, cfg) : raw;
58
58
  }
59
59
 
60
- export function systemAddition(cfg: SubsubConfig, cwd: string, libraryChanges: boolean = cfg.libraryChanges): string {
60
+ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
61
61
  const user = who(cfg);
62
- const parts = ["# Sub-Sub", roleText(cfg)];
63
- if (!libraryChanges) {
64
- parts.push(
65
- `Library changes are off: you read the library, search, write notes and queue imports. To import, tag or change items, ask ${user} to turn library changes on (/library on, or the switch in the browser view).`,
66
- );
67
- }
62
+ const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
68
63
  const shared = readIf(cfg.sharedRules);
69
64
  if (shared) {
70
65
  parts.push(
package/src/roles.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Which tools the Assistant may use, and which calls need the user's approval.
2
+ * Which tools each mode may use, and which calls need the user's approval.
3
3
  */
4
4
 
5
- import type { Profile } from "./config.ts";
5
+ import type { Mode, Profile } from "./config.ts";
6
6
  import { profileSpec } from "./profiles.ts";
7
7
 
8
8
  export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
9
9
 
10
- /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the switch and the profile for these). */
10
+ /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
11
11
  export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
12
12
 
13
13
  export const ZOTERO_READ = [
@@ -53,42 +53,41 @@ export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates",
53
53
  (n) => `zotero_${n}`,
54
54
  );
55
55
 
56
+
56
57
  export interface ToolOptions {
57
- /** Library changes on: the Zotero write tools are offered (each still previewed). */
58
- libraryChanges: boolean;
59
58
  profile?: Profile;
60
59
  /** The model test: also the bakeoff_ tools. */
61
60
  bench?: boolean;
62
61
  }
63
62
 
64
63
  /**
65
- * The Assistant's tools: reading the library, searching, notes and reference
66
- * checks always; the Zotero write tools when library changes are on. The profile
67
- * then removes tools.
64
+ * The tools of a mode. The Librarian manages the library: Zotero tools and files.
65
+ * The Researcher has the same, plus searches, notes and reference checks. The
66
+ * profile then removes tools. Every library change is still previewed (gateKind).
68
67
  */
69
- export function toolsFor(registered: string[], opts: ToolOptions): string[] {
68
+ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
70
69
  const has = new Set(registered);
71
70
  const without = new Set(profileSpec(opts.profile ?? "editor").without);
72
71
  const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
73
72
  const wanted = [
74
73
  ...ZOTERO_READ,
75
74
  ...ZOTERO_CHECKS,
76
- ...(opts.libraryChanges ? zotero : []),
77
- ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
75
+ ...zotero,
76
+ ...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
78
77
  ...FILE_TOOLS,
79
78
  ];
80
79
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
81
80
  }
82
81
 
83
82
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
84
- export function unavailableReason(name: string, registered: string[], opts: ToolOptions): string | undefined {
85
- if (toolsFor(registered, opts).includes(name)) return undefined;
83
+ export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
84
+ if (toolsFor(mode, registered, opts).includes(name)) return undefined;
86
85
  const profile = opts.profile ?? "editor";
87
86
  if (profileSpec(profile).without.includes(name)) {
88
87
  return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
89
88
  }
90
- if (!opts.libraryChanges && toolsFor(registered, { ...opts, libraryChanges: true }).includes(name)) {
91
- return `${name} changes the library, and library changes are off. The user can turn them on with /library on.`;
89
+ if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
90
+ return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
92
91
  }
93
92
  return `${name} is not available.`;
94
93
  }