@tiagojct/subsub 0.15.0 → 0.17.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 +22 -0
- package/README.md +15 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +0 -6
- package/dist/doctor.js +1 -2
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/prompt.js +4 -6
- package/dist/read.js +88 -0
- package/dist/roles.js +4 -10
- package/dist/statement.js +35 -0
- package/dist/subsub.js +102 -47
- package/dist/web.js +122 -9
- package/package.json +1 -1
- package/prompts/lit.md +6 -2
- package/roles/researcher.md +3 -1
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +1 -11
- package/src/doctor.ts +1 -2
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/prompt.ts +6 -8
- package/src/read.ts +107 -0
- package/src/roles.ts +5 -12
- package/src/statement.ts +45 -0
- package/src/subsub.ts +97 -48
- package/src/usage.ts +1 -1
- package/src/web.ts +114 -9
- package/templates/Zotero agent.md +1 -1
- package/web/app.css +61 -2
- package/web/app.js +215 -18
- package/web/i18n.js +56 -24
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/src/init.ts
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
|
|
|
@@ -33,10 +33,8 @@ import {
|
|
|
33
33
|
EU_MODELS,
|
|
34
34
|
FREE_MODELS,
|
|
35
35
|
expand,
|
|
36
|
-
isProfile,
|
|
37
36
|
loadConfig,
|
|
38
37
|
parseEnvFile,
|
|
39
|
-
PROFILES,
|
|
40
38
|
readConfigFile,
|
|
41
39
|
saveConfig,
|
|
42
40
|
starbuckEnabled,
|
|
@@ -46,7 +44,6 @@ import {
|
|
|
46
44
|
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
47
45
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
|
|
48
46
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
49
|
-
import { PROFILE_SPECS } from "./profiles.ts";
|
|
50
47
|
|
|
51
48
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
52
49
|
|
|
@@ -176,40 +173,18 @@ export async function runInit(
|
|
|
176
173
|
}
|
|
177
174
|
}
|
|
178
175
|
|
|
179
|
-
// Asked here, not first: it only changes the
|
|
180
|
-
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
|
|
176
|
+
// Asked here, not first: it only changes the default of the tag list question.
|
|
177
|
+
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.");
|
|
181
178
|
const setup = await io.choose(
|
|
182
179
|
"Set-up",
|
|
183
180
|
[
|
|
184
181
|
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
185
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags
|
|
182
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags (the FMUP model service is coming soon)" },
|
|
186
183
|
],
|
|
187
184
|
flags.setup ?? existing.setup ?? "standard",
|
|
188
185
|
);
|
|
189
186
|
const fmup = setup === "fmup";
|
|
190
187
|
|
|
191
|
-
// Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
|
|
192
|
-
let student = fmup;
|
|
193
|
-
if (!flags.profile && !existing.profile && !fmup) {
|
|
194
|
-
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.");
|
|
195
|
-
student = (await io.choose(
|
|
196
|
-
"You are",
|
|
197
|
-
[
|
|
198
|
-
{ value: "student", label: "A student" },
|
|
199
|
-
{ value: "researcher", label: "A researcher, teacher or clinician" },
|
|
200
|
-
],
|
|
201
|
-
flags.student === "no" ? "researcher" : "student",
|
|
202
|
-
)) === "student";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
206
|
-
const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
|
|
207
|
-
const profile = await io.choose(
|
|
208
|
-
"Profile",
|
|
209
|
-
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
210
|
-
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
211
|
-
);
|
|
212
|
-
|
|
213
188
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
214
189
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
215
190
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -322,7 +297,7 @@ export async function runInit(
|
|
|
322
297
|
if (brave || hadBrave) chmodSync(envFile, 0o600); // the file holds a key
|
|
323
298
|
|
|
324
299
|
const patch: Record<string, unknown> = {
|
|
325
|
-
profile,
|
|
300
|
+
profile: undefined,
|
|
326
301
|
userName: userName || undefined,
|
|
327
302
|
about: about || undefined,
|
|
328
303
|
language,
|
package/src/look.ts
CHANGED
|
@@ -84,8 +84,6 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
|
|
|
84
84
|
export function headerLines(opts: {
|
|
85
85
|
version: string;
|
|
86
86
|
mode: "librarian" | "researcher";
|
|
87
|
-
/** Profile label, e.g. "Scholar". */
|
|
88
|
-
profile?: string;
|
|
89
87
|
model: string;
|
|
90
88
|
library: LibraryState;
|
|
91
89
|
quote?: [string, string];
|
|
@@ -94,7 +92,7 @@ export function headerLines(opts: {
|
|
|
94
92
|
}): string[] {
|
|
95
93
|
const p = opts.paint ?? plain;
|
|
96
94
|
const w = Math.max(10, opts.width);
|
|
97
|
-
const modeLabel =
|
|
95
|
+
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
98
96
|
const lib = libraryLine(opts.library);
|
|
99
97
|
if (w < BANNER[0].length + 2) {
|
|
100
98
|
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))];
|
|
@@ -107,7 +105,7 @@ export function headerLines(opts: {
|
|
|
107
105
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
108
106
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
109
107
|
}
|
|
110
|
-
out.push(p.dim(cut("/researcher /librarian /
|
|
108
|
+
out.push(p.dim(cut("/researcher /librarian /read /history /undo | / for all commands", w)), "");
|
|
111
109
|
return out;
|
|
112
110
|
}
|
|
113
111
|
|
package/src/prompt.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
|
|
@@ -11,7 +11,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
11
11
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { type Mode, type SubsubConfig, who } from "./config.ts";
|
|
14
|
-
import { profileSpec } from "./profiles.ts";
|
|
15
14
|
|
|
16
15
|
const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
|
|
17
16
|
|
|
@@ -41,18 +40,18 @@ function capital(s: string): string {
|
|
|
41
40
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
|
|
43
|
+
export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about">): string {
|
|
45
44
|
const user = who(cfg);
|
|
46
45
|
const about = cfg.about ? `${capital(user)} is ${cfg.about}.` : "";
|
|
47
46
|
return text
|
|
48
47
|
.replaceAll("{{User}}", capital(user))
|
|
49
48
|
.replaceAll("{{user}}", user)
|
|
50
49
|
.replaceAll("{{about}}", about)
|
|
51
|
-
.replaceAll("{{batch}}",
|
|
50
|
+
.replaceAll("{{batch}}", "25")
|
|
52
51
|
.replace(/[ \t]+\n/g, "\n");
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about"
|
|
54
|
+
export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about">): string {
|
|
56
55
|
const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
|
|
57
56
|
return cfg ? fill(raw, cfg) : raw;
|
|
58
57
|
}
|
|
@@ -86,8 +85,7 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
|
|
|
86
85
|
`${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.`,
|
|
87
86
|
);
|
|
88
87
|
}
|
|
89
|
-
// The
|
|
90
|
-
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
88
|
+
// The language rule comes last, so it wins over the shared rules and the vault conventions.
|
|
91
89
|
parts.push("# Language", languageRule(cfg.language, user));
|
|
92
90
|
return parts.join("\n\n");
|
|
93
91
|
}
|
package/src/read.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
8
|
+
/** The reasons to read, with the labels of the choice (English, Portuguese). */
|
|
9
|
+
export const PURPOSES = {
|
|
10
|
+
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)"],
|
|
11
|
+
fact: ["One fact: find a specific detail", "Um dado: encontrar um pormenor concreto"],
|
|
12
|
+
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)"],
|
|
13
|
+
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)"],
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type Purpose = keyof typeof PURPOSES;
|
|
17
|
+
|
|
18
|
+
export interface ReadRequest {
|
|
19
|
+
ref: string;
|
|
20
|
+
purpose: Purpose;
|
|
21
|
+
date: string;
|
|
22
|
+
/** fact: what the user looks for. */
|
|
23
|
+
fact?: string;
|
|
24
|
+
/** extract: an existing sheet, relative to the Sub-Sub folder. */
|
|
25
|
+
sheet?: string;
|
|
26
|
+
/** extract: the review question of a new sheet. */
|
|
27
|
+
question?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "/read extract smith2026" -> purpose and reference; the first word is a purpose only when it is one exactly. */
|
|
31
|
+
export function readArgs(args: string): { purpose?: Purpose; ref: string } {
|
|
32
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
const first = words[0]?.toLowerCase();
|
|
34
|
+
if (first && Object.hasOwn(PURPOSES, first)) return { purpose: first as Purpose, ref: words.slice(1).join(" ") };
|
|
35
|
+
return { ref: words.join(" ") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GUIDELINES =
|
|
39
|
+
"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";
|
|
40
|
+
|
|
41
|
+
function depth(r: ReadRequest): string[] {
|
|
42
|
+
switch (r.purpose) {
|
|
43
|
+
case "background":
|
|
44
|
+
return [
|
|
45
|
+
"Depth: the title and the abstract only. Do not read the full text.",
|
|
46
|
+
'Under "## The paper at a glance":',
|
|
47
|
+
"- What it asks: the sentence of the title or abstract that states the question or aim, quoted.",
|
|
48
|
+
"- How: the sentence that names the design and the population, quoted.",
|
|
49
|
+
'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.',
|
|
50
|
+
];
|
|
51
|
+
case "fact":
|
|
52
|
+
return [
|
|
53
|
+
`The user looks for: ${r.fact}`,
|
|
54
|
+
"Depth: only the passages that hold this fact. Get the section list first and read only the sections you need.",
|
|
55
|
+
'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:".',
|
|
56
|
+
"If the paper does not say it, write that you did not find it and where you looked.",
|
|
57
|
+
];
|
|
58
|
+
case "extract": {
|
|
59
|
+
const sheet = r.sheet
|
|
60
|
+
? `the sheet ${r.sheet}: read it first for its columns, then add the row under its table with edit.`
|
|
61
|
+
: `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 |.`;
|
|
62
|
+
return [
|
|
63
|
+
"Depth: the methods and the results (the full text when there is one).",
|
|
64
|
+
'Under "## Extraction": for each field below, the passage that answers it, quoted with its place, then an empty line "Your answer:".',
|
|
65
|
+
"- The question or aim",
|
|
66
|
+
"- The design",
|
|
67
|
+
"- The population (who, how many)",
|
|
68
|
+
"- The setting (where, when)",
|
|
69
|
+
"- The outcome and how it was measured",
|
|
70
|
+
"- The main finding: quote the sentence of the results that answers the question. Do not restate it.",
|
|
71
|
+
`Then add one row for this paper to ${sheet}`,
|
|
72
|
+
"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.",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
case "critique":
|
|
76
|
+
return [
|
|
77
|
+
"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.",
|
|
78
|
+
'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?).',
|
|
79
|
+
`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.`,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The instruction for the model. The first line is the command itself: the chat shows a long
|
|
86
|
+
* message folded under its first line, so the user sees what they asked for.
|
|
87
|
+
*/
|
|
88
|
+
export function readInstruction(r: ReadRequest): string {
|
|
89
|
+
return [
|
|
90
|
+
`/read ${r.purpose} ${r.ref}`,
|
|
91
|
+
"",
|
|
92
|
+
`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.`,
|
|
93
|
+
"",
|
|
94
|
+
"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.",
|
|
95
|
+
"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.",
|
|
96
|
+
"3. Write the reading note as described below.",
|
|
97
|
+
"4. Give the user the path of the note and the first question to answer. Do not answer the questions.",
|
|
98
|
+
"",
|
|
99
|
+
"Rules:",
|
|
100
|
+
"- 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.",
|
|
101
|
+
"- Copy quotes word for word from what you read. After each quote give its place: the page only with evidence: full text, otherwise the section (Abstract, Methods, Results).",
|
|
102
|
+
`- 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.`,
|
|
103
|
+
'- 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).',
|
|
104
|
+
"",
|
|
105
|
+
...depth(r),
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
package/src/roles.ts
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
* Which tools each mode may use, and which calls need the user's approval.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Mode
|
|
6
|
-
import { profileSpec } from "./profiles.ts";
|
|
5
|
+
import type { Mode } from "./config.ts";
|
|
7
6
|
|
|
8
7
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
9
8
|
|
|
10
|
-
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode
|
|
9
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
|
|
11
10
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
12
11
|
|
|
13
12
|
export const ZOTERO_READ = [
|
|
@@ -66,19 +65,17 @@ export const ZOTERO_CHECKS = [
|
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
export interface ToolOptions {
|
|
69
|
-
profile?: Profile;
|
|
70
68
|
/** The model test: also the bakeoff_ tools. */
|
|
71
69
|
bench?: boolean;
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
73
|
* The tools of a mode. The Librarian manages the library: Zotero tools and files.
|
|
76
|
-
* The Researcher has the same, plus searches, notes and reference checks.
|
|
77
|
-
*
|
|
74
|
+
* The Researcher has the same, plus searches, notes and reference checks. Every
|
|
75
|
+
* library change is still previewed (gateKind).
|
|
78
76
|
*/
|
|
79
77
|
export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
|
|
80
78
|
const has = new Set(registered);
|
|
81
|
-
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
82
79
|
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
83
80
|
const wanted = [
|
|
84
81
|
...ZOTERO_READ,
|
|
@@ -87,16 +84,12 @@ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {
|
|
|
87
84
|
...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
|
|
88
85
|
...FILE_TOOLS,
|
|
89
86
|
];
|
|
90
|
-
return [...new Set(wanted)].filter((n) => has.has(n)
|
|
87
|
+
return [...new Set(wanted)].filter((n) => has.has(n));
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
94
91
|
export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
|
|
95
92
|
if (toolsFor(mode, registered, opts).includes(name)) return undefined;
|
|
96
|
-
const profile = opts.profile ?? "editor";
|
|
97
|
-
if (profileSpec(profile).without.includes(name)) {
|
|
98
|
-
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
99
|
-
}
|
|
100
93
|
if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
|
|
101
94
|
return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
|
|
102
95
|
}
|
package/src/statement.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /ai-statement: a draft of the statement on the use of AI that journals ask for (ICMJE and
|
|
3
|
+
* most publishers), with the facts Sub-Sub knows: its version, the models of each mode and their
|
|
4
|
+
* providers, and the checks the program makes. The authors complete the bracketed parts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SUBSUB_DOI = "10.5281/zenodo.23238602";
|
|
8
|
+
|
|
9
|
+
export interface StatementFacts {
|
|
10
|
+
version: string;
|
|
11
|
+
models: Partial<Record<"librarian" | "researcher", string>>;
|
|
12
|
+
uses?: string;
|
|
13
|
+
date: string;
|
|
14
|
+
starbuck: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "opencode-go/mimo-v2.6-pro" -> "mimo-v2.6-pro (OpenCode Go)". */
|
|
18
|
+
export function modelLabel(spec: string): string {
|
|
19
|
+
const i = spec.indexOf("/");
|
|
20
|
+
if (i < 0) return spec;
|
|
21
|
+
const provider = spec.slice(0, i);
|
|
22
|
+
const names: Record<string, string> = { "opencode-go": "OpenCode Go", google: "Google", mistral: "Mistral AI", openai: "OpenAI", anthropic: "Anthropic", openrouter: "OpenRouter", deepseek: "DeepSeek" };
|
|
23
|
+
return `${spec.slice(i + 1)} (${names[provider] ?? provider})`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function aiStatement(f: StatementFacts): string {
|
|
27
|
+
const models = [...new Set(Object.values(f.models).filter(Boolean) as string[])].map(modelLabel);
|
|
28
|
+
const modelText = models.length ? `the language model${models.length > 1 ? "s" : ""} ${models.join(" and ")}` : "[the language model and its provider]";
|
|
29
|
+
const uses = f.uses?.trim() || "[the tasks: for example, literature searches, screening of search results, reading notes on included studies, checks of the references]";
|
|
30
|
+
return [
|
|
31
|
+
"## Use of AI tools",
|
|
32
|
+
"",
|
|
33
|
+
`We used Sub-Sub ${f.version} (Jacinto T., doi:${SUBSUB_DOI}), an open-source research assistant that works from a Zotero reference library, with ${modelText}, in [month and year]. We used it for ${uses}.`,
|
|
34
|
+
"",
|
|
35
|
+
"Sub-Sub records in each note whether it was written from the full text, the abstract or the bibliographic record, checks that quotations are in the full text, and makes no change to the reference library without the authors' approval." +
|
|
36
|
+
(f.starbuck ? " The cited works were checked for existence, bibliographic accuracy and retraction with Starbuck." : ""),
|
|
37
|
+
"",
|
|
38
|
+
"[Choose one: No text of this manuscript was written by an AI tool. | Sub-Sub drafted [which parts]; the authors revised and rewrote this text.]",
|
|
39
|
+
"",
|
|
40
|
+
"The authors checked every reference and every statement taken from the sources against the original publications, and take full responsibility for the content of this article.",
|
|
41
|
+
"",
|
|
42
|
+
"<!-- Draft made by Sub-Sub on " + f.date + ". Complete the parts in brackets, delete this line, and follow the journal's instructions on where the statement goes (often the Methods or the Acknowledgements). -->",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
package/src/subsub.ts
CHANGED
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* and writes notes. Each mode can have its own model.
|
|
7
7
|
* - An approval gate: every library change is previewed by the server (dry run)
|
|
8
8
|
* and shown to the user; nothing is applied without a yes.
|
|
9
|
-
* -
|
|
10
|
-
* tools it offers; /profile changes it.
|
|
11
|
-
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
9
|
+
* - Commands: /librarian, /researcher, /read, /subsub, /history, /undo.
|
|
12
10
|
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
13
11
|
* ("verify") for reference checks.
|
|
14
12
|
*
|
|
@@ -17,7 +15,7 @@
|
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
19
|
import { dirname, join } from "node:path";
|
|
22
20
|
import { fileURLToPath } from "node:url";
|
|
23
21
|
import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
@@ -25,12 +23,9 @@ import {
|
|
|
25
23
|
expand,
|
|
26
24
|
findUv,
|
|
27
25
|
FREE_MODELS,
|
|
28
|
-
isProfile,
|
|
29
26
|
loadConfig,
|
|
30
27
|
parseEnvFile,
|
|
31
|
-
type Profile,
|
|
32
28
|
type Mode,
|
|
33
|
-
saveConfig,
|
|
34
29
|
serverCommand,
|
|
35
30
|
starbuckCommand,
|
|
36
31
|
starbuckEnabled,
|
|
@@ -38,8 +33,9 @@ import {
|
|
|
38
33
|
uiLanguage,
|
|
39
34
|
who,
|
|
40
35
|
} from "./config.ts";
|
|
41
|
-
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
42
36
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
37
|
+
import { aiStatement } from "./statement.ts";
|
|
38
|
+
import { PURPOSES, type Purpose, readArgs, readInstruction, type ReadRequest } from "./read.ts";
|
|
43
39
|
import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.ts";
|
|
44
40
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
45
41
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
@@ -142,8 +138,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
142
138
|
|
|
143
139
|
let mode: Mode = cfg.defaultMode;
|
|
144
140
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
145
|
-
const toolOpts = () => ({
|
|
146
|
-
let profile: Profile = cfg.profile;
|
|
141
|
+
const toolOpts = () => ({ bench: benchRun });
|
|
147
142
|
const user = who(cfg);
|
|
148
143
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
149
144
|
const standalone = process.env.SUBSUB_CLI === "1";
|
|
@@ -170,8 +165,6 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
170
165
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
171
166
|
const state = {
|
|
172
167
|
mode,
|
|
173
|
-
profile,
|
|
174
|
-
profileLabel: profileSpec(profile).label,
|
|
175
168
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
176
169
|
down,
|
|
177
170
|
library,
|
|
@@ -237,7 +230,6 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
237
230
|
return headerLines({
|
|
238
231
|
version,
|
|
239
232
|
mode,
|
|
240
|
-
profile: profileSpec(profile).label,
|
|
241
233
|
model: modelId,
|
|
242
234
|
library,
|
|
243
235
|
quote,
|
|
@@ -259,7 +251,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
259
251
|
function status(ctx: ExtensionContext): void {
|
|
260
252
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
261
253
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
262
|
-
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${
|
|
254
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
263
255
|
if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
264
256
|
headerTui?.requestRender();
|
|
265
257
|
webCtx = ctx;
|
|
@@ -323,7 +315,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
323
315
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
324
316
|
applyTheme(ctx);
|
|
325
317
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
326
|
-
logUsage(cfg.usageLog, { ev: "session", mode,
|
|
318
|
+
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
327
319
|
if (lookOn(ctx)) {
|
|
328
320
|
setHeader(ctx);
|
|
329
321
|
void refreshLibrary();
|
|
@@ -346,6 +338,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
346
338
|
|
|
347
339
|
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
348
340
|
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
341
|
+
const researcherOnly = (cmd: string) => L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`);
|
|
349
342
|
|
|
350
343
|
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
351
344
|
pi.on("session_tree", async (_event, ctx) => {
|
|
@@ -392,8 +385,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
392
385
|
});
|
|
393
386
|
|
|
394
387
|
|
|
395
|
-
//
|
|
396
|
-
// The input event comes before pi expands a prompt template.
|
|
388
|
+
// Research prompts in the Librarian. The input event comes before pi expands a prompt template.
|
|
397
389
|
pi.on("input", async (event, ctx) => {
|
|
398
390
|
if (resumeTimer) clearTimeout(resumeTimer);
|
|
399
391
|
resumeTimer = undefined;
|
|
@@ -402,13 +394,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
402
394
|
// The usage log keeps the kind of request and its length, never its text.
|
|
403
395
|
if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
404
396
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
405
|
-
ctx.ui.notify(
|
|
397
|
+
ctx.ui.notify(researcherOnly(cmd), "warning");
|
|
406
398
|
return { action: "handled" };
|
|
407
399
|
}
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
400
|
+
// shortcut: a notice for the habit of 0.16 and earlier; drop it a few versions on.
|
|
401
|
+
if (cmd === "profile") {
|
|
402
|
+
ctx.ui.notify(L("Sub-Sub no longer has profiles: every command is in the Researcher. To read a paper with guidance, type /read.", "O Sub-Sub já não tem perfis: todos os comandos estão no Investigador. Para ler um artigo com orientação, escreva /read."), "info");
|
|
403
|
+
return { action: "handled" };
|
|
404
|
+
}
|
|
405
|
+
return { action: "continue" };
|
|
412
406
|
});
|
|
413
407
|
|
|
414
408
|
pi.on("agent_start", async () => {
|
|
@@ -417,7 +411,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
417
411
|
});
|
|
418
412
|
|
|
419
413
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
420
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode,
|
|
414
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
|
|
421
415
|
}));
|
|
422
416
|
|
|
423
417
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
@@ -632,34 +626,10 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
632
626
|
},
|
|
633
627
|
});
|
|
634
628
|
|
|
635
|
-
pi.registerCommand("profile", {
|
|
636
|
-
description: "Sub-Sub: show or change the profile (reader, scholar, author, editor)",
|
|
637
|
-
handler: async (args, ctx) => {
|
|
638
|
-
const want = args.trim().toLowerCase();
|
|
639
|
-
if (!want) {
|
|
640
|
-
ctx.ui.notify(`Profile: ${profileSpec(profile).label}. To change it, type /profile <name>.\n${profileList(profile)}`, "info");
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
if (!isProfile(want)) {
|
|
644
|
-
ctx.ui.notify(`Unknown profile "${want}". Choose one of:\n${profileList(profile)}`, "warning");
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
profile = want;
|
|
648
|
-
let saved = "";
|
|
649
|
-
try {
|
|
650
|
-
saved = ` Saved in ${saveConfig({ profile })}.`;
|
|
651
|
-
} catch (err) {
|
|
652
|
-
saved = ` Not saved: ${(err as Error).message}`;
|
|
653
|
-
}
|
|
654
|
-
await applyMode(ctx, mode, false, false);
|
|
655
|
-
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
656
|
-
},
|
|
657
|
-
});
|
|
658
|
-
|
|
659
629
|
pi.registerCommand("subsub", {
|
|
660
630
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
661
631
|
handler: async (_args, ctx) => {
|
|
662
|
-
const lines = [`Mode: ${mode}.
|
|
632
|
+
const lines = [`Mode: ${mode}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
663
633
|
if (!bridge) {
|
|
664
634
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
665
635
|
return;
|
|
@@ -695,6 +665,85 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
695
665
|
},
|
|
696
666
|
});
|
|
697
667
|
|
|
668
|
+
// The web view's library search: the result goes to the page through a status, like the library counts.
|
|
669
|
+
pi.registerCommand("subsub-find", {
|
|
670
|
+
description: "Sub-Sub: search the library for the web view",
|
|
671
|
+
handler: async (args, ctx) => {
|
|
672
|
+
if (ctx.mode !== "rpc") return;
|
|
673
|
+
const q = String(args ?? "").trim();
|
|
674
|
+
let out: Record<string, unknown>;
|
|
675
|
+
try {
|
|
676
|
+
const r = await current().call("zotero_find_items", { query: q, limit: 25 });
|
|
677
|
+
out = r.isError ? { error: r.text } : { items: (r.data as { items?: unknown[] })?.items ?? [] };
|
|
678
|
+
} catch (err) {
|
|
679
|
+
out = { error: (err as Error).message };
|
|
680
|
+
}
|
|
681
|
+
ctx.ui.setStatus("subsub.find", JSON.stringify({ q, ...out }));
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
pi.registerCommand("ai-statement", {
|
|
686
|
+
description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
|
|
687
|
+
handler: async (args, ctx) => {
|
|
688
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
689
|
+
const text = aiStatement({ version: ownVersion(), models: cfg.models, uses: String(args ?? ""), date, starbuck: starbuckEnabled(cfg) });
|
|
690
|
+
const dir = join(cfg.vault ?? ctx.cwd, "Research");
|
|
691
|
+
let file = join(dir, `${date} AI use statement.md`);
|
|
692
|
+
for (let n = 2; existsSync(file); n++) file = join(dir, `${date} AI use statement-${n}.md`);
|
|
693
|
+
try {
|
|
694
|
+
mkdirSync(dir, { recursive: true });
|
|
695
|
+
writeFileSync(file, text);
|
|
696
|
+
} catch (err) {
|
|
697
|
+
ctx.ui.notify(`${text}\n${L("Could not save it", "Não foi possível guardar")}: ${(err as Error).message}`, "warning");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
ctx.ui.notify(
|
|
701
|
+
L(
|
|
702
|
+
`Sub-Sub: a draft statement on the use of AI is in ${file}. Complete the parts in brackets. To name the tasks, type /ai-statement followed by them, for example: /ai-statement literature searches and reading notes.`,
|
|
703
|
+
`Sub-Sub: há um rascunho da declaração sobre o uso de IA em ${file}. Complete as partes entre parênteses retos. Para indicar as tarefas, escreva /ai-statement seguido delas, por exemplo: /ai-statement literature searches and reading notes.`,
|
|
704
|
+
),
|
|
705
|
+
"info",
|
|
706
|
+
);
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
711
|
+
pi.registerCommand("read", {
|
|
712
|
+
description: "Sub-Sub: read a paper with guidance; Sub-Sub finds the passages and asks, you write the answers. /read [background|fact|extract|critique] <citekey, DOI, PMID or title words>",
|
|
713
|
+
handler: async (args, ctx) => {
|
|
714
|
+
if (mode === "librarian") return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
715
|
+
let { purpose, ref } = readArgs(String(args ?? ""));
|
|
716
|
+
ref ||= (await ctx.ui.input(L("Which paper?", "Que artigo?"), L("citekey, DOI, PMID or title words", "citekey, DOI, PMID ou palavras do título")))?.trim() ?? "";
|
|
717
|
+
if (!ref) return;
|
|
718
|
+
if (!purpose) {
|
|
719
|
+
const labels = Object.values(PURPOSES).map(([en, pt]) => L(en, pt));
|
|
720
|
+
purpose = (Object.keys(PURPOSES) as Purpose[])[labels.indexOf((await ctx.ui.select(L("Why are you reading it?", "Porque vai ler este artigo?"), labels)) ?? "")];
|
|
721
|
+
if (!purpose) return;
|
|
722
|
+
}
|
|
723
|
+
const req: ReadRequest = { ref, purpose, date: new Date().toISOString().slice(0, 10) };
|
|
724
|
+
if (purpose === "fact") {
|
|
725
|
+
req.fact = (await ctx.ui.input(L("Which fact are you looking for?", "Que dado procura?")))?.trim();
|
|
726
|
+
if (!req.fact) return;
|
|
727
|
+
}
|
|
728
|
+
if (purpose === "extract") {
|
|
729
|
+
let sheets: string[] = [];
|
|
730
|
+
try {
|
|
731
|
+
sheets = readdirSync(join(cfg.vault ?? ctx.cwd, "Research")).filter((f) => f.endsWith(" extraction.md")).sort();
|
|
732
|
+
} catch {
|
|
733
|
+
// No Research folder yet: the first sheet is a new one.
|
|
734
|
+
}
|
|
735
|
+
const fresh = L("New sheet", "Nova folha");
|
|
736
|
+
const pick = sheets.length ? await ctx.ui.select(L("Which extraction sheet?", "Que folha de extração?"), [...sheets, fresh]) : fresh;
|
|
737
|
+
if (!pick) return;
|
|
738
|
+
if (pick === fresh) {
|
|
739
|
+
req.question = (await ctx.ui.input(L("The question of your review", "A pergunta da sua revisão")))?.trim();
|
|
740
|
+
if (!req.question) return;
|
|
741
|
+
} else req.sheet = `Research/${pick}`;
|
|
742
|
+
}
|
|
743
|
+
pi.sendUserMessage(readInstruction(req), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
|
|
698
747
|
pi.registerCommand("history", {
|
|
699
748
|
description: "Sub-Sub: recent library changes",
|
|
700
749
|
handler: async (_args, ctx) => {
|
package/src/usage.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
export type UsageEvent =
|
|
13
|
-
| { ev: "session"; mode: string;
|
|
13
|
+
| { ev: "session"; mode: string; model?: string }
|
|
14
14
|
| { ev: "ask"; kind: string; words: number }
|
|
15
15
|
| { ev: "turn"; seconds: number; tools: number; error?: string }
|
|
16
16
|
| { ev: "change"; tool: string; approved: boolean };
|