@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/src/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Writes:
6
6
  * - the settings file (~/.config/subsub/config.json): profile, name, language,
7
- * Sub-Sub folder, library changes, model;
7
+ * Sub-Sub folder, models;
8
8
  * - the server settings file (~/.config/zotero-local-mcp/env, or the one that
9
9
  * already exists): the Sub-Sub folder, tag list, alerts file, contact email.
10
10
  * Other lines stay;
@@ -19,7 +19,7 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --library on|off, --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go.
23
23
  */
24
24
 
25
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -29,7 +29,7 @@ import { createInterface } from "node:readline/promises";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
- DEFAULT_MODEL,
32
+ DEFAULT_MODELS,
33
33
  expand,
34
34
  isProfile,
35
35
  loadConfig,
@@ -53,7 +53,7 @@ export const STARTER_TAGS: Record<string, string> = {
53
53
  "any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
54
54
  };
55
55
 
56
- export const TESTED_MODEL = DEFAULT_MODEL;
56
+ export const TESTED_MODELS = DEFAULT_MODELS;
57
57
 
58
58
  export interface InitIO {
59
59
  ask(question: string, def: string): Promise<string>;
@@ -152,8 +152,8 @@ export async function runInit(
152
152
  // Without questions (--yes), an existing folder stays exactly as it is.
153
153
  const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
154
154
  const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
155
- const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)).trim() || folderDefault, home);
156
- const notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
155
+ const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
156
+ let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
157
157
  if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
158
158
 
159
159
  // ---- files of the old layout
@@ -171,10 +171,16 @@ export async function runInit(
171
171
  "Move them",
172
172
  [
173
173
  { value: "yes", label: "Move them (recommended)" },
174
- { value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : "Leave them where they are" },
174
+ // In a new folder, files left behind would not be read: then "no" means keeping the old folder.
175
+ { 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}` },
175
176
  ],
176
177
  flags.move ?? (flags.yes ? "no" : "yes"),
177
178
  );
179
+ // A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
180
+ if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
181
+ notes = from;
182
+ io.say(`Sub-Sub keeps using ${notes}.`);
183
+ }
178
184
  }
179
185
 
180
186
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
@@ -185,19 +191,6 @@ export async function runInit(
185
191
  isProfile(profileDefault) ? profileDefault : "scholar",
186
192
  );
187
193
 
188
- explain(
189
- "Library changes",
190
- "With library changes on, Sub-Sub can import, tag and repair items in Zotero. It shows a preview of every change, and nothing changes until you select Yes. With them off, it only reads, searches and writes notes. Switch at any time with /library on or /library off.",
191
- );
192
- const library = await io.choose(
193
- "Library changes",
194
- [
195
- { value: "on", label: "On (recommended): every change shows a preview first" },
196
- { value: "off", label: "Off: read, search and write notes only" },
197
- ],
198
- flags.library ?? (existing.libraryChanges === false ? "off" : "on"),
199
- );
200
-
201
194
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
202
195
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
203
196
  const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
@@ -220,7 +213,7 @@ export async function runInit(
220
213
  );
221
214
  }
222
215
 
223
- 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.");
216
+ 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.");
224
217
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
225
218
 
226
219
  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.");
@@ -233,16 +226,20 @@ export async function runInit(
233
226
  flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
234
227
  );
235
228
 
236
- explain("Models", "Sub-Sub needs an account with a model provider. You can connect one later in Sub-Sub with the model button (or /login in the terminal).");
229
+ explain(
230
+ "Models",
231
+ "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).",
232
+ );
237
233
  const hadModel = "model" in existing || "models" in existing;
234
+ const show = (m: Partial<Record<string, string>>) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
238
235
  const modelOptions = [
239
- ...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
236
+ ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
240
237
  { value: "later", label: "Choose later in Sub-Sub" },
241
- { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
238
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
242
239
  ];
243
240
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
244
241
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
245
- const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
242
+ const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
246
243
 
247
244
  // ---- write
248
245
  if (move === "yes") applyMoves(moves);
@@ -278,15 +275,14 @@ export async function runInit(
278
275
  setup: fmup ? "fmup" : undefined,
279
276
  vault: notes,
280
277
  envFile,
281
- libraryChanges: library === "on",
282
- defaultMode: undefined,
278
+ libraryChanges: undefined,
283
279
  addons: withStarbuck(current.addons, starbuck === "on"),
284
280
  };
285
- // One model since 0.10: the per-mode models are replaced.
286
- patch.models = undefined;
287
- if (models === "keep") patch.model = current.model ?? "";
288
- if (models === "later") patch.model = "";
289
- if (models === "opencode-go") patch.model = TESTED_MODEL;
281
+ // A model per mode; "model" (0.10, one for both) is replaced.
282
+ patch.model = undefined;
283
+ if (models === "keep") patch.models = current.models;
284
+ if (models === "later") patch.models = {};
285
+ if (models === "opencode-go") patch.models = TESTED_MODELS;
290
286
  saveConfig(patch, initEnv);
291
287
 
292
288
  const zotero = await zoteroReachable(deps.fetch);
@@ -374,3 +370,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
374
370
  (io as { close?: () => void }).close?.();
375
371
  }
376
372
  }
373
+
374
+ /** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
375
+ export function cleanPath(raw: string): string {
376
+ let p = raw.trim();
377
+ if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0]) return p.slice(1, -1);
378
+ if (process.platform !== "win32") p = p.replace(/\\([ '"()&])/g, "$1");
379
+ return p;
380
+ }
package/src/look.ts CHANGED
@@ -83,8 +83,7 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
83
83
  /** Header lines. Each line is cut to the width before it is coloured. */
84
84
  export function headerLines(opts: {
85
85
  version: string;
86
- /** Library changes on (false: read only). */
87
- libraryChanges: boolean;
86
+ mode: "librarian" | "researcher";
88
87
  /** Profile label, e.g. "Scholar". */
89
88
  profile?: string;
90
89
  model: string;
@@ -95,7 +94,7 @@ export function headerLines(opts: {
95
94
  }): string[] {
96
95
  const p = opts.paint ?? plain;
97
96
  const w = Math.max(10, opts.width);
98
- const modeLabel = `Assistant${opts.profile ? `, ${opts.profile}` : ""}${opts.libraryChanges ? "" : ", read only"}`;
97
+ const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
99
98
  const lib = libraryLine(opts.library);
100
99
  if (w < BANNER[0].length + 2) {
101
100
  return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
@@ -108,7 +107,7 @@ export function headerLines(opts: {
108
107
  if ([...one].length <= w) out.push(p.dim(one));
109
108
  else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
110
109
  }
111
- out.push(p.dim(cut("/library /profile /history /undo | / for all commands", w)), "");
110
+ out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
112
111
  return out;
113
112
  }
114
113
 
package/src/paths.ts CHANGED
@@ -92,6 +92,27 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
92
92
  export function judgePath(target: string, policy: PathPolicy): PathVerdict {
93
93
  const hit = policy.protectedPaths.find((p) => within(target, p));
94
94
  if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
95
+ // Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
96
+ const hidden = target.split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
97
+ if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
95
98
  if (policy.allowed.some((a) => within(target, a))) return { ok: true };
96
99
  return { ok: false, why: "it is outside the vault and the working folder" };
97
100
  }
101
+
102
+ /** Files with keys and passwords: the model may not read them, even when you ask. */
103
+ export function secretPaths(): string[] {
104
+ const home = realish(homedir());
105
+ return [
106
+ join(home, ".subsub", "agent", "auth.json"),
107
+ join(home, ".pi", "agent", "auth.json"),
108
+ join(home, ".config", "zotero-local-mcp", "env"),
109
+ join(home, ".ssh"),
110
+ join(home, ".aws"),
111
+ join(home, ".netrc"),
112
+ ];
113
+ }
114
+
115
+ /** True if reading `target` would read a secret; for grep, also a folder that holds one. */
116
+ export function isSecret(target: string, recursive = false): boolean {
117
+ return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
118
+ }
package/src/prompt.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { type SubsubConfig, who } from "./config.ts";
13
+ import { type Mode, type SubsubConfig, who } from "./config.ts";
14
14
  import { profileSpec } from "./profiles.ts";
15
15
 
16
16
  const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
@@ -52,17 +52,18 @@ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
52
52
  .replace(/[ \t]+\n/g, "\n");
53
53
  }
54
54
 
55
- export function roleText(cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
56
- const raw = readIf(join(ROLE_DIR, "assistant.md")) ?? "You are Sub-Sub, a Zotero librarian and research assistant.";
55
+ export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
56
+ const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
57
57
  return cfg ? fill(raw, cfg) : raw;
58
58
  }
59
59
 
60
- export function systemAddition(cfg: SubsubConfig, cwd: string, libraryChanges: boolean = cfg.libraryChanges): string {
60
+ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
61
61
  const user = who(cfg);
62
- const parts = ["# Sub-Sub", roleText(cfg)];
63
- if (!libraryChanges) {
62
+ const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
63
+ if (cfg.vault) {
64
64
  parts.push(
65
- `Library changes are off: you read the library, search, write notes and queue imports. To import, tag or change items, ask ${user} to turn library changes on (/library on, or the switch in the browser view).`,
65
+ "# The Sub-Sub folder",
66
+ `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.`,
66
67
  );
67
68
  }
68
69
  const shared = readIf(cfg.sharedRules);
package/src/roles.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Which tools the Assistant may use, and which calls need the user's approval.
2
+ * Which tools each mode may use, and which calls need the user's approval.
3
3
  */
4
4
 
5
- import type { Profile } from "./config.ts";
5
+ import type { Mode, Profile } from "./config.ts";
6
6
  import { profileSpec } from "./profiles.ts";
7
7
 
8
8
  export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
9
9
 
10
- /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the switch and the profile for these). */
10
+ /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
11
11
  export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
12
12
 
13
13
  export const ZOTERO_READ = [
@@ -53,42 +53,41 @@ export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates",
53
53
  (n) => `zotero_${n}`,
54
54
  );
55
55
 
56
+
56
57
  export interface ToolOptions {
57
- /** Library changes on: the Zotero write tools are offered (each still previewed). */
58
- libraryChanges: boolean;
59
58
  profile?: Profile;
60
59
  /** The model test: also the bakeoff_ tools. */
61
60
  bench?: boolean;
62
61
  }
63
62
 
64
63
  /**
65
- * The Assistant's tools: reading the library, searching, notes and reference
66
- * checks always; the Zotero write tools when library changes are on. The profile
67
- * then removes tools.
64
+ * The tools of a mode. The Librarian manages the library: Zotero tools and files.
65
+ * The Researcher has the same, plus searches, notes and reference checks. The
66
+ * profile then removes tools. Every library change is still previewed (gateKind).
68
67
  */
69
- export function toolsFor(registered: string[], opts: ToolOptions): string[] {
68
+ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
70
69
  const has = new Set(registered);
71
70
  const without = new Set(profileSpec(opts.profile ?? "editor").without);
72
71
  const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
73
72
  const wanted = [
74
73
  ...ZOTERO_READ,
75
74
  ...ZOTERO_CHECKS,
76
- ...(opts.libraryChanges ? zotero : []),
77
- ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
75
+ ...zotero,
76
+ ...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
78
77
  ...FILE_TOOLS,
79
78
  ];
80
79
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
81
80
  }
82
81
 
83
82
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
84
- export function unavailableReason(name: string, registered: string[], opts: ToolOptions): string | undefined {
85
- if (toolsFor(registered, opts).includes(name)) return undefined;
83
+ export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
84
+ if (toolsFor(mode, registered, opts).includes(name)) return undefined;
86
85
  const profile = opts.profile ?? "editor";
87
86
  if (profileSpec(profile).without.includes(name)) {
88
87
  return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
89
88
  }
90
- if (!opts.libraryChanges && toolsFor(registered, { ...opts, libraryChanges: true }).includes(name)) {
91
- return `${name} changes the library, and library changes are off. The user can turn them on with /library on.`;
89
+ if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
90
+ return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
92
91
  }
93
92
  return `${name} is not available.`;
94
93
  }
package/src/shortcut.ts CHANGED
@@ -81,7 +81,7 @@ export function desktopEntry(p: ShortcutPlan): string {
81
81
  "[Desktop Entry]",
82
82
  "Type=Application",
83
83
  "Name=Sub-Sub",
84
- "Comment=Zotero librarian and research assistant",
84
+ "Comment=Research assistant for your Zotero library",
85
85
  `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
86
86
  `Path=${p.home}`,
87
87
  `Icon=${join(p.packageDir, "assets", "icon.png")}`,
@@ -103,7 +103,7 @@ export function windowsScript(p: ShortcutPlan): string {
103
103
  ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
104
104
  ` $s.WorkingDirectory = ${psQuote(p.home)}`,
105
105
  ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
106
- " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
106
+ " $s.Description = 'Sub-Sub: research assistant for your Zotero library'",
107
107
  " $s.WindowStyle = 7",
108
108
  " $s.Save()",
109
109
  " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
package/src/subsub.ts 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
  *
@@ -29,6 +28,7 @@ import {
29
28
  loadConfig,
30
29
  parseEnvFile,
31
30
  type Profile,
31
+ type Mode,
32
32
  saveConfig,
33
33
  serverCommand,
34
34
  starbuckCommand,
@@ -39,12 +39,14 @@ import {
39
39
  import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
40
40
  import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
41
41
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
42
- import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
42
+ import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
43
43
  import { describeArgs, formatPreview } from "./preview.ts";
44
44
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
45
45
  import { systemAddition } from "./prompt.ts";
46
46
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
47
47
 
48
+ const MODE_ENTRY = "subsub-mode";
49
+
48
50
  export interface SubsubDeps {
49
51
  config?: SubsubConfig;
50
52
  /** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
@@ -127,12 +129,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
127
129
 
128
130
  if (deps.bridge) registerTools(deps.bridge);
129
131
 
130
- pi.registerFlag("read-only", { description: "Start Sub-Sub with library changes off (this run only)", type: "boolean", default: false });
131
- pi.registerFlag("librarian", { description: "Start Sub-Sub with library changes on (this run only)", type: "boolean", default: false });
132
+ pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
133
+ pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
132
134
 
133
- let libraryChanges = cfg.libraryChanges;
135
+ let mode: Mode = cfg.defaultMode;
134
136
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
135
- const toolOpts = () => ({ libraryChanges, profile, bench: benchRun });
137
+ const toolOpts = () => ({ profile, bench: benchRun });
136
138
  let profile: Profile = cfg.profile;
137
139
  const user = who(cfg);
138
140
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -154,7 +156,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
154
156
  if (!webView || !ctx || ctx.mode !== "rpc") return;
155
157
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
156
158
  const state = {
157
- libraryChanges,
159
+ mode,
158
160
  profile,
159
161
  profileLabel: profileSpec(profile).label,
160
162
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
@@ -221,7 +223,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
221
223
  const th = ctx.ui.theme;
222
224
  return headerLines({
223
225
  version,
224
- libraryChanges,
226
+ mode,
225
227
  profile: profileSpec(profile).label,
226
228
  model: modelId,
227
229
  library,
@@ -244,37 +246,28 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
244
246
  function status(ctx: ExtensionContext): void {
245
247
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
246
248
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
247
- const lib = libraryChanges ? "library changes on" : "read only";
248
- ctx.ui.setStatus("subsub", `Sub-Sub: ${lib} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
249
- if (standalone) ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
249
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
250
+ if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
250
251
  headerTui?.requestRender();
251
252
  webCtx = ctx;
252
253
  emitWeb();
253
254
  }
254
255
 
255
- /** Set the tools for the switch and the profile; save the switch when asked. */
256
- function applyLibrary(ctx: ExtensionContext, on: boolean, save: boolean): void {
257
- libraryChanges = on;
258
- pi.setActiveTools(toolsFor(registered(), toolOpts()));
259
- if (save) {
260
- try {
261
- saveConfig({ libraryChanges: on });
262
- } catch (err) {
263
- ctx.ui.notify(`Sub-Sub: the setting was not saved: ${(err as Error).message}`, "warning");
264
- }
256
+ /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
257
+ async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
258
+ mode = next;
259
+ pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
260
+ const spec = cfg.models[mode];
261
+ if (spec && switchModel) {
262
+ const i = spec.indexOf("/");
263
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
264
+ const ok = model ? await pi.setModel(model) : false;
265
+ if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
265
266
  }
267
+ if (remember) pi.appendEntry(MODE_ENTRY, { mode });
266
268
  status(ctx);
267
269
  }
268
270
 
269
- async function applyModel(ctx: ExtensionContext): Promise<void> {
270
- const spec = cfg.model;
271
- if (!spec) return;
272
- const i = spec.indexOf("/");
273
- const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
274
- const ok = model ? await pi.setModel(model) : false;
275
- if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
276
- }
277
-
278
271
  pi.on("session_start", async (_event, ctx) => {
279
272
  if (!bridge) {
280
273
  const b = new Bridge();
@@ -282,12 +275,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
282
275
  bridge = b;
283
276
  registerTools(b);
284
277
  }
278
+ let restored: Mode | undefined;
279
+ let modelChosen = false; // the session already has a model: a resumed conversation keeps it
280
+ for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
281
+ const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
282
+ if (m === "librarian" || m === "researcher") restored = m;
283
+ if (e.type === "model_change") modelChosen = true;
284
+ }
285
285
  // Judge light or dark before Sub-Sub changes the theme.
286
286
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
287
- await applyModel(ctx);
288
287
  applyTheme(ctx);
289
- // The flags change this run only; the saved setting stays.
290
- applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
288
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
291
289
  if (lookOn(ctx)) {
292
290
  setHeader(ctx);
293
291
  void refreshLibrary();
@@ -306,11 +304,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
306
304
  await b?.close();
307
305
  });
308
306
 
307
+ /** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
308
+ const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
309
+
310
+ // /tree moves to another branch: take that branch's mode (pi restores its model itself).
311
+ pi.on("session_tree", async (_event, ctx) => {
312
+ let m: Mode | undefined;
313
+ for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
314
+ const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
315
+ if (x === "librarian" || x === "researcher") m = x;
316
+ }
317
+ if (m && m !== mode) await applyMode(ctx, m, false, false);
318
+ });
319
+
309
320
  pi.on("model_select", async (_event, ctx) => status(ctx));
310
321
 
311
322
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
312
323
  // The input event comes before pi expands a prompt template.
313
324
  pi.on("input", async (event, ctx) => {
325
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
326
+ if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
327
+ ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
328
+ return { action: "handled" };
329
+ }
314
330
  const why = promptBlocked(event.text, profile);
315
331
  if (!why) return { action: "continue" };
316
332
  ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
@@ -318,7 +334,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
318
334
  });
319
335
 
320
336
  pi.on("before_agent_start", async (event, ctx) => ({
321
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd, libraryChanges)}`,
337
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
322
338
  }));
323
339
 
324
340
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
@@ -333,12 +349,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
333
349
  if (schema) coerceArgs(schema, input);
334
350
  resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
335
351
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
336
- const why = unavailableReason(name, registered(), toolOpts());
352
+ const why = unavailableReason(name, mode, registered(), toolOpts());
337
353
  if (why) {
338
354
  if (benchOut) record({ kind: "wrong_mode", tool: name, input });
339
355
  return { block: true, reason: why };
340
356
  }
341
357
  }
358
+ if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
359
+ return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
360
+ }
342
361
  if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
343
362
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
344
363
  if (!kind) return undefined;
@@ -447,39 +466,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
447
466
  };
448
467
  }
449
468
 
450
- const libraryNote = (on: boolean) =>
451
- on
452
- ? "Sub-Sub: library changes on. Every change shows a preview first."
453
- : "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
454
-
455
- pi.registerCommand("library", {
456
- description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
457
- handler: async (args, ctx) => {
458
- const a = args.trim().toLowerCase();
459
- const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
460
- if (on === undefined) {
461
- ctx.ui.notify("Type /library on or /library off.", "warning");
462
- return;
463
- }
464
- applyLibrary(ctx, on, true);
465
- ctx.ui.notify(libraryNote(on), "info");
466
- },
467
- });
468
-
469
- // The two modes before 0.10, kept so that old habits and notes still work.
470
469
  pi.registerCommand("librarian", {
471
- description: "Sub-Sub: same as /library on",
470
+ description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
472
471
  handler: async (_args, ctx) => {
473
- applyLibrary(ctx, true, true);
474
- ctx.ui.notify(`${libraryNote(true)} (/librarian is now /library on.)`, "info");
472
+ await applyMode(ctx, "librarian", true);
473
+ ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
475
474
  },
476
475
  });
477
476
 
478
477
  pi.registerCommand("researcher", {
479
- description: "Sub-Sub: same as /library off",
478
+ description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
480
479
  handler: async (_args, ctx) => {
481
- applyLibrary(ctx, false, true);
482
- ctx.ui.notify(`${libraryNote(false)} (/researcher is now /library off.)`, "info");
480
+ await applyMode(ctx, "researcher", true);
481
+ ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
483
482
  },
484
483
  });
485
484
 
@@ -502,7 +501,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
502
501
  } catch (err) {
503
502
  saved = ` Not saved: ${(err as Error).message}`;
504
503
  }
505
- applyLibrary(ctx, libraryChanges, false);
504
+ await applyMode(ctx, mode, false, false);
506
505
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
507
506
  },
508
507
  });
@@ -510,7 +509,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
510
509
  pi.registerCommand("subsub", {
511
510
  description: "Sub-Sub: Zotero connection and library overview",
512
511
  handler: async (_args, ctx) => {
513
- const lines = [`Library changes: ${libraryChanges ? "on" : "off"}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
512
+ const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
514
513
  if (!bridge) {
515
514
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
516
515
  return;