@tiagojct/subsub 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  Sub-Sub follows the version numbers of npm: a change in the second number (0.13) adds features or changes how you use it; a change in the third (0.13.1) only fixes.
4
4
 
5
+ ## 0.24.1 (2026-11-01)
6
+
7
+ - In the terminal, the tool line of a library search with several queries (your words and their synonyms, 0.24.0) shows the words it searched. It showed nothing after the tool name.
8
+
9
+ ## 0.24.0 (2026-11-01)
10
+
11
+ With the Zotero server 0.7.0.
12
+
13
+ - Inside plain pi, Sub-Sub no longer turns off the tools of pi and of your other extensions: it adds its own and swaps only those when the mode changes (issue #1). The `subsub` command, `subsub web` and `subsub tui` keep only Sub-Sub's tools, as before.
14
+ - Without Zotero. Sub-Sub starts when Zotero is closed, and `subsub doctor` shows a NOTE, not a FIX. The library line says "Zotero is closed: search only". The Researcher can then search PubMed, Europe PMC and OpenAlex, read open-access full texts and write notes; the Librarian needs Zotero. Sub-Sub is still made to start from your Zotero library. A new FAQ in the README and on the Help page says this.
15
+ - Searching the library: one search with your words and their synonyms. The items that only a synonym found are listed under their own head, "Found only by a synonym", because they can be only near the topic.
16
+ - Another provider that speaks the OpenAI API: your institution's server, or a local one (Ollama, LM Studio). Connect it in `subsub init`, in the browser view (the model button, "Another provider") or with `/login` in `subsub tui`. Sub-Sub saves it in `models.json`, readable only by you; a server on another computer must use https. This is also how the FMUP model service will connect.
17
+ - Group libraries, read-only. The library search covers your library and the group libraries in Zotero, and marks each item from a group with the group's name. Sub-Sub can read, quote and write notes on items in a group; it cannot change them.
18
+ - `subsub tui`: `/login` connects a provider (an API key, shown as stars, or another provider), and `/resume` opens the earlier conversations.
19
+ - A cost limit. With `"costLimit": 2` (US dollars) in the settings, Sub-Sub says when a conversation has used 80% of it, and from the limit on it asks before each new request to the model. It never stops an answer that is running. The browser view then shows the cost next to the model.
20
+ - `/check-library` (Check the library in the actions): duplicates, retracted or corrected works and files without a parent item, in one report, then one preview to tag the retracted works `_retracted`. Sub-Sub does not merge or delete.
21
+ - `/lit` counts the duplicates removed and the works already in your library, in the screening log and the flow diagram. At the end it offers a RIS file of the screened works, for a second screening in Rayyan or Covidence. Bibliographies can now be RIS as well as CSL JSON, and can include works that are not in your library (by DOI, PMID or ISBN).
22
+ - Tests: plain pi with other tools, the prompt without Zotero, the library search with groups, the provider checks, `/login` in `subsub tui` with the key hidden, the cost limit, and the new command.
23
+
5
24
  ## 0.23.0 (2026-10-24)
6
25
 
7
26
  - The browser view is set like the website. Replies and notes have the website's headings (a fine rule above each section), its numbered steps and a slightly larger text. A note opens on the lighter field of a page, and the list of notes is grouped by folder under small-capital heads, with the date on the right. The empty conversation has the website's running head and a larger heading. Notes and the model in the top bar are plain text buttons, as in the website's menu. A command you send (`/read ...`) is shown in the mono. The working line is drawn dotted, dashed and solid, as in the terminal, instead of a moving gradient. Scrollbars are thin. On a phone, Notes is no longer cut to "No..." (the theme button is in the side panel there).
package/README.md CHANGED
@@ -39,7 +39,7 @@ The [Commands page](https://subsub.tiagojacinto.eu/profiles/) lists the tools an
39
39
 
40
40
  ## Requirements
41
41
 
42
- - Zotero 10 or later, running, with Settings > Advanced > "Allow other applications on this computer to communicate with Zotero" on.
42
+ - Zotero 10 or later, running, with Settings > Advanced > "Allow other applications on this computer to communicate with Zotero" on. Sub-Sub starts without Zotero, but then only the search tools work (see [FAQ](#faq)).
43
43
  - Node.js 22.19 or later, and [uv](https://docs.astral.sh/uv/). The installers add both if they are missing.
44
44
  - 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)).
45
45
  - macOS, Linux or Windows.
@@ -70,7 +70,7 @@ subsub doctor
70
70
 
71
71
  `subsub init` asks a few questions. Press Enter to accept the default. It creates the Sub-Sub folder (`~/Documents/Sub-Sub`, or a `Sub-Sub` folder inside your Obsidian vault) with `Inbox/`, `Literature/`, `Syntheses/`, `Research/` and `Zotero/`, a starter tag list (`Zotero/Zotero tags.md`) and the note formats (`Zotero/Zotero agent.md`). `subsub doctor` checks the set-up; each line marked FIX says what to do.
72
72
 
73
- If you already use pi, you can add Sub-Sub to it: `pi install npm:@tiagojct/subsub`.
73
+ If you already use pi, you can add Sub-Sub to it: `pi install npm:@tiagojct/subsub`. Sub-Sub then adds its tools and keeps pi's tools and the tools of your other extensions.
74
74
 
75
75
  Sub-Sub runs its Zotero server, [zotero-local-mcp](https://github.com/tiagojct/zotero-local-mcp), from PyPI with uv.
76
76
 
@@ -89,7 +89,7 @@ In the browser view, connect a model provider with the model button. In the term
89
89
 
90
90
  The terminal has its own look, drawn like the website: a footer with the mode, the library, the model, the context in use and the cost; Sub-Sub's tools in plain words (`search the library: asthma`); and an approval screen for library changes (`y` applies, `n` or Esc leaves the library as it is, Enter does nothing). `/notes`, `/library` and `/actions` open screens for your notes, a library search and the actions of the browser view; F2, F3, F4 and F5 (mode) are shortcuts; so are Alt+N, Alt+L, Alt+A and Alt+M where your terminal sends Alt (in macOS Terminal and iTerm2, Option types a symbol unless you turn on "Use Option as Meta key", and a Mac needs Fn with the F key unless the F keys are set as standard). Nothing is red: a failure is ink with a hollow mark and a word, and a removal is struck through. For terminals that cannot draw box glyphs, set `SUBSUB_ASCII=1`; `SUBSUB_REDUCE_MOTION=1` stops the working indicator from moving; `"look": false` in the settings brings back the plain screen.
91
91
 
92
- `subsub tui` puts Sub-Sub on the whole terminal, as the browser view puts it in the browser: a conversation that scrolls, a side panel from 110 columns, the editor and the footer, with the same colours and screens. Besides the commands above, `/sessions` and `/new` change the conversation, `/model` chooses the model, `/restart` starts pi again in the same conversation, and `/quit` leaves (Ctrl+C twice also quits). `--light` or `--dark` set the colours when your terminal does not say. It does not have pi's own screens (`/login`, `/settings`, `/tree`, `/resume`): use `subsub` or `subsub web` for them.
92
+ `subsub tui` puts Sub-Sub on the whole terminal, as the browser view puts it in the browser: a conversation that scrolls, a side panel from 110 columns, the editor and the footer, with the same colours and screens. Besides the commands above, `/sessions` (or `/resume`) and `/new` change the conversation, `/model` chooses the model, `/login` connects a provider (an API key, or another provider that speaks the OpenAI API), `/restart` starts pi again in the same conversation, and `/quit` leaves (Ctrl+C twice also quits). `--light` or `--dark` set the colours when your terminal does not say. It does not have pi's other screens (`/settings`, `/tree`, `/fork`) or sign-in through a provider's website: use `subsub` or `subsub web` for them.
93
93
 
94
94
  The browser view also has a library search (each result can start `/read` or `/lit-note`), a notes pane that shows the notes of your Sub-Sub folder next to the conversation (note paths and `[[links]]` in replies open there), argument hints for the commands, and rename, delete (to `~/.subsub/trash`) and export for conversations.
95
95
 
@@ -106,9 +106,10 @@ Commands in Sub-Sub:
106
106
  | `/ai-statement [uses]` | A draft statement on the use of AI for a manuscript, with the Sub-Sub version, its DOI and your models |
107
107
  | `/tag-batch [size]` | Tag items that have no topic, with your tag list |
108
108
  | `/clean-tags` | Find tags that are not in your tag list and propose fixes |
109
+ | `/check-library` | Check the whole library: duplicates, retracted or corrected works, files without a parent item. One report, then one preview to tag retracted works `_retracted` |
109
110
  | `/import-queue [path]` | Import the references in the import queue note |
110
111
  | `/read [reason] <citekey>` | Guided reading of one paper. Sub-Sub asks why you read it (background, one fact, extraction for a review, critique), finds the passages and asks questions; you write the answers. For extraction it adds a row to your extraction sheet in `Research/` |
111
- | `/lit <question>` | Literature review: the library first, then one merged search; an evidence table, agreement, disagreement and open questions; one note in `Research/` |
112
+ | `/lit <question>` | Literature review: the library first, then one merged search; an evidence table, agreement, disagreement and open questions; one note in `Research/`, with a flow of the counts (duplicates removed, already in the library, screened, read, used). On request, a RIS file of the screened works for Rayyan or Covidence |
112
113
  | `/compare <question or citekeys>` | A matrix of sources: claim, evidence, caveats, confidence |
113
114
  | `/lit-note <citekey>` | A literature note on one item, from its full text |
114
115
  | `/synthesis <topic>` | A synthesis of what the library says on a topic |
@@ -142,6 +143,7 @@ subsub review apply 13-31
142
143
  | `defaultMode` | `researcher` or `librarian` | `researcher` |
143
144
  | `addons` | `["starbuck"]` adds reference checks | `[]` |
144
145
  | `usageLog` | `true` keeps the pilot's usage log on this computer (`subsub usage`) | off |
146
+ | `costLimit` | US dollars for one conversation, for example `2`. At 80% Sub-Sub says so; from the limit on, it asks before each new request to the model. A running answer is not stopped. The browser view then shows the cost next to the model | none |
145
147
  | `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
146
148
  | `starbuckDir` | A local copy of Starbuck, for development | the pinned release |
147
149
  | `look`, `quotes`, `themes` | The terminal look (header, footer, approval screen, tool lines), the Moby-Dick quotations, and the terminal colours (`subsub-glauca`, `subsub-try-works`, or `false` for your pi theme). `themes` can be `{"researcher": "...", "librarian": "..."}`: the Librarian uses `subsub-try-works` unless you say otherwise | on, on, `subsub-glauca` |
@@ -162,6 +164,18 @@ Sub-Sub finds open-access copies through Unpaywall and Europe PMC. For other pap
162
164
 
163
165
  Turn it on in `subsub init`, with the switch in the browser view, or with `"addons": ["starbuck"]` in the settings. Then `subsub doctor` must show Starbuck ok. Starbuck writes its report in a `_starbuck` folder next to the manuscript.
164
166
 
167
+ ## FAQ
168
+
169
+ ### Do I need a Zotero library?
170
+
171
+ Sub-Sub is made to start from your Zotero library: it reads what you already have, and it finds, imports and tags only what is missing. Use it with Zotero when you can, even if the library is empty at first.
172
+
173
+ Sub-Sub also starts when Zotero is not running. The library line then says "Zotero is closed: search only". In the Researcher, Sub-Sub can search PubMed, Europe PMC and OpenAlex, read open-access full texts and write notes in the Sub-Sub folder. It cannot search, read, import, tag or undo in the library, and it cannot attach notes to items. The Librarian needs Zotero. `subsub doctor` shows a NOTE for Zotero, not a FIX. Start Zotero and type `/subsub` to connect.
174
+
175
+ ### Can Sub-Sub use group libraries?
176
+
177
+ Yes, for reading. The library search covers your own library and the group libraries in your Zotero, and marks each item from a group with the group's name. Sub-Sub can read, quote and write notes on items in a group, but it cannot change them (tags, fields, imports): change them in the Zotero window.
178
+
165
179
  ## Model test
166
180
 
167
181
  `subsub-bench` runs several models on the same tasks against your own library and scores them. Nothing in the library changes. See [docs/model-test.md](https://github.com/tiagojct/subsub/blob/main/docs/model-test.md) and the [Models page](https://subsub.tiagojacinto.eu/models/) for the latest results.
package/dist/chrome.js CHANGED
@@ -92,7 +92,7 @@ export function toolDetail(args, max = 40) {
92
92
  if (!args || typeof args !== "object")
93
93
  return "";
94
94
  const a = args;
95
- for (const k of ["query", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
95
+ for (const k of ["query", "queries", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
96
96
  const v = a[k];
97
97
  if (v === undefined || v === null || v === "")
98
98
  continue;
package/dist/config.js CHANGED
@@ -57,7 +57,7 @@ export function uiLanguage(language) {
57
57
  return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
58
58
  }
59
59
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
60
- export const SERVER_VERSION = "0.6.0";
60
+ export const SERVER_VERSION = "0.7.0";
61
61
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
62
62
  export const STARBUCK_VERSION = "0.2.0";
63
63
  export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
@@ -162,6 +162,7 @@ export function loadConfig(env = process.env) {
162
162
  ? settingsFile(vault, "rules")
163
163
  : undefined,
164
164
  startTimeout: user.startTimeout ?? 45,
165
+ costLimit: typeof user.costLimit === "number" && user.costLimit > 0 ? user.costLimit : undefined,
165
166
  uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
166
167
  look: user.look,
167
168
  quotes: user.quotes,
package/dist/cost.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The cost limit of a session (`costLimit` in the settings, in US dollars). At 80% Sub-Sub says so once; from 100%
3
+ * it asks before each new request. A running answer is never cut.
4
+ */
5
+ /** Where the session's cost is against the limit. An unknown cost (null) or no limit is "ok". */
6
+ export function costStatus(spent, limit) {
7
+ if (spent === null || !limit || limit <= 0)
8
+ return { level: "ok", spent: spent ?? 0, limit };
9
+ return { level: spent >= limit ? "over" : spent >= 0.8 * limit ? "near" : "ok", spent, limit };
10
+ }
11
+ export const money = (n) => `$${n.toFixed(2)}`;
12
+ /** Commands that do not send anything to the model: no question for these. */
13
+ export const LOCAL_COMMANDS = new Set(["subsub", "researcher", "librarian", "history", "undo", "notes", "library", "actions", "feedback"]);
14
+ export function usesModel(text) {
15
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(text.trim())?.[1];
16
+ return !cmd || !(cmd.startsWith("subsub-") || LOCAL_COMMANDS.has(cmd));
17
+ }
package/dist/doctor.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * Checks Node, the settings file, uv, the Zotero server (it runs
5
5
  * `zotero-local-mcp --check`), Zotero itself, the tag list, the notes folder,
6
6
  * the contact email, Starbuck (when the add-on is on) and the model login. Exit
7
- * code 1 when something needs a fix.
7
+ * code 1 when something needs a fix. Zotero is optional: when it is not
8
+ * running, the line is a NOTE, because the search tools work without it.
8
9
  */
9
10
  import { spawnSync } from "node:child_process";
10
11
  import { existsSync, readFileSync } from "node:fs";
@@ -12,6 +13,7 @@ import { homedir } from "node:os";
12
13
  import { join } from "node:path";
13
14
  import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, refreshedArgs, } from "./config.js";
14
15
  import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
16
+ import { customProviders } from "./providers.js";
15
17
  export const defaultRunner = (command, args, env) => {
16
18
  const r = spawnSync(command, args, { env, encoding: "utf8", timeout: 120_000 });
17
19
  return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error?.message };
@@ -22,6 +24,8 @@ function nodeOk(version) {
22
24
  return major > 22 || (major === 22 && minor >= 19);
23
25
  }
24
26
  function hasLogin(agentDir) {
27
+ if (customProviders(agentDir).length)
28
+ return true; // another provider, in models.json
25
29
  try {
26
30
  const data = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
27
31
  return Boolean(data) && typeof data === "object" && Object.keys(data).length > 0;
@@ -80,7 +84,14 @@ export function runChecks(cfg, env, deps = {}) {
80
84
  out.push({ name: "Zotero server", ok: true, detail: local ? `from ${cfg.serverDir}` : `zotero-local-mcp ${SERVER_VERSION}` });
81
85
  out.push(st.zotero === "reachable"
82
86
  ? { name: "Zotero", ok: true, detail: `reachable, write permission ${st.write_key_remembered ? "remembered" : "asked at the first change"}` }
83
- : { name: "Zotero", ok: false, detail: String(st.error ?? "not reachable"), fix: `Start Zotero 10 or later. ${ZOTERO_SETTING}` });
87
+ : // Optional: without Zotero, Sub-Sub still searches PubMed, Europe PMC and OpenAlex and writes notes.
88
+ {
89
+ name: "Zotero",
90
+ ok: true,
91
+ warn: true,
92
+ detail: `${String(st.error ?? "not reachable")}: the search tools work, the library tools do not`,
93
+ fix: `Start Zotero 10 or later. ${ZOTERO_SETTING}`,
94
+ });
84
95
  const v = st.vocabulary ?? {};
85
96
  if (v.loaded === false || !v.tags) {
86
97
  const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
package/dist/init.js CHANGED
@@ -19,7 +19,8 @@
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, --tags health-sciences|health-informatics|any-field,
22
- * --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
22
+ * --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free|custom,
23
+ * and for custom: --provider-url, --provider-key, --provider-model.
23
24
  */
24
25
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
26
  import { homedir } from "node:os";
@@ -30,6 +31,7 @@ import { configPath, DEFAULT_MODELS, EU_MODELS, FREE_MODELS, expand, loadConfig,
30
31
  import { defaultRunner, starbuckCheck } from "./doctor.js";
31
32
  import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
32
33
  import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
34
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.js";
33
35
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
34
36
  export const STARTER_TAGS = {
35
37
  "health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
@@ -37,6 +39,18 @@ export const STARTER_TAGS = {
37
39
  "any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
38
40
  };
39
41
  export const TESTED_MODELS = DEFAULT_MODELS;
42
+ /**
43
+ * After a login in subsub web or subsub tui: when no models are set yet, OpenCode Go gets the tested models and
44
+ * another provider (spec "<name>/<id>") its own model in both modes. Models already chosen stay.
45
+ */
46
+ export function modelsAfterLogin(env, provider, spec) {
47
+ if (Object.keys(loadConfig(env).models).length)
48
+ return;
49
+ if (spec)
50
+ saveConfig({ models: { librarian: spec, researcher: spec }, model: undefined }, env);
51
+ else if (provider === "opencode-go")
52
+ saveConfig({ models: TESTED_MODELS, model: undefined }, env);
53
+ }
40
54
  /** Set KEY=VALUE lines in an env file text; other lines and comments stay. */
41
55
  export function upsertEnv(text, values) {
42
56
  const lines = text ? text.replace(/\n$/, "").split("\n") : [];
@@ -173,6 +187,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
173
187
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
174
188
  { value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
175
189
  { 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)` },
190
+ { value: "custom", label: "Another provider that speaks the OpenAI API: your institution's server, or a local one (Ollama, LM Studio)" },
176
191
  ];
177
192
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
178
193
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
@@ -183,6 +198,16 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
183
198
  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.`);
184
199
  googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
185
200
  }
201
+ let custom;
202
+ if (models === "custom") {
203
+ explain("Another provider", `Give the address of the server (it usually ends in /v1), the API key and the model id. Sub-Sub saves them in ${join(agentDir, "models.json")}, readable only by you, and uses the model in both modes. Your messages go to that server.`);
204
+ custom = {
205
+ name: CUSTOM_DEFAULT_NAME,
206
+ baseUrl: (await io.ask("Server address, e.g. https://llm.example.org/v1", flags["provider-url"] ?? "")).trim(),
207
+ apiKey: (await io.ask("API key (Enter for none, for a local server)", flags["provider-key"] ?? "")).trim(),
208
+ models: [(await io.ask("Model id", flags["provider-model"] ?? "")).trim()],
209
+ };
210
+ }
186
211
  // ---- write
187
212
  if (move === "yes")
188
213
  applyMoves(moves);
@@ -235,8 +260,19 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
235
260
  patch.models = FREE_MODELS;
236
261
  if (models === "mistral-eu")
237
262
  patch.models = EU_MODELS;
238
- saveConfig(patch, initEnv);
239
263
  let keyNote = "";
264
+ if (custom) {
265
+ try {
266
+ const [spec] = saveCustomProvider(agentDir, custom);
267
+ patch.models = { librarian: spec, researcher: spec };
268
+ keyNote = `Saved: the provider ${custom.name}, in ${join(agentDir, "models.json")}`;
269
+ }
270
+ catch (err) {
271
+ patch.models = current.models;
272
+ keyNote = `The provider was not saved: ${err.message} Type subsub init again, or choose a model in Sub-Sub.`;
273
+ }
274
+ }
275
+ saveConfig(patch, initEnv);
240
276
  if (googleKey) {
241
277
  try {
242
278
  saveApiKey(agentDir, "google", googleKey);
package/dist/look.js CHANGED
@@ -55,8 +55,8 @@ export function themeName(scheme, base = DEFAULT_THEME, colorMode) {
55
55
  export const plain = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, ok: (s) => s, pend: (s) => s, strike: (s) => s };
56
56
  /** The words of the library line, as in the web view (web/i18n.js: zoteroChecking, zoteroDown, items, toReview). */
57
57
  export const LIBRARY_WORDS = {
58
- en: { checking: "Zotero: checking...", down: "Zotero is not running. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
59
- pt: { checking: "Zotero: a verificar...", down: "O Zotero não está aberto. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
58
+ en: { checking: "Zotero: checking...", down: "Zotero is closed: search only. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
59
+ pt: { checking: "Zotero: a verificar...", down: "O Zotero está fechado: só pesquisa. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
60
60
  };
61
61
  function cut(s, width) {
62
62
  return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
package/dist/prompt.js CHANGED
@@ -50,7 +50,7 @@ export function roleText(mode, cfg) {
50
50
  const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
51
51
  return cfg ? fill(raw, cfg) : raw;
52
52
  }
53
- export function systemAddition(mode, cfg, cwd) {
53
+ export function systemAddition(mode, cfg, cwd, state = {}) {
54
54
  const user = who(cfg);
55
55
  const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
56
56
  if (cfg.vault) {
@@ -70,6 +70,11 @@ export function systemAddition(mode, cfg, cwd) {
70
70
  if (cfg.setup === "fmup") {
71
71
  parts.push("# U.Porto", `${capital(user)} works at the University of Porto. Papers that are not open access often open through the university's subscriptions when ${user} uses Sub-Sub on the U.Porto network: say so when a work is not open access.`);
72
72
  }
73
+ if (state.zoteroDown) {
74
+ parts.push("# Zotero is closed", mode === "librarian"
75
+ ? `Zotero is not running now, so the zotero_ tools fail. The Librarian needs Zotero: tell ${user} to start Zotero and type /subsub. In the Researcher, searches in PubMed, Europe PMC and OpenAlex work without Zotero.`
76
+ : `Zotero is not running now, so the zotero_ tools fail. The scholar_ tools work: search PubMed, Europe PMC and OpenAlex, read open-access full texts, and write notes in the Sub-Sub folder. When a request needs ${user}'s library, say that Zotero is closed and ask ${user} to start it and type /subsub. Do not say whether a work is in the library.`);
77
+ }
73
78
  // The language rule comes last, so it wins over the shared rules and the vault conventions.
74
79
  parts.push("# Language", languageRule(cfg.language, user));
75
80
  return parts.join("\n\n");
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A model provider that pi does not include: a server that speaks the OpenAI API (an institution's
3
+ * server, Ollama, LM Studio, vLLM). Sub-Sub writes it to pi's models.json in the agent folder.
4
+ * The FMUP model service will be one of these.
5
+ */
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { KEY_PROVIDERS } from "./keys.js";
9
+ export const CUSTOM_DEFAULT_NAME = "custom";
10
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
11
+ /** The problem with a custom provider, or undefined when it can be saved. */
12
+ export function customProviderProblem(p) {
13
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(p.name))
14
+ return "The provider name can have only lowercase letters, digits and hyphens.";
15
+ if (KEY_PROVIDERS.some((k) => k.id === p.name))
16
+ return `${p.name} is a provider that pi already has: select it in the list.`;
17
+ let url;
18
+ try {
19
+ url = new URL(p.baseUrl.trim());
20
+ }
21
+ catch {
22
+ return "The address must be a full URL, for example https://llm.example.org/v1.";
23
+ }
24
+ if (url.protocol !== "https:" && url.protocol !== "http:")
25
+ return "The address must start with https://.";
26
+ if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname))
27
+ return "Use https:// for a server on another computer, so that your key and your messages are encrypted.";
28
+ const key = (p.apiKey ?? "").trim();
29
+ if (/\s/.test(key) || key.length > 400)
30
+ return "That does not look like an API key.";
31
+ const ids = p.models.map((m) => m.trim()).filter(Boolean);
32
+ if (!ids.length)
33
+ return "Give at least one model id.";
34
+ if (ids.some((m) => /\s/.test(m) || m.length > 200))
35
+ return "A model id has no spaces.";
36
+ return undefined;
37
+ }
38
+ /**
39
+ * Add or replace the provider in <agentDir>/models.json; other providers stay. The file is readable only by
40
+ * the user, because it can hold the key. Returns the model specs ("<name>/<id>").
41
+ */
42
+ export function saveCustomProvider(agentDir, p) {
43
+ const problem = customProviderProblem(p);
44
+ if (problem)
45
+ throw new Error(problem);
46
+ mkdirSync(agentDir, { recursive: true });
47
+ const file = join(agentDir, "models.json");
48
+ let data = {};
49
+ if (existsSync(file)) {
50
+ try {
51
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
52
+ }
53
+ catch {
54
+ throw new Error(`${file} is not valid JSON; fix it first.`);
55
+ }
56
+ }
57
+ const ids = [...new Set(p.models.map((m) => m.trim()).filter(Boolean))];
58
+ data.providers = {
59
+ ...(data.providers ?? {}),
60
+ // pi shows a provider's models only when it has a key; a local server ignores the placeholder.
61
+ [p.name]: { baseUrl: p.baseUrl.trim(), api: "openai-completions", apiKey: (p.apiKey ?? "").trim() || "none", models: ids.map((id) => ({ id })) },
62
+ };
63
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
64
+ chmodSync(file, 0o600); // also when the file was there before
65
+ return ids.map((id) => `${p.name}/${id}`);
66
+ }
67
+ /** The providers in <agentDir>/models.json that have at least one model. */
68
+ export function customProviders(agentDir) {
69
+ try {
70
+ const data = JSON.parse(readFileSync(join(agentDir, "models.json"), "utf8"));
71
+ return Object.entries((data?.providers ?? {}))
72
+ .filter(([, v]) => Array.isArray(v?.models) && v.models.length > 0)
73
+ .map(([k]) => k);
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
package/dist/roles.js CHANGED
@@ -69,6 +69,17 @@ export function toolsFor(mode, registered, opts = {}) {
69
69
  ];
70
70
  return [...new Set(wanted)].filter((n) => has.has(n));
71
71
  }
72
+ /**
73
+ * The active tools for `next`, Sub-Sub's set for the mode. Inside plain pi, Sub-Sub replaces only its own
74
+ * server tools and keeps the rest (pi's tools, other extensions' tools); with the `subsub` command, the set is
75
+ * Sub-Sub's alone.
76
+ */
77
+ export function activeTools(current, next, standalone) {
78
+ if (standalone)
79
+ return next;
80
+ const kept = current.filter((n) => !SERVER_PREFIXES.some((p) => n.startsWith(p)));
81
+ return [...new Set([...kept, ...next])];
82
+ }
72
83
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
73
84
  export function unavailableReason(name, mode, registered, opts = {}) {
74
85
  if (toolsFor(mode, registered, opts).includes(name))
package/dist/screens.js CHANGED
@@ -204,6 +204,11 @@ export class NotesView {
204
204
  this.o.redraw?.();
205
205
  }
206
206
  }
207
+ /** How a command names an item: the citekey (or key), and the group library when it is in one. */
208
+ export function itemRef(it) {
209
+ const ref = it.citekey || it.key;
210
+ return it.library ? `${ref} in ${it.library}` : ref;
211
+ }
207
212
  export function libraryLines(o) {
208
213
  const g = o.glyphs ?? UNICODE;
209
214
  const p = o.paint;
@@ -225,7 +230,7 @@ export function libraryLines(o) {
225
230
  const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
226
231
  for (const [k, it] of o.items.slice(start, start + fit).entries()) {
227
232
  const chosen = start + k === o.selected;
228
- const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
233
+ const meta = [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ");
229
234
  out.push(chosen ? `${p.mark(g.pointer)} ${p.text(cut(it.title || it.key, w - 2))}` : ` ${p.muted(cut(it.title || it.key, w - 2))}`, ` ${p.dim(cut(meta, w - 2))}`);
230
235
  }
231
236
  }
@@ -398,7 +403,8 @@ export class InputView {
398
403
  this.text = o.prefill ?? "";
399
404
  }
400
405
  render(width) {
401
- return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text: this.text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
406
+ const text = this.o.secret ? "*".repeat([...this.text].length) : this.text;
407
+ return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
402
408
  }
403
409
  invalidate() { }
404
410
  handleInput(data) {
package/dist/subsub.js CHANGED
@@ -30,14 +30,15 @@ import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
30
30
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
31
31
  import { headerLines, LIBRARIAN_THEME, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
32
32
  import { footerLines, toolRenderers, usageTotals } from "./chrome.js";
33
+ import { costStatus, money, usesModel } from "./cost.js";
33
34
  import { ApprovalView } from "./dialogs.js";
34
35
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.js";
35
- import { LibraryView, NotesView } from "./screens.js";
36
+ import { itemRef, LibraryView, NotesView } from "./screens.js";
36
37
  import { glyphs, paintFromTheme, workingFrames } from "./skin.js";
37
38
  import { loadWords, toolWord } from "./words.js";
38
39
  import { systemAddition } from "./prompt.js";
39
40
  import { logUsage } from "./usage.js";
40
- import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.js";
41
+ import { activeTools, gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.js";
41
42
  const MODE_ENTRY = "subsub-mode";
42
43
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
43
44
  const BENCH_ENTRY = "subsub-bench";
@@ -152,6 +153,8 @@ export async function createSubsub(pi, deps = {}) {
152
153
  let resumeTimer;
153
154
  let turnStart = 0;
154
155
  let turnTools = 0;
156
+ /** The cost level already noted in this session, so each note comes once. */
157
+ let costNoted;
155
158
  let webCtx;
156
159
  function emitWeb() {
157
160
  const ctx = webCtx;
@@ -167,6 +170,8 @@ export async function createSubsub(pi, deps = {}) {
167
170
  userName: cfg.userName ?? null,
168
171
  vault: cfg.vault ?? null,
169
172
  starbuck: starbuckEnabled(cfg),
173
+ // Shown next to the model only when there is a limit.
174
+ cost: cfg.costLimit ? { spent: usageTotals(ctx.sessionManager.getEntries()).cost, limit: cfg.costLimit } : null,
170
175
  };
171
176
  try {
172
177
  ctx.ui.setStatus("subsub.web", JSON.stringify(state));
@@ -242,14 +247,9 @@ export async function createSubsub(pi, deps = {}) {
242
247
  async function openLibrary(ctx) {
243
248
  if (!screenOk(ctx))
244
249
  return;
245
- const ref = (it) => it.citekey || it.key;
250
+ const ref = itemRef;
246
251
  await ctx.ui.custom((tui, theme, _keys, done) => new LibraryView({
247
- search: async (query) => {
248
- const r = await current().call("zotero_find_items", { query, limit: 25 });
249
- if (r.isError)
250
- throw new Error(r.text);
251
- return (r.data?.items ?? []).filter((it) => it && it.key);
252
- },
252
+ search: searchLibraries,
253
253
  choose: (it) => {
254
254
  ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
255
255
  done();
@@ -285,6 +285,36 @@ export async function createSubsub(pi, deps = {}) {
285
285
  })()
286
286
  : text;
287
287
  }
288
+ /** The group libraries in Zotero, from the last library check (read-only; searched with the user's library). */
289
+ let groups = [];
290
+ /** Search the user's library, then each group library; a group's items carry the group. */
291
+ async function searchLibraries(query) {
292
+ const first = await current().call("zotero_find_items", { query, limit: 25 });
293
+ if (first.isError)
294
+ throw new Error(first.text);
295
+ const items = (first.data?.items ?? []).filter((it) => it && it.key);
296
+ for (const g of groups) {
297
+ const r = await current().call("zotero_find_items", { query, limit: 25, library: g.library });
298
+ if (r.isError)
299
+ continue;
300
+ for (const it of r.data?.items ?? [])
301
+ if (it?.key)
302
+ items.push({ ...it, library: g.library, group: g.name });
303
+ }
304
+ return items;
305
+ }
306
+ async function refreshGroups() {
307
+ if (!current().has("zotero_list_libraries"))
308
+ return;
309
+ try {
310
+ const r = await current().call("zotero_list_libraries", {});
311
+ const all = (r.data?.libraries ?? []);
312
+ groups = r.isError ? [] : all.filter((l) => l.library?.startsWith("group:")).map((l) => ({ library: l.library, name: l.name || l.library }));
313
+ }
314
+ catch {
315
+ groups = [];
316
+ }
317
+ }
288
318
  async function refreshLibrary() {
289
319
  try {
290
320
  const st = await current().call("zotero_status", {});
@@ -296,6 +326,7 @@ export async function createSubsub(pi, deps = {}) {
296
326
  const ov = await current().call("zotero_library_overview", {});
297
327
  const o = (ov.data ?? {});
298
328
  Object.assign(library, { zotero: "reachable", items: o.items, toReview: o.awaiting_review });
329
+ await refreshGroups();
299
330
  }
300
331
  }
301
332
  catch {
@@ -366,7 +397,7 @@ export async function createSubsub(pi, deps = {}) {
366
397
  /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
367
398
  async function applyMode(ctx, next, remember, switchModel = true) {
368
399
  mode = next;
369
- pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
400
+ pi.setActiveTools(activeTools(pi.getActiveTools(), toolsFor(mode, registered(), toolOpts()), standalone));
370
401
  const spec = cfg.models[mode];
371
402
  if (spec && switchModel) {
372
403
  const i = spec.indexOf("/");
@@ -386,6 +417,7 @@ export async function createSubsub(pi, deps = {}) {
386
417
  status(ctx);
387
418
  }
388
419
  pi.on("session_start", async (_event, ctx) => {
420
+ costNoted = undefined; // a new or another conversation: its own cost
389
421
  if (!bridge) {
390
422
  const b = new Bridge();
391
423
  await b.start(deps.specs ?? serverSpecs(cfg), cfg.startTimeout * 1000);
@@ -486,6 +518,15 @@ export async function createSubsub(pi, deps = {}) {
486
518
  // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
487
519
  // up to three times per request of the user. Anything the user types cancels the wait.
488
520
  pi.on("agent_end", async (_event, ctx) => {
521
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
522
+ if (spent.level !== "ok" && costNoted !== spent.level) {
523
+ costNoted = spent.level;
524
+ ctx.ui.notify(spent.level === "near"
525
+ ? L(`Sub-Sub: this conversation has used 80% of your cost limit (${money(spent.spent)} of ${money(spent.limit)}).`, `Sub-Sub: esta conversa já usou 80% do seu limite de custo (${money(spent.spent)} de ${money(spent.limit)}).`)
526
+ : L(`Sub-Sub: this conversation has reached your cost limit (${money(spent.spent)} of ${money(spent.limit)}). Sub-Sub will ask before each new request.`, `Sub-Sub: esta conversa atingiu o seu limite de custo (${money(spent.spent)} de ${money(spent.limit)}). O Sub-Sub vai perguntar antes de cada pedido novo.`), "warning");
527
+ }
528
+ if (cfg.costLimit)
529
+ emitWeb(); // the cost next to the model in the browser view
489
530
  if (turnStart)
490
531
  logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
491
532
  turnStart = 0;
@@ -511,6 +552,17 @@ export async function createSubsub(pi, deps = {}) {
511
552
  resumeTimer = undefined;
512
553
  resumes = 0;
513
554
  const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
555
+ // Over the cost limit: ask before each new request to the model (a running answer is not cut).
556
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
557
+ if (spent.level === "over" && usesModel(event.text)) {
558
+ const text = L(`This conversation has cost ${money(spent.spent)}, over your limit of ${money(spent.limit)} (costLimit in the settings). Send this request to the model?`, `Esta conversa já custou ${money(spent.spent)}, acima do seu limite de ${money(spent.limit)} (costLimit nas definições). Enviar este pedido ao modelo?`);
559
+ const go = ctx.hasUI ? await ctx.ui.confirm(L("Sub-Sub: cost limit", "Sub-Sub: limite de custo"), text) : false;
560
+ if (!go) {
561
+ if (!ctx.hasUI)
562
+ ctx.ui.notify(text, "warning");
563
+ return { action: "handled" };
564
+ }
565
+ }
514
566
  // The usage log keeps the kind of request and its length, never its text.
515
567
  if (cmd !== "subsub-refresh")
516
568
  logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
@@ -530,7 +582,7 @@ export async function createSubsub(pi, deps = {}) {
530
582
  turnTools = 0;
531
583
  });
532
584
  pi.on("before_agent_start", async (event, ctx) => ({
533
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
585
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd, { zoteroDown: library.zotero === "down" })}`,
534
586
  }));
535
587
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
536
588
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
@@ -819,6 +871,9 @@ export async function createSubsub(pi, deps = {}) {
819
871
  lines.push(ov.isError
820
872
  ? ov.text
821
873
  : `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`);
874
+ await refreshGroups();
875
+ if (groups.length)
876
+ lines.push(`Group libraries (read-only, searched with yours): ${groups.map((g) => g.name).join(", ")}.`);
822
877
  }
823
878
  }
824
879
  redraw();
@@ -842,8 +897,7 @@ export async function createSubsub(pi, deps = {}) {
842
897
  const q = String(args ?? "").trim();
843
898
  let out;
844
899
  try {
845
- const r = await current().call("zotero_find_items", { query: q, limit: 25 });
846
- out = r.isError ? { error: r.text } : { items: r.data?.items ?? [] };
900
+ out = { items: await searchLibraries(q) };
847
901
  }
848
902
  catch (err) {
849
903
  out = { error: err.message };
package/dist/tui-view.js CHANGED
@@ -208,7 +208,7 @@ export function sideLines(s, width, rows, p, g = UNICODE) {
208
208
  const w = Math.max(16, width);
209
209
  const words = SIDE_WORDS[s.lang];
210
210
  const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
211
- const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
211
+ const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero: ${s.lang === "pt" ? "só pesquisa" : "search only"}` : "Zotero...";
212
212
  const lines = [p.mark(cut(` Sub-Sub ${s.version}`, w)), p.text(cut(` ${mode}`, w)), p.muted(cut(` ${lib}`, w)), p.dim(cut(` ${s.model}`, w)), rule(w, "solid", p.muted, g)];
213
213
  for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]])
214
214
  lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));