@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/CHANGELOG.md +64 -0
- package/README.md +8 -3
- package/dist/bench.js +3 -2
- package/dist/cli.js +6 -0
- package/dist/config.js +13 -2
- package/dist/init.js +24 -3
- package/dist/keys.js +34 -8
- package/dist/preview.js +101 -26
- package/dist/subsub.js +67 -17
- package/dist/usage.js +69 -0
- package/dist/web.js +2 -5
- package/package.json +4 -3
- package/src/bench.ts +3 -2
- package/src/cli.ts +6 -0
- package/src/config.ts +16 -3
- package/src/init.ts +35 -2
- package/src/keys.ts +33 -9
- package/src/preview.ts +100 -25
- package/src/subsub.ts +66 -16
- package/src/usage.ts +75 -0
- package/src/web.ts +2 -5
- package/web/app.css +11 -0
- package/web/app.js +49 -0
- package/web/i18n.js +26 -0
- package/web/index.html +1 -0
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)}
|
|
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(
|
|
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 ||
|
|
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("; ") ||
|
|
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(
|
|
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(
|
|
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(
|
|
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(", ") ||
|
|
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(
|
|
134
|
+
lines.push(w.wouldChange(p.would_change));
|
|
63
135
|
const changes = (p.changes as Obj[]) ?? [];
|
|
64
|
-
lines.push(...
|
|
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(
|
|
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 ? "" :
|
|
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(
|
|
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
|
|
88
|
-
lines.push(
|
|
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(
|
|
92
|
-
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
|
|
93
|
-
if (p.link) lines.push(
|
|
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(
|
|
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(
|
|
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 (
|
|
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: ${
|
|
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
|
-
|
|
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();
|