@tiagojct/subsub 0.9.0 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +14 -16
  2. package/dist/args.js +23 -0
  3. package/dist/bench.js +8 -7
  4. package/dist/bridge.js +2 -2
  5. package/dist/config.js +17 -10
  6. package/dist/doctor.js +2 -2
  7. package/dist/init.js +25 -13
  8. package/dist/layout.js +9 -0
  9. package/dist/look.js +5 -5
  10. package/dist/profiles.js +6 -6
  11. package/dist/prompt.js +7 -4
  12. package/dist/roles.js +26 -17
  13. package/dist/subsub.js +71 -43
  14. package/dist/web.js +8 -11
  15. package/docs/Subsub.md +16 -16
  16. package/package.json +1 -1
  17. package/prompts/alert.md +1 -1
  18. package/prompts/clean-tags.md +1 -1
  19. package/prompts/compare.md +1 -1
  20. package/prompts/digest.md +2 -2
  21. package/prompts/gaps.md +2 -2
  22. package/prompts/import-queue.md +1 -1
  23. package/prompts/lit-note.md +1 -1
  24. package/prompts/lit.md +2 -2
  25. package/prompts/manuscript.md +1 -1
  26. package/prompts/request-copy.md +1 -1
  27. package/prompts/review.md +1 -1
  28. package/prompts/synthesis.md +1 -1
  29. package/prompts/tag-batch.md +1 -1
  30. package/prompts/verify.md +1 -1
  31. package/roles/assistant.md +95 -0
  32. package/src/args.ts +24 -0
  33. package/src/bench.ts +12 -7
  34. package/src/bridge.ts +2 -2
  35. package/src/config.ts +25 -17
  36. package/src/doctor.ts +2 -2
  37. package/src/init.ts +32 -12
  38. package/src/layout.ts +8 -0
  39. package/src/look.ts +7 -7
  40. package/src/profiles.ts +6 -6
  41. package/src/prompt.ts +10 -5
  42. package/src/roles.ts +38 -20
  43. package/src/subsub.ts +72 -41
  44. package/src/web.ts +7 -9
  45. package/templates/Zotero agent.md +6 -6
  46. package/web/app.css +16 -22
  47. package/web/app.js +37 -26
  48. package/web/i18n.js +37 -35
  49. package/web/index.html +2 -6
  50. package/roles/librarian.md +0 -46
  51. package/roles/researcher.md +0 -50
package/README.md CHANGED
@@ -17,9 +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
- * **Two Dedicated Roles**:
21
- * 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *Runs completely offline with no web search access.*
22
- * 🔬 **The Researcher**: Reads items, searches PubMed and OpenAlex, finds gaps, checks citations in manuscripts, and writes structured Markdown notes in your Sub-Sub folder (it can sit inside an Obsidian vault). *Cannot modify library records.*
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.
23
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.
24
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.
25
23
 
@@ -32,7 +30,7 @@ Profiles calibrate how much Sub-Sub drafts and executes for you. Choose your pro
32
30
  | Profile | Description |
33
31
  |---|---|
34
32
  | **Reader** | Guided and instructional. Reading notes are quotes with page numbers and reflective questions. Never generates unrequested summaries or syntheses. No bulk library edits. |
35
- | **Scholar** | Full research workflow: literature notes, conceptual syntheses, database searches, alerts, and the full librarian toolkit. |
33
+ | **Scholar** | Full research workflow: literature notes, conceptual syntheses, database searches, alerts, and all library changes. |
36
34
  | **Author** | Scholar capabilities plus manuscript workflows: citation auditing against your library, automated Pandoc/Quarto bibliographies, and argument analysis. It critiques but does not write your paragraphs. |
37
35
  | **Editor** | Everything unlocked, including drafting full manuscript sections and abstracts upon request. |
38
36
 
@@ -106,7 +104,8 @@ Starting Sub-Sub with `subsub web` (or `subsub serve`) launches the browser appl
106
104
 
107
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>.
108
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.
109
- * **Theme Switcher**: Header toggle between **Light**, **Dark**, and **System** themes, with tailored styling for each mode (Try-Works amber for Librarian, Glauca blue for Researcher).
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.
108
+ * **Theme Switcher**: Header toggle between **Light**, **Dark**, and **System** themes.
110
109
  * **One-Click Copy**: Built-in copy buttons on all code snippets, BibTeX records, and assistant literature notes.
111
110
  * **Session Search & Markdown Export**: Filter conversation history in real time and download full discussions as Markdown notes.
112
111
  * **Local Security**: Binds strictly to `127.0.0.1`. The initial link carries a random token that converts into an `HttpOnly`, `SameSite=Strict` cookie. Protected with strict Content Security Policy (CSP), origin validation, and sanitized output.
@@ -116,11 +115,11 @@ Starting Sub-Sub with `subsub web` (or `subsub serve`) launches the browser appl
116
115
 
117
116
  ## Usage
118
117
 
119
- ### Interactive Modes & Commands
118
+ ### Starting and commands
120
119
 
121
120
  ```sh
122
- subsub # Start in researcher mode
123
- subsub --librarian # Start in librarian mode
121
+ subsub # Start in the terminal
122
+ subsub --read-only # Start with library changes off (this session only)
124
123
  subsub web # Start browser view
125
124
  subsub -c # Continue the previous session
126
125
  subsub -r # Resume an older session
@@ -128,8 +127,7 @@ subsub -r # Resume an older session
128
127
 
129
128
  Commands available within Sub-Sub:
130
129
 
131
- * `/librarian`: Switch to the librarian (modify library, tag, import; offline).
132
- * `/researcher`: Switch to the researcher (read library, search literature, write notes).
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.
133
131
  * `/profile [name]`: View or change active profile (`reader`, `scholar`, `author`, `editor`).
134
132
  * `/subsub`: Display Zotero connection health and library statistics.
135
133
  * `/history`: List recent library changes with journal IDs.
@@ -139,11 +137,11 @@ Commands available within Sub-Sub:
139
137
 
140
138
  Type the command followed by arguments:
141
139
 
142
- * **Librarian**:
140
+ * **Library** (need library changes on):
143
141
  * `/tag-batch [size]`: Tag untagged items using your tag vocabulary.
144
142
  * `/clean-tags`: Find irregular tags and reconcile with your vocabulary.
145
143
  * `/import-queue [path]`: Import pending references from an inbox note.
146
- * **Researcher**:
144
+ * **Research**:
147
145
  * `/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).
148
146
  * `/compare <question or citekeys>`: Source matrix (claim, evidence type, caveats, confidence), agreements and disagreements (Scholar, Author, Editor).
149
147
  * `/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).
@@ -177,12 +175,12 @@ subsub review apply 13-31
177
175
  | `about` | string | Brief background context about your research domain | `null` |
178
176
  | `language` | string | Response language, or `"auto"` to match your input | `"English"` |
179
177
  | `vault` | string | Path to your Sub-Sub folder | `$ZOTERO_VAULT` |
180
- | `models` | object | Model overrides per mode (e.g. `{"librarian": "opencode-go/glm-5.3-flash"}`) | Current model |
181
- | `defaultMode`| string | Default launch mode (`"researcher"` or `"librarian"`) | `"researcher"` |
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` |
182
180
  | `serverDir` | string | Local development path for `zotero-local-mcp` | PyPI package |
183
181
  | `look` | boolean| Show header banners and status lines in terminal | `true` |
184
182
  | `quotes` | boolean| Display quotes from *Moby-Dick* in terminal banner | `true` |
185
- | `themes` | object | Mode-specific terminal themes | `true` |
183
+ | `themes` | string or false | Terminal theme family (`subsub-glauca` or `subsub-try-works`); `false` keeps your pi theme | `"subsub-glauca"` |
186
184
  | `addons` | array | Optional servers; `["starbuck"]` adds reference checks | `[]` |
187
185
  | `starbuckDir` | string | Local Starbuck folder (used when it has a `pyproject.toml`) | `~/Projects/starbuck` |
188
186
 
@@ -203,7 +201,7 @@ Sub-Sub finds open-access copies (Unpaywall, Europe PMC). For other papers, use
203
201
  2. Optional: set `"starbuckDir"` to a local copy of Starbuck. Without it, Sub-Sub runs the pinned Starbuck release with uv.
204
202
  3. Start Sub-Sub again and run `subsub doctor`. The line `Starbuck` must show `ok`.
205
203
 
206
- 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.
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.
207
205
 
208
206
  ---
209
207
 
package/dist/args.js CHANGED
@@ -65,3 +65,26 @@ export function coerceArgs(original, input) {
65
65
  }
66
66
  return input;
67
67
  }
68
+ /**
69
+ * Arguments that name a file in the user's working folder (a manuscript, a report
70
+ * folder, a bibliography to write). The servers run in their own working folder, so
71
+ * Sub-Sub makes these paths absolute against its own, as pi's file tools read them.
72
+ * Note paths (tag reviews, the import queue, linked notes) are not here: the servers
73
+ * read those from the Sub-Sub folder.
74
+ */
75
+ export const CWD_PATH_ARGS = {
76
+ scholar_check_manuscript: ["path"],
77
+ scholar_export_bibliography: ["output_path"],
78
+ verify_check_manuscript: ["path", "report_dir"],
79
+ verify_prepare_claims: ["path", "report_dir"],
80
+ verify_record_claims: ["path", "report_dir"],
81
+ };
82
+ /** Make the working-folder paths of a tool call absolute. Changes input in place. */
83
+ export function resolvePathArgs(tool, input, toAbsolute) {
84
+ for (const k of CWD_PATH_ARGS[tool] ?? []) {
85
+ const v = input[k];
86
+ if (typeof v === "string" && v.trim())
87
+ input[k] = toAbsolute(v);
88
+ }
89
+ return input;
90
+ }
package/dist/bench.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * with no changes applied. See docs/Subsub.md ("Model test").
4
4
  *
5
5
  * subsub-bench prepare [--seed 7] pick the tasks (read-only)
6
- * subsub-bench run [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
6
+ * subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
7
+ * (--models sets the models for both task groups.)
7
8
  * subsub-bench score objective checks -> results.md
8
9
  *
9
10
  * Results go to ~/Projects/subsub/bench-results/<date>/ (or --dir). Each model gets
@@ -80,8 +81,7 @@ const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end
80
81
  export function runOne(opts) {
81
82
  mkdirSync(opts.out, { recursive: true });
82
83
  const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
83
- if (opts.role === "librarian")
84
- args.push("--librarian");
84
+ args.push("--librarian"); // library changes on for every task
85
85
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
86
86
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
87
87
  const events = [];
@@ -530,9 +530,10 @@ export async function main(argv = process.argv.slice(2)) {
530
530
  if (!cfg.vault)
531
531
  throw new Error("No vault configured (ZOTERO_VAULT).");
532
532
  const t = JSON.parse(readFileSync(tasksFile, "utf8"));
533
+ const both = arg(argv, "models")?.split(",").filter(Boolean);
533
534
  const lists = {
534
- librarian: arg(argv, "librarian")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.librarian,
535
- researcher: arg(argv, "researcher")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.researcher,
535
+ librarian: arg(argv, "librarian")?.split(",").filter(Boolean) ?? both ?? DEFAULT_MODELS.librarian,
536
+ researcher: arg(argv, "researcher")?.split(",").filter(Boolean) ?? both ?? DEFAULT_MODELS.researcher,
536
537
  };
537
538
  const only = arg(argv, "only")?.split(",");
538
539
  const codes = codesFor([...new Set([...lists.librarian, ...lists.researcher])], dir);
@@ -556,7 +557,7 @@ export async function main(argv = process.argv.slice(2)) {
556
557
  if (!prompt)
557
558
  return;
558
559
  const cf = join(cfgDir, `${j.code}.json`);
559
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` } }));
560
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", model: `opencode-go/${j.model}`, models: undefined, libraryChanges: true }));
560
561
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
561
562
  n++;
562
563
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
@@ -585,5 +586,5 @@ export async function main(argv = process.argv.slice(2)) {
585
586
  console.log(`Wrote ${join(dir, "results.md")}`);
586
587
  return;
587
588
  }
588
- console.log("Usage: subsub-bench prepare | run [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
589
+ console.log("Usage: subsub-bench prepare | run [--models a,b] [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
589
590
  }
package/dist/bridge.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Bridge to the Python MCP servers of zotero-local-mcp (librarian "zotero" and
3
- * researcher "scholar"). Starts them over stdio, lists their tools, and calls them.
2
+ * Bridge to the Python MCP servers of zotero-local-mcp ("zotero" for the library,
3
+ * "scholar" for searches and notes). Starts them over stdio, lists their tools, and calls them.
4
4
  * All safety logic (dry runs, version checks, journal, vocabulary, the researcher's
5
5
  * note-only client) stays in the Python servers.
6
6
  */
package/dist/config.js CHANGED
@@ -6,10 +6,14 @@
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
- * "models": { "librarian": "opencode-go/glm-5.3-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
10
- * "defaultMode": "researcher"
9
+ * "model": "opencode-go/mimo-v2.6-pro",
10
+ * "libraryChanges": true
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.
16
+ *
13
17
  * `subsub init` writes this file. Without "serverDir" (or when that folder does
14
18
  * not exist), the Zotero servers run from PyPI with uv.
15
19
  *
@@ -22,9 +26,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
26
  import { homedir } from "node:os";
23
27
  import { dirname, join, resolve } from "node:path";
24
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";
25
31
  export const PROFILES = ["reader", "scholar", "author", "editor"];
26
32
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
27
- export const SERVER_VERSION = "0.5.0";
33
+ export const SERVER_VERSION = "0.5.1";
28
34
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
29
35
  export const STARBUCK_VERSION = "0.2.0";
30
36
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
@@ -101,7 +107,11 @@ export function loadConfig(env = process.env) {
101
107
  const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
102
108
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
103
109
  const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
104
- const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
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;
114
+ const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
105
115
  return {
106
116
  configFile: file,
107
117
  profile: isProfile(user.profile) ? user.profile : "scholar",
@@ -112,11 +122,8 @@ export function loadConfig(env = process.env) {
112
122
  serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
113
123
  envFile,
114
124
  vault,
115
- models: user.models ?? {
116
- librarian: "opencode-go/glm-5.3-flash",
117
- researcher: "opencode-go/mimo-v2.6-pro",
118
- },
119
- defaultMode: mode,
125
+ model,
126
+ libraryChanges: user.libraryChanges !== false,
120
127
  // A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
121
128
  vaultContext: (user.vaultContext ??
122
129
  (vault ? [join(vault, ".claude", "CLAUDE.md"), ...(isObsidianVault(dirname(vault)) ? [join(dirname(vault), ".claude", "CLAUDE.md")] : [])] : [])).map((p) => expand(p, home)),
@@ -129,7 +136,7 @@ export function loadConfig(env = process.env) {
129
136
  uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
130
137
  look: user.look,
131
138
  quotes: user.quotes,
132
- themes: user.themes,
139
+ themes,
133
140
  addons: Array.isArray(user.addons) ? user.addons.filter((a) => typeof a === "string") : [],
134
141
  starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
135
142
  };
package/dist/doctor.js CHANGED
@@ -92,7 +92,7 @@ export function runChecks(cfg, env, deps = {}) {
92
92
  fix: v.problems?.length
93
93
  ? `Fix these lines in the tag list: ${v.problems.slice(0, 3).join("; ")}`
94
94
  : noTopics
95
- ? "In Sub-Sub (librarian), ask: propose topics for my tag list."
95
+ ? "In Sub-Sub, ask: propose topics for my tag list."
96
96
  : undefined,
97
97
  });
98
98
  }
@@ -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: Object.keys(cfg.models).length ? Object.values(cfg.models).join(", ") : "pi's default model" }
107
+ ? { name: "Model login", ok: true, detail: cfg.model ?? "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
- * notes folder, models;
7
+ * Sub-Sub folder, library changes, model;
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
- * --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --library on|off, --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, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
29
+ import { configPath, DEFAULT_MODEL, 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";
@@ -34,9 +34,9 @@ const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templa
34
34
  export const STARTER_TAGS = {
35
35
  "health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
36
36
  "health-informatics": "Health informatics: information systems, data, AI, digital health",
37
- "any-field": "Any field: methods and document types only; the librarian proposes topics from your library",
37
+ "any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
38
38
  };
39
- export const TESTED_MODELS = { librarian: "opencode-go/glm-5.3-flash", researcher: "opencode-go/mimo-v2.6-pro" };
39
+ export const TESTED_MODEL = DEFAULT_MODEL;
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,6 +125,11 @@ 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"));
128
133
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
129
134
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
130
135
  const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
@@ -140,25 +145,26 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
140
145
  io.say(`Tag list: keeping ${vocab}.`);
141
146
  }
142
147
  else {
143
- explain("Tag list", `The librarian tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
148
+ explain("Tag list", `Sub-Sub tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
144
149
  tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
145
150
  }
146
151
  explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it only to Unpaywall, Crossref, OpenAlex and PubMed, as they ask.");
147
152
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
148
- explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. The researcher then has /verify.");
153
+ explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. Sub-Sub then has /verify.");
149
154
  const starbuck = await io.choose("Reference checks", [
150
155
  { value: "off", label: "Off" },
151
156
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
152
157
  ], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
153
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).");
159
+ const hadModel = "model" in existing || "models" in existing;
154
160
  const modelOptions = [
155
- ...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
161
+ ...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
156
162
  { value: "later", label: "Choose later in Sub-Sub" },
157
- { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
163
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
158
164
  ];
159
165
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
160
- const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
161
- const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
166
+ const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
167
+ const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
162
168
  // ---- write
163
169
  if (move === "yes")
164
170
  applyMoves(moves);
@@ -193,12 +199,18 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
193
199
  setup: fmup ? "fmup" : undefined,
194
200
  vault: notes,
195
201
  envFile,
202
+ libraryChanges: library === "on",
203
+ defaultMode: undefined,
196
204
  addons: withStarbuck(current.addons, starbuck === "on"),
197
205
  };
206
+ // One model since 0.10: the per-mode models are replaced.
207
+ patch.models = undefined;
208
+ if (models === "keep")
209
+ patch.model = current.model ?? "";
198
210
  if (models === "later")
199
- patch.models = {};
211
+ patch.model = "";
200
212
  if (models === "opencode-go")
201
- patch.models = TESTED_MODELS;
213
+ patch.model = TESTED_MODEL;
202
214
  saveConfig(patch, initEnv);
203
215
  const zotero = await zoteroReachable(deps.fetch);
204
216
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
package/dist/layout.js CHANGED
@@ -24,6 +24,15 @@ export const SETTINGS_FILES = { tags: "Zotero tags.md", rules: "Zotero agent.md"
24
24
  export function isObsidianVault(dir) {
25
25
  return existsSync(join(dir, ".obsidian"));
26
26
  }
27
+ /** The Obsidian vault that holds a folder: the folder itself or the nearest parent with .obsidian/. */
28
+ export function obsidianRoot(dir) {
29
+ for (let d = dir;; d = dirname(d)) {
30
+ if (isObsidianVault(d))
31
+ return d;
32
+ if (dirname(d) === d)
33
+ return undefined;
34
+ }
35
+ }
27
36
  /** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
28
37
  export function subsubFolder(answer) {
29
38
  return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
package/dist/look.js CHANGED
@@ -20,7 +20,8 @@ export const QUOTES = [
20
20
  ["God keep me from ever completing anything.", "Moby-Dick, ch. 32"],
21
21
  ["Call me Ishmael.", "Moby-Dick, ch. 1"],
22
22
  ];
23
- export const MODE_THEMES = { librarian: "subsub-try-works", researcher: "subsub-glauca" };
23
+ /** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
24
+ export const DEFAULT_THEME = "subsub-glauca";
24
25
  /** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
25
26
  export function schemeFromAnsi(fgAnsi, themeName) {
26
27
  if (themeName?.endsWith("-light") || themeName === "light")
@@ -41,8 +42,7 @@ export function schemeFromAnsi(fgAnsi, themeName) {
41
42
  }
42
43
  return "dark";
43
44
  }
44
- export function themeName(mode, scheme, map = MODE_THEMES) {
45
- const base = map[mode];
45
+ export function themeName(scheme, base = DEFAULT_THEME) {
46
46
  return base ? `${base}-${scheme}` : undefined;
47
47
  }
48
48
  const plain = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, warn: (s) => s };
@@ -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 = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
66
+ const modeLabel = `Assistant${opts.profile ? `, ${opts.profile}` : ""}${opts.libraryChanges ? "" : ", read only"}`;
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("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
81
+ out.push(p.dim(cut("/library /profile /history /undo | / for all commands", w)), "");
82
82
  return out;
83
83
  }
84
84
  /**
package/dist/profiles.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - reader: explains each step; reading notes are quotes with page numbers and
7
7
  * questions; no summaries or syntheses; no bulk library changes.
8
- * - scholar: literature notes, syntheses, searches, alerts; the full librarian.
8
+ * - scholar: literature notes, syntheses, searches, alerts; all library changes.
9
9
  * - author: scholar plus manuscript work: citation check, bibliography, comments
10
10
  * on the argument. Does not write the manuscript's paragraphs.
11
11
  * - editor: everything, including drafting text for a manuscript on request.
@@ -26,13 +26,13 @@ export const PROFILE_SPECS = {
26
26
  "- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
27
27
  "- For searches, list works with one line each on what they study, not on what they conclude.",
28
28
  "- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
29
- "- Librarian: at most 10 items per review note. Explain each proposed tag in the Reason column.",
29
+ "- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
30
30
  ].join("\n"),
31
31
  },
32
32
  scholar: {
33
33
  name: "scholar",
34
34
  label: "Scholar",
35
- summary: "literature notes, syntheses, searches and alerts; the full librarian",
35
+ summary: "literature notes, syntheses, searches and alerts; all library changes",
36
36
  without: MANUSCRIPT,
37
37
  withoutPrompts: ["review"],
38
38
  batch: 25,
@@ -40,7 +40,7 @@ export const PROFILE_SPECS = {
40
40
  "Profile: Scholar.",
41
41
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
42
42
  "- Do not draft text for {{user}}'s manuscripts, theses or assignments. If asked, say that the Author and Editor profiles help with manuscripts (/profile).",
43
- "- Librarian: 25 items per review note.",
43
+ "- Tag review notes: 25 items.",
44
44
  ].join("\n"),
45
45
  },
46
46
  author: {
@@ -55,7 +55,7 @@ export const PROFILE_SPECS = {
55
55
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
56
56
  "- Help with {{user}}'s manuscripts: check citations against the library, export the bibliography, and comment on the argument, the structure and the evidence that is missing, with the sources that could support each point.",
57
57
  "- Do not write the manuscript's paragraphs. Say what is missing and where the support is; {{user}} writes the text. The Editor profile drafts text (/profile).",
58
- "- Librarian: 25 items per review note.",
58
+ "- Tag review notes: 25 items.",
59
59
  ].join("\n"),
60
60
  },
61
61
  editor: {
@@ -69,7 +69,7 @@ export const PROFILE_SPECS = {
69
69
  "Profile: Editor. All tools.",
70
70
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
71
71
  "- When {{user}} asks, draft text for a manuscript: cite library items as [@citekey], use only claims from sources you read, and mark drafted text clearly so that {{user}} can check it.",
72
- "- Librarian: 25 items per review note unless {{user}} asks for more.",
72
+ "- Tag review notes: 25 items unless {{user}} asks for more.",
73
73
  ].join("\n"),
74
74
  },
75
75
  };
package/dist/prompt.js CHANGED
@@ -47,13 +47,16 @@ 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(mode, cfg) {
51
- const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are the ${mode}.`;
50
+ export function roleText(cfg) {
51
+ const raw = readIf(join(ROLE_DIR, "assistant.md")) ?? "You are Sub-Sub, a Zotero librarian and research assistant.";
52
52
  return cfg ? fill(raw, cfg) : raw;
53
53
  }
54
- export function systemAddition(mode, cfg, cwd) {
54
+ export function systemAddition(cfg, cwd, libraryChanges = cfg.libraryChanges) {
55
55
  const user = who(cfg);
56
- const parts = [`# Sub-Sub: ${mode} mode`, roleText(mode, 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
+ }
57
60
  const shared = readIf(cfg.sharedRules);
58
61
  if (shared) {
59
62
  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 each mode may use, and which calls need the user's approval.
2
+ * Which tools the Assistant 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 mode for these). */
6
+ /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the switch and the profile for these). */
7
7
  export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
8
8
  export const ZOTERO_READ = [
9
9
  "status",
@@ -40,26 +40,38 @@ export const PREVIEW_WRITES = new Set([
40
40
  ]);
41
41
  /** Writes without a dry run that still need a yes. */
42
42
  export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
43
- export function toolsFor(mode, registered, profile = "editor") {
43
+ /** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
44
+ export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map((n) => `zotero_${n}`);
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.
49
+ */
50
+ export function toolsFor(registered, opts) {
44
51
  const has = new Set(registered);
45
- const without = new Set(profileSpec(profile).without);
46
- const wanted = mode === "librarian"
47
- ? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
48
- : [
49
- ...ZOTERO_READ,
50
- ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
51
- ...FILE_TOOLS,
52
- ];
52
+ const without = new Set(profileSpec(opts.profile ?? "editor").without);
53
+ const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
54
+ const wanted = [
55
+ ...ZOTERO_READ,
56
+ ...ZOTERO_CHECKS,
57
+ ...(opts.libraryChanges ? zotero : []),
58
+ ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
59
+ ...FILE_TOOLS,
60
+ ];
53
61
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
54
62
  }
55
63
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
56
- export function unavailableReason(name, mode, registered, profile) {
57
- if (toolsFor(mode, registered, profile).includes(name))
64
+ export function unavailableReason(name, registered, opts) {
65
+ if (toolsFor(registered, opts).includes(name))
58
66
  return undefined;
67
+ const profile = opts.profile ?? "editor";
59
68
  if (profileSpec(profile).without.includes(name)) {
60
69
  return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
61
70
  }
62
- return `${name} is not available in ${mode} mode. The user can switch with /${otherMode(mode)}.`;
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.`;
73
+ }
74
+ return `${name} is not available.`;
63
75
  }
64
76
  /** A dry run only when dry_run is absent (server default true) or clearly true. */
65
77
  export function isDryRun(value) {
@@ -74,6 +86,3 @@ export function gateKind(toolName, input) {
74
86
  return "path";
75
87
  return null;
76
88
  }
77
- export function otherMode(mode) {
78
- return mode === "librarian" ? "researcher" : "librarian";
79
- }