@tiagojct/subsub 0.6.0 → 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 -61
- package/THIRD_PARTY_NOTICES +55 -0
- package/dist/bench.js +60 -5
- package/dist/config.js +50 -18
- package/dist/doctor.js +23 -2
- package/dist/init.js +26 -11
- 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/docs/Subsub.md +4 -2
- 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 +61 -18
- package/src/doctor.ts +32 -2
- package/src/init.ts +42 -11
- 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 +821 -131
- package/web/app.js +270 -26
- package/web/glauca.css +54 -49
- package/web/i18n.js +60 -0
- package/web/icon.svg +12 -1
- package/web/index.html +21 -4
package/src/doctor.ts
CHANGED
|
@@ -3,14 +3,24 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Checks Node, the settings file, uv, the Zotero server (it runs
|
|
5
5
|
* `zotero-local-mcp --check`), Zotero itself, the tag list, the notes folder,
|
|
6
|
-
* the contact email and the model login. Exit
|
|
6
|
+
* the contact email, Starbuck (when the add-on is on) and the model login. Exit
|
|
7
|
+
* code 1 when something needs a fix.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
12
13
|
import { join } from "node:path";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
findUv,
|
|
16
|
+
loadConfig,
|
|
17
|
+
SERVER_VERSION,
|
|
18
|
+
serverCommand,
|
|
19
|
+
STARBUCK_SOURCE,
|
|
20
|
+
starbuckCommand,
|
|
21
|
+
starbuckEnabled,
|
|
22
|
+
type SubsubConfig,
|
|
23
|
+
} from "./config.ts";
|
|
14
24
|
import { profileSpec } from "./profiles.ts";
|
|
15
25
|
|
|
16
26
|
export interface Check {
|
|
@@ -137,6 +147,7 @@ export function runChecks(
|
|
|
137
147
|
? { name: "Contact email", ok: true, detail: "set" }
|
|
138
148
|
: { name: "Contact email", ok: true, warn: true, detail: "not set: open-access PDFs cannot be found", fix: "Type subsub init and give an email address." },
|
|
139
149
|
);
|
|
150
|
+
if (starbuckEnabled(cfg)) out.push(starbuckCheck(cfg, env, run));
|
|
140
151
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
141
152
|
out.push(
|
|
142
153
|
hasLogin(agentDir)
|
|
@@ -146,6 +157,25 @@ export function runChecks(
|
|
|
146
157
|
return out;
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
/** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
|
|
161
|
+
export function starbuckCheck(cfg: SubsubConfig, env: NodeJS.ProcessEnv, run: Runner): Check {
|
|
162
|
+
const cmd = starbuckCommand(cfg, env);
|
|
163
|
+
const local = cmd.args.includes("--directory");
|
|
164
|
+
const args = cmd.args.slice(0, -1).concat(["starbuck", "--version"]);
|
|
165
|
+
const res = run(cmd.command, args, env);
|
|
166
|
+
if (res.status === 0) {
|
|
167
|
+
return { name: "Starbuck", ok: true, detail: `${res.stdout.trim()} ${local ? `from ${cfg.starbuckDir}` : `from ${STARBUCK_SOURCE}`}` };
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
name: "Starbuck",
|
|
171
|
+
ok: false,
|
|
172
|
+
detail: local ? `cannot start from ${cfg.starbuckDir}` : `cannot install ${STARBUCK_SOURCE} with uv`,
|
|
173
|
+
fix: local
|
|
174
|
+
? `Type: cd "${cfg.starbuckDir}" && uv sync`
|
|
175
|
+
: "Check the internet connection and run subsub doctor again. To turn reference checks off, type subsub init.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
export function formatChecks(checks: Check[]): string {
|
|
150
180
|
return checks
|
|
151
181
|
.map((c) => {
|
package/src/init.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
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
|
|
|
18
19
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -20,7 +21,20 @@ import { homedir } from "node:os";
|
|
|
20
21
|
import { dirname, join, resolve } from "node:path";
|
|
21
22
|
import { createInterface } from "node:readline/promises";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
configPath,
|
|
26
|
+
expand,
|
|
27
|
+
isProfile,
|
|
28
|
+
loadConfig,
|
|
29
|
+
parseEnvFile,
|
|
30
|
+
PROFILES,
|
|
31
|
+
readConfigFile,
|
|
32
|
+
saveConfig,
|
|
33
|
+
starbuckEnabled,
|
|
34
|
+
type SubsubConfig,
|
|
35
|
+
withStarbuck,
|
|
36
|
+
} from "./config.ts";
|
|
37
|
+
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
24
38
|
import { PROFILE_SPECS } from "./profiles.ts";
|
|
25
39
|
|
|
26
40
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
@@ -83,13 +97,14 @@ export async function runInit(
|
|
|
83
97
|
io: InitIO,
|
|
84
98
|
flags: Record<string, string>,
|
|
85
99
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
-
deps: { fetch?: typeof fetch; home?: string } = {},
|
|
100
|
+
deps: { fetch?: typeof fetch; home?: string; prepareStarbuck?: (cfg: SubsubConfig) => string } = {},
|
|
87
101
|
): Promise<InitResult> {
|
|
88
|
-
const home = deps.home ?? homedir();
|
|
89
|
-
const
|
|
102
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
103
|
+
const initEnv: NodeJS.ProcessEnv = { ...env, HOME: home };
|
|
104
|
+
const configFile = configPath(initEnv);
|
|
90
105
|
const existing = readConfigFile(configFile) as Record<string, any>;
|
|
91
|
-
const current = loadConfig(
|
|
92
|
-
const envFile = current.envFile ?? expand(
|
|
106
|
+
const current = loadConfig(initEnv);
|
|
107
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
93
108
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
94
109
|
const created: string[] = [];
|
|
95
110
|
|
|
@@ -109,6 +124,7 @@ export async function runInit(
|
|
|
109
124
|
const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
|
|
110
125
|
const notes = expand(
|
|
111
126
|
(await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(),
|
|
127
|
+
home,
|
|
112
128
|
);
|
|
113
129
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
114
130
|
const profile = await io.choose(
|
|
@@ -117,7 +133,7 @@ export async function runInit(
|
|
|
117
133
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
118
134
|
);
|
|
119
135
|
|
|
120
|
-
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
136
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
121
137
|
let tags = "keep";
|
|
122
138
|
if (existsSync(vocab)) {
|
|
123
139
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -131,12 +147,20 @@ export async function runInit(
|
|
|
131
147
|
const email = (
|
|
132
148
|
await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")
|
|
133
149
|
).trim();
|
|
150
|
+
const starbuck = await io.choose(
|
|
151
|
+
"Reference checks (Starbuck): check that cited works exist, match their citation and were not retracted",
|
|
152
|
+
[
|
|
153
|
+
{ value: "off", label: "Off" },
|
|
154
|
+
{ value: "on", label: "On (the researcher gets /verify; uses the email above)" },
|
|
155
|
+
],
|
|
156
|
+
flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
|
|
157
|
+
);
|
|
134
158
|
const modelOptions = [
|
|
135
159
|
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
136
160
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
137
161
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
138
162
|
];
|
|
139
|
-
const agentDir =
|
|
163
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
140
164
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
141
165
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
142
166
|
|
|
@@ -169,12 +193,14 @@ export async function runInit(
|
|
|
169
193
|
setup: fmup ? "fmup" : undefined,
|
|
170
194
|
vault: notes,
|
|
171
195
|
envFile,
|
|
196
|
+
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
172
197
|
};
|
|
173
198
|
if (models === "later") patch.models = {};
|
|
174
199
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
175
|
-
saveConfig(patch,
|
|
200
|
+
saveConfig(patch, initEnv);
|
|
176
201
|
|
|
177
202
|
const zotero = await zoteroReachable(deps.fetch);
|
|
203
|
+
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
178
204
|
io.say("");
|
|
179
205
|
io.say(`Saved: ${configFile}`);
|
|
180
206
|
io.say(`Saved: ${envFile}`);
|
|
@@ -184,6 +210,7 @@ export async function runInit(
|
|
|
184
210
|
? "Zotero: reachable."
|
|
185
211
|
: "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\".",
|
|
186
212
|
);
|
|
213
|
+
if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
187
214
|
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
188
215
|
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.");
|
|
189
216
|
return { configFile, envFile, notes, created, zotero };
|
|
@@ -241,7 +268,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
|
|
|
241
268
|
const flags = parseFlags(args);
|
|
242
269
|
const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
243
270
|
try {
|
|
244
|
-
await runInit(io, flags, env)
|
|
271
|
+
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
272
|
+
// Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
|
|
273
|
+
const c = starbuckCheck(cfg, env, defaultRunner);
|
|
274
|
+
return c.ok ? `Reference checks: on (${c.detail}).` : `Reference checks: on, but ${c.detail}. ${c.fix ?? ""}`.trim();
|
|
275
|
+
} });
|
|
245
276
|
return 0;
|
|
246
277
|
} catch (err) {
|
|
247
278
|
console.error(`Sub-Sub init: ${(err as Error).message}`);
|
package/src/notes.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
|
|
9
|
+
export const EVIDENCE = ["full text", "abstract", "metadata"] as const;
|
|
10
|
+
|
|
11
|
+
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
12
|
+
const PAGES = /\b(?:pp?\.|pages?)\s?\d+/i;
|
|
13
|
+
|
|
14
|
+
export function frontMatter(text: string): string | undefined {
|
|
15
|
+
return FRONT.exec(text)?.[1];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function field(front: string, name: string): string | undefined {
|
|
19
|
+
const m = new RegExp(`^${name}:[ \\t]*(.*)$`, "m").exec(front);
|
|
20
|
+
return m ? m[1].trim().replace(/^["']|["']$/g, "").trim() : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Why a new literature note cannot be written as it is, or undefined when it can. */
|
|
24
|
+
export function checkLiteratureNote(text: string): string | undefined {
|
|
25
|
+
const m = FRONT.exec(text);
|
|
26
|
+
if (!m || !field(m[1], "citekey")) return undefined;
|
|
27
|
+
const evidence = field(m[1], "evidence")?.toLowerCase();
|
|
28
|
+
if (!evidence || !(EVIDENCE as readonly string[]).includes(evidence)) {
|
|
29
|
+
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.';
|
|
30
|
+
}
|
|
31
|
+
const body = text.slice(m[0].length);
|
|
32
|
+
if (evidence !== "full text" && PAGES.test(body)) {
|
|
33
|
+
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.`;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The text of a file after pi's edit tool applies edits[] (each oldText matched against the original). */
|
|
39
|
+
export function applyEdits(current: string, edits: Array<{ oldText?: unknown; newText?: unknown }>): string {
|
|
40
|
+
let out = current;
|
|
41
|
+
for (const e of edits) {
|
|
42
|
+
const oldText = String(e.oldText ?? "");
|
|
43
|
+
if (oldText && out.includes(oldText)) out = out.replace(oldText, () => String(e.newText ?? ""));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
package/src/profiles.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface ProfileSpec {
|
|
|
19
19
|
summary: string;
|
|
20
20
|
/** Tools this profile does not offer (full names). */
|
|
21
21
|
without: string[];
|
|
22
|
+
/** Prompt commands this profile does not run (names without the slash). */
|
|
23
|
+
withoutPrompts: string[];
|
|
22
24
|
/** Items per review note and per batch. */
|
|
23
25
|
batch: number;
|
|
24
26
|
/** Added to the system prompt. {{user}} is the user's name or "the user". */
|
|
@@ -36,6 +38,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
36
38
|
label: "Reader",
|
|
37
39
|
summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
|
|
38
40
|
without: [...BULK_LIBRARY, ...MANUSCRIPT],
|
|
41
|
+
withoutPrompts: ["lit", "compare", "review", "digest"],
|
|
39
42
|
batch: 10,
|
|
40
43
|
rules: [
|
|
41
44
|
"Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
|
|
@@ -51,6 +54,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
51
54
|
label: "Scholar",
|
|
52
55
|
summary: "literature notes, syntheses, searches and alerts; the full librarian",
|
|
53
56
|
without: MANUSCRIPT,
|
|
57
|
+
withoutPrompts: ["review"],
|
|
54
58
|
batch: 25,
|
|
55
59
|
rules: [
|
|
56
60
|
"Profile: Scholar.",
|
|
@@ -64,6 +68,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
64
68
|
label: "Author",
|
|
65
69
|
summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
|
|
66
70
|
without: [],
|
|
71
|
+
withoutPrompts: [],
|
|
67
72
|
batch: 25,
|
|
68
73
|
rules: [
|
|
69
74
|
"Profile: Author.",
|
|
@@ -78,6 +83,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
78
83
|
label: "Editor",
|
|
79
84
|
summary: "everything, including drafting manuscript text on request",
|
|
80
85
|
without: [],
|
|
86
|
+
withoutPrompts: [],
|
|
81
87
|
batch: 25,
|
|
82
88
|
rules: [
|
|
83
89
|
"Profile: Editor. All tools.",
|
|
@@ -92,6 +98,17 @@ export function profileSpec(p: Profile): ProfileSpec {
|
|
|
92
98
|
return PROFILE_SPECS[p];
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
/** Why a prompt command does not run in this profile, or undefined when it does. */
|
|
102
|
+
export function promptBlocked(text: string, profile: Profile): string | undefined {
|
|
103
|
+
const m = /^\/([\w-]+)(?:\s|$)/.exec(text.trim());
|
|
104
|
+
if (!m || !PROFILE_SPECS[profile].withoutPrompts.includes(m[1])) return undefined;
|
|
105
|
+
const allowed = Object.values(PROFILE_SPECS)
|
|
106
|
+
.filter((s) => !s.withoutPrompts.includes(m[1]))
|
|
107
|
+
.map((s) => s.label)
|
|
108
|
+
.join(", ");
|
|
109
|
+
return `/${m[1]} is not part of the ${PROFILE_SPECS[profile].label} profile (it is in: ${allowed}). Change the profile with /profile.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
export function profileList(current?: Profile): string {
|
|
96
113
|
return Object.values(PROFILE_SPECS)
|
|
97
114
|
.map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
|
package/src/prompt.ts
CHANGED
|
@@ -74,6 +74,12 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
|
|
|
74
74
|
if (t) parts.push(`# Vault conventions (${f})`, t);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
if (cfg.setup === "fmup") {
|
|
78
|
+
parts.push(
|
|
79
|
+
"# U.Porto",
|
|
80
|
+
`${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.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
77
83
|
// The profile and the language rule come last, so they win over the shared rules and the vault conventions.
|
|
78
84
|
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
79
85
|
parts.push("# Language", languageRule(cfg.language, user));
|
package/src/roles.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { profileSpec } from "./profiles.ts";
|
|
|
7
7
|
|
|
8
8
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
9
9
|
|
|
10
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
|
|
11
|
+
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
12
|
+
|
|
10
13
|
export const ZOTERO_READ = [
|
|
11
14
|
"status",
|
|
12
15
|
"library_overview",
|
|
@@ -51,7 +54,11 @@ export function toolsFor(mode: Mode, registered: string[], profile: Profile = "e
|
|
|
51
54
|
const wanted =
|
|
52
55
|
mode === "librarian"
|
|
53
56
|
? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
|
|
54
|
-
: [
|
|
57
|
+
: [
|
|
58
|
+
...ZOTERO_READ,
|
|
59
|
+
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
60
|
+
...FILE_TOOLS,
|
|
61
|
+
];
|
|
55
62
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
56
63
|
}
|
|
57
64
|
|
package/src/subsub.ts
CHANGED
|
@@ -8,24 +8,41 @@
|
|
|
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
17
|
|
|
16
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
20
|
import { dirname, join } from "node:path";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
+
import {
|
|
24
|
+
expand,
|
|
25
|
+
findUv,
|
|
26
|
+
isProfile,
|
|
27
|
+
loadConfig,
|
|
28
|
+
type Mode,
|
|
29
|
+
parseEnvFile,
|
|
30
|
+
type Profile,
|
|
31
|
+
saveConfig,
|
|
32
|
+
serverCommand,
|
|
33
|
+
starbuckCommand,
|
|
34
|
+
starbuckEnabled,
|
|
35
|
+
type SubsubConfig,
|
|
36
|
+
who,
|
|
37
|
+
} from "./config.ts";
|
|
38
|
+
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
23
39
|
import { coerceArgs, loosenArrays } from "./args.ts";
|
|
40
|
+
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
24
41
|
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
25
42
|
import { describeArgs, formatPreview } from "./preview.ts";
|
|
26
43
|
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
27
44
|
import { systemAddition } from "./prompt.ts";
|
|
28
|
-
import { gateKind, toolsFor, unavailableReason } from "./roles.ts";
|
|
45
|
+
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
|
|
29
46
|
|
|
30
47
|
export interface SubsubDeps {
|
|
31
48
|
config?: SubsubConfig;
|
|
@@ -48,12 +65,30 @@ function ownVersion(): string {
|
|
|
48
65
|
|
|
49
66
|
export { findUv };
|
|
50
67
|
|
|
51
|
-
export function serverSpecs(cfg: SubsubConfig): ServerSpec[] {
|
|
68
|
+
export function serverSpecs(cfg: SubsubConfig, processEnv: NodeJS.ProcessEnv = process.env): ServerSpec[] {
|
|
52
69
|
const env: Record<string, string> = cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {};
|
|
53
|
-
|
|
54
|
-
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp"), env },
|
|
55
|
-
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp"), env },
|
|
70
|
+
const specs: ServerSpec[] = [
|
|
71
|
+
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp", [], processEnv), env },
|
|
72
|
+
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp", [], processEnv), env },
|
|
56
73
|
];
|
|
74
|
+
if (starbuckEnabled(cfg)) specs.push({ name: "verify", ...starbuckCommand(cfg, processEnv), env: starbuckEnv(cfg, processEnv) });
|
|
75
|
+
return specs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Starbuck reads its contact address and API keys from the environment. Sub-Sub keeps
|
|
80
|
+
* them in the zotero-local-mcp env file, so they are passed on (the environment wins).
|
|
81
|
+
*/
|
|
82
|
+
export function starbuckEnv(cfg: Pick<SubsubConfig, "envFile">, processEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
83
|
+
const file = cfg.envFile ? parseEnvFile(cfg.envFile) : {};
|
|
84
|
+
const out: Record<string, string> = {};
|
|
85
|
+
const email = processEnv.STARBUCK_EMAIL || file.STARBUCK_EMAIL || processEnv.ZOTERO_CONTACT_EMAIL || file.ZOTERO_CONTACT_EMAIL;
|
|
86
|
+
if (email) out.STARBUCK_EMAIL = email;
|
|
87
|
+
for (const key of ["NCBI_API_KEY", "OPENALEX_API_KEY"]) {
|
|
88
|
+
const v = processEnv[key] || file[key];
|
|
89
|
+
if (v) out[key] = v;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
57
92
|
}
|
|
58
93
|
|
|
59
94
|
export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Promise<void> {
|
|
@@ -125,6 +160,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
125
160
|
language: cfg.language,
|
|
126
161
|
userName: cfg.userName ?? null,
|
|
127
162
|
vault: cfg.vault ?? null,
|
|
163
|
+
starbuck: starbuckEnabled(cfg),
|
|
128
164
|
};
|
|
129
165
|
try {
|
|
130
166
|
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
@@ -262,6 +298,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
262
298
|
|
|
263
299
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
264
300
|
|
|
301
|
+
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
302
|
+
// The input event comes before pi expands a prompt template.
|
|
303
|
+
pi.on("input", async (event, ctx) => {
|
|
304
|
+
const why = promptBlocked(event.text, profile);
|
|
305
|
+
if (!why) return { action: "continue" };
|
|
306
|
+
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
307
|
+
return { action: "handled" };
|
|
308
|
+
});
|
|
309
|
+
|
|
265
310
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
266
311
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
267
312
|
}));
|
|
@@ -276,18 +321,21 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
276
321
|
// Previews and the real call must see the same, repaired arguments.
|
|
277
322
|
const schema = schemas.get(name);
|
|
278
323
|
if (schema) coerceArgs(schema, input);
|
|
279
|
-
if (
|
|
324
|
+
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
280
325
|
const why = unavailableReason(name, mode, registered(), profile);
|
|
281
326
|
if (why) {
|
|
282
327
|
if (benchOut) record({ kind: "wrong_mode", tool: name, input });
|
|
283
328
|
return { block: true, reason: why };
|
|
284
329
|
}
|
|
285
330
|
}
|
|
331
|
+
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
286
332
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
287
333
|
if (!kind) return undefined;
|
|
288
334
|
if (benchOut) return benchGate(name, input, kind, ctx.cwd, ctx.signal);
|
|
289
335
|
if (kind === "path") {
|
|
290
336
|
const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
|
|
337
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
338
|
+
if (noteProblem) return { block: true, reason: noteProblem };
|
|
291
339
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
292
340
|
if (verdict.ok) return undefined;
|
|
293
341
|
if (!ctx.hasUI) return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
@@ -318,9 +366,52 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
318
366
|
return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
319
367
|
});
|
|
320
368
|
|
|
369
|
+
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
370
|
+
function literatureNoteProblem(name: string, target: string, input: Record<string, unknown>): string | undefined {
|
|
371
|
+
if (!target.toLowerCase().endsWith(".md")) return undefined;
|
|
372
|
+
if (name === "write") return checkLiteratureNote(String(input.content ?? ""));
|
|
373
|
+
if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
|
|
374
|
+
const current = readFileSync(target, "utf8");
|
|
375
|
+
return checkLiteratureNote(applyEdits(current, input.edits as Array<{ oldText?: unknown; newText?: unknown }>));
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Starbuck writes its reports into a folder: report_dir, or _starbuck next to the
|
|
382
|
+
* manuscript. Inside the vault or the working folder that needs no approval; elsewhere
|
|
383
|
+
* Sub-Sub asks, as for any file write. record_claims gets the current model's name.
|
|
384
|
+
*/
|
|
385
|
+
async function verifyGate(name: string, input: Record<string, unknown>, ctx: ExtensionContext) {
|
|
386
|
+
if (name === "verify_record_claims") {
|
|
387
|
+
input.judged_by = ctx.model ? [ctx.model.provider, ctx.model.id].filter(Boolean).join("/") : "the agent's model";
|
|
388
|
+
}
|
|
389
|
+
if (name === "verify_check_references") return undefined;
|
|
390
|
+
const dir = input.report_dir
|
|
391
|
+
? normalizeToolPath(String(input.report_dir), ctx.cwd)
|
|
392
|
+
: input.path
|
|
393
|
+
? join(dirname(normalizeToolPath(String(input.path), ctx.cwd)), "_starbuck")
|
|
394
|
+
: undefined;
|
|
395
|
+
if (!dir) return undefined;
|
|
396
|
+
const verdict = judgePath(dir, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
397
|
+
if (verdict.ok) return undefined;
|
|
398
|
+
if (benchOut) {
|
|
399
|
+
record({ kind: "path_blocked", tool: name, path: dir });
|
|
400
|
+
return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
|
|
401
|
+
}
|
|
402
|
+
if (!ctx.hasUI) return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
403
|
+
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
404
|
+
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
405
|
+
}
|
|
406
|
+
|
|
321
407
|
async function benchGate(name: string, input: Record<string, unknown>, kind: string, cwd: string, signal?: AbortSignal) {
|
|
322
408
|
if (kind === "path") {
|
|
323
409
|
const target = normalizeToolPath(String(input.path ?? ""), cwd);
|
|
410
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
411
|
+
if (noteProblem) {
|
|
412
|
+
record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
|
|
413
|
+
return { block: true, reason: noteProblem };
|
|
414
|
+
}
|
|
324
415
|
if (within(target, realish(benchOut!))) return undefined;
|
|
325
416
|
record({ kind: "path_blocked", tool: name, path: target });
|
|
326
417
|
return { block: true, reason: `Model test: write only inside ${benchOut}.` };
|
package/src/web.ts
CHANGED
|
@@ -22,9 +22,9 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
23
23
|
import { StringDecoder } from "node:string_decoder";
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
|
-
import { expand, loadConfig, saveConfig, type SubsubConfig } from "./config.ts";
|
|
25
|
+
import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
26
26
|
import { TESTED_MODELS } from "./init.ts";
|
|
27
|
-
import { within } from "./paths.ts";
|
|
27
|
+
import { realish, within } from "./paths.ts";
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------- options
|
|
30
30
|
|
|
@@ -577,6 +577,15 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
577
577
|
await restartAgent();
|
|
578
578
|
return send(res, 200, { ok: true });
|
|
579
579
|
}
|
|
580
|
+
case "POST /api/addons": {
|
|
581
|
+
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
582
|
+
if (busy) return send(res, 409, { error: "busy" });
|
|
583
|
+
const body = await readJson(req);
|
|
584
|
+
if (typeof body.starbuck !== "boolean") return send(res, 400, { error: "starbuck must be true or false" });
|
|
585
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
586
|
+
await restartAgent();
|
|
587
|
+
return send(res, 200, { ok: true });
|
|
588
|
+
}
|
|
580
589
|
case "POST /api/restart":
|
|
581
590
|
if (agent.proc) return send(res, 200, { ok: true });
|
|
582
591
|
agent.start();
|
|
@@ -595,7 +604,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
595
604
|
const body = await readJson(req);
|
|
596
605
|
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
597
606
|
const vault = cfg.vault;
|
|
598
|
-
|
|
607
|
+
// Judge the real file, so a link inside the notes folder cannot open a document outside it.
|
|
608
|
+
const real = realish(target);
|
|
609
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
610
|
+
if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the notes folder" });
|
|
599
611
|
// Documents only: a file that the model wrote must never be run.
|
|
600
612
|
if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
601
613
|
if (vault && within(target, vault) && existsSync(join(vault, ".obsidian"))) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
@@ -40,6 +40,7 @@ Shared rules for the Sub-Sub librarian and researcher. Sub-Sub reads this file a
|
|
|
40
40
|
- Folder: Literature/. File name: the citekey (smith2026.md).
|
|
41
41
|
- Create a note only when the user asks. Ask before creating more than 10 notes.
|
|
42
42
|
- Write from the full text (get_fulltext) when it exists. Otherwise use the abstract and the Zotero notes, and write "(from abstract)" after the summary.
|
|
43
|
+
- Front matter field evidence: full text, abstract or metadata (what you read). Page numbers only with evidence: full text. Sub-Sub refuses a literature note without this field.
|
|
43
44
|
- Do not copy the abstract in full. Give page numbers for quotes when the text shows them.
|
|
44
45
|
- If a note for the citekey exists, do not overwrite it. Add a dated section or ask.
|
|
45
46
|
- After the note is written, offer to link it in Zotero with attach_note.
|
|
@@ -58,6 +59,7 @@ item-type: journalArticle
|
|
|
58
59
|
venue: Journal name
|
|
59
60
|
doi: 10.xxxx/xxxxx
|
|
60
61
|
zotero: zotero://select/library/items/ITEMKEY
|
|
62
|
+
evidence: full text
|
|
61
63
|
status: to-read
|
|
62
64
|
tags:
|
|
63
65
|
- topic/example
|
|
@@ -2,28 +2,28 @@
|
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
3
|
"name": "subsub-glauca-dark",
|
|
4
4
|
"vars": {
|
|
5
|
-
"bg": "#
|
|
6
|
-
"surface": "#
|
|
7
|
-
"raised": "#
|
|
8
|
-
"text": "#
|
|
9
|
-
"muted": "#
|
|
10
|
-
"line": "#
|
|
11
|
-
"mark": "#
|
|
12
|
-
"markBright": "#
|
|
13
|
-
"markDeep": "#
|
|
14
|
-
"toneDeep": "#
|
|
15
|
-
"tone": "#
|
|
16
|
-
"toneBright": "#
|
|
17
|
-
"tonePale": "#
|
|
18
|
-
"green": "#
|
|
19
|
-
"red": "#
|
|
20
|
-
"yellow": "#
|
|
21
|
-
"violet": "#
|
|
22
|
-
"link": "#
|
|
23
|
-
"dim": "#
|
|
24
|
-
"select": "#
|
|
25
|
-
"okBg": "#
|
|
26
|
-
"errBg": "#
|
|
5
|
+
"bg": "#121517",
|
|
6
|
+
"surface": "#181d20",
|
|
7
|
+
"raised": "#20272b",
|
|
8
|
+
"text": "#e5ebec",
|
|
9
|
+
"muted": "#8a9699",
|
|
10
|
+
"line": "#283236",
|
|
11
|
+
"mark": "#5d99be",
|
|
12
|
+
"markBright": "#78b4da",
|
|
13
|
+
"markDeep": "#41789c",
|
|
14
|
+
"toneDeep": "#0e181c",
|
|
15
|
+
"tone": "#263e48",
|
|
16
|
+
"toneBright": "#486e7e",
|
|
17
|
+
"tonePale": "#8cb6cb",
|
|
18
|
+
"green": "#68aa80",
|
|
19
|
+
"red": "#cf5c5c",
|
|
20
|
+
"yellow": "#c49a46",
|
|
21
|
+
"violet": "#9d82b3",
|
|
22
|
+
"link": "#78b4da",
|
|
23
|
+
"dim": "#5d6a6d",
|
|
24
|
+
"select": "#263e48",
|
|
25
|
+
"okBg": "#1b2920",
|
|
26
|
+
"errBg": "#331c19"
|
|
27
27
|
},
|
|
28
28
|
"colors": {
|
|
29
29
|
"accent": "mark",
|
|
@@ -65,15 +65,15 @@
|
|
|
65
65
|
"toolDiffAdded": "green",
|
|
66
66
|
"toolDiffRemoved": "red",
|
|
67
67
|
"toolDiffContext": "muted",
|
|
68
|
-
"syntaxComment": "#
|
|
69
|
-
"syntaxKeyword": "#
|
|
70
|
-
"syntaxFunction": "#
|
|
71
|
-
"syntaxVariable": "#
|
|
72
|
-
"syntaxString": "#
|
|
73
|
-
"syntaxNumber": "#
|
|
74
|
-
"syntaxType": "#
|
|
75
|
-
"syntaxOperator": "#
|
|
76
|
-
"syntaxPunctuation": "#
|
|
68
|
+
"syntaxComment": "#5d6a6d",
|
|
69
|
+
"syntaxKeyword": "#78b4da",
|
|
70
|
+
"syntaxFunction": "#5d99be",
|
|
71
|
+
"syntaxVariable": "#e5ebec",
|
|
72
|
+
"syntaxString": "#68aa80",
|
|
73
|
+
"syntaxNumber": "#9d82b3",
|
|
74
|
+
"syntaxType": "#8cb6cb",
|
|
75
|
+
"syntaxOperator": "#8a9699",
|
|
76
|
+
"syntaxPunctuation": "#8a9699",
|
|
77
77
|
"thinkingOff": "line",
|
|
78
78
|
"thinkingMinimal": "dim",
|
|
79
79
|
"thinkingLow": "toneBright",
|
|
@@ -84,8 +84,8 @@
|
|
|
84
84
|
"bashMode": "mark"
|
|
85
85
|
},
|
|
86
86
|
"export": {
|
|
87
|
-
"pageBg": "#
|
|
88
|
-
"cardBg": "#
|
|
89
|
-
"infoBg": "#
|
|
87
|
+
"pageBg": "#121517",
|
|
88
|
+
"cardBg": "#181d20",
|
|
89
|
+
"infoBg": "#20272b"
|
|
90
90
|
}
|
|
91
91
|
}
|