@tiagojct/subsub 0.10.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/dist/web.js CHANGED
@@ -21,8 +21,8 @@ import { homedir } from "node:os";
21
21
  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
- import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
25
- import { TESTED_MODEL } from "./init.js";
24
+ import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.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) {
@@ -300,7 +300,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
300
300
  const bin = join(PACKAGE_DIR, "bin", "subsub.js");
301
301
  env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
302
302
  const childEnv = { ...env, SUBSUB_WEB: "1" };
303
- const baseArgs = [bin, "--mode", "rpc", "--here", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
303
+ const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
304
304
  const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
305
305
  // ---- state shared with the pages
306
306
  const clients = new Set();
@@ -309,7 +309,19 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
309
309
  let busy = false;
310
310
  let lastSeen = Date.now();
311
311
  let restarting = false;
312
+ let switching = false; // a restart for a new model, add-on or key is under way
313
+ let lastSession;
312
314
  let refreshAfterRun = false;
315
+ /** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
316
+ function modeArgs() {
317
+ const shown = subsubState?.mode;
318
+ if (shown === "librarian" || shown === "researcher")
319
+ return [`--${shown}`];
320
+ return opts.librarian ? ["--librarian"] : [];
321
+ }
322
+ function startAgent() {
323
+ agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
324
+ }
313
325
  function broadcast(ev) {
314
326
  const data = `data: ${JSON.stringify(ev)}\n\n`;
315
327
  for (const c of clients)
@@ -352,11 +364,13 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
352
364
  if (restarting)
353
365
  return;
354
366
  busy = false;
367
+ for (const id of dialogs.keys())
368
+ broadcast({ type: "dialog_closed", id });
355
369
  dialogs.clear();
356
370
  broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
357
371
  log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
358
372
  };
359
- agent.start();
373
+ startAgent();
360
374
  async function snapshot() {
361
375
  const out = {
362
376
  type: "hello",
@@ -369,6 +383,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
369
383
  };
370
384
  try {
371
385
  out.session = await agent.request({ type: "get_state" });
386
+ lastSession = out.session.sessionFile ?? lastSession;
372
387
  out.messages = (await agent.request({ type: "get_messages" })).messages;
373
388
  out.commands = (await agent.request({ type: "get_commands" })).commands;
374
389
  }
@@ -387,12 +402,11 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
387
402
  .map((s) => ({ path: s.path, name: s.name ?? null, first: s.firstMessage.slice(0, 140), modified: s.modified, messages: s.messageCount }));
388
403
  }
389
404
  async function restartAgent() {
390
- let sessionFile;
391
405
  try {
392
- sessionFile = (await agent.request({ type: "get_state" })).sessionFile;
406
+ lastSession = (await agent.request({ type: "get_state" })).sessionFile ?? lastSession;
393
407
  }
394
408
  catch {
395
- /* start fresh */
409
+ /* keep the last one known */
396
410
  }
397
411
  restarting = true;
398
412
  for (const id of dialogs.keys())
@@ -401,9 +415,23 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
401
415
  await agent.stop();
402
416
  restarting = false;
403
417
  busy = false;
404
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
418
+ startAgent();
405
419
  broadcast(await snapshot());
406
420
  }
421
+ /** Run a change that restarts pi, one at a time. */
422
+ async function withRestart(res, change) {
423
+ if (busy || switching)
424
+ return send(res, 409, { error: "busy" });
425
+ switching = true;
426
+ try {
427
+ await change();
428
+ await restartAgent();
429
+ }
430
+ finally {
431
+ switching = false;
432
+ }
433
+ return send(res, 200, { ok: true });
434
+ }
407
435
  // ---- idle stop
408
436
  let closing = false;
409
437
  const idleTimer = setInterval(() => {
@@ -545,44 +573,50 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
545
573
  return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
546
574
  }
547
575
  case "POST /api/model": {
548
- if (busy)
576
+ if (busy || switching)
549
577
  return send(res, 409, { error: "busy" });
550
578
  const body = await readJson(req);
551
579
  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);
555
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
556
- await restartAgent();
557
- return send(res, 200, { ok: true });
580
+ // Remember the model for the current mode (or both), then restart pi so the extension reads it.
581
+ const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
582
+ // Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
583
+ const raw = readConfigFile(configPath(env));
584
+ const saved = raw.models && typeof raw.models === "object" ? raw.models : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
585
+ const models = { ...saved, [mode]: spec };
586
+ if (body.both)
587
+ models[mode === "librarian" ? "researcher" : "librarian"] = spec;
588
+ saveConfig({ models, model: undefined }, env);
589
+ return withRestart(res, async () => {
590
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
591
+ });
558
592
  }
559
593
  case "POST /api/addons": {
560
594
  // Turn an add-on on or off, then restart pi so the extension starts or stops its server.
561
- if (busy)
595
+ if (busy || switching)
562
596
  return send(res, 409, { error: "busy" });
563
597
  const body = await readJson(req);
564
598
  if (typeof body.starbuck !== "boolean")
565
599
  return send(res, 400, { error: "starbuck must be true or false" });
566
- saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
567
- await restartAgent();
568
- return send(res, 200, { ok: true });
600
+ return withRestart(res, () => {
601
+ saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
602
+ });
569
603
  }
570
604
  case "POST /api/restart":
571
- if (agent.proc)
605
+ if (agent.proc || switching)
572
606
  return send(res, 200, { ok: true });
573
- agent.start();
607
+ startAgent();
574
608
  broadcast(await snapshot());
575
609
  return send(res, 200, { ok: true });
576
610
  case "POST /api/login": {
577
- if (busy)
611
+ if (busy || switching)
578
612
  return send(res, 409, { error: "busy" });
579
613
  const body = await readJson(req);
580
- saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
581
- const current = loadConfig(env);
582
- if (body.provider === "opencode-go" && !current.model)
583
- saveConfig({ model: TESTED_MODEL, models: undefined }, env);
584
- await restartAgent();
585
- return send(res, 200, { ok: true });
614
+ return withRestart(res, () => {
615
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
616
+ const current = loadConfig(env);
617
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
618
+ saveConfig({ models: TESTED_MODELS, model: undefined }, env);
619
+ });
586
620
  }
587
621
  case "POST /api/open": {
588
622
  const body = await readJson(req);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.10.0",
4
- "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
3
+ "version": "0.11.1",
4
+ "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Tiago Jacinto",
@@ -49,7 +49,6 @@
49
49
  "assets",
50
50
  "bin",
51
51
  "dist",
52
- "docs",
53
52
  "extensions",
54
53
  "prompts",
55
54
  "roles",
@@ -2,4 +2,4 @@
2
2
  description: "Literature note for one item"
3
3
  argument-hint: "<citekey or title words>"
4
4
  ---
5
- Make a literature note for $ARGUMENTS in Resources/Zotero/, following the template in the shared rules. Read the full text if it is indexed. Then offer to link it in Zotero.
5
+ Make a literature note for $ARGUMENTS in Literature/ in the Sub-Sub folder, following the template in the shared rules. Read the full text if it is indexed. Then offer to link it in Zotero.
@@ -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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * subsub-bench: the same tasks for several models, against the real library,
3
- * with no changes applied. See docs/Subsub.md ("Model test").
3
+ * with no changes applied. See docs/model-test.md.
4
4
  *
5
5
  * subsub-bench prepare [--seed 7] pick the tasks (read-only)
6
6
  * subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
@@ -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[]> = {
@@ -82,7 +79,7 @@ export function promptFor(task: string, t: Tasks, code: string, out: string): st
82
79
  case "lit_note":
83
80
  return t.lit_note
84
81
  ? `Make a literature note for ${t.lit_note.citekey}, following the literature-note format in the shared rules. Read the full text. ` +
85
- `This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Resources/Zotero/, and do not attach anything to Zotero.`
82
+ `This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Literature/ in the Sub-Sub folder, and do not attach anything to Zotero.`
86
83
  : undefined;
87
84
  case "synthesis":
88
85
  return t.synthesis
@@ -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/cli.ts CHANGED
@@ -134,12 +134,38 @@ export function reviewCommand(args: string[], cfg: SubsubConfig, env: NodeJS.Pro
134
134
  };
135
135
  }
136
136
 
137
+ const HELP = `Sub-Sub: your research assistant, working from your own Zotero library.
138
+
139
+ Usage:
140
+ subsub Start Sub-Sub in the terminal
141
+ subsub --librarian Start in the Librarian, which only manages the library
142
+ subsub web Open Sub-Sub in the browser
143
+ subsub init Set up or change the settings (folder, tag list, profile, models)
144
+ subsub doctor Check the set-up; each line marked FIX says what to do
145
+ subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
146
+ subsub review preview|apply <note>
147
+ Check or apply a tag review note from Inbox/
148
+ subsub --version Show the versions
149
+
150
+ Options:
151
+ --here Work in the current folder, not in the Sub-Sub folder
152
+
153
+ In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
154
+ /profile to change the profile, /undo to undo the last change, and / for all commands.
155
+ Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
156
+
157
+ Docs: https://subsub.tiagojacinto.eu`;
158
+
137
159
  export async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
138
160
  const env = process.env;
139
161
  const home = homedir();
140
162
  const here = argv.includes("--here");
141
163
  const args = argv.filter((a) => a !== "--here");
142
164
 
165
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
166
+ console.log(HELP);
167
+ return;
168
+ }
143
169
  if (args[0] === "--version" || args[0] === "-v") {
144
170
  const piPkg = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..");
145
171
  console.log(`subsub ${packageVersion()} (pi ${packageVersion(piPkg)})`);
@@ -173,7 +199,12 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
173
199
  return;
174
200
  }
175
201
  if (isSelfUpdate(args)) {
176
- console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
202
+ if (PACKAGE_DIR.split(/[\\/]/).includes("node_modules")) {
203
+ console.log("Sub-Sub is not updated with \"update\". To update it, run the installer again (https://subsub.tiagojacinto.eu/install/);");
204
+ console.log("if you installed it with npm, type: npm install -g @tiagojct/subsub");
205
+ } else {
206
+ console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
207
+ }
177
208
  console.log('To update packages added to Sub-Sub, use "subsub update --extensions".');
178
209
  process.exitCode = 1;
179
210
  return;
@@ -206,6 +237,8 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
206
237
  env.SUBSUB_CLI = "1";
207
238
  // pi's update notice would point to the global pi, not to this copy.
208
239
  env.PI_SKIP_VERSION_CHECK ??= "1";
240
+ // pi reports a first install to pi.dev and names itself to some providers; Sub-Sub sends no usage data.
241
+ env.PI_TELEMETRY ??= "0";
209
242
 
210
243
  process.argv = [process.argv[0], process.argv[1], ...args];
211
244
  const index = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
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,13 @@ 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
+ // Without a settings file (no subsub init yet), the user chooses in pi: no default that may lack a key.
163
+ const fallback = existsSync(file) ? { ...DEFAULT_MODELS } : {};
164
+ const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : fallback);
165
+ for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
159
166
  const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
160
167
  return {
161
168
  configFile: file,
@@ -167,8 +174,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
167
174
  serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
168
175
  envFile,
169
176
  vault,
170
- model,
171
- libraryChanges: user.libraryChanges !== false,
177
+ models,
178
+ defaultMode: user.defaultMode === "librarian" ? "librarian" : "researcher",
172
179
  // A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
173
180
  vaultContext: (
174
181
  user.vaultContext ??
package/src/doctor.ts CHANGED
@@ -123,7 +123,8 @@ export function runChecks(
123
123
  );
124
124
  const v = st.vocabulary ?? {};
125
125
  if (v.loaded === false || !v.tags) {
126
- out.push({ name: "Tag list", ok: false, detail: v.path ? `cannot read ${v.path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
126
+ const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
127
+ out.push({ name: "Tag list", ok: false, detail: path ? `cannot read ${path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
127
128
  } else {
128
129
  const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
129
130
  out.push({
@@ -148,7 +149,7 @@ export function runChecks(
148
149
  const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
149
150
  out.push(
150
151
  hasLogin(agentDir)
151
- ? { name: "Model login", ok: true, detail: cfg.model ?? "pi's default model" }
152
+ ? { 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
153
  : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
153
154
  );
154
155
  return out;