@tiagojct/subsub 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,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
+ }
package/src/web.ts CHANGED
@@ -22,7 +22,7 @@ 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, RECOMMENDED_MODELS, 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
27
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
28
28
  import { obsidianRoot } from "./layout.ts";
@@ -56,10 +56,7 @@ export function parseWebArgs(args: string[]): WebOptions {
56
56
  return o;
57
57
  }
58
58
 
59
- /** "pt" when the language setting is Portuguese, else "en". */
60
- export function uiLanguage(language: string | undefined): "pt" | "en" {
61
- return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
62
- }
59
+ export { uiLanguage } from "./config.ts";
63
60
 
64
61
  // ---------------------------------------------------------------- providers (API keys)
65
62
 
package/web/app.css CHANGED
@@ -883,3 +883,14 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
883
883
  @media (prefers-reduced-motion: reduce) {
884
884
  *, *::before, *::after { animation: none !important; transition: none !important; }
885
885
  }
886
+
887
+ /* First run: what is still missing */
888
+ .setup { margin-block: 1.4rem 1rem; padding: 0.9rem 1rem; border: 1px solid var(--rule); border-left: 3px solid var(--accent); border-radius: 6px; background: var(--raised); text-align: left; max-width: 40rem; }
889
+ .setup h2 { margin: 0 0 0.5rem; font-size: 0.95rem; }
890
+ .setup ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
891
+ .setup li { display: grid; grid-template-columns: 4.5rem 1fr auto; gap: 0.6rem; align-items: start; font-size: 0.88rem; line-height: 1.45; }
892
+ .setup li > * { min-width: 0; }
893
+ .setup .mark { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; padding-top: 0.15rem; color: var(--muted); }
894
+ .setup li.todo .mark { color: var(--rem); }
895
+ .setup li.ok .step-text { color: var(--muted); }
896
+ @media (max-width: 560px) { .setup li { grid-template-columns: 1fr; gap: 0.2rem; } }
package/web/app.js CHANGED
@@ -395,6 +395,36 @@ function clearEmpty() {
395
395
  log.querySelector(".empty")?.remove();
396
396
  }
397
397
 
398
+ /** First run: what is still missing (Zotero, a model, the servers), each with its fix. Hidden when all is ready. */
399
+ function setupList() {
400
+ const L = state?.library;
401
+ const m = state?.model ?? session?.model;
402
+ const steps = [
403
+ { ok: L?.zotero === "reachable", wait: !L || L.zotero === "checking", text: t("stepZotero") },
404
+ { ok: Boolean(m && m.id && m.id !== "unknown"), text: t("stepModel"), action: { label: t("connect"), run: openModels } },
405
+ { ok: !state?.down?.length, text: t("stepServers", { x: (state?.down ?? []).join(", ") }) },
406
+ ];
407
+ if (steps.every((x) => x.ok || x.wait)) return null;
408
+ return el(
409
+ "div",
410
+ { class: "setup" },
411
+ el("h2", { text: t("setupTitle") }),
412
+ el(
413
+ "ol",
414
+ {},
415
+ steps.map((x) =>
416
+ el(
417
+ "li",
418
+ { class: x.ok ? "ok" : x.wait ? "wait" : "todo" },
419
+ el("span", { class: "mark", text: x.ok ? t("stepDone") : x.wait ? "…" : t("stepTodo") }),
420
+ el("span", { class: "step-text", text: x.ok ? x.text.split(". ")[0] : x.text }),
421
+ !x.ok && x.action ? el("button", { type: "button", class: "primary small-btn", text: x.action.label, onclick: x.action.run }) : null,
422
+ ),
423
+ ),
424
+ ),
425
+ );
426
+ }
427
+
398
428
  function renderEmpty() {
399
429
  if (log.children.length) return;
400
430
  const s = SUGGESTIONS[mode()].map((x) => x[lang] ?? x.en);
@@ -405,6 +435,7 @@ function renderEmpty() {
405
435
  el("p", { class: "kicker", text: t(mode()) }),
406
436
  el("h1", { text: t("emptyTitle") }),
407
437
  el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
438
+ setupList(),
408
439
  el(
409
440
  "div",
410
441
  { class: "chips" },
@@ -505,6 +536,23 @@ function finishTool(id, result, isError) {
505
536
  row.querySelector("pre")?.remove();
506
537
  if (text) row.append(el("pre", { text: text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text }));
507
538
  if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
539
+ // An applied library change gets an Undo button; it runs /undo with its id, which previews first.
540
+ const jid = !isError && !/undo/.test(row.dataset.name) && /"journal_id":\s*"(\d{8}-\d{6}-\d{2}-[a-z_]+)"/.exec(text ?? "")?.[1];
541
+ if (jid && !row.querySelector(".undo")) {
542
+ row.querySelector("summary").append(
543
+ el("button", {
544
+ type: "button",
545
+ class: "open undo",
546
+ text: t("undo"),
547
+ title: t("undoTitle"),
548
+ onclick: (e) => {
549
+ e.preventDefault();
550
+ if (busy) return note(t("busy"), "warning");
551
+ sendMessage(`/undo ${jid}`);
552
+ },
553
+ }),
554
+ );
555
+ }
508
556
  }
509
557
 
510
558
  function attachCopyButtons(root) {
@@ -925,6 +973,7 @@ async function openModels() {
925
973
  const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
926
974
  $("login-free").hidden = !free;
927
975
  $("login-free").textContent = free ? t(free) : "";
976
+ $("login-where").textContent = p?.where ? t(`where_${p.where}`) : "";
928
977
  };
929
978
  sel.onchange = setLink;
930
979
  setLink();