@tiagojct/subsub 0.11.4 → 0.12.0

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.0",
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",
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.4";
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,7 +19,7 @@
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
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -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}` })),
@@ -230,6 +245,19 @@ export async function runInit(
230
245
  flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
231
246
  );
232
247
 
248
+ explain(
249
+ "Usage log for the pilot",
250
+ "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.",
251
+ );
252
+ const usage = await io.choose(
253
+ "Usage log",
254
+ [
255
+ { value: "off", label: "Off" },
256
+ { value: "on", label: "On (for the pilot)" },
257
+ ],
258
+ flags.usage ?? (existing.usageLog === true ? "on" : "off"),
259
+ );
260
+
233
261
  explain(
234
262
  "Models",
235
263
  "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 +268,7 @@ export async function runInit(
240
268
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
241
269
  { value: "later", label: "Choose later in Sub-Sub" },
242
270
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
271
+ { 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
272
  { 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
273
  ];
245
274
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
@@ -291,6 +320,7 @@ export async function runInit(
291
320
  envFile,
292
321
  libraryChanges: undefined,
293
322
  addons: withStarbuck(current.addons, starbuck === "on"),
323
+ usageLog: usage === "on" ? true : undefined,
294
324
  };
295
325
  // A model per mode; "model" (0.10, one for both) is replaced.
296
326
  patch.model = undefined;
@@ -298,6 +328,7 @@ export async function runInit(
298
328
  if (models === "later") patch.models = {};
299
329
  if (models === "opencode-go") patch.models = TESTED_MODELS;
300
330
  if (models === "google-free") patch.models = FREE_MODELS;
331
+ if (models === "mistral-eu") patch.models = EU_MODELS;
301
332
  saveConfig(patch, initEnv);
302
333
  let keyNote = "";
303
334
  if (googleKey) {
@@ -307,6 +338,8 @@ export async function runInit(
307
338
  } catch (err) {
308
339
  keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
309
340
  }
341
+ } else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
342
+ 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
343
  } else if (models === "google-free" && !hasProvider(agentDir, "google")) {
311
344
  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
345
  }
package/src/keys.ts CHANGED
@@ -3,14 +3,18 @@
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
 
6
- export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
7
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
8
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
9
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
10
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
11
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
12
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
13
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
6
+ /**
7
+ * `where`: where the provider processes the data, as a key into the web view's strings
8
+ * (eu, us, cn, varies). Shown next to the key form, so the choice of provider is an informed one.
9
+ */
10
+ export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string; where: "eu" | "us" | "cn" | "varies" }> = [
11
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth", where: "varies" },
12
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys", where: "varies" },
13
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys", where: "us" },
14
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys", where: "us" },
15
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey", where: "us" },
16
+ { id: "mistral", label: "Mistral (EU)", url: "https://console.mistral.ai/api-keys", where: "eu" },
17
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys", where: "cn" },
14
18
  ];
15
19
 
16
20
  /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
@@ -46,10 +50,30 @@ export function providerTrouble(text: string | undefined): Trouble | undefined {
46
50
  return undefined;
47
51
  }
48
52
 
49
- /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
53
+ /** The words for each kind in notices (the web view has its own, in web/i18n.js). */
54
+ export const TROUBLE_TEXT_PT: Record<Trouble, string> = {
55
+ quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou use outro modelo (/model).",
56
+ busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou use outro modelo (/model).",
57
+ blocked: "O fornecedor do modelo interrompeu a resposta (por exemplo, por repetir uma fonte palavra por palavra). Peça outra vez por outras palavras ou use outro modelo (/model).",
58
+ key: "O fornecedor do modelo não aceitou a chave da API. Verifique-a ou introduza uma nova com /login.",
59
+ };
50
60
  export const TROUBLE_TEXT: Record<Trouble, string> = {
51
61
  quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or use another model (/model).",
52
62
  busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
63
  blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
54
64
  key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
65
  };
66
+
67
+ /**
68
+ * Seconds to wait before a per-minute limit lets the request through again, or undefined when the
69
+ * error is not a per-minute limit (a daily limit, a spent balance, anything else). pi does not
70
+ * retry Google's per-minute 429, because its text mentions billing.
71
+ */
72
+ export function perMinuteWait(text: string | undefined): number | undefined {
73
+ if (!text || providerTrouble(text) !== "quota") return undefined;
74
+ if (/PerDay|per day|daily/i.test(text)) return undefined;
75
+ const delay = /retryDelay\\*"\s*:\s*\\*"(\d+(?:\.\d+)?)s/.exec(text);
76
+ const seconds = delay?.[1] ?? /retry in (\d+(?:\.\d+)?)s/i.exec(text)?.[1];
77
+ if (seconds) return Math.min(120, Math.ceil(Number(seconds)) + 2);
78
+ return /PerMinute|per minute/i.test(text) ? 60 : undefined;
79
+ }