@tiagojct/subsub 0.11.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/subsub.js 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
@@ -23,7 +23,7 @@ import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, server
23
23
  import { profileList, profileSpec, promptBlocked } from "./profiles.js";
24
24
  import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
25
25
  import { applyEdits, checkLiteratureNote } from "./notes.js";
26
- import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
26
+ import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
27
27
  import { describeArgs, formatPreview } from "./preview.js";
28
28
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
29
29
  import { systemAddition } from "./prompt.js";
@@ -104,6 +104,7 @@ export async function createSubsub(pi, deps = {}) {
104
104
  if (deps.bridge)
105
105
  registerTools(deps.bridge);
106
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 });
107
108
  let mode = cfg.defaultMode;
108
109
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
109
110
  const toolOpts = () => ({ profile, bench: benchRun });
@@ -220,11 +221,12 @@ export async function createSubsub(pi, deps = {}) {
220
221
  webCtx = ctx;
221
222
  emitWeb();
222
223
  }
223
- async function applyMode(ctx, next, remember) {
224
+ /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
225
+ async function applyMode(ctx, next, remember, switchModel = true) {
224
226
  mode = next;
225
227
  pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
226
228
  const spec = cfg.models[mode];
227
- if (spec) {
229
+ if (spec && switchModel) {
228
230
  const i = spec.indexOf("/");
229
231
  const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
230
232
  const ok = model ? await pi.setModel(model) : false;
@@ -243,16 +245,19 @@ export async function createSubsub(pi, deps = {}) {
243
245
  registerTools(b);
244
246
  }
245
247
  let restored;
248
+ let modelChosen = false; // the session already has a model: a resumed conversation keeps it
246
249
  for (const e of ctx.sessionManager.getBranch()) {
247
250
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
248
251
  if (m === "librarian" || m === "researcher")
249
252
  restored = m;
253
+ if (e.type === "model_change")
254
+ modelChosen = true;
250
255
  }
251
256
  // Judge light or dark before Sub-Sub changes the theme.
252
257
  if (lookOn(ctx))
253
258
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
254
259
  applyTheme(ctx);
255
- await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
260
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
256
261
  if (lookOn(ctx)) {
257
262
  setHeader(ctx);
258
263
  void refreshLibrary();
@@ -271,10 +276,28 @@ export async function createSubsub(pi, deps = {}) {
271
276
  bridge = undefined;
272
277
  await b?.close();
273
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
+ });
274
292
  pi.on("model_select", async (_event, ctx) => status(ctx));
275
293
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
276
294
  // The input event comes before pi expands a prompt template.
277
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
+ }
278
301
  const why = promptBlocked(event.text, profile);
279
302
  if (!why)
280
303
  return { action: "continue" };
@@ -303,6 +326,9 @@ export async function createSubsub(pi, deps = {}) {
303
326
  return { block: true, reason: why };
304
327
  }
305
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
+ }
306
332
  if (name.startsWith("verify_"))
307
333
  return verifyGate(name, input, ctx);
308
334
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
@@ -453,7 +479,7 @@ export async function createSubsub(pi, deps = {}) {
453
479
  catch (err) {
454
480
  saved = ` Not saved: ${err.message}`;
455
481
  }
456
- await applyMode(ctx, mode, false);
482
+ await applyMode(ctx, mode, false, false);
457
483
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
458
484
  },
459
485
  });
package/dist/web.js CHANGED
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
21
21
  import { extname, join, normalize, resolve, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
- import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
24
+ import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
25
25
  import { TESTED_MODELS } from "./init.js";
26
26
  import { obsidianRoot } from "./layout.js";
27
27
  import { realish, within } from "./paths.js";
@@ -300,7 +300,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
300
300
  const bin = join(PACKAGE_DIR, "bin", "subsub.js");
301
301
  env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
302
302
  const childEnv = { ...env, SUBSUB_WEB: "1" };
303
- const baseArgs = [bin, "--mode", "rpc", "--here", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
303
+ const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
304
304
  const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
305
305
  // ---- state shared with the pages
306
306
  const clients = new Set();
@@ -309,7 +309,19 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
309
309
  let busy = false;
310
310
  let lastSeen = Date.now();
311
311
  let restarting = false;
312
+ let switching = false; // a restart for a new model, add-on or key is under way
313
+ let lastSession;
312
314
  let refreshAfterRun = false;
315
+ /** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
316
+ function modeArgs() {
317
+ const shown = subsubState?.mode;
318
+ if (shown === "librarian" || shown === "researcher")
319
+ return [`--${shown}`];
320
+ return opts.librarian ? ["--librarian"] : [];
321
+ }
322
+ function startAgent() {
323
+ agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
324
+ }
313
325
  function broadcast(ev) {
314
326
  const data = `data: ${JSON.stringify(ev)}\n\n`;
315
327
  for (const c of clients)
@@ -352,11 +364,13 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
352
364
  if (restarting)
353
365
  return;
354
366
  busy = false;
367
+ for (const id of dialogs.keys())
368
+ broadcast({ type: "dialog_closed", id });
355
369
  dialogs.clear();
356
370
  broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
357
371
  log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
358
372
  };
359
- agent.start();
373
+ startAgent();
360
374
  async function snapshot() {
361
375
  const out = {
362
376
  type: "hello",
@@ -369,6 +383,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
369
383
  };
370
384
  try {
371
385
  out.session = await agent.request({ type: "get_state" });
386
+ lastSession = out.session.sessionFile ?? lastSession;
372
387
  out.messages = (await agent.request({ type: "get_messages" })).messages;
373
388
  out.commands = (await agent.request({ type: "get_commands" })).commands;
374
389
  }
@@ -387,12 +402,11 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
387
402
  .map((s) => ({ path: s.path, name: s.name ?? null, first: s.firstMessage.slice(0, 140), modified: s.modified, messages: s.messageCount }));
388
403
  }
389
404
  async function restartAgent() {
390
- let sessionFile;
391
405
  try {
392
- sessionFile = (await agent.request({ type: "get_state" })).sessionFile;
406
+ lastSession = (await agent.request({ type: "get_state" })).sessionFile ?? lastSession;
393
407
  }
394
408
  catch {
395
- /* start fresh */
409
+ /* keep the last one known */
396
410
  }
397
411
  restarting = true;
398
412
  for (const id of dialogs.keys())
@@ -401,9 +415,23 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
401
415
  await agent.stop();
402
416
  restarting = false;
403
417
  busy = false;
404
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
418
+ startAgent();
405
419
  broadcast(await snapshot());
406
420
  }
421
+ /** Run a change that restarts pi, one at a time. */
422
+ async function withRestart(res, change) {
423
+ if (busy || switching)
424
+ return send(res, 409, { error: "busy" });
425
+ switching = true;
426
+ try {
427
+ await change();
428
+ await restartAgent();
429
+ }
430
+ finally {
431
+ switching = false;
432
+ }
433
+ return send(res, 200, { ok: true });
434
+ }
407
435
  // ---- idle stop
408
436
  let closing = false;
409
437
  const idleTimer = setInterval(() => {
@@ -545,47 +573,50 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
545
573
  return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
546
574
  }
547
575
  case "POST /api/model": {
548
- if (busy)
576
+ if (busy || switching)
549
577
  return send(res, 409, { error: "busy" });
550
578
  const body = await readJson(req);
551
579
  const spec = `${String(body.provider)}/${String(body.id)}`;
552
580
  // Remember the model for the current mode (or both), then restart pi so the extension reads it.
553
581
  const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
554
- const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
582
+ // Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
583
+ const raw = readConfigFile(configPath(env));
584
+ const saved = raw.models && typeof raw.models === "object" ? raw.models : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
585
+ const models = { ...saved, [mode]: spec };
555
586
  if (body.both)
556
587
  models[mode === "librarian" ? "researcher" : "librarian"] = spec;
557
588
  saveConfig({ models, model: undefined }, env);
558
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
559
- await restartAgent();
560
- return send(res, 200, { ok: true });
589
+ return withRestart(res, async () => {
590
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
591
+ });
561
592
  }
562
593
  case "POST /api/addons": {
563
594
  // Turn an add-on on or off, then restart pi so the extension starts or stops its server.
564
- if (busy)
595
+ if (busy || switching)
565
596
  return send(res, 409, { error: "busy" });
566
597
  const body = await readJson(req);
567
598
  if (typeof body.starbuck !== "boolean")
568
599
  return send(res, 400, { error: "starbuck must be true or false" });
569
- saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
570
- await restartAgent();
571
- return send(res, 200, { ok: true });
600
+ return withRestart(res, () => {
601
+ saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
602
+ });
572
603
  }
573
604
  case "POST /api/restart":
574
- if (agent.proc)
605
+ if (agent.proc || switching)
575
606
  return send(res, 200, { ok: true });
576
- agent.start();
607
+ startAgent();
577
608
  broadcast(await snapshot());
578
609
  return send(res, 200, { ok: true });
579
610
  case "POST /api/login": {
580
- if (busy)
611
+ if (busy || switching)
581
612
  return send(res, 409, { error: "busy" });
582
613
  const body = await readJson(req);
583
- saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
584
- const current = loadConfig(env);
585
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
586
- saveConfig({ models: TESTED_MODELS, model: undefined }, env);
587
- await restartAgent();
588
- return send(res, 200, { ok: true });
614
+ return withRestart(res, () => {
615
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
616
+ const current = loadConfig(env);
617
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
618
+ saveConfig({ models: TESTED_MODELS, model: undefined }, env);
619
+ });
589
620
  }
590
621
  case "POST /api/open": {
591
622
  const body = await readJson(req);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.0",
4
- "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
3
+ "version": "0.11.1",
4
+ "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Tiago Jacinto",
@@ -49,7 +49,6 @@
49
49
  "assets",
50
50
  "bin",
51
51
  "dist",
52
- "docs",
53
52
  "extensions",
54
53
  "prompts",
55
54
  "roles",
@@ -2,4 +2,4 @@
2
2
  description: "Literature note for one item"
3
3
  argument-hint: "<citekey or title words>"
4
4
  ---
5
- Make a literature note for $ARGUMENTS in Resources/Zotero/, following the template in the shared rules. Read the full text if it is indexed. Then offer to link it in Zotero.
5
+ Make a literature note for $ARGUMENTS in Literature/ in the Sub-Sub folder, following the template in the shared rules. Read the full text if it is indexed. Then offer to link it in Zotero.
package/src/bench.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * subsub-bench: the same tasks for several models, against the real library,
3
- * with no changes applied. See docs/Subsub.md ("Model test").
3
+ * with no changes applied. See docs/model-test.md.
4
4
  *
5
5
  * subsub-bench prepare [--seed 7] pick the tasks (read-only)
6
6
  * subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
@@ -79,7 +79,7 @@ export function promptFor(task: string, t: Tasks, code: string, out: string): st
79
79
  case "lit_note":
80
80
  return t.lit_note
81
81
  ? `Make a literature note for ${t.lit_note.citekey}, following the literature-note format in the shared rules. Read the full text. ` +
82
- `This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Resources/Zotero/, and do not attach anything to Zotero.`
82
+ `This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Literature/ in the Sub-Sub folder, and do not attach anything to Zotero.`
83
83
  : undefined;
84
84
  case "synthesis":
85
85
  return t.synthesis
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
@@ -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 {
package/src/doctor.ts CHANGED
@@ -123,7 +123,8 @@ export function runChecks(
123
123
  );
124
124
  const v = st.vocabulary ?? {};
125
125
  if (v.loaded === false || !v.tags) {
126
- out.push({ name: "Tag list", ok: false, detail: v.path ? `cannot read ${v.path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
126
+ const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
127
+ out.push({ name: "Tag list", ok: false, detail: path ? `cannot read ${path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
127
128
  } else {
128
129
  const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
129
130
  out.push({
package/src/init.ts CHANGED
@@ -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.");
@@ -207,7 +213,7 @@ export async function runInit(
207
213
  );
208
214
  }
209
215
 
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.");
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.");
211
217
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
212
218
 
213
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.");
@@ -364,3 +370,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
364
370
  (io as { close?: () => void }).close?.();
365
371
  }
366
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/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
@@ -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,14 +276,16 @@ 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
+ 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 } }>) {
278
281
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
279
282
  if (m === "librarian" || m === "researcher") restored = m;
283
+ if (e.type === "model_change") modelChosen = true;
280
284
  }
281
285
  // Judge light or dark before Sub-Sub changes the theme.
282
286
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
283
287
  applyTheme(ctx);
284
- await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
288
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
285
289
  if (lookOn(ctx)) {
286
290
  setHeader(ctx);
287
291
  void refreshLibrary();
@@ -300,11 +304,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
300
304
  await b?.close();
301
305
  });
302
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
+
303
320
  pi.on("model_select", async (_event, ctx) => status(ctx));
304
321
 
305
322
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
306
323
  // The input event comes before pi expands a prompt template.
307
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
+ }
308
330
  const why = promptBlocked(event.text, profile);
309
331
  if (!why) return { action: "continue" };
310
332
  ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
@@ -333,6 +355,9 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
333
355
  return { block: true, reason: why };
334
356
  }
335
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
+ }
336
361
  if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
337
362
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
338
363
  if (!kind) return undefined;
@@ -476,7 +501,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
476
501
  } catch (err) {
477
502
  saved = ` Not saved: ${(err as Error).message}`;
478
503
  }
479
- await applyMode(ctx, mode, false);
504
+ await applyMode(ctx, mode, false, false);
480
505
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
481
506
  },
482
507
  });