@tiagojct/subsub 0.11.2 → 0.11.4

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/README.md CHANGED
@@ -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
@@ -133,7 +133,7 @@ subsub review apply 13-31
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
139
  | `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
package/dist/bench.js CHANGED
@@ -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 };
@@ -558,7 +561,9 @@ export async function main(argv = process.argv.slice(2)) {
558
561
  if (!prompt)
559
562
  return;
560
563
  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 }));
564
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
565
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
566
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
562
567
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
563
568
  n++;
564
569
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/dist/config.js CHANGED
@@ -31,9 +31,25 @@ 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
+ /**
40
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
41
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
42
+ * free (tested, works on a free tier).
43
+ */
44
+ export const RECOMMENDED_MODELS = [
45
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
46
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
47
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
48
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
49
+ ];
34
50
  export const PROFILES = ["reader", "scholar", "author", "editor"];
35
51
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
36
- export const SERVER_VERSION = "0.5.2";
52
+ export const SERVER_VERSION = "0.5.3";
37
53
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
38
54
  export const STARBUCK_VERSION = "0.2.0";
39
55
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
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|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, 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 = {
@@ -88,12 +89,6 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
88
89
  io.say("");
89
90
  io.say(`${title}. ${text}`);
90
91
  };
91
- explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
92
- const setup = await io.choose("Setup", [
93
- { value: "standard", label: "Standard" },
94
- { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
95
- ], flags.setup ?? existing.setup ?? "standard");
96
- const fmup = setup === "fmup";
97
92
  explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
98
93
  const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
99
94
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
@@ -130,6 +125,13 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
130
125
  io.say(`Sub-Sub keeps using ${notes}.`);
131
126
  }
132
127
  }
128
+ // Asked here, not first: it only changes the defaults of the next two questions.
129
+ explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
130
+ const setup = await io.choose("Set-up", [
131
+ { value: "standard", label: "Standard (any field, any institution)" },
132
+ { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
133
+ ], flags.setup ?? existing.setup ?? "standard");
134
+ const fmup = setup === "fmup";
133
135
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
134
136
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
135
137
  const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
@@ -165,10 +167,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
165
167
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
166
168
  { value: "later", label: "Choose later in Sub-Sub" },
167
169
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
170
+ { 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)` },
168
171
  ];
169
172
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
170
173
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
171
174
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
175
+ let googleKey = "";
176
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
177
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
178
+ 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.`);
179
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
180
+ }
172
181
  // ---- write
173
182
  if (move === "yes")
174
183
  applyMoves(moves);
@@ -214,7 +223,22 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
214
223
  patch.models = {};
215
224
  if (models === "opencode-go")
216
225
  patch.models = TESTED_MODELS;
226
+ if (models === "google-free")
227
+ patch.models = FREE_MODELS;
217
228
  saveConfig(patch, initEnv);
229
+ let keyNote = "";
230
+ if (googleKey) {
231
+ try {
232
+ saveApiKey(agentDir, "google", googleKey);
233
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
234
+ }
235
+ catch (err) {
236
+ keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
237
+ }
238
+ }
239
+ else if (models === "google-free" && !hasProvider(agentDir, "google")) {
240
+ 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
+ }
218
242
  const zotero = await zoteroReachable(deps.fetch);
219
243
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
220
244
  io.say("");
@@ -222,6 +246,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
222
246
  io.say(`Saved: ${envFile}`);
223
247
  for (const c of created)
224
248
  io.say(`Created: ${c}`);
249
+ if (keyNote)
250
+ io.say(keyNote);
225
251
  if (move === "yes")
226
252
  io.say(`Moved: ${moves.length} file(s) into ${notes}`);
227
253
  for (const d of leftBehind(from, notes))
package/dist/keys.js ADDED
@@ -0,0 +1,55 @@
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
+ 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" },
12
+ ];
13
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
14
+ export function saveApiKey(agentDir, provider, key) {
15
+ if (!KEY_PROVIDERS.some((p) => p.id === provider))
16
+ throw new Error(`Unknown provider: ${provider}`);
17
+ const clean = key.trim();
18
+ if (!clean || /\s/.test(clean) || clean.length > 400)
19
+ throw new Error("That does not look like an API key.");
20
+ mkdirSync(agentDir, { recursive: true });
21
+ const file = join(agentDir, "auth.json");
22
+ let data = {};
23
+ if (existsSync(file)) {
24
+ try {
25
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
26
+ }
27
+ catch {
28
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
29
+ }
30
+ }
31
+ data[provider] = { type: "api_key", key: clean };
32
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
33
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
34
+ }
35
+ /** What a provider's error means for the user, or undefined when it is something else. */
36
+ export function providerTrouble(text) {
37
+ if (!text)
38
+ return undefined;
39
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text))
40
+ return "quota";
41
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text))
42
+ return "busy";
43
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text))
44
+ return "blocked";
45
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text))
46
+ return "key";
47
+ return undefined;
48
+ }
49
+ /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
50
+ export const TROUBLE_TEXT = {
51
+ 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
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
+ 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
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
+ };
package/dist/subsub.js CHANGED
@@ -25,6 +25,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
25
25
  import { applyEdits, checkLiteratureNote } from "./notes.js";
26
26
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
27
27
  import { describeArgs, formatPreview } from "./preview.js";
28
+ import { providerTrouble, TROUBLE_TEXT } from "./keys.js";
28
29
  import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
29
30
  import { systemAddition } from "./prompt.js";
30
31
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
@@ -298,6 +299,15 @@ export async function createSubsub(pi, deps = {}) {
298
299
  await applyMode(ctx, m, false, false);
299
300
  });
300
301
  pi.on("model_select", async (_event, ctx) => status(ctx));
302
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
303
+ pi.on("message_end", async (event, ctx) => {
304
+ const m = event.message;
305
+ if (webView || m.role !== "assistant" || m.stopReason !== "error")
306
+ return;
307
+ const kind = providerTrouble(m.errorMessage);
308
+ if (kind)
309
+ ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
310
+ });
301
311
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
302
312
  // The input event comes before pi expands a prompt template.
303
313
  pi.on("input", async (event, ctx) => {
package/dist/web.js CHANGED
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
21
21
  import { extname, join, normalize, resolve, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
- import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
24
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, withStarbuck } from "./config.js";
25
25
  import { TESTED_MODELS } from "./init.js";
26
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
26
27
  import { obsidianRoot } from "./layout.js";
27
28
  import { realish, within } from "./paths.js";
28
29
  export function parseWebArgs(args) {
@@ -51,37 +52,7 @@ export function uiLanguage(language) {
51
52
  return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
52
53
  }
53
54
  // ---------------------------------------------------------------- providers (API keys)
54
- export const KEY_PROVIDERS = [
55
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
56
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
57
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
58
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
59
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
60
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
61
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
62
- ];
63
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
64
- export function saveApiKey(agentDir, provider, key) {
65
- if (!KEY_PROVIDERS.some((p) => p.id === provider))
66
- throw new Error(`Unknown provider: ${provider}`);
67
- const clean = key.trim();
68
- if (!clean || /\s/.test(clean) || clean.length > 400)
69
- throw new Error("That does not look like an API key.");
70
- mkdirSync(agentDir, { recursive: true });
71
- const file = join(agentDir, "auth.json");
72
- let data = {};
73
- if (existsSync(file)) {
74
- try {
75
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
76
- }
77
- catch {
78
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
79
- }
80
- }
81
- data[provider] = { type: "api_key", key: clean };
82
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
83
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
84
- }
55
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
85
56
  /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
86
57
  export class Agent {
87
58
  proc;
@@ -582,7 +553,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
582
553
  }
583
554
  case "GET /api/models": {
584
555
  const data = (await agent.request({ type: "get_available_models" }));
585
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
556
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
586
557
  }
587
558
  case "POST /api/model": {
588
559
  if (busy || switching)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/bench.ts CHANGED
@@ -134,7 +134,10 @@ export function runOne(opts: {
134
134
  provider?: string;
135
135
  }): Promise<RunSummary> {
136
136
  mkdirSync(opts.out, { recursive: true });
137
- const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
137
+ // "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
138
+ const slash = opts.model.indexOf("/");
139
+ const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
140
+ const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
138
141
  if (opts.role === "librarian") args.push("--librarian");
139
142
  const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
140
143
  const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
@@ -608,7 +611,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
608
611
  const prompt = promptFor(j.task, t, j.code, out);
609
612
  if (!prompt) return;
610
613
  const cf = join(cfgDir, `${j.code}.json`);
611
- writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
614
+ // A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
615
+ const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
616
+ writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
612
617
  const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
613
618
  n++;
614
619
  console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
package/src/config.ts CHANGED
@@ -35,11 +35,27 @@ export const DEFAULT_MODELS: Record<Mode, string> = {
35
35
  librarian: "opencode-go/mimo-v2.6-flash",
36
36
  researcher: "opencode-go/mimo-v2.6-pro",
37
37
  };
38
+ /** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
39
+ export const FREE_MODELS: Record<Mode, string> = {
40
+ librarian: "google/gemini-3.1-flash-lite",
41
+ researcher: "google/gemini-3.1-flash-lite",
42
+ };
43
+ /**
44
+ * Models marked in the model dialog, from the model tests (see the Models page). `why` is a
45
+ * key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
46
+ * free (tested, works on a free tier).
47
+ */
48
+ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" }> = [
49
+ { spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
50
+ { spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
51
+ { spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
52
+ { spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
53
+ ];
38
54
  export type Profile = "reader" | "scholar" | "author" | "editor";
39
55
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
40
56
 
41
57
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
42
- export const SERVER_VERSION = "0.5.2";
58
+ export const SERVER_VERSION = "0.5.3";
43
59
 
44
60
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
45
61
  export const STARBUCK_VERSION = "0.2.0";
package/src/init.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Flags (for scripts and tests): --yes (take every default), --setup
20
20
  * standard|fmup, --name, --about, --language, --folder (or --notes), --move
21
21
  * yes|no, --profile, --tags health-sciences|health-informatics|any-field,
22
- * --email, --starbuck on|off, --models keep|later|opencode-go.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
23
23
  */
24
24
 
25
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
30
30
  import {
31
31
  configPath,
32
32
  DEFAULT_MODELS,
33
+ FREE_MODELS,
33
34
  expand,
34
35
  isProfile,
35
36
  loadConfig,
@@ -43,6 +44,7 @@ import {
43
44
  } from "./config.ts";
44
45
  import { defaultRunner, starbuckCheck } from "./doctor.ts";
45
46
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
47
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
46
48
  import { PROFILE_SPECS } from "./profiles.ts";
47
49
 
48
50
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
@@ -128,17 +130,6 @@ export async function runInit(
128
130
  io.say(`${title}. ${text}`);
129
131
  };
130
132
 
131
- explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
132
- const setup = await io.choose(
133
- "Setup",
134
- [
135
- { value: "standard", label: "Standard" },
136
- { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
137
- ],
138
- flags.setup ?? existing.setup ?? "standard",
139
- );
140
- const fmup = setup === "fmup";
141
-
142
133
  explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
143
134
  const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
144
135
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
@@ -184,6 +175,18 @@ export async function runInit(
184
175
  }
185
176
  }
186
177
 
178
+ // Asked here, not first: it only changes the defaults of the next two questions.
179
+ explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
180
+ const setup = await io.choose(
181
+ "Set-up",
182
+ [
183
+ { value: "standard", label: "Standard (any field, any institution)" },
184
+ { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
185
+ ],
186
+ flags.setup ?? existing.setup ?? "standard",
187
+ );
188
+ const fmup = setup === "fmup";
189
+
187
190
  explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
188
191
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
189
192
  const profile = await io.choose(
@@ -237,10 +240,20 @@ export async function runInit(
237
240
  ...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
238
241
  { value: "later", label: "Choose later in Sub-Sub" },
239
242
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
243
+ { 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)` },
240
244
  ];
241
245
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
242
246
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
243
247
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
248
+ let googleKey = "";
249
+ if (models === "google-free" && !hasProvider(agentDir, "google")) {
250
+ const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
251
+ explain(
252
+ "Free Google key",
253
+ `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.`,
254
+ );
255
+ googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
256
+ }
244
257
 
245
258
  // ---- write
246
259
  if (move === "yes") applyMoves(moves);
@@ -284,7 +297,19 @@ export async function runInit(
284
297
  if (models === "keep") patch.models = current.models;
285
298
  if (models === "later") patch.models = {};
286
299
  if (models === "opencode-go") patch.models = TESTED_MODELS;
300
+ if (models === "google-free") patch.models = FREE_MODELS;
287
301
  saveConfig(patch, initEnv);
302
+ let keyNote = "";
303
+ if (googleKey) {
304
+ try {
305
+ saveApiKey(agentDir, "google", googleKey);
306
+ keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
307
+ } catch (err) {
308
+ keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
309
+ }
310
+ } else if (models === "google-free" && !hasProvider(agentDir, "google")) {
311
+ keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
312
+ }
288
313
 
289
314
  const zotero = await zoteroReachable(deps.fetch);
290
315
  const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
@@ -292,6 +317,7 @@ export async function runInit(
292
317
  io.say(`Saved: ${configFile}`);
293
318
  io.say(`Saved: ${envFile}`);
294
319
  for (const c of created) io.say(`Created: ${c}`);
320
+ if (keyNote) io.say(keyNote);
295
321
  if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
296
322
  for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
297
323
  io.say(
package/src/keys.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Model providers that take an API key, and plain words for their common errors. */
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
7
+ { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
8
+ { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
9
+ { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
10
+ { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
11
+ { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
12
+ { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
13
+ { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
14
+ ];
15
+
16
+ /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
17
+ export function saveApiKey(agentDir: string, provider: string, key: string): void {
18
+ if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
19
+ const clean = key.trim();
20
+ if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
21
+ mkdirSync(agentDir, { recursive: true });
22
+ const file = join(agentDir, "auth.json");
23
+ let data: Record<string, unknown> = {};
24
+ if (existsSync(file)) {
25
+ try {
26
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
27
+ } catch {
28
+ throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
29
+ }
30
+ }
31
+ data[provider] = { type: "api_key", key: clean };
32
+ // Write through a symlink (a shared pi auth.json) rather than replacing it.
33
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
34
+ }
35
+
36
+
37
+ export type Trouble = "quota" | "busy" | "blocked" | "key";
38
+
39
+ /** What a provider's error means for the user, or undefined when it is something else. */
40
+ export function providerTrouble(text: string | undefined): Trouble | undefined {
41
+ if (!text) return undefined;
42
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
43
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
44
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
45
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
46
+ return undefined;
47
+ }
48
+
49
+ /** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
50
+ export const TROUBLE_TEXT: Record<Trouble, string> = {
51
+ 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
+ busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
53
+ 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
+ key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
55
+ };
package/src/subsub.ts CHANGED
@@ -41,6 +41,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
41
41
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
42
42
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
43
43
  import { describeArgs, formatPreview } from "./preview.ts";
44
+ import { providerTrouble, TROUBLE_TEXT } from "./keys.ts";
44
45
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
45
46
  import { systemAddition } from "./prompt.ts";
46
47
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
@@ -324,6 +325,14 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
324
325
  });
325
326
 
326
327
  pi.on("model_select", async (_event, ctx) => status(ctx));
328
+ // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
329
+ pi.on("message_end", async (event, ctx) => {
330
+ const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
331
+ if (webView || m.role !== "assistant" || m.stopReason !== "error") return;
332
+ const kind = providerTrouble(m.errorMessage);
333
+ if (kind) ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
334
+ });
335
+
327
336
 
328
337
  // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
329
338
  // The input event comes before pi expands a prompt template.
package/src/web.ts CHANGED
@@ -22,8 +22,9 @@ import { homedir } from "node:os";
22
22
  import { extname, join, normalize, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
25
+ import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
27
28
  import { obsidianRoot } from "./layout.ts";
28
29
  import { realish, within } from "./paths.ts";
29
30
 
@@ -62,35 +63,7 @@ export function uiLanguage(language: string | undefined): "pt" | "en" {
62
63
 
63
64
  // ---------------------------------------------------------------- providers (API keys)
64
65
 
65
- export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
66
- { id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
67
- { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
68
- { id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
69
- { id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
70
- { id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
71
- { id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
72
- { id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
73
- ];
74
-
75
- /** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
76
- export function saveApiKey(agentDir: string, provider: string, key: string): void {
77
- if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
78
- const clean = key.trim();
79
- if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
80
- mkdirSync(agentDir, { recursive: true });
81
- const file = join(agentDir, "auth.json");
82
- let data: Record<string, unknown> = {};
83
- if (existsSync(file)) {
84
- try {
85
- data = JSON.parse(readFileSync(file, "utf8")) ?? {};
86
- } catch {
87
- throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
88
- }
89
- }
90
- data[provider] = { type: "api_key", key: clean };
91
- // Write through a symlink (a shared pi auth.json) rather than replacing it.
92
- writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
93
- }
66
+ export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
94
67
 
95
68
  // ---------------------------------------------------------------- pi in RPC mode
96
69
 
@@ -597,7 +570,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
597
570
  }
598
571
  case "GET /api/models": {
599
572
  const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
600
- return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
573
+ return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
601
574
  }
602
575
  case "POST /api/model": {
603
576
  if (busy || switching) return send(res, 409, { error: "busy" });
package/web/app.css CHANGED
@@ -631,6 +631,11 @@ body.layout-full { --measure: 72rem; }
631
631
  .note.mono { font-family: var(--mono); font-size: 0.76rem; line-height: 1.55; }
632
632
  .note pre { font-family: var(--mono); font-size: 0.74rem; white-space: pre-wrap; }
633
633
  .note button { margin-top: 0.5rem; }
634
+ .note p { margin: 0; }
635
+ .note details { margin-top: 0.4rem; color: var(--muted); }
636
+ .note details summary { cursor: pointer; font-size: 0.78rem; }
637
+ .note details pre { margin: 0.3rem 0 0; overflow-wrap: anywhere; }
638
+ .free-note { border-left: 3px solid var(--accent); padding-left: 0.6rem; }
634
639
 
635
640
  .working {
636
641
  margin-top: 1rem;
@@ -826,6 +831,14 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
826
831
  .models button:hover { background: var(--bg); }
827
832
  .models .current { color: var(--accent); font-weight: 600; background: var(--accent-soft); }
828
833
  .models .empty-models { padding: 0.65rem 0.8rem; color: var(--muted); font-size: 0.88rem; }
834
+ .model-search { width: 100%; margin-bottom: 0.5rem; }
835
+ .models { max-height: 20rem; }
836
+ .models .models-group { padding: 0.45rem 0.8rem 0.3rem; font-size: 0.78rem; font-weight: 600; color: var(--muted); background: var(--surface); }
837
+ .models button { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; }
838
+ .models .needs-key { opacity: 0.65; }
839
+ .models .model-name { min-width: 0; overflow-wrap: anywhere; }
840
+ .models .model-tag { font-family: var(--sans); font-size: 0.72rem; font-weight: 600; color: var(--accent); border: 1px solid currentColor; border-radius: 999px; padding: 0 0.45rem; }
841
+ .models .model-why { font-family: var(--sans); font-size: 0.75rem; color: var(--muted); }
829
842
 
830
843
  /* ---------------------------------------------------------------- narrow screens */
831
844
 
package/web/app.js CHANGED
@@ -207,8 +207,10 @@ function renderHeader() {
207
207
  txt.textContent += ` · ${t("serversDown", { x: state.down.join(", ") })}`;
208
208
  lib.classList.add("down");
209
209
  }
210
- lib.title = txt.textContent;
211
- $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
210
+ lib.title = L?.toReview ? `${txt.textContent}\n${t("toReviewHint")}` : txt.textContent;
211
+ // On a phone the long hint does not fit in the box.
212
+ const narrow = window.matchMedia("(max-width: 560px)").matches;
213
+ $("input").placeholder = narrow ? t("placeholderShort") : mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
212
214
  if (log.querySelector(".empty")) {
213
215
  clearEmpty();
214
216
  renderEmpty();
@@ -366,6 +368,21 @@ function scrollDown(force = false) {
366
368
  if (force || near) log.scrollTop = log.scrollHeight;
367
369
  }
368
370
 
371
+ // A provider error in plain words (keep in step with providerTrouble in src/keys.ts), with the raw text below.
372
+ function troubleOf(text) {
373
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
374
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
375
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
376
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
377
+ return null;
378
+ }
379
+
380
+ function errorNote(text) {
381
+ const kind = troubleOf(text);
382
+ if (!kind) return el("div", { class: "note error", text });
383
+ return el("div", { class: "note error" }, el("p", { text: t(`trouble_${kind}`) }), el("details", {}, el("summary", { text: t("details") }), el("pre", { text })));
384
+ }
385
+
369
386
  function note(text, kind = "info", mono = false) {
370
387
  clearEmpty();
371
388
  const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
@@ -594,7 +611,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
594
611
  );
595
612
  msg.box.append(actions);
596
613
  }
597
- if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
614
+ if (message.stopReason === "error" && message.errorMessage) msg.box.append(errorNote(message.errorMessage));
598
615
  if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
599
616
  if (!msg.box.children.length) msg.box.remove();
600
617
  scrollDown();
@@ -905,44 +922,93 @@ async function openModels() {
905
922
  const setLink = () => {
906
923
  const p = providers.find((x) => x.id === sel.value);
907
924
  link.href = p?.url ?? "#";
925
+ const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
926
+ $("login-free").hidden = !free;
927
+ $("login-free").textContent = free ? t(free) : "";
908
928
  };
909
929
  sel.onchange = setLink;
910
930
  setLink();
911
931
  if (!dlg.open) dlg.showModal();
912
932
  let models = [];
933
+ let recommended = [];
913
934
  try {
914
- models = (await api("/api/models")).models ?? [];
935
+ const data = await api("/api/models");
936
+ models = data.models ?? [];
937
+ recommended = data.recommended ?? [];
915
938
  } catch (err) {
916
939
  note(err.message, "error");
917
940
  }
918
- ul.replaceChildren();
919
- if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
920
941
  const cur = state?.model ?? session?.model;
942
+ const specOf = (m) => `${m.provider}/${m.id}`;
921
943
  const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
922
- models.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
923
- for (const m of models) {
924
- const isCur = cur && cur.provider === m.provider && cur.id === m.id;
925
- ul.append(
944
+ const available = new Set(models.map(specOf));
945
+ // Recommended for this mode first (also when the provider has no key yet), then the rest.
946
+ const mine = recommended.filter((r) => r.modes.includes(mode()));
947
+ const recSpecs = new Set(mine.map((r) => r.spec));
948
+ const others = models
949
+ .filter((m) => !recSpecs.has(specOf(m)))
950
+ .sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || specOf(a).localeCompare(specOf(b)));
951
+ const choose = async (m) => {
952
+ if (busy) return note(t("busy"), "warning");
953
+ try {
954
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
955
+ dlg.close();
956
+ } catch (err) {
957
+ note(err.message, "error");
958
+ }
959
+ };
960
+ const row = (m, rec) => {
961
+ const spec = specOf(m);
962
+ const can = available.has(spec);
963
+ const label = providers.find((p) => p.id === m.provider)?.label ?? m.provider;
964
+ return el(
965
+ "li",
966
+ { "data-spec": spec.toLowerCase() },
926
967
  el(
927
- "li",
928
- {},
929
- el("button", {
968
+ "button",
969
+ {
930
970
  type: "button",
931
- class: isCur ? "current" : "",
932
- text: `${m.provider}/${m.id}`,
933
- onclick: async () => {
934
- if (busy) return note(t("busy"), "warning");
935
- try {
936
- await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
937
- dlg.close();
938
- } catch (err) {
939
- note(err.message, "error");
971
+ class: [isCurrent(m) ? "current" : "", rec ? "rec" : "", can ? "" : "needs-key"].join(" ").trim(),
972
+ // Without a key, the click goes to the form below: that provider, and the key field.
973
+ onclick: () => {
974
+ if (can) return choose(m);
975
+ if (providers.some((p) => p.id === m.provider)) {
976
+ sel.value = m.provider;
977
+ setLink();
940
978
  }
979
+ $("login-key").focus();
941
980
  },
942
- }),
981
+ },
982
+ el("span", { class: "model-name", text: spec }),
983
+ rec ? el("span", { class: "model-tag", text: t(`rec_${rec.why}`) }) : null,
984
+ rec && !can ? el("span", { class: "model-why", text: t("needsKey", { x: label }) }) : null,
943
985
  ),
944
986
  );
945
- }
987
+ };
988
+ const search = $("model-search");
989
+ const render = () => {
990
+ const q = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
991
+ const hit = (spec) => q.every((w) => spec.toLowerCase().includes(w));
992
+ const recRows = mine.filter((r) => hit(r.spec)).map((r) => {
993
+ const i = r.spec.indexOf("/");
994
+ return row({ provider: r.spec.slice(0, i), id: r.spec.slice(i + 1) }, r);
995
+ });
996
+ const rest = others.filter((m) => hit(specOf(m))).map((m) => row(m, null));
997
+ ul.replaceChildren();
998
+ if (recRows.length) ul.append(el("li", { class: "models-group", text: t("recommended") }), ...recRows);
999
+ if (rest.length) ul.append(el("li", { class: "models-group", text: t(recRows.length ? "otherModels" : "allModels") }), ...rest);
1000
+ if (!recRows.length && !rest.length) ul.append(el("li", { class: "empty-models", text: models.length ? t("noMatch") : t("noModels") }));
1001
+ };
1002
+ search.value = "";
1003
+ search.oninput = render;
1004
+ search.onkeydown = (e) => {
1005
+ if (e.key === "Enter") {
1006
+ e.preventDefault();
1007
+ ul.querySelector("button:not(.needs-key)")?.click();
1008
+ }
1009
+ };
1010
+ render();
1011
+ search.focus();
946
1012
  }
947
1013
 
948
1014
  $("login-form")?.addEventListener("submit", async (e) => {
package/web/i18n.js CHANGED
@@ -27,7 +27,8 @@ export const STRINGS = {
27
27
  zoteroChecking: "Zotero: checking",
28
28
  zoteroDown: "Zotero is not running",
29
29
  items: "{n} items",
30
- toReview: "{n} to review",
30
+ toReview: "{n} to check",
31
+ toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
31
32
  serversDown: "Not running: {x}",
32
33
  working: "Working",
33
34
  using: "Using: {x}",
@@ -44,6 +45,15 @@ export const STRINGS = {
44
45
  modelTitleResearcher: "Model for the Researcher",
45
46
  modelTitleLibrarian: "Model for the Librarian",
46
47
  chooseModel: "Choose a model",
48
+ searchModels: "Search, e.g. flash or openrouter free",
49
+ recommended: "Recommended for this mode",
50
+ otherModels: "Other models",
51
+ allModels: "All models",
52
+ noMatch: "No model matches.",
53
+ rec_tested: "tested default",
54
+ rec_cheaper: "tested, cheaper",
55
+ rec_free: "tested, free",
56
+ needsKey: "needs a key from {x}",
47
57
  noModels: "No models yet. Connect a provider below.",
48
58
  connectProvider: "Connect a provider",
49
59
  provider: "Provider",
@@ -51,6 +61,13 @@ export const STRINGS = {
51
61
  save: "Save",
52
62
  getKey: "Get a key",
53
63
  keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
64
+ freeGoogle: "Free to try: a Google AI Studio key needs no card. The free tier limits the text sent each minute and the requests each day, enough to try Sub-Sub, and gemini-3.1-flash-lite is the tested free model. You must be 18 or older. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may.",
65
+ freeOpenRouter: "Free to try: models whose names end in :free cost nothing, with 50 requests a day (1000 after you buy 10 dollars of credit). The providers of some free models may keep your prompts.",
66
+ trouble_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 choose another model with the model button.",
67
+ trouble_busy: "The model provider is busy at the moment. Try again in a few minutes, or choose another model with the model button.",
68
+ trouble_blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or choose another model.",
69
+ trouble_key: "The model provider did not accept the API key. Enter it again with the model button.",
70
+ details: "Details",
54
71
  oauthNote: "To sign in through a provider's website (for example with a subscription), open a terminal, type subsub, then /login.",
55
72
  saving: "Saving",
56
73
  saved: "Saved.",
@@ -89,6 +106,7 @@ export const STRINGS = {
89
106
  librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
90
107
  placeholderResearcher: "Ask about your library or the literature, or type / for commands",
91
108
  placeholderLibrarian: "Ask for a change to your library, or type / for commands",
109
+ placeholderShort: "Ask, or type / for commands",
92
110
  emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
93
111
  emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
94
112
  forBoth: "Use it in both modes",
@@ -120,7 +138,8 @@ export const STRINGS = {
120
138
  zoteroChecking: "Zotero: a verificar",
121
139
  zoteroDown: "O Zotero não está aberto",
122
140
  items: "{n} itens",
123
- toReview: "{n} por rever",
141
+ toReview: "{n} por verificar",
142
+ toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
124
143
  serversDown: "Parados: {x}",
125
144
  working: "A trabalhar",
126
145
  using: "A usar: {x}",
@@ -137,6 +156,15 @@ export const STRINGS = {
137
156
  modelTitleResearcher: "Modelo do Investigador",
138
157
  modelTitleLibrarian: "Modelo do Bibliotecário",
139
158
  chooseModel: "Escolha um modelo",
159
+ searchModels: "Pesquisar, por exemplo flash ou openrouter free",
160
+ recommended: "Recomendados para este modo",
161
+ otherModels: "Outros modelos",
162
+ allModels: "Todos os modelos",
163
+ noMatch: "Nenhum modelo corresponde.",
164
+ rec_tested: "testado, por omissão",
165
+ rec_cheaper: "testado, mais barato",
166
+ rec_free: "testado, gratuito",
167
+ needsKey: "precisa de uma chave de {x}",
140
168
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
141
169
  connectProvider: "Ligar um fornecedor",
142
170
  provider: "Fornecedor",
@@ -144,6 +172,13 @@ export const STRINGS = {
144
172
  save: "Guardar",
145
173
  getKey: "Obter uma chave",
146
174
  keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
175
+ freeGoogle: "Para experimentar sem pagar: uma chave do Google AI Studio não pede cartão. O nível gratuito limita o texto enviado por minuto e os pedidos por dia, o que chega para experimentar o Sub-Sub; o modelo gratuito testado é o gemini-3.1-flash-lite. Tem de ter 18 anos ou mais. Na UE, no Reino Unido e na Suíça, a Google não usa os seus pedidos para melhorar os produtos; noutros países, no nível gratuito, pode usar.",
176
+ freeOpenRouter: "Para experimentar sem pagar: os modelos cujo nome termina em :free não custam nada, com 50 pedidos por dia (1000 depois de comprar 10 dólares de crédito). Os fornecedores de alguns modelos gratuitos podem guardar os seus pedidos.",
177
+ trouble_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 escolha outro modelo no botão do modelo.",
178
+ trouble_busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou escolha outro modelo no botão do modelo.",
179
+ trouble_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 escolha outro modelo.",
180
+ trouble_key: "O fornecedor do modelo não aceitou a chave da API. Volte a introduzi-la no botão do modelo.",
181
+ details: "Detalhes",
147
182
  oauthNote: "Para entrar através do site de um fornecedor (por exemplo, com uma subscrição), abra um terminal, escreva subsub e depois /login.",
148
183
  saving: "A guardar",
149
184
  saved: "Guardado.",
@@ -182,6 +217,7 @@ export const STRINGS = {
182
217
  librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
183
218
  placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
184
219
  placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
220
+ placeholderShort: "Pergunte, ou escreva / para os comandos",
185
221
  emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
186
222
  emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
187
223
  forBoth: "Usar nos dois modos",
package/web/index.html CHANGED
@@ -111,6 +111,7 @@
111
111
  </div>
112
112
  <h3 data-i18n="chooseModel">Choose a model</h3>
113
113
  <label class="check"><input type="checkbox" id="model-both"> <span data-i18n="forBoth">Use it in both modes</span></label>
114
+ <input type="search" id="model-search" class="model-search" data-i18n-placeholder="searchModels" placeholder="Search models" autocomplete="off" spellcheck="false">
114
115
  <ul class="models" id="models"></ul>
115
116
  <h3 data-i18n="connectProvider">Connect a provider</h3>
116
117
  <form id="login-form" class="login">
@@ -121,6 +122,7 @@
121
122
  <input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
122
123
  </label>
123
124
  <p class="small"><a id="login-link" href="#" target="_blank" rel="noopener noreferrer" data-i18n="getKey">Get a key</a></p>
125
+ <p class="small free-note" id="login-free" hidden></p>
124
126
  <p class="small muted" data-i18n="keyNote"></p>
125
127
  <div class="modal-buttons"><button type="submit" class="primary" id="login-save" data-i18n="save">Save</button></div>
126
128
  <p class="small muted" data-i18n="oauthNote"></p>