@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/src/keys.ts ADDED
@@ -0,0 +1,79 @@
1
+ /** Model providers that take an API key, and plain words for their common errors. */
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
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" },
18
+ ];
19
+
20
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
21
+ export function saveApiKey(agentDir: string, provider: string, key: string): void {
22
+ if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
23
+ const clean = key.trim();
24
+ if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
25
+ mkdirSync(agentDir, { recursive: true });
26
+ const file = join(agentDir, "auth.json");
27
+ let data: Record<string, unknown> = {};
28
+ if (existsSync(file)) {
29
+ try {
30
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
31
+ } catch {
32
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
33
+ }
34
+ }
35
+ data[provider] = { type: "api_key", key: clean };
36
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
37
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
38
+ }
39
+
40
+
41
+ export type Trouble = "quota" | "busy" | "blocked" | "key";
42
+
43
+ /** What a provider's error means for the user, or undefined when it is something else. */
44
+ export function providerTrouble(text: string | undefined): Trouble | undefined {
45
+ if (!text) return undefined;
46
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
47
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
48
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
49
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
50
+ return undefined;
51
+ }
52
+
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
+ };
60
+ export const TROUBLE_TEXT: Record<Trouble, string> = {
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).",
62
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
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).",
64
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
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
+ }
package/src/preview.ts CHANGED
@@ -5,6 +5,62 @@
5
5
 
6
6
  const MAX_LINES = 14;
7
7
 
8
+ export type Lang = "en" | "pt";
9
+
10
+ /** The words of the preview in English and European Portuguese; tags, titles and values stay as they are. */
11
+ const WORDS = {
12
+ en: {
13
+ more: (n: number) => `... and ${n} more`,
14
+ moreChars: (n: number) => `[${n} more characters not shown]`,
15
+ empty: "(empty)",
16
+ change: "(change)",
17
+ undo: "Undo",
18
+ already: "ALREADY APPLIED",
19
+ drift: (n: number) => `${n} item(s) changed after the note was written; the note overwrites them:`,
20
+ now: "now",
21
+ none: "(none)",
22
+ wouldChange: (n: number) => `${n} item(s) would change.`,
23
+ inShort: (listed: number, total: number) => (listed < total ? `In short (in the ${listed} items listed):` : "In short:"),
24
+ items: "Items:",
25
+ itemsN: (n: number) => `${n} item(s)`,
26
+ changed: "changed",
27
+ wouldImport: (n: number) => `${n} item(s) would be imported:`,
28
+ noAbstract: "(no abstract)",
29
+ wouldAttach: (n: number) => `${n} PDF(s) would be attached:`,
30
+ newCollection: "New collection",
31
+ inside: "inside",
32
+ noteOn: "Note on",
33
+ link: "Link",
34
+ skipped: "Skipped",
35
+ errors: "Errors",
36
+ },
37
+ pt: {
38
+ more: (n: number) => `... e mais ${n}`,
39
+ moreChars: (n: number) => `[mais ${n} caracteres não mostrados]`,
40
+ empty: "(vazio)",
41
+ change: "(alteração)",
42
+ undo: "Anular",
43
+ already: "JÁ APLICADO",
44
+ drift: (n: number) => `${n} item(ns) mudaram depois de a nota ser escrita; a nota substitui-os:`,
45
+ now: "agora",
46
+ none: "(nenhuma)",
47
+ wouldChange: (n: number) => `${n} item(ns) vão mudar.`,
48
+ inShort: (listed: number, total: number) => (listed < total ? `Em resumo (nos ${listed} itens listados):` : "Em resumo:"),
49
+ items: "Itens:",
50
+ itemsN: (n: number) => `${n} item(ns)`,
51
+ changed: "alterado",
52
+ wouldImport: (n: number) => `${n} item(ns) vão ser importados:`,
53
+ noAbstract: "(sem resumo)",
54
+ wouldAttach: (n: number) => `${n} PDF(s) vão ser anexados:`,
55
+ newCollection: "Nova coleção",
56
+ inside: "dentro de",
57
+ noteOn: "Nota sobre",
58
+ link: "Ligação",
59
+ skipped: "Ignorados",
60
+ errors: "Erros",
61
+ },
62
+ };
63
+
8
64
  type Obj = Record<string, unknown>;
9
65
 
10
66
  function s(v: unknown, n = 90): string {
@@ -13,19 +69,19 @@ function s(v: unknown, n = 90): string {
13
69
  }
14
70
 
15
71
  /** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
16
- function whole(v: unknown, n = 3000): string {
72
+ function whole(v: unknown, w: (typeof WORDS)[Lang], n = 3000): string {
17
73
  const t = typeof v === "string" ? v : JSON.stringify(v);
18
- return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
74
+ return t.length > n ? `${t.slice(0, n)} ${w.moreChars(t.length - n)}` : t;
19
75
  }
20
76
 
21
- function list(rows: string[], total?: number): string[] {
77
+ function list(rows: string[], w: (typeof WORDS)[Lang], total?: number): string[] {
22
78
  const out = rows.slice(0, MAX_LINES);
23
79
  const rest = (total ?? rows.length) - out.length;
24
- if (rest > 0) out.push(`... and ${rest} more`);
80
+ if (rest > 0) out.push(w.more(rest));
25
81
  return out;
26
82
  }
27
83
 
28
- function describeChange(c: Obj): string {
84
+ function describeChange(c: Obj, w: (typeof WORDS)[Lang]): string {
29
85
  const parts: string[] = [];
30
86
  if (Array.isArray(c.added) && c.added.length) parts.push(`+ ${c.added.join(", ")}`);
31
87
  if (Array.isArray(c.removed) && c.removed.length) parts.push(`- ${c.removed.join(", ")}`);
@@ -33,73 +89,92 @@ function describeChange(c: Obj): string {
33
89
  if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
34
90
  if (v && typeof v === "object" && "after" in (v as Obj)) {
35
91
  const b = (v as Obj).before;
36
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
92
+ parts.push(`${k}: ${s(b || w.empty, 40)} -> ${whole((v as Obj).after, w)}`);
37
93
  }
38
94
  }
39
- return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
95
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || w.change}`;
96
+ }
97
+
98
+ /** For a large batch: each tag and field with the number of items it changes, so the whole is readable at once. */
99
+ function tally(changes: Obj[], total: number, w: (typeof WORDS)[Lang]): string[] {
100
+ const count = new Map<string, number>();
101
+ const add = (k: string) => count.set(k, (count.get(k) ?? 0) + 1);
102
+ for (const c of changes) {
103
+ for (const tag of (c.added as string[] | undefined) ?? []) add(`+ ${tag}`);
104
+ for (const tag of (c.removed as string[] | undefined) ?? []) add(`- ${tag}`);
105
+ for (const [k, v] of Object.entries(c)) if (v && typeof v === "object" && "after" in (v as Obj)) add(`${k} ${w.changed}`);
106
+ }
107
+ if (!count.size) return [];
108
+ const rank = (k: string) => (k.startsWith("+") ? 0 : k.startsWith("-") ? 1 : 2);
109
+ const rows = [...count].sort((a, b) => rank(a[0]) - rank(b[0]) || b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => ` ${k}: ${w.itemsN(n)}`);
110
+ return [w.inShort(changes.length, total), ...list(rows, w), w.items];
40
111
  }
41
112
 
42
- export function formatPreview(tool: string, preview: unknown): string {
113
+ export function formatPreview(tool: string, preview: unknown, lang: Lang = "en"): string {
114
+ const w = WORDS[lang];
43
115
  if (!preview || typeof preview !== "object") return s(preview, 1500);
44
116
  const p = preview as Obj;
45
117
  const lines: string[] = [];
46
118
  if (p.undoing && typeof p.undoing === "object") {
47
119
  const u = p.undoing as Obj;
48
- lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
120
+ lines.push(`${w.undo} ${u.op}: ${s(u.summary, 80)}`);
49
121
  }
50
122
  if (Array.isArray(p.already_applied) && p.already_applied.length) {
51
- lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
123
+ lines.push(`${w.already}: ${p.already_applied.join(", ")}`);
52
124
  }
53
125
  const drift = p.changed_since_note as Obj | undefined;
54
126
  if (drift && typeof drift === "object" && typeof drift.count === "number") {
55
- lines.push(`${drift.count} item(s) changed after the note was written; the note overwrites them:`);
127
+ lines.push(w.drift(drift.count));
56
128
  const items = Object.entries((drift.items as Obj) ?? {}).map(
57
- ([k, v]) => ` ${k} now: ${((v as Obj).now as string[] | undefined)?.join(", ") || "(none)"}`,
129
+ ([k, v]) => ` ${k} ${w.now}: ${((v as Obj).now as string[] | undefined)?.join(", ") || w.none}`,
58
130
  );
59
131
  lines.push(...items.slice(0, 4));
60
132
  }
61
133
  if (typeof p.would_change === "number") {
62
- lines.push(`${p.would_change} item(s) would change.`);
134
+ lines.push(w.wouldChange(p.would_change));
63
135
  const changes = (p.changes as Obj[]) ?? [];
64
- lines.push(...list(changes.map(describeChange), p.would_change as number));
136
+ if (p.would_change > 10) lines.push(...tally(changes, p.would_change as number, w));
137
+ lines.push(...list(changes.map((c) => describeChange(c, w)), w, p.would_change as number));
65
138
  }
66
139
  if (Array.isArray(p.would_import)) {
67
- lines.push(`${p.would_import.length} item(s) would be imported:`);
140
+ lines.push(w.wouldImport(p.would_import.length));
68
141
  lines.push(
69
142
  ...list(
70
143
  (p.would_import as Obj[]).map(
71
- (r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : " (no abstract)"}`,
144
+ (r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : ` ${w.noAbstract}`}`,
72
145
  ),
146
+ w,
73
147
  ),
74
148
  );
75
149
  }
76
150
  if (Array.isArray(p.would_attach)) {
77
- lines.push(`${p.would_attach.length} PDF(s) would be attached:`);
151
+ lines.push(w.wouldAttach(p.would_attach.length));
78
152
  lines.push(
79
153
  ...list(
80
154
  (p.would_attach as Obj[]).map(
81
155
  (r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`,
82
156
  ),
157
+ w,
83
158
  ),
84
159
  );
85
160
  }
86
161
  if (p.would_create && typeof p.would_create === "object") {
87
- const w = p.would_create as Obj;
88
- lines.push(`New collection: ${w.name}${w.parent ? ` (inside ${w.parent})` : ""}`);
162
+ const nc = p.would_create as Obj;
163
+ lines.push(`${w.newCollection}: ${nc.name}${nc.parent ? ` (${w.inside} ${nc.parent})` : ""}`);
89
164
  }
90
165
  if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
91
- if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
92
- if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
93
- if (p.link) lines.push(`Link: ${p.link}`);
166
+ if (p.item || p.parent) lines.push(`${w.noteOn}: ${s(p.item ?? p.parent, 90)}`);
167
+ if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview, w));
168
+ if (p.link) lines.push(`${w.link}: ${p.link}`);
94
169
  }
95
170
  const skipped = p.skipped as Obj | undefined;
96
171
  if (skipped && typeof skipped === "object" && Object.keys(skipped).length) {
97
- lines.push(`Skipped: ${Object.keys(skipped).length}`);
98
- lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`)).slice(0, 5));
172
+ lines.push(`${w.skipped}: ${Object.keys(skipped).length}`);
173
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`), w).slice(0, 5));
99
174
  }
100
175
  const errors = p.errors as Obj | undefined;
101
176
  if (errors && typeof errors === "object" && Object.keys(errors).length) {
102
- lines.push(`Errors: ${Object.keys(errors).length}`);
177
+ lines.push(`${w.errors}: ${Object.keys(errors).length}`);
103
178
  lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
104
179
  }
105
180
  if (!lines.length) lines.push(s(p, 1500));
package/src/subsub.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  starbuckCommand,
35
35
  starbuckEnabled,
36
36
  type SubsubConfig,
37
+ uiLanguage,
37
38
  who,
38
39
  } from "./config.ts";
39
40
  import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
@@ -41,8 +42,10 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
41
42
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
42
43
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
43
44
  import { describeArgs, formatPreview } from "./preview.ts";
45
+ import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
44
46
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
45
47
  import { systemAddition } from "./prompt.ts";
48
+ import { logUsage } from "./usage.ts";
46
49
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
47
50
 
48
51
  const MODE_ENTRY = "subsub-mode";
@@ -95,6 +98,10 @@ export function starbuckEnv(cfg: Pick<SubsubConfig, "envFile">, processEnv: Node
95
98
 
96
99
  export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Promise<void> {
97
100
  const cfg = deps.config ?? loadConfig();
101
+ // Notices, dialogs and previews in the language of the interface (English or European Portuguese).
102
+ const lang = uiLanguage(cfg.language);
103
+ const L = (en: string, pt: string) => (lang === "pt" ? pt : en);
104
+ const trouble = lang === "pt" ? TROUBLE_TEXT_PT : TROUBLE_TEXT;
98
105
  let bridge: Bridge | undefined = deps.bridge;
99
106
  const toolNames = new Set<string>();
100
107
  /** Original input schemas, to turn "[...]" strings back into lists. */
@@ -150,6 +157,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
150
157
  const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
151
158
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
152
159
  const webView = process.env.SUBSUB_WEB === "1";
160
+ let lastError: string | undefined;
161
+ let resumes = 0;
162
+ let resumeTimer: ReturnType<typeof setTimeout> | undefined;
163
+ let turnStart = 0;
164
+ let turnTools = 0;
153
165
  let webCtx: ExtensionContext | undefined;
154
166
  function emitWeb(): void {
155
167
  const ctx = webCtx;
@@ -291,19 +303,21 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
291
303
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
292
304
  applyTheme(ctx);
293
305
  await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
306
+ logUsage(cfg.usageLog, { ev: "session", mode, profile, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
294
307
  if (lookOn(ctx)) {
295
308
  setHeader(ctx);
296
309
  void refreshLibrary();
297
310
  } else if (webView) {
298
311
  void refreshLibrary();
299
312
  }
300
- if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
313
+ if (!ctx.model && !webView) 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");
301
314
  for (const [name, err] of Object.entries(bridge.errors)) {
302
315
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
303
316
  }
304
317
  });
305
318
 
306
319
  pi.on("session_shutdown", async () => {
320
+ if (resumeTimer) clearTimeout(resumeTimer);
307
321
  if (deps.bridge) return;
308
322
  const b = bridge;
309
323
  bridge = undefined;
@@ -324,13 +338,51 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
324
338
  });
325
339
 
326
340
  pi.on("model_select", async (_event, ctx) => status(ctx));
341
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
342
+ pi.on("message_end", async (event, ctx) => {
343
+ const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
344
+ if (m.role !== "assistant" || m.stopReason !== "error") return;
345
+ lastError = m.errorMessage;
346
+ if (webView || perMinuteWait(m.errorMessage) !== undefined) return;
347
+ const kind = providerTrouble(m.errorMessage);
348
+ if (kind) ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
349
+ });
350
+
351
+ // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
352
+ // up to three times per request of the user. Anything the user types cancels the wait.
353
+ pi.on("agent_end", async (_event, ctx) => {
354
+ if (turnStart) logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
355
+ turnStart = 0;
356
+ const wait = perMinuteWait(lastError);
357
+ lastError = undefined;
358
+ if (wait === undefined) return;
359
+ if (resumes >= 3) {
360
+ ctx.ui.notify(`Sub-Sub: ${trouble.quota}`, "warning");
361
+ return;
362
+ }
363
+ resumes++;
364
+ 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");
365
+ resumeTimer = setTimeout(() => {
366
+ resumeTimer = undefined;
367
+ pi.sendMessage(
368
+ { 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 },
369
+ { triggerTurn: true },
370
+ );
371
+ }, wait * 1000);
372
+ });
373
+
327
374
 
328
375
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
329
376
  // The input event comes before pi expands a prompt template.
330
377
  pi.on("input", async (event, ctx) => {
378
+ if (resumeTimer) clearTimeout(resumeTimer);
379
+ resumeTimer = undefined;
380
+ resumes = 0;
331
381
  const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
382
+ // The usage log keeps the kind of request and its length, never its text.
383
+ if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
332
384
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
333
- ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
385
+ 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");
334
386
  return { action: "handled" };
335
387
  }
336
388
  const why = promptBlocked(event.text, profile);
@@ -339,6 +391,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
339
391
  return { action: "handled" };
340
392
  });
341
393
 
394
+ pi.on("agent_start", async () => {
395
+ turnStart = Date.now();
396
+ turnTools = 0;
397
+ });
398
+
342
399
  pi.on("before_agent_start", async (event, ctx) => ({
343
400
  systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
344
401
  }));
@@ -348,6 +405,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
348
405
  const record = (data: Record<string, unknown>) => pi.appendEntry(BENCH_ENTRY, data);
349
406
 
350
407
  pi.on("tool_call", async (event, ctx) => {
408
+ turnTools++;
351
409
  const name = event.toolName;
352
410
  const input = (event.input ?? {}) as Record<string, unknown>;
353
411
  // Previews and the real call must see the same, repaired arguments.
@@ -376,7 +434,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
376
434
  const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
377
435
  if (verdict.ok) return undefined;
378
436
  if (!ctx.hasUI) return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
379
- const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, `Asking because ${verdict.why}.`);
437
+ const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
380
438
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
381
439
  }
382
440
  if (!ctx.hasUI) {
@@ -388,7 +446,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
388
446
  const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
389
447
  if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
390
448
  }
391
- const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
449
+ const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
392
450
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
393
451
  }
394
452
  // kind === "preview": the server computes the change first, then the user decides.
@@ -401,12 +459,13 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
401
459
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
402
460
  }
403
461
  if (preview.isError) return { block: true, reason: preview.text };
404
- const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
405
- let shown = formatPreview(name, preview.data ?? preview.text);
462
+ const title = L(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, `Sub-Sub: aplicar ${name.replace(/^(zotero|scholar)_/, "")}?`);
463
+ let shown = formatPreview(name, preview.data ?? preview.text, lang);
406
464
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
407
465
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
408
466
  for (let round = 0; round < 3; round++) {
409
- const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
467
+ 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));
468
+ logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
410
469
  if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
411
470
  let again;
412
471
  try {
@@ -415,7 +474,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
415
474
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
416
475
  }
417
476
  if (again.isError) return { block: true, reason: again.text };
418
- const now = formatPreview(name, again.data ?? again.text);
477
+ const now = formatPreview(name, again.data ?? again.text, lang);
419
478
  if (now === shown) return undefined;
420
479
  shown = now;
421
480
  }
@@ -456,7 +515,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
456
515
  return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
457
516
  }
458
517
  if (!ctx.hasUI) 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}.`);
518
+ 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
519
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
461
520
  }
462
521
 
@@ -496,7 +555,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
496
555
  description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
497
556
  handler: async (_args, ctx) => {
498
557
  await applyMode(ctx, "librarian", true);
499
- ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
558
+ 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");
500
559
  },
501
560
  });
502
561
 
@@ -504,7 +563,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
504
563
  description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
505
564
  handler: async (_args, ctx) => {
506
565
  await applyMode(ctx, "researcher", true);
507
- ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
566
+ 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");
508
567
  },
509
568
  });
510
569
 
@@ -608,11 +667,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
608
667
  ctx.ui.notify(preview.text, "warning");
609
668
  return;
610
669
  }
611
- const ok = await ctx.ui.confirm("Sub-Sub: undo?", paintFor(ctx)(formatPreview("zotero_undo", preview.data)));
670
+ const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
612
671
  if (!ok) return;
613
672
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
614
673
  const d = (res.data ?? {}) as Record<string, unknown>;
615
- ctx.ui.notify(res.isError ? res.text : `Undone: ${d.applied} item(s).`, res.isError ? "error" : "info");
674
+ ctx.ui.notify(res.isError ? res.text : L(`Undone: ${d.applied} item(s).`, `Anulado: ${d.applied} item(ns).`), res.isError ? "error" : "info");
616
675
  },
617
676
  });
618
677
  }
package/src/usage.ts ADDED
@@ -0,0 +1,75 @@
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
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+
12
+ export type UsageEvent =
13
+ | { ev: "session"; mode: string; profile: string; model?: string }
14
+ | { ev: "ask"; kind: string; words: number }
15
+ | { ev: "turn"; seconds: number; tools: number; error?: string }
16
+ | { ev: "change"; tool: string; approved: boolean };
17
+
18
+ export function usageFile(env: NodeJS.ProcessEnv = process.env): string {
19
+ return join(env.HOME ?? homedir(), ".subsub", "usage-log.jsonl");
20
+ }
21
+
22
+ export function logUsage(on: boolean | undefined, event: UsageEvent, env: NodeJS.ProcessEnv = process.env): void {
23
+ if (!on) return;
24
+ try {
25
+ const file = usageFile(env);
26
+ mkdirSync(dirname(file), { recursive: true });
27
+ appendFileSync(file, `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n`, { mode: 0o600 });
28
+ } catch {
29
+ /* a log must never stop the work */
30
+ }
31
+ }
32
+
33
+ /** A plain summary of the log: days, sessions, requests, commands, time, library changes. */
34
+ export function summarizeUsage(text: string): string {
35
+ const rows = text
36
+ .split("\n")
37
+ .filter(Boolean)
38
+ .flatMap((l) => {
39
+ try {
40
+ return [JSON.parse(l) as UsageEvent & { t: string }];
41
+ } catch {
42
+ return [];
43
+ }
44
+ });
45
+ if (!rows.length) return "The usage log is empty.";
46
+ const days = new Set(rows.map((r) => r.t.slice(0, 10)));
47
+ const count = <T extends UsageEvent["ev"]>(ev: T) => rows.filter((r) => r.ev === ev) as Array<Extract<UsageEvent, { ev: T }> & { t: string }>;
48
+ const asks = count("ask");
49
+ const turns = count("turn");
50
+ const changes = count("change");
51
+ const kinds = new Map<string, number>();
52
+ for (const a of asks) kinds.set(a.kind, (kinds.get(a.kind) ?? 0) + 1);
53
+ const minutes = Math.round(turns.reduce((s, r) => s + r.seconds, 0) / 60);
54
+ const errors = turns.filter((r) => r.error).length;
55
+ const lines = [
56
+ `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).`,
57
+ `Requests: ${asks.length} (${[...kinds].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(", ")}).`,
58
+ `Time Sub-Sub spent working: ${minutes} minute(s), with ${turns.reduce((s, r) => s + r.tools, 0)} tool call(s) and ${errors} error(s).`,
59
+ `Library changes: ${changes.filter((c) => c.approved).length} approved, ${changes.filter((c) => !c.approved).length} declined.`,
60
+ ];
61
+ return lines.join("\n");
62
+ }
63
+
64
+ export function usageMain(env: NodeJS.ProcessEnv = process.env): number {
65
+ const file = usageFile(env);
66
+ if (!existsSync(file)) {
67
+ 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.`);
68
+ return 0;
69
+ }
70
+ console.log(summarizeUsage(readFileSync(file, "utf8")));
71
+ console.log("");
72
+ console.log(`The log: ${file}`);
73
+ 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.");
74
+ return 0;
75
+ }
package/src/web.ts CHANGED
@@ -22,8 +22,9 @@ import { homedir } from "node:os";
22
22
  import { extname, join, normalize, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
25
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
27
28
  import { obsidianRoot } from "./layout.ts";
28
29
  import { realish, within } from "./paths.ts";
29
30
 
@@ -55,42 +56,11 @@ export function parseWebArgs(args: string[]): WebOptions {
55
56
  return o;
56
57
  }
57
58
 
58
- /** "pt" when the language setting is Portuguese, else "en". */
59
- export function uiLanguage(language: string | undefined): "pt" | "en" {
60
- return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
61
- }
59
+ export { uiLanguage } from "./config.ts";
62
60
 
63
61
  // ---------------------------------------------------------------- providers (API keys)
64
62
 
65
- export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
66
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
67
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
68
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
69
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
70
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
71
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
72
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
73
- ];
74
-
75
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
76
- export function saveApiKey(agentDir: string, provider: string, key: string): void {
77
- if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
78
- const clean = key.trim();
79
- if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
80
- mkdirSync(agentDir, { recursive: true });
81
- const file = join(agentDir, "auth.json");
82
- let data: Record<string, unknown> = {};
83
- if (existsSync(file)) {
84
- try {
85
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
86
- } catch {
87
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
88
- }
89
- }
90
- data[provider] = { type: "api_key", key: clean };
91
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
92
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
93
- }
63
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
94
64
 
95
65
  // ---------------------------------------------------------------- pi in RPC mode
96
66
 
@@ -597,7 +567,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
597
567
  }
598
568
  case "GET /api/models": {
599
569
  const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
600
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
570
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
601
571
  }
602
572
  case "POST /api/model": {
603
573
  if (busy || switching) return send(res, 409, { error: "busy" });