@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/README.md CHANGED
@@ -17,7 +17,7 @@ A Zotero librarian and research assistant that runs on your computer, in your br
17
17
 
18
18
  * **Code-Enforced Safety Gate**: The model **cannot** modify your library without your explicit approval. Every change is calculated first as a dry-run preview (+added in green, -removed in red) and executed only when you confirm.
19
19
  * **Full Journal & Reversible Edits**: Every library modification is recorded with a unique journal ID and can be reverted anytime using `/undo`.
20
- * **One assistant, one switch**: Sub-Sub reads items, searches PubMed, Europe PMC and OpenAlex, finds gaps, checks citations in manuscripts and writes Markdown notes in your Sub-Sub folder (it can sit inside an Obsidian vault). With **library changes** on (the default), it also tags, imports by DOI, PMID or ISBN, repairs metadata, finds duplicates, attaches open-access PDFs and checks retractions, in the same conversation. Turn library changes off (`/library off`, or the switch in the browser view) to read only.
20
+ * **Two modes**: The **Researcher** (the default) reads items, searches PubMed, Europe PMC and OpenAlex, finds gaps, checks citations in manuscripts and writes Markdown notes in your Sub-Sub folder (it can sit inside an Obsidian vault). It also does everything the Librarian does, so you can research a topic and import and tag what you find in the same conversation. The **Librarian** manages the library only: it tags, imports by DOI, PMID or ISBN, repairs metadata, finds duplicates, attaches open-access PDFs and checks retractions. It has no search tools; use it with a fast, cheap model for tagging sessions. Each mode can have its own model. In both modes, every library change needs your approval.
21
21
  * **Local & Private**: Listens strictly on `127.0.0.1` protected by an authenticated single-origin session cookie. Your library contents remain on your computer.
22
22
  * **Modern Web Interface & Terminal UI**: Use Sub-Sub in your browser with desktop shortcuts (`~/Applications/Sub-Sub.app`, Start menu, or `.desktop`), or launch it right in your terminal with adaptive color themes.
23
23
 
@@ -104,7 +104,7 @@ Starting Sub-Sub with `subsub web` (or `subsub serve`) launches the browser appl
104
104
 
105
105
  * **Approval Previews**: Shows server-computed dry-run diffs directly in the UI. Changes are applied only after clicking **Yes** or pressing <kbd>Y</kbd>.
106
106
  * **Keyboard-First Review**: In approval dialogs, press <kbd>Y</kbd> or <kbd>Cmd</kbd>/<kbd>Ctrl</kbd>+<kbd>Enter</kbd> to approve, or <kbd>N</kbd>/<kbd>Esc</kbd> to decline.
107
- * **Library changes switch**: In the header. Off, Sub-Sub reads, searches and writes notes only; the Library actions in the side panel are hidden.
107
+ * **Mode buttons**: Researcher and Librarian, in the header. The side panel shows the actions of the mode: Research and Library in the Researcher, Library only in the Librarian. The model button saves the model for the current mode, or for both modes.
108
108
  * **Theme Switcher**: Header toggle between **Light**, **Dark**, and **System** themes.
109
109
  * **One-Click Copy**: Built-in copy buttons on all code snippets, BibTeX records, and assistant literature notes.
110
110
  * **Session Search & Markdown Export**: Filter conversation history in real time and download full discussions as Markdown notes.
@@ -118,8 +118,8 @@ Starting Sub-Sub with `subsub web` (or `subsub serve`) launches the browser appl
118
118
  ### Starting and commands
119
119
 
120
120
  ```sh
121
- subsub # Start in the terminal
122
- subsub --read-only # Start with library changes off (this session only)
121
+ subsub # Start in the terminal (Researcher)
122
+ subsub --librarian # Start in the Librarian
123
123
  subsub web # Start browser view
124
124
  subsub -c # Continue the previous session
125
125
  subsub -r # Resume an older session
@@ -127,7 +127,8 @@ subsub -r # Resume an older session
127
127
 
128
128
  Commands available within Sub-Sub:
129
129
 
130
- * `/library on|off`: Turn library changes on or off (saved). Without an argument, it toggles. `/librarian` and `/researcher`, the two modes before 0.10, still work: on and off.
130
+ * `/researcher`: Change to the Researcher (research and library). Each mode can have its own model.
131
+ * `/librarian`: Change to the Librarian (library only).
131
132
  * `/profile [name]`: View or change active profile (`reader`, `scholar`, `author`, `editor`).
132
133
  * `/subsub`: Display Zotero connection health and library statistics.
133
134
  * `/history`: List recent library changes with journal IDs.
@@ -137,11 +138,11 @@ Commands available within Sub-Sub:
137
138
 
138
139
  Type the command followed by arguments:
139
140
 
140
- * **Library** (need library changes on):
141
+ * **Library (both modes)**:
141
142
  * `/tag-batch [size]`: Tag untagged items using your tag vocabulary.
142
143
  * `/clean-tags`: Find irregular tags and reconcile with your vocabulary.
143
144
  * `/import-queue [path]`: Import pending references from an inbox note.
144
- * **Research**:
145
+ * **Research (Researcher)**:
145
146
  * `/lit <question>`: Literature review: the library first, then PubMed, Europe PMC and OpenAlex in one merged search; reads before it summarises; an evidence table, agreement, disagreement and open questions; references checked; one note in `Research/` (Scholar, Author, Editor).
146
147
  * `/compare <question or citekeys>`: Source matrix (claim, evidence type, caveats, confidence), agreements and disagreements (Scholar, Author, Editor).
147
148
  * `/review <path>`: Critical review of your own manuscript: FATAL, MAJOR and MINOR issues against the reporting guideline that applies (CONSORT, STROBE, PRISMA, STARD, TRIPOD), with quotations and a revision plan. Comments only (Author, Editor).
@@ -175,8 +176,8 @@ subsub review apply 13-31
175
176
  | `about` | string | Brief background context about your research domain | `null` |
176
177
  | `language` | string | Response language, or `"auto"` to match your input | `"English"` |
177
178
  | `vault` | string | Path to your Sub-Sub folder | `$ZOTERO_VAULT` |
178
- | `model` | string | The model, as provider/id; `""` to choose it in Sub-Sub | `"opencode-go/mimo-v2.6-pro"` |
179
- | `libraryChanges` | boolean | Library changes on (each change still needs your Yes) | `true` |
179
+ | `models` | object | The model for each mode, as provider/id: `{"librarian": ..., "researcher": ...}`; `{}` to choose them in Sub-Sub. The 0.10 key `model` (one for both modes) still works; `libraryChanges` is ignored. | `{"librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro"}` |
180
+ | `defaultMode` | string | `"researcher"` or `"librarian"`: the mode Sub-Sub starts in | `"researcher"` |
180
181
  | `serverDir` | string | Local development path for `zotero-local-mcp` | PyPI package |
181
182
  | `look` | boolean| Show header banners and status lines in terminal | `true` |
182
183
  | `quotes` | boolean| Display quotes from *Moby-Dick* in terminal banner | `true` |
@@ -201,7 +202,7 @@ Sub-Sub finds open-access copies (Unpaywall, Europe PMC). For other papers, use
201
202
  2. Optional: set `"starbuckDir"` to a local copy of Starbuck. Without it, Sub-Sub runs the pinned Starbuck release with uv.
202
203
  3. Start Sub-Sub again and run `subsub doctor`. The line `Starbuck` must show `ok`.
203
204
 
204
- Sub-Sub gets four tools: `verify_check_manuscript`, `verify_check_references`, `verify_prepare_claims` and `verify_record_claims`. Starbuck writes its report to a `_starbuck` folder next to the manuscript. Inside the Sub-Sub folder or the working folder this needs no approval; elsewhere Sub-Sub asks first. Starbuck gets the contact email from the zotero-local-mcp env file. For the claim check, Sub-Sub's own model judges; Starbuck checks that every quoted passage is in the source.
205
+ The Researcher gets four tools: `verify_check_manuscript`, `verify_check_references`, `verify_prepare_claims` and `verify_record_claims`. Starbuck writes its report to a `_starbuck` folder next to the manuscript. Inside the Sub-Sub folder or the working folder this needs no approval; elsewhere Sub-Sub asks first. Starbuck gets the contact email from the zotero-local-mcp env file. For the claim check, Sub-Sub's own model judges; Starbuck checks that every quoted passage is in the source.
205
206
 
206
207
  ---
207
208
 
package/dist/bench.js CHANGED
@@ -20,8 +20,8 @@ import { expand, loadConfig, starbuckCommand, starbuckEnabled } from "./config.j
20
20
  import { findUv } from "./subsub.js";
21
21
  export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
22
22
  export const DEFAULT_MODELS = {
23
- librarian: ["glm-5.3-flash", "deepseek-v4.1-flash", "qwen3.8-flash", "mimo-v2.6-flash", "minimax-m3", "mimo-v2.6-pro"],
24
- researcher: ["mimo-v2.6-pro", "deepseek-v4-pro", "kimi-k2.6", "glm-5.3", "qwen3.7-plus", "minimax-m3"],
23
+ librarian: ["mimo-v2.6-flash", "glm-5.3-flash", "qwen3.8-flash", "gpt-5.6-luna", "hy3", "mimo-v2.6-pro"],
24
+ researcher: ["mimo-v2.6-pro", "qwen3.8-flash", "kimi-k3", "glm-5.3", "minimax-m3", "gpt-5.6-luna"],
25
25
  };
26
26
  export const TASKS = {
27
27
  librarian: ["tagging", "facet_fix", "import"],
@@ -81,7 +81,8 @@ const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end
81
81
  export function runOne(opts) {
82
82
  mkdirSync(opts.out, { recursive: true });
83
83
  const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
84
- args.push("--librarian"); // library changes on for every task
84
+ if (opts.role === "librarian")
85
+ args.push("--librarian");
85
86
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
86
87
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
87
88
  const events = [];
@@ -557,7 +558,7 @@ export async function main(argv = process.argv.slice(2)) {
557
558
  if (!prompt)
558
559
  return;
559
560
  const cf = join(cfgDir, `${j.code}.json`);
560
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", model: `opencode-go/${j.model}`, models: undefined, libraryChanges: true }));
561
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
561
562
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
562
563
  n++;
563
564
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/dist/config.js 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.
@@ -26,8 +26,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
28
28
  import { isObsidianVault, settingsFile } from "./layout.js";
29
- /** The tested model (September 2026 model test). */
30
- export const DEFAULT_MODEL = "opencode-go/mimo-v2.6-pro";
29
+ /** The tested models, one per mode (model test, see bench-results). */
30
+ export const DEFAULT_MODELS = {
31
+ librarian: "opencode-go/mimo-v2.6-flash",
32
+ researcher: "opencode-go/mimo-v2.6-pro",
33
+ };
31
34
  export const PROFILES = ["reader", "scholar", "author", "editor"];
32
35
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
33
36
  export const SERVER_VERSION = "0.5.1";
@@ -107,10 +110,13 @@ export function loadConfig(env = process.env) {
107
110
  const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
108
111
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
109
112
  const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
110
- // Before 0.10: a model per mode, and themes per mode.
111
- const legacyModels = user.models && typeof user.models === "object" ? user.models : undefined;
112
- // "model": "" means: the user chooses in pi (/login, /model).
113
- const model = "model" in user ? (typeof user.model === "string" && user.model) || undefined : legacyModels ? legacyModels.researcher || legacyModels.librarian || undefined : DEFAULT_MODEL;
113
+ // "models" per mode; "model" (0.10) for both; {} or "" means: the user chooses in pi (/login, /model).
114
+ const one = typeof user.model === "string" ? user.model : undefined;
115
+ const given = user.models && typeof user.models === "object" ? user.models : undefined;
116
+ const models = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : { ...DEFAULT_MODELS });
117
+ for (const k of Object.keys(models))
118
+ if (!models[k])
119
+ delete models[k];
114
120
  const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
115
121
  return {
116
122
  configFile: file,
@@ -122,8 +128,8 @@ export function loadConfig(env = process.env) {
122
128
  serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
123
129
  envFile,
124
130
  vault,
125
- model,
126
- libraryChanges: user.libraryChanges !== false,
131
+ models,
132
+ defaultMode: user.defaultMode === "librarian" ? "librarian" : "researcher",
127
133
  // A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
128
134
  vaultContext: (user.vaultContext ??
129
135
  (vault ? [join(vault, ".claude", "CLAUDE.md"), ...(isObsidianVault(dirname(vault)) ? [join(dirname(vault), ".claude", "CLAUDE.md")] : [])] : [])).map((p) => expand(p, home)),
package/dist/doctor.js CHANGED
@@ -104,7 +104,7 @@ export function runChecks(cfg, env, deps = {}) {
104
104
  out.push(starbuckCheck(cfg, env, run));
105
105
  const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
106
106
  out.push(hasLogin(agentDir)
107
- ? { name: "Model login", ok: true, detail: cfg.model ?? "pi's default model" }
107
+ ? { 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" }
108
108
  : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." });
109
109
  return out;
110
110
  }
package/dist/init.js 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,14 +19,14 @@
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
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { dirname, join, resolve } from "node:path";
27
27
  import { createInterface } from "node:readline/promises";
28
28
  import { fileURLToPath } from "node:url";
29
- import { configPath, DEFAULT_MODEL, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
29
+ import { configPath, DEFAULT_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
30
30
  import { defaultRunner, starbuckCheck } from "./doctor.js";
31
31
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
32
32
  import { PROFILE_SPECS } from "./profiles.js";
@@ -36,7 +36,7 @@ export const STARTER_TAGS = {
36
36
  "health-informatics": "Health informatics: information systems, data, AI, digital health",
37
37
  "any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
38
38
  };
39
- export const TESTED_MODEL = DEFAULT_MODEL;
39
+ export const TESTED_MODELS = DEFAULT_MODELS;
40
40
  /** Set KEY=VALUE lines in an env file text; other lines and comments stay. */
41
41
  export function upsertEnv(text, values) {
42
42
  const lines = text ? text.replace(/\n$/, "").split("\n") : [];
@@ -125,11 +125,6 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
125
125
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
126
126
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
127
127
  const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
128
- explain("Library changes", "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.");
129
- const library = await io.choose("Library changes", [
130
- { value: "on", label: "On (recommended): every change shows a preview first" },
131
- { value: "off", label: "Off: read, search and write notes only" },
132
- ], flags.library ?? (existing.libraryChanges === false ? "off" : "on"));
133
128
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
134
129
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
135
130
  const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
@@ -155,16 +150,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
155
150
  { value: "off", label: "Off" },
156
151
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
157
152
  ], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
158
- 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).");
153
+ explain("Models", "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).");
159
154
  const hadModel = "model" in existing || "models" in existing;
155
+ const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
160
156
  const modelOptions = [
161
- ...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
157
+ ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
162
158
  { value: "later", label: "Choose later in Sub-Sub" },
163
- { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
159
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
164
160
  ];
165
161
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
166
162
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
167
- const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
163
+ const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
168
164
  // ---- write
169
165
  if (move === "yes")
170
166
  applyMoves(moves);
@@ -199,18 +195,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
199
195
  setup: fmup ? "fmup" : undefined,
200
196
  vault: notes,
201
197
  envFile,
202
- libraryChanges: library === "on",
203
- defaultMode: undefined,
198
+ libraryChanges: undefined,
204
199
  addons: withStarbuck(current.addons, starbuck === "on"),
205
200
  };
206
- // One model since 0.10: the per-mode models are replaced.
207
- patch.models = undefined;
201
+ // A model per mode; "model" (0.10, one for both) is replaced.
202
+ patch.model = undefined;
208
203
  if (models === "keep")
209
- patch.model = current.model ?? "";
204
+ patch.models = current.models;
210
205
  if (models === "later")
211
- patch.model = "";
206
+ patch.models = {};
212
207
  if (models === "opencode-go")
213
- patch.model = TESTED_MODEL;
208
+ patch.models = TESTED_MODELS;
214
209
  saveConfig(patch, initEnv);
215
210
  const zotero = await zoteroReachable(deps.fetch);
216
211
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
package/dist/look.js CHANGED
@@ -63,7 +63,7 @@ export function libraryLine(st) {
63
63
  export function headerLines(opts) {
64
64
  const p = opts.paint ?? plain;
65
65
  const w = Math.max(10, opts.width);
66
- const modeLabel = `Assistant${opts.profile ? `, ${opts.profile}` : ""}${opts.libraryChanges ? "" : ", read only"}`;
66
+ const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
67
67
  const lib = libraryLine(opts.library);
68
68
  if (w < BANNER[0].length + 2) {
69
69
  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))];
@@ -78,7 +78,7 @@ export function headerLines(opts) {
78
78
  else
79
79
  out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
80
80
  }
81
- out.push(p.dim(cut("/library /profile /history /undo | / for all commands", w)), "");
81
+ out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
82
82
  return out;
83
83
  }
84
84
  /**
package/dist/prompt.js CHANGED
@@ -47,16 +47,13 @@ export function fill(text, cfg) {
47
47
  .replaceAll("{{batch}}", String(profileSpec(cfg.profile).batch))
48
48
  .replace(/[ \t]+\n/g, "\n");
49
49
  }
50
- export function roleText(cfg) {
51
- const raw = readIf(join(ROLE_DIR, "assistant.md")) ?? "You are Sub-Sub, a Zotero librarian and research assistant.";
50
+ export function roleText(mode, cfg) {
51
+ const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
52
52
  return cfg ? fill(raw, cfg) : raw;
53
53
  }
54
- export function systemAddition(cfg, cwd, libraryChanges = cfg.libraryChanges) {
54
+ export function systemAddition(mode, cfg, cwd) {
55
55
  const user = who(cfg);
56
- const parts = ["# Sub-Sub", roleText(cfg)];
57
- if (!libraryChanges) {
58
- parts.push(`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).`);
59
- }
56
+ const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
60
57
  const shared = readIf(cfg.sharedRules);
61
58
  if (shared) {
62
59
  parts.push("# Shared Zotero rules (from the Sub-Sub folder)", shared, `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`);
package/dist/roles.js CHANGED
@@ -1,9 +1,9 @@
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
  import { profileSpec } from "./profiles.js";
5
5
  export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
6
- /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the switch and the profile for these). */
6
+ /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
7
7
  export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
8
8
  export const ZOTERO_READ = [
9
9
  "status",
@@ -43,33 +43,33 @@ export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
43
43
  /** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
44
44
  export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map((n) => `zotero_${n}`);
45
45
  /**
46
- * The Assistant's tools: reading the library, searching, notes and reference
47
- * checks always; the Zotero write tools when library changes are on. The profile
48
- * then removes tools.
46
+ * The tools of a mode. The Librarian manages the library: Zotero tools and files.
47
+ * The Researcher has the same, plus searches, notes and reference checks. The
48
+ * profile then removes tools. Every library change is still previewed (gateKind).
49
49
  */
50
- export function toolsFor(registered, opts) {
50
+ export function toolsFor(mode, registered, opts = {}) {
51
51
  const has = new Set(registered);
52
52
  const without = new Set(profileSpec(opts.profile ?? "editor").without);
53
53
  const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
54
54
  const wanted = [
55
55
  ...ZOTERO_READ,
56
56
  ...ZOTERO_CHECKS,
57
- ...(opts.libraryChanges ? zotero : []),
58
- ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
57
+ ...zotero,
58
+ ...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
59
59
  ...FILE_TOOLS,
60
60
  ];
61
61
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
62
62
  }
63
63
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
64
- export function unavailableReason(name, registered, opts) {
65
- if (toolsFor(registered, opts).includes(name))
64
+ export function unavailableReason(name, mode, registered, opts = {}) {
65
+ if (toolsFor(mode, registered, opts).includes(name))
66
66
  return undefined;
67
67
  const profile = opts.profile ?? "editor";
68
68
  if (profileSpec(profile).without.includes(name)) {
69
69
  return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
70
70
  }
71
- if (!opts.libraryChanges && toolsFor(registered, { ...opts, libraryChanges: true }).includes(name)) {
72
- return `${name} changes the library, and library changes are off. The user can turn them on with /library on.`;
71
+ if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
72
+ return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
73
73
  }
74
74
  return `${name} is not available.`;
75
75
  }
package/dist/subsub.js CHANGED
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Sub-Sub: a Zotero librarian and research assistant for pi.
3
3
  *
4
- * - One assistant: it reads the library, searches PubMed, Europe PMC and
5
- * OpenAlex, writes notes in the Sub-Sub folder and, with library changes on,
6
- * changes the library. /library on|off is the switch (saved in the settings).
4
+ * - Two modes: the Librarian manages the library (Zotero tools only); the
5
+ * Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
6
+ * and writes notes. Each mode can have its own model.
7
7
  * - An approval gate: every library change is previewed by the server (dry run)
8
8
  * and shown to the user; nothing is applied without a yes.
9
9
  * - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
10
10
  * tools it offers; /profile changes it.
11
- * - Commands: /library, /profile, /subsub, /history, /undo. /librarian and
12
- * /researcher (the two modes before 0.10) turn library changes on and off.
11
+ * - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
13
12
  * - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
14
13
  * ("verify") for reference checks.
15
14
  *
@@ -29,6 +28,7 @@ import { describeArgs, formatPreview } from "./preview.js";
29
28
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
30
29
  import { systemAddition } from "./prompt.js";
31
30
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
31
+ const MODE_ENTRY = "subsub-mode";
32
32
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
33
33
  const BENCH_ENTRY = "subsub-bench";
34
34
  function ownVersion() {
@@ -103,11 +103,10 @@ export async function createSubsub(pi, deps = {}) {
103
103
  }
104
104
  if (deps.bridge)
105
105
  registerTools(deps.bridge);
106
- pi.registerFlag("read-only", { description: "Start Sub-Sub with library changes off (this run only)", type: "boolean", default: false });
107
- pi.registerFlag("librarian", { description: "Start Sub-Sub with library changes on (this run only)", type: "boolean", default: false });
108
- let libraryChanges = cfg.libraryChanges;
106
+ pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
107
+ let mode = cfg.defaultMode;
109
108
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
110
- const toolOpts = () => ({ libraryChanges, profile, bench: benchRun });
109
+ const toolOpts = () => ({ profile, bench: benchRun });
111
110
  let profile = cfg.profile;
112
111
  const user = who(cfg);
113
112
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -129,7 +128,7 @@ export async function createSubsub(pi, deps = {}) {
129
128
  return;
130
129
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
131
130
  const state = {
132
- libraryChanges,
131
+ mode,
133
132
  profile,
134
133
  profileLabel: profileSpec(profile).label,
135
134
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
@@ -192,7 +191,7 @@ export async function createSubsub(pi, deps = {}) {
192
191
  const th = ctx.ui.theme;
193
192
  return headerLines({
194
193
  version,
195
- libraryChanges,
194
+ mode,
196
195
  profile: profileSpec(profile).label,
197
196
  model: modelId,
198
197
  library,
@@ -214,38 +213,28 @@ export async function createSubsub(pi, deps = {}) {
214
213
  function status(ctx) {
215
214
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
216
215
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
217
- const lib = libraryChanges ? "library changes on" : "read only";
218
- ctx.ui.setStatus("subsub", `Sub-Sub: ${lib} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
216
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
219
217
  if (standalone)
220
- ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
218
+ ctx.ui.setTitle(`Sub-Sub: ${mode}`);
221
219
  headerTui?.requestRender();
222
220
  webCtx = ctx;
223
221
  emitWeb();
224
222
  }
225
- /** Set the tools for the switch and the profile; save the switch when asked. */
226
- function applyLibrary(ctx, on, save) {
227
- libraryChanges = on;
228
- pi.setActiveTools(toolsFor(registered(), toolOpts()));
229
- if (save) {
230
- try {
231
- saveConfig({ libraryChanges: on });
232
- }
233
- catch (err) {
234
- ctx.ui.notify(`Sub-Sub: the setting was not saved: ${err.message}`, "warning");
235
- }
223
+ async function applyMode(ctx, next, remember) {
224
+ mode = next;
225
+ pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
226
+ const spec = cfg.models[mode];
227
+ if (spec) {
228
+ const i = spec.indexOf("/");
229
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
230
+ const ok = model ? await pi.setModel(model) : false;
231
+ if (!ok)
232
+ ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
236
233
  }
234
+ if (remember)
235
+ pi.appendEntry(MODE_ENTRY, { mode });
237
236
  status(ctx);
238
237
  }
239
- async function applyModel(ctx) {
240
- const spec = cfg.model;
241
- if (!spec)
242
- return;
243
- const i = spec.indexOf("/");
244
- const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
245
- const ok = model ? await pi.setModel(model) : false;
246
- if (!ok)
247
- ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
248
- }
249
238
  pi.on("session_start", async (_event, ctx) => {
250
239
  if (!bridge) {
251
240
  const b = new Bridge();
@@ -253,13 +242,17 @@ export async function createSubsub(pi, deps = {}) {
253
242
  bridge = b;
254
243
  registerTools(b);
255
244
  }
245
+ let restored;
246
+ for (const e of ctx.sessionManager.getBranch()) {
247
+ const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
248
+ if (m === "librarian" || m === "researcher")
249
+ restored = m;
250
+ }
256
251
  // Judge light or dark before Sub-Sub changes the theme.
257
252
  if (lookOn(ctx))
258
253
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
259
- await applyModel(ctx);
260
254
  applyTheme(ctx);
261
- // The flags change this run only; the saved setting stays.
262
- applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
255
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
263
256
  if (lookOn(ctx)) {
264
257
  setHeader(ctx);
265
258
  void refreshLibrary();
@@ -289,7 +282,7 @@ export async function createSubsub(pi, deps = {}) {
289
282
  return { action: "handled" };
290
283
  });
291
284
  pi.on("before_agent_start", async (event, ctx) => ({
292
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd, libraryChanges)}`,
285
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
293
286
  }));
294
287
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
295
288
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
@@ -303,7 +296,7 @@ export async function createSubsub(pi, deps = {}) {
303
296
  coerceArgs(schema, input);
304
297
  resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
305
298
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
306
- const why = unavailableReason(name, registered(), toolOpts());
299
+ const why = unavailableReason(name, mode, registered(), toolOpts());
307
300
  if (why) {
308
301
  if (benchOut)
309
302
  record({ kind: "wrong_mode", tool: name, input });
@@ -426,35 +419,18 @@ export async function createSubsub(pi, deps = {}) {
426
419
  `for ${user}.${shown}`,
427
420
  };
428
421
  }
429
- const libraryNote = (on) => on
430
- ? "Sub-Sub: library changes on. Every change shows a preview first."
431
- : "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
432
- pi.registerCommand("library", {
433
- description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
434
- handler: async (args, ctx) => {
435
- const a = args.trim().toLowerCase();
436
- const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
437
- if (on === undefined) {
438
- ctx.ui.notify("Type /library on or /library off.", "warning");
439
- return;
440
- }
441
- applyLibrary(ctx, on, true);
442
- ctx.ui.notify(libraryNote(on), "info");
443
- },
444
- });
445
- // The two modes before 0.10, kept so that old habits and notes still work.
446
422
  pi.registerCommand("librarian", {
447
- description: "Sub-Sub: same as /library on",
423
+ description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
448
424
  handler: async (_args, ctx) => {
449
- applyLibrary(ctx, true, true);
450
- ctx.ui.notify(`${libraryNote(true)} (/librarian is now /library on.)`, "info");
425
+ await applyMode(ctx, "librarian", true);
426
+ ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
451
427
  },
452
428
  });
453
429
  pi.registerCommand("researcher", {
454
- description: "Sub-Sub: same as /library off",
430
+ description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
455
431
  handler: async (_args, ctx) => {
456
- applyLibrary(ctx, false, true);
457
- ctx.ui.notify(`${libraryNote(false)} (/researcher is now /library off.)`, "info");
432
+ await applyMode(ctx, "researcher", true);
433
+ ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
458
434
  },
459
435
  });
460
436
  pi.registerCommand("profile", {
@@ -477,14 +453,14 @@ export async function createSubsub(pi, deps = {}) {
477
453
  catch (err) {
478
454
  saved = ` Not saved: ${err.message}`;
479
455
  }
480
- applyLibrary(ctx, libraryChanges, false);
456
+ await applyMode(ctx, mode, false);
481
457
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
482
458
  },
483
459
  });
484
460
  pi.registerCommand("subsub", {
485
461
  description: "Sub-Sub: Zotero connection and library overview",
486
462
  handler: async (_args, ctx) => {
487
- const lines = [`Library changes: ${libraryChanges ? "on" : "off"}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
463
+ const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
488
464
  if (!bridge) {
489
465
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
490
466
  return;