@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 ADDED
@@ -0,0 +1,78 @@
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.1 (2026-10-07)
6
+
7
+ Files and notes without a parent item (Zotero server 0.5.5):
8
+
9
+ - Sub-Sub lists them, reads each file, finds the reference and makes the parent item: from a DOI, PMID or ISBN, or from the file's own text (magazines, reports, books). An item already in the library is used instead of a copy. One preview for up to 25 files; undo puts the files back and moves the new items to the trash.
10
+ - Reference search for these files in Crossref, Google Books, Internet Archive, Open Library and Wikidata, without a key. Web search through Brave Search when you save a key in `subsub init`.
11
+ - A search of the library for attachments or notes now says that it covers regular items only, instead of finding nothing.
12
+
13
+ Also:
14
+
15
+ - Sub-Sub no longer offers to change its own programs; it says what it cannot do.
16
+ - Citekeys for long organisation names are shorter (europeancommission2026), and an item without creators takes the first main word of its title (economist2026, not the2026). Existing keys do not change.
17
+ - OpenCode's "requires Global regions" error is explained in plain words.
18
+
19
+ ## 0.12.0 (2026-10-07)
20
+
21
+ For new users:
22
+
23
+ - `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.
24
+ - The installers end with `subsub doctor`, which also downloads the Zotero server, so the first start is quicker.
25
+ - The browser view shows what is still missing on the first start (Zotero, a model, the servers), each with its fix.
26
+ - A tested EU option: Mistral, with servers in the European Union. The key form says where each provider processes your data.
27
+
28
+ Library changes:
29
+
30
+ - Every applied change has an Undo button in the browser view. It previews first, like `/undo`.
31
+ - New collections are journaled too: undo moves them to the Zotero trash. (Zotero server 0.5.4.)
32
+ - A preview of more than 10 items starts with a summary: each tag and field, and how many items it changes.
33
+ - Previews, approvals and notices are in European Portuguese when the language is Portuguese.
34
+
35
+ Free tiers and errors:
36
+
37
+ - 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.
38
+
39
+ For the pilot:
40
+
41
+ - 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`.
42
+ - The pilot page has a task that compares the same work with and without Sub-Sub.
43
+
44
+ Robustness:
45
+
46
+ - 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.
47
+ - Randomised tests of the path gate: 3000 spellings of paths (dot segments, case, accents, links) must agree with the rules.
48
+
49
+ Models page: the limits of the tests come first. Most tested models are adequate; the table is not a ranking.
50
+
51
+ ## 0.11.4 (2026-10-07)
52
+
53
+ - 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).
54
+ - Model dialog: search, and the tested models first, marked.
55
+ - Provider errors in plain words: limit reached, provider busy, reply blocked, key refused.
56
+
57
+ ## 0.11.3 (2026-10-07)
58
+
59
+ - `subsub init` asks about the FMUP set-up after the folder.
60
+ - Created notes keep raw HTML as text; the preview shows the whole note (Zotero server 0.5.3).
61
+
62
+ ## 0.11.2 (2026-10-07)
63
+
64
+ - The mode's model is applied again at start (a regression in 0.11.1).
65
+ - After Yes, the preview is computed again; if the library changed meanwhile, the new preview is shown first.
66
+ - Case and Unicode spellings no longer get round protected and key files.
67
+
68
+ ## 0.11.1 (2026-10-07)
69
+
70
+ - Writes into hidden folders always ask; the model cannot read files with keys.
71
+ - pi's install report is off.
72
+
73
+ ## 0.11.0 (2026-10-06)
74
+
75
+ - Two modes again: the Librarian manages the library; the Researcher does that and the research.
76
+ - Defaults from the October model test: mimo-v2.6-flash for the Librarian, mimo-v2.6-pro for the Researcher.
77
+
78
+ 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
  |---|---|
@@ -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
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";
@@ -408,7 +408,8 @@ export function scoreRun(task, dir, t, index, blindRef, starbuck) {
408
408
  dois: dois.length,
409
409
  pmids: pmids.length,
410
410
  ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
411
- 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)),
412
413
  multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
413
414
  fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
414
415
  starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
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
@@ -36,20 +36,30 @@ export const FREE_MODELS = {
36
36
  librarian: "google/gemini-3.1-flash-lite",
37
37
  researcher: "google/gemini-3.1-flash-lite",
38
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
+ };
39
44
  /**
40
45
  * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
41
46
  * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
42
- * free (tested, works on a free tier).
47
+ * free (tested, works on a free tier), eu (tested, servers in the EU).
43
48
  */
44
49
  export const RECOMMENDED_MODELS = [
45
50
  { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
46
51
  { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
47
52
  { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
53
+ { spec: EU_MODELS.researcher, modes: ["librarian", "researcher"], why: "eu" },
48
54
  { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
49
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
+ }
50
60
  export const PROFILES = ["reader", "scholar", "author", "editor"];
51
61
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
52
- export const SERVER_VERSION = "0.5.3";
62
+ export const SERVER_VERSION = "0.5.5";
53
63
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
54
64
  export const STARBUCK_VERSION = "0.2.0";
55
65
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
@@ -163,6 +173,7 @@ export function loadConfig(env = process.env) {
163
173
  themes,
164
174
  addons: Array.isArray(user.addons) ? user.addons.filter((a) => typeof a === "string") : [],
165
175
  starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
176
+ usageLog: user.usageLog === true,
166
177
  };
167
178
  }
168
179
  /** uv is often missing from PATH when pi is started outside a login shell. */
package/dist/init.js CHANGED
@@ -19,14 +19,14 @@
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
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { chmodSync, 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, FREE_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
32
  import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
@@ -132,8 +132,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
132
132
  { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
133
133
  ], flags.setup ?? existing.setup ?? "standard");
134
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
+ }
135
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.");
136
- const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
145
+ const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
137
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");
138
147
  // The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
139
148
  const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
@@ -155,11 +164,19 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
155
164
  }
156
165
  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.");
157
166
  const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
167
+ explain("Web search", "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.");
168
+ const hadBrave = Boolean(envValues.BRAVE_API_KEY);
169
+ const brave = (await io.ask(hadBrave ? "Brave Search key (Enter keeps the saved key)" : "Brave Search key (Enter for none)", flags.brave ?? "")).trim();
158
170
  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.");
159
171
  const starbuck = await io.choose("Reference checks", [
160
172
  { value: "off", label: "Off" },
161
173
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
162
174
  ], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
175
+ 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.");
176
+ const usage = await io.choose("Usage log", [
177
+ { value: "off", label: "Off" },
178
+ { value: "on", label: "On (for the pilot)" },
179
+ ], flags.usage ?? (existing.usageLog === true ? "on" : "off"));
163
180
  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).");
164
181
  const hadModel = "model" in existing || "models" in existing;
165
182
  const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
@@ -167,6 +184,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
167
184
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
168
185
  { value: "later", label: "Choose later in Sub-Sub" },
169
186
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
187
+ { value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
170
188
  { 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)` },
171
189
  ];
172
190
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
@@ -203,7 +221,9 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
203
221
  const alerts = !existsSync(alertsTarget) && existsSync(legacyAlerts) ? legacyAlerts : alertsTarget;
204
222
  mkdirSync(dirname(envFile), { recursive: true });
205
223
  const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
206
- writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined }));
224
+ writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined, BRAVE_API_KEY: brave || undefined }));
225
+ if (brave || hadBrave)
226
+ chmodSync(envFile, 0o600); // the file holds a key
207
227
  const patch = {
208
228
  profile,
209
229
  userName: userName || undefined,
@@ -214,6 +234,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
214
234
  envFile,
215
235
  libraryChanges: undefined,
216
236
  addons: withStarbuck(current.addons, starbuck === "on"),
237
+ usageLog: usage === "on" ? true : undefined,
217
238
  };
218
239
  // A model per mode; "model" (0.10, one for both) is replaced.
219
240
  patch.model = undefined;
@@ -225,6 +246,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
225
246
  patch.models = TESTED_MODELS;
226
247
  if (models === "google-free")
227
248
  patch.models = FREE_MODELS;
249
+ if (models === "mistral-eu")
250
+ patch.models = EU_MODELS;
228
251
  saveConfig(patch, initEnv);
229
252
  let keyNote = "";
230
253
  if (googleKey) {
@@ -236,6 +259,9 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
236
259
  keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
237
260
  }
238
261
  }
262
+ else if (models === "mistral-eu" && !hasProvider(agentDir, "mistral")) {
263
+ 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).`;
264
+ }
239
265
  else if (models === "google-free" && !hasProvider(agentDir, "google")) {
240
266
  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.";
241
267
  }
package/dist/keys.js CHANGED
@@ -1,14 +1,18 @@
1
1
  /** Model providers that take an API key, and plain words for their common errors. */
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
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
+ */
4
8
  export const KEY_PROVIDERS = [
5
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
6
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
7
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
8
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
9
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
10
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
11
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
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" },
12
16
  ];
13
17
  /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
14
18
  export function saveApiKey(agentDir, provider, key) {
@@ -36,6 +40,8 @@ export function saveApiKey(agentDir, provider, key) {
36
40
  export function providerTrouble(text) {
37
41
  if (!text)
38
42
  return undefined;
43
+ if (/requires Global regions|not available in your region|region.{0,40}not (supported|allowed)/i.test(text))
44
+ return "region";
39
45
  if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text))
40
46
  return "quota";
41
47
  if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text))
@@ -46,10 +52,34 @@ export function providerTrouble(text) {
46
52
  return "key";
47
53
  return undefined;
48
54
  }
49
- /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
55
+ /** The words for each kind in notices (the web view has its own, in web/i18n.js). */
56
+ export const TROUBLE_TEXT_PT = {
57
+ 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).",
58
+ busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou use outro modelo (/model).",
59
+ 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).",
60
+ key: "O fornecedor do modelo não aceitou a chave da API. Verifique-a ou introduza uma nova com /login.",
61
+ region: "O fornecedor recusa este modelo com a definição de região da sua conta. No OpenCode, permita as regiões globais (Global) nas definições de privacidade do workspace, ou escolha um modelo que a sua região permita (/model).",
62
+ };
50
63
  export const TROUBLE_TEXT = {
51
64
  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).",
52
65
  busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
66
  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).",
54
67
  key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
68
+ region: "The provider refuses this model with your account's region setting. In OpenCode, allow Global regions in the workspace's Privacy settings, or choose a model your region allows (/model).",
55
69
  };
70
+ /**
71
+ * Seconds to wait before a per-minute limit lets the request through again, or undefined when the
72
+ * error is not a per-minute limit (a daily limit, a spent balance, anything else). pi does not
73
+ * retry Google's per-minute 429, because its text mentions billing.
74
+ */
75
+ export function perMinuteWait(text) {
76
+ if (!text || providerTrouble(text) !== "quota")
77
+ return undefined;
78
+ if (/PerDay|per day|daily/i.test(text))
79
+ return undefined;
80
+ const delay = /retryDelay\\*"\s*:\s*\\*"(\d+(?:\.\d+)?)s/.exec(text);
81
+ const seconds = delay?.[1] ?? /retry in (\d+(?:\.\d+)?)s/i.exec(text)?.[1];
82
+ if (seconds)
83
+ return Math.min(120, Math.ceil(Number(seconds)) + 2);
84
+ return /PerMinute|per minute/i.test(text) ? 60 : undefined;
85
+ }
package/dist/preview.js CHANGED
@@ -3,23 +3,82 @@
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
+ wouldParent: (n) => `${n} file(s) or note(s) would get a parent item:`,
27
+ newItem: "new",
28
+ existingItem: "existing item",
29
+ newCollection: "New collection",
30
+ inside: "inside",
31
+ noteOn: "Note on",
32
+ link: "Link",
33
+ skipped: "Skipped",
34
+ errors: "Errors",
35
+ },
36
+ pt: {
37
+ more: (n) => `... e mais ${n}`,
38
+ moreChars: (n) => `[mais ${n} caracteres não mostrados]`,
39
+ empty: "(vazio)",
40
+ change: "(alteração)",
41
+ undo: "Anular",
42
+ already: "JÁ APLICADO",
43
+ drift: (n) => `${n} item(ns) mudaram depois de a nota ser escrita; a nota substitui-os:`,
44
+ now: "agora",
45
+ none: "(nenhuma)",
46
+ wouldChange: (n) => `${n} item(ns) vão mudar.`,
47
+ inShort: (listed, total) => (listed < total ? `Em resumo (nos ${listed} itens listados):` : "Em resumo:"),
48
+ items: "Itens:",
49
+ itemsN: (n) => `${n} item(ns)`,
50
+ changed: "alterado",
51
+ wouldImport: (n) => `${n} item(ns) vão ser importados:`,
52
+ noAbstract: "(sem resumo)",
53
+ wouldAttach: (n) => `${n} PDF(s) vão ser anexados:`,
54
+ wouldParent: (n) => `${n} ficheiro(s) ou nota(s) vão ter um item principal:`,
55
+ newItem: "novo",
56
+ existingItem: "item existente",
57
+ newCollection: "Nova coleção",
58
+ inside: "dentro de",
59
+ noteOn: "Nota sobre",
60
+ link: "Ligação",
61
+ skipped: "Ignorados",
62
+ errors: "Erros",
63
+ },
64
+ };
6
65
  function s(v, n = 90) {
7
66
  const t = typeof v === "string" ? v : JSON.stringify(v);
8
67
  return t.length > n ? `${t.slice(0, n - 3)}...` : t;
9
68
  }
10
69
  /** 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) {
70
+ function whole(v, w, n = 3000) {
12
71
  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;
72
+ return t.length > n ? `${t.slice(0, n)} ${w.moreChars(t.length - n)}` : t;
14
73
  }
15
- function list(rows, total) {
74
+ function list(rows, w, total) {
16
75
  const out = rows.slice(0, MAX_LINES);
17
76
  const rest = (total ?? rows.length) - out.length;
18
77
  if (rest > 0)
19
- out.push(`... and ${rest} more`);
78
+ out.push(w.more(rest));
20
79
  return out;
21
80
  }
22
- function describeChange(c) {
81
+ function describeChange(c, w) {
23
82
  const parts = [];
24
83
  if (Array.isArray(c.added) && c.added.length)
25
84
  parts.push(`+ ${c.added.join(", ")}`);
@@ -30,62 +89,107 @@ function describeChange(c) {
30
89
  continue;
31
90
  if (v && typeof v === "object" && "after" in v) {
32
91
  const b = v.before;
33
- parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
92
+ parts.push(`${k}: ${s(b || w.empty, 40)} -> ${whole(v.after, w)}`);
34
93
  }
35
94
  }
36
- return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
95
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || w.change}`;
37
96
  }
38
- export function formatPreview(tool, preview) {
97
+ /** For a large batch: each tag and field with the number of items it changes, so the whole is readable at once. */
98
+ function tally(changes, total, w) {
99
+ const count = new Map();
100
+ const add = (k) => count.set(k, (count.get(k) ?? 0) + 1);
101
+ for (const c of changes) {
102
+ for (const tag of c.added ?? [])
103
+ add(`+ ${tag}`);
104
+ for (const tag of c.removed ?? [])
105
+ add(`- ${tag}`);
106
+ for (const [k, v] of Object.entries(c))
107
+ if (v && typeof v === "object" && "after" in v)
108
+ add(`${k} ${w.changed}`);
109
+ }
110
+ if (!count.size)
111
+ return [];
112
+ const rank = (k) => (k.startsWith("+") ? 0 : k.startsWith("-") ? 1 : 2);
113
+ 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)}`);
114
+ return [w.inShort(changes.length, total), ...list(rows, w), w.items];
115
+ }
116
+ export function formatPreview(tool, preview, lang = "en") {
117
+ const w = WORDS[lang];
39
118
  if (!preview || typeof preview !== "object")
40
119
  return s(preview, 1500);
41
120
  const p = preview;
42
121
  const lines = [];
43
122
  if (p.undoing && typeof p.undoing === "object") {
44
123
  const u = p.undoing;
45
- lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
124
+ lines.push(`${w.undo} ${u.op}: ${s(u.summary, 80)}`);
46
125
  }
47
126
  if (Array.isArray(p.already_applied) && p.already_applied.length) {
48
- lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
127
+ lines.push(`${w.already}: ${p.already_applied.join(", ")}`);
49
128
  }
50
129
  const drift = p.changed_since_note;
51
130
  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)"}`);
131
+ lines.push(w.drift(drift.count));
132
+ const items = Object.entries(drift.items ?? {}).map(([k, v]) => ` ${k} ${w.now}: ${v.now?.join(", ") || w.none}`);
54
133
  lines.push(...items.slice(0, 4));
55
134
  }
56
135
  if (typeof p.would_change === "number") {
57
- lines.push(`${p.would_change} item(s) would change.`);
136
+ lines.push(w.wouldChange(p.would_change));
58
137
  const changes = p.changes ?? [];
59
- lines.push(...list(changes.map(describeChange), p.would_change));
138
+ if (p.would_change > 10)
139
+ lines.push(...tally(changes, p.would_change, w));
140
+ lines.push(...list(changes.map((c) => describeChange(c, w)), w, p.would_change));
60
141
  }
61
142
  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)"}`)));
143
+ lines.push(w.wouldImport(p.would_import.length));
144
+ lines.push(...list(p.would_import.map((r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : ` ${w.noAbstract}`}`), w));
64
145
  }
65
146
  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}` : ""}]`)));
147
+ lines.push(w.wouldAttach(p.would_attach.length));
148
+ lines.push(...list(p.would_attach.map((r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`), w));
149
+ }
150
+ if (Array.isArray(p.would_set_parent)) {
151
+ lines.push(w.wouldParent(p.would_set_parent.length));
152
+ const rows = [];
153
+ for (const r of p.would_set_parent.slice(0, 6)) {
154
+ rows.push(`${s(r.file, 60)}`);
155
+ if (r.parent === "new") {
156
+ const f = r.fields ?? {};
157
+ const where = ["publicationTitle", "date", "issue", "volume", "pages", "publisher", "ISBN", "DOI"]
158
+ .filter((k) => f[k])
159
+ .map((k) => `${k}: ${s(f[k], 40)}`)
160
+ .join(", ");
161
+ rows.push(` -> ${w.newItem} ${r.type} ${r.citekey}: ${s(r.item, 80)}`);
162
+ if (where)
163
+ rows.push(` ${where}`);
164
+ }
165
+ else {
166
+ rows.push(` -> ${w.existingItem} ${r.parent_key}: ${s(r.item, 70)}`);
167
+ }
168
+ }
169
+ lines.push(...rows);
170
+ if (p.would_set_parent.length > 6)
171
+ lines.push(w.more(p.would_set_parent.length - 6));
68
172
  }
69
173
  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})` : ""}`);
174
+ const nc = p.would_create;
175
+ lines.push(`${w.newCollection}: ${nc.name}${nc.parent ? ` (${w.inside} ${nc.parent})` : ""}`);
72
176
  }
73
177
  if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
74
178
  if (p.item || p.parent)
75
- lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
179
+ lines.push(`${w.noteOn}: ${s(p.item ?? p.parent, 90)}`);
76
180
  if (p.summary || p.note_preview)
77
- lines.push(whole(p.summary ?? p.note_preview));
181
+ lines.push(whole(p.summary ?? p.note_preview, w));
78
182
  if (p.link)
79
- lines.push(`Link: ${p.link}`);
183
+ lines.push(`${w.link}: ${p.link}`);
80
184
  }
81
185
  const skipped = p.skipped;
82
186
  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));
187
+ lines.push(`${w.skipped}: ${Object.keys(skipped).length}`);
188
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`), w).slice(0, 5));
85
189
  }
86
190
  const errors = p.errors;
87
191
  if (errors && typeof errors === "object" && Object.keys(errors).length) {
88
- lines.push(`Errors: ${Object.keys(errors).length}`);
192
+ lines.push(`${w.errors}: ${Object.keys(errors).length}`);
89
193
  lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
90
194
  }
91
195
  if (!lines.length)
package/dist/roles.js CHANGED
@@ -9,6 +9,7 @@ export const ZOTERO_READ = [
9
9
  "status",
10
10
  "library_overview",
11
11
  "find_items",
12
+ "standalone_items",
12
13
  "get_item",
13
14
  "get_fulltext",
14
15
  "list_tags",
@@ -35,13 +36,23 @@ export const PREVIEW_WRITES = new Set([
35
36
  "import_queue",
36
37
  "repair_metadata",
37
38
  "attach_oa_pdfs",
39
+ "set_parent_items",
38
40
  ].map((n) => `zotero_${n}`),
39
41
  "scholar_attach_note",
40
42
  ]);
41
43
  /** Writes without a dry run that still need a yes. */
42
44
  export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
43
45
  /** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
44
- export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map((n) => `zotero_${n}`);
46
+ export const ZOTERO_CHECKS = [
47
+ "tag_audit",
48
+ "audit_metadata",
49
+ "find_duplicates",
50
+ "check_retractions",
51
+ "missing_pdfs",
52
+ "write_tag_review",
53
+ "find_reference",
54
+ "web_search",
55
+ ].map((n) => `zotero_${n}`);
45
56
  /**
46
57
  * The tools of a mode. The Librarian manages the library: Zotero tools and files.
47
58
  * The Researcher has the same, plus searches, notes and reference checks. The