@tiagojct/subsub 0.11.4 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. */
@@ -34,11 +38,12 @@ export function saveApiKey(agentDir: string, provider: string, key: string): voi
34
38
  }
35
39
 
36
40
 
37
- export type Trouble = "quota" | "busy" | "blocked" | "key";
41
+ export type Trouble = "quota" | "busy" | "blocked" | "key" | "region";
38
42
 
39
43
  /** What a provider's error means for the user, or undefined when it is something else. */
40
44
  export function providerTrouble(text: string | undefined): Trouble | undefined {
41
45
  if (!text) return undefined;
46
+ if (/requires Global regions|not available in your region|region.{0,40}not (supported|allowed)/i.test(text)) return "region";
42
47
  if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
43
48
  if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
44
49
  if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
@@ -46,10 +51,32 @@ export function providerTrouble(text: string | undefined): Trouble | undefined {
46
51
  return undefined;
47
52
  }
48
53
 
49
- /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
54
+ /** The words for each kind in notices (the web view has its own, in web/i18n.js). */
55
+ export const TROUBLE_TEXT_PT: Record<Trouble, string> = {
56
+ 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).",
57
+ busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou use outro modelo (/model).",
58
+ 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).",
59
+ key: "O fornecedor do modelo não aceitou a chave da API. Verifique-a ou introduza uma nova com /login.",
60
+ region: "O fornecedor recusa este modelo com a definição de região da sua conta. No OpenCode, permita as regiões globais (Global) nas definições de privacidade do workspace, ou escolha um modelo que a sua região permita (/model).",
61
+ };
50
62
  export const TROUBLE_TEXT: Record<Trouble, string> = {
51
63
  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
64
  busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
65
  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
66
  key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
67
+ region: "The provider refuses this model with your account's region setting. In OpenCode, allow Global regions in the workspace's Privacy settings, or choose a model your region allows (/model).",
55
68
  };
69
+
70
+ /**
71
+ * Seconds to wait before a per-minute limit lets the request through again, or undefined when the
72
+ * error is not a per-minute limit (a daily limit, a spent balance, anything else). pi does not
73
+ * retry Google's per-minute 429, because its text mentions billing.
74
+ */
75
+ export function perMinuteWait(text: string | undefined): number | undefined {
76
+ if (!text || providerTrouble(text) !== "quota") return undefined;
77
+ if (/PerDay|per day|daily/i.test(text)) return undefined;
78
+ const delay = /retryDelay\\*"\s*:\s*\\*"(\d+(?:\.\d+)?)s/.exec(text);
79
+ const seconds = delay?.[1] ?? /retry in (\d+(?:\.\d+)?)s/i.exec(text)?.[1];
80
+ if (seconds) return Math.min(120, Math.ceil(Number(seconds)) + 2);
81
+ return /PerMinute|per minute/i.test(text) ? 60 : undefined;
82
+ }
package/src/preview.ts CHANGED
@@ -5,6 +5,68 @@
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
+ wouldParent: (n: number) => `${n} file(s) or note(s) would get a parent item:`,
31
+ newItem: "new",
32
+ existingItem: "existing item",
33
+ newCollection: "New collection",
34
+ inside: "inside",
35
+ noteOn: "Note on",
36
+ link: "Link",
37
+ skipped: "Skipped",
38
+ errors: "Errors",
39
+ },
40
+ pt: {
41
+ more: (n: number) => `... e mais ${n}`,
42
+ moreChars: (n: number) => `[mais ${n} caracteres não mostrados]`,
43
+ empty: "(vazio)",
44
+ change: "(alteração)",
45
+ undo: "Anular",
46
+ already: "JÁ APLICADO",
47
+ drift: (n: number) => `${n} item(ns) mudaram depois de a nota ser escrita; a nota substitui-os:`,
48
+ now: "agora",
49
+ none: "(nenhuma)",
50
+ wouldChange: (n: number) => `${n} item(ns) vão mudar.`,
51
+ inShort: (listed: number, total: number) => (listed < total ? `Em resumo (nos ${listed} itens listados):` : "Em resumo:"),
52
+ items: "Itens:",
53
+ itemsN: (n: number) => `${n} item(ns)`,
54
+ changed: "alterado",
55
+ wouldImport: (n: number) => `${n} item(ns) vão ser importados:`,
56
+ noAbstract: "(sem resumo)",
57
+ wouldAttach: (n: number) => `${n} PDF(s) vão ser anexados:`,
58
+ wouldParent: (n: number) => `${n} ficheiro(s) ou nota(s) vão ter um item principal:`,
59
+ newItem: "novo",
60
+ existingItem: "item existente",
61
+ newCollection: "Nova coleção",
62
+ inside: "dentro de",
63
+ noteOn: "Nota sobre",
64
+ link: "Ligação",
65
+ skipped: "Ignorados",
66
+ errors: "Erros",
67
+ },
68
+ };
69
+
8
70
  type Obj = Record<string, unknown>;
9
71
 
10
72
  function s(v: unknown, n = 90): string {
@@ -13,19 +75,19 @@ function s(v: unknown, n = 90): string {
13
75
  }
14
76
 
15
77
  /** 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 {
78
+ function whole(v: unknown, w: (typeof WORDS)[Lang], n = 3000): string {
17
79
  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;
80
+ return t.length > n ? `${t.slice(0, n)} ${w.moreChars(t.length - n)}` : t;
19
81
  }
20
82
 
21
- function list(rows: string[], total?: number): string[] {
83
+ function list(rows: string[], w: (typeof WORDS)[Lang], total?: number): string[] {
22
84
  const out = rows.slice(0, MAX_LINES);
23
85
  const rest = (total ?? rows.length) - out.length;
24
- if (rest > 0) out.push(`... and ${rest} more`);
86
+ if (rest > 0) out.push(w.more(rest));
25
87
  return out;
26
88
  }
27
89
 
28
- function describeChange(c: Obj): string {
90
+ function describeChange(c: Obj, w: (typeof WORDS)[Lang]): string {
29
91
  const parts: string[] = [];
30
92
  if (Array.isArray(c.added) && c.added.length) parts.push(`+ ${c.added.join(", ")}`);
31
93
  if (Array.isArray(c.removed) && c.removed.length) parts.push(`- ${c.removed.join(", ")}`);
@@ -33,73 +95,112 @@ function describeChange(c: Obj): string {
33
95
  if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
34
96
  if (v && typeof v === "object" && "after" in (v as Obj)) {
35
97
  const b = (v as Obj).before;
36
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
98
+ parts.push(`${k}: ${s(b || w.empty, 40)} -> ${whole((v as Obj).after, w)}`);
37
99
  }
38
100
  }
39
- return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
101
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || w.change}`;
40
102
  }
41
103
 
42
- export function formatPreview(tool: string, preview: unknown): string {
104
+ /** For a large batch: each tag and field with the number of items it changes, so the whole is readable at once. */
105
+ function tally(changes: Obj[], total: number, w: (typeof WORDS)[Lang]): string[] {
106
+ const count = new Map<string, number>();
107
+ const add = (k: string) => count.set(k, (count.get(k) ?? 0) + 1);
108
+ for (const c of changes) {
109
+ for (const tag of (c.added as string[] | undefined) ?? []) add(`+ ${tag}`);
110
+ for (const tag of (c.removed as string[] | undefined) ?? []) add(`- ${tag}`);
111
+ for (const [k, v] of Object.entries(c)) if (v && typeof v === "object" && "after" in (v as Obj)) add(`${k} ${w.changed}`);
112
+ }
113
+ if (!count.size) return [];
114
+ const rank = (k: string) => (k.startsWith("+") ? 0 : k.startsWith("-") ? 1 : 2);
115
+ 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)}`);
116
+ return [w.inShort(changes.length, total), ...list(rows, w), w.items];
117
+ }
118
+
119
+ export function formatPreview(tool: string, preview: unknown, lang: Lang = "en"): string {
120
+ const w = WORDS[lang];
43
121
  if (!preview || typeof preview !== "object") return s(preview, 1500);
44
122
  const p = preview as Obj;
45
123
  const lines: string[] = [];
46
124
  if (p.undoing && typeof p.undoing === "object") {
47
125
  const u = p.undoing as Obj;
48
- lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
126
+ lines.push(`${w.undo} ${u.op}: ${s(u.summary, 80)}`);
49
127
  }
50
128
  if (Array.isArray(p.already_applied) && p.already_applied.length) {
51
- lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
129
+ lines.push(`${w.already}: ${p.already_applied.join(", ")}`);
52
130
  }
53
131
  const drift = p.changed_since_note as Obj | undefined;
54
132
  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:`);
133
+ lines.push(w.drift(drift.count));
56
134
  const items = Object.entries((drift.items as Obj) ?? {}).map(
57
- ([k, v]) => ` ${k} now: ${((v as Obj).now as string[] | undefined)?.join(", ") || "(none)"}`,
135
+ ([k, v]) => ` ${k} ${w.now}: ${((v as Obj).now as string[] | undefined)?.join(", ") || w.none}`,
58
136
  );
59
137
  lines.push(...items.slice(0, 4));
60
138
  }
61
139
  if (typeof p.would_change === "number") {
62
- lines.push(`${p.would_change} item(s) would change.`);
140
+ lines.push(w.wouldChange(p.would_change));
63
141
  const changes = (p.changes as Obj[]) ?? [];
64
- lines.push(...list(changes.map(describeChange), p.would_change as number));
142
+ if (p.would_change > 10) lines.push(...tally(changes, p.would_change as number, w));
143
+ lines.push(...list(changes.map((c) => describeChange(c, w)), w, p.would_change as number));
65
144
  }
66
145
  if (Array.isArray(p.would_import)) {
67
- lines.push(`${p.would_import.length} item(s) would be imported:`);
146
+ lines.push(w.wouldImport(p.would_import.length));
68
147
  lines.push(
69
148
  ...list(
70
149
  (p.would_import as Obj[]).map(
71
- (r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : " (no abstract)"}`,
150
+ (r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : ` ${w.noAbstract}`}`,
72
151
  ),
152
+ w,
73
153
  ),
74
154
  );
75
155
  }
76
156
  if (Array.isArray(p.would_attach)) {
77
- lines.push(`${p.would_attach.length} PDF(s) would be attached:`);
157
+ lines.push(w.wouldAttach(p.would_attach.length));
78
158
  lines.push(
79
159
  ...list(
80
160
  (p.would_attach as Obj[]).map(
81
161
  (r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`,
82
162
  ),
163
+ w,
83
164
  ),
84
165
  );
85
166
  }
167
+ if (Array.isArray(p.would_set_parent)) {
168
+ lines.push(w.wouldParent(p.would_set_parent.length));
169
+ const rows: string[] = [];
170
+ for (const r of (p.would_set_parent as Obj[]).slice(0, 6)) {
171
+ rows.push(`${s(r.file, 60)}`);
172
+ if (r.parent === "new") {
173
+ const f = (r.fields as Obj | undefined) ?? {};
174
+ const where = ["publicationTitle", "date", "issue", "volume", "pages", "publisher", "ISBN", "DOI"]
175
+ .filter((k) => f[k])
176
+ .map((k) => `${k}: ${s(f[k], 40)}`)
177
+ .join(", ");
178
+ rows.push(` -> ${w.newItem} ${r.type} ${r.citekey}: ${s(r.item, 80)}`);
179
+ if (where) rows.push(` ${where}`);
180
+ } else {
181
+ rows.push(` -> ${w.existingItem} ${r.parent_key}: ${s(r.item, 70)}`);
182
+ }
183
+ }
184
+ lines.push(...rows);
185
+ if (p.would_set_parent.length > 6) lines.push(w.more(p.would_set_parent.length - 6));
186
+ }
86
187
  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})` : ""}`);
188
+ const nc = p.would_create as Obj;
189
+ lines.push(`${w.newCollection}: ${nc.name}${nc.parent ? ` (${w.inside} ${nc.parent})` : ""}`);
89
190
  }
90
191
  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}`);
192
+ if (p.item || p.parent) lines.push(`${w.noteOn}: ${s(p.item ?? p.parent, 90)}`);
193
+ if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview, w));
194
+ if (p.link) lines.push(`${w.link}: ${p.link}`);
94
195
  }
95
196
  const skipped = p.skipped as Obj | undefined;
96
197
  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));
198
+ lines.push(`${w.skipped}: ${Object.keys(skipped).length}`);
199
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`), w).slice(0, 5));
99
200
  }
100
201
  const errors = p.errors as Obj | undefined;
101
202
  if (errors && typeof errors === "object" && Object.keys(errors).length) {
102
- lines.push(`Errors: ${Object.keys(errors).length}`);
203
+ lines.push(`${w.errors}: ${Object.keys(errors).length}`);
103
204
  lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
104
205
  }
105
206
  if (!lines.length) lines.push(s(p, 1500));
package/src/roles.ts CHANGED
@@ -14,6 +14,7 @@ export const ZOTERO_READ = [
14
14
  "status",
15
15
  "library_overview",
16
16
  "find_items",
17
+ "standalone_items",
17
18
  "get_item",
18
19
  "get_fulltext",
19
20
  "list_tags",
@@ -41,6 +42,7 @@ export const PREVIEW_WRITES = new Set([
41
42
  "import_queue",
42
43
  "repair_metadata",
43
44
  "attach_oa_pdfs",
45
+ "set_parent_items",
44
46
  ].map((n) => `zotero_${n}`),
45
47
  "scholar_attach_note",
46
48
  ]);
@@ -49,7 +51,16 @@ export const PREVIEW_WRITES = new Set([
49
51
  export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
50
52
 
51
53
  /** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
52
- export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map(
54
+ export const ZOTERO_CHECKS = [
55
+ "tag_audit",
56
+ "audit_metadata",
57
+ "find_duplicates",
58
+ "check_retractions",
59
+ "missing_pdfs",
60
+ "write_tag_review",
61
+ "find_reference",
62
+ "web_search",
63
+ ].map(
53
64
  (n) => `zotero_${n}`,
54
65
  );
55
66
 
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,9 +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";
44
- import { providerTrouble, TROUBLE_TEXT } from "./keys.ts";
45
+ import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
45
46
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
46
47
  import { systemAddition } from "./prompt.ts";
48
+ import { logUsage } from "./usage.ts";
47
49
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
48
50
 
49
51
  const MODE_ENTRY = "subsub-mode";
@@ -96,6 +98,10 @@ export function starbuckEnv(cfg: Pick<SubsubConfig, "envFile">, processEnv: Node
96
98
 
97
99
  export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Promise<void> {
98
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;
99
105
  let bridge: Bridge | undefined = deps.bridge;
100
106
  const toolNames = new Set<string>();
101
107
  /** Original input schemas, to turn "[...]" strings back into lists. */
@@ -151,6 +157,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
151
157
  const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
152
158
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
153
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;
154
165
  let webCtx: ExtensionContext | undefined;
155
166
  function emitWeb(): void {
156
167
  const ctx = webCtx;
@@ -292,19 +303,21 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
292
303
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
293
304
  applyTheme(ctx);
294
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 });
295
307
  if (lookOn(ctx)) {
296
308
  setHeader(ctx);
297
309
  void refreshLibrary();
298
310
  } else if (webView) {
299
311
  void refreshLibrary();
300
312
  }
301
- 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");
302
314
  for (const [name, err] of Object.entries(bridge.errors)) {
303
315
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
304
316
  }
305
317
  });
306
318
 
307
319
  pi.on("session_shutdown", async () => {
320
+ if (resumeTimer) clearTimeout(resumeTimer);
308
321
  if (deps.bridge) return;
309
322
  const b = bridge;
310
323
  bridge = undefined;
@@ -328,18 +341,48 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
328
341
  // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
329
342
  pi.on("message_end", async (event, ctx) => {
330
343
  const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
331
- if (webView || m.role !== "assistant" || m.stopReason !== "error") return;
344
+ if (m.role !== "assistant" || m.stopReason !== "error") return;
345
+ lastError = m.errorMessage;
346
+ if (webView || perMinuteWait(m.errorMessage) !== undefined) return;
332
347
  const kind = providerTrouble(m.errorMessage);
333
- if (kind) ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
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);
334
372
  });
335
373
 
336
374
 
337
375
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
338
376
  // The input event comes before pi expands a prompt template.
339
377
  pi.on("input", async (event, ctx) => {
378
+ if (resumeTimer) clearTimeout(resumeTimer);
379
+ resumeTimer = undefined;
380
+ resumes = 0;
340
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 });
341
384
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
342
- 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");
343
386
  return { action: "handled" };
344
387
  }
345
388
  const why = promptBlocked(event.text, profile);
@@ -348,6 +391,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
348
391
  return { action: "handled" };
349
392
  });
350
393
 
394
+ pi.on("agent_start", async () => {
395
+ turnStart = Date.now();
396
+ turnTools = 0;
397
+ });
398
+
351
399
  pi.on("before_agent_start", async (event, ctx) => ({
352
400
  systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
353
401
  }));
@@ -357,6 +405,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
357
405
  const record = (data: Record<string, unknown>) => pi.appendEntry(BENCH_ENTRY, data);
358
406
 
359
407
  pi.on("tool_call", async (event, ctx) => {
408
+ turnTools++;
360
409
  const name = event.toolName;
361
410
  const input = (event.input ?? {}) as Record<string, unknown>;
362
411
  // Previews and the real call must see the same, repaired arguments.
@@ -385,7 +434,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
385
434
  const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
386
435
  if (verdict.ok) return undefined;
387
436
  if (!ctx.hasUI) return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
388
- 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}.`));
389
438
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
390
439
  }
391
440
  if (!ctx.hasUI) {
@@ -397,7 +446,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
397
446
  const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
398
447
  if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
399
448
  }
400
- 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));
401
450
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
402
451
  }
403
452
  // kind === "preview": the server computes the change first, then the user decides.
@@ -410,12 +459,13 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
410
459
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
411
460
  }
412
461
  if (preview.isError) return { block: true, reason: preview.text };
413
- const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
414
- 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);
415
464
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
416
465
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
417
466
  for (let round = 0; round < 3; round++) {
418
- 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 });
419
469
  if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
420
470
  let again;
421
471
  try {
@@ -424,7 +474,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
424
474
  return { block: true, reason: `Preview failed: ${(err as Error).message}` };
425
475
  }
426
476
  if (again.isError) return { block: true, reason: again.text };
427
- const now = formatPreview(name, again.data ?? again.text);
477
+ const now = formatPreview(name, again.data ?? again.text, lang);
428
478
  if (now === shown) return undefined;
429
479
  shown = now;
430
480
  }
@@ -465,7 +515,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
465
515
  return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
466
516
  }
467
517
  if (!ctx.hasUI) return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
468
- 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}.`));
469
519
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
470
520
  }
471
521
 
@@ -505,7 +555,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
505
555
  description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
506
556
  handler: async (_args, ctx) => {
507
557
  await applyMode(ctx, "librarian", true);
508
- 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");
509
559
  },
510
560
  });
511
561
 
@@ -513,7 +563,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
513
563
  description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
514
564
  handler: async (_args, ctx) => {
515
565
  await applyMode(ctx, "researcher", true);
516
- 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");
517
567
  },
518
568
  });
519
569
 
@@ -617,11 +667,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
617
667
  ctx.ui.notify(preview.text, "warning");
618
668
  return;
619
669
  }
620
- 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)));
621
671
  if (!ok) return;
622
672
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
623
673
  const d = (res.data ?? {}) as Record<string, unknown>;
624
- 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");
625
675
  },
626
676
  });
627
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
+ }