@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/dist/init.js CHANGED
@@ -103,10 +103,12 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
103
103
  // Without questions (--yes), an existing folder stays exactly as it is.
104
104
  const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
105
105
  const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
106
- const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)).trim() || folderDefault, home);
107
- const notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
106
+ const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
107
+ let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
108
108
  if (notes !== answer)
109
109
  io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
110
+ else if (notes !== folderDefault)
111
+ io.say(`Sub-Sub folder: ${notes}`);
110
112
  // ---- files of the old layout
111
113
  const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
112
114
  const moves = planMoves(from, notes);
@@ -119,8 +121,14 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
119
121
  io.say(` and ${moves.length - 6} more`);
120
122
  move = await io.choose("Move them", [
121
123
  { value: "yes", label: "Move them (recommended)" },
122
- { value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : "Leave them where they are" },
124
+ // In a new folder, files left behind would not be read: then "no" means keeping the old folder.
125
+ { value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : flags.folder || flags.notes ? "Leave them where they are" : `Leave them, and keep using ${from}` },
123
126
  ], flags.move ?? (flags.yes ? "no" : "yes"));
127
+ // A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
128
+ if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
129
+ notes = from;
130
+ io.say(`Sub-Sub keeps using ${notes}.`);
131
+ }
124
132
  }
125
133
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
126
134
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
@@ -143,7 +151,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
143
151
  explain("Tag list", `Sub-Sub tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
144
152
  tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
145
153
  }
146
- 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.");
154
+ 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.");
147
155
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
148
156
  explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. Sub-Sub then has /verify.");
149
157
  const starbuck = await io.choose("Reference checks", [
@@ -226,6 +234,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
226
234
  if (fmup)
227
235
  io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
228
236
  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.");
237
+ if (tags === "any-field")
238
+ io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
229
239
  return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
230
240
  }
231
241
  export function parseFlags(args) {
@@ -281,8 +291,10 @@ function terminalIO() {
281
291
  }
282
292
  export async function initMain(args, env = process.env) {
283
293
  const flags = parseFlags(args);
284
- if (!process.stdin.isTTY)
285
- flags.yes ??= "true";
294
+ if (!process.stdin.isTTY && flags.yes === undefined) {
295
+ flags.yes = "true";
296
+ console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
297
+ }
286
298
  const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
287
299
  try {
288
300
  await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
@@ -300,3 +312,12 @@ export async function initMain(args, env = process.env) {
300
312
  io.close?.();
301
313
  }
302
314
  }
315
+ /** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
316
+ export function cleanPath(raw) {
317
+ let p = raw.trim();
318
+ if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0])
319
+ return p.slice(1, -1);
320
+ if (process.platform !== "win32")
321
+ p = p.replace(/\\([ '"()&])/g, "$1");
322
+ return p;
323
+ }
package/dist/paths.js CHANGED
@@ -46,10 +46,14 @@ export function realish(abs) {
46
46
  }
47
47
  return tail.length ? join(real, ...tail) : real;
48
48
  }
49
+ /** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
50
+ const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
49
51
  export function within(child, parent) {
50
52
  if (!parent)
51
53
  return false;
52
- const rel = relative(parent, child);
54
+ // NFC: a name typed with combining accents is the same file as the precomposed one.
55
+ const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
56
+ const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
53
57
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
54
58
  }
55
59
  export function buildPolicy(opts) {
@@ -81,7 +85,30 @@ export function judgePath(target, policy) {
81
85
  const hit = policy.protectedPaths.find((p) => within(target, p));
82
86
  if (hit)
83
87
  return { ok: false, why: `it changes a protected file or folder (${hit})` };
84
- if (policy.allowed.some((a) => within(target, a)))
85
- return { ok: true };
86
- return { ok: false, why: "it is outside the vault and the working folder" };
88
+ const root = policy.allowed.find((a) => within(target, a));
89
+ if (!root)
90
+ return { ok: false, why: "it is outside the vault and the working folder" };
91
+ // Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
92
+ // Only the part inside the folder counts, so a vault under ~/.something still works.
93
+ const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
94
+ if (hidden)
95
+ return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
96
+ return { ok: true };
97
+ }
98
+ /** Files with keys and passwords: the model may not read them, even when you ask. */
99
+ export function secretPaths() {
100
+ const home = realish(homedir());
101
+ return [
102
+ join(home, ".subsub", "agent", "auth.json"),
103
+ ...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d) => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
104
+ join(home, ".pi", "agent", "auth.json"),
105
+ join(home, ".config", "zotero-local-mcp", "env"),
106
+ join(home, ".ssh"),
107
+ join(home, ".aws"),
108
+ join(home, ".netrc"),
109
+ ];
110
+ }
111
+ /** True if reading `target` would read a secret; for grep, also a folder that holds one. */
112
+ export function isSecret(target, recursive = false, envFile) {
113
+ return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
87
114
  }
package/dist/preview.js CHANGED
@@ -7,6 +7,11 @@ function s(v, n = 90) {
7
7
  const t = typeof v === "string" ? v : JSON.stringify(v);
8
8
  return t.length > n ? `${t.slice(0, n - 3)}...` : t;
9
9
  }
10
+ /** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
11
+ function whole(v, n = 3000) {
12
+ const t = typeof v === "string" ? v : JSON.stringify(v);
13
+ return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
14
+ }
10
15
  function list(rows, total) {
11
16
  const out = rows.slice(0, MAX_LINES);
12
17
  const rest = (total ?? rows.length) - out.length;
@@ -25,7 +30,7 @@ function describeChange(c) {
25
30
  continue;
26
31
  if (v && typeof v === "object" && "after" in v) {
27
32
  const b = v.before;
28
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${s(v.after, 60)}`);
33
+ parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
29
34
  }
30
35
  }
31
36
  return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
@@ -69,7 +74,7 @@ export function formatPreview(tool, preview) {
69
74
  if (p.item || p.parent)
70
75
  lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
71
76
  if (p.summary || p.note_preview)
72
- lines.push(s(p.summary ?? p.note_preview, 600));
77
+ lines.push(whole(p.summary ?? p.note_preview));
73
78
  if (p.link)
74
79
  lines.push(`Link: ${p.link}`);
75
80
  }
package/dist/prompt.js CHANGED
@@ -54,6 +54,9 @@ export function roleText(mode, cfg) {
54
54
  export function systemAddition(mode, cfg, cwd) {
55
55
  const user = who(cfg);
56
56
  const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
57
+ if (cfg.vault) {
58
+ parts.push("# The Sub-Sub folder", `The Sub-Sub folder is ${cfg.vault}. Folder names in these instructions (Inbox/, Literature/, Syntheses/, Research/, Zotero/) are inside it. Write notes there with absolute paths, even when the working folder is somewhere else.`);
59
+ }
57
60
  const shared = readIf(cfg.sharedRules);
58
61
  if (shared) {
59
62
  parts.push("# Shared Zotero rules (from the Sub-Sub folder)", shared, `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`);
package/dist/shortcut.js CHANGED
@@ -64,7 +64,7 @@ export function desktopEntry(p) {
64
64
  "[Desktop Entry]",
65
65
  "Type=Application",
66
66
  "Name=Sub-Sub",
67
- "Comment=Zotero librarian and research assistant",
67
+ "Comment=Research assistant for your Zotero library",
68
68
  `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
69
69
  `Path=${p.home}`,
70
70
  `Icon=${join(p.packageDir, "assets", "icon.png")}`,
@@ -85,7 +85,7 @@ export function windowsScript(p) {
85
85
  ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
86
86
  ` $s.WorkingDirectory = ${psQuote(p.home)}`,
87
87
  ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
88
- " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
88
+ " $s.Description = 'Sub-Sub: research assistant for your Zotero library'",
89
89
  " $s.WindowStyle = 7",
90
90
  " $s.Save()",
91
91
  " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
package/dist/subsub.js CHANGED
@@ -1,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,25 @@ export async function createSubsub(pi, deps = {}) {
243
245
  registerTools(b);
244
246
  }
245
247
  let restored;
248
+ // A resumed conversation keeps its model. pi records a model for every new session too,
249
+ // so only a session with messages counts (pi's own test for an existing session).
250
+ let hasModel = false;
251
+ let hasMessages = false;
246
252
  for (const e of ctx.sessionManager.getBranch()) {
247
253
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
248
254
  if (m === "librarian" || m === "researcher")
249
255
  restored = m;
256
+ if (e.type === "model_change")
257
+ hasModel = true;
258
+ if (e.type === "message")
259
+ hasMessages = true;
250
260
  }
261
+ const modelChosen = hasModel && hasMessages;
251
262
  // Judge light or dark before Sub-Sub changes the theme.
252
263
  if (lookOn(ctx))
253
264
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
254
265
  applyTheme(ctx);
255
- await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
266
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
256
267
  if (lookOn(ctx)) {
257
268
  setHeader(ctx);
258
269
  void refreshLibrary();
@@ -260,6 +271,8 @@ export async function createSubsub(pi, deps = {}) {
260
271
  else if (webView) {
261
272
  void refreshLibrary();
262
273
  }
274
+ if (!ctx.model && !webView)
275
+ 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");
263
276
  for (const [name, err] of Object.entries(bridge.errors)) {
264
277
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
265
278
  }
@@ -271,10 +284,28 @@ export async function createSubsub(pi, deps = {}) {
271
284
  bridge = undefined;
272
285
  await b?.close();
273
286
  });
287
+ /** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
288
+ const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
289
+ // /tree moves to another branch: take that branch's mode (pi restores its model itself).
290
+ pi.on("session_tree", async (_event, ctx) => {
291
+ let m;
292
+ for (const e of ctx.sessionManager.getBranch()) {
293
+ const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
294
+ if (x === "librarian" || x === "researcher")
295
+ m = x;
296
+ }
297
+ if (m && m !== mode)
298
+ await applyMode(ctx, m, false, false);
299
+ });
274
300
  pi.on("model_select", async (_event, ctx) => status(ctx));
275
301
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
276
302
  // The input event comes before pi expands a prompt template.
277
303
  pi.on("input", async (event, ctx) => {
304
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
305
+ if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
306
+ ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
307
+ return { action: "handled" };
308
+ }
278
309
  const why = promptBlocked(event.text, profile);
279
310
  if (!why)
280
311
  return { action: "continue" };
@@ -303,6 +334,10 @@ export async function createSubsub(pi, deps = {}) {
303
334
  return { block: true, reason: why };
304
335
  }
305
336
  }
337
+ // grep, find and ls search the working folder when no path is given.
338
+ if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
339
+ return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
340
+ }
306
341
  if (name.startsWith("verify_"))
307
342
  return verifyGate(name, input, ctx);
308
343
  const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
@@ -327,6 +362,12 @@ export async function createSubsub(pi, deps = {}) {
327
362
  return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
328
363
  }
329
364
  if (kind === "confirm") {
365
+ // A file the tool writes is judged like any file write: protected files stay protected.
366
+ if (typeof input.output_path === "string") {
367
+ const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
368
+ if (!verdict.ok && /protected|hidden/.test(verdict.why))
369
+ return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
370
+ }
330
371
  const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
331
372
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
332
373
  }
@@ -342,8 +383,29 @@ export async function createSubsub(pi, deps = {}) {
342
383
  }
343
384
  if (preview.isError)
344
385
  return { block: true, reason: preview.text };
345
- const ok = await ctx.ui.confirm(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)));
346
- return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
386
+ const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
387
+ let shown = formatPreview(name, preview.data ?? preview.text);
388
+ // The dialog may stay open for minutes. Before applying, compute the preview again: if the
389
+ // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
390
+ for (let round = 0; round < 3; round++) {
391
+ const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
392
+ if (!ok)
393
+ return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
394
+ let again;
395
+ try {
396
+ again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
397
+ }
398
+ catch (err) {
399
+ return { block: true, reason: `Preview failed: ${err.message}` };
400
+ }
401
+ if (again.isError)
402
+ return { block: true, reason: again.text };
403
+ const now = formatPreview(name, again.data ?? again.text);
404
+ if (now === shown)
405
+ return undefined;
406
+ shown = now;
407
+ }
408
+ return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
347
409
  });
348
410
  /** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
349
411
  function literatureNoteProblem(name, target, input) {
@@ -453,7 +515,7 @@ export async function createSubsub(pi, deps = {}) {
453
515
  catch (err) {
454
516
  saved = ` Not saved: ${err.message}`;
455
517
  }
456
- await applyMode(ctx, mode, false);
518
+ await applyMode(ctx, mode, false, false);
457
519
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
458
520
  },
459
521
  });
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,23 +402,40 @@ 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())
399
413
  broadcast({ type: "dialog_closed", id });
400
414
  dialogs.clear();
401
- await agent.stop();
402
- restarting = false;
415
+ try {
416
+ await agent.stop();
417
+ }
418
+ finally {
419
+ restarting = false;
420
+ }
403
421
  busy = false;
404
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
422
+ startAgent();
405
423
  broadcast(await snapshot());
406
424
  }
425
+ /** Run a change that restarts pi, one at a time. */
426
+ async function withRestart(res, change) {
427
+ if (busy || switching)
428
+ return send(res, 409, { error: "busy" });
429
+ switching = true;
430
+ try {
431
+ await change();
432
+ await restartAgent();
433
+ }
434
+ finally {
435
+ switching = false;
436
+ }
437
+ return send(res, 200, { ok: true });
438
+ }
407
439
  // ---- idle stop
408
440
  let closing = false;
409
441
  const idleTimer = setInterval(() => {
@@ -492,7 +524,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
492
524
  const message = String(body.message ?? "").trim();
493
525
  if (!message)
494
526
  return send(res, 400, { error: "empty message" });
495
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
527
+ try {
528
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
529
+ }
530
+ catch (err) {
531
+ // No provider yet: the page opens the model dialog instead of showing pi's text.
532
+ if (/no api key|no model|no models available/i.test(err.message))
533
+ return send(res, 409, { error: "nomodel" });
534
+ throw err;
535
+ }
496
536
  return send(res, 200, { ok: true });
497
537
  }
498
538
  case "POST /api/abort":
@@ -545,47 +585,51 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
545
585
  return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
546
586
  }
547
587
  case "POST /api/model": {
548
- if (busy)
588
+ if (busy || switching)
549
589
  return send(res, 409, { error: "busy" });
550
590
  const body = await readJson(req);
551
591
  const spec = `${String(body.provider)}/${String(body.id)}`;
552
592
  // Remember the model for the current mode (or both), then restart pi so the extension reads it.
553
593
  const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
554
- const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
594
+ // Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
595
+ const raw = readConfigFile(configPath(env));
596
+ const saved = raw.models && typeof raw.models === "object" ? raw.models : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
597
+ const models = { ...saved, [mode]: spec };
555
598
  if (body.both)
556
599
  models[mode === "librarian" ? "researcher" : "librarian"] = spec;
557
- 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 });
600
+ return withRestart(res, async () => {
601
+ saveConfig({ models, model: undefined }, env);
602
+ // The open conversation records the new model, so it keeps it after the restart.
603
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => { });
604
+ });
561
605
  }
562
606
  case "POST /api/addons": {
563
607
  // Turn an add-on on or off, then restart pi so the extension starts or stops its server.
564
- if (busy)
608
+ if (busy || switching)
565
609
  return send(res, 409, { error: "busy" });
566
610
  const body = await readJson(req);
567
611
  if (typeof body.starbuck !== "boolean")
568
612
  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 });
613
+ return withRestart(res, () => {
614
+ saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
615
+ });
572
616
  }
573
617
  case "POST /api/restart":
574
- if (agent.proc)
618
+ if (agent.proc || switching)
575
619
  return send(res, 200, { ok: true });
576
- agent.start();
620
+ startAgent();
577
621
  broadcast(await snapshot());
578
622
  return send(res, 200, { ok: true });
579
623
  case "POST /api/login": {
580
- if (busy)
624
+ if (busy || switching)
581
625
  return send(res, 409, { error: "busy" });
582
626
  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 });
627
+ return withRestart(res, () => {
628
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
629
+ const current = loadConfig(env);
630
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
631
+ saveConfig({ models: TESTED_MODELS, model: undefined }, env);
632
+ });
589
633
  }
590
634
  case "POST /api/open": {
591
635
  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.2",
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/bridge.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
9
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import { refreshedArgs } from "./config.ts";
10
11
 
11
12
  export interface ServerSpec {
12
13
  name: string; // tool prefix: "zotero" or "scholar"
@@ -85,12 +86,14 @@ export class Bridge {
85
86
  });
86
87
  }
87
88
  } catch (err) {
88
- this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
89
89
  try {
90
90
  await client.close();
91
91
  } catch {
92
92
  /* ignore */
93
93
  }
94
+ const retry = refreshedArgs(spec.args, stderr);
95
+ if (retry) return this.startOne({ ...spec, args: retry }, timeoutMs);
96
+ this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
94
97
  }
95
98
  }
96
99