@tiagojct/subsub 0.11.1 → 0.11.3

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/README.md CHANGED
@@ -25,8 +25,8 @@ Each mode can have its own model. Switch with `/researcher` and `/librarian`, or
25
25
  Every library change works the same way in both modes:
26
26
 
27
27
  - The Zotero server computes the change first. Sub-Sub shows it to you (added tags in green, removed tags in red) and applies it only when you select Yes. The check is in the program, so the model cannot skip it.
28
- - Each change sends the item's version, so an edit you make in Zotero at the same time is not overwritten.
29
- - Every change is journaled. `/undo` reverts it.
28
+ - If you edit an item in Zotero while the preview is open, Sub-Sub shows you the new preview before it changes anything. Each change also sends the item's version, so a field you edit at the moment of writing is not overwritten.
29
+ - Every change to an item is journaled. `/undo` reverts it. (A new collection is not; remove it in Zotero if you do not want it.)
30
30
  - Nothing is deleted: items go to the Zotero trash. Only tags from your tag list can be added.
31
31
 
32
32
  ## Profiles
package/dist/bridge.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
+ import { refreshedArgs } from "./config.js";
9
10
  function withTimeout(p, ms, what) {
10
11
  return new Promise((resolve, reject) => {
11
12
  const t = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s`)), ms);
@@ -56,13 +57,16 @@ export class Bridge {
56
57
  }
57
58
  }
58
59
  catch (err) {
59
- this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
60
60
  try {
61
61
  await client.close();
62
62
  }
63
63
  catch {
64
64
  /* ignore */
65
65
  }
66
+ const retry = refreshedArgs(spec.args, stderr);
67
+ if (retry)
68
+ return this.startOne({ ...spec, args: retry }, timeoutMs);
69
+ this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
66
70
  }
67
71
  }
68
72
  has(fullName) {
package/dist/config.js CHANGED
@@ -33,7 +33,7 @@ export const DEFAULT_MODELS = {
33
33
  };
34
34
  export const PROFILES = ["reader", "scholar", "author", "editor"];
35
35
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
36
- export const SERVER_VERSION = "0.5.1";
36
+ export const SERVER_VERSION = "0.5.3";
37
37
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
38
38
  export const STARBUCK_VERSION = "0.2.0";
39
39
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
@@ -171,6 +171,20 @@ export function serverCommand(cfg, program, args = [], env = process.env) {
171
171
  }
172
172
  return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
173
173
  }
174
+ /**
175
+ * uv caches the PyPI index; right after a release it may not know the pinned version yet
176
+ * ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
177
+ * the index for that package refreshed. Returns undefined when a retry would not help.
178
+ */
179
+ export function refreshedArgs(args, stderr) {
180
+ if (!/there is no version of|no solution found/i.test(stderr))
181
+ return undefined;
182
+ const i = args.indexOf("--from");
183
+ if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package"))
184
+ return undefined;
185
+ const pkg = args[i + 1].split(/[=<>!~]/)[0];
186
+ return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
187
+ }
174
188
  export function starbuckEnabled(cfg) {
175
189
  return (cfg.addons ?? []).includes("starbuck");
176
190
  }
package/dist/doctor.js CHANGED
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, } from "./config.js";
13
+ import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, refreshedArgs, } from "./config.js";
14
14
  import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
15
15
  import { profileSpec } from "./profiles.js";
16
16
  export const defaultRunner = (command, args, env) => {
@@ -50,7 +50,11 @@ export function runChecks(cfg, env, deps = {}) {
50
50
  out.push({ name: "uv", ok: true, detail: uvRun.stdout.trim() });
51
51
  const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
52
52
  const local = cmd.args.includes("--directory");
53
- const res = run(cmd.command, cmd.args, { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) });
53
+ const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
54
+ let res = run(cmd.command, cmd.args, serverEnv);
55
+ const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
56
+ if (retry)
57
+ res = run(cmd.command, retry, serverEnv);
54
58
  let st;
55
59
  try {
56
60
  st = JSON.parse(res.stdout);
@@ -106,7 +110,7 @@ export function runChecks(cfg, env, deps = {}) {
106
110
  const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
107
111
  out.push(hasLogin(agentDir)
108
112
  ? { 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" }
109
- : { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." });
113
+ : { 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." });
110
114
  return out;
111
115
  }
112
116
  /** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
package/dist/init.js CHANGED
@@ -88,12 +88,6 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
88
88
  io.say("");
89
89
  io.say(`${title}. ${text}`);
90
90
  };
91
- explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
92
- const setup = await io.choose("Setup", [
93
- { value: "standard", label: "Standard" },
94
- { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
95
- ], flags.setup ?? existing.setup ?? "standard");
96
- const fmup = setup === "fmup";
97
91
  explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
98
92
  const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
99
93
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
@@ -107,6 +101,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
107
101
  let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
108
102
  if (notes !== answer)
109
103
  io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
104
+ else if (notes !== folderDefault)
105
+ io.say(`Sub-Sub folder: ${notes}`);
110
106
  // ---- files of the old layout
111
107
  const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
112
108
  const moves = planMoves(from, notes);
@@ -128,6 +124,13 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
128
124
  io.say(`Sub-Sub keeps using ${notes}.`);
129
125
  }
130
126
  }
127
+ // Asked here, not first: it only changes the defaults of the next two questions.
128
+ explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
129
+ const setup = await io.choose("Set-up", [
130
+ { value: "standard", label: "Standard (any field, any institution)" },
131
+ { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
132
+ ], flags.setup ?? existing.setup ?? "standard");
133
+ const fmup = setup === "fmup";
131
134
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
132
135
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
133
136
  const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
@@ -232,6 +235,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
232
235
  if (fmup)
233
236
  io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
234
237
  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.");
238
+ if (tags === "any-field")
239
+ io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
235
240
  return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
236
241
  }
237
242
  export function parseFlags(args) {
@@ -287,8 +292,10 @@ function terminalIO() {
287
292
  }
288
293
  export async function initMain(args, env = process.env) {
289
294
  const flags = parseFlags(args);
290
- if (!process.stdin.isTTY)
291
- flags.yes ??= "true";
295
+ if (!process.stdin.isTTY && flags.yes === undefined) {
296
+ flags.yes = "true";
297
+ console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
298
+ }
292
299
  const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
293
300
  try {
294
301
  await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
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,19 +85,22 @@ 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})` };
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" };
84
91
  // Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
85
- const hidden = target.split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
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 !== "..");
86
94
  if (hidden)
87
95
  return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
88
- if (policy.allowed.some((a) => within(target, a)))
89
- return { ok: true };
90
- return { ok: false, why: "it is outside the vault and the working folder" };
96
+ return { ok: true };
91
97
  }
92
98
  /** Files with keys and passwords: the model may not read them, even when you ask. */
93
99
  export function secretPaths() {
94
100
  const home = realish(homedir());
95
101
  return [
96
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")),
97
104
  join(home, ".pi", "agent", "auth.json"),
98
105
  join(home, ".config", "zotero-local-mcp", "env"),
99
106
  join(home, ".ssh"),
@@ -102,6 +109,6 @@ export function secretPaths() {
102
109
  ];
103
110
  }
104
111
  /** True if reading `target` would read a secret; for grep, also a folder that holds one. */
105
- export function isSecret(target, recursive = false) {
106
- return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
112
+ export function isSecret(target, recursive = false, envFile) {
113
+ return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
107
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/subsub.js CHANGED
@@ -245,14 +245,20 @@ export async function createSubsub(pi, deps = {}) {
245
245
  registerTools(b);
246
246
  }
247
247
  let restored;
248
- let modelChosen = false; // the session already has a model: a resumed conversation keeps it
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;
249
252
  for (const e of ctx.sessionManager.getBranch()) {
250
253
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
251
254
  if (m === "librarian" || m === "researcher")
252
255
  restored = m;
253
256
  if (e.type === "model_change")
254
- modelChosen = true;
257
+ hasModel = true;
258
+ if (e.type === "message")
259
+ hasMessages = true;
255
260
  }
261
+ const modelChosen = hasModel && hasMessages;
256
262
  // Judge light or dark before Sub-Sub changes the theme.
257
263
  if (lookOn(ctx))
258
264
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
@@ -265,6 +271,8 @@ export async function createSubsub(pi, deps = {}) {
265
271
  else if (webView) {
266
272
  void refreshLibrary();
267
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");
268
276
  for (const [name, err] of Object.entries(bridge.errors)) {
269
277
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
270
278
  }
@@ -326,7 +334,8 @@ export async function createSubsub(pi, deps = {}) {
326
334
  return { block: true, reason: why };
327
335
  }
328
336
  }
329
- if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
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)) {
330
339
  return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
331
340
  }
332
341
  if (name.startsWith("verify_"))
@@ -353,6 +362,12 @@ export async function createSubsub(pi, deps = {}) {
353
362
  return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
354
363
  }
355
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
+ }
356
371
  const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
357
372
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
358
373
  }
@@ -368,8 +383,29 @@ export async function createSubsub(pi, deps = {}) {
368
383
  }
369
384
  if (preview.isError)
370
385
  return { block: true, reason: preview.text };
371
- const ok = await ctx.ui.confirm(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)));
372
- 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." };
373
409
  });
374
410
  /** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
375
411
  function literatureNoteProblem(name, target, input) {
package/dist/web.js CHANGED
@@ -412,8 +412,12 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
412
412
  for (const id of dialogs.keys())
413
413
  broadcast({ type: "dialog_closed", id });
414
414
  dialogs.clear();
415
- await agent.stop();
416
- restarting = false;
415
+ try {
416
+ await agent.stop();
417
+ }
418
+ finally {
419
+ restarting = false;
420
+ }
417
421
  busy = false;
418
422
  startAgent();
419
423
  broadcast(await snapshot());
@@ -520,7 +524,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
520
524
  const message = String(body.message ?? "").trim();
521
525
  if (!message)
522
526
  return send(res, 400, { error: "empty message" });
523
- 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
+ }
524
536
  return send(res, 200, { ok: true });
525
537
  }
526
538
  case "POST /api/abort":
@@ -585,9 +597,10 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
585
597
  const models = { ...saved, [mode]: spec };
586
598
  if (body.both)
587
599
  models[mode === "librarian" ? "researcher" : "librarian"] = spec;
588
- saveConfig({ models, model: undefined }, env);
589
600
  return withRestart(res, async () => {
590
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
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(() => { });
591
604
  });
592
605
  }
593
606
  case "POST /api/addons": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
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",
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
 
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.3";
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";
@@ -225,6 +225,19 @@ export function serverCommand(
225
225
  return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
226
226
  }
227
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
+
228
241
  export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
229
242
  return (cfg.addons ?? []).includes("starbuck");
230
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);
@@ -150,7 +154,7 @@ export function runChecks(
150
154
  out.push(
151
155
  hasLogin(agentDir)
152
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" }
153
- : { 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." },
154
158
  );
155
159
  return out;
156
160
  }
package/src/init.ts CHANGED
@@ -128,17 +128,6 @@ export async function runInit(
128
128
  io.say(`${title}. ${text}`);
129
129
  };
130
130
 
131
- explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
132
- const setup = await io.choose(
133
- "Setup",
134
- [
135
- { value: "standard", label: "Standard" },
136
- { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
137
- ],
138
- flags.setup ?? existing.setup ?? "standard",
139
- );
140
- const fmup = setup === "fmup";
141
-
142
131
  explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
143
132
  const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
144
133
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
@@ -155,6 +144,7 @@ export async function runInit(
155
144
  const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
156
145
  let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
157
146
  if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
147
+ else if (notes !== folderDefault) io.say(`Sub-Sub folder: ${notes}`);
158
148
 
159
149
  // ---- files of the old layout
160
150
  const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
@@ -183,6 +173,18 @@ export async function runInit(
183
173
  }
184
174
  }
185
175
 
176
+ // Asked here, not first: it only changes the defaults of the next two questions.
177
+ explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
178
+ const setup = await io.choose(
179
+ "Set-up",
180
+ [
181
+ { value: "standard", label: "Standard (any field, any institution)" },
182
+ { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
183
+ ],
184
+ flags.setup ?? existing.setup ?? "standard",
185
+ );
186
+ const fmup = setup === "fmup";
187
+
186
188
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
187
189
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
188
190
  const profile = await io.choose(
@@ -301,6 +303,7 @@ export async function runInit(
301
303
  if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
302
304
  if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
303
305
  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.");
306
+ 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".');
304
307
  return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
305
308
  }
306
309
 
@@ -354,7 +357,10 @@ function terminalIO(): InitIO & { close(): void } {
354
357
 
355
358
  export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
356
359
  const flags = parseFlags(args);
357
- if (!process.stdin.isTTY) flags.yes ??= "true";
360
+ if (!process.stdin.isTTY && flags.yes === undefined) {
361
+ flags.yes = "true";
362
+ console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
363
+ }
358
364
  const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
359
365
  try {
360
366
  await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
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,11 +97,13 @@ 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})` };
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" };
95
102
  // 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 !== "..");
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 !== "..");
97
105
  if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
98
- if (policy.allowed.some((a) => within(target, a))) return { ok: true };
99
- return { ok: false, why: "it is outside the vault and the working folder" };
106
+ return { ok: true };
100
107
  }
101
108
 
102
109
  /** Files with keys and passwords: the model may not read them, even when you ask. */
@@ -104,6 +111,7 @@ export function secretPaths(): string[] {
104
111
  const home = realish(homedir());
105
112
  return [
106
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")),
107
115
  join(home, ".pi", "agent", "auth.json"),
108
116
  join(home, ".config", "zotero-local-mcp", "env"),
109
117
  join(home, ".ssh"),
@@ -113,6 +121,6 @@ export function secretPaths(): string[] {
113
121
  }
114
122
 
115
123
  /** 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)));
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)));
118
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/subsub.ts CHANGED
@@ -276,12 +276,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
276
276
  registerTools(b);
277
277
  }
278
278
  let restored: Mode | undefined;
279
- let modelChosen = false; // the session already has a model: a resumed conversation keeps it
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;
280
283
  for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
281
284
  const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
282
285
  if (m === "librarian" || m === "researcher") restored = m;
283
- if (e.type === "model_change") modelChosen = true;
286
+ if (e.type === "model_change") hasModel = true;
287
+ if (e.type === "message") hasMessages = true;
284
288
  }
289
+ const modelChosen = hasModel && hasMessages;
285
290
  // Judge light or dark before Sub-Sub changes the theme.
286
291
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
287
292
  applyTheme(ctx);
@@ -292,6 +297,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
292
297
  } else if (webView) {
293
298
  void refreshLibrary();
294
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");
295
301
  for (const [name, err] of Object.entries(bridge.errors)) {
296
302
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
297
303
  }
@@ -355,7 +361,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
355
361
  return { block: true, reason: why };
356
362
  }
357
363
  }
358
- if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
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)) {
359
366
  return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
360
367
  }
361
368
  if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
@@ -376,6 +383,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
376
383
  return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
377
384
  }
378
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
+ }
379
391
  const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
380
392
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
381
393
  }
@@ -389,11 +401,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
389
401
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
390
402
  }
391
403
  if (preview.isError) return { block: true, reason: preview.text };
392
- const ok = await ctx.ui.confirm(
393
- `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`,
394
- paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)),
395
- );
396
- 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." };
397
423
  });
398
424
 
399
425
  /** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
package/src/web.ts CHANGED
@@ -444,8 +444,11 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
444
444
  restarting = true;
445
445
  for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
446
446
  dialogs.clear();
447
- await agent.stop();
448
- restarting = false;
447
+ try {
448
+ await agent.stop();
449
+ } finally {
450
+ restarting = false;
451
+ }
449
452
  busy = false;
450
453
  startAgent();
451
454
  broadcast(await snapshot());
@@ -547,7 +550,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
547
550
  const body = await readJson(req);
548
551
  const message = String(body.message ?? "").trim();
549
552
  if (!message) return send(res, 400, { error: "empty message" });
550
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
553
+ try {
554
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
555
+ } catch (err) {
556
+ // No provider yet: the page opens the model dialog instead of showing pi's text.
557
+ if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
558
+ throw err;
559
+ }
551
560
  return send(res, 200, { ok: true });
552
561
  }
553
562
  case "POST /api/abort":
@@ -601,9 +610,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
601
610
  const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
602
611
  const models: Record<string, string> = { ...saved, [mode]: spec };
603
612
  if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
604
- saveConfig({ models, model: undefined }, env);
605
613
  return withRestart(res, async () => {
606
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
614
+ saveConfig({ models, model: undefined }, env);
615
+ // The open conversation records the new model, so it keeps it after the restart.
616
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => {});
607
617
  });
608
618
  }
609
619
  case "POST /api/addons": {
package/web/app.css CHANGED
@@ -158,6 +158,7 @@ button:disabled { opacity: 0.45; cursor: default; }
158
158
  .icon-btn:hover { color: var(--text); background: var(--surface); }
159
159
  .icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
160
160
 
161
+ .model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
161
162
  .chip-btn {
162
163
  max-width: 16rem;
163
164
  overflow: hidden;
package/web/app.js CHANGED
@@ -183,7 +183,9 @@ function renderHeader() {
183
183
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
184
184
  $("starbuck-label").title = t("starbuckHint");
185
185
  const m = state?.model ?? session?.model;
186
- $("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
186
+ const none = !m || !m.id || m.id === "unknown";
187
+ $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
188
+ for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
187
189
  const lib = $("library");
188
190
  const txt = lib.querySelector(".library-text") ?? lib;
189
191
  const L = state?.library;
@@ -205,8 +207,10 @@ function renderHeader() {
205
207
  txt.textContent += ` · ${t("serversDown", { x: state.down.join(", ") })}`;
206
208
  lib.classList.add("down");
207
209
  }
208
- lib.title = txt.textContent;
209
- $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
210
+ lib.title = L?.toReview ? `${txt.textContent}\n${t("toReviewHint")}` : txt.textContent;
211
+ // On a phone the long hint does not fit in the box.
212
+ const narrow = window.matchMedia("(max-width: 560px)").matches;
213
+ $("input").placeholder = narrow ? t("placeholderShort") : mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
210
214
  if (log.querySelector(".empty")) {
211
215
  clearEmpty();
212
216
  renderEmpty();
@@ -862,7 +866,8 @@ function nextDialog() {
862
866
  dlg.onkeydown = (e) => {
863
867
  if (d.method === "confirm") {
864
868
  if (e.repeat || performance.now() - openedAt < 700) {
865
- if (!e.metaKey && !e.ctrlKey && e.key.length === 1) e.preventDefault();
869
+ // Escape still declines (through oncancel); Enter and Space would press the focused button.
870
+ if (e.key !== "Escape" && e.key !== "Tab") e.preventDefault();
866
871
  return;
867
872
  }
868
873
  if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
@@ -981,6 +986,16 @@ async function sendMessage(text) {
981
986
  return true;
982
987
  } catch (err) {
983
988
  if (isTemplate) sentTemplates.pop();
989
+ if (err.message === "nomodel") {
990
+ // Keep what the user wrote, and show where to connect a provider.
991
+ if (!input.value.trim()) {
992
+ input.value = message;
993
+ autosize();
994
+ }
995
+ note(t("noModelSend"), "warning");
996
+ showModels();
997
+ return false;
998
+ }
984
999
  note(err.message, "error");
985
1000
  return false;
986
1001
  }
package/web/i18n.js CHANGED
@@ -5,7 +5,8 @@ export const STRINGS = {
5
5
  en: {
6
6
  profile: "Profile",
7
7
  model: "Model",
8
- noModel: "No model",
8
+ noModel: "Connect a model",
9
+ noModelSend: "No model is connected yet. Choose a provider and save its key; your message is still in the box.",
9
10
  newConversation: "New conversation",
10
11
  actions: "Actions",
11
12
  conversations: "Conversations",
@@ -26,7 +27,8 @@ export const STRINGS = {
26
27
  zoteroChecking: "Zotero: checking",
27
28
  zoteroDown: "Zotero is not running",
28
29
  items: "{n} items",
29
- toReview: "{n} to review",
30
+ toReview: "{n} to check",
31
+ toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
30
32
  serversDown: "Not running: {x}",
31
33
  working: "Working",
32
34
  using: "Using: {x}",
@@ -88,6 +90,7 @@ export const STRINGS = {
88
90
  librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
89
91
  placeholderResearcher: "Ask about your library or the literature, or type / for commands",
90
92
  placeholderLibrarian: "Ask for a change to your library, or type / for commands",
93
+ placeholderShort: "Ask, or type / for commands",
91
94
  emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
92
95
  emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
93
96
  forBoth: "Use it in both modes",
@@ -97,7 +100,8 @@ export const STRINGS = {
97
100
  pt: {
98
101
  profile: "Perfil",
99
102
  model: "Modelo",
100
- noModel: "Sem modelo",
103
+ noModel: "Ligar um modelo",
104
+ noModelSend: "Ainda não há um modelo ligado. Escolha um fornecedor e guarde a chave; a sua mensagem continua na caixa.",
101
105
  newConversation: "Nova conversa",
102
106
  actions: "Ações",
103
107
  conversations: "Conversas",
@@ -118,7 +122,8 @@ export const STRINGS = {
118
122
  zoteroChecking: "Zotero: a verificar",
119
123
  zoteroDown: "O Zotero não está aberto",
120
124
  items: "{n} itens",
121
- toReview: "{n} por rever",
125
+ toReview: "{n} por verificar",
126
+ toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
122
127
  serversDown: "Parados: {x}",
123
128
  working: "A trabalhar",
124
129
  using: "A usar: {x}",
@@ -180,6 +185,7 @@ export const STRINGS = {
180
185
  librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
181
186
  placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
182
187
  placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
188
+ placeholderShort: "Pergunte, ou escreva / para os comandos",
183
189
  emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
184
190
  emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
185
191
  forBoth: "Usar nos dois modos",