@tiagojct/subsub 0.16.0 → 0.18.0
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 +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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.18.0 (2026-10-15)
|
|
6
|
+
|
|
7
|
+
- Figures and tables from the PDF. With the Zotero server 0.6.0, Sub-Sub can cut a figure or a table out of an item's PDF (`zotero_get_figure`, with the label of its caption, such as "Figure 2" or "Table 1") and show it in a note, with its caption and a link to the page. `/read critique` shows the table or figure with the main result. The images are PNG files in `Literature/figures/`; the notes pane and Obsidian show them. When the figure cannot be cut out, Sub-Sub takes the whole page and says so.
|
|
8
|
+
- In a note written from the full text, the page after each quote, "(p. N)", becomes a link that opens the PDF in Zotero at the page where Sub-Sub found the quote. The label stays as written; the link goes to the right page even when the printed page number differs from the PDF's.
|
|
9
|
+
- `/feedback [what happened]` (Report a problem in the browser) writes a draft bug report in `Inbox/`: the versions, the mode and model, the output of `subsub doctor` and the last error, with your folders, email addresses and keys taken out. You read it and post it on GitHub; Sub-Sub sends nothing.
|
|
10
|
+
- Preprints (medRxiv, bioRxiv, Research Square, SSRN, arXiv) are marked as not peer reviewed wherever Sub-Sub uses them: "(preprint)" in evidence tables and comparison matrices, and in the text. When a journal version exists, Sub-Sub uses and cites it instead.
|
|
11
|
+
- `/lit` rates how hard the search is (1 to 10) and allows none, one or two follow-up search rounds to match, with one publication window for every search; it stops early when the sources cover the question. The note's Methods give the rating and the rounds. `/gaps` gives a ranked list of 5 to 15 works and does not read them. This keeps the cost of a search in proportion to the question.
|
|
12
|
+
- In the browser view, every table in a note or a reply has a Download CSV button: evidence tables, extraction sheets, comparison matrices. The file opens in Excel (with semicolons when the interface is in Portuguese, as Excel expects there), Rayyan or Covidence.
|
|
13
|
+
- A `/lit` note (and a reading list) shows the flow of works under Methods: found, unique, screened, read and used, with what was left out at each step. It is a Mermaid diagram, so Obsidian, Quarto and GitHub draw it; the browser view draws it too.
|
|
14
|
+
- The quote check is stricter. A quote may skip text only at a page break (the running footer and header); two passages of one page joined into one quote, without [...] for the omission, are refused. Zotero marks page breaks in the full text, so the check knows where they are.
|
|
15
|
+
- `/lit reading list:` no longer ends with "the answer in three lines": a reading list states no results, in the reply too.
|
|
16
|
+
- In Portuguese, the browser button for `/lit-note` is now Nota de literatura, so that it is not confused with the reading notes of `/read`.
|
|
17
|
+
- Delete in the browser view keeps the conversation's subfolder in `~/.subsub/trash`, so that moving it back is one step.
|
|
18
|
+
|
|
19
|
+
## 0.17.0 (2026-10-14)
|
|
20
|
+
|
|
21
|
+
For people who are learning to read the literature:
|
|
22
|
+
|
|
23
|
+
- `/read <citekey>` is a guided reading of one paper. Sub-Sub asks why you read it: background (the title and abstract, then keep or drop), one fact, extraction for a review, or critique. It reads only as deep as that reason needs, quotes the passages with their place and asks questions; you write the answers. It writes no results or summaries in this note. For an extraction, it also adds a row for the paper to your extraction sheet in `Research/`, with where to find each value. Put the reason first to skip the question: `/read extract smith2021`. In the browser it is the button Read a paper.
|
|
24
|
+
- Sub-Sub has no profiles any more (Reader, Scholar, Author, Editor). The two modes stay: the Researcher has every command and tool, and every library change still needs your yes. Sub-Sub writes notes and syntheses with a source for every claim, drafts text for a manuscript or thesis only when you ask, and does not draft text for assignments. What the Reader profile did is now `/read` (a guided reading of one paper) and `/lit reading list: <topic>` (a reading list with questions, no results). `subsub init` no longer asks whether you are a student or which profile you want, and removes the old setting. `/profile` says where things went.
|
|
25
|
+
- The browser view is more useful:
|
|
26
|
+
- A library search in the left panel. Each result has Read, Note and a link to the item in Zotero.
|
|
27
|
+
- A notes pane: Notes at the top lists the notes in your Sub-Sub folder, and a note opens next to the conversation. Note paths in replies, `[[links]]` and the Open button on a written file open there too; Open in app still hands it to Obsidian or your editor.
|
|
28
|
+
- After a command and a space, the message box shows what the command needs.
|
|
29
|
+
- Conversations can be renamed and deleted. Delete moves the file to `~/.subsub/trash`. Export is in the language of the interface and includes the tools that Sub-Sub used and their results.
|
|
30
|
+
- The reasons of `/read` show as a list. The undo question has its own title again, and the page no longer scrolls as a whole when the left panel is long.
|
|
31
|
+
- In the browser, a command that asks a question (`/read`, `/undo`) no longer shows an error when the answer takes more than 30 seconds.
|
|
32
|
+
|
|
5
33
|
## 0.16.0 (2026-10-08)
|
|
6
34
|
|
|
7
35
|
For clinical researchers:
|
package/README.md
CHANGED
|
@@ -29,18 +29,13 @@ Every library change works the same way in both modes:
|
|
|
29
29
|
- Every change is journaled, including new collections. `/undo` reverts the last one, and the browser view shows an Undo button under each change.
|
|
30
30
|
- Nothing is deleted: items go to the Zotero trash. Only tags from your tag list can be added.
|
|
31
31
|
|
|
32
|
-
##
|
|
32
|
+
## What Sub-Sub writes
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Sub-Sub writes literature notes, syntheses and reviews from what it read, with a source for every claim. It drafts text for your manuscript or thesis only when you ask, and marks the drafted text so that you can check it. It does not draft text for assignments, even when you ask.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|---|---|
|
|
38
|
-
| Reader | Reading notes with quotations, page numbers and questions for you. No summaries or syntheses unless you ask. No bulk library changes. |
|
|
39
|
-
| Scholar | Literature notes, syntheses, searches, alerts and all library changes. |
|
|
40
|
-
| Author | Scholar, plus manuscript work: citation checks against your library, bibliographies for Pandoc and Quarto, and comments on the argument. It does not write your paragraphs. |
|
|
41
|
-
| Editor | Everything, including drafts of manuscript sections and abstracts when you ask. |
|
|
36
|
+
To learn to read papers yourself, use `/read`: Sub-Sub finds the passages and asks questions, and you write the answers. `/lit reading list: <topic>` gives a reading list with questions to read with, and no results.
|
|
42
37
|
|
|
43
|
-
The [
|
|
38
|
+
The [Commands page](https://subsub.tiagojacinto.eu/profiles/) lists the tools and commands of each mode.
|
|
44
39
|
|
|
45
40
|
## Requirements
|
|
46
41
|
|
|
@@ -91,18 +86,21 @@ subsub --help # all commands
|
|
|
91
86
|
|
|
92
87
|
In the browser view, connect a model provider with the model button. In the terminal, type `/login`.
|
|
93
88
|
|
|
89
|
+
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.
|
|
90
|
+
|
|
94
91
|
Commands in Sub-Sub:
|
|
95
92
|
|
|
96
93
|
| Command | What it does |
|
|
97
94
|
|---|---|
|
|
98
95
|
| `/researcher`, `/librarian` | Switch the mode |
|
|
99
|
-
| `/profile [name]` | Show or change the profile |
|
|
100
96
|
| `/subsub` | Zotero connection, mode, model and library counts |
|
|
101
97
|
| `/history`, `/undo [id]` | Recent library changes; undo the last one or one by its id |
|
|
98
|
+
| `/feedback [what happened]` | A draft bug report in `Inbox/`, with versions, `subsub doctor` and the last error, and without your folders, emails or keys. You post it on GitHub; Sub-Sub sends nothing |
|
|
102
99
|
| `/ai-statement [uses]` | A draft statement on the use of AI for a manuscript, with the Sub-Sub version, its DOI and your models |
|
|
103
100
|
| `/tag-batch [size]` | Tag items that have no topic, with your tag list |
|
|
104
101
|
| `/clean-tags` | Find tags that are not in your tag list and propose fixes |
|
|
105
102
|
| `/import-queue [path]` | Import the references in the import queue note |
|
|
103
|
+
| `/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/` |
|
|
106
104
|
| `/lit <question>` | Literature review: the library first, then one merged search; an evidence table, agreement, disagreement and open questions; one note in `Research/` |
|
|
107
105
|
| `/compare <question or citekeys>` | A matrix of sources: claim, evidence, caveats, confidence |
|
|
108
106
|
| `/lit-note <citekey>` | A literature note on one item, from its full text |
|
|
@@ -115,7 +113,7 @@ Commands in Sub-Sub:
|
|
|
115
113
|
| `/digest [days] [topic]` | What changed in the last days: new notes, alerts, works worth reading |
|
|
116
114
|
| `/request-copy <citekey, DOI or PMID>` | A draft email to the corresponding author for a paper that is not open access. You send it yourself |
|
|
117
115
|
|
|
118
|
-
The library commands work in both modes; the research commands need the Researcher.
|
|
116
|
+
The library commands work in both modes; the research commands need the Researcher.
|
|
119
117
|
|
|
120
118
|
To check or apply a tag review note from `Inbox/` outside Sub-Sub:
|
|
121
119
|
|
|
@@ -130,7 +128,6 @@ subsub review apply 13-31
|
|
|
130
128
|
|
|
131
129
|
| Setting | Meaning | Default |
|
|
132
130
|
|---|---|---|
|
|
133
|
-
| `profile` | `reader`, `scholar`, `author` or `editor` | `reader` for students, `scholar` for others (`subsub init` asks) |
|
|
134
131
|
| `userName`, `about` | Your name and one line about you, for the replies | none |
|
|
135
132
|
| `language` | Language of replies and notes, or `auto` for the language you write in | `English` |
|
|
136
133
|
| `vault` | The Sub-Sub folder | from the server settings |
|
package/dist/bench.js
CHANGED
|
@@ -844,7 +844,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
844
844
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
845
845
|
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
846
846
|
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
847
|
-
writeFileSync(cf, JSON.stringify({ ...userCfg, profile:
|
|
847
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: undefined, models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
848
848
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
|
|
849
849
|
n++;
|
|
850
850
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/dist/cli.js
CHANGED
|
@@ -149,7 +149,7 @@ Usage:
|
|
|
149
149
|
subsub Start Sub-Sub in the terminal
|
|
150
150
|
subsub --librarian Start in the Librarian, which only manages the library
|
|
151
151
|
subsub web Open Sub-Sub in the browser
|
|
152
|
-
subsub init Set up or change the settings (folder, tag list,
|
|
152
|
+
subsub init Set up or change the settings (folder, tag list, models)
|
|
153
153
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
154
154
|
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
155
155
|
subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
|
|
@@ -161,7 +161,7 @@ Options:
|
|
|
161
161
|
--here Work in the current folder, not in the Sub-Sub folder
|
|
162
162
|
|
|
163
163
|
In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
|
|
164
|
-
/
|
|
164
|
+
/read to read a paper with guidance, /undo to undo the last change, and / for all commands.
|
|
165
165
|
Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
|
|
166
166
|
|
|
167
167
|
Docs: https://subsub.tiagojacinto.eu`;
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* (~/.config/subsub/config.json, or SUBSUB_CONFIG) overrides it.
|
|
4
4
|
*
|
|
5
5
|
* {
|
|
6
|
-
* "profile": "scholar",
|
|
7
6
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
7
|
* "vault": "~/Documents/Sub-Sub",
|
|
9
8
|
* "models": { "librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
@@ -57,9 +56,8 @@ export const RECOMMENDED_MODELS = [
|
|
|
57
56
|
export function uiLanguage(language) {
|
|
58
57
|
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
59
58
|
}
|
|
60
|
-
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
61
59
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
62
|
-
export const SERVER_VERSION = "0.
|
|
60
|
+
export const SERVER_VERSION = "0.6.0";
|
|
63
61
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
64
62
|
export const STARBUCK_VERSION = "0.2.0";
|
|
65
63
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
@@ -109,9 +107,6 @@ export function saveConfig(patch, env = process.env) {
|
|
|
109
107
|
writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
110
108
|
return file;
|
|
111
109
|
}
|
|
112
|
-
export function isProfile(x) {
|
|
113
|
-
return typeof x === "string" && PROFILES.includes(x);
|
|
114
|
-
}
|
|
115
110
|
export function loadConfig(env = process.env) {
|
|
116
111
|
const home = env.HOME;
|
|
117
112
|
const file = configPath(env);
|
|
@@ -148,7 +143,6 @@ export function loadConfig(env = process.env) {
|
|
|
148
143
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
149
144
|
return {
|
|
150
145
|
configFile: file,
|
|
151
|
-
profile: isProfile(user.profile) ? user.profile : "scholar",
|
|
152
146
|
userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
|
|
153
147
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
154
148
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
package/dist/doctor.js
CHANGED
|
@@ -12,7 +12,6 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, refreshedArgs, } from "./config.js";
|
|
14
14
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
|
|
15
|
-
import { profileSpec } from "./profiles.js";
|
|
16
15
|
export const defaultRunner = (command, args, env) => {
|
|
17
16
|
const r = spawnSync(command, args, { env, encoding: "utf8", timeout: 120_000 });
|
|
18
17
|
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error?.message };
|
|
@@ -39,7 +38,7 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
39
38
|
? { name: "Node", ok: true, detail: node }
|
|
40
39
|
: { name: "Node", ok: false, detail: node, fix: "Install Node.js 22.19 or later (https://nodejs.org), or run the Sub-Sub installer again." });
|
|
41
40
|
out.push(existsSync(cfg.configFile)
|
|
42
|
-
? { name: "Settings", ok: true, detail: `${cfg.configFile},
|
|
41
|
+
? { name: "Settings", ok: true, detail: `${cfg.configFile}, replies in ${cfg.language}` }
|
|
43
42
|
: { name: "Settings", ok: false, detail: `no ${cfg.configFile}`, fix: "Type subsub init." });
|
|
44
43
|
const uv = findUv(cfg, env);
|
|
45
44
|
const uvRun = run(uv, ["--version"], env);
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /feedback: a draft bug report or idea in the Inbox, for the user to read and post on GitHub.
|
|
3
|
+
* Sub-Sub sends nothing: the user decides what to share. The home and Sub-Sub folders, email
|
|
4
|
+
* addresses and keys are taken out before the draft is written.
|
|
5
|
+
*/
|
|
6
|
+
export const ISSUES_URL = "https://github.com/tiagojct/subsub/issues/new";
|
|
7
|
+
/** Text made safe to post: the Sub-Sub and home folders, email addresses and keys taken out. */
|
|
8
|
+
export function redact(text, home, vault) {
|
|
9
|
+
let out = text;
|
|
10
|
+
if (vault)
|
|
11
|
+
out = out.split(vault).join("<Sub-Sub folder>");
|
|
12
|
+
if (home)
|
|
13
|
+
out = out.split(home).join("~");
|
|
14
|
+
return out
|
|
15
|
+
.replace(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, "<email>")
|
|
16
|
+
.replace(/\b(?:sk-|AIza|ghp_|gho_|github_pat_|xox[bp]-)[\w-]{8,}/g, "<key>")
|
|
17
|
+
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, "<key>");
|
|
18
|
+
}
|
|
19
|
+
export function feedbackNote(f) {
|
|
20
|
+
const clean = (s) => redact(s, f.home, f.vault);
|
|
21
|
+
return [
|
|
22
|
+
"---",
|
|
23
|
+
"type: feedback",
|
|
24
|
+
`created: ${f.date}`,
|
|
25
|
+
"---",
|
|
26
|
+
"",
|
|
27
|
+
"# Sub-Sub feedback",
|
|
28
|
+
"",
|
|
29
|
+
`Read this draft, take out anything you do not want to share, then post it at ${ISSUES_URL}. Sub-Sub has sent nothing.`,
|
|
30
|
+
"",
|
|
31
|
+
"## What happened",
|
|
32
|
+
"",
|
|
33
|
+
clean(f.what.trim()) || "(what you did, and what you saw)",
|
|
34
|
+
"",
|
|
35
|
+
"## What you expected",
|
|
36
|
+
"",
|
|
37
|
+
"(what should have happened)",
|
|
38
|
+
"",
|
|
39
|
+
"## Set-up",
|
|
40
|
+
"",
|
|
41
|
+
`- ${clean(f.versions)}`,
|
|
42
|
+
...f.setup.map((l) => `- ${clean(l)}`),
|
|
43
|
+
"",
|
|
44
|
+
"## subsub doctor",
|
|
45
|
+
"",
|
|
46
|
+
"```",
|
|
47
|
+
clean(f.checks.trim()),
|
|
48
|
+
"```",
|
|
49
|
+
"",
|
|
50
|
+
"## Last error",
|
|
51
|
+
"",
|
|
52
|
+
f.lastError ? ["```", clean(f.lastError.trim()).slice(0, 2000), "```"].join("\n") : "(none in this session)",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* existing one. Press Enter to keep the value in brackets.
|
|
4
4
|
*
|
|
5
5
|
* Writes:
|
|
6
|
-
* - the settings file (~/.config/subsub/config.json):
|
|
6
|
+
* - the settings file (~/.config/subsub/config.json): name, language,
|
|
7
7
|
* Sub-Sub folder, models;
|
|
8
8
|
* - the server settings file (~/.config/zotero-local-mcp/env, or the one that
|
|
9
9
|
* already exists): the Sub-Sub folder, tag list, alerts file, contact email.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
|
-
* yes|no, --
|
|
21
|
+
* yes|no, --tags health-sciences|health-informatics|any-field,
|
|
22
22
|
* --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
|
|
23
23
|
*/
|
|
24
24
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -26,11 +26,10 @@ import { homedir } from "node:os";
|
|
|
26
26
|
import { dirname, join, resolve } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { configPath, DEFAULT_MODELS, EU_MODELS, FREE_MODELS, expand,
|
|
29
|
+
import { configPath, DEFAULT_MODELS, EU_MODELS, FREE_MODELS, expand, loadConfig, parseEnvFile, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
30
30
|
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
31
31
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
|
|
32
32
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
33
|
-
import { PROFILE_SPECS } from "./profiles.js";
|
|
34
33
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
35
34
|
export const STARTER_TAGS = {
|
|
36
35
|
"health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
|
|
@@ -125,25 +124,13 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
125
124
|
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
|
-
// Asked here, not first: it only changes the
|
|
129
|
-
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags
|
|
127
|
+
// Asked here, not first: it only changes the default of the tag list question.
|
|
128
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags. Everyone else keeps the standard set-up.");
|
|
130
129
|
const setup = await io.choose("Set-up", [
|
|
131
130
|
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
132
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags
|
|
131
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags (the FMUP model service is coming soon)" },
|
|
133
132
|
], flags.setup ?? existing.setup ?? "standard");
|
|
134
133
|
const fmup = setup === "fmup";
|
|
135
|
-
// Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
|
|
136
|
-
let student = fmup;
|
|
137
|
-
if (!flags.profile && !existing.profile && !fmup) {
|
|
138
|
-
explain("Who you are", "Students start with the Reader profile: Sub-Sub helps you read (notes with quotations, page numbers and questions) and does not write summaries or syntheses unless you ask. You can change the profile at any time.");
|
|
139
|
-
student = (await io.choose("You are", [
|
|
140
|
-
{ value: "student", label: "A student" },
|
|
141
|
-
{ value: "researcher", label: "A researcher, teacher or clinician" },
|
|
142
|
-
], flags.student === "no" ? "researcher" : "student")) === "student";
|
|
143
|
-
}
|
|
144
|
-
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
145
|
-
const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
|
|
146
|
-
const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
147
134
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
148
135
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
149
136
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -225,7 +212,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
225
212
|
if (brave || hadBrave)
|
|
226
213
|
chmodSync(envFile, 0o600); // the file holds a key
|
|
227
214
|
const patch = {
|
|
228
|
-
profile,
|
|
215
|
+
profile: undefined,
|
|
229
216
|
userName: userName || undefined,
|
|
230
217
|
about: about || undefined,
|
|
231
218
|
language,
|
package/dist/look.js
CHANGED
|
@@ -63,7 +63,7 @@ export function libraryLine(st) {
|
|
|
63
63
|
export function headerLines(opts) {
|
|
64
64
|
const p = opts.paint ?? plain;
|
|
65
65
|
const w = Math.max(10, opts.width);
|
|
66
|
-
const modeLabel =
|
|
66
|
+
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
67
67
|
const lib = libraryLine(opts.library);
|
|
68
68
|
if (w < BANNER[0].length + 2) {
|
|
69
69
|
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
|
|
@@ -78,7 +78,7 @@ export function headerLines(opts) {
|
|
|
78
78
|
else
|
|
79
79
|
out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
80
80
|
}
|
|
81
|
-
out.push(p.dim(cut("/researcher /librarian /
|
|
81
|
+
out.push(p.dim(cut("/researcher /librarian /read /history /undo | / for all commands", w)), "");
|
|
82
82
|
return out;
|
|
83
83
|
}
|
|
84
84
|
/**
|
package/dist/notes.js
CHANGED
|
@@ -77,12 +77,16 @@ export function matchable(s) {
|
|
|
77
77
|
.toLowerCase()
|
|
78
78
|
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
79
79
|
}
|
|
80
|
+
/** The full text as matchable pages joined by "|": Zotero marks a page break with a form feed. */
|
|
81
|
+
export function sourceOf(fullText) {
|
|
82
|
+
return fullText.split("\f").map(matchable).join("|");
|
|
83
|
+
}
|
|
80
84
|
/**
|
|
81
85
|
* The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
|
|
82
86
|
* square brackets (an editor's insertion) are left out of the comparison.
|
|
83
87
|
*/
|
|
84
88
|
export function missingQuotes(note, fullText, already = []) {
|
|
85
|
-
const source =
|
|
89
|
+
const source = sourceOf(fullText);
|
|
86
90
|
const old = new Set(already);
|
|
87
91
|
return quotesIn(note)
|
|
88
92
|
.filter((q) => !old.has(q))
|
|
@@ -92,33 +96,59 @@ export function missingQuotes(note, fullText, already = []) {
|
|
|
92
96
|
});
|
|
93
97
|
}
|
|
94
98
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
99
|
+
* Where a passage starts in the source (from sourceOf), or -1. It must be there word for word.
|
|
100
|
+
* The only gap allowed is a page break with the running footer and header around it (groups of
|
|
101
|
+
* three words in order, at most `gap` characters apart), so that a quote cannot join two passages
|
|
102
|
+
* of one page without marking the omission.
|
|
97
103
|
*/
|
|
98
|
-
export function
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
export function locate(source, passage, gap = 300) {
|
|
105
|
+
const whole = source.indexOf(matchable(passage));
|
|
106
|
+
if (whole >= 0)
|
|
107
|
+
return whole;
|
|
101
108
|
const words = passage.split(/\s+/).map(matchable).filter(Boolean);
|
|
102
109
|
const groups = [];
|
|
103
110
|
for (let i = 0; i < words.length; i += 3)
|
|
104
111
|
groups.push(words.slice(i, i + 3).join(""));
|
|
105
112
|
if (groups.length < 2)
|
|
106
|
-
return
|
|
113
|
+
return -1;
|
|
107
114
|
for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
|
|
108
115
|
let end = start + groups[0].length;
|
|
109
116
|
let ok = true;
|
|
110
117
|
for (const g of groups.slice(1)) {
|
|
111
118
|
const at = source.indexOf(g, end);
|
|
112
|
-
if (at < 0 || at - end > gap) {
|
|
119
|
+
if (at < 0 || (at > end && (at - end > gap || !source.slice(end, at).includes("|")))) {
|
|
113
120
|
ok = false;
|
|
114
121
|
break;
|
|
115
122
|
}
|
|
116
123
|
end = at + g.length;
|
|
117
124
|
}
|
|
118
125
|
if (ok)
|
|
119
|
-
return
|
|
126
|
+
return start;
|
|
120
127
|
}
|
|
121
|
-
return
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
export function appearsIn(source, passage, gap = 300) {
|
|
131
|
+
return locate(source, passage, gap) >= 0;
|
|
132
|
+
}
|
|
133
|
+
/** The page of the PDF (1-based, as Zotero's reader counts) where a passage starts, or undefined. */
|
|
134
|
+
export function pageOf(source, passage) {
|
|
135
|
+
const at = locate(source, passage);
|
|
136
|
+
return at < 0 ? undefined : source.slice(0, at).split("|").length;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Each "(p. N)" after a quote becomes a link that opens the PDF in Zotero at the page where the quote
|
|
140
|
+
* is. The label stays as written (the page printed on the paper may differ from the PDF's count).
|
|
141
|
+
*/
|
|
142
|
+
export function withPageLinks(text, source, attachment) {
|
|
143
|
+
const link = (quote, label) => {
|
|
144
|
+
const page = pageOf(source, quote.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/)[0]);
|
|
145
|
+
return page ? `([${label}](zotero://open-pdf/library/items/${attachment}?page=${page}))` : `(${label})`;
|
|
146
|
+
};
|
|
147
|
+
const PAGE = String.raw `\((pp?\.\s?\d+(?:\s?[-–]\s?\d+)?)\)`;
|
|
148
|
+
return text
|
|
149
|
+
.replace(new RegExp(String.raw `(")([^"\n]{25,600})("\s*)${PAGE}`, "g"), (_m, o, q, c, label) => `${o}${q}${c}${link(q, label)}`)
|
|
150
|
+
.replace(new RegExp(String.raw `(“)([^“”\n]{25,600})(”\s*)${PAGE}`, "g"), (_m, o, q, c, label) => `${o}${q}${c}${link(q, label)}`)
|
|
151
|
+
.replace(new RegExp(String.raw `^(>[ \t]*)(.{25,}?)(\s*)${PAGE}[ \t]*$`, "gm"), (_m, o, q, c, label) => `${o}${q}${c}${link(q.replace(/^["“]|["”]$/g, ""), label)}`);
|
|
122
152
|
}
|
|
123
153
|
export function quoteProblem(missing) {
|
|
124
154
|
if (!missing.length)
|
|
@@ -127,6 +157,6 @@ export function quoteProblem(missing) {
|
|
|
127
157
|
return [
|
|
128
158
|
`${missing.length} quote(s) in this note are not in the item's full text:`,
|
|
129
159
|
...shown,
|
|
130
|
-
"Copy each quote word for word from the full text (zotero_get_fulltext)
|
|
160
|
+
"Copy each quote word for word from the full text (zotero_get_fulltext). Mark every omission with [...]: two passages joined without that mark are not a quote. Or remove the quote marks and say it in your own words. Then write the note again.",
|
|
131
161
|
].join("\n");
|
|
132
162
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Sub-Sub part of the system prompt: role text for the current mode, the
|
|
3
|
-
*
|
|
3
|
+
* shared Zotero rules from the Sub-Sub folder, the vault
|
|
4
4
|
* conventions when pi runs inside the Sub-Sub folder, and the language rule.
|
|
5
5
|
*
|
|
6
|
-
* Role
|
|
6
|
+
* Role texts use placeholders: {{user}} / {{User}} (the user's name,
|
|
7
7
|
* or "the user"), {{about}} (one line about the user) and {{batch}}.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { who } from "./config.js";
|
|
13
|
-
import { profileSpec } from "./profiles.js";
|
|
14
13
|
const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
|
|
15
14
|
function readIf(path) {
|
|
16
15
|
if (!path || !existsSync(path))
|
|
@@ -44,7 +43,7 @@ export function fill(text, cfg) {
|
|
|
44
43
|
.replaceAll("{{User}}", capital(user))
|
|
45
44
|
.replaceAll("{{user}}", user)
|
|
46
45
|
.replaceAll("{{about}}", about)
|
|
47
|
-
.replaceAll("{{batch}}",
|
|
46
|
+
.replaceAll("{{batch}}", "25")
|
|
48
47
|
.replace(/[ \t]+\n/g, "\n");
|
|
49
48
|
}
|
|
50
49
|
export function roleText(mode, cfg) {
|
|
@@ -71,8 +70,7 @@ export function systemAddition(mode, cfg, cwd) {
|
|
|
71
70
|
if (cfg.setup === "fmup") {
|
|
72
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.`);
|
|
73
72
|
}
|
|
74
|
-
// The
|
|
75
|
-
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
73
|
+
// The language rule comes last, so it wins over the shared rules and the vault conventions.
|
|
76
74
|
parts.push("# Language", languageRule(cfg.language, user));
|
|
77
75
|
return parts.join("\n\n");
|
|
78
76
|
}
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /read: guided reading of one paper, for people who are learning to read the literature.
|
|
3
|
+
* Sub-Sub asks why the user reads the paper and reads only as deep as that reason needs. It finds
|
|
4
|
+
* the passages (quotes with their place) and asks questions; the user writes the answers. It states
|
|
5
|
+
* no results: /lit-note is the note that Sub-Sub writes for you.
|
|
6
|
+
*/
|
|
7
|
+
/** The reasons to read, with the labels of the choice (English, Portuguese). */
|
|
8
|
+
export const PURPOSES = {
|
|
9
|
+
background: ["Background: is it worth reading? (title and abstract, keep or drop)", "Contexto: vale a pena ler? (título e resumo, manter ou pôr de parte)"],
|
|
10
|
+
fact: ["One fact: find a specific detail", "Um dado: encontrar um pormenor concreto"],
|
|
11
|
+
extract: ["Extraction: one row for my review (question, design, population, setting, finding)", "Extração: uma linha para a minha revisão (pergunta, desenho, população, contexto, resultado)"],
|
|
12
|
+
critique: ["Critique: read it closely (peer review, the paper closest to mine)", "Crítica: ler com atenção (revisão por pares, o artigo mais próximo do meu)"],
|
|
13
|
+
};
|
|
14
|
+
/** "/read extract smith2026" -> purpose and reference; the first word is a purpose only when it is one exactly. */
|
|
15
|
+
export function readArgs(args) {
|
|
16
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
17
|
+
const first = words[0]?.toLowerCase();
|
|
18
|
+
if (first && Object.hasOwn(PURPOSES, first))
|
|
19
|
+
return { purpose: first, ref: words.slice(1).join(" ") };
|
|
20
|
+
return { ref: words.join(" ") };
|
|
21
|
+
}
|
|
22
|
+
const GUIDELINES = "CONSORT for randomised trials, STROBE for observational studies, PRISMA for systematic reviews, STARD for diagnostic accuracy, TRIPOD or TRIPOD+AI for prediction models, CHEERS for economic evaluations, SRQR or COREQ for qualitative research";
|
|
23
|
+
function depth(r) {
|
|
24
|
+
switch (r.purpose) {
|
|
25
|
+
case "background":
|
|
26
|
+
return [
|
|
27
|
+
"Depth: the title and the abstract only. Do not read the full text.",
|
|
28
|
+
'Under "## The paper at a glance":',
|
|
29
|
+
"- What it asks: the sentence of the title or abstract that states the question or aim, quoted.",
|
|
30
|
+
"- How: the sentence that names the design and the population, quoted.",
|
|
31
|
+
'Under "## Keep or drop": the question "Does this paper fit your reason? Look at the population, the design and the outcome.", then the lines "- [ ] Keep", "- [ ] Drop" and "Reason:", empty for the user.',
|
|
32
|
+
];
|
|
33
|
+
case "fact":
|
|
34
|
+
return [
|
|
35
|
+
`The user looks for: ${r.fact}`,
|
|
36
|
+
"Depth: only the passages that hold this fact. Get the section list first and read only the sections you need.",
|
|
37
|
+
'Under "## Where the paper says it": each passage, quoted, with its place. Under each passage, one question that makes the user read it closely (for example: what exactly does it say, for which population, at what time?) and an empty line "Your answer:".',
|
|
38
|
+
"If the paper does not say it, write that you did not find it and where you looked.",
|
|
39
|
+
];
|
|
40
|
+
case "extract": {
|
|
41
|
+
const sheet = r.sheet
|
|
42
|
+
? `the sheet ${r.sheet}: read it first for its columns, then add the row under its table with edit.`
|
|
43
|
+
: `a new sheet Research/<slug> extraction.md (slug: the review question in lower case with hyphens, at most five words; do not overwrite an existing file). Front matter: title, question: "${r.question}", type: extraction-sheet, created: ${r.date}. Then a table with the columns | Study | Question | Design | Population | Setting | Outcome | Main finding |.`;
|
|
44
|
+
return [
|
|
45
|
+
"Depth: the methods and the results (the full text when there is one).",
|
|
46
|
+
'Under "## Extraction": for each field below, the passage that answers it, quoted with its place, then an empty line "Your answer:".',
|
|
47
|
+
"- The question or aim",
|
|
48
|
+
"- The design",
|
|
49
|
+
"- The population (who, how many)",
|
|
50
|
+
"- The setting (where, when)",
|
|
51
|
+
"- The outcome and how it was measured",
|
|
52
|
+
"- The main finding: quote the sentence of the results that answers the question. Do not restate it.",
|
|
53
|
+
`Then add one row for this paper to ${sheet}`,
|
|
54
|
+
"The row: [[citekey]] in the Study column, and in every other column only where the user finds it (the page or the section). Leave the content of the cells for the user.",
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
case "critique":
|
|
58
|
+
return [
|
|
59
|
+
"Depth: the whole paper, section by section. A critique needs the full text: if you can read only the abstract, say so, write what the abstract allows, and offer /request-copy to ask the authors for the paper.",
|
|
60
|
+
'Under "## Methods", "## Results" and "## Discussion": the passages that matter to judge the study, quoted with their place, each with one question for the user (for example: how were the participants selected? what was compared? what else could explain the result?).',
|
|
61
|
+
`Under "## Appraisal": name the design and the reporting guideline that applies (${GUIDELINES}), and tell the user to check the paper against it item by item. Do not copy the checklist. Then five to eight questions on the risk of bias for this design (selection, confounding, measurement, missing data, selective reporting), each with the passage to look at.`,
|
|
62
|
+
"If the zotero_get_figure tool is available, show the table or figure with the main result: call it with the label of its caption and put the Markdown line it returns under the passage that cites it, with the page link, and a question about what the user sees in it.",
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The instruction for the model. The first line is the command itself: the chat shows a long
|
|
68
|
+
* message folded under its first line, so the user sees what they asked for.
|
|
69
|
+
*/
|
|
70
|
+
export function readInstruction(r) {
|
|
71
|
+
return [
|
|
72
|
+
`/read ${r.purpose} ${r.ref}`,
|
|
73
|
+
"",
|
|
74
|
+
`Guided reading of ${r.ref}. The user is learning to read papers: you find the passages and ask; the user reads and writes the answers.`,
|
|
75
|
+
"",
|
|
76
|
+
"1. Find the work: zotero_find_items first. For a DOI or PMID that is not in the library, scholar_get_work, and offer to import it.",
|
|
77
|
+
"2. Read only what the depth below needs: zotero_get_fulltext for a library item; otherwise scholar_read_oa_fulltext (the section list first, then only the sections you need); otherwise scholar_get_work for the abstract.",
|
|
78
|
+
"3. Write the reading note as described below.",
|
|
79
|
+
"4. Give the user the path of the note and the first question to answer. Do not answer the questions.",
|
|
80
|
+
"",
|
|
81
|
+
"Rules:",
|
|
82
|
+
"- State no results, effect sizes, summaries or conclusions, in the note or in the chat. Quote the paper and ask; the user writes the answers.",
|
|
83
|
+
"- Copy quotes word for word from what you read, and mark every omission with [...]. After each quote give its place: (p. N) only with evidence: full text (Sub-Sub links it to that page of the PDF), otherwise the section (Abstract, Methods, Results).",
|
|
84
|
+
`- The note is Literature/<citekey>.md in the Sub-Sub folder, with the front matter of a literature note from the shared rules plus type: reading-note and purpose: ${r.purpose}; evidence says what you read. If the note exists, do not overwrite it: add a section "## Reading ${r.date}: ${r.purpose}" at the end with edit, with the content below one heading level lower.`,
|
|
85
|
+
'- Start with "## Why I am reading this" (the reason, in the user\'s words when they gave them) and end with one line "Next step:" (what to read or do next).',
|
|
86
|
+
"",
|
|
87
|
+
...depth(r),
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
package/dist/roles.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which tools each mode may use, and which calls need the user's approval.
|
|
3
3
|
*/
|
|
4
|
-
import { profileSpec } from "./profiles.js";
|
|
5
4
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
6
|
-
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode
|
|
5
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
|
|
7
6
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
8
7
|
export const ZOTERO_READ = [
|
|
9
8
|
"status",
|
|
@@ -55,12 +54,11 @@ export const ZOTERO_CHECKS = [
|
|
|
55
54
|
].map((n) => `zotero_${n}`);
|
|
56
55
|
/**
|
|
57
56
|
* The tools of a mode. The Librarian manages the library: Zotero tools and files.
|
|
58
|
-
* The Researcher has the same, plus searches, notes and reference checks.
|
|
59
|
-
*
|
|
57
|
+
* The Researcher has the same, plus searches, notes and reference checks. Every
|
|
58
|
+
* library change is still previewed (gateKind).
|
|
60
59
|
*/
|
|
61
60
|
export function toolsFor(mode, registered, opts = {}) {
|
|
62
61
|
const has = new Set(registered);
|
|
63
|
-
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
64
62
|
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
65
63
|
const wanted = [
|
|
66
64
|
...ZOTERO_READ,
|
|
@@ -69,16 +67,12 @@ export function toolsFor(mode, registered, opts = {}) {
|
|
|
69
67
|
...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
|
|
70
68
|
...FILE_TOOLS,
|
|
71
69
|
];
|
|
72
|
-
return [...new Set(wanted)].filter((n) => has.has(n)
|
|
70
|
+
return [...new Set(wanted)].filter((n) => has.has(n));
|
|
73
71
|
}
|
|
74
72
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
75
73
|
export function unavailableReason(name, mode, registered, opts = {}) {
|
|
76
74
|
if (toolsFor(mode, registered, opts).includes(name))
|
|
77
75
|
return undefined;
|
|
78
|
-
const profile = opts.profile ?? "editor";
|
|
79
|
-
if (profileSpec(profile).without.includes(name)) {
|
|
80
|
-
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
81
|
-
}
|
|
82
76
|
if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
|
|
83
77
|
return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
|
|
84
78
|
}
|