@tiagojct/subsub 0.11.0 → 0.11.2

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/cli.ts CHANGED
@@ -134,12 +134,38 @@ export function reviewCommand(args: string[], cfg: SubsubConfig, env: NodeJS.Pro
134
134
  };
135
135
  }
136
136
 
137
+ const HELP = `Sub-Sub: your research assistant, working from your own Zotero library.
138
+
139
+ Usage:
140
+ subsub Start Sub-Sub in the terminal
141
+ subsub --librarian Start in the Librarian, which only manages the library
142
+ subsub web Open Sub-Sub in the browser
143
+ subsub init Set up or change the settings (folder, tag list, profile, models)
144
+ subsub doctor Check the set-up; each line marked FIX says what to do
145
+ subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
146
+ subsub review preview|apply <note>
147
+ Check or apply a tag review note from Inbox/
148
+ subsub --version Show the versions
149
+
150
+ Options:
151
+ --here Work in the current folder, not in the Sub-Sub folder
152
+
153
+ In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
154
+ /profile to change the profile, /undo to undo the last change, and / for all commands.
155
+ Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
156
+
157
+ Docs: https://subsub.tiagojacinto.eu`;
158
+
137
159
  export async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
138
160
  const env = process.env;
139
161
  const home = homedir();
140
162
  const here = argv.includes("--here");
141
163
  const args = argv.filter((a) => a !== "--here");
142
164
 
165
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
166
+ console.log(HELP);
167
+ return;
168
+ }
143
169
  if (args[0] === "--version" || args[0] === "-v") {
144
170
  const piPkg = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..");
145
171
  console.log(`subsub ${packageVersion()} (pi ${packageVersion(piPkg)})`);
@@ -173,7 +199,12 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
173
199
  return;
174
200
  }
175
201
  if (isSelfUpdate(args)) {
176
- console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
202
+ if (PACKAGE_DIR.split(/[\\/]/).includes("node_modules")) {
203
+ console.log("Sub-Sub is not updated with \"update\". To update it, run the installer again (https://subsub.tiagojacinto.eu/install/);");
204
+ console.log("if you installed it with npm, type: npm install -g @tiagojct/subsub");
205
+ } else {
206
+ console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
207
+ }
177
208
  console.log('To update packages added to Sub-Sub, use "subsub update --extensions".');
178
209
  process.exitCode = 1;
179
210
  return;
@@ -206,6 +237,8 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
206
237
  env.SUBSUB_CLI = "1";
207
238
  // pi's update notice would point to the global pi, not to this copy.
208
239
  env.PI_SKIP_VERSION_CHECK ??= "1";
240
+ // pi reports a first install to pi.dev and names itself to some providers; Sub-Sub sends no usage data.
241
+ env.PI_TELEMETRY ??= "0";
209
242
 
210
243
  process.argv = [process.argv[0], process.argv[1], ...args];
211
244
  const index = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
package/src/config.ts CHANGED
@@ -39,7 +39,7 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
39
39
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
40
40
 
41
41
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
42
- export const SERVER_VERSION = "0.5.1";
42
+ export const SERVER_VERSION = "0.5.2";
43
43
 
44
44
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
45
45
  export const STARBUCK_VERSION = "0.2.0";
@@ -159,7 +159,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
159
159
  // "models" per mode; "model" (0.10) for both; {} or "" means: the user chooses in pi (/login, /model).
160
160
  const one = typeof user.model === "string" ? user.model : undefined;
161
161
  const given = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
162
- const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : { ...DEFAULT_MODELS });
162
+ // Without a settings file (no subsub init yet), the user chooses in pi: no default that may lack a key.
163
+ const fallback = existsSync(file) ? { ...DEFAULT_MODELS } : {};
164
+ const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : fallback);
163
165
  for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
164
166
  const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
165
167
  return {
@@ -223,6 +225,19 @@ export function serverCommand(
223
225
  return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
224
226
  }
225
227
 
228
+ /**
229
+ * uv caches the PyPI index; right after a release it may not know the pinned version yet
230
+ * ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
231
+ * the index for that package refreshed. Returns undefined when a retry would not help.
232
+ */
233
+ export function refreshedArgs(args: string[], stderr: string): string[] | undefined {
234
+ if (!/there is no version of|no solution found/i.test(stderr)) return undefined;
235
+ const i = args.indexOf("--from");
236
+ if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package")) return undefined;
237
+ const pkg = args[i + 1].split(/[=<>!~]/)[0];
238
+ return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
239
+ }
240
+
226
241
  export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
227
242
  return (cfg.addons ?? []).includes("starbuck");
228
243
  }
package/src/doctor.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  starbuckCommand,
21
21
  starbuckEnabled,
22
22
  type SubsubConfig,
23
+ refreshedArgs,
23
24
  } from "./config.ts";
24
25
  import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
25
26
  import { profileSpec } from "./profiles.ts";
@@ -92,7 +93,10 @@ export function runChecks(
92
93
 
93
94
  const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
94
95
  const local = cmd.args.includes("--directory");
95
- const res = run(cmd.command, cmd.args, { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) });
96
+ const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
97
+ let res = run(cmd.command, cmd.args, serverEnv);
98
+ const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
99
+ if (retry) res = run(cmd.command, retry, serverEnv);
96
100
  let st: Record<string, any> | undefined;
97
101
  try {
98
102
  st = JSON.parse(res.stdout);
@@ -123,7 +127,8 @@ export function runChecks(
123
127
  );
124
128
  const v = st.vocabulary ?? {};
125
129
  if (v.loaded === false || !v.tags) {
126
- out.push({ name: "Tag list", ok: false, detail: v.path ? `cannot read ${v.path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
130
+ const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
131
+ out.push({ name: "Tag list", ok: false, detail: path ? `cannot read ${path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
127
132
  } else {
128
133
  const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
129
134
  out.push({
@@ -149,7 +154,7 @@ export function runChecks(
149
154
  out.push(
150
155
  hasLogin(agentDir)
151
156
  ? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
152
- : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
157
+ : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." },
153
158
  );
154
159
  return out;
155
160
  }
package/src/init.ts CHANGED
@@ -152,9 +152,10 @@ 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
+ else if (notes !== folderDefault) io.say(`Sub-Sub folder: ${notes}`);
158
159
 
159
160
  // ---- files of the old layout
160
161
  const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
@@ -171,10 +172,16 @@ export async function runInit(
171
172
  "Move them",
172
173
  [
173
174
  { 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" },
175
+ // In a new folder, files left behind would not be read: then "no" means keeping the old folder.
176
+ { 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
177
  ],
176
178
  flags.move ?? (flags.yes ? "no" : "yes"),
177
179
  );
180
+ // A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
181
+ if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
182
+ notes = from;
183
+ io.say(`Sub-Sub keeps using ${notes}.`);
184
+ }
178
185
  }
179
186
 
180
187
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
@@ -207,7 +214,7 @@ export async function runInit(
207
214
  );
208
215
  }
209
216
 
210
- 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.");
217
+ 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.");
211
218
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
212
219
 
213
220
  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.");
@@ -295,6 +302,7 @@ export async function runInit(
295
302
  if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
296
303
  if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
297
304
  io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
305
+ if (tags === "any-field") io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
298
306
  return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
299
307
  }
300
308
 
@@ -348,7 +356,10 @@ function terminalIO(): InitIO & { close(): void } {
348
356
 
349
357
  export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
350
358
  const flags = parseFlags(args);
351
- if (!process.stdin.isTTY) flags.yes ??= "true";
359
+ if (!process.stdin.isTTY && flags.yes === undefined) {
360
+ flags.yes = "true";
361
+ console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
362
+ }
352
363
  const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
353
364
  try {
354
365
  await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
@@ -364,3 +375,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
364
375
  (io as { close?: () => void }).close?.();
365
376
  }
366
377
  }
378
+
379
+ /** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
380
+ export function cleanPath(raw: string): string {
381
+ let p = raw.trim();
382
+ if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0]) return p.slice(1, -1);
383
+ if (process.platform !== "win32") p = p.replace(/\\([ '"()&])/g, "$1");
384
+ return p;
385
+ }
package/src/paths.ts CHANGED
@@ -45,9 +45,14 @@ export function realish(abs: string): string {
45
45
  return tail.length ? join(real, ...tail) : real;
46
46
  }
47
47
 
48
+ /** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
49
+ const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
50
+
48
51
  export function within(child: string, parent: string | undefined): boolean {
49
52
  if (!parent) return false;
50
- const rel = relative(parent, child);
53
+ // NFC: a name typed with combining accents is the same file as the precomposed one.
54
+ const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
55
+ const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
51
56
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
52
57
  }
53
58
 
@@ -92,6 +97,30 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
92
97
  export function judgePath(target: string, policy: PathPolicy): PathVerdict {
93
98
  const hit = policy.protectedPaths.find((p) => within(target, p));
94
99
  if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
95
- if (policy.allowed.some((a) => within(target, a))) return { ok: true };
96
- return { ok: false, why: "it is outside the vault and the working folder" };
100
+ const root = policy.allowed.find((a) => within(target, a));
101
+ if (!root) return { ok: false, why: "it is outside the vault and the working folder" };
102
+ // Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
103
+ // Only the part inside the folder counts, so a vault under ~/.something still works.
104
+ const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
105
+ if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
106
+ return { ok: true };
107
+ }
108
+
109
+ /** Files with keys and passwords: the model may not read them, even when you ask. */
110
+ export function secretPaths(): string[] {
111
+ const home = realish(homedir());
112
+ return [
113
+ join(home, ".subsub", "agent", "auth.json"),
114
+ ...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d): d is string => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
115
+ join(home, ".pi", "agent", "auth.json"),
116
+ join(home, ".config", "zotero-local-mcp", "env"),
117
+ join(home, ".ssh"),
118
+ join(home, ".aws"),
119
+ join(home, ".netrc"),
120
+ ];
121
+ }
122
+
123
+ /** True if reading `target` would read a secret; for grep, also a folder that holds one. */
124
+ export function isSecret(target: string, recursive = false, envFile?: string): boolean {
125
+ return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
97
126
  }
package/src/preview.ts CHANGED
@@ -12,6 +12,12 @@ function s(v: unknown, n = 90): string {
12
12
  return t.length > n ? `${t.slice(0, n - 3)}...` : t;
13
13
  }
14
14
 
15
+ /** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
16
+ function whole(v: unknown, n = 3000): string {
17
+ const t = typeof v === "string" ? v : JSON.stringify(v);
18
+ return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
19
+ }
20
+
15
21
  function list(rows: string[], total?: number): string[] {
16
22
  const out = rows.slice(0, MAX_LINES);
17
23
  const rest = (total ?? rows.length) - out.length;
@@ -27,7 +33,7 @@ function describeChange(c: Obj): string {
27
33
  if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
28
34
  if (v && typeof v === "object" && "after" in (v as Obj)) {
29
35
  const b = (v as Obj).before;
30
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${s((v as Obj).after, 60)}`);
36
+ parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
31
37
  }
32
38
  }
33
39
  return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
@@ -83,7 +89,7 @@ export function formatPreview(tool: string, preview: unknown): string {
83
89
  }
84
90
  if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
85
91
  if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
86
- if (p.summary || p.note_preview) lines.push(s(p.summary ?? p.note_preview, 600));
92
+ if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
87
93
  if (p.link) lines.push(`Link: ${p.link}`);
88
94
  }
89
95
  const skipped = p.skipped as Obj | undefined;
package/src/prompt.ts CHANGED
@@ -60,6 +60,12 @@ export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "abou
60
60
  export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
61
61
  const user = who(cfg);
62
62
  const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
63
+ if (cfg.vault) {
64
+ parts.push(
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.`,
67
+ );
68
+ }
63
69
  const shared = readIf(cfg.sharedRules);
64
70
  if (shared) {
65
71
  parts.push(
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,5 +1,5 @@
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
4
  * - Two modes: the Librarian manages the library (Zotero tools only); the
5
5
  * Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
@@ -39,7 +39,7 @@ 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";
@@ -130,6 +130,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
130
130
  if (deps.bridge) registerTools(deps.bridge);
131
131
 
132
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 });
133
134
 
134
135
  let mode: Mode = cfg.defaultMode;
135
136
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
@@ -252,11 +253,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
252
253
  emitWeb();
253
254
  }
254
255
 
255
- async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
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> {
256
258
  mode = next;
257
259
  pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
258
260
  const spec = cfg.models[mode];
259
- if (spec) {
261
+ if (spec && switchModel) {
260
262
  const i = spec.indexOf("/");
261
263
  const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
262
264
  const ok = model ? await pi.setModel(model) : false;
@@ -274,20 +276,28 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
274
276
  registerTools(b);
275
277
  }
276
278
  let restored: Mode | undefined;
277
- for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
279
+ // A resumed conversation keeps its model. pi records a model for every new session too,
280
+ // so only a session with messages counts (pi's own test for an existing session).
281
+ let hasModel = false;
282
+ let hasMessages = false;
283
+ for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
278
284
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
279
285
  if (m === "librarian" || m === "researcher") restored = m;
286
+ if (e.type === "model_change") hasModel = true;
287
+ if (e.type === "message") hasMessages = true;
280
288
  }
289
+ const modelChosen = hasModel && hasMessages;
281
290
  // Judge light or dark before Sub-Sub changes the theme.
282
291
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
283
292
  applyTheme(ctx);
284
- await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
293
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
285
294
  if (lookOn(ctx)) {
286
295
  setHeader(ctx);
287
296
  void refreshLibrary();
288
297
  } else if (webView) {
289
298
  void refreshLibrary();
290
299
  }
300
+ if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
291
301
  for (const [name, err] of Object.entries(bridge.errors)) {
292
302
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
293
303
  }
@@ -300,11 +310,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
300
310
  await b?.close();
301
311
  });
302
312
 
313
+ /** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
314
+ const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
315
+
316
+ // /tree moves to another branch: take that branch's mode (pi restores its model itself).
317
+ pi.on("session_tree", async (_event, ctx) => {
318
+ let m: Mode | undefined;
319
+ for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
320
+ const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
321
+ if (x === "librarian" || x === "researcher") m = x;
322
+ }
323
+ if (m && m !== mode) await applyMode(ctx, m, false, false);
324
+ });
325
+
303
326
  pi.on("model_select", async (_event, ctx) => status(ctx));
304
327
 
305
328
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
306
329
  // The input event comes before pi expands a prompt template.
307
330
  pi.on("input", async (event, ctx) => {
331
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
332
+ if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
333
+ ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
334
+ return { action: "handled" };
335
+ }
308
336
  const why = promptBlocked(event.text, profile);
309
337
  if (!why) return { action: "continue" };
310
338
  ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
@@ -333,6 +361,10 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
333
361
  return { block: true, reason: why };
334
362
  }
335
363
  }
364
+ // grep, find and ls search the working folder when no path is given.
365
+ if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
366
+ return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
367
+ }
336
368
  if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
337
369
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
338
370
  if (!kind) return undefined;
@@ -351,6 +383,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
351
383
  return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
352
384
  }
353
385
  if (kind === "confirm") {
386
+ // A file the tool writes is judged like any file write: protected files stay protected.
387
+ if (typeof input.output_path === "string") {
388
+ const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
389
+ if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
390
+ }
354
391
  const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
355
392
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
356
393
  }
@@ -364,11 +401,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
364
401
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
365
402
  }
366
403
  if (preview.isError) return { block: true, reason: preview.text };
367
- const ok = await ctx.ui.confirm(
368
- `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`,
369
- paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)),
370
- );
371
- return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
404
+ const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
405
+ let shown = formatPreview(name, preview.data ?? preview.text);
406
+ // The dialog may stay open for minutes. Before applying, compute the preview again: if the
407
+ // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
408
+ for (let round = 0; round < 3; round++) {
409
+ const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
410
+ if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
411
+ let again;
412
+ try {
413
+ again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
414
+ } catch (err) {
415
+ return { block: true, reason: `Preview failed: ${(err as Error).message}` };
416
+ }
417
+ if (again.isError) return { block: true, reason: again.text };
418
+ const now = formatPreview(name, again.data ?? again.text);
419
+ if (now === shown) return undefined;
420
+ shown = now;
421
+ }
422
+ return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
372
423
  });
373
424
 
374
425
  /** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
@@ -476,7 +527,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
476
527
  } catch (err) {
477
528
  saved = ` Not saved: ${(err as Error).message}`;
478
529
  }
479
- await applyMode(ctx, mode, false);
530
+ await applyMode(ctx, mode, false, false);
480
531
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
481
532
  },
482
533
  });