@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 ADDED
@@ -0,0 +1,64 @@
1
+ # Changes
2
+
3
+ Sub-Sub follows the version numbers of npm: a change in the second number (0.12) can change how you use it; a change in the third (0.12.1) only fixes. Until the pilot results are in, new versions are fixes only, apart from changes that the pilot asks for.
4
+
5
+ ## 0.12.0 (2026-10-07)
6
+
7
+ For new users:
8
+
9
+ - `subsub init` asks whether you are a student. Students start with the Reader profile: Sub-Sub helps them read and does not write summaries or syntheses unless asked.
10
+ - The installers end with `subsub doctor`, which also downloads the Zotero server, so the first start is quicker.
11
+ - The browser view shows what is still missing on the first start (Zotero, a model, the servers), each with its fix.
12
+ - A tested EU option: Mistral, with servers in the European Union. The key form says where each provider processes your data.
13
+
14
+ Library changes:
15
+
16
+ - Every applied change has an Undo button in the browser view. It previews first, like `/undo`.
17
+ - New collections are journaled too: undo moves them to the Zotero trash. (Zotero server 0.5.4.)
18
+ - A preview of more than 10 items starts with a summary: each tag and field, and how many items it changes.
19
+ - Previews, approvals and notices are in European Portuguese when the language is Portuguese.
20
+
21
+ Free tiers and errors:
22
+
23
+ - After a per-minute limit (Google's free tier), Sub-Sub waits as long as the provider says and continues the task, up to three times. pi does not retry these errors, because their text mentions billing.
24
+
25
+ For the pilot:
26
+
27
+ - An optional usage log, kept on your computer (`subsub usage`): times, counts, commands and tool names, never what you wrote. It is off unless you turn it on in `subsub init`.
28
+ - The pilot page has a task that compares the same work with and without Sub-Sub.
29
+
30
+ Robustness:
31
+
32
+ - Sub-Sub accepts pi patch releases only (~1.0.4). A weekly test runs against pi's latest release, so a change in pi shows before users meet it.
33
+ - Randomised tests of the path gate: 3000 spellings of paths (dot segments, case, accents, links) must agree with the rules.
34
+
35
+ Models page: the limits of the tests come first. Most tested models are adequate; the table is not a ranking.
36
+
37
+ ## 0.11.4 (2026-10-07)
38
+
39
+ - A free way to try Sub-Sub: Google AI Studio, with gemini-3.1-flash-lite in both modes (`subsub init`, or the model dialog).
40
+ - Model dialog: search, and the tested models first, marked.
41
+ - Provider errors in plain words: limit reached, provider busy, reply blocked, key refused.
42
+
43
+ ## 0.11.3 (2026-10-07)
44
+
45
+ - `subsub init` asks about the FMUP set-up after the folder.
46
+ - Created notes keep raw HTML as text; the preview shows the whole note (Zotero server 0.5.3).
47
+
48
+ ## 0.11.2 (2026-10-07)
49
+
50
+ - The mode's model is applied again at start (a regression in 0.11.1).
51
+ - After Yes, the preview is computed again; if the library changed meanwhile, the new preview is shown first.
52
+ - Case and Unicode spellings no longer get round protected and key files.
53
+
54
+ ## 0.11.1 (2026-10-07)
55
+
56
+ - Writes into hidden folders always ask; the model cannot read files with keys.
57
+ - pi's install report is off.
58
+
59
+ ## 0.11.0 (2026-10-06)
60
+
61
+ - Two modes again: the Librarian manages the library; the Researcher does that and the research.
62
+ - Defaults from the October model test: mimo-v2.6-flash for the Librarian, mimo-v2.6-pro for the Researcher.
63
+
64
+ Earlier versions: see the git history.
package/README.md CHANGED
@@ -26,12 +26,12 @@ Every library change works the same way in both modes:
26
26
 
27
27
  - The Zotero server computes the change first. Sub-Sub shows it to you (added tags in green, removed tags in red) and applies it only when you select Yes. The check is in the program, so the model cannot skip it.
28
28
  - If you edit an item in Zotero while the preview is open, Sub-Sub shows you the new preview before it changes anything. Each change also sends the item's version, so a field you edit at the moment of writing is not overwritten.
29
- - Every change to an item is journaled. `/undo` reverts it. (A new collection is not; remove it in Zotero if you do not want it.)
29
+ - Every change is journaled, including new collections. `/undo` reverts the last one, and the browser view shows an Undo button under each change.
30
30
  - Nothing is deleted: items go to the Zotero trash. Only tags from your tag list can be added.
31
31
 
32
32
  ## Profiles
33
33
 
34
- The profile sets how much Sub-Sub writes for you and which tools it offers. Choose it in `subsub init`, or change it with `/profile`.
34
+ The profile sets how much Sub-Sub writes for you and which tools it offers. Choose it in `subsub init`, or change it with `/profile`. Students start with Reader.
35
35
 
36
36
  | Profile | What Sub-Sub does |
37
37
  |---|---|
@@ -46,7 +46,7 @@ The [Profiles page](https://subsub.tiagojacinto.eu/profiles/) lists the tools an
46
46
 
47
47
  - Zotero 10 or later, running, with Settings > Advanced > "Allow other applications on this computer to communicate with Zotero" on.
48
48
  - Node.js 22.19 or later, and [uv](https://docs.astral.sh/uv/). The installers add both if they are missing.
49
- - An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model.
49
+ - An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model. To try Sub-Sub without paying, a free Google AI Studio key is enough (see [Use Sub-Sub for free](https://subsub.tiagojacinto.eu/models/#use-sub-sub-for-free)).
50
50
  - macOS, Linux or Windows.
51
51
 
52
52
  ## Install
@@ -129,13 +129,14 @@ subsub review apply 13-31
129
129
 
130
130
  | Setting | Meaning | Default |
131
131
  |---|---|---|
132
- | `profile` | `reader`, `scholar`, `author` or `editor` | `scholar` |
132
+ | `profile` | `reader`, `scholar`, `author` or `editor` | `reader` for students, `scholar` for others (`subsub init` asks) |
133
133
  | `userName`, `about` | Your name and one line about you, for the replies | none |
134
134
  | `language` | Language of replies and notes, or `auto` for the language you write in | `English` |
135
135
  | `vault` | The Sub-Sub folder | from the server settings |
136
- | `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
136
+ | `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub. The free choice in `subsub init` sets `google/gemini-3.1-flash-lite` for both | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
137
137
  | `defaultMode` | `researcher` or `librarian` | `researcher` |
138
138
  | `addons` | `["starbuck"]` adds reference checks | `[]` |
139
+ | `usageLog` | `true` keeps the pilot's usage log on this computer (`subsub usage`) | off |
139
140
  | `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
140
141
  | `starbuckDir` | A local copy of Starbuck, for development | the pinned release |
141
142
  | `look`, `quotes`, `themes` | The terminal header, the Moby-Dick quotations, and the terminal colours (`subsub-glauca`, `subsub-try-works`, or `false` for your pi theme) | on, on, `subsub-glauca` |
@@ -172,6 +173,10 @@ npm run build
172
173
 
173
174
  End-to-end tests (real pi and Python servers, a fake Zotero, a scripted model): `ZLM_DIR=/path/to/zotero-local-mcp npm run test:e2e`.
174
175
 
176
+ ## Changes
177
+
178
+ See [CHANGELOG.md](CHANGELOG.md).
179
+
175
180
  ## Licence
176
181
 
177
182
  [MIT](LICENSE), Tiago Jacinto. The `/lit`, `/compare` and `/review` prompts adapt text from [Feynman](https://github.com/Companion-Inc/feynman) (MIT); see [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES). IBM Plex fonts: SIL Open Font License.
package/dist/bench.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * writes are allowed only in the run folder.
14
14
  */
15
15
  import { spawn, spawnSync } from "node:child_process";
16
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { expand, loadConfig, starbuckCommand, starbuckEnabled } from "./config.js";
@@ -80,7 +80,10 @@ export function promptFor(task, t, code, out) {
80
80
  const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end", "entry_appended", "agent_end", "agent_settled", "auto_retry_start", "extension_error"]);
81
81
  export function runOne(opts) {
82
82
  mkdirSync(opts.out, { recursive: true });
83
- const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
83
+ // "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
84
+ const slash = opts.model.indexOf("/");
85
+ const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
86
+ const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
84
87
  if (opts.role === "librarian")
85
88
  args.push("--librarian");
86
89
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
@@ -405,7 +408,8 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
405
408
  dois: dois.length,
406
409
  pmids: pmids.length,
407
410
  ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
408
- screening_log: existsSync(plans) && readdirSync(plans).some((f) => /screening/i.test(f)),
411
+ // A model may write .plans as a file instead of a folder: then there is no screening log.
412
+ screening_log: existsSync(plans) && statSync(plans).isDirectory() && readdirSync(plans).some((f) => /screening/i.test(f)),
409
413
  multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
410
414
  fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
411
415
  starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
@@ -558,7 +562,9 @@ export async function main(argv = process.argv.slice(2)) {
558
562
  if (!prompt)
559
563
  return;
560
564
  const cf = join(cfgDir, `${j.code}.json`);
561
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
565
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
566
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
567
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
562
568
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
563
569
  n++;
564
570
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/dist/cli.js CHANGED
@@ -151,6 +151,7 @@ Usage:
151
151
  subsub web Open Sub-Sub in the browser
152
152
  subsub init Set up or change the settings (folder, tag list, profile, models)
153
153
  subsub doctor Check the set-up; each line marked FIX says what to do
154
+ subsub usage The pilot's usage log (if turned on): a summary and where the file is
154
155
  subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
155
156
  subsub review preview|apply <note>
156
157
  Check or apply a tag review note from Inbox/
@@ -198,6 +199,11 @@ export async function run(argv = process.argv.slice(2)) {
198
199
  process.exitCode = await doctorMain(env);
199
200
  return;
200
201
  }
202
+ if (args[0] === "usage") {
203
+ const { usageMain } = await import("./usage.js");
204
+ process.exitCode = usageMain(env);
205
+ return;
206
+ }
201
207
  if (args[0] === "review") {
202
208
  const r = reviewCommand(args.slice(1), loadConfig(env), env);
203
209
  const res = spawnSync(r.command, r.args, { env: r.env, stdio: "inherit" });
package/dist/config.js CHANGED
@@ -31,9 +31,35 @@ export const DEFAULT_MODELS = {
31
31
  librarian: "opencode-go/mimo-v2.6-flash",
32
32
  researcher: "opencode-go/mimo-v2.6-pro",
33
33
  };
34
+ /** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
35
+ export const FREE_MODELS = {
36
+ librarian: "google/gemini-3.1-flash-lite",
37
+ researcher: "google/gemini-3.1-flash-lite",
38
+ };
39
+ /** The tested EU-hosted models (Mistral, servers in the EU; see the Models page). */
40
+ export const EU_MODELS = {
41
+ librarian: "mistral/mistral-medium-3.5",
42
+ researcher: "mistral/mistral-medium-3.5",
43
+ };
44
+ /**
45
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
46
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
47
+ * free (tested, works on a free tier), eu (tested, servers in the EU).
48
+ */
49
+ export const RECOMMENDED_MODELS = [
50
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
51
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
52
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
53
+ { spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
54
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
55
+ ];
56
+ /** "pt" when the language setting is Portuguese, else "en": the language of the interface. */
57
+ export function uiLanguage(language) {
58
+ return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
59
+ }
34
60
  export const PROFILES = ["reader", "scholar", "author", "editor"];
35
61
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
36
- export const SERVER_VERSION = "0.5.3";
62
+ export const SERVER_VERSION = "0.5.4";
37
63
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
38
64
  export const STARBUCK_VERSION = "0.2.0";
39
65
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
@@ -147,6 +173,7 @@ export function loadConfig(env = process.env) {
147
173
  themes,
148
174
  addons: Array.isArray(user.addons) ? user.addons.filter((a) => typeof a === "string") : [],
149
175
  starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
176
+ usageLog: user.usageLog === true,
150
177
  };
151
178
  }
152
179
  /** uv is often missing from PATH when pi is started outside a login shell. */
package/dist/init.js CHANGED
@@ -19,16 +19,17 @@
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
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { dirname, join, resolve } from "node:path";
27
27
  import { createInterface } from "node:readline/promises";
28
28
  import { fileURLToPath } from "node:url";
29
- import { configPath, DEFAULT_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
29
+ import { configPath, DEFAULT_MODELS, EU_MODELS, FREE_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
30
30
  import { defaultRunner, starbuckCheck } from "./doctor.js";
31
31
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
32
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
32
33
  import { PROFILE_SPECS } from "./profiles.js";
33
34
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
34
35
  export const STARTER_TAGS = {
@@ -131,8 +132,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
131
132
  { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
132
133
  ], flags.setup ?? existing.setup ?? "standard");
133
134
  const fmup = setup === "fmup";
135
+ // Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
136
+ let student = fmup;
137
+ if (!flags.profile && !existing.profile && !fmup) {
138
+ 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.");
139
+ student = (await io.choose("You are", [
140
+ { value: "student", label: "A student" },
141
+ { value: "researcher", label: "A researcher, teacher or clinician" },
142
+ ], flags.student === "no" ? "researcher" : "student")) === "student";
143
+ }
134
144
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
135
- const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
145
+ const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
136
146
  const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
137
147
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
138
148
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
@@ -159,6 +169,11 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
159
169
  { value: "off", label: "Off" },
160
170
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
161
171
  ], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
172
+ explain("Usage log for the pilot", "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.");
173
+ const usage = await io.choose("Usage log", [
174
+ { value: "off", label: "Off" },
175
+ { value: "on", label: "On (for the pilot)" },
176
+ ], flags.usage ?? (existing.usageLog === true ? "on" : "off"));
162
177
  explain("Models", "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).");
163
178
  const hadModel = "model" in existing || "models" in existing;
164
179
  const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
@@ -166,10 +181,18 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
166
181
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
167
182
  { value: "later", label: "Choose later in Sub-Sub" },
168
183
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
184
+ { value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
185
+ { 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)` },
169
186
  ];
170
187
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
171
188
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
172
189
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
190
+ let googleKey = "";
191
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
192
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
193
+ explain("Free Google key", `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.`);
194
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
195
+ }
173
196
  // ---- write
174
197
  if (move === "yes")
175
198
  applyMoves(moves);
@@ -206,6 +229,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
206
229
  envFile,
207
230
  libraryChanges: undefined,
208
231
  addons: withStarbuck(current.addons, starbuck === "on"),
232
+ usageLog: usage === "on" ? true : undefined,
209
233
  };
210
234
  // A model per mode; "model" (0.10, one for both) is replaced.
211
235
  patch.model = undefined;
@@ -215,7 +239,27 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
215
239
  patch.models = {};
216
240
  if (models === "opencode-go")
217
241
  patch.models = TESTED_MODELS;
242
+ if (models === "google-free")
243
+ patch.models = FREE_MODELS;
244
+ if (models === "mistral-eu")
245
+ patch.models = EU_MODELS;
218
246
  saveConfig(patch, initEnv);
247
+ let keyNote = "";
248
+ if (googleKey) {
249
+ try {
250
+ saveApiKey(agentDir, "google", googleKey);
251
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
252
+ }
253
+ catch (err) {
254
+ keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
255
+ }
256
+ }
257
+ else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
258
+ 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).`;
259
+ }
260
+ else if (models === "google-free" && !hasProvider(agentDir, "google")) {
261
+ 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.";
262
+ }
219
263
  const zotero = await zoteroReachable(deps.fetch);
220
264
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
221
265
  io.say("");
@@ -223,6 +267,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
223
267
  io.say(`Saved: ${envFile}`);
224
268
  for (const c of created)
225
269
  io.say(`Created: ${c}`);
270
+ if (keyNote)
271
+ io.say(keyNote);
226
272
  if (move === "yes")
227
273
  io.say(`Moved: ${moves.length} file(s) into ${notes}`);
228
274
  for (const d of leftBehind(from, notes))
package/dist/keys.js ADDED
@@ -0,0 +1,81 @@
1
+ /** Model providers that take an API key, and plain words for their common errors. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * `where`: where the provider processes the data, as a key into the web view's strings
6
+ * (eu, us, cn, varies). Shown next to the key form, so the choice of provider is an informed one.
7
+ */
8
+ export const KEY_PROVIDERS = [
9
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth", where: "varies" },
10
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys", where: "varies" },
11
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys", where: "us" },
12
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys", where: "us" },
13
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey", where: "us" },
14
+ { id: "mistral", label: "Mistral (EU)", url: "https://console.mistral.ai/api-keys", where: "eu" },
15
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys", where: "cn" },
16
+ ];
17
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
18
+ export function saveApiKey(agentDir, provider, key) {
19
+ if (!KEY_PROVIDERS.some((p) => p.id === provider))
20
+ throw new Error(`Unknown provider: ${provider}`);
21
+ const clean = key.trim();
22
+ if (!clean || /\s/.test(clean) || clean.length > 400)
23
+ throw new Error("That does not look like an API key.");
24
+ mkdirSync(agentDir, { recursive: true });
25
+ const file = join(agentDir, "auth.json");
26
+ let data = {};
27
+ if (existsSync(file)) {
28
+ try {
29
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
30
+ }
31
+ catch {
32
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
33
+ }
34
+ }
35
+ data[provider] = { type: "api_key", key: clean };
36
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
37
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
38
+ }
39
+ /** What a provider's error means for the user, or undefined when it is something else. */
40
+ export function providerTrouble(text) {
41
+ if (!text)
42
+ return undefined;
43
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text))
44
+ return "quota";
45
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text))
46
+ return "busy";
47
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text))
48
+ return "blocked";
49
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text))
50
+ return "key";
51
+ return undefined;
52
+ }
53
+ /** The words for each kind in notices (the web view has its own, in web/i18n.js). */
54
+ export const TROUBLE_TEXT_PT = {
55
+ quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou use outro modelo (/model).",
56
+ busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou use outro modelo (/model).",
57
+ blocked: "O fornecedor do modelo interrompeu a resposta (por exemplo, por repetir uma fonte palavra por palavra). Peça outra vez por outras palavras ou use outro modelo (/model).",
58
+ key: "O fornecedor do modelo não aceitou a chave da API. Verifique-a ou introduza uma nova com /login.",
59
+ };
60
+ export const TROUBLE_TEXT = {
61
+ quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or use another model (/model).",
62
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
63
+ blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
64
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
65
+ };
66
+ /**
67
+ * Seconds to wait before a per-minute limit lets the request through again, or undefined when the
68
+ * error is not a per-minute limit (a daily limit, a spent balance, anything else). pi does not
69
+ * retry Google's per-minute 429, because its text mentions billing.
70
+ */
71
+ export function perMinuteWait(text) {
72
+ if (!text || providerTrouble(text) !== "quota")
73
+ return undefined;
74
+ if (/PerDay|per day|daily/i.test(text))
75
+ return undefined;
76
+ const delay = /retryDelay\\*"\s*:\s*\\*"(\d+(?:\.\d+)?)s/.exec(text);
77
+ const seconds = delay?.[1] ?? /retry in (\d+(?:\.\d+)?)s/i.exec(text)?.[1];
78
+ if (seconds)
79
+ return Math.min(120, Math.ceil(Number(seconds)) + 2);
80
+ return /PerMinute|per minute/i.test(text) ? 60 : undefined;
81
+ }
package/dist/preview.js CHANGED
@@ -3,23 +3,76 @@
3
3
  * reads in the approval dialog. Plain ASCII, at most about 20 lines.
4
4
  */
5
5
  const MAX_LINES = 14;
6
+ /** The words of the preview in English and European Portuguese; tags, titles and values stay as they are. */
7
+ const WORDS = {
8
+ en: {
9
+ more: (n) => `... and ${n} more`,
10
+ moreChars: (n) => `[${n} more characters not shown]`,
11
+ empty: "(empty)",
12
+ change: "(change)",
13
+ undo: "Undo",
14
+ already: "ALREADY APPLIED",
15
+ drift: (n) => `${n} item(s) changed after the note was written; the note overwrites them:`,
16
+ now: "now",
17
+ none: "(none)",
18
+ wouldChange: (n) => `${n} item(s) would change.`,
19
+ inShort: (listed, total) => (listed < total ? `In short (in the ${listed} items listed):` : "In short:"),
20
+ items: "Items:",
21
+ itemsN: (n) => `${n} item(s)`,
22
+ changed: "changed",
23
+ wouldImport: (n) => `${n} item(s) would be imported:`,
24
+ noAbstract: "(no abstract)",
25
+ wouldAttach: (n) => `${n} PDF(s) would be attached:`,
26
+ newCollection: "New collection",
27
+ inside: "inside",
28
+ noteOn: "Note on",
29
+ link: "Link",
30
+ skipped: "Skipped",
31
+ errors: "Errors",
32
+ },
33
+ pt: {
34
+ more: (n) => `... e mais ${n}`,
35
+ moreChars: (n) => `[mais ${n} caracteres não mostrados]`,
36
+ empty: "(vazio)",
37
+ change: "(alteração)",
38
+ undo: "Anular",
39
+ already: "JÁ APLICADO",
40
+ drift: (n) => `${n} item(ns) mudaram depois de a nota ser escrita; a nota substitui-os:`,
41
+ now: "agora",
42
+ none: "(nenhuma)",
43
+ wouldChange: (n) => `${n} item(ns) vão mudar.`,
44
+ inShort: (listed, total) => (listed < total ? `Em resumo (nos ${listed} itens listados):` : "Em resumo:"),
45
+ items: "Itens:",
46
+ itemsN: (n) => `${n} item(ns)`,
47
+ changed: "alterado",
48
+ wouldImport: (n) => `${n} item(ns) vão ser importados:`,
49
+ noAbstract: "(sem resumo)",
50
+ wouldAttach: (n) => `${n} PDF(s) vão ser anexados:`,
51
+ newCollection: "Nova coleção",
52
+ inside: "dentro de",
53
+ noteOn: "Nota sobre",
54
+ link: "Ligação",
55
+ skipped: "Ignorados",
56
+ errors: "Erros",
57
+ },
58
+ };
6
59
  function s(v, n = 90) {
7
60
  const t = typeof v === "string" ? v : JSON.stringify(v);
8
61
  return t.length > n ? `${t.slice(0, n - 3)}...` : t;
9
62
  }
10
63
  /** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
11
- function whole(v, n = 3000) {
64
+ function whole(v, w, n = 3000) {
12
65
  const t = typeof v === "string" ? v : JSON.stringify(v);
13
- return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
66
+ return t.length > n ? `${t.slice(0, n)} ${w.moreChars(t.length - n)}` : t;
14
67
  }
15
- function list(rows, total) {
68
+ function list(rows, w, total) {
16
69
  const out = rows.slice(0, MAX_LINES);
17
70
  const rest = (total ?? rows.length) - out.length;
18
71
  if (rest > 0)
19
- out.push(`... and ${rest} more`);
72
+ out.push(w.more(rest));
20
73
  return out;
21
74
  }
22
- function describeChange(c) {
75
+ function describeChange(c, w) {
23
76
  const parts = [];
24
77
  if (Array.isArray(c.added) && c.added.length)
25
78
  parts.push(`+ ${c.added.join(", ")}`);
@@ -30,62 +83,84 @@ function describeChange(c) {
30
83
  continue;
31
84
  if (v && typeof v === "object" && "after" in v) {
32
85
  const b = v.before;
33
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
86
+ parts.push(`${k}: ${s(b || w.empty, 40)} -> ${whole(v.after, w)}`);
34
87
  }
35
88
  }
36
- return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
89
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || w.change}`;
37
90
  }
38
- export function formatPreview(tool, preview) {
91
+ /** For a large batch: each tag and field with the number of items it changes, so the whole is readable at once. */
92
+ function tally(changes, total, w) {
93
+ const count = new Map();
94
+ const add = (k) => count.set(k, (count.get(k) ?? 0) + 1);
95
+ for (const c of changes) {
96
+ for (const tag of c.added ?? [])
97
+ add(`+ ${tag}`);
98
+ for (const tag of c.removed ?? [])
99
+ add(`- ${tag}`);
100
+ for (const [k, v] of Object.entries(c))
101
+ if (v && typeof v === "object" && "after" in v)
102
+ add(`${k} ${w.changed}`);
103
+ }
104
+ if (!count.size)
105
+ return [];
106
+ const rank = (k) => (k.startsWith("+") ? 0 : k.startsWith("-") ? 1 : 2);
107
+ 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)}`);
108
+ return [w.inShort(changes.length, total), ...list(rows, w), w.items];
109
+ }
110
+ export function formatPreview(tool, preview, lang = "en") {
111
+ const w = WORDS[lang];
39
112
  if (!preview || typeof preview !== "object")
40
113
  return s(preview, 1500);
41
114
  const p = preview;
42
115
  const lines = [];
43
116
  if (p.undoing && typeof p.undoing === "object") {
44
117
  const u = p.undoing;
45
- lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
118
+ lines.push(`${w.undo} ${u.op}: ${s(u.summary, 80)}`);
46
119
  }
47
120
  if (Array.isArray(p.already_applied) && p.already_applied.length) {
48
- lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
121
+ lines.push(`${w.already}: ${p.already_applied.join(", ")}`);
49
122
  }
50
123
  const drift = p.changed_since_note;
51
124
  if (drift && typeof drift === "object" && typeof drift.count === "number") {
52
- lines.push(`${drift.count} item(s) changed after the note was written; the note overwrites them:`);
53
- const items = Object.entries(drift.items ?? {}).map(([k, v]) => ` ${k} now: ${v.now?.join(", ") || "(none)"}`);
125
+ lines.push(w.drift(drift.count));
126
+ const items = Object.entries(drift.items ?? {}).map(([k, v]) => ` ${k} ${w.now}: ${v.now?.join(", ") || w.none}`);
54
127
  lines.push(...items.slice(0, 4));
55
128
  }
56
129
  if (typeof p.would_change === "number") {
57
- lines.push(`${p.would_change} item(s) would change.`);
130
+ lines.push(w.wouldChange(p.would_change));
58
131
  const changes = p.changes ?? [];
59
- lines.push(...list(changes.map(describeChange), p.would_change));
132
+ if (p.would_change > 10)
133
+ lines.push(...tally(changes, p.would_change, w));
134
+ lines.push(...list(changes.map((c) => describeChange(c, w)), w, p.would_change));
60
135
  }
61
136
  if (Array.isArray(p.would_import)) {
62
- lines.push(`${p.would_import.length} item(s) would be imported:`);
63
- lines.push(...list(p.would_import.map((r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : " (no abstract)"}`)));
137
+ lines.push(w.wouldImport(p.would_import.length));
138
+ lines.push(...list(p.would_import.map((r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : ` ${w.noAbstract}`}`), w));
64
139
  }
65
140
  if (Array.isArray(p.would_attach)) {
66
- lines.push(`${p.would_attach.length} PDF(s) would be attached:`);
67
- lines.push(...list(p.would_attach.map((r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`)));
141
+ lines.push(w.wouldAttach(p.would_attach.length));
142
+ lines.push(...list(p.would_attach.map((r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`), w));
68
143
  }
69
144
  if (p.would_create && typeof p.would_create === "object") {
70
- const w = p.would_create;
71
- lines.push(`New collection: ${w.name}${w.parent ? ` (inside ${w.parent})` : ""}`);
145
+ const nc = p.would_create;
146
+ lines.push(`${w.newCollection}: ${nc.name}${nc.parent ? ` (${w.inside} ${nc.parent})` : ""}`);
72
147
  }
73
148
  if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
74
149
  if (p.item || p.parent)
75
- lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
150
+ lines.push(`${w.noteOn}: ${s(p.item ?? p.parent, 90)}`);
76
151
  if (p.summary || p.note_preview)
77
- lines.push(whole(p.summary ?? p.note_preview));
152
+ lines.push(whole(p.summary ?? p.note_preview, w));
78
153
  if (p.link)
79
- lines.push(`Link: ${p.link}`);
154
+ lines.push(`${w.link}: ${p.link}`);
80
155
  }
81
156
  const skipped = p.skipped;
82
157
  if (skipped && typeof skipped === "object" && Object.keys(skipped).length) {
83
- lines.push(`Skipped: ${Object.keys(skipped).length}`);
84
- lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`)).slice(0, 5));
158
+ lines.push(`${w.skipped}: ${Object.keys(skipped).length}`);
159
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`), w).slice(0, 5));
85
160
  }
86
161
  const errors = p.errors;
87
162
  if (errors && typeof errors === "object" && Object.keys(errors).length) {
88
- lines.push(`Errors: ${Object.keys(errors).length}`);
163
+ lines.push(`${w.errors}: ${Object.keys(errors).length}`);
89
164
  lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
90
165
  }
91
166
  if (!lines.length)