@tiagojct/subsub 0.11.3 → 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,14 +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 { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
28
29
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
29
30
  import { systemAddition } from "./prompt.js";
31
+ import { logUsage } from "./usage.js";
30
32
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
31
33
  const MODE_ENTRY = "subsub-mode";
32
34
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -69,6 +71,10 @@ export function starbuckEnv(cfg, processEnv = process.env) {
69
71
  }
70
72
  export async function createSubsub(pi, deps = {}) {
71
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;
72
78
  let bridge = deps.bridge;
73
79
  const toolNames = new Set();
74
80
  /** Original input schemas, to turn "[...]" strings back into lists. */
@@ -122,6 +128,11 @@ export async function createSubsub(pi, deps = {}) {
122
128
  const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
123
129
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
124
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;
125
136
  let webCtx;
126
137
  function emitWeb() {
127
138
  const ctx = webCtx;
@@ -264,6 +275,7 @@ export async function createSubsub(pi, deps = {}) {
264
275
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
265
276
  applyTheme(ctx);
266
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 });
267
279
  if (lookOn(ctx)) {
268
280
  setHeader(ctx);
269
281
  void refreshLibrary();
@@ -272,12 +284,14 @@ export async function createSubsub(pi, deps = {}) {
272
284
  void refreshLibrary();
273
285
  }
274
286
  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");
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");
276
288
  for (const [name, err] of Object.entries(bridge.errors)) {
277
289
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
278
290
  }
279
291
  });
280
292
  pi.on("session_shutdown", async () => {
293
+ if (resumeTimer)
294
+ clearTimeout(resumeTimer);
281
295
  if (deps.bridge)
282
296
  return;
283
297
  const b = bridge;
@@ -298,12 +312,52 @@ export async function createSubsub(pi, deps = {}) {
298
312
  await applyMode(ctx, m, false, false);
299
313
  });
300
314
  pi.on("model_select", async (_event, ctx) => status(ctx));
315
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
316
+ pi.on("message_end", async (event, ctx) => {
317
+ const m = event.message;
318
+ if (m.role !== "assistant" || m.stopReason !== "error")
319
+ return;
320
+ lastError = m.errorMessage;
321
+ if (webView || perMinuteWait(m.errorMessage) !== undefined)
322
+ return;
323
+ const kind = providerTrouble(m.errorMessage);
324
+ if (kind)
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);
347
+ });
301
348
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
302
349
  // The input event comes before pi expands a prompt template.
303
350
  pi.on("input", async (event, ctx) => {
351
+ if (resumeTimer)
352
+ clearTimeout(resumeTimer);
353
+ resumeTimer = undefined;
354
+ resumes = 0;
304
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 });
305
359
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
306
- 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");
307
361
  return { action: "handled" };
308
362
  }
309
363
  const why = promptBlocked(event.text, profile);
@@ -312,6 +366,10 @@ export async function createSubsub(pi, deps = {}) {
312
366
  ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
313
367
  return { action: "handled" };
314
368
  });
369
+ pi.on("agent_start", async () => {
370
+ turnStart = Date.now();
371
+ turnTools = 0;
372
+ });
315
373
  pi.on("before_agent_start", async (event, ctx) => ({
316
374
  systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
317
375
  }));
@@ -319,6 +377,7 @@ export async function createSubsub(pi, deps = {}) {
319
377
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
320
378
  const record = (data) => pi.appendEntry(BENCH_ENTRY, data);
321
379
  pi.on("tool_call", async (event, ctx) => {
380
+ turnTools++;
322
381
  const name = event.toolName;
323
382
  const input = (event.input ?? {});
324
383
  // Previews and the real call must see the same, repaired arguments.
@@ -355,7 +414,7 @@ export async function createSubsub(pi, deps = {}) {
355
414
  return undefined;
356
415
  if (!ctx.hasUI)
357
416
  return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
358
- 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}.`));
359
418
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
360
419
  }
361
420
  if (!ctx.hasUI) {
@@ -368,7 +427,7 @@ export async function createSubsub(pi, deps = {}) {
368
427
  if (!verdict.ok && /protected|hidden/.test(verdict.why))
369
428
  return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
370
429
  }
371
- 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));
372
431
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
373
432
  }
374
433
  // kind === "preview": the server computes the change first, then the user decides.
@@ -383,12 +442,13 @@ export async function createSubsub(pi, deps = {}) {
383
442
  }
384
443
  if (preview.isError)
385
444
  return { block: true, reason: preview.text };
386
- const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
387
- 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);
388
447
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
389
448
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
390
449
  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));
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 });
392
452
  if (!ok)
393
453
  return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
394
454
  let again;
@@ -400,7 +460,7 @@ export async function createSubsub(pi, deps = {}) {
400
460
  }
401
461
  if (again.isError)
402
462
  return { block: true, reason: again.text };
403
- const now = formatPreview(name, again.data ?? again.text);
463
+ const now = formatPreview(name, again.data ?? again.text, lang);
404
464
  if (now === shown)
405
465
  return undefined;
406
466
  shown = now;
@@ -446,7 +506,7 @@ export async function createSubsub(pi, deps = {}) {
446
506
  }
447
507
  if (!ctx.hasUI)
448
508
  return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
449
- 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}.`));
450
510
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
451
511
  }
452
512
  async function benchGate(name, input, kind, cwd, signal) {
@@ -485,14 +545,14 @@ export async function createSubsub(pi, deps = {}) {
485
545
  description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
486
546
  handler: async (_args, ctx) => {
487
547
  await applyMode(ctx, "librarian", true);
488
- 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");
489
549
  },
490
550
  });
491
551
  pi.registerCommand("researcher", {
492
552
  description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
493
553
  handler: async (_args, ctx) => {
494
554
  await applyMode(ctx, "researcher", true);
495
- 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");
496
556
  },
497
557
  });
498
558
  pi.registerCommand("profile", {
@@ -594,12 +654,12 @@ export async function createSubsub(pi, deps = {}) {
594
654
  ctx.ui.notify(preview.text, "warning");
595
655
  return;
596
656
  }
597
- 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)));
598
658
  if (!ok)
599
659
  return;
600
660
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
601
661
  const d = (res.data ?? {});
602
- 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");
603
663
  },
604
664
  });
605
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,8 +21,9 @@ 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, 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
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
26
27
  import { obsidianRoot } from "./layout.js";
27
28
  import { realish, within } from "./paths.js";
28
29
  export function parseWebArgs(args) {
@@ -46,42 +47,9 @@ export function parseWebArgs(args) {
46
47
  }
47
48
  return o;
48
49
  }
49
- /** "pt" when the language setting is Portuguese, else "en". */
50
- export function uiLanguage(language) {
51
- return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
52
- }
50
+ export { uiLanguage } from "./config.js";
53
51
  // ---------------------------------------------------------------- providers (API keys)
54
- export const KEY_PROVIDERS = [
55
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
56
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
57
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
58
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
59
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
60
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
61
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
62
- ];
63
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
64
- export function saveApiKey(agentDir, provider, key) {
65
- if (!KEY_PROVIDERS.some((p) => p.id === provider))
66
- throw new Error(`Unknown provider: ${provider}`);
67
- const clean = key.trim();
68
- if (!clean || /\s/.test(clean) || clean.length > 400)
69
- throw new Error("That does not look like an API key.");
70
- mkdirSync(agentDir, { recursive: true });
71
- const file = join(agentDir, "auth.json");
72
- let data = {};
73
- if (existsSync(file)) {
74
- try {
75
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
76
- }
77
- catch {
78
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
79
- }
80
- }
81
- data[provider] = { type: "api_key", key: clean };
82
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
83
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
84
- }
52
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
85
53
  /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
86
54
  export class Agent {
87
55
  proc;
@@ -582,7 +550,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
582
550
  }
583
551
  case "GET /api/models": {
584
552
  const data = (await agent.request({ type: "get_available_models" }));
585
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
553
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
586
554
  }
587
555
  case "POST /api/model": {
588
556
  if (busy || switching)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.3",
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";
@@ -134,7 +134,10 @@ export function runOne(opts: {
134
134
  provider?: string;
135
135
  }): Promise<RunSummary> {
136
136
  mkdirSync(opts.out, { recursive: true });
137
- const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
137
+ // "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
138
+ const slash = opts.model.indexOf("/");
139
+ const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
140
+ const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
138
141
  if (opts.role === "librarian") args.push("--librarian");
139
142
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
140
143
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
@@ -460,7 +463,8 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
460
463
  dois: dois.length,
461
464
  pmids: pmids.length,
462
465
  ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
463
- 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)),
464
468
  multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
465
469
  fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
466
470
  starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
@@ -608,7 +612,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
608
612
  const prompt = promptFor(j.task, t, j.code, out);
609
613
  if (!prompt) return;
610
614
  const cf = join(cfgDir, `${j.code}.json`);
611
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
615
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
616
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
617
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
612
618
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
613
619
  n++;
614
620
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
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
@@ -35,11 +35,37 @@ export const DEFAULT_MODELS: Record<Mode, string> = {
35
35
  librarian: "opencode-go/mimo-v2.6-flash",
36
36
  researcher: "opencode-go/mimo-v2.6-pro",
37
37
  };
38
+ /** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
39
+ export const FREE_MODELS: Record<Mode, string> = {
40
+ librarian: "google/gemini-3.1-flash-lite",
41
+ researcher: "google/gemini-3.1-flash-lite",
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
+ };
48
+ /**
49
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
50
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
51
+ * free (tested, works on a free tier), eu (tested, servers in the EU).
52
+ */
53
+ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" | "eu" }> = [
54
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
55
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
56
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
57
+ { spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
58
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
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
+ }
38
64
  export type Profile = "reader" | "scholar" | "author" | "editor";
39
65
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
40
66
 
41
67
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
42
- export const SERVER_VERSION = "0.5.3";
68
+ export const SERVER_VERSION = "0.5.4";
43
69
 
44
70
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
45
71
  export const STARBUCK_VERSION = "0.2.0";
@@ -82,6 +108,8 @@ export interface SubsubConfig {
82
108
  addons?: string[];
83
109
  /** Local Starbuck folder (used when it has a pyproject.toml). */
84
110
  starbuckDir?: string;
111
+ /** The pilot's usage log, kept on this computer only (see usage.ts). Off by default. */
112
+ usageLog?: boolean;
85
113
  }
86
114
 
87
115
  export function expand(p: string, home?: string): string {
@@ -193,6 +221,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
193
221
  themes,
194
222
  addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
195
223
  starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
224
+ usageLog: user.usageLog === true,
196
225
  };
197
226
  }
198
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.
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,8 @@ import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
32
  DEFAULT_MODELS,
33
+ EU_MODELS,
34
+ FREE_MODELS,
33
35
  expand,
34
36
  isProfile,
35
37
  loadConfig,
@@ -43,6 +45,7 @@ import {
43
45
  } from "./config.ts";
44
46
  import { defaultRunner, starbuckCheck } from "./doctor.ts";
45
47
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
48
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
46
49
  import { PROFILE_SPECS } from "./profiles.ts";
47
50
 
48
51
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
@@ -185,8 +188,22 @@ export async function runInit(
185
188
  );
186
189
  const fmup = setup === "fmup";
187
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
+
188
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.");
189
- const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
206
+ const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
190
207
  const profile = await io.choose(
191
208
  "Profile",
192
209
  PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
@@ -228,6 +245,19 @@ export async function runInit(
228
245
  flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
229
246
  );
230
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
+
231
261
  explain(
232
262
  "Models",
233
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).",
@@ -238,10 +268,21 @@ export async function runInit(
238
268
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
239
269
  { value: "later", label: "Choose later in Sub-Sub" },
240
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` },
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)` },
241
273
  ];
242
274
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
243
275
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
244
276
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
277
+ let googleKey = "";
278
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
279
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
280
+ explain(
281
+ "Free Google key",
282
+ `Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`,
283
+ );
284
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
285
+ }
245
286
 
246
287
  // ---- write
247
288
  if (move === "yes") applyMoves(moves);
@@ -279,13 +320,29 @@ export async function runInit(
279
320
  envFile,
280
321
  libraryChanges: undefined,
281
322
  addons: withStarbuck(current.addons, starbuck === "on"),
323
+ usageLog: usage === "on" ? true : undefined,
282
324
  };
283
325
  // A model per mode; "model" (0.10, one for both) is replaced.
284
326
  patch.model = undefined;
285
327
  if (models === "keep") patch.models = current.models;
286
328
  if (models === "later") patch.models = {};
287
329
  if (models === "opencode-go") patch.models = TESTED_MODELS;
330
+ if (models === "google-free") patch.models = FREE_MODELS;
331
+ if (models === "mistral-eu") patch.models = EU_MODELS;
288
332
  saveConfig(patch, initEnv);
333
+ let keyNote = "";
334
+ if (googleKey) {
335
+ try {
336
+ saveApiKey(agentDir, "google", googleKey);
337
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
338
+ } catch (err) {
339
+ keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
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).`;
343
+ } else if (models === "google-free" && !hasProvider(agentDir, "google")) {
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.";
345
+ }
289
346
 
290
347
  const zotero = await zoteroReachable(deps.fetch);
291
348
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
@@ -293,6 +350,7 @@ export async function runInit(
293
350
  io.say(`Saved: ${configFile}`);
294
351
  io.say(`Saved: ${envFile}`);
295
352
  for (const c of created) io.say(`Created: ${c}`);
353
+ if (keyNote) io.say(keyNote);
296
354
  if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
297
355
  for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
298
356
  io.say(