@tiagojct/subsub 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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
  |---|---|
@@ -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.4";
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
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, 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);
@@ -160,6 +169,11 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
160
169
  { value: "off", label: "Off" },
161
170
  { value: "on", label: "On (downloads Starbuck now; uses the email above)" },
162
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"));
163
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).");
164
178
  const hadModel = "model" in existing || "models" in existing;
165
179
  const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
@@ -167,6 +181,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
167
181
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
168
182
  { value: "later", label: "Choose later in Sub-Sub" },
169
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` },
170
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)` },
171
186
  ];
172
187
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
@@ -214,6 +229,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
214
229
  envFile,
215
230
  libraryChanges: undefined,
216
231
  addons: withStarbuck(current.addons, starbuck === "on"),
232
+ usageLog: usage === "on" ? true : undefined,
217
233
  };
218
234
  // A model per mode; "model" (0.10, one for both) is replaced.
219
235
  patch.model = undefined;
@@ -225,6 +241,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
225
241
  patch.models = TESTED_MODELS;
226
242
  if (models === "google-free")
227
243
  patch.models = FREE_MODELS;
244
+ if (models === "mistral-eu")
245
+ patch.models = EU_MODELS;
228
246
  saveConfig(patch, initEnv);
229
247
  let keyNote = "";
230
248
  if (googleKey) {
@@ -236,6 +254,9 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
236
254
  keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
237
255
  }
238
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
+ }
239
260
  else if (models === "google-free" && !hasProvider(agentDir, "google")) {
240
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.";
241
262
  }
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) {
@@ -46,10 +50,32 @@ export function providerTrouble(text) {
46
50
  return "key";
47
51
  return undefined;
48
52
  }
49
- /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
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
+ };
50
60
  export const TROUBLE_TEXT = {
51
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).",
52
62
  busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
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).",
54
64
  key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
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)