@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/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
|
|
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.
|
|
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
|
-
:
|
|
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
|
|
59
|
-
pt: { checking: "Zotero: a verificar...", down: "O 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
|
-
|
|
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 =
|
|
250
|
+
const ref = itemRef;
|
|
246
251
|
await ctx.ui.custom((tui, theme, _keys, done) => new LibraryView({
|
|
247
|
-
search:
|
|
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
|
-
|
|
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)));
|