@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 +19 -0
- package/README.md +18 -4
- package/dist/chrome.js +1 -1
- package/dist/config.js +2 -1
- package/dist/cost.js +17 -0
- package/dist/doctor.js +13 -2
- package/dist/init.js +38 -2
- package/dist/look.js +2 -2
- package/dist/prompt.js +6 -1
- package/dist/providers.js +78 -0
- package/dist/roles.js +11 -0
- package/dist/screens.js +8 -2
- package/dist/subsub.js +67 -13
- package/dist/tui-view.js +1 -1
- package/dist/tui.js +76 -4
- package/dist/web.js +8 -4
- package/package.json +1 -1
- package/prompts/check-library.md +12 -0
- package/prompts/compare.md +1 -1
- package/prompts/lit.md +9 -4
- package/roles/librarian.md +2 -1
- package/roles/researcher.md +3 -2
- package/src/chrome.ts +1 -1
- package/src/config.ts +4 -1
- package/src/cost.ts +22 -0
- package/src/doctor.ts +12 -2
- package/src/init.ts +39 -2
- package/src/look.ts +2 -2
- package/src/prompt.ts +14 -1
- package/src/providers.ts +82 -0
- package/src/roles.ts +11 -0
- package/src/screens.ts +14 -2
- package/src/subsub.ts +67 -12
- package/src/tui-view.ts +1 -1
- package/src/tui.ts +79 -4
- package/src/web.ts +8 -3
- package/web/app.js +21 -7
- package/web/i18n.js +13 -2
- package/web/index.html +6 -0
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 =
|
|
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 = /^\/(
|
|
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 {
|
|
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
|
-
|
|
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
|
@@ -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.
|
package/prompts/compare.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/roles/librarian.md
CHANGED
|
@@ -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
|
package/roles/researcher.md
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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
|
-
:
|
|
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
|
|
88
|
-
pt: { checking: "Zotero: a verificar...", down: "O 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
|
-
|
|
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");
|
package/src/providers.ts
ADDED
|
@@ -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;
|