@tiagojct/subsub 0.11.4 → 0.12.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
@@ -19,15 +19,16 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { Bridge } from "./bridge.js";
22
- import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
22
+ import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
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
26
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
27
27
  import { describeArgs, formatPreview } from "./preview.js";
28
- import { providerTrouble, TROUBLE_TEXT } from "./keys.js";
28
+ import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
29
29
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
30
30
  import { systemAddition } from "./prompt.js";
31
+ import { logUsage } from "./usage.js";
31
32
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
32
33
  const MODE_ENTRY = "subsub-mode";
33
34
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -70,6 +71,10 @@ export function starbuckEnv(cfg, processEnv = process.env) {
70
71
  }
71
72
  export async function createSubsub(pi, deps = {}) {
72
73
  const cfg = deps.config ?? loadConfig();
74
+ // Notices, dialogs and previews in the language of the interface (English or European Portuguese).
75
+ const lang = uiLanguage(cfg.language);
76
+ const L = (en, pt) => (lang === "pt" ? pt : en);
77
+ const trouble = lang === "pt" ? TROUBLE_TEXT_PT : TROUBLE_TEXT;
73
78
  let bridge = deps.bridge;
74
79
  const toolNames = new Set();
75
80
  /** Original input schemas, to turn "[...]" strings back into lists. */
@@ -123,6 +128,11 @@ export async function createSubsub(pi, deps = {}) {
123
128
  const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
124
129
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
125
130
  const webView = process.env.SUBSUB_WEB === "1";
131
+ let lastError;
132
+ let resumes = 0;
133
+ let resumeTimer;
134
+ let turnStart = 0;
135
+ let turnTools = 0;
126
136
  let webCtx;
127
137
  function emitWeb() {
128
138
  const ctx = webCtx;
@@ -265,6 +275,7 @@ export async function createSubsub(pi, deps = {}) {
265
275
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
266
276
  applyTheme(ctx);
267
277
  await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
278
+ logUsage(cfg.usageLog, { ev: "session", mode, profile, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
268
279
  if (lookOn(ctx)) {
269
280
  setHeader(ctx);
270
281
  void refreshLibrary();
@@ -273,12 +284,14 @@ export async function createSubsub(pi, deps = {}) {
273
284
  void refreshLibrary();
274
285
  }
275
286
  if (!ctx.model && !webView)
276
- 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");
287
+ ctx.ui.notify(L("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "Sub-Sub: ainda não há um modelo ligado. Escreva /login e selecione um fornecedor (ou use o botão do modelo em subsub web)."), "warning");
277
288
  for (const [name, err] of Object.entries(bridge.errors)) {
278
289
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
279
290
  }
280
291
  });
281
292
  pi.on("session_shutdown", async () => {
293
+ if (resumeTimer)
294
+ clearTimeout(resumeTimer);
282
295
  if (deps.bridge)
283
296
  return;
284
297
  const b = bridge;
@@ -302,18 +315,49 @@ export async function createSubsub(pi, deps = {}) {
302
315
  // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
303
316
  pi.on("message_end", async (event, ctx) => {
304
317
  const m = event.message;
305
- if (webView || m.role !== "assistant" || m.stopReason !== "error")
318
+ if (m.role !== "assistant" || m.stopReason !== "error")
319
+ return;
320
+ lastError = m.errorMessage;
321
+ if (webView || perMinuteWait(m.errorMessage) !== undefined)
306
322
  return;
307
323
  const kind = providerTrouble(m.errorMessage);
308
324
  if (kind)
309
- ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
325
+ ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
326
+ });
327
+ // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
328
+ // up to three times per request of the user. Anything the user types cancels the wait.
329
+ pi.on("agent_end", async (_event, ctx) => {
330
+ if (turnStart)
331
+ logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
332
+ turnStart = 0;
333
+ const wait = perMinuteWait(lastError);
334
+ lastError = undefined;
335
+ if (wait === undefined)
336
+ return;
337
+ if (resumes >= 3) {
338
+ ctx.ui.notify(`Sub-Sub: ${trouble.quota}`, "warning");
339
+ return;
340
+ }
341
+ resumes++;
342
+ ctx.ui.notify(L(`Sub-Sub: the provider's per-minute limit was reached. Continuing in ${wait} seconds (type anything to stop waiting).`, `Sub-Sub: atingiu o limite por minuto do fornecedor. Continua dentro de ${wait} segundos (escreva qualquer coisa para deixar de esperar).`), "info");
343
+ resumeTimer = setTimeout(() => {
344
+ resumeTimer = undefined;
345
+ pi.sendMessage({ customType: "subsub-resume", content: "The model provider's per-minute limit was reached and has now passed. Continue the task where you stopped; do not repeat finished steps.", display: false }, { triggerTurn: true });
346
+ }, wait * 1000);
310
347
  });
311
348
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
312
349
  // The input event comes before pi expands a prompt template.
313
350
  pi.on("input", async (event, ctx) => {
351
+ if (resumeTimer)
352
+ clearTimeout(resumeTimer);
353
+ resumeTimer = undefined;
354
+ resumes = 0;
314
355
  const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
356
+ // The usage log keeps the kind of request and its length, never its text.
357
+ if (cmd !== "subsub-refresh")
358
+ logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
315
359
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
316
- ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
360
+ ctx.ui.notify(L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`), "warning");
317
361
  return { action: "handled" };
318
362
  }
319
363
  const why = promptBlocked(event.text, profile);
@@ -322,6 +366,10 @@ export async function createSubsub(pi, deps = {}) {
322
366
  ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
323
367
  return { action: "handled" };
324
368
  });
369
+ pi.on("agent_start", async () => {
370
+ turnStart = Date.now();
371
+ turnTools = 0;
372
+ });
325
373
  pi.on("before_agent_start", async (event, ctx) => ({
326
374
  systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
327
375
  }));
@@ -329,6 +377,7 @@ export async function createSubsub(pi, deps = {}) {
329
377
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
330
378
  const record = (data) => pi.appendEntry(BENCH_ENTRY, data);
331
379
  pi.on("tool_call", async (event, ctx) => {
380
+ turnTools++;
332
381
  const name = event.toolName;
333
382
  const input = (event.input ?? {});
334
383
  // Previews and the real call must see the same, repaired arguments.
@@ -365,7 +414,7 @@ export async function createSubsub(pi, deps = {}) {
365
414
  return undefined;
366
415
  if (!ctx.hasUI)
367
416
  return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
368
- const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, `Asking because ${verdict.why}.`);
417
+ const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
369
418
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
370
419
  }
371
420
  if (!ctx.hasUI) {
@@ -378,7 +427,7 @@ export async function createSubsub(pi, deps = {}) {
378
427
  if (!verdict.ok && /protected|hidden/.test(verdict.why))
379
428
  return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
380
429
  }
381
- const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
430
+ const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
382
431
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
383
432
  }
384
433
  // kind === "preview": the server computes the change first, then the user decides.
@@ -393,12 +442,13 @@ export async function createSubsub(pi, deps = {}) {
393
442
  }
394
443
  if (preview.isError)
395
444
  return { block: true, reason: preview.text };
396
- const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
397
- let shown = formatPreview(name, preview.data ?? preview.text);
445
+ const title = L(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, `Sub-Sub: aplicar ${name.replace(/^(zotero|scholar)_/, "")}?`);
446
+ let shown = formatPreview(name, preview.data ?? preview.text, lang);
398
447
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
399
448
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
400
449
  for (let round = 0; round < 3; round++) {
401
- const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
450
+ const ok = await ctx.ui.confirm(round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
451
+ logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
402
452
  if (!ok)
403
453
  return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
404
454
  let again;
@@ -410,7 +460,7 @@ export async function createSubsub(pi, deps = {}) {
410
460
  }
411
461
  if (again.isError)
412
462
  return { block: true, reason: again.text };
413
- const now = formatPreview(name, again.data ?? again.text);
463
+ const now = formatPreview(name, again.data ?? again.text, lang);
414
464
  if (now === shown)
415
465
  return undefined;
416
466
  shown = now;
@@ -456,7 +506,7 @@ export async function createSubsub(pi, deps = {}) {
456
506
  }
457
507
  if (!ctx.hasUI)
458
508
  return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
459
- const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
509
+ const ok = await ctx.ui.confirm(L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
460
510
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
461
511
  }
462
512
  async function benchGate(name, input, kind, cwd, signal) {
@@ -495,14 +545,14 @@ export async function createSubsub(pi, deps = {}) {
495
545
  description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
496
546
  handler: async (_args, ctx) => {
497
547
  await applyMode(ctx, "librarian", true);
498
- ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
548
+ ctx.ui.notify(L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher."), "info");
499
549
  },
500
550
  });
501
551
  pi.registerCommand("researcher", {
502
552
  description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
503
553
  handler: async (_args, ctx) => {
504
554
  await applyMode(ctx, "researcher", true);
505
- ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
555
+ ctx.ui.notify(L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
506
556
  },
507
557
  });
508
558
  pi.registerCommand("profile", {
@@ -604,12 +654,12 @@ export async function createSubsub(pi, deps = {}) {
604
654
  ctx.ui.notify(preview.text, "warning");
605
655
  return;
606
656
  }
607
- const ok = await ctx.ui.confirm("Sub-Sub: undo?", paintFor(ctx)(formatPreview("zotero_undo", preview.data)));
657
+ const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
608
658
  if (!ok)
609
659
  return;
610
660
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
611
661
  const d = (res.data ?? {});
612
- ctx.ui.notify(res.isError ? res.text : `Undone: ${d.applied} item(s).`, res.isError ? "error" : "info");
662
+ ctx.ui.notify(res.isError ? res.text : L(`Undone: ${d.applied} item(s).`, `Anulado: ${d.applied} item(ns).`), res.isError ? "error" : "info");
613
663
  },
614
664
  });
615
665
  }
package/dist/usage.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The pilot's usage log: off unless the user turns it on (subsub init, or "usageLog": true).
3
+ * One JSON line per event in ~/.subsub/usage-log.jsonl: times, counts, commands and tool names.
4
+ * Never what the user wrote, the replies, titles or file names. Nothing is sent anywhere; the
5
+ * user reads it with `subsub usage` and sends the file to the pilot team if they want to.
6
+ */
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ export function usageFile(env = process.env) {
11
+ return join(env.HOME ?? homedir(), ".subsub", "usage-log.jsonl");
12
+ }
13
+ export function logUsage(on, event, env = process.env) {
14
+ if (!on)
15
+ return;
16
+ try {
17
+ const file = usageFile(env);
18
+ mkdirSync(dirname(file), { recursive: true });
19
+ appendFileSync(file, `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n`, { mode: 0o600 });
20
+ }
21
+ catch {
22
+ /* a log must never stop the work */
23
+ }
24
+ }
25
+ /** A plain summary of the log: days, sessions, requests, commands, time, library changes. */
26
+ export function summarizeUsage(text) {
27
+ const rows = text
28
+ .split("\n")
29
+ .filter(Boolean)
30
+ .flatMap((l) => {
31
+ try {
32
+ return [JSON.parse(l)];
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ });
38
+ if (!rows.length)
39
+ return "The usage log is empty.";
40
+ const days = new Set(rows.map((r) => r.t.slice(0, 10)));
41
+ const count = (ev) => rows.filter((r) => r.ev === ev);
42
+ const asks = count("ask");
43
+ const turns = count("turn");
44
+ const changes = count("change");
45
+ const kinds = new Map();
46
+ for (const a of asks)
47
+ kinds.set(a.kind, (kinds.get(a.kind) ?? 0) + 1);
48
+ const minutes = Math.round(turns.reduce((s, r) => s + r.seconds, 0) / 60);
49
+ const errors = turns.filter((r) => r.error).length;
50
+ const lines = [
51
+ `From ${rows[0].t.slice(0, 10)} to ${rows.at(-1).t.slice(0, 10)}: Sub-Sub used on ${days.size} day(s), in ${count("session").length} session(s).`,
52
+ `Requests: ${asks.length} (${[...kinds].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(", ")}).`,
53
+ `Time Sub-Sub spent working: ${minutes} minute(s), with ${turns.reduce((s, r) => s + r.tools, 0)} tool call(s) and ${errors} error(s).`,
54
+ `Library changes: ${changes.filter((c) => c.approved).length} approved, ${changes.filter((c) => !c.approved).length} declined.`,
55
+ ];
56
+ return lines.join("\n");
57
+ }
58
+ export function usageMain(env = process.env) {
59
+ const file = usageFile(env);
60
+ if (!existsSync(file)) {
61
+ console.log(`No usage log yet (${file}). To keep one for the pilot, type subsub init and turn it on, or set "usageLog": true in the settings.`);
62
+ return 0;
63
+ }
64
+ console.log(summarizeUsage(readFileSync(file, "utf8")));
65
+ console.log("");
66
+ console.log(`The log: ${file}`);
67
+ console.log("It holds times, counts, commands and tool names, never what you wrote or what Sub-Sub replied. To share it with the pilot team, send this file.");
68
+ return 0;
69
+ }
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 { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, withStarbuck } from "./config.js";
24
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
25
25
  import { TESTED_MODELS } from "./init.js";
26
26
  import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
27
27
  import { obsidianRoot } from "./layout.js";
@@ -47,10 +47,7 @@ export function parseWebArgs(args) {
47
47
  }
48
48
  return o;
49
49
  }
50
- /** "pt" when the language setting is Portuguese, else "en". */
51
- export function uiLanguage(language) {
52
- return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
53
- }
50
+ export { uiLanguage } from "./config.js";
54
51
  // ---------------------------------------------------------------- providers (API keys)
55
52
  export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
56
53
  /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.4",
3
+ "version": "0.12.1",
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",
@@ -29,13 +29,13 @@
29
29
  "@modelcontextprotocol/sdk": "^1.30.1"
30
30
  },
31
31
  "peerDependencies": {
32
- "@earendil-works/pi-coding-agent": "^1.0.4"
32
+ "@earendil-works/pi-coding-agent": "~1.0.4"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=22.19.0"
36
36
  },
37
37
  "scripts": {
38
- "test": "node --test test/unit.test.ts",
38
+ "test": "node --test test/unit.test.ts test/paths.property.test.ts",
39
39
  "test:e2e": "node --test test/e2e.test.ts",
40
40
  "build": "node scripts/build.mjs",
41
41
  "prepack": "npm run build"
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "files": [
48
48
  "README.md",
49
+ "CHANGELOG.md",
49
50
  "assets",
50
51
  "bin",
51
52
  "dist",
@@ -1,6 +1,6 @@
1
1
  You are Sub-Sub, the librarian for {{user}}'s Zotero library. {{about}} Reply briefly. No emojis.
2
2
 
3
- You change the library through the zotero_ tools. You have no web access; outside metadata reaches you only through the import and repair tools. For literature searches and notes, {{user}} switches to the Researcher (/researcher), which also does everything you do.
3
+ You change the library through the zotero_ tools. Outside metadata reaches you through the import and repair tools, zotero_find_reference and, when {{user}} has set it up, zotero_web_search. You do not search the literature. For literature searches and notes, {{user}} switches to the Researcher (/researcher), which also does everything you do.
4
4
 
5
5
  ## How changes work
6
6
 
@@ -8,6 +8,18 @@ You change the library through the zotero_ tools. You have no web access; outsid
8
8
  - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
9
9
  - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
10
10
  - Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
11
+ - You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
12
+
13
+ ## Files and notes without a parent item
14
+
15
+ PDFs, scans and notes that sit alone in the library (no parent item) are not found by zotero_find_items. To give them a proper reference:
16
+
17
+ 1. zotero_standalone_items lists them (kind="attachment" for files only).
18
+ 2. For each file, read it with zotero_get_fulltext(key): the first pages usually give title, authors, date, publication, issue and publisher. If there is no indexed text, say that {{user}} can right-click the file in Zotero and choose Reindex Item; a scan without a text layer cannot be read. A clear file name ("Weird_Tales_-_v31n02_[1938-02].pdf") can still identify the work: propose the item and say that it rests on the file name only.
19
+ 3. If the text gives a DOI or ISBN, use it. Otherwise look the work up with zotero_find_reference (Crossref, Google Books, Internet Archive, Open Library, Wikidata; good for magazines, books and reports) and, if it is available, zotero_web_search. Put only the title, the publication and the date in a query, never other text from the file.
20
+ 4. Accept a candidate only when it agrees with the file's own text (title, date, issue). Search results are often near misses. When nothing agrees, build the item from the file's text alone, and say which fields you could not confirm.
21
+ 5. Call zotero_set_parent_items with up to 25 files: identifier= for a DOI, PMID or ISBN; item_type + fields + creators for anything else. A magazine article is magazineArticle (publicationTitle, date, issue, pages); a whole issue is a magazineArticle with the issue title, or a document; a newspaper article is newspaperArticle; a book is book; a report is report. Use parent_key when the work is already in the library. A person is a creator with lastName and firstName; name is only for an organisation.
22
+ 6. The server uses an item that is already in the library instead of making a copy. Report what was new and what was existing, and the journal_id.
11
23
 
12
24
  ## How to tag
13
25
 
@@ -22,6 +22,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
22
22
  - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
23
23
  - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
24
24
  - Start work on the library with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
25
+ - You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
25
26
  - Older shared rules may say that the researcher cannot change the library and hands works over to the librarian. That was before version 0.10: you do both.
26
27
 
27
28
  ## Questions about the library
@@ -44,6 +45,17 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
44
45
  - zotero_check_retractions: call again while still_unchecked > 0. Propose the tag _retracted for retracted items.
45
46
  - zotero_missing_pdfs, then zotero_attach_oa_pdfs in batches of 25. Say when a file is an accepted manuscript.
46
47
 
48
+ ## Files and notes without a parent item
49
+
50
+ PDFs, scans and notes that sit alone in the library (no parent item) are not found by zotero_find_items. To give them a proper reference:
51
+
52
+ 1. zotero_standalone_items lists them (kind="attachment" for files only).
53
+ 2. For each file, read it with zotero_get_fulltext(key): the first pages usually give title, authors, date, publication, issue and publisher. If there is no indexed text, say that {{user}} can right-click the file in Zotero and choose Reindex Item; a scan without a text layer cannot be read. A clear file name ("Weird_Tales_-_v31n02_[1938-02].pdf") can still identify the work: propose the item and say that it rests on the file name only.
54
+ 3. If the text gives a DOI or ISBN, use it. Otherwise look the work up with zotero_find_reference (Crossref, Google Books, Internet Archive, Open Library, Wikidata; good for magazines, books and reports) and, if it is available, zotero_web_search. Put only the title, the publication and the date in a query, never other text from the file.
55
+ 4. Accept a candidate only when it agrees with the file's own text (title, date, issue). Search results are often near misses. When nothing agrees, build the item from the file's text alone, and say which fields you could not confirm.
56
+ 5. Call zotero_set_parent_items with up to 25 files: identifier= for a DOI, PMID or ISBN; item_type + fields + creators for anything else. A magazine article is magazineArticle (publicationTitle, date, issue, pages); a whole issue is a magazineArticle with the issue title, or a document; a newspaper article is newspaperArticle; a book is book; a report is report. Use parent_key when the work is already in the library. A person is a creator with lastName and firstName; name is only for an organisation.
57
+ 6. The server uses an item that is already in the library instead of making a copy. Report what was new and what was existing, and the journal_id.
58
+
47
59
  ## How to tag
48
60
 
49
61
  - A tag must be supported by the item itself: title and abstract, or the full text. If you are unsure, leave the tag out. Fewer correct tags are better than many loose ones.
package/src/bench.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { spawn, spawnSync } from "node:child_process";
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { expand, loadConfig, type Mode, starbuckCommand, starbuckEnabled } from "./config.ts";
@@ -463,7 +463,8 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
463
463
  dois: dois.length,
464
464
  pmids: pmids.length,
465
465
  ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
466
- screening_log: existsSync(plans) && readdirSync(plans).some((f) => /screening/i.test(f)),
466
+ // A model may write .plans as a file instead of a folder: then there is no screening log.
467
+ screening_log: existsSync(plans) && statSync(plans).isDirectory() && readdirSync(plans).some((f) => /screening/i.test(f)),
467
468
  multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
468
469
  fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
469
470
  starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
package/src/cli.ts CHANGED
@@ -142,6 +142,7 @@ Usage:
142
142
  subsub web Open Sub-Sub in the browser
143
143
  subsub init Set up or change the settings (folder, tag list, profile, models)
144
144
  subsub doctor Check the set-up; each line marked FIX says what to do
145
+ subsub usage The pilot's usage log (if turned on): a summary and where the file is
145
146
  subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
146
147
  subsub review preview|apply <note>
147
148
  Check or apply a tag review note from Inbox/
@@ -191,6 +192,11 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
191
192
  process.exitCode = await doctorMain(env);
192
193
  return;
193
194
  }
195
+ if (args[0] === "usage") {
196
+ const { usageMain } = await import("./usage.ts");
197
+ process.exitCode = usageMain(env);
198
+ return;
199
+ }
194
200
  if (args[0] === "review") {
195
201
  const r = reviewCommand(args.slice(1), loadConfig(env), env);
196
202
  const res = spawnSync(r.command, r.args, { env: r.env, stdio: "inherit" });
package/src/config.ts CHANGED
@@ -40,22 +40,32 @@ export const FREE_MODELS: Record<Mode, string> = {
40
40
  librarian: "google/gemini-3.1-flash-lite",
41
41
  researcher: "google/gemini-3.1-flash-lite",
42
42
  };
43
+ /** The tested EU-hosted models (Mistral, servers in the EU; see the Models page). */
44
+ export const EU_MODELS: Record<Mode, string> = {
45
+ librarian: "mistral/mistral-medium-3.5",
46
+ researcher: "mistral/mistral-medium-3.5",
47
+ };
43
48
  /**
44
49
  * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
45
50
  * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
46
- * free (tested, works on a free tier).
51
+ * free (tested, works on a free tier), eu (tested, servers in the EU).
47
52
  */
48
- export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" }> = [
53
+ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" | "eu" }> = [
49
54
  { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
50
55
  { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
51
56
  { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
57
+ { spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
52
58
  { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
53
59
  ];
60
+ /** "pt" when the language setting is Portuguese, else "en": the language of the interface. */
61
+ export function uiLanguage(language: string | undefined): "pt" | "en" {
62
+ return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
63
+ }
54
64
  export type Profile = "reader" | "scholar" | "author" | "editor";
55
65
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
56
66
 
57
67
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
58
- export const SERVER_VERSION = "0.5.3";
68
+ export const SERVER_VERSION = "0.5.5";
59
69
 
60
70
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
61
71
  export const STARBUCK_VERSION = "0.2.0";
@@ -98,6 +108,8 @@ export interface SubsubConfig {
98
108
  addons?: string[];
99
109
  /** Local Starbuck folder (used when it has a pyproject.toml). */
100
110
  starbuckDir?: string;
111
+ /** The pilot's usage log, kept on this computer only (see usage.ts). Off by default. */
112
+ usageLog?: boolean;
101
113
  }
102
114
 
103
115
  export function expand(p: string, home?: string): string {
@@ -209,6 +221,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
209
221
  themes,
210
222
  addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
211
223
  starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
224
+ usageLog: user.usageLog === true,
212
225
  };
213
226
  }
214
227
 
package/src/init.ts CHANGED
@@ -19,10 +19,10 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
23
23
  */
24
24
 
25
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
28
28
  import { createInterface } from "node:readline/promises";
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
32
  DEFAULT_MODELS,
33
+ EU_MODELS,
33
34
  FREE_MODELS,
34
35
  expand,
35
36
  isProfile,
@@ -187,8 +188,22 @@ export async function runInit(
187
188
  );
188
189
  const fmup = setup === "fmup";
189
190
 
191
+ // Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
192
+ let student = fmup;
193
+ if (!flags.profile && !existing.profile && !fmup) {
194
+ explain("Who you are", "Students start with the Reader profile: Sub-Sub helps you read (notes with quotations, page numbers and questions) and does not write summaries or syntheses unless you ask. You can change the profile at any time.");
195
+ student = (await io.choose(
196
+ "You are",
197
+ [
198
+ { value: "student", label: "A student" },
199
+ { value: "researcher", label: "A researcher, teacher or clinician" },
200
+ ],
201
+ flags.student === "no" ? "researcher" : "student",
202
+ )) === "student";
203
+ }
204
+
190
205
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
191
- const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
206
+ const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
192
207
  const profile = await io.choose(
193
208
  "Profile",
194
209
  PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
@@ -220,6 +235,13 @@ export async function runInit(
220
235
  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.");
221
236
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
222
237
 
238
+ explain(
239
+ "Web search",
240
+ "Optional. To identify files without a parent item, such as magazines and reports, Sub-Sub can search the web through Brave Search. Brave needs a key: https://brave.com/search/api/ (free tier; a card is asked at sign-up). Searches then go to Brave, in the United States. Without a key Sub-Sub uses Crossref, Google Books, Internet Archive, Open Library and Wikidata.",
241
+ );
242
+ const hadBrave = Boolean(envValues.BRAVE_API_KEY);
243
+ const brave = (await io.ask(hadBrave ? "Brave Search key (Enter keeps the saved key)" : "Brave Search key (Enter for none)", flags.brave ?? "")).trim();
244
+
223
245
  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.");
224
246
  const starbuck = await io.choose(
225
247
  "Reference checks",
@@ -230,6 +252,19 @@ export async function runInit(
230
252
  flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
231
253
  );
232
254
 
255
+ explain(
256
+ "Usage log for the pilot",
257
+ "If you take part in the Sub-Sub pilot, Sub-Sub can keep a log on this computer: when you used it, which commands, how long it worked and how many library changes you approved. Never what you wrote or what it replied. Nothing is sent; subsub usage shows the log, and you decide whether to send it.",
258
+ );
259
+ const usage = await io.choose(
260
+ "Usage log",
261
+ [
262
+ { value: "off", label: "Off" },
263
+ { value: "on", label: "On (for the pilot)" },
264
+ ],
265
+ flags.usage ?? (existing.usageLog === true ? "on" : "off"),
266
+ );
267
+
233
268
  explain(
234
269
  "Models",
235
270
  "Sub-Sub needs an account with a model provider. Each mode can use its own model: the Librarian a fast, cheap one for tags and imports, the Researcher a stronger one for reading. You can connect a provider later in Sub-Sub with the model button (or /login in the terminal).",
@@ -240,6 +275,7 @@ export async function runInit(
240
275
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
241
276
  { value: "later", label: "Choose later in Sub-Sub" },
242
277
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
278
+ { value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
243
279
  { value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
244
280
  ];
245
281
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
@@ -279,7 +315,11 @@ export async function runInit(
279
315
  const alerts = !existsSync(alertsTarget) && existsSync(legacyAlerts) ? legacyAlerts : alertsTarget;
280
316
  mkdirSync(dirname(envFile), { recursive: true });
281
317
  const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
282
- writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined }));
318
+ writeFileSync(
319
+ envFile,
320
+ upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined, BRAVE_API_KEY: brave || undefined }),
321
+ );
322
+ if (brave || hadBrave) chmodSync(envFile, 0o600); // the file holds a key
283
323
 
284
324
  const patch: Record<string, unknown> = {
285
325
  profile,
@@ -291,6 +331,7 @@ export async function runInit(
291
331
  envFile,
292
332
  libraryChanges: undefined,
293
333
  addons: withStarbuck(current.addons, starbuck === "on"),
334
+ usageLog: usage === "on" ? true : undefined,
294
335
  };
295
336
  // A model per mode; "model" (0.10, one for both) is replaced.
296
337
  patch.model = undefined;
@@ -298,6 +339,7 @@ export async function runInit(
298
339
  if (models === "later") patch.models = {};
299
340
  if (models === "opencode-go") patch.models = TESTED_MODELS;
300
341
  if (models === "google-free") patch.models = FREE_MODELS;
342
+ if (models === "mistral-eu") patch.models = EU_MODELS;
301
343
  saveConfig(patch, initEnv);
302
344
  let keyNote = "";
303
345
  if (googleKey) {
@@ -307,6 +349,8 @@ export async function runInit(
307
349
  } catch (err) {
308
350
  keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
309
351
  }
352
+ } else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
353
+ keyNote = `No Mistral key yet. Get one at ${KEY_PROVIDERS.find((p) => p.id === "mistral")?.url}, then add it in Sub-Sub with the model button (subsub web) or /login (in the terminal).`;
310
354
  } else if (models === "google-free" && !hasProvider(agentDir, "google")) {
311
355
  keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
312
356
  }