@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/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") : [];
@@ -103,8 +103,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
103
103
  // Without questions (--yes), an existing folder stays exactly as it is.
104
104
  const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
105
105
  const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
106
- const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)).trim() || folderDefault, home);
107
- const notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
106
+ const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
107
+ let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
108
108
  if (notes !== answer)
109
109
  io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
110
110
  // ---- files of the old layout
@@ -119,17 +119,18 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
119
119
  io.say(` and ${moves.length - 6} more`);
120
120
  move = await io.choose("Move them", [
121
121
  { value: "yes", label: "Move them (recommended)" },
122
- { value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : "Leave them where they are" },
122
+ // In a new folder, files left behind would not be read: then "no" means keeping the old folder.
123
+ { value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : flags.folder || flags.notes ? "Leave them where they are" : `Leave them, and keep using ${from}` },
123
124
  ], flags.move ?? (flags.yes ? "no" : "yes"));
125
+ // A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
126
+ if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
127
+ notes = from;
128
+ io.say(`Sub-Sub keeps using ${notes}.`);
129
+ }
124
130
  }
125
131
  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
132
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
127
133
  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
134
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
134
135
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
135
136
  const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
@@ -148,23 +149,24 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
148
149
  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}.`);
149
150
  tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
150
151
  }
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.");
152
+ explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it with its requests to PubMed, Europe PMC, OpenAlex, Crossref and Unpaywall, as they ask, and to nobody else.");
152
153
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
153
154
  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.");
154
155
  const starbuck = await io.choose("Reference checks", [
155
156
  { value: "off", label: "Off" },
156
157
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
157
158
  ], 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).");
159
+ 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
160
  const hadModel = "model" in existing || "models" in existing;
161
+ const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
160
162
  const modelOptions = [
161
- ...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
163
+ ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
162
164
  { value: "later", label: "Choose later in Sub-Sub" },
163
- { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
165
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
164
166
  ];
165
167
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
166
168
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
167
- const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
169
+ const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
168
170
  // ---- write
169
171
  if (move === "yes")
170
172
  applyMoves(moves);
@@ -199,18 +201,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
199
201
  setup: fmup ? "fmup" : undefined,
200
202
  vault: notes,
201
203
  envFile,
202
- libraryChanges: library === "on",
203
- defaultMode: undefined,
204
+ libraryChanges: undefined,
204
205
  addons: withStarbuck(current.addons, starbuck === "on"),
205
206
  };
206
- // One model since 0.10: the per-mode models are replaced.
207
- patch.models = undefined;
207
+ // A model per mode; "model" (0.10, one for both) is replaced.
208
+ patch.model = undefined;
208
209
  if (models === "keep")
209
- patch.model = current.model ?? "";
210
+ patch.models = current.models;
210
211
  if (models === "later")
211
- patch.model = "";
212
+ patch.models = {};
212
213
  if (models === "opencode-go")
213
- patch.model = TESTED_MODEL;
214
+ patch.models = TESTED_MODELS;
214
215
  saveConfig(patch, initEnv);
215
216
  const zotero = await zoteroReachable(deps.fetch);
216
217
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
@@ -305,3 +306,12 @@ export async function initMain(args, env = process.env) {
305
306
  io.close?.();
306
307
  }
307
308
  }
309
+ /** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
310
+ export function cleanPath(raw) {
311
+ let p = raw.trim();
312
+ if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0])
313
+ return p.slice(1, -1);
314
+ if (process.platform !== "win32")
315
+ p = p.replace(/\\([ '"()&])/g, "$1");
316
+ return p;
317
+ }
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/paths.js CHANGED
@@ -81,7 +81,27 @@ export function judgePath(target, policy) {
81
81
  const hit = policy.protectedPaths.find((p) => within(target, p));
82
82
  if (hit)
83
83
  return { ok: false, why: `it changes a protected file or folder (${hit})` };
84
+ // Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
85
+ const hidden = target.split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
86
+ if (hidden)
87
+ return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
84
88
  if (policy.allowed.some((a) => within(target, a)))
85
89
  return { ok: true };
86
90
  return { ok: false, why: "it is outside the vault and the working folder" };
87
91
  }
92
+ /** Files with keys and passwords: the model may not read them, even when you ask. */
93
+ export function secretPaths() {
94
+ const home = realish(homedir());
95
+ return [
96
+ join(home, ".subsub", "agent", "auth.json"),
97
+ join(home, ".pi", "agent", "auth.json"),
98
+ join(home, ".config", "zotero-local-mcp", "env"),
99
+ join(home, ".ssh"),
100
+ join(home, ".aws"),
101
+ join(home, ".netrc"),
102
+ ];
103
+ }
104
+ /** True if reading `target` would read a secret; for grep, also a folder that holds one. */
105
+ export function isSecret(target, recursive = false) {
106
+ return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
107
+ }
package/dist/prompt.js CHANGED
@@ -47,15 +47,15 @@ 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).`);
56
+ const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
57
+ if (cfg.vault) {
58
+ parts.push("# The Sub-Sub folder", `The Sub-Sub folder is ${cfg.vault}. Folder names in these instructions (Inbox/, Literature/, Syntheses/, Research/, Zotero/) are inside it. Write notes there with absolute paths, even when the working folder is somewhere else.`);
59
59
  }
60
60
  const shared = readIf(cfg.sharedRules);
61
61
  if (shared) {
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/shortcut.js CHANGED
@@ -64,7 +64,7 @@ export function desktopEntry(p) {
64
64
  "[Desktop Entry]",
65
65
  "Type=Application",
66
66
  "Name=Sub-Sub",
67
- "Comment=Zotero librarian and research assistant",
67
+ "Comment=Research assistant for your Zotero library",
68
68
  `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
69
69
  `Path=${p.home}`,
70
70
  `Icon=${join(p.packageDir, "assets", "icon.png")}`,
@@ -85,7 +85,7 @@ export function windowsScript(p) {
85
85
  ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
86
86
  ` $s.WorkingDirectory = ${psQuote(p.home)}`,
87
87
  ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
88
- " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
88
+ " $s.Description = 'Sub-Sub: research assistant for your Zotero library'",
89
89
  " $s.WindowStyle = 7",
90
90
  " $s.Save()",
91
91
  " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
package/dist/subsub.js CHANGED
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Sub-Sub: a Zotero librarian and research assistant for pi.
2
+ * Sub-Sub: a research assistant for pi that works from your own Zotero library.
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
  *
@@ -24,11 +23,12 @@ import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, server
24
23
  import { profileList, profileSpec, promptBlocked } from "./profiles.js";
25
24
  import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
26
25
  import { applyEdits, checkLiteratureNote } from "./notes.js";
27
- import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
26
+ import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
28
27
  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,11 @@ 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
+ pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
108
+ let mode = cfg.defaultMode;
109
109
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
110
- const toolOpts = () => ({ libraryChanges, profile, bench: benchRun });
110
+ const toolOpts = () => ({ profile, bench: benchRun });
111
111
  let profile = cfg.profile;
112
112
  const user = who(cfg);
113
113
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -129,7 +129,7 @@ export async function createSubsub(pi, deps = {}) {
129
129
  return;
130
130
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
131
131
  const state = {
132
- libraryChanges,
132
+ mode,
133
133
  profile,
134
134
  profileLabel: profileSpec(profile).label,
135
135
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
@@ -192,7 +192,7 @@ export async function createSubsub(pi, deps = {}) {
192
192
  const th = ctx.ui.theme;
193
193
  return headerLines({
194
194
  version,
195
- libraryChanges,
195
+ mode,
196
196
  profile: profileSpec(profile).label,
197
197
  model: modelId,
198
198
  library,
@@ -214,38 +214,29 @@ export async function createSubsub(pi, deps = {}) {
214
214
  function status(ctx) {
215
215
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
216
216
  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(", ")}` : ""}`);
217
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
219
218
  if (standalone)
220
- ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
219
+ ctx.ui.setTitle(`Sub-Sub: ${mode}`);
221
220
  headerTui?.requestRender();
222
221
  webCtx = ctx;
223
222
  emitWeb();
224
223
  }
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
- }
224
+ /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
225
+ async function applyMode(ctx, next, remember, switchModel = true) {
226
+ mode = next;
227
+ pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
228
+ const spec = cfg.models[mode];
229
+ if (spec && switchModel) {
230
+ const i = spec.indexOf("/");
231
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
232
+ const ok = model ? await pi.setModel(model) : false;
233
+ if (!ok)
234
+ ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
236
235
  }
236
+ if (remember)
237
+ pi.appendEntry(MODE_ENTRY, { mode });
237
238
  status(ctx);
238
239
  }
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
240
  pi.on("session_start", async (_event, ctx) => {
250
241
  if (!bridge) {
251
242
  const b = new Bridge();
@@ -253,13 +244,20 @@ export async function createSubsub(pi, deps = {}) {
253
244
  bridge = b;
254
245
  registerTools(b);
255
246
  }
247
+ let restored;
248
+ let modelChosen = false; // the session already has a model: a resumed conversation keeps it
249
+ for (const e of ctx.sessionManager.getBranch()) {
250
+ const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
251
+ if (m === "librarian" || m === "researcher")
252
+ restored = m;
253
+ if (e.type === "model_change")
254
+ modelChosen = true;
255
+ }
256
256
  // Judge light or dark before Sub-Sub changes the theme.
257
257
  if (lookOn(ctx))
258
258
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
259
- await applyModel(ctx);
260
259
  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);
260
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
263
261
  if (lookOn(ctx)) {
264
262
  setHeader(ctx);
265
263
  void refreshLibrary();
@@ -278,10 +276,28 @@ export async function createSubsub(pi, deps = {}) {
278
276
  bridge = undefined;
279
277
  await b?.close();
280
278
  });
279
+ /** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
280
+ const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
281
+ // /tree moves to another branch: take that branch's mode (pi restores its model itself).
282
+ pi.on("session_tree", async (_event, ctx) => {
283
+ let m;
284
+ for (const e of ctx.sessionManager.getBranch()) {
285
+ const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
286
+ if (x === "librarian" || x === "researcher")
287
+ m = x;
288
+ }
289
+ if (m && m !== mode)
290
+ await applyMode(ctx, m, false, false);
291
+ });
281
292
  pi.on("model_select", async (_event, ctx) => status(ctx));
282
293
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
283
294
  // The input event comes before pi expands a prompt template.
284
295
  pi.on("input", async (event, ctx) => {
296
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
297
+ if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
298
+ ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
299
+ return { action: "handled" };
300
+ }
285
301
  const why = promptBlocked(event.text, profile);
286
302
  if (!why)
287
303
  return { action: "continue" };
@@ -289,7 +305,7 @@ export async function createSubsub(pi, deps = {}) {
289
305
  return { action: "handled" };
290
306
  });
291
307
  pi.on("before_agent_start", async (event, ctx) => ({
292
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd, libraryChanges)}`,
308
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
293
309
  }));
294
310
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
295
311
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
@@ -303,13 +319,16 @@ export async function createSubsub(pi, deps = {}) {
303
319
  coerceArgs(schema, input);
304
320
  resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
305
321
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
306
- const why = unavailableReason(name, registered(), toolOpts());
322
+ const why = unavailableReason(name, mode, registered(), toolOpts());
307
323
  if (why) {
308
324
  if (benchOut)
309
325
  record({ kind: "wrong_mode", tool: name, input });
310
326
  return { block: true, reason: why };
311
327
  }
312
328
  }
329
+ if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
330
+ return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
331
+ }
313
332
  if (name.startsWith("verify_"))
314
333
  return verifyGate(name, input, ctx);
315
334
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
@@ -426,35 +445,18 @@ export async function createSubsub(pi, deps = {}) {
426
445
  `for ${user}.${shown}`,
427
446
  };
428
447
  }
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
448
  pi.registerCommand("librarian", {
447
- description: "Sub-Sub: same as /library on",
449
+ description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
448
450
  handler: async (_args, ctx) => {
449
- applyLibrary(ctx, true, true);
450
- ctx.ui.notify(`${libraryNote(true)} (/librarian is now /library on.)`, "info");
451
+ await applyMode(ctx, "librarian", true);
452
+ ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
451
453
  },
452
454
  });
453
455
  pi.registerCommand("researcher", {
454
- description: "Sub-Sub: same as /library off",
456
+ description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
455
457
  handler: async (_args, ctx) => {
456
- applyLibrary(ctx, false, true);
457
- ctx.ui.notify(`${libraryNote(false)} (/researcher is now /library off.)`, "info");
458
+ await applyMode(ctx, "researcher", true);
459
+ ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
458
460
  },
459
461
  });
460
462
  pi.registerCommand("profile", {
@@ -477,14 +479,14 @@ export async function createSubsub(pi, deps = {}) {
477
479
  catch (err) {
478
480
  saved = ` Not saved: ${err.message}`;
479
481
  }
480
- applyLibrary(ctx, libraryChanges, false);
482
+ await applyMode(ctx, mode, false, false);
481
483
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
482
484
  },
483
485
  });
484
486
  pi.registerCommand("subsub", {
485
487
  description: "Sub-Sub: Zotero connection and library overview",
486
488
  handler: async (_args, ctx) => {
487
- const lines = [`Library changes: ${libraryChanges ? "on" : "off"}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
489
+ const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
488
490
  if (!bridge) {
489
491
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
490
492
  return;