@tiagojct/subsub 0.6.1 → 0.7.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/LICENSE +21 -0
- package/README.md +200 -62
- package/THIRD_PARTY_NOTICES +55 -0
- package/dist/bench.js +60 -5
- package/dist/config.js +28 -1
- package/dist/doctor.js +23 -2
- package/dist/init.js +17 -3
- package/dist/notes.js +42 -0
- package/dist/profiles.js +15 -0
- package/dist/prompt.js +3 -0
- package/dist/roles.js +7 -1
- package/dist/subsub.js +94 -9
- package/dist/web.js +17 -3
- package/package.json +7 -3
- package/prompts/compare.md +23 -0
- package/prompts/digest.md +23 -0
- package/prompts/lit.md +87 -0
- package/prompts/request-copy.md +35 -0
- package/prompts/review.md +31 -0
- package/prompts/verify.md +18 -0
- package/roles/researcher.md +18 -5
- package/src/bench.ts +63 -4
- package/src/config.ts +39 -1
- package/src/doctor.ts +32 -2
- package/src/init.ts +33 -4
- package/src/notes.ts +46 -0
- package/src/profiles.ts +17 -0
- package/src/prompt.ts +6 -0
- package/src/roles.ts +8 -1
- package/src/subsub.ts +100 -9
- package/src/web.ts +15 -3
- package/templates/Zotero agent.md +2 -0
- package/themes/subsub-glauca-dark.json +34 -34
- package/themes/subsub-glauca-light.json +32 -32
- package/themes/subsub-try-works-dark.json +34 -34
- package/themes/subsub-try-works-light.json +34 -34
- package/web/app.css +801 -164
- package/web/app.js +78 -8
- package/web/glauca.css +51 -70
- package/web/i18n.js +36 -0
- package/web/icon.svg +12 -1
- package/web/index.html +9 -2
package/dist/init.js
CHANGED
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
14
14
|
* standard|fmup, --name, --about, --language, --notes, --profile, --tags
|
|
15
|
-
* health-sciences|health-informatics|any-field, --email, --
|
|
15
|
+
* health-sciences|health-informatics|any-field, --email, --starbuck on|off,
|
|
16
|
+
* --models keep|later|opencode-go.
|
|
16
17
|
*/
|
|
17
18
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
20
|
import { dirname, join, resolve } from "node:path";
|
|
20
21
|
import { createInterface } from "node:readline/promises";
|
|
21
22
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig } from "./config.js";
|
|
23
|
+
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
24
|
+
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
23
25
|
import { PROFILE_SPECS } from "./profiles.js";
|
|
24
26
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
25
27
|
export const STARTER_TAGS = {
|
|
@@ -92,6 +94,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
92
94
|
tags = await io.choose("Starter tag list (edit it later in the notes folder)", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
|
|
93
95
|
}
|
|
94
96
|
const email = (await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
97
|
+
const starbuck = await io.choose("Reference checks (Starbuck): check that cited works exist, match their citation and were not retracted", [
|
|
98
|
+
{ value: "off", label: "Off" },
|
|
99
|
+
{ value: "on", label: "On (the researcher gets /verify; uses the email above)" },
|
|
100
|
+
], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
|
|
95
101
|
const modelOptions = [
|
|
96
102
|
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
97
103
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
@@ -128,6 +134,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
128
134
|
setup: fmup ? "fmup" : undefined,
|
|
129
135
|
vault: notes,
|
|
130
136
|
envFile,
|
|
137
|
+
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
131
138
|
};
|
|
132
139
|
if (models === "later")
|
|
133
140
|
patch.models = {};
|
|
@@ -135,6 +142,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
135
142
|
patch.models = TESTED_MODELS;
|
|
136
143
|
saveConfig(patch, initEnv);
|
|
137
144
|
const zotero = await zoteroReachable(deps.fetch);
|
|
145
|
+
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
138
146
|
io.say("");
|
|
139
147
|
io.say(`Saved: ${configFile}`);
|
|
140
148
|
io.say(`Saved: ${envFile}`);
|
|
@@ -143,6 +151,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
143
151
|
io.say(zotero
|
|
144
152
|
? "Zotero: reachable."
|
|
145
153
|
: "Zotero: not reachable. Start Zotero 10, then in Zotero open Settings > Advanced and turn on \"Allow other applications on this computer to communicate with Zotero\".");
|
|
154
|
+
if (starbuck === "on")
|
|
155
|
+
io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
146
156
|
if (fmup)
|
|
147
157
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
148
158
|
io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
|
|
@@ -203,7 +213,11 @@ export async function initMain(args, env = process.env) {
|
|
|
203
213
|
const flags = parseFlags(args);
|
|
204
214
|
const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
205
215
|
try {
|
|
206
|
-
await runInit(io, flags, env)
|
|
216
|
+
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
217
|
+
// Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
|
|
218
|
+
const c = starbuckCheck(cfg, env, defaultRunner);
|
|
219
|
+
return c.ok ? `Reference checks: on (${c.detail}).` : `Reference checks: on, but ${c.detail}. ${c.fix ?? ""}`.trim();
|
|
220
|
+
} });
|
|
207
221
|
return 0;
|
|
208
222
|
}
|
|
209
223
|
catch (err) {
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks on the literature notes the researcher writes (a note whose front matter has a
|
|
3
|
+
* citekey). Each note must say what it was written from (evidence: full text, abstract
|
|
4
|
+
* or metadata), and only a note written from the full text may give page numbers.
|
|
5
|
+
* The write gate calls these before a write or edit runs; a failed check blocks the call
|
|
6
|
+
* with a reason, so the model corrects the note.
|
|
7
|
+
*/
|
|
8
|
+
export const EVIDENCE = ["full text", "abstract", "metadata"];
|
|
9
|
+
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
10
|
+
const PAGES = /\b(?:pp?\.|pages?)\s?\d+/i;
|
|
11
|
+
export function frontMatter(text) {
|
|
12
|
+
return FRONT.exec(text)?.[1];
|
|
13
|
+
}
|
|
14
|
+
export function field(front, name) {
|
|
15
|
+
const m = new RegExp(`^${name}:[ \\t]*(.*)$`, "m").exec(front);
|
|
16
|
+
return m ? m[1].trim().replace(/^["']|["']$/g, "").trim() : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Why a new literature note cannot be written as it is, or undefined when it can. */
|
|
19
|
+
export function checkLiteratureNote(text) {
|
|
20
|
+
const m = FRONT.exec(text);
|
|
21
|
+
if (!m || !field(m[1], "citekey"))
|
|
22
|
+
return undefined;
|
|
23
|
+
const evidence = field(m[1], "evidence")?.toLowerCase();
|
|
24
|
+
if (!evidence || !EVIDENCE.includes(evidence)) {
|
|
25
|
+
return 'A literature note needs "evidence:" in its front matter, with one of: full text, abstract, metadata (what you read to write it). Add it and write the note again.';
|
|
26
|
+
}
|
|
27
|
+
const body = text.slice(m[0].length);
|
|
28
|
+
if (evidence !== "full text" && PAGES.test(body)) {
|
|
29
|
+
return `This note says evidence: ${evidence}, but it gives page numbers. Page numbers come only from the full text: remove them, or read the full text and set evidence: full text.`;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/** The text of a file after pi's edit tool applies edits[] (each oldText matched against the original). */
|
|
34
|
+
export function applyEdits(current, edits) {
|
|
35
|
+
let out = current;
|
|
36
|
+
for (const e of edits) {
|
|
37
|
+
const oldText = String(e.oldText ?? "");
|
|
38
|
+
if (oldText && out.includes(oldText))
|
|
39
|
+
out = out.replace(oldText, () => String(e.newText ?? ""));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/dist/profiles.js
CHANGED
|
@@ -18,6 +18,7 @@ export const PROFILE_SPECS = {
|
|
|
18
18
|
label: "Reader",
|
|
19
19
|
summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
|
|
20
20
|
without: [...BULK_LIBRARY, ...MANUSCRIPT],
|
|
21
|
+
withoutPrompts: ["lit", "compare", "review", "digest"],
|
|
21
22
|
batch: 10,
|
|
22
23
|
rules: [
|
|
23
24
|
"Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
|
|
@@ -33,6 +34,7 @@ export const PROFILE_SPECS = {
|
|
|
33
34
|
label: "Scholar",
|
|
34
35
|
summary: "literature notes, syntheses, searches and alerts; the full librarian",
|
|
35
36
|
without: MANUSCRIPT,
|
|
37
|
+
withoutPrompts: ["review"],
|
|
36
38
|
batch: 25,
|
|
37
39
|
rules: [
|
|
38
40
|
"Profile: Scholar.",
|
|
@@ -46,6 +48,7 @@ export const PROFILE_SPECS = {
|
|
|
46
48
|
label: "Author",
|
|
47
49
|
summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
|
|
48
50
|
without: [],
|
|
51
|
+
withoutPrompts: [],
|
|
49
52
|
batch: 25,
|
|
50
53
|
rules: [
|
|
51
54
|
"Profile: Author.",
|
|
@@ -60,6 +63,7 @@ export const PROFILE_SPECS = {
|
|
|
60
63
|
label: "Editor",
|
|
61
64
|
summary: "everything, including drafting manuscript text on request",
|
|
62
65
|
without: [],
|
|
66
|
+
withoutPrompts: [],
|
|
63
67
|
batch: 25,
|
|
64
68
|
rules: [
|
|
65
69
|
"Profile: Editor. All tools.",
|
|
@@ -72,6 +76,17 @@ export const PROFILE_SPECS = {
|
|
|
72
76
|
export function profileSpec(p) {
|
|
73
77
|
return PROFILE_SPECS[p];
|
|
74
78
|
}
|
|
79
|
+
/** Why a prompt command does not run in this profile, or undefined when it does. */
|
|
80
|
+
export function promptBlocked(text, profile) {
|
|
81
|
+
const m = /^\/([\w-]+)(?:\s|$)/.exec(text.trim());
|
|
82
|
+
if (!m || !PROFILE_SPECS[profile].withoutPrompts.includes(m[1]))
|
|
83
|
+
return undefined;
|
|
84
|
+
const allowed = Object.values(PROFILE_SPECS)
|
|
85
|
+
.filter((s) => !s.withoutPrompts.includes(m[1]))
|
|
86
|
+
.map((s) => s.label)
|
|
87
|
+
.join(", ");
|
|
88
|
+
return `/${m[1]} is not part of the ${PROFILE_SPECS[profile].label} profile (it is in: ${allowed}). Change the profile with /profile.`;
|
|
89
|
+
}
|
|
75
90
|
export function profileList(current) {
|
|
76
91
|
return Object.values(PROFILE_SPECS)
|
|
77
92
|
.map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
|
package/dist/prompt.js
CHANGED
|
@@ -65,6 +65,9 @@ export function systemAddition(mode, cfg, cwd) {
|
|
|
65
65
|
parts.push(`# Vault conventions (${f})`, t);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
if (cfg.setup === "fmup") {
|
|
69
|
+
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.`);
|
|
70
|
+
}
|
|
68
71
|
// The profile and the language rule come last, so they win over the shared rules and the vault conventions.
|
|
69
72
|
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
70
73
|
parts.push("# Language", languageRule(cfg.language, user));
|
package/dist/roles.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { profileSpec } from "./profiles.js";
|
|
5
5
|
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 for these). */
|
|
7
|
+
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
6
8
|
export const ZOTERO_READ = [
|
|
7
9
|
"status",
|
|
8
10
|
"library_overview",
|
|
@@ -43,7 +45,11 @@ export function toolsFor(mode, registered, profile = "editor") {
|
|
|
43
45
|
const without = new Set(profileSpec(profile).without);
|
|
44
46
|
const wanted = mode === "librarian"
|
|
45
47
|
? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
|
|
46
|
-
: [
|
|
48
|
+
: [
|
|
49
|
+
...ZOTERO_READ,
|
|
50
|
+
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
51
|
+
...FILE_TOOLS,
|
|
52
|
+
];
|
|
47
53
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
48
54
|
}
|
|
49
55
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
package/dist/subsub.js
CHANGED
|
@@ -8,22 +8,25 @@
|
|
|
8
8
|
* - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
|
|
9
9
|
* tools it offers; /profile changes it.
|
|
10
10
|
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
11
|
+
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
12
|
+
* ("verify") for reference checks; its tools belong to researcher mode.
|
|
11
13
|
*
|
|
12
14
|
* The Python servers start in session_start (not in the factory) and stop in
|
|
13
15
|
* session_shutdown. Tools are registered once and use the current bridge.
|
|
14
16
|
*/
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
18
|
import { dirname, join } from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
18
20
|
import { Bridge } from "./bridge.js";
|
|
19
|
-
import { expand, findUv, isProfile, loadConfig, saveConfig, serverCommand, who } from "./config.js";
|
|
20
|
-
import { profileList, profileSpec } from "./profiles.js";
|
|
21
|
+
import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
|
|
22
|
+
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
21
23
|
import { coerceArgs, loosenArrays } from "./args.js";
|
|
24
|
+
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
22
25
|
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
23
26
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
24
27
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
25
28
|
import { systemAddition } from "./prompt.js";
|
|
26
|
-
import { gateKind, toolsFor, unavailableReason } from "./roles.js";
|
|
29
|
+
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
27
30
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
31
|
const MODE_ENTRY = "subsub-mode";
|
|
29
32
|
const BENCH_ENTRY = "subsub-bench";
|
|
@@ -36,12 +39,32 @@ function ownVersion() {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
export { findUv };
|
|
39
|
-
export function serverSpecs(cfg) {
|
|
42
|
+
export function serverSpecs(cfg, processEnv = process.env) {
|
|
40
43
|
const env = cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {};
|
|
41
|
-
|
|
42
|
-
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp"), env },
|
|
43
|
-
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp"), env },
|
|
44
|
+
const specs = [
|
|
45
|
+
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp", [], processEnv), env },
|
|
46
|
+
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp", [], processEnv), env },
|
|
44
47
|
];
|
|
48
|
+
if (starbuckEnabled(cfg))
|
|
49
|
+
specs.push({ name: "verify", ...starbuckCommand(cfg, processEnv), env: starbuckEnv(cfg, processEnv) });
|
|
50
|
+
return specs;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Starbuck reads its contact address and API keys from the environment. Sub-Sub keeps
|
|
54
|
+
* them in the zotero-local-mcp env file, so they are passed on (the environment wins).
|
|
55
|
+
*/
|
|
56
|
+
export function starbuckEnv(cfg, processEnv = process.env) {
|
|
57
|
+
const file = cfg.envFile ? parseEnvFile(cfg.envFile) : {};
|
|
58
|
+
const out = {};
|
|
59
|
+
const email = processEnv.STARBUCK_EMAIL || file.STARBUCK_EMAIL || processEnv.ZOTERO_CONTACT_EMAIL || file.ZOTERO_CONTACT_EMAIL;
|
|
60
|
+
if (email)
|
|
61
|
+
out.STARBUCK_EMAIL = email;
|
|
62
|
+
for (const key of ["NCBI_API_KEY", "OPENALEX_API_KEY"]) {
|
|
63
|
+
const v = processEnv[key] || file[key];
|
|
64
|
+
if (v)
|
|
65
|
+
out[key] = v;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
45
68
|
}
|
|
46
69
|
export async function createSubsub(pi, deps = {}) {
|
|
47
70
|
const cfg = deps.config ?? loadConfig();
|
|
@@ -111,6 +134,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
111
134
|
language: cfg.language,
|
|
112
135
|
userName: cfg.userName ?? null,
|
|
113
136
|
vault: cfg.vault ?? null,
|
|
137
|
+
starbuck: starbuckEnabled(cfg),
|
|
114
138
|
};
|
|
115
139
|
try {
|
|
116
140
|
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
@@ -245,6 +269,15 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
245
269
|
await b?.close();
|
|
246
270
|
});
|
|
247
271
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
272
|
+
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
273
|
+
// The input event comes before pi expands a prompt template.
|
|
274
|
+
pi.on("input", async (event, ctx) => {
|
|
275
|
+
const why = promptBlocked(event.text, profile);
|
|
276
|
+
if (!why)
|
|
277
|
+
return { action: "continue" };
|
|
278
|
+
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
279
|
+
return { action: "handled" };
|
|
280
|
+
});
|
|
248
281
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
249
282
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
250
283
|
}));
|
|
@@ -258,7 +291,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
258
291
|
const schema = schemas.get(name);
|
|
259
292
|
if (schema)
|
|
260
293
|
coerceArgs(schema, input);
|
|
261
|
-
if (
|
|
294
|
+
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
262
295
|
const why = unavailableReason(name, mode, registered(), profile);
|
|
263
296
|
if (why) {
|
|
264
297
|
if (benchOut)
|
|
@@ -266,6 +299,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
266
299
|
return { block: true, reason: why };
|
|
267
300
|
}
|
|
268
301
|
}
|
|
302
|
+
if (name.startsWith("verify_"))
|
|
303
|
+
return verifyGate(name, input, ctx);
|
|
269
304
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
270
305
|
if (!kind)
|
|
271
306
|
return undefined;
|
|
@@ -273,6 +308,9 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
273
308
|
return benchGate(name, input, kind, ctx.cwd, ctx.signal);
|
|
274
309
|
if (kind === "path") {
|
|
275
310
|
const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
|
|
311
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
312
|
+
if (noteProblem)
|
|
313
|
+
return { block: true, reason: noteProblem };
|
|
276
314
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
277
315
|
if (verdict.ok)
|
|
278
316
|
return undefined;
|
|
@@ -303,9 +341,56 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
303
341
|
const ok = await ctx.ui.confirm(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)));
|
|
304
342
|
return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
305
343
|
});
|
|
344
|
+
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
345
|
+
function literatureNoteProblem(name, target, input) {
|
|
346
|
+
if (!target.toLowerCase().endsWith(".md"))
|
|
347
|
+
return undefined;
|
|
348
|
+
if (name === "write")
|
|
349
|
+
return checkLiteratureNote(String(input.content ?? ""));
|
|
350
|
+
if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
|
|
351
|
+
const current = readFileSync(target, "utf8");
|
|
352
|
+
return checkLiteratureNote(applyEdits(current, input.edits));
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Starbuck writes its reports into a folder: report_dir, or _starbuck next to the
|
|
358
|
+
* manuscript. Inside the vault or the working folder that needs no approval; elsewhere
|
|
359
|
+
* Sub-Sub asks, as for any file write. record_claims gets the current model's name.
|
|
360
|
+
*/
|
|
361
|
+
async function verifyGate(name, input, ctx) {
|
|
362
|
+
if (name === "verify_record_claims") {
|
|
363
|
+
input.judged_by = ctx.model ? [ctx.model.provider, ctx.model.id].filter(Boolean).join("/") : "the agent's model";
|
|
364
|
+
}
|
|
365
|
+
if (name === "verify_check_references")
|
|
366
|
+
return undefined;
|
|
367
|
+
const dir = input.report_dir
|
|
368
|
+
? normalizeToolPath(String(input.report_dir), ctx.cwd)
|
|
369
|
+
: input.path
|
|
370
|
+
? join(dirname(normalizeToolPath(String(input.path), ctx.cwd)), "_starbuck")
|
|
371
|
+
: undefined;
|
|
372
|
+
if (!dir)
|
|
373
|
+
return undefined;
|
|
374
|
+
const verdict = judgePath(dir, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
375
|
+
if (verdict.ok)
|
|
376
|
+
return undefined;
|
|
377
|
+
if (benchOut) {
|
|
378
|
+
record({ kind: "path_blocked", tool: name, path: dir });
|
|
379
|
+
return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
|
|
380
|
+
}
|
|
381
|
+
if (!ctx.hasUI)
|
|
382
|
+
return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
383
|
+
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
384
|
+
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
385
|
+
}
|
|
306
386
|
async function benchGate(name, input, kind, cwd, signal) {
|
|
307
387
|
if (kind === "path") {
|
|
308
388
|
const target = normalizeToolPath(String(input.path ?? ""), cwd);
|
|
389
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
390
|
+
if (noteProblem) {
|
|
391
|
+
record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
|
|
392
|
+
return { block: true, reason: noteProblem };
|
|
393
|
+
}
|
|
309
394
|
if (within(target, realish(benchOut)))
|
|
310
395
|
return undefined;
|
|
311
396
|
record({ kind: "path_blocked", tool: name, path: target });
|
package/dist/web.js
CHANGED
|
@@ -21,9 +21,9 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { expand, loadConfig, saveConfig } from "./config.js";
|
|
24
|
+
import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
|
-
import { within } from "./paths.js";
|
|
26
|
+
import { realish, within } from "./paths.js";
|
|
27
27
|
export function parseWebArgs(args) {
|
|
28
28
|
const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
|
|
29
29
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -559,6 +559,17 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
559
559
|
await restartAgent();
|
|
560
560
|
return send(res, 200, { ok: true });
|
|
561
561
|
}
|
|
562
|
+
case "POST /api/addons": {
|
|
563
|
+
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
564
|
+
if (busy)
|
|
565
|
+
return send(res, 409, { error: "busy" });
|
|
566
|
+
const body = await readJson(req);
|
|
567
|
+
if (typeof body.starbuck !== "boolean")
|
|
568
|
+
return send(res, 400, { error: "starbuck must be true or false" });
|
|
569
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
570
|
+
await restartAgent();
|
|
571
|
+
return send(res, 200, { ok: true });
|
|
572
|
+
}
|
|
562
573
|
case "POST /api/restart":
|
|
563
574
|
if (agent.proc)
|
|
564
575
|
return send(res, 200, { ok: true });
|
|
@@ -580,7 +591,10 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
580
591
|
const body = await readJson(req);
|
|
581
592
|
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
582
593
|
const vault = cfg.vault;
|
|
583
|
-
|
|
594
|
+
// Judge the real file, so a link inside the notes folder cannot open a document outside it.
|
|
595
|
+
const real = realish(target);
|
|
596
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
597
|
+
if (!inside || !existsSync(target))
|
|
584
598
|
return send(res, 404, { error: "not found in the notes folder" });
|
|
585
599
|
// Documents only: a file that the model wrote must never be run.
|
|
586
600
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
]
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
30
29
|
"@modelcontextprotocol/sdk": "^1.30.1"
|
|
31
30
|
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^1.0.2"
|
|
33
|
+
},
|
|
32
34
|
"engines": {
|
|
33
35
|
"node": ">=22.19.0"
|
|
34
36
|
},
|
|
@@ -55,9 +57,11 @@
|
|
|
55
57
|
"src",
|
|
56
58
|
"templates",
|
|
57
59
|
"themes",
|
|
58
|
-
"web"
|
|
60
|
+
"web",
|
|
61
|
+
"THIRD_PARTY_NOTICES"
|
|
59
62
|
],
|
|
60
63
|
"devDependencies": {
|
|
64
|
+
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
61
65
|
"@types/node": "^24.19.0",
|
|
62
66
|
"typescript": "^5.9.3"
|
|
63
67
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Researcher: compare sources on a question (a source matrix: claim, evidence type, caveats, confidence)"
|
|
3
|
+
argument-hint: "<question, or citekeys, DOIs or PMIDs>"
|
|
4
|
+
---
|
|
5
|
+
Compare the sources on: $ARGUMENTS
|
|
6
|
+
|
|
7
|
+
Make a short slug: lower case, hyphens, at most five words. Use today's date (YYYY-MM-DD).
|
|
8
|
+
|
|
9
|
+
1. Find the sources.
|
|
10
|
+
- If the user gave citekeys, DOIs or PMIDs, use exactly those.
|
|
11
|
+
- Otherwise: library first (zotero_find_items), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
|
|
12
|
+
2. Read each source before you describe it: zotero_get_fulltext for library items, scholar_read_oa_fulltext (methods and results) for open-access works, scholar_get_work for the abstract of the others. Note whether you read the full text or only the abstract.
|
|
13
|
+
3. Build the matrix, one row per source:
|
|
14
|
+
| # | Source | Claim on the question | Evidence type | Population and setting | Caveats | Confidence |
|
|
15
|
+
- Evidence type: for example randomised trial, cohort, case-control, cross-sectional, diagnostic accuracy, systematic review, guideline, expert opinion, modelling.
|
|
16
|
+
- Caveats: risk of bias, small sample, surrogate outcome, short follow-up, industry funding, abstract only.
|
|
17
|
+
- Confidence: high, moderate or low, with one reason.
|
|
18
|
+
4. Below the matrix, write: where the sources agree; where they disagree and the most likely reason (population, design, outcome definition, analysis); what no source answers. Cite source numbers in every sentence. Write "inference:" before a statement that no single source makes.
|
|
19
|
+
5. Check the sources: verify_check_references when the verify_ tools are available, otherwise scholar_get_work for each identifier. Never write "verified" for a check you did not run.
|
|
20
|
+
6. Write one note: `Research/<date> <slug> comparison.md` in the notes folder (do not overwrite; add -2). Front matter: title, date, type: comparison, sources (the identifiers), verification (PASS, PASS WITH NOTES or BLOCKED), reference_check (path of the Starbuck report, or none). End with a Sources section: [@citekey] for library items, full reference with DOI and "(not in library)" for the others.
|
|
21
|
+
7. If a step fails, finish the note anyway and mark the step "BLOCKED: <what failed>".
|
|
22
|
+
|
|
23
|
+
Give the user the path and three lines on what the comparison shows.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Researcher: digest of the last days: new works from the alerts and what the recent notes changed"
|
|
3
|
+
argument-hint: "[days, default 7] [topic]"
|
|
4
|
+
---
|
|
5
|
+
Write a digest of the last ${1:-7} days. Topic, if given: ${@:2}
|
|
6
|
+
|
|
7
|
+
1. Collect, in the notes folder:
|
|
8
|
+
- the alert notes `Inbox/Literature alerts YYYY-MM-DD.md` dated in the period (the date is in the file name);
|
|
9
|
+
- the notes in `Research/` whose front matter `date:` is in the period;
|
|
10
|
+
- the literature notes whose front matter `created:` is in the period.
|
|
11
|
+
Use ls and find to list them, and read their front matter for the dates. If a topic is given, keep only what is about it.
|
|
12
|
+
2. Read each note you use. Do not describe a work that you did not read in a note, an abstract (scholar_get_work) or a full text.
|
|
13
|
+
3. Write `Research/Digest <today, YYYY-MM-DD>.md` (do not overwrite; add -2) with these sections, in this order:
|
|
14
|
+
- What changed in our understanding: what the period's notes and works add to or change in what the user knew, each point with its sources.
|
|
15
|
+
- New works worth reading: at most ten from the alerts, one line each on why, with DOI or PMID and whether it is in the library. Mark the ones the alert pre-screen excluded only if you keep them, and say why.
|
|
16
|
+
- Disagreements and open questions: between the new works, or between them and the earlier notes.
|
|
17
|
+
- Questions for the user: decisions that need the user (what to read, what to import, what to drop).
|
|
18
|
+
- Sources: [@citekey] for library items; full reference with DOI and "(not in library)" for the others.
|
|
19
|
+
Front matter: title, date, type: digest, period (from and to), sources (the notes and alert files used).
|
|
20
|
+
4. If there is nothing in the period, say so in one line and do not write a note.
|
|
21
|
+
5. Offer scholar_queue_imports for the works the user wants to add.
|
|
22
|
+
|
|
23
|
+
Give the user the path and the three most important points.
|
package/prompts/lit.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Researcher: literature review on a health research question (library first, then PubMed, Europe PMC, OpenAlex)"
|
|
3
|
+
argument-hint: "<question or topic>"
|
|
4
|
+
---
|
|
5
|
+
Do a literature review on: $ARGUMENTS
|
|
6
|
+
|
|
7
|
+
Make a short slug from the topic: lower case, hyphens, at most five words. Use today's date (YYYY-MM-DD).
|
|
8
|
+
|
|
9
|
+
## 1. Plan
|
|
10
|
+
|
|
11
|
+
1. Write the question in one sentence. If it is a clinical question, state population, exposure or intervention, comparison and outcome when they apply.
|
|
12
|
+
2. Decide the scale:
|
|
13
|
+
- Narrow question ("what is X", one fact, one test): at most 10 tool calls, a short answer, no plan file.
|
|
14
|
+
- Review question: write the plan to `Research/.plans/<slug>.md` in the notes folder: the question, the search phrasings, the study designs that answer it (for example randomised trials, cohorts, diagnostic accuracy studies, systematic reviews), the years, and a task list.
|
|
15
|
+
3. Tell the user the plan in three lines. Then continue at once. Do not wait for an answer unless the user asked to see the plan first.
|
|
16
|
+
|
|
17
|
+
## 2. Gather
|
|
18
|
+
|
|
19
|
+
1. Library first: zotero_find_items for the topic (and the topic tags). Note what the library already has.
|
|
20
|
+
2. Outside: scholar_search_multi with three or four phrasings of the question (synonyms, the test or drug name, the condition name, MeSH terms). Give include_terms (the key concepts) and exclude_terms (populations or designs outside the question) so that the server pre-screens the results. Use year limits when the question needs recent work.
|
|
21
|
+
3. Screen. For every work in the merged list, decide: read, maybe, or exclude, with a reason of a few words. Prefer systematic reviews, guidelines and primary studies with the right design. Write the screening log to `Research/.plans/<slug>-screening.md`: a table (#, work, identifier, pre-screen, decision, reason) and the counts for a flow diagram: found by the searches, unique works, pre-screen excluded, screened, read, used.
|
|
22
|
+
4. Read before you summarise:
|
|
23
|
+
- Library items: zotero_get_fulltext, or the abstract when there is no full text.
|
|
24
|
+
- Open-access works: scholar_read_oa_fulltext, first the section list, then only the sections you need (usually methods and results).
|
|
25
|
+
- Other works: scholar_get_work for the abstract.
|
|
26
|
+
5. Keep an evidence table as you read. Give each source a number that does not change: [1], [2], ... At most two rows from one paper, so that no single paper dominates. In the Read column write exactly one of: full text, abstract, metadata.
|
|
27
|
+
|
|
28
|
+
## 3. Synthesise
|
|
29
|
+
|
|
30
|
+
- Separate what the sources agree on, where they disagree (and why: population, design, outcome definition, follow-up), and what is still open.
|
|
31
|
+
- Every factual sentence cites at least one source number. Write "inference:" before a statement that combines sources and is not stated in any of them.
|
|
32
|
+
- Say for each source whether you read the full text or only the abstract.
|
|
33
|
+
- Give numbers (effect sizes, accuracy, sample sizes) only from what you read, with the source.
|
|
34
|
+
- Do not draft text for the user's own manuscript.
|
|
35
|
+
|
|
36
|
+
## 4. Check the draft for gaps
|
|
37
|
+
|
|
38
|
+
1. Read your draft as a critical reviewer. Name at most three gaps that matter for the question (a population, outcome, design or recent period that the draft does not cover), each as one self-contained search question.
|
|
39
|
+
2. Search only for those questions (scholar_search_multi), read what you add, and give the new sources the next numbers.
|
|
40
|
+
3. Edit only the parts of the draft that a gap names. Do not shorten the draft by more than a tenth. If a search finds nothing new, say so in Methods.
|
|
41
|
+
|
|
42
|
+
## 5. Verify
|
|
43
|
+
|
|
44
|
+
- If the verify_ tools are available: verify_check_references with the identifiers of every source in the evidence table. Fix the sources that fail. Report the ones that need checking.
|
|
45
|
+
- If not: check each DOI or PMID with scholar_get_work.
|
|
46
|
+
- Never write "verified" for a check you did not run.
|
|
47
|
+
|
|
48
|
+
## 6. Deliver
|
|
49
|
+
|
|
50
|
+
Write one note: `Research/<date> <slug>.md` in the notes folder. Do not overwrite an existing note; add -2 to the name. Use this structure:
|
|
51
|
+
|
|
52
|
+
```markdown
|
|
53
|
+
---
|
|
54
|
+
title: "<question>"
|
|
55
|
+
date: <YYYY-MM-DD>
|
|
56
|
+
type: literature-review
|
|
57
|
+
question: "<question in one sentence>"
|
|
58
|
+
searches: ["<phrasing 1>", "<phrasing 2>", "<phrasing 3>"]
|
|
59
|
+
sources_found: <number of unique works>
|
|
60
|
+
sources_screened: <number>
|
|
61
|
+
sources_read: <number>
|
|
62
|
+
sources_used: <number>
|
|
63
|
+
screening_log: "Research/.plans/<slug>-screening.md"
|
|
64
|
+
verification: PASS | PASS WITH NOTES | BLOCKED
|
|
65
|
+
reference_check: "<path of the Starbuck report, or none>"
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Answer
|
|
69
|
+
(three to six sentences, with source numbers)
|
|
70
|
+
|
|
71
|
+
## Evidence table
|
|
72
|
+
| # | Source | Design | Population | Key finding | Read | In library |
|
|
73
|
+
|---|---|---|---|---|---|---|
|
|
74
|
+
|
|
75
|
+
## Agreement
|
|
76
|
+
## Disagreement
|
|
77
|
+
## Open questions
|
|
78
|
+
## Methods
|
|
79
|
+
(where you searched, the phrasings, the pre-screen terms, the screening counts, the gap searches, what you included and left out, and why)
|
|
80
|
+
|
|
81
|
+
## Sources
|
|
82
|
+
1. [@citekey] for library items; for other works: Authors, year, title, journal. doi:... (not in library)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If a step fails (a service does not answer, a full text cannot be read), do not stop. Finish the note, mark the step "BLOCKED: <what failed>", and set verification: BLOCKED.
|
|
86
|
+
|
|
87
|
+
Before you answer, read the note back to confirm that it exists. Then give the user the path, the answer in three lines, and the works worth adding to the library (offer scholar_queue_imports).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Researcher: draft an email asking the corresponding author for a copy of a paper (you send it)"
|
|
3
|
+
argument-hint: "<citekey, DOI or PMID>"
|
|
4
|
+
---
|
|
5
|
+
Draft an email to ask the corresponding author of $1 for a copy of the paper.
|
|
6
|
+
|
|
7
|
+
1. Find the work: zotero_get_item for a citekey or Zotero key, otherwise scholar_get_work. If it is open access (open_access, or scholar_read_oa_fulltext gives the full text), tell the user where to read it and stop: no email is needed.
|
|
8
|
+
2. Find the address with scholar_find_contact. Use only an address it returns. Never guess or build an address from a name. If it returns none, give the user the article link and say: look for "Correspondence" on the article page; then stop.
|
|
9
|
+
3. Write the draft to `Inbox/Copy request <citekey or DOI>.md` in the notes folder (do not overwrite; add -2):
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
---
|
|
13
|
+
title: "Copy request: <short title>"
|
|
14
|
+
date: <YYYY-MM-DD>
|
|
15
|
+
type: copy-request
|
|
16
|
+
work: "<DOI or PMID>"
|
|
17
|
+
to: "<address>"
|
|
18
|
+
status: draft
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
[Open in the mail program](mailto:<address>?subject=<subject, URL-encoded>&body=<body, URL-encoded>)
|
|
22
|
+
|
|
23
|
+
To: <address> (<author name>, from the PubMed record)
|
|
24
|
+
Subject: Request for a copy of "<short title>"
|
|
25
|
+
|
|
26
|
+
Dear Dr <family name>,
|
|
27
|
+
|
|
28
|
+
<two or three sentences: who the user is (name, and the line about the user if set), why the paper matters for their work, and the request for a copy for personal research use.>
|
|
29
|
+
|
|
30
|
+
Best regards,
|
|
31
|
+
<the user's name>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
4. Write the email in English unless the user asks for another language, or the author's affiliation and the user's language are both Portuguese. Keep it short and polite. Do not claim things about the user that you do not know.
|
|
35
|
+
5. Do not send anything. Tell the user the path, and that the link opens the draft in their mail program.
|