@tiagojct/subsub 0.11.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +10 -5
- package/dist/bench.js +10 -4
- package/dist/cli.js +6 -0
- package/dist/config.js +28 -1
- package/dist/init.js +49 -3
- package/dist/keys.js +81 -0
- package/dist/preview.js +101 -26
- package/dist/subsub.js +74 -14
- package/dist/usage.js +69 -0
- package/dist/web.js +5 -37
- package/package.json +4 -3
- package/src/bench.ts +10 -4
- package/src/cli.ts +6 -0
- package/src/config.ts +30 -1
- package/src/init.ts +60 -2
- package/src/keys.ts +79 -0
- package/src/preview.ts +100 -25
- package/src/subsub.ts +72 -13
- package/src/usage.ts +75 -0
- package/src/web.ts +5 -35
- package/web/app.css +24 -0
- package/web/app.js +135 -22
- package/web/i18n.js +58 -0
- package/web/index.html +3 -0
package/dist/subsub.js
CHANGED
|
@@ -19,14 +19,16 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { Bridge } from "./bridge.js";
|
|
22
|
-
import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
|
|
22
|
+
import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
|
|
23
23
|
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
24
24
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
25
25
|
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
26
26
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
27
27
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
28
|
+
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
|
|
28
29
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
29
30
|
import { systemAddition } from "./prompt.js";
|
|
31
|
+
import { logUsage } from "./usage.js";
|
|
30
32
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
31
33
|
const MODE_ENTRY = "subsub-mode";
|
|
32
34
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -69,6 +71,10 @@ export function starbuckEnv(cfg, processEnv = process.env) {
|
|
|
69
71
|
}
|
|
70
72
|
export async function createSubsub(pi, deps = {}) {
|
|
71
73
|
const cfg = deps.config ?? loadConfig();
|
|
74
|
+
// Notices, dialogs and previews in the language of the interface (English or European Portuguese).
|
|
75
|
+
const lang = uiLanguage(cfg.language);
|
|
76
|
+
const L = (en, pt) => (lang === "pt" ? pt : en);
|
|
77
|
+
const trouble = lang === "pt" ? TROUBLE_TEXT_PT : TROUBLE_TEXT;
|
|
72
78
|
let bridge = deps.bridge;
|
|
73
79
|
const toolNames = new Set();
|
|
74
80
|
/** Original input schemas, to turn "[...]" strings back into lists. */
|
|
@@ -122,6 +128,11 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
122
128
|
const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
123
129
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
124
130
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
131
|
+
let lastError;
|
|
132
|
+
let resumes = 0;
|
|
133
|
+
let resumeTimer;
|
|
134
|
+
let turnStart = 0;
|
|
135
|
+
let turnTools = 0;
|
|
125
136
|
let webCtx;
|
|
126
137
|
function emitWeb() {
|
|
127
138
|
const ctx = webCtx;
|
|
@@ -264,6 +275,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
264
275
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
265
276
|
applyTheme(ctx);
|
|
266
277
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
278
|
+
logUsage(cfg.usageLog, { ev: "session", mode, profile, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
267
279
|
if (lookOn(ctx)) {
|
|
268
280
|
setHeader(ctx);
|
|
269
281
|
void refreshLibrary();
|
|
@@ -272,12 +284,14 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
272
284
|
void refreshLibrary();
|
|
273
285
|
}
|
|
274
286
|
if (!ctx.model && !webView)
|
|
275
|
-
ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
287
|
+
ctx.ui.notify(L("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "Sub-Sub: ainda não há um modelo ligado. Escreva /login e selecione um fornecedor (ou use o botão do modelo em subsub web)."), "warning");
|
|
276
288
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
277
289
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
278
290
|
}
|
|
279
291
|
});
|
|
280
292
|
pi.on("session_shutdown", async () => {
|
|
293
|
+
if (resumeTimer)
|
|
294
|
+
clearTimeout(resumeTimer);
|
|
281
295
|
if (deps.bridge)
|
|
282
296
|
return;
|
|
283
297
|
const b = bridge;
|
|
@@ -298,12 +312,52 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
298
312
|
await applyMode(ctx, m, false, false);
|
|
299
313
|
});
|
|
300
314
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
315
|
+
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
316
|
+
pi.on("message_end", async (event, ctx) => {
|
|
317
|
+
const m = event.message;
|
|
318
|
+
if (m.role !== "assistant" || m.stopReason !== "error")
|
|
319
|
+
return;
|
|
320
|
+
lastError = m.errorMessage;
|
|
321
|
+
if (webView || perMinuteWait(m.errorMessage) !== undefined)
|
|
322
|
+
return;
|
|
323
|
+
const kind = providerTrouble(m.errorMessage);
|
|
324
|
+
if (kind)
|
|
325
|
+
ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
|
|
326
|
+
});
|
|
327
|
+
// A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
|
|
328
|
+
// up to three times per request of the user. Anything the user types cancels the wait.
|
|
329
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
330
|
+
if (turnStart)
|
|
331
|
+
logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
|
|
332
|
+
turnStart = 0;
|
|
333
|
+
const wait = perMinuteWait(lastError);
|
|
334
|
+
lastError = undefined;
|
|
335
|
+
if (wait === undefined)
|
|
336
|
+
return;
|
|
337
|
+
if (resumes >= 3) {
|
|
338
|
+
ctx.ui.notify(`Sub-Sub: ${trouble.quota}`, "warning");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
resumes++;
|
|
342
|
+
ctx.ui.notify(L(`Sub-Sub: the provider's per-minute limit was reached. Continuing in ${wait} seconds (type anything to stop waiting).`, `Sub-Sub: atingiu o limite por minuto do fornecedor. Continua dentro de ${wait} segundos (escreva qualquer coisa para deixar de esperar).`), "info");
|
|
343
|
+
resumeTimer = setTimeout(() => {
|
|
344
|
+
resumeTimer = undefined;
|
|
345
|
+
pi.sendMessage({ customType: "subsub-resume", content: "The model provider's per-minute limit was reached and has now passed. Continue the task where you stopped; do not repeat finished steps.", display: false }, { triggerTurn: true });
|
|
346
|
+
}, wait * 1000);
|
|
347
|
+
});
|
|
301
348
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
302
349
|
// The input event comes before pi expands a prompt template.
|
|
303
350
|
pi.on("input", async (event, ctx) => {
|
|
351
|
+
if (resumeTimer)
|
|
352
|
+
clearTimeout(resumeTimer);
|
|
353
|
+
resumeTimer = undefined;
|
|
354
|
+
resumes = 0;
|
|
304
355
|
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
356
|
+
// The usage log keeps the kind of request and its length, never its text.
|
|
357
|
+
if (cmd !== "subsub-refresh")
|
|
358
|
+
logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
305
359
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
306
|
-
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
360
|
+
ctx.ui.notify(L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`), "warning");
|
|
307
361
|
return { action: "handled" };
|
|
308
362
|
}
|
|
309
363
|
const why = promptBlocked(event.text, profile);
|
|
@@ -312,6 +366,10 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
312
366
|
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
313
367
|
return { action: "handled" };
|
|
314
368
|
});
|
|
369
|
+
pi.on("agent_start", async () => {
|
|
370
|
+
turnStart = Date.now();
|
|
371
|
+
turnTools = 0;
|
|
372
|
+
});
|
|
315
373
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
316
374
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
317
375
|
}));
|
|
@@ -319,6 +377,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
319
377
|
const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
|
|
320
378
|
const record = (data) => pi.appendEntry(BENCH_ENTRY, data);
|
|
321
379
|
pi.on("tool_call", async (event, ctx) => {
|
|
380
|
+
turnTools++;
|
|
322
381
|
const name = event.toolName;
|
|
323
382
|
const input = (event.input ?? {});
|
|
324
383
|
// Previews and the real call must see the same, repaired arguments.
|
|
@@ -355,7 +414,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
355
414
|
return undefined;
|
|
356
415
|
if (!ctx.hasUI)
|
|
357
416
|
return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
358
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, `Asking because ${verdict.why}.`);
|
|
417
|
+
const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
359
418
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
|
|
360
419
|
}
|
|
361
420
|
if (!ctx.hasUI) {
|
|
@@ -368,7 +427,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
368
427
|
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
369
428
|
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
370
429
|
}
|
|
371
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
430
|
+
const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
|
|
372
431
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
373
432
|
}
|
|
374
433
|
// kind === "preview": the server computes the change first, then the user decides.
|
|
@@ -383,12 +442,13 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
383
442
|
}
|
|
384
443
|
if (preview.isError)
|
|
385
444
|
return { block: true, reason: preview.text };
|
|
386
|
-
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}
|
|
387
|
-
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
445
|
+
const title = L(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, `Sub-Sub: aplicar ${name.replace(/^(zotero|scholar)_/, "")}?`);
|
|
446
|
+
let shown = formatPreview(name, preview.data ?? preview.text, lang);
|
|
388
447
|
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
389
448
|
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
390
449
|
for (let round = 0; round < 3; round++) {
|
|
391
|
-
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
450
|
+
const ok = await ctx.ui.confirm(round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
|
|
451
|
+
logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
|
|
392
452
|
if (!ok)
|
|
393
453
|
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
394
454
|
let again;
|
|
@@ -400,7 +460,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
400
460
|
}
|
|
401
461
|
if (again.isError)
|
|
402
462
|
return { block: true, reason: again.text };
|
|
403
|
-
const now = formatPreview(name, again.data ?? again.text);
|
|
463
|
+
const now = formatPreview(name, again.data ?? again.text, lang);
|
|
404
464
|
if (now === shown)
|
|
405
465
|
return undefined;
|
|
406
466
|
shown = now;
|
|
@@ -446,7 +506,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
446
506
|
}
|
|
447
507
|
if (!ctx.hasUI)
|
|
448
508
|
return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
449
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
509
|
+
const ok = await ctx.ui.confirm(L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
450
510
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
451
511
|
}
|
|
452
512
|
async function benchGate(name, input, kind, cwd, signal) {
|
|
@@ -485,14 +545,14 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
485
545
|
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
486
546
|
handler: async (_args, ctx) => {
|
|
487
547
|
await applyMode(ctx, "librarian", true);
|
|
488
|
-
ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
|
|
548
|
+
ctx.ui.notify(L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher."), "info");
|
|
489
549
|
},
|
|
490
550
|
});
|
|
491
551
|
pi.registerCommand("researcher", {
|
|
492
552
|
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
493
553
|
handler: async (_args, ctx) => {
|
|
494
554
|
await applyMode(ctx, "researcher", true);
|
|
495
|
-
ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
|
|
555
|
+
ctx.ui.notify(L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
|
|
496
556
|
},
|
|
497
557
|
});
|
|
498
558
|
pi.registerCommand("profile", {
|
|
@@ -594,12 +654,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
594
654
|
ctx.ui.notify(preview.text, "warning");
|
|
595
655
|
return;
|
|
596
656
|
}
|
|
597
|
-
const ok = await ctx.ui.confirm("Sub-Sub: undo?", paintFor(ctx)(formatPreview("zotero_undo", preview.data)));
|
|
657
|
+
const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
|
|
598
658
|
if (!ok)
|
|
599
659
|
return;
|
|
600
660
|
const res = await current().call("zotero_undo", { ...input, dry_run: false });
|
|
601
661
|
const d = (res.data ?? {});
|
|
602
|
-
ctx.ui.notify(res.isError ? res.text : `Undone: ${d.applied} item(s).`, res.isError ? "error" : "info");
|
|
662
|
+
ctx.ui.notify(res.isError ? res.text : L(`Undone: ${d.applied} item(s).`, `Anulado: ${d.applied} item(ns).`), res.isError ? "error" : "info");
|
|
603
663
|
},
|
|
604
664
|
});
|
|
605
665
|
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pilot's usage log: off unless the user turns it on (subsub init, or "usageLog": true).
|
|
3
|
+
* One JSON line per event in ~/.subsub/usage-log.jsonl: times, counts, commands and tool names.
|
|
4
|
+
* Never what the user wrote, the replies, titles or file names. Nothing is sent anywhere; the
|
|
5
|
+
* user reads it with `subsub usage` and sends the file to the pilot team if they want to.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
export function usageFile(env = process.env) {
|
|
11
|
+
return join(env.HOME ?? homedir(), ".subsub", "usage-log.jsonl");
|
|
12
|
+
}
|
|
13
|
+
export function logUsage(on, event, env = process.env) {
|
|
14
|
+
if (!on)
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
const file = usageFile(env);
|
|
18
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
19
|
+
appendFileSync(file, `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n`, { mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* a log must never stop the work */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A plain summary of the log: days, sessions, requests, commands, time, library changes. */
|
|
26
|
+
export function summarizeUsage(text) {
|
|
27
|
+
const rows = text
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.flatMap((l) => {
|
|
31
|
+
try {
|
|
32
|
+
return [JSON.parse(l)];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (!rows.length)
|
|
39
|
+
return "The usage log is empty.";
|
|
40
|
+
const days = new Set(rows.map((r) => r.t.slice(0, 10)));
|
|
41
|
+
const count = (ev) => rows.filter((r) => r.ev === ev);
|
|
42
|
+
const asks = count("ask");
|
|
43
|
+
const turns = count("turn");
|
|
44
|
+
const changes = count("change");
|
|
45
|
+
const kinds = new Map();
|
|
46
|
+
for (const a of asks)
|
|
47
|
+
kinds.set(a.kind, (kinds.get(a.kind) ?? 0) + 1);
|
|
48
|
+
const minutes = Math.round(turns.reduce((s, r) => s + r.seconds, 0) / 60);
|
|
49
|
+
const errors = turns.filter((r) => r.error).length;
|
|
50
|
+
const lines = [
|
|
51
|
+
`From ${rows[0].t.slice(0, 10)} to ${rows.at(-1).t.slice(0, 10)}: Sub-Sub used on ${days.size} day(s), in ${count("session").length} session(s).`,
|
|
52
|
+
`Requests: ${asks.length} (${[...kinds].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(", ")}).`,
|
|
53
|
+
`Time Sub-Sub spent working: ${minutes} minute(s), with ${turns.reduce((s, r) => s + r.tools, 0)} tool call(s) and ${errors} error(s).`,
|
|
54
|
+
`Library changes: ${changes.filter((c) => c.approved).length} approved, ${changes.filter((c) => !c.approved).length} declined.`,
|
|
55
|
+
];
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
export function usageMain(env = process.env) {
|
|
59
|
+
const file = usageFile(env);
|
|
60
|
+
if (!existsSync(file)) {
|
|
61
|
+
console.log(`No usage log yet (${file}). To keep one for the pilot, type subsub init and turn it on, or set "usageLog": true in the settings.`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
console.log(summarizeUsage(readFileSync(file, "utf8")));
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(`The log: ${file}`);
|
|
67
|
+
console.log("It holds times, counts, commands and tool names, never what you wrote or what Sub-Sub replied. To share it with the pilot team, send this file.");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
package/dist/web.js
CHANGED
|
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
|
|
24
|
+
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
26
27
|
import { obsidianRoot } from "./layout.js";
|
|
27
28
|
import { realish, within } from "./paths.js";
|
|
28
29
|
export function parseWebArgs(args) {
|
|
@@ -46,42 +47,9 @@ export function parseWebArgs(args) {
|
|
|
46
47
|
}
|
|
47
48
|
return o;
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
export function uiLanguage(language) {
|
|
51
|
-
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
52
|
-
}
|
|
50
|
+
export { uiLanguage } from "./config.js";
|
|
53
51
|
// ---------------------------------------------------------------- providers (API keys)
|
|
54
|
-
export
|
|
55
|
-
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
56
|
-
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
57
|
-
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
58
|
-
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
59
|
-
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
60
|
-
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
61
|
-
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
62
|
-
];
|
|
63
|
-
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
64
|
-
export function saveApiKey(agentDir, provider, key) {
|
|
65
|
-
if (!KEY_PROVIDERS.some((p) => p.id === provider))
|
|
66
|
-
throw new Error(`Unknown provider: ${provider}`);
|
|
67
|
-
const clean = key.trim();
|
|
68
|
-
if (!clean || /\s/.test(clean) || clean.length > 400)
|
|
69
|
-
throw new Error("That does not look like an API key.");
|
|
70
|
-
mkdirSync(agentDir, { recursive: true });
|
|
71
|
-
const file = join(agentDir, "auth.json");
|
|
72
|
-
let data = {};
|
|
73
|
-
if (existsSync(file)) {
|
|
74
|
-
try {
|
|
75
|
-
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
data[provider] = { type: "api_key", key: clean };
|
|
82
|
-
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
83
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
84
|
-
}
|
|
52
|
+
export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
85
53
|
/** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
|
|
86
54
|
export class Agent {
|
|
87
55
|
proc;
|
|
@@ -582,7 +550,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
582
550
|
}
|
|
583
551
|
case "GET /api/models": {
|
|
584
552
|
const data = (await agent.request({ type: "get_available_models" }));
|
|
585
|
-
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
553
|
+
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
|
|
586
554
|
}
|
|
587
555
|
case "POST /api/model": {
|
|
588
556
|
if (busy || switching)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.30.1"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@earendil-works/pi-coding-agent": "
|
|
32
|
+
"@earendil-works/pi-coding-agent": "~1.0.4"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.19.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"test": "node --test test/unit.test.ts",
|
|
38
|
+
"test": "node --test test/unit.test.ts test/paths.property.test.ts",
|
|
39
39
|
"test:e2e": "node --test test/e2e.test.ts",
|
|
40
40
|
"build": "node scripts/build.mjs",
|
|
41
41
|
"prepack": "npm run build"
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
48
|
"README.md",
|
|
49
|
+
"CHANGELOG.md",
|
|
49
50
|
"assets",
|
|
50
51
|
"bin",
|
|
51
52
|
"dist",
|
package/src/bench.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { expand, loadConfig, type Mode, starbuckCommand, starbuckEnabled } from "./config.ts";
|
|
@@ -134,7 +134,10 @@ export function runOne(opts: {
|
|
|
134
134
|
provider?: string;
|
|
135
135
|
}): Promise<RunSummary> {
|
|
136
136
|
mkdirSync(opts.out, { recursive: true });
|
|
137
|
-
|
|
137
|
+
// "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
|
|
138
|
+
const slash = opts.model.indexOf("/");
|
|
139
|
+
const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
|
|
140
|
+
const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
|
|
138
141
|
if (opts.role === "librarian") args.push("--librarian");
|
|
139
142
|
const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
|
|
140
143
|
const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
|
|
@@ -460,7 +463,8 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
|
|
|
460
463
|
dois: dois.length,
|
|
461
464
|
pmids: pmids.length,
|
|
462
465
|
ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
|
|
463
|
-
|
|
466
|
+
// A model may write .plans as a file instead of a folder: then there is no screening log.
|
|
467
|
+
screening_log: existsSync(plans) && statSync(plans).isDirectory() && readdirSync(plans).some((f) => /screening/i.test(f)),
|
|
464
468
|
multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
|
|
465
469
|
fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
|
|
466
470
|
starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
|
|
@@ -608,7 +612,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
608
612
|
const prompt = promptFor(j.task, t, j.code, out);
|
|
609
613
|
if (!prompt) return;
|
|
610
614
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
611
|
-
|
|
615
|
+
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
616
|
+
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
617
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
612
618
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
613
619
|
n++;
|
|
614
620
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/src/cli.ts
CHANGED
|
@@ -142,6 +142,7 @@ Usage:
|
|
|
142
142
|
subsub web Open Sub-Sub in the browser
|
|
143
143
|
subsub init Set up or change the settings (folder, tag list, profile, models)
|
|
144
144
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
145
|
+
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
145
146
|
subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
|
|
146
147
|
subsub review preview|apply <note>
|
|
147
148
|
Check or apply a tag review note from Inbox/
|
|
@@ -191,6 +192,11 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
|
|
|
191
192
|
process.exitCode = await doctorMain(env);
|
|
192
193
|
return;
|
|
193
194
|
}
|
|
195
|
+
if (args[0] === "usage") {
|
|
196
|
+
const { usageMain } = await import("./usage.ts");
|
|
197
|
+
process.exitCode = usageMain(env);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
194
200
|
if (args[0] === "review") {
|
|
195
201
|
const r = reviewCommand(args.slice(1), loadConfig(env), env);
|
|
196
202
|
const res = spawnSync(r.command, r.args, { env: r.env, stdio: "inherit" });
|
package/src/config.ts
CHANGED
|
@@ -35,11 +35,37 @@ export const DEFAULT_MODELS: Record<Mode, string> = {
|
|
|
35
35
|
librarian: "opencode-go/mimo-v2.6-flash",
|
|
36
36
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
37
37
|
};
|
|
38
|
+
/** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
|
|
39
|
+
export const FREE_MODELS: Record<Mode, string> = {
|
|
40
|
+
librarian: "google/gemini-3.1-flash-lite",
|
|
41
|
+
researcher: "google/gemini-3.1-flash-lite",
|
|
42
|
+
};
|
|
43
|
+
/** The tested EU-hosted models (Mistral, servers in the EU; see the Models page). */
|
|
44
|
+
export const EU_MODELS: Record<Mode, string> = {
|
|
45
|
+
librarian: "mistral/mistral-medium-3.5",
|
|
46
|
+
researcher: "mistral/mistral-medium-3.5",
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Models marked in the model dialog, from the model tests (see the Models page). `why` is a
|
|
50
|
+
* key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
|
|
51
|
+
* free (tested, works on a free tier), eu (tested, servers in the EU).
|
|
52
|
+
*/
|
|
53
|
+
export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" | "eu" }> = [
|
|
54
|
+
{ spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
|
|
55
|
+
{ spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
|
|
56
|
+
{ spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
|
|
57
|
+
{ spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
|
|
58
|
+
{ spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
|
|
59
|
+
];
|
|
60
|
+
/** "pt" when the language setting is Portuguese, else "en": the language of the interface. */
|
|
61
|
+
export function uiLanguage(language: string | undefined): "pt" | "en" {
|
|
62
|
+
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
63
|
+
}
|
|
38
64
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
39
65
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
40
66
|
|
|
41
67
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
42
|
-
export const SERVER_VERSION = "0.5.
|
|
68
|
+
export const SERVER_VERSION = "0.5.4";
|
|
43
69
|
|
|
44
70
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
45
71
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -82,6 +108,8 @@ export interface SubsubConfig {
|
|
|
82
108
|
addons?: string[];
|
|
83
109
|
/** Local Starbuck folder (used when it has a pyproject.toml). */
|
|
84
110
|
starbuckDir?: string;
|
|
111
|
+
/** The pilot's usage log, kept on this computer only (see usage.ts). Off by default. */
|
|
112
|
+
usageLog?: boolean;
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
export function expand(p: string, home?: string): string {
|
|
@@ -193,6 +221,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
193
221
|
themes,
|
|
194
222
|
addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
|
|
195
223
|
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
224
|
+
usageLog: user.usageLog === true,
|
|
196
225
|
};
|
|
197
226
|
}
|
|
198
227
|
|
package/src/init.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
21
|
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
-
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -30,6 +30,8 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
import {
|
|
31
31
|
configPath,
|
|
32
32
|
DEFAULT_MODELS,
|
|
33
|
+
EU_MODELS,
|
|
34
|
+
FREE_MODELS,
|
|
33
35
|
expand,
|
|
34
36
|
isProfile,
|
|
35
37
|
loadConfig,
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
} from "./config.ts";
|
|
44
46
|
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
45
47
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
|
|
48
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
46
49
|
import { PROFILE_SPECS } from "./profiles.ts";
|
|
47
50
|
|
|
48
51
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
@@ -185,8 +188,22 @@ export async function runInit(
|
|
|
185
188
|
);
|
|
186
189
|
const fmup = setup === "fmup";
|
|
187
190
|
|
|
191
|
+
// Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
|
|
192
|
+
let student = fmup;
|
|
193
|
+
if (!flags.profile && !existing.profile && !fmup) {
|
|
194
|
+
explain("Who you are", "Students start with the Reader profile: Sub-Sub helps you read (notes with quotations, page numbers and questions) and does not write summaries or syntheses unless you ask. You can change the profile at any time.");
|
|
195
|
+
student = (await io.choose(
|
|
196
|
+
"You are",
|
|
197
|
+
[
|
|
198
|
+
{ value: "student", label: "A student" },
|
|
199
|
+
{ value: "researcher", label: "A researcher, teacher or clinician" },
|
|
200
|
+
],
|
|
201
|
+
flags.student === "no" ? "researcher" : "student",
|
|
202
|
+
)) === "student";
|
|
203
|
+
}
|
|
204
|
+
|
|
188
205
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
189
|
-
const profileDefault = flags.profile ?? existing.profile ?? (
|
|
206
|
+
const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
|
|
190
207
|
const profile = await io.choose(
|
|
191
208
|
"Profile",
|
|
192
209
|
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
@@ -228,6 +245,19 @@ export async function runInit(
|
|
|
228
245
|
flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
|
|
229
246
|
);
|
|
230
247
|
|
|
248
|
+
explain(
|
|
249
|
+
"Usage log for the pilot",
|
|
250
|
+
"If you take part in the Sub-Sub pilot, Sub-Sub can keep a log on this computer: when you used it, which commands, how long it worked and how many library changes you approved. Never what you wrote or what it replied. Nothing is sent; subsub usage shows the log, and you decide whether to send it.",
|
|
251
|
+
);
|
|
252
|
+
const usage = await io.choose(
|
|
253
|
+
"Usage log",
|
|
254
|
+
[
|
|
255
|
+
{ value: "off", label: "Off" },
|
|
256
|
+
{ value: "on", label: "On (for the pilot)" },
|
|
257
|
+
],
|
|
258
|
+
flags.usage ?? (existing.usageLog === true ? "on" : "off"),
|
|
259
|
+
);
|
|
260
|
+
|
|
231
261
|
explain(
|
|
232
262
|
"Models",
|
|
233
263
|
"Sub-Sub needs an account with a model provider. Each mode can use its own model: the Librarian a fast, cheap one for tags and imports, the Researcher a stronger one for reading. You can connect a provider later in Sub-Sub with the model button (or /login in the terminal).",
|
|
@@ -238,10 +268,21 @@ export async function runInit(
|
|
|
238
268
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
239
269
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
240
270
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
271
|
+
{ value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
|
|
272
|
+
{ value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
|
|
241
273
|
];
|
|
242
274
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
243
275
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
244
276
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
277
|
+
let googleKey = "";
|
|
278
|
+
if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
279
|
+
const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
|
|
280
|
+
explain(
|
|
281
|
+
"Free Google key",
|
|
282
|
+
`Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`,
|
|
283
|
+
);
|
|
284
|
+
googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
|
|
285
|
+
}
|
|
245
286
|
|
|
246
287
|
// ---- write
|
|
247
288
|
if (move === "yes") applyMoves(moves);
|
|
@@ -279,13 +320,29 @@ export async function runInit(
|
|
|
279
320
|
envFile,
|
|
280
321
|
libraryChanges: undefined,
|
|
281
322
|
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
323
|
+
usageLog: usage === "on" ? true : undefined,
|
|
282
324
|
};
|
|
283
325
|
// A model per mode; "model" (0.10, one for both) is replaced.
|
|
284
326
|
patch.model = undefined;
|
|
285
327
|
if (models === "keep") patch.models = current.models;
|
|
286
328
|
if (models === "later") patch.models = {};
|
|
287
329
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
330
|
+
if (models === "google-free") patch.models = FREE_MODELS;
|
|
331
|
+
if (models === "mistral-eu") patch.models = EU_MODELS;
|
|
288
332
|
saveConfig(patch, initEnv);
|
|
333
|
+
let keyNote = "";
|
|
334
|
+
if (googleKey) {
|
|
335
|
+
try {
|
|
336
|
+
saveApiKey(agentDir, "google", googleKey);
|
|
337
|
+
keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
|
|
340
|
+
}
|
|
341
|
+
} else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
|
|
342
|
+
keyNote = `No Mistral key yet. Get one at ${KEY_PROVIDERS.find((p) => p.id === "mistral")?.url}, then add it in Sub-Sub with the model button (subsub web) or /login (in the terminal).`;
|
|
343
|
+
} else if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
344
|
+
keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
|
|
345
|
+
}
|
|
289
346
|
|
|
290
347
|
const zotero = await zoteroReachable(deps.fetch);
|
|
291
348
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
@@ -293,6 +350,7 @@ export async function runInit(
|
|
|
293
350
|
io.say(`Saved: ${configFile}`);
|
|
294
351
|
io.say(`Saved: ${envFile}`);
|
|
295
352
|
for (const c of created) io.say(`Created: ${c}`);
|
|
353
|
+
if (keyNote) io.say(keyNote);
|
|
296
354
|
if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
297
355
|
for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
|
|
298
356
|
io.say(
|