@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/dist/tui.js CHANGED
@@ -11,12 +11,15 @@ import { CombinedAutocompleteProvider, Editor, HStack, Markdown, matchesKey, Pro
11
11
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
12
12
  import { fill, footerLines } from "./chrome.js";
13
13
  import { loadConfig, uiLanguage } from "./config.js";
14
+ import { modelsAfterLogin } from "./init.js";
14
15
  import { LIBRARIAN_THEME, DEFAULT_THEME, paintPreview, QUOTES } from "./look.js";
15
16
  import { PURPOSES } from "./read.js";
16
17
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.js";
17
18
  import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.js";
19
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
20
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.js";
18
21
  import { ApprovalView } from "./dialogs.js";
19
- import { ChoiceView, InputView, LibraryView, NotesView } from "./screens.js";
22
+ import { itemRef, ChoiceView, InputView, LibraryView, NotesView } from "./screens.js";
20
23
  import { glyphs as chooseGlyphs, workingFrames } from "./skin.js";
21
24
  import { bannerLines, entriesFromMessages, loadPalette, messageText, schemeFromEnv, SIDE_WIDTH, sideLines, transcriptLines } from "./tui-view.js";
22
25
  import { loadWords } from "./words.js";
@@ -161,6 +164,8 @@ export class TuiApp {
161
164
  { name: "library", description: pt ? "Pesquisar na biblioteca à medida que escreve" : "Search your library as you type" },
162
165
  { name: "actions", description: pt ? "As ações da vista no navegador" : "The actions of the browser view" },
163
166
  { name: "sessions", description: pt ? "Conversas anteriores" : "Earlier conversations" },
167
+ { name: "resume", description: pt ? "Retomar uma conversa anterior" : "Resume an earlier conversation" },
168
+ { name: "login", description: pt ? "Ligar um fornecedor do modelo" : "Connect a model provider" },
164
169
  { name: "new", description: pt ? "Uma conversa nova" : "A new conversation" },
165
170
  { name: "model", description: pt ? "Escolher o modelo" : "Choose the model" },
166
171
  { name: "restart", description: pt ? "Recomeçar o pi na mesma conversa" : "Start pi again in the same conversation" },
@@ -445,7 +450,7 @@ export class TuiApp {
445
450
  async openLibrary() {
446
451
  if (this.overlay)
447
452
  return;
448
- const ref = (it) => it.citekey || it.key;
453
+ const ref = itemRef;
449
454
  this.open(new LibraryView({
450
455
  ...this.common(),
451
456
  researcher: this.mode === "researcher",
@@ -503,6 +508,71 @@ export class TuiApp {
503
508
  cancel: () => this.close(),
504
509
  }), true);
505
510
  }
511
+ /** Ask one line on its own screen; undefined when the user presses Esc. */
512
+ ask(title, opts = {}) {
513
+ return new Promise((resolve) => {
514
+ this.open(new InputView({
515
+ ...this.common(),
516
+ title,
517
+ ...opts,
518
+ submit: (text) => (this.close(), resolve(text.trim())),
519
+ cancel: () => (this.close(), resolve(undefined)),
520
+ }), true);
521
+ });
522
+ }
523
+ /** /login: a provider that pi has (its API key) or another provider that speaks the OpenAI API; then pi starts again. */
524
+ async openLogin() {
525
+ if (this.overlay)
526
+ return;
527
+ const pt = this.lang === "pt";
528
+ const agentDir = this.d.agentDir;
529
+ if (!agentDir)
530
+ return void this.notice("error", pt ? "Sem pasta do agente: use subsub web." : "No agent folder: use subsub web.");
531
+ const choices = [
532
+ ...KEY_PROVIDERS.map((p) => ({ label: p.label, hint: p.url, value: p.id })),
533
+ { label: pt ? "Outro fornecedor (API da OpenAI)" : "Another provider (OpenAI API)", hint: pt ? "servidor da instituição, Ollama, LM Studio" : "your institution's server, Ollama, LM Studio", value: CUSTOM_DEFAULT_NAME },
534
+ ];
535
+ const provider = await new Promise((resolve) => {
536
+ this.open(new ChoiceView({
537
+ ...this.common(),
538
+ title: pt ? "Ligar um fornecedor" : "Connect a provider",
539
+ choices,
540
+ choose: (v) => (this.close(), resolve(v)),
541
+ cancel: () => (this.close(), resolve(undefined)),
542
+ }), true);
543
+ });
544
+ if (!provider)
545
+ return;
546
+ let spec;
547
+ try {
548
+ if (provider === CUSTOM_DEFAULT_NAME) {
549
+ const baseUrl = await this.ask(pt ? "Endereço do servidor" : "Server address", { placeholder: "https://llm.example.org/v1" });
550
+ if (baseUrl === undefined)
551
+ return;
552
+ const model = await this.ask(pt ? "Identificador do modelo" : "Model id");
553
+ if (model === undefined)
554
+ return;
555
+ const key = await this.ask(pt ? "Chave de API (Enter sem chave, num servidor local)" : "API key (Enter for none, for a local server)", { secret: true });
556
+ if (key === undefined)
557
+ return;
558
+ [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl, apiKey: key, models: [model] });
559
+ }
560
+ else {
561
+ const label = KEY_PROVIDERS.find((p) => p.id === provider)?.label ?? provider;
562
+ const key = await this.ask(pt ? `Chave de API de ${label}` : `API key for ${label}`, { secret: true });
563
+ if (!key)
564
+ return;
565
+ saveApiKey(agentDir, provider, key);
566
+ }
567
+ this.d.afterLogin?.(provider, spec);
568
+ }
569
+ catch (err) {
570
+ this.notice("error", err.message);
571
+ return void this.tui.requestRender();
572
+ }
573
+ this.notice("info", pt ? "Guardado. O pi recomeça com o fornecedor." : "Saved. pi starts again with the provider.");
574
+ await this.restart();
575
+ }
506
576
  /** /new: an empty conversation. */
507
577
  async newConversation() {
508
578
  try {
@@ -579,12 +649,12 @@ export class TuiApp {
579
649
  this.editor.setText("");
580
650
  if (/^\/(quit|exit)\s*$/.test(line))
581
651
  return void (await this.quit());
582
- const unavailable = /^\/(login|logout|settings|tree|resume|fork|share|export|import|hotkeys|reload)\b/.exec(line);
652
+ const unavailable = /^\/(logout|settings|tree|fork|share|export|import|hotkeys|reload)\b/.exec(line);
583
653
  if (unavailable) {
584
654
  this.notice("info", this.lang === "pt" ? `/${unavailable[1]} não existe em subsub tui. Use subsub no terminal ou subsub web.` : `/${unavailable[1]} is not in subsub tui. Use subsub in the terminal, or subsub web.`);
585
655
  return void this.tui.requestRender();
586
656
  }
587
- const own = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/new": () => this.newConversation() };
657
+ const own = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/resume": () => this.openSessions(), "/login": () => this.openLogin(), "/new": () => this.newConversation() };
588
658
  if (own[line])
589
659
  return void (await own[line]());
590
660
  try {
@@ -645,6 +715,8 @@ export async function runTui(args, env = process.env) {
645
715
  themes: { researcher: typeof cfg.themes === "string" ? cfg.themes : undefined, librarian: cfg.themesLibrarian ?? (typeof cfg.themes === "string" ? cfg.themes : undefined) },
646
716
  sessions: async () => (await SessionManager.list(cwd)).filter((s) => s.messageCount > 0).sort((a, b) => +new Date(b.modified) - +new Date(a.modified)).map((s) => ({ title: s.name ?? s.firstMessage.slice(0, 60), path: s.path })),
647
717
  onQuit: resolve,
718
+ agentDir: env.PI_CODING_AGENT_DIR,
719
+ afterLogin: (provider, spec) => modelsAfterLogin(env, provider, spec),
648
720
  });
649
721
  void app.start();
650
722
  });
package/dist/web.js CHANGED
@@ -20,8 +20,9 @@ import { homedir } from "node:os";
20
20
  import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
21
21
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
22
22
  import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
23
- import { TESTED_MODELS } from "./init.js";
23
+ import { modelsAfterLogin } from "./init.js";
24
24
  import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
25
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.js";
25
26
  export function parseWebArgs(args) {
26
27
  const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
27
28
  for (let i = 0; i < args.length; i++) {
@@ -463,10 +464,13 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
463
464
  return send(res, 409, { error: "busy" });
464
465
  const body = await readJson(req);
465
466
  return withRestart(res, () => {
467
+ if (body.provider === CUSTOM_DEFAULT_NAME) {
468
+ // Another provider (OpenAI API): models.json; its model becomes the default when none is set.
469
+ const [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl: String(body.baseUrl ?? ""), apiKey: String(body.key ?? ""), models: [String(body.model ?? "")] });
470
+ return modelsAfterLogin(env, CUSTOM_DEFAULT_NAME, spec);
471
+ }
466
472
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
467
- const current = loadConfig(env);
468
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
469
- saveConfig({ models: TESTED_MODELS, model: undefined }, env);
473
+ modelsAfterLogin(env, String(body.provider ?? ""));
470
474
  });
471
475
  }
472
476
  case "GET /api/notes":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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",
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: "Check the library: duplicates, retracted or corrected works, and files without a parent item"
3
+ ---
4
+ Check my whole library in three parts, then give me one report.
5
+
6
+ 1. Duplicates: run zotero_find_duplicates. For each group, give the items with their evidence (identifiers, fields, files, citekeys) and say which record is the fullest. Do not call items duplicates on a similar title alone. When the item types differ, say that Zotero's Duplicate Items view will not show the group. I merge them in Zotero; do not change these items.
7
+ 2. Retractions and corrections: run zotero_check_retractions, and call it again while still_unchecked > 0. List the retracted works and the expressions of concern first, then the corrections. Give the notice for each (type, date, DOI).
8
+ 3. Files and notes without a parent item: run zotero_standalone_items and give the count by kind, with the first ten names.
9
+
10
+ The report: one short section for each part, with counts first. Say how many items were checked and how many had no DOI (those cannot be checked for retractions). If a part found nothing, say so in one line.
11
+
12
+ Then, if there are retracted works or expressions of concern, propose the tag _retracted for them with one zotero_tag_items call (dry_run=false), so that I see one preview for all of them. Do not tag corrections. For the files without a parent item, offer the steps of "Files and notes without a parent item"; do not start them without my answer.
@@ -8,7 +8,7 @@ Make a short slug: lower case, hyphens, at most five words. Use today's date (YY
8
8
 
9
9
  1. Find the sources.
10
10
  - If the user gave citekeys, DOIs or PMIDs, use exactly those.
11
- - Otherwise: library first (zotero_find_items, with the user's own words and then close synonyms), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
11
+ - Otherwise: library first (one zotero_find_items call with queries: the user's own words, then close synonyms), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
12
12
  2. Read each source before you describe it: zotero_get_fulltext for library items, scholar_read_oa_fulltext (methods and results) for open-access works, scholar_get_work for the abstract of the others. Note whether you read the full text or only the abstract.
13
13
  3. Build the matrix, one row per source:
14
14
  | # | Source | Claim on the question | Evidence type | Population and setting | Caveats | Confidence |
package/prompts/lit.md CHANGED
@@ -18,9 +18,9 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
18
18
 
19
19
  ## 2. Gather
20
20
 
21
- 1. Library first: zotero_find_items for the topic, with the user's own words first and then with close synonyms and word forms, one call for each (and the topic tags). Note what the library already has.
21
+ 1. Library first: one zotero_find_items call for the topic with queries: the user's own words first, then close synonyms and word forms (and the topic tags). Note what the library already has. Check that the items that only a synonym found (found_by without q1) are on the topic before you use them.
22
22
  2. Outside: scholar_search_multi with three or four phrasings of the question (synonyms, the test or drug name, the condition name, MeSH terms). Give include_terms (the key concepts) and exclude_terms (populations or designs outside the question) so that the server pre-screens the results. Use year limits when the question needs recent work.
23
- 3. Screen. For every work in the merged list, decide: read, maybe, or exclude, with a reason of a few words. Prefer systematic reviews, guidelines and primary studies with the right design. Write the screening log to `Research/.plans/<slug>-screening.md`: a table (#, work, identifier, pre-screen, decision, reason) and the counts for a flow diagram: found by the searches, unique works, pre-screen excluded, screened, read, used.
23
+ 3. Screen. For every work in the merged list, decide: read, maybe, or exclude, with a reason of a few words. Prefer systematic reviews, guidelines and primary studies with the right design. Write the screening log to `Research/.plans/<slug>-screening.md`: a table (#, work, identifier, pre-screen, decision, reason) and the counts for a flow diagram: found by the searches (the library search included), duplicates removed, unique works, of these already in the library, pre-screen excluded, screened, read, used.
24
24
  4. Read before you summarise:
25
25
  - Library items: zotero_get_fulltext, or the abstract when there is no full text.
26
26
  - Open-access works: scholar_read_oa_fulltext, first the section list, then only the sections you need (usually methods and results).
@@ -62,6 +62,8 @@ review_type: AI-assisted rapid review (not a systematic review)
62
62
  question: "<question in one sentence>"
63
63
  searches: ["<phrasing 1>", "<phrasing 2>", "<phrasing 3>"]
64
64
  sources_found: <number of unique works>
65
+ duplicates_removed: <number>
66
+ sources_in_library: <number of unique works already in the library>
65
67
  sources_screened: <number>
66
68
  sources_read: <number>
67
69
  sources_used: <number>
@@ -94,7 +96,8 @@ Under Methods, after the text, draw the flow of works with the counts of the scr
94
96
 
95
97
  ```mermaid
96
98
  flowchart TD
97
- A["Found by the searches: <n>"] --> B["Unique works: <n>"]
99
+ A["Found by the searches: <n>"] --> B["Unique works: <n> (in the library: <n>)"]
100
+ A -.-> X0["Duplicates removed: <n>"]
98
101
  B -.-> X1["Excluded by the pre-screen: <n>"]
99
102
  B --> C["Screened: <n>"]
100
103
  C -.-> X2["Excluded at screening: <n>"]
@@ -103,7 +106,7 @@ flowchart TD
103
106
  D --> E["Used in this note: <n>"]
104
107
  ```
105
108
 
106
- The counts must add up: unique works = pre-screen excluded + screened; screened = excluded at screening + read; read = not used + used.
109
+ The counts must add up: found = duplicates removed + unique works; unique works = pre-screen excluded + screened; screened = excluded at screening + read; read = not used + used.
107
110
 
108
111
  If a step fails (a service does not answer, a full text cannot be read), do not stop. Finish the note, mark the step "BLOCKED: <what failed>", and set verification: BLOCKED.
109
112
 
@@ -129,3 +132,5 @@ When the user asks for a reading list (for example `/lit reading list: <topic>`,
129
132
  The Methods section has the same flow diagram, with "On the reading list: <n>" as the last box. "What it studies" says what the work asks and how, never what it found. Do not state results, effect sizes or conclusions anywhere in the note. Then give the user the path, the first work to read and why, and the works worth adding to the library.
130
133
 
131
134
  Before you answer, read the note back to confirm that it exists. For a review, give the user the path, the answer in three lines, and the works worth adding to the library (offer to import them). For a reading list, give the user what the section above says, with no results.
135
+
136
+ Then offer a RIS file of the screened works, for a second screening in Rayyan or Covidence: `Research/.plans/<slug>-screened.ris`. Write it only when the user agrees, with one scholar_export_bibliography call: citekeys for the library items, and identifiers (the DOI, or pmid:<number>) for the others. Report the works it did not find.
@@ -12,7 +12,8 @@ You change the library through the zotero_ tools. Outside metadata reaches you t
12
12
  - Call a write tool with dry_run=false when you intend the change. Sub-Sub shows {{user}} the server's preview and applies it only on approval.
13
13
  - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
14
14
  - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
15
- - Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
15
+ - Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}: the Librarian needs Zotero. Searches in PubMed, Europe PMC and OpenAlex work without Zotero in the Researcher.
16
+ - Group libraries are read-only. zotero_list_libraries lists them. To search or read a group, pass library="group:<id>" to the read tools (find_items, get_item, get_fulltext, get_figure, find_duplicates, check_retractions and others). A reference written as "<citekey or key> in group:<id>" is in that group. You cannot change items in a group: say so, and tell {{user}} to change them in the Zotero window.
16
17
  - You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
17
18
 
18
19
  ## Files and notes without a parent item
@@ -24,13 +24,14 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
24
24
  - Call a write tool with dry_run=false when you intend the change. Sub-Sub shows {{user}} the server's preview and applies it only on approval. The same holds for scholar_attach_note and scholar_export_bibliography.
25
25
  - If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
26
26
  - Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
27
- - Start work on the library with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
27
+ - Start work on the library with zotero_status. If Zotero is not reachable, tell {{user}} that the library tools do not work until Zotero is open. When the request does not need the library, continue with the scholar tools, and do not say whether a work is in the library.
28
+ - Group libraries are read-only. zotero_list_libraries lists them. To search or read a group, pass library="group:<id>" to the read tools (find_items, get_item, get_fulltext, get_figure, find_duplicates, check_retractions and others). A reference written as "<citekey or key> in group:<id>" is in that group. You cannot change items in a group: say so, and tell {{user}} to change them in the Zotero window.
28
29
  - You cannot change Sub-Sub, its servers or its settings, and you do not offer to. When a tool you need is missing or fails, say in plain words what you cannot do and what {{user}} can do instead in Zotero. {{User}} can report it at https://github.com/tiagojct/subsub/issues.
29
30
  - Older shared rules may say that the researcher cannot change the library and hands works over to the librarian. That was before version 0.10: you do both.
30
31
 
31
32
  ## Questions about the library
32
33
 
33
- - "What do I have on X": search with zotero_find_items (query, tags, fulltext=true when needed). First search with {{user}}'s own words, as written. Then search again with close synonyms and other word forms (singular and plural, noun and adjective, spelling variants, an older or foreign name), one call for each. Join the results and list each item once. Answer in the chat: citekey, year, one line per item, and the words you searched.
34
+ - "What do I have on X": search with one zotero_find_items call with queries: first {{user}}'s own words, as written, then close synonyms and other word forms (singular and plural, noun and adjective, spelling variants, an older or foreign name). Add tags, or fulltext=true, when needed. Answer in the chat: citekey, year, one line per item, and the words you searched. Put the items that only a synonym found (found_by without q1) under their own head, "Found only by a synonym", with the synonym after each: they can be only near the topic. When zotero_list_libraries shows group libraries, search them too with the same queries, and name the group of each item from a group.
34
35
 
35
36
  ## Outside search and gaps
36
37
 
package/src/chrome.ts CHANGED
@@ -116,7 +116,7 @@ export function toolLine(opts: { state: ToolState; word: string; detail?: string
116
116
  export function toolDetail(args: unknown, max = 40): string {
117
117
  if (!args || typeof args !== "object") return "";
118
118
  const a = args as Record<string, unknown>;
119
- for (const k of ["query", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
119
+ for (const k of ["query", "queries", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
120
120
  const v = a[k];
121
121
  if (v === undefined || v === null || v === "") continue;
122
122
  const s = Array.isArray(v) ? v.filter((x) => typeof x !== "object").join(", ") : String(v);
package/src/config.ts CHANGED
@@ -62,7 +62,7 @@ export function uiLanguage(language: string | undefined): "pt" | "en" {
62
62
  }
63
63
 
64
64
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
65
- export const SERVER_VERSION = "0.6.0";
65
+ export const SERVER_VERSION = "0.7.0";
66
66
 
67
67
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
68
68
  export const STARBUCK_VERSION = "0.2.0";
@@ -107,6 +107,8 @@ export interface SubsubConfig {
107
107
  starbuckDir?: string;
108
108
  /** The pilot's usage log, kept on this computer only (see usage.ts). Off by default. */
109
109
  usageLog?: boolean;
110
+ /** US dollars per session: a note at 80%, a question before each new request from 100% (see cost.ts). */
111
+ costLimit?: number;
110
112
  }
111
113
 
112
114
  export function expand(p: string, home?: string): string {
@@ -208,6 +210,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
208
210
  ? settingsFile(vault, "rules")
209
211
  : undefined,
210
212
  startTimeout: user.startTimeout ?? 45,
213
+ costLimit: typeof user.costLimit === "number" && user.costLimit > 0 ? user.costLimit : undefined,
211
214
  uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
212
215
  look: user.look,
213
216
  quotes: user.quotes,
package/src/cost.ts ADDED
@@ -0,0 +1,22 @@
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
+
6
+ export type CostLevel = "ok" | "near" | "over";
7
+
8
+ /** Where the session's cost is against the limit. An unknown cost (null) or no limit is "ok". */
9
+ export function costStatus(spent: number | null, limit: number | undefined): { level: CostLevel; spent: number; limit?: number } {
10
+ if (spent === null || !limit || limit <= 0) return { level: "ok", spent: spent ?? 0, limit };
11
+ return { level: spent >= limit ? "over" : spent >= 0.8 * limit ? "near" : "ok", spent, limit };
12
+ }
13
+
14
+ export const money = (n: number) => `$${n.toFixed(2)}`;
15
+
16
+ /** Commands that do not send anything to the model: no question for these. */
17
+ export const LOCAL_COMMANDS = new Set(["subsub", "researcher", "librarian", "history", "undo", "notes", "library", "actions", "feedback"]);
18
+
19
+ export function usesModel(text: string): boolean {
20
+ const cmd = /^\/([\w-]+)(?:\s|$)/.exec(text.trim())?.[1];
21
+ return !cmd || !(cmd.startsWith("subsub-") || LOCAL_COMMANDS.has(cmd));
22
+ }
package/src/doctor.ts 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
 
10
11
  import { spawnSync } from "node:child_process";
@@ -23,6 +24,7 @@ import {
23
24
  refreshedArgs,
24
25
  } from "./config.ts";
25
26
  import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
27
+ import { customProviders } from "./providers.ts";
26
28
 
27
29
  export interface Check {
28
30
  name: string;
@@ -55,6 +57,7 @@ function nodeOk(version: string): boolean {
55
57
  }
56
58
 
57
59
  function hasLogin(agentDir: string): boolean {
60
+ if (customProviders(agentDir).length) return true; // another provider, in models.json
58
61
  try {
59
62
  const data = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
60
63
  return Boolean(data) && typeof data === "object" && Object.keys(data).length > 0;
@@ -122,7 +125,14 @@ export function runChecks(
122
125
  out.push(
123
126
  st.zotero === "reachable"
124
127
  ? { name: "Zotero", ok: true, detail: `reachable, write permission ${st.write_key_remembered ? "remembered" : "asked at the first change"}` }
125
- : { name: "Zotero", ok: false, detail: String(st.error ?? "not reachable"), fix: `Start Zotero 10 or later. ${ZOTERO_SETTING}` },
128
+ : // Optional: without Zotero, Sub-Sub still searches PubMed, Europe PMC and OpenAlex and writes notes.
129
+ {
130
+ name: "Zotero",
131
+ ok: true,
132
+ warn: true,
133
+ detail: `${String(st.error ?? "not reachable")}: the search tools work, the library tools do not`,
134
+ fix: `Start Zotero 10 or later. ${ZOTERO_SETTING}`,
135
+ },
126
136
  );
127
137
  const v = st.vocabulary ?? {};
128
138
  if (v.loaded === false || !v.tags) {
package/src/init.ts 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
 
25
26
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -44,6 +45,7 @@ import {
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";
46
47
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
48
+ import { CUSTOM_DEFAULT_NAME, type CustomProvider, saveCustomProvider } from "./providers.ts";
47
49
 
48
50
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
49
51
 
@@ -55,6 +57,16 @@ export const STARTER_TAGS: Record<string, string> = {
55
57
 
56
58
  export const TESTED_MODELS = DEFAULT_MODELS;
57
59
 
60
+ /**
61
+ * After a login in subsub web or subsub tui: when no models are set yet, OpenCode Go gets the tested models and
62
+ * another provider (spec "<name>/<id>") its own model in both modes. Models already chosen stay.
63
+ */
64
+ export function modelsAfterLogin(env: NodeJS.ProcessEnv, provider: string, spec?: string): void {
65
+ if (Object.keys(loadConfig(env).models).length) return;
66
+ if (spec) saveConfig({ models: { librarian: spec, researcher: spec }, model: undefined }, env);
67
+ else if (provider === "opencode-go") saveConfig({ models: TESTED_MODELS, model: undefined }, env);
68
+ }
69
+
58
70
  export interface InitIO {
59
71
  ask(question: string, def: string): Promise<string>;
60
72
  choose(question: string, options: Array<{ value: string; label: string }>, def: string): Promise<string>;
@@ -252,6 +264,7 @@ export async function runInit(
252
264
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
253
265
  { value: "mistral-eu", label: `Mistral, servers in the EU (GDPR): ${EU_MODELS.researcher} in both modes; about six times the cost of OpenCode Go` },
254
266
  { 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)` },
267
+ { value: "custom", label: "Another provider that speaks the OpenAI API: your institution's server, or a local one (Ollama, LM Studio)" },
255
268
  ];
256
269
  const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
257
270
  const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
@@ -266,6 +279,20 @@ export async function runInit(
266
279
  googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
267
280
  }
268
281
 
282
+ let custom: CustomProvider | undefined;
283
+ if (models === "custom") {
284
+ explain(
285
+ "Another provider",
286
+ `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.`,
287
+ );
288
+ custom = {
289
+ name: CUSTOM_DEFAULT_NAME,
290
+ baseUrl: (await io.ask("Server address, e.g. https://llm.example.org/v1", flags["provider-url"] ?? "")).trim(),
291
+ apiKey: (await io.ask("API key (Enter for none, for a local server)", flags["provider-key"] ?? "")).trim(),
292
+ models: [(await io.ask("Model id", flags["provider-model"] ?? "")).trim()],
293
+ };
294
+ }
295
+
269
296
  // ---- write
270
297
  if (move === "yes") applyMoves(moves);
271
298
  for (const dir of [notes, ...NOTE_DIRS.map((d) => join(notes, d)), join(notes, SETTINGS_DIR)]) {
@@ -315,8 +342,18 @@ export async function runInit(
315
342
  if (models === "opencode-go") patch.models = TESTED_MODELS;
316
343
  if (models === "google-free") patch.models = FREE_MODELS;
317
344
  if (models === "mistral-eu") patch.models = EU_MODELS;
318
- saveConfig(patch, initEnv);
319
345
  let keyNote = "";
346
+ if (custom) {
347
+ try {
348
+ const [spec] = saveCustomProvider(agentDir, custom);
349
+ patch.models = { librarian: spec, researcher: spec };
350
+ keyNote = `Saved: the provider ${custom.name}, in ${join(agentDir, "models.json")}`;
351
+ } catch (err) {
352
+ patch.models = current.models;
353
+ keyNote = `The provider was not saved: ${(err as Error).message} Type subsub init again, or choose a model in Sub-Sub.`;
354
+ }
355
+ }
356
+ saveConfig(patch, initEnv);
320
357
  if (googleKey) {
321
358
  try {
322
359
  saveApiKey(agentDir, "google", googleKey);
package/src/look.ts CHANGED
@@ -84,8 +84,8 @@ type Lang = "en" | "pt";
84
84
 
85
85
  /** The words of the library line, as in the web view (web/i18n.js: zoteroChecking, zoteroDown, items, toReview). */
86
86
  export const LIBRARY_WORDS = {
87
- en: { checking: "Zotero: checking...", down: "Zotero is not running. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
88
- pt: { checking: "Zotero: a verificar...", down: "O Zotero não está aberto. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
87
+ en: { checking: "Zotero: checking...", down: "Zotero is closed: search only. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
88
+ pt: { checking: "Zotero: a verificar...", down: "O Zotero está fechado: só pesquisa. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
89
89
  };
90
90
 
91
91
  function cut(s: string, width: number): string {
package/src/prompt.ts CHANGED
@@ -56,7 +56,12 @@ export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "abou
56
56
  return cfg ? fill(raw, cfg) : raw;
57
57
  }
58
58
 
59
- export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
59
+ /** What Sub-Sub knows now: `zoteroDown` when the last check found Zotero closed. */
60
+ export interface PromptState {
61
+ zoteroDown?: boolean;
62
+ }
63
+
64
+ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string, state: PromptState = {}): string {
60
65
  const user = who(cfg);
61
66
  const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
62
67
  if (cfg.vault) {
@@ -85,6 +90,14 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
85
90
  `${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.`,
86
91
  );
87
92
  }
93
+ if (state.zoteroDown) {
94
+ parts.push(
95
+ "# Zotero is closed",
96
+ mode === "librarian"
97
+ ? `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.`
98
+ : `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.`,
99
+ );
100
+ }
88
101
  // The language rule comes last, so it wins over the shared rules and the vault conventions.
89
102
  parts.push("# Language", languageRule(cfg.language, user));
90
103
  return parts.join("\n\n");
@@ -0,0 +1,82 @@
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
+
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { KEY_PROVIDERS } from "./keys.ts";
10
+
11
+ export interface CustomProvider {
12
+ /** The provider name in pi (models are "<name>/<model id>"). */
13
+ name: string;
14
+ baseUrl: string;
15
+ /** Empty for a local server without a key. */
16
+ apiKey?: string;
17
+ models: string[];
18
+ }
19
+
20
+ export const CUSTOM_DEFAULT_NAME = "custom";
21
+
22
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
23
+
24
+ /** The problem with a custom provider, or undefined when it can be saved. */
25
+ export function customProviderProblem(p: CustomProvider): string | undefined {
26
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(p.name)) return "The provider name can have only lowercase letters, digits and hyphens.";
27
+ if (KEY_PROVIDERS.some((k) => k.id === p.name)) return `${p.name} is a provider that pi already has: select it in the list.`;
28
+ let url: URL;
29
+ try {
30
+ url = new URL(p.baseUrl.trim());
31
+ } catch {
32
+ return "The address must be a full URL, for example https://llm.example.org/v1.";
33
+ }
34
+ if (url.protocol !== "https:" && url.protocol !== "http:") return "The address must start with https://.";
35
+ if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname)) return "Use https:// for a server on another computer, so that your key and your messages are encrypted.";
36
+ const key = (p.apiKey ?? "").trim();
37
+ if (/\s/.test(key) || key.length > 400) return "That does not look like an API key.";
38
+ const ids = p.models.map((m) => m.trim()).filter(Boolean);
39
+ if (!ids.length) return "Give at least one model id.";
40
+ if (ids.some((m) => /\s/.test(m) || m.length > 200)) return "A model id has no spaces.";
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Add or replace the provider in <agentDir>/models.json; other providers stay. The file is readable only by
46
+ * the user, because it can hold the key. Returns the model specs ("<name>/<id>").
47
+ */
48
+ export function saveCustomProvider(agentDir: string, p: CustomProvider): string[] {
49
+ const problem = customProviderProblem(p);
50
+ if (problem) throw new Error(problem);
51
+ mkdirSync(agentDir, { recursive: true });
52
+ const file = join(agentDir, "models.json");
53
+ let data: { providers?: Record<string, unknown> } & Record<string, unknown> = {};
54
+ if (existsSync(file)) {
55
+ try {
56
+ data = JSON.parse(readFileSync(file, "utf8")) ?? {};
57
+ } catch {
58
+ throw new Error(`${file} is not valid JSON; fix it first.`);
59
+ }
60
+ }
61
+ const ids = [...new Set(p.models.map((m) => m.trim()).filter(Boolean))];
62
+ data.providers = {
63
+ ...(data.providers ?? {}),
64
+ // pi shows a provider's models only when it has a key; a local server ignores the placeholder.
65
+ [p.name]: { baseUrl: p.baseUrl.trim(), api: "openai-completions", apiKey: (p.apiKey ?? "").trim() || "none", models: ids.map((id) => ({ id })) },
66
+ };
67
+ writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
68
+ chmodSync(file, 0o600); // also when the file was there before
69
+ return ids.map((id) => `${p.name}/${id}`);
70
+ }
71
+
72
+ /** The providers in <agentDir>/models.json that have at least one model. */
73
+ export function customProviders(agentDir: string): string[] {
74
+ try {
75
+ const data = JSON.parse(readFileSync(join(agentDir, "models.json"), "utf8"));
76
+ return Object.entries((data?.providers ?? {}) as Record<string, { models?: unknown[] }>)
77
+ .filter(([, v]) => Array.isArray(v?.models) && v.models.length > 0)
78
+ .map(([k]) => k);
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
package/src/roles.ts CHANGED
@@ -87,6 +87,17 @@ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {
87
87
  return [...new Set(wanted)].filter((n) => has.has(n));
88
88
  }
89
89
 
90
+ /**
91
+ * The active tools for `next`, Sub-Sub's set for the mode. Inside plain pi, Sub-Sub replaces only its own
92
+ * server tools and keeps the rest (pi's tools, other extensions' tools); with the `subsub` command, the set is
93
+ * Sub-Sub's alone.
94
+ */
95
+ export function activeTools(current: string[], next: string[], standalone: boolean): string[] {
96
+ if (standalone) return next;
97
+ const kept = current.filter((n) => !SERVER_PREFIXES.some((p) => n.startsWith(p)));
98
+ return [...new Set([...kept, ...next])];
99
+ }
100
+
90
101
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
91
102
  export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
92
103
  if (toolsFor(mode, registered, opts).includes(name)) return undefined;