@tiagojct/subsub 0.11.4 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -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 +31 -5
- package/dist/keys.js +38 -8
- package/dist/preview.js +130 -26
- package/dist/roles.js +12 -1
- package/dist/subsub.js +67 -17
- package/dist/usage.js +69 -0
- package/dist/web.js +2 -5
- package/package.json +4 -3
- package/roles/librarian.md +13 -1
- package/roles/researcher.md +12 -0
- package/src/bench.ts +3 -2
- package/src/cli.ts +6 -0
- package/src/config.ts +16 -3
- package/src/init.ts +48 -4
- package/src/keys.ts +37 -10
- package/src/preview.ts +126 -25
- package/src/roles.ts +12 -1
- 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 +50 -0
- package/web/i18n.js +36 -0
- package/web/index.html +1 -0
package/dist/subsub.js
CHANGED
|
@@ -19,15 +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 { providerTrouble, TROUBLE_TEXT } from "./keys.js";
|
|
28
|
+
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
|
|
29
29
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
30
30
|
import { systemAddition } from "./prompt.js";
|
|
31
|
+
import { logUsage } from "./usage.js";
|
|
31
32
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
32
33
|
const MODE_ENTRY = "subsub-mode";
|
|
33
34
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -70,6 +71,10 @@ export function starbuckEnv(cfg, processEnv = process.env) {
|
|
|
70
71
|
}
|
|
71
72
|
export async function createSubsub(pi, deps = {}) {
|
|
72
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;
|
|
73
78
|
let bridge = deps.bridge;
|
|
74
79
|
const toolNames = new Set();
|
|
75
80
|
/** Original input schemas, to turn "[...]" strings back into lists. */
|
|
@@ -123,6 +128,11 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
123
128
|
const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
124
129
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
125
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;
|
|
126
136
|
let webCtx;
|
|
127
137
|
function emitWeb() {
|
|
128
138
|
const ctx = webCtx;
|
|
@@ -265,6 +275,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
265
275
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
266
276
|
applyTheme(ctx);
|
|
267
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 });
|
|
268
279
|
if (lookOn(ctx)) {
|
|
269
280
|
setHeader(ctx);
|
|
270
281
|
void refreshLibrary();
|
|
@@ -273,12 +284,14 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
273
284
|
void refreshLibrary();
|
|
274
285
|
}
|
|
275
286
|
if (!ctx.model && !webView)
|
|
276
|
-
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");
|
|
277
288
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
278
289
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
279
290
|
}
|
|
280
291
|
});
|
|
281
292
|
pi.on("session_shutdown", async () => {
|
|
293
|
+
if (resumeTimer)
|
|
294
|
+
clearTimeout(resumeTimer);
|
|
282
295
|
if (deps.bridge)
|
|
283
296
|
return;
|
|
284
297
|
const b = bridge;
|
|
@@ -302,18 +315,49 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
302
315
|
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
303
316
|
pi.on("message_end", async (event, ctx) => {
|
|
304
317
|
const m = event.message;
|
|
305
|
-
if (
|
|
318
|
+
if (m.role !== "assistant" || m.stopReason !== "error")
|
|
319
|
+
return;
|
|
320
|
+
lastError = m.errorMessage;
|
|
321
|
+
if (webView || perMinuteWait(m.errorMessage) !== undefined)
|
|
306
322
|
return;
|
|
307
323
|
const kind = providerTrouble(m.errorMessage);
|
|
308
324
|
if (kind)
|
|
309
|
-
ctx.ui.notify(`Sub-Sub: ${
|
|
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);
|
|
310
347
|
});
|
|
311
348
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
312
349
|
// The input event comes before pi expands a prompt template.
|
|
313
350
|
pi.on("input", async (event, ctx) => {
|
|
351
|
+
if (resumeTimer)
|
|
352
|
+
clearTimeout(resumeTimer);
|
|
353
|
+
resumeTimer = undefined;
|
|
354
|
+
resumes = 0;
|
|
314
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 });
|
|
315
359
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
316
|
-
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");
|
|
317
361
|
return { action: "handled" };
|
|
318
362
|
}
|
|
319
363
|
const why = promptBlocked(event.text, profile);
|
|
@@ -322,6 +366,10 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
322
366
|
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
323
367
|
return { action: "handled" };
|
|
324
368
|
});
|
|
369
|
+
pi.on("agent_start", async () => {
|
|
370
|
+
turnStart = Date.now();
|
|
371
|
+
turnTools = 0;
|
|
372
|
+
});
|
|
325
373
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
326
374
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
327
375
|
}));
|
|
@@ -329,6 +377,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
329
377
|
const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
|
|
330
378
|
const record = (data) => pi.appendEntry(BENCH_ENTRY, data);
|
|
331
379
|
pi.on("tool_call", async (event, ctx) => {
|
|
380
|
+
turnTools++;
|
|
332
381
|
const name = event.toolName;
|
|
333
382
|
const input = (event.input ?? {});
|
|
334
383
|
// Previews and the real call must see the same, repaired arguments.
|
|
@@ -365,7 +414,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
365
414
|
return undefined;
|
|
366
415
|
if (!ctx.hasUI)
|
|
367
416
|
return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
368
|
-
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}.`));
|
|
369
418
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
|
|
370
419
|
}
|
|
371
420
|
if (!ctx.hasUI) {
|
|
@@ -378,7 +427,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
378
427
|
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
379
428
|
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
380
429
|
}
|
|
381
|
-
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));
|
|
382
431
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
383
432
|
}
|
|
384
433
|
// kind === "preview": the server computes the change first, then the user decides.
|
|
@@ -393,12 +442,13 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
393
442
|
}
|
|
394
443
|
if (preview.isError)
|
|
395
444
|
return { block: true, reason: preview.text };
|
|
396
|
-
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}
|
|
397
|
-
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);
|
|
398
447
|
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
399
448
|
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
400
449
|
for (let round = 0; round < 3; round++) {
|
|
401
|
-
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 });
|
|
402
452
|
if (!ok)
|
|
403
453
|
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
404
454
|
let again;
|
|
@@ -410,7 +460,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
410
460
|
}
|
|
411
461
|
if (again.isError)
|
|
412
462
|
return { block: true, reason: again.text };
|
|
413
|
-
const now = formatPreview(name, again.data ?? again.text);
|
|
463
|
+
const now = formatPreview(name, again.data ?? again.text, lang);
|
|
414
464
|
if (now === shown)
|
|
415
465
|
return undefined;
|
|
416
466
|
shown = now;
|
|
@@ -456,7 +506,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
456
506
|
}
|
|
457
507
|
if (!ctx.hasUI)
|
|
458
508
|
return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
459
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
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}.`));
|
|
460
510
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
461
511
|
}
|
|
462
512
|
async function benchGate(name, input, kind, cwd, signal) {
|
|
@@ -495,14 +545,14 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
495
545
|
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
496
546
|
handler: async (_args, ctx) => {
|
|
497
547
|
await applyMode(ctx, "librarian", true);
|
|
498
|
-
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");
|
|
499
549
|
},
|
|
500
550
|
});
|
|
501
551
|
pi.registerCommand("researcher", {
|
|
502
552
|
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
503
553
|
handler: async (_args, ctx) => {
|
|
504
554
|
await applyMode(ctx, "researcher", true);
|
|
505
|
-
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");
|
|
506
556
|
},
|
|
507
557
|
});
|
|
508
558
|
pi.registerCommand("profile", {
|
|
@@ -604,12 +654,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
604
654
|
ctx.ui.notify(preview.text, "warning");
|
|
605
655
|
return;
|
|
606
656
|
}
|
|
607
|
-
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)));
|
|
608
658
|
if (!ok)
|
|
609
659
|
return;
|
|
610
660
|
const res = await current().call("zotero_undo", { ...input, dry_run: false });
|
|
611
661
|
const d = (res.data ?? {});
|
|
612
|
-
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");
|
|
613
663
|
},
|
|
614
664
|
});
|
|
615
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,7 +21,7 @@ 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, RECOMMENDED_MODELS, 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
26
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
27
27
|
import { obsidianRoot } from "./layout.js";
|
|
@@ -47,10 +47,7 @@ export function parseWebArgs(args) {
|
|
|
47
47
|
}
|
|
48
48
|
return o;
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
export function uiLanguage(language) {
|
|
52
|
-
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
53
|
-
}
|
|
50
|
+
export { uiLanguage } from "./config.js";
|
|
54
51
|
// ---------------------------------------------------------------- providers (API keys)
|
|
55
52
|
export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
56
53
|
/** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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/roles/librarian.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
You are Sub-Sub, the librarian for {{user}}'s Zotero library. {{about}} Reply briefly. No emojis.
|
|
2
2
|
|
|
3
|
-
You change the library through the zotero_ tools.
|
|
3
|
+
You change the library through the zotero_ tools. Outside metadata reaches you through the import and repair tools, zotero_find_reference and, when {{user}} has set it up, zotero_web_search. You do not search the literature. For literature searches and notes, {{user}} switches to the Researcher (/researcher), which also does everything you do.
|
|
4
4
|
|
|
5
5
|
## How changes work
|
|
6
6
|
|
|
@@ -8,6 +8,18 @@ You change the library through the zotero_ tools. You have no web access; outsid
|
|
|
8
8
|
- If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
|
|
9
9
|
- Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
|
|
10
10
|
- Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
|
|
11
|
+
- You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
|
|
12
|
+
|
|
13
|
+
## Files and notes without a parent item
|
|
14
|
+
|
|
15
|
+
PDFs, scans and notes that sit alone in the library (no parent item) are not found by zotero_find_items. To give them a proper reference:
|
|
16
|
+
|
|
17
|
+
1. zotero_standalone_items lists them (kind="attachment" for files only).
|
|
18
|
+
2. For each file, read it with zotero_get_fulltext(key): the first pages usually give title, authors, date, publication, issue and publisher. If there is no indexed text, say that {{user}} can right-click the file in Zotero and choose Reindex Item; a scan without a text layer cannot be read. A clear file name ("Weird_Tales_-_v31n02_[1938-02].pdf") can still identify the work: propose the item and say that it rests on the file name only.
|
|
19
|
+
3. If the text gives a DOI or ISBN, use it. Otherwise look the work up with zotero_find_reference (Crossref, Google Books, Internet Archive, Open Library, Wikidata; good for magazines, books and reports) and, if it is available, zotero_web_search. Put only the title, the publication and the date in a query, never other text from the file.
|
|
20
|
+
4. Accept a candidate only when it agrees with the file's own text (title, date, issue). Search results are often near misses. When nothing agrees, build the item from the file's text alone, and say which fields you could not confirm.
|
|
21
|
+
5. Call zotero_set_parent_items with up to 25 files: identifier= for a DOI, PMID or ISBN; item_type + fields + creators for anything else. A magazine article is magazineArticle (publicationTitle, date, issue, pages); a whole issue is a magazineArticle with the issue title, or a document; a newspaper article is newspaperArticle; a book is book; a report is report. Use parent_key when the work is already in the library. A person is a creator with lastName and firstName; name is only for an organisation.
|
|
22
|
+
6. The server uses an item that is already in the library instead of making a copy. Report what was new and what was existing, and the journal_id.
|
|
11
23
|
|
|
12
24
|
## How to tag
|
|
13
25
|
|
package/roles/researcher.md
CHANGED
|
@@ -22,6 +22,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
22
22
|
- If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
|
|
23
23
|
- Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
|
|
24
24
|
- Start work on the library with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
|
|
25
|
+
- You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
|
|
25
26
|
- Older shared rules may say that the researcher cannot change the library and hands works over to the librarian. That was before version 0.10: you do both.
|
|
26
27
|
|
|
27
28
|
## Questions about the library
|
|
@@ -44,6 +45,17 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
44
45
|
- zotero_check_retractions: call again while still_unchecked > 0. Propose the tag _retracted for retracted items.
|
|
45
46
|
- zotero_missing_pdfs, then zotero_attach_oa_pdfs in batches of 25. Say when a file is an accepted manuscript.
|
|
46
47
|
|
|
48
|
+
## Files and notes without a parent item
|
|
49
|
+
|
|
50
|
+
PDFs, scans and notes that sit alone in the library (no parent item) are not found by zotero_find_items. To give them a proper reference:
|
|
51
|
+
|
|
52
|
+
1. zotero_standalone_items lists them (kind="attachment" for files only).
|
|
53
|
+
2. For each file, read it with zotero_get_fulltext(key): the first pages usually give title, authors, date, publication, issue and publisher. If there is no indexed text, say that {{user}} can right-click the file in Zotero and choose Reindex Item; a scan without a text layer cannot be read. A clear file name ("Weird_Tales_-_v31n02_[1938-02].pdf") can still identify the work: propose the item and say that it rests on the file name only.
|
|
54
|
+
3. If the text gives a DOI or ISBN, use it. Otherwise look the work up with zotero_find_reference (Crossref, Google Books, Internet Archive, Open Library, Wikidata; good for magazines, books and reports) and, if it is available, zotero_web_search. Put only the title, the publication and the date in a query, never other text from the file.
|
|
55
|
+
4. Accept a candidate only when it agrees with the file's own text (title, date, issue). Search results are often near misses. When nothing agrees, build the item from the file's text alone, and say which fields you could not confirm.
|
|
56
|
+
5. Call zotero_set_parent_items with up to 25 files: identifier= for a DOI, PMID or ISBN; item_type + fields + creators for anything else. A magazine article is magazineArticle (publicationTitle, date, issue, pages); a whole issue is a magazineArticle with the issue title, or a document; a newspaper article is newspaperArticle; a book is book; a report is report. Use parent_key when the work is already in the library. A person is a creator with lastName and firstName; name is only for an organisation.
|
|
57
|
+
6. The server uses an item that is already in the library instead of making a copy. Report what was new and what was existing, and the journal_id.
|
|
58
|
+
|
|
47
59
|
## How to tag
|
|
48
60
|
|
|
49
61
|
- A tag must be supported by the item itself: title and abstract, or the full text. If you are unsure, leave the tag out. Fewer correct tags are better than many loose ones.
|
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";
|
|
@@ -463,7 +463,8 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
|
|
|
463
463
|
dois: dois.length,
|
|
464
464
|
pmids: pmids.length,
|
|
465
465
|
ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
|
|
466
|
-
|
|
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)),
|
|
467
468
|
multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
|
|
468
469
|
fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
|
|
469
470
|
starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
|
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
|
@@ -40,22 +40,32 @@ export const FREE_MODELS: Record<Mode, string> = {
|
|
|
40
40
|
librarian: "google/gemini-3.1-flash-lite",
|
|
41
41
|
researcher: "google/gemini-3.1-flash-lite",
|
|
42
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
|
+
};
|
|
43
48
|
/**
|
|
44
49
|
* Models marked in the model dialog, from the model tests (see the Models page). `why` is a
|
|
45
50
|
* key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
|
|
46
|
-
* free (tested, works on a free tier).
|
|
51
|
+
* free (tested, works on a free tier), eu (tested, servers in the EU).
|
|
47
52
|
*/
|
|
48
|
-
export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" }> = [
|
|
53
|
+
export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" | "eu" }> = [
|
|
49
54
|
{ spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
|
|
50
55
|
{ spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
|
|
51
56
|
{ spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
|
|
57
|
+
{ spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
|
|
52
58
|
{ spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
|
|
53
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
|
+
}
|
|
54
64
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
55
65
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
56
66
|
|
|
57
67
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
58
|
-
export const SERVER_VERSION = "0.5.
|
|
68
|
+
export const SERVER_VERSION = "0.5.5";
|
|
59
69
|
|
|
60
70
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
61
71
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -98,6 +108,8 @@ export interface SubsubConfig {
|
|
|
98
108
|
addons?: string[];
|
|
99
109
|
/** Local Starbuck folder (used when it has a pyproject.toml). */
|
|
100
110
|
starbuckDir?: string;
|
|
111
|
+
/** The pilot's usage log, kept on this computer only (see usage.ts). Off by default. */
|
|
112
|
+
usageLog?: boolean;
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
export function expand(p: string, home?: string): string {
|
|
@@ -209,6 +221,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
209
221
|
themes,
|
|
210
222
|
addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
|
|
211
223
|
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
224
|
+
usageLog: user.usageLog === true,
|
|
212
225
|
};
|
|
213
226
|
}
|
|
214
227
|
|
package/src/init.ts
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
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|google-free.
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { dirname, join, resolve } from "node:path";
|
|
28
28
|
import { createInterface } from "node:readline/promises";
|
|
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
import {
|
|
31
31
|
configPath,
|
|
32
32
|
DEFAULT_MODELS,
|
|
33
|
+
EU_MODELS,
|
|
33
34
|
FREE_MODELS,
|
|
34
35
|
expand,
|
|
35
36
|
isProfile,
|
|
@@ -187,8 +188,22 @@ export async function runInit(
|
|
|
187
188
|
);
|
|
188
189
|
const fmup = setup === "fmup";
|
|
189
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
|
+
|
|
190
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.");
|
|
191
|
-
const profileDefault = flags.profile ?? existing.profile ?? (
|
|
206
|
+
const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
|
|
192
207
|
const profile = await io.choose(
|
|
193
208
|
"Profile",
|
|
194
209
|
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
@@ -220,6 +235,13 @@ export async function runInit(
|
|
|
220
235
|
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it with its requests to PubMed, Europe PMC, OpenAlex, Crossref and Unpaywall, as they ask, and to nobody else.");
|
|
221
236
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
222
237
|
|
|
238
|
+
explain(
|
|
239
|
+
"Web search",
|
|
240
|
+
"Optional. To identify files without a parent item, such as magazines and reports, Sub-Sub can search the web through Brave Search. Brave needs a key: https://brave.com/search/api/ (free tier; a card is asked at sign-up). Searches then go to Brave, in the United States. Without a key Sub-Sub uses Crossref, Google Books, Internet Archive, Open Library and Wikidata.",
|
|
241
|
+
);
|
|
242
|
+
const hadBrave = Boolean(envValues.BRAVE_API_KEY);
|
|
243
|
+
const brave = (await io.ask(hadBrave ? "Brave Search key (Enter keeps the saved key)" : "Brave Search key (Enter for none)", flags.brave ?? "")).trim();
|
|
244
|
+
|
|
223
245
|
explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. Sub-Sub then has /verify.");
|
|
224
246
|
const starbuck = await io.choose(
|
|
225
247
|
"Reference checks",
|
|
@@ -230,6 +252,19 @@ export async function runInit(
|
|
|
230
252
|
flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
|
|
231
253
|
);
|
|
232
254
|
|
|
255
|
+
explain(
|
|
256
|
+
"Usage log for the pilot",
|
|
257
|
+
"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.",
|
|
258
|
+
);
|
|
259
|
+
const usage = await io.choose(
|
|
260
|
+
"Usage log",
|
|
261
|
+
[
|
|
262
|
+
{ value: "off", label: "Off" },
|
|
263
|
+
{ value: "on", label: "On (for the pilot)" },
|
|
264
|
+
],
|
|
265
|
+
flags.usage ?? (existing.usageLog === true ? "on" : "off"),
|
|
266
|
+
);
|
|
267
|
+
|
|
233
268
|
explain(
|
|
234
269
|
"Models",
|
|
235
270
|
"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).",
|
|
@@ -240,6 +275,7 @@ export async function runInit(
|
|
|
240
275
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
241
276
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
242
277
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
278
|
+
{ value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
|
|
243
279
|
{ 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)` },
|
|
244
280
|
];
|
|
245
281
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
@@ -279,7 +315,11 @@ export async function runInit(
|
|
|
279
315
|
const alerts = !existsSync(alertsTarget) && existsSync(legacyAlerts) ? legacyAlerts : alertsTarget;
|
|
280
316
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
281
317
|
const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
|
|
282
|
-
writeFileSync(
|
|
318
|
+
writeFileSync(
|
|
319
|
+
envFile,
|
|
320
|
+
upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined, BRAVE_API_KEY: brave || undefined }),
|
|
321
|
+
);
|
|
322
|
+
if (brave || hadBrave) chmodSync(envFile, 0o600); // the file holds a key
|
|
283
323
|
|
|
284
324
|
const patch: Record<string, unknown> = {
|
|
285
325
|
profile,
|
|
@@ -291,6 +331,7 @@ export async function runInit(
|
|
|
291
331
|
envFile,
|
|
292
332
|
libraryChanges: undefined,
|
|
293
333
|
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
334
|
+
usageLog: usage === "on" ? true : undefined,
|
|
294
335
|
};
|
|
295
336
|
// A model per mode; "model" (0.10, one for both) is replaced.
|
|
296
337
|
patch.model = undefined;
|
|
@@ -298,6 +339,7 @@ export async function runInit(
|
|
|
298
339
|
if (models === "later") patch.models = {};
|
|
299
340
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
300
341
|
if (models === "google-free") patch.models = FREE_MODELS;
|
|
342
|
+
if (models === "mistral-eu") patch.models = EU_MODELS;
|
|
301
343
|
saveConfig(patch, initEnv);
|
|
302
344
|
let keyNote = "";
|
|
303
345
|
if (googleKey) {
|
|
@@ -307,6 +349,8 @@ export async function runInit(
|
|
|
307
349
|
} catch (err) {
|
|
308
350
|
keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
|
|
309
351
|
}
|
|
352
|
+
} else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
|
|
353
|
+
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).`;
|
|
310
354
|
} else if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
311
355
|
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.";
|
|
312
356
|
}
|