@tiagojct/subsub 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -178
- package/dist/bench.js +7 -6
- package/dist/cli.js +34 -1
- package/dist/config.js +21 -13
- package/dist/doctor.js +3 -2
- package/dist/init.js +34 -24
- package/dist/look.js +2 -2
- package/dist/paths.js +20 -0
- package/dist/prompt.js +6 -6
- package/dist/roles.js +12 -12
- package/dist/shortcut.js +2 -2
- package/dist/subsub.js +69 -67
- package/dist/web.js +62 -28
- package/package.json +2 -3
- package/prompts/lit-note.md +1 -1
- package/roles/librarian.md +46 -0
- package/roles/{assistant.md → researcher.md} +4 -5
- package/src/bench.ts +7 -10
- package/src/cli.ts +34 -1
- package/src/config.ts +24 -17
- package/src/doctor.ts +3 -2
- package/src/init.ts +36 -32
- package/src/look.ts +3 -4
- package/src/paths.ts +21 -0
- package/src/prompt.ts +8 -7
- package/src/roles.ts +14 -15
- package/src/shortcut.ts +2 -2
- package/src/subsub.ts +67 -68
- package/src/web.ts +58 -27
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +21 -14
- package/web/app.js +51 -23
- package/web/i18n.js +43 -29
- package/web/index.html +6 -2
- package/docs/Subsub.md +0 -140
package/dist/init.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Writes:
|
|
6
6
|
* - the settings file (~/.config/subsub/config.json): profile, name, language,
|
|
7
|
-
* Sub-Sub folder,
|
|
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.
|
|
10
10
|
* Other lines stay;
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
21
|
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
-
* --
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
23
23
|
*/
|
|
24
24
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { dirname, join, resolve } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { configPath,
|
|
29
|
+
import { configPath, DEFAULT_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
30
30
|
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
31
31
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
|
|
32
32
|
import { PROFILE_SPECS } from "./profiles.js";
|
|
@@ -36,7 +36,7 @@ export const STARTER_TAGS = {
|
|
|
36
36
|
"health-informatics": "Health informatics: information systems, data, AI, digital health",
|
|
37
37
|
"any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
|
|
38
38
|
};
|
|
39
|
-
export const
|
|
39
|
+
export const TESTED_MODELS = DEFAULT_MODELS;
|
|
40
40
|
/** Set KEY=VALUE lines in an env file text; other lines and comments stay. */
|
|
41
41
|
export function upsertEnv(text, values) {
|
|
42
42
|
const lines = text ? text.replace(/\n$/, "").split("\n") : [];
|
|
@@ -103,8 +103,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
103
103
|
// Without questions (--yes), an existing folder stays exactly as it is.
|
|
104
104
|
const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
|
|
105
105
|
const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
|
|
106
|
-
const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault))
|
|
107
|
-
|
|
106
|
+
const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
|
|
107
|
+
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
108
108
|
if (notes !== answer)
|
|
109
109
|
io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
110
110
|
// ---- files of the old layout
|
|
@@ -119,17 +119,18 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
119
119
|
io.say(` and ${moves.length - 6} more`);
|
|
120
120
|
move = await io.choose("Move them", [
|
|
121
121
|
{ value: "yes", label: "Move them (recommended)" },
|
|
122
|
-
|
|
122
|
+
// In a new folder, files left behind would not be read: then "no" means keeping the old folder.
|
|
123
|
+
{ value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : flags.folder || flags.notes ? "Leave them where they are" : `Leave them, and keep using ${from}` },
|
|
123
124
|
], flags.move ?? (flags.yes ? "no" : "yes"));
|
|
125
|
+
// A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
|
|
126
|
+
if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
|
|
127
|
+
notes = from;
|
|
128
|
+
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
129
|
+
}
|
|
124
130
|
}
|
|
125
131
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
126
132
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
127
133
|
const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
128
|
-
explain("Library changes", "With library changes on, Sub-Sub can import, tag and repair items in Zotero. It shows a preview of every change, and nothing changes until you select Yes. With them off, it only reads, searches and writes notes. Switch at any time with /library on or /library off.");
|
|
129
|
-
const library = await io.choose("Library changes", [
|
|
130
|
-
{ value: "on", label: "On (recommended): every change shows a preview first" },
|
|
131
|
-
{ value: "off", label: "Off: read, search and write notes only" },
|
|
132
|
-
], flags.library ?? (existing.libraryChanges === false ? "off" : "on"));
|
|
133
134
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
134
135
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
135
136
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -148,23 +149,24 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
148
149
|
explain("Tag list", `Sub-Sub tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
|
|
149
150
|
tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
|
|
150
151
|
}
|
|
151
|
-
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it
|
|
152
|
+
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it with its requests to PubMed, Europe PMC, OpenAlex, Crossref and Unpaywall, as they ask, and to nobody else.");
|
|
152
153
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
153
154
|
explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. Sub-Sub then has /verify.");
|
|
154
155
|
const starbuck = await io.choose("Reference checks", [
|
|
155
156
|
{ value: "off", label: "Off" },
|
|
156
157
|
{ value: "on", label: "On (downloads Starbuck now; uses the email above)" },
|
|
157
158
|
], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
|
|
158
|
-
explain("Models", "Sub-Sub needs an account with a model provider. You can connect
|
|
159
|
+
explain("Models", "Sub-Sub needs an account with a model provider. Each mode can use its own model: the Librarian a fast, cheap one for tags and imports, the Researcher a stronger one for reading. You can connect a provider later in Sub-Sub with the model button (or /login in the terminal).");
|
|
159
160
|
const hadModel = "model" in existing || "models" in existing;
|
|
161
|
+
const show = (m) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
|
|
160
162
|
const modelOptions = [
|
|
161
|
-
...(hadModel ? [{ value: "keep", label: `Keep: ${current.
|
|
163
|
+
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
162
164
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
163
|
-
{ value: "opencode-go", label: `OpenCode Go, tested: ${
|
|
165
|
+
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
164
166
|
];
|
|
165
167
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
166
168
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
167
|
-
const models = await io.choose("
|
|
169
|
+
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
168
170
|
// ---- write
|
|
169
171
|
if (move === "yes")
|
|
170
172
|
applyMoves(moves);
|
|
@@ -199,18 +201,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
199
201
|
setup: fmup ? "fmup" : undefined,
|
|
200
202
|
vault: notes,
|
|
201
203
|
envFile,
|
|
202
|
-
libraryChanges:
|
|
203
|
-
defaultMode: undefined,
|
|
204
|
+
libraryChanges: undefined,
|
|
204
205
|
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
205
206
|
};
|
|
206
|
-
//
|
|
207
|
-
patch.
|
|
207
|
+
// A model per mode; "model" (0.10, one for both) is replaced.
|
|
208
|
+
patch.model = undefined;
|
|
208
209
|
if (models === "keep")
|
|
209
|
-
patch.
|
|
210
|
+
patch.models = current.models;
|
|
210
211
|
if (models === "later")
|
|
211
|
-
patch.
|
|
212
|
+
patch.models = {};
|
|
212
213
|
if (models === "opencode-go")
|
|
213
|
-
patch.
|
|
214
|
+
patch.models = TESTED_MODELS;
|
|
214
215
|
saveConfig(patch, initEnv);
|
|
215
216
|
const zotero = await zoteroReachable(deps.fetch);
|
|
216
217
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
@@ -305,3 +306,12 @@ export async function initMain(args, env = process.env) {
|
|
|
305
306
|
io.close?.();
|
|
306
307
|
}
|
|
307
308
|
}
|
|
309
|
+
/** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
|
|
310
|
+
export function cleanPath(raw) {
|
|
311
|
+
let p = raw.trim();
|
|
312
|
+
if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0])
|
|
313
|
+
return p.slice(1, -1);
|
|
314
|
+
if (process.platform !== "win32")
|
|
315
|
+
p = p.replace(/\\([ '"()&])/g, "$1");
|
|
316
|
+
return p;
|
|
317
|
+
}
|
package/dist/look.js
CHANGED
|
@@ -63,7 +63,7 @@ export function libraryLine(st) {
|
|
|
63
63
|
export function headerLines(opts) {
|
|
64
64
|
const p = opts.paint ?? plain;
|
|
65
65
|
const w = Math.max(10, opts.width);
|
|
66
|
-
const modeLabel =
|
|
66
|
+
const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
|
|
67
67
|
const lib = libraryLine(opts.library);
|
|
68
68
|
if (w < BANNER[0].length + 2) {
|
|
69
69
|
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
|
|
@@ -78,7 +78,7 @@ export function headerLines(opts) {
|
|
|
78
78
|
else
|
|
79
79
|
out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
80
80
|
}
|
|
81
|
-
out.push(p.dim(cut("/
|
|
81
|
+
out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
|
|
82
82
|
return out;
|
|
83
83
|
}
|
|
84
84
|
/**
|
package/dist/paths.js
CHANGED
|
@@ -81,7 +81,27 @@ export function judgePath(target, policy) {
|
|
|
81
81
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
82
82
|
if (hit)
|
|
83
83
|
return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
84
|
+
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
85
|
+
const hidden = target.split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
86
|
+
if (hidden)
|
|
87
|
+
return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
84
88
|
if (policy.allowed.some((a) => within(target, a)))
|
|
85
89
|
return { ok: true };
|
|
86
90
|
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
87
91
|
}
|
|
92
|
+
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
93
|
+
export function secretPaths() {
|
|
94
|
+
const home = realish(homedir());
|
|
95
|
+
return [
|
|
96
|
+
join(home, ".subsub", "agent", "auth.json"),
|
|
97
|
+
join(home, ".pi", "agent", "auth.json"),
|
|
98
|
+
join(home, ".config", "zotero-local-mcp", "env"),
|
|
99
|
+
join(home, ".ssh"),
|
|
100
|
+
join(home, ".aws"),
|
|
101
|
+
join(home, ".netrc"),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
105
|
+
export function isSecret(target, recursive = false) {
|
|
106
|
+
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
107
|
+
}
|
package/dist/prompt.js
CHANGED
|
@@ -47,15 +47,15 @@ export function fill(text, cfg) {
|
|
|
47
47
|
.replaceAll("{{batch}}", String(profileSpec(cfg.profile).batch))
|
|
48
48
|
.replace(/[ \t]+\n/g, "\n");
|
|
49
49
|
}
|
|
50
|
-
export function roleText(cfg) {
|
|
51
|
-
const raw = readIf(join(ROLE_DIR,
|
|
50
|
+
export function roleText(mode, cfg) {
|
|
51
|
+
const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
|
|
52
52
|
return cfg ? fill(raw, cfg) : raw;
|
|
53
53
|
}
|
|
54
|
-
export function systemAddition(cfg, cwd
|
|
54
|
+
export function systemAddition(mode, cfg, cwd) {
|
|
55
55
|
const user = who(cfg);
|
|
56
|
-
const parts = [
|
|
57
|
-
if (
|
|
58
|
-
parts.push(
|
|
56
|
+
const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
|
|
57
|
+
if (cfg.vault) {
|
|
58
|
+
parts.push("# The Sub-Sub folder", `The Sub-Sub folder is ${cfg.vault}. Folder names in these instructions (Inbox/, Literature/, Syntheses/, Research/, Zotero/) are inside it. Write notes there with absolute paths, even when the working folder is somewhere else.`);
|
|
59
59
|
}
|
|
60
60
|
const shared = readIf(cfg.sharedRules);
|
|
61
61
|
if (shared) {
|
package/dist/roles.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Which tools
|
|
2
|
+
* Which tools each mode may use, and which calls need the user's approval.
|
|
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
|
|
6
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
|
|
7
7
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
8
8
|
export const ZOTERO_READ = [
|
|
9
9
|
"status",
|
|
@@ -43,33 +43,33 @@ export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
|
|
|
43
43
|
/** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
|
|
44
44
|
export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map((n) => `zotero_${n}`);
|
|
45
45
|
/**
|
|
46
|
-
* The
|
|
47
|
-
*
|
|
48
|
-
* then removes tools.
|
|
46
|
+
* The tools of a mode. The Librarian manages the library: Zotero tools and files.
|
|
47
|
+
* The Researcher has the same, plus searches, notes and reference checks. The
|
|
48
|
+
* profile then removes tools. Every library change is still previewed (gateKind).
|
|
49
49
|
*/
|
|
50
|
-
export function toolsFor(registered, opts) {
|
|
50
|
+
export function toolsFor(mode, registered, opts = {}) {
|
|
51
51
|
const has = new Set(registered);
|
|
52
52
|
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
53
53
|
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
54
54
|
const wanted = [
|
|
55
55
|
...ZOTERO_READ,
|
|
56
56
|
...ZOTERO_CHECKS,
|
|
57
|
-
...
|
|
58
|
-
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
57
|
+
...zotero,
|
|
58
|
+
...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
|
|
59
59
|
...FILE_TOOLS,
|
|
60
60
|
];
|
|
61
61
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
62
62
|
}
|
|
63
63
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
64
|
-
export function unavailableReason(name, registered, opts) {
|
|
65
|
-
if (toolsFor(registered, opts).includes(name))
|
|
64
|
+
export function unavailableReason(name, mode, registered, opts = {}) {
|
|
65
|
+
if (toolsFor(mode, registered, opts).includes(name))
|
|
66
66
|
return undefined;
|
|
67
67
|
const profile = opts.profile ?? "editor";
|
|
68
68
|
if (profileSpec(profile).without.includes(name)) {
|
|
69
69
|
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
70
70
|
}
|
|
71
|
-
if (
|
|
72
|
-
return `${name}
|
|
71
|
+
if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
|
|
72
|
+
return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
|
|
73
73
|
}
|
|
74
74
|
return `${name} is not available.`;
|
|
75
75
|
}
|
package/dist/shortcut.js
CHANGED
|
@@ -64,7 +64,7 @@ export function desktopEntry(p) {
|
|
|
64
64
|
"[Desktop Entry]",
|
|
65
65
|
"Type=Application",
|
|
66
66
|
"Name=Sub-Sub",
|
|
67
|
-
"Comment=
|
|
67
|
+
"Comment=Research assistant for your Zotero library",
|
|
68
68
|
`Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
|
|
69
69
|
`Path=${p.home}`,
|
|
70
70
|
`Icon=${join(p.packageDir, "assets", "icon.png")}`,
|
|
@@ -85,7 +85,7 @@ export function windowsScript(p) {
|
|
|
85
85
|
` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
|
|
86
86
|
` $s.WorkingDirectory = ${psQuote(p.home)}`,
|
|
87
87
|
` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
|
|
88
|
-
" $s.Description = 'Sub-Sub:
|
|
88
|
+
" $s.Description = 'Sub-Sub: research assistant for your Zotero library'",
|
|
89
89
|
" $s.WindowStyle = 7",
|
|
90
90
|
" $s.Save()",
|
|
91
91
|
" Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
|
package/dist/subsub.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sub-Sub: a
|
|
2
|
+
* Sub-Sub: a research assistant for pi that works from your own Zotero library.
|
|
3
3
|
*
|
|
4
|
-
* -
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* - Two modes: the Librarian manages the library (Zotero tools only); the
|
|
5
|
+
* Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
|
|
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
9
|
* - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
|
|
10
10
|
* tools it offers; /profile changes it.
|
|
11
|
-
* - Commands: /
|
|
12
|
-
* /researcher (the two modes before 0.10) turn library changes on and off.
|
|
11
|
+
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
13
12
|
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
14
13
|
* ("verify") for reference checks.
|
|
15
14
|
*
|
|
@@ -24,11 +23,12 @@ import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, server
|
|
|
24
23
|
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
25
24
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
26
25
|
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
27
|
-
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
26
|
+
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
28
27
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
29
28
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
30
29
|
import { systemAddition } from "./prompt.js";
|
|
31
30
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
31
|
+
const MODE_ENTRY = "subsub-mode";
|
|
32
32
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
33
|
const BENCH_ENTRY = "subsub-bench";
|
|
34
34
|
function ownVersion() {
|
|
@@ -103,11 +103,11 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
if (deps.bridge)
|
|
105
105
|
registerTools(deps.bridge);
|
|
106
|
-
pi.registerFlag("
|
|
107
|
-
pi.registerFlag("
|
|
108
|
-
let
|
|
106
|
+
pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
|
|
107
|
+
pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
|
|
108
|
+
let mode = cfg.defaultMode;
|
|
109
109
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
110
|
-
const toolOpts = () => ({
|
|
110
|
+
const toolOpts = () => ({ profile, bench: benchRun });
|
|
111
111
|
let profile = cfg.profile;
|
|
112
112
|
const user = who(cfg);
|
|
113
113
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
@@ -129,7 +129,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
129
129
|
return;
|
|
130
130
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
131
131
|
const state = {
|
|
132
|
-
|
|
132
|
+
mode,
|
|
133
133
|
profile,
|
|
134
134
|
profileLabel: profileSpec(profile).label,
|
|
135
135
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
@@ -192,7 +192,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
192
192
|
const th = ctx.ui.theme;
|
|
193
193
|
return headerLines({
|
|
194
194
|
version,
|
|
195
|
-
|
|
195
|
+
mode,
|
|
196
196
|
profile: profileSpec(profile).label,
|
|
197
197
|
model: modelId,
|
|
198
198
|
library,
|
|
@@ -214,38 +214,29 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
214
214
|
function status(ctx) {
|
|
215
215
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
216
216
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
217
|
-
|
|
218
|
-
ctx.ui.setStatus("subsub", `Sub-Sub: ${lib} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
217
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
219
218
|
if (standalone)
|
|
220
|
-
ctx.ui.setTitle(
|
|
219
|
+
ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
221
220
|
headerTui?.requestRender();
|
|
222
221
|
webCtx = ctx;
|
|
223
222
|
emitWeb();
|
|
224
223
|
}
|
|
225
|
-
/** Set the tools
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
pi.setActiveTools(toolsFor(registered(), toolOpts()));
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
224
|
+
/** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
|
|
225
|
+
async function applyMode(ctx, next, remember, switchModel = true) {
|
|
226
|
+
mode = next;
|
|
227
|
+
pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
|
|
228
|
+
const spec = cfg.models[mode];
|
|
229
|
+
if (spec && switchModel) {
|
|
230
|
+
const i = spec.indexOf("/");
|
|
231
|
+
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
232
|
+
const ok = model ? await pi.setModel(model) : false;
|
|
233
|
+
if (!ok)
|
|
234
|
+
ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
|
|
236
235
|
}
|
|
236
|
+
if (remember)
|
|
237
|
+
pi.appendEntry(MODE_ENTRY, { mode });
|
|
237
238
|
status(ctx);
|
|
238
239
|
}
|
|
239
|
-
async function applyModel(ctx) {
|
|
240
|
-
const spec = cfg.model;
|
|
241
|
-
if (!spec)
|
|
242
|
-
return;
|
|
243
|
-
const i = spec.indexOf("/");
|
|
244
|
-
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
245
|
-
const ok = model ? await pi.setModel(model) : false;
|
|
246
|
-
if (!ok)
|
|
247
|
-
ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
|
|
248
|
-
}
|
|
249
240
|
pi.on("session_start", async (_event, ctx) => {
|
|
250
241
|
if (!bridge) {
|
|
251
242
|
const b = new Bridge();
|
|
@@ -253,13 +244,20 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
253
244
|
bridge = b;
|
|
254
245
|
registerTools(b);
|
|
255
246
|
}
|
|
247
|
+
let restored;
|
|
248
|
+
let modelChosen = false; // the session already has a model: a resumed conversation keeps it
|
|
249
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
250
|
+
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
251
|
+
if (m === "librarian" || m === "researcher")
|
|
252
|
+
restored = m;
|
|
253
|
+
if (e.type === "model_change")
|
|
254
|
+
modelChosen = true;
|
|
255
|
+
}
|
|
256
256
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
257
257
|
if (lookOn(ctx))
|
|
258
258
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
259
|
-
await applyModel(ctx);
|
|
260
259
|
applyTheme(ctx);
|
|
261
|
-
|
|
262
|
-
applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
|
|
260
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
263
261
|
if (lookOn(ctx)) {
|
|
264
262
|
setHeader(ctx);
|
|
265
263
|
void refreshLibrary();
|
|
@@ -278,10 +276,28 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
278
276
|
bridge = undefined;
|
|
279
277
|
await b?.close();
|
|
280
278
|
});
|
|
279
|
+
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
280
|
+
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
281
|
+
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
282
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
283
|
+
let m;
|
|
284
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
285
|
+
const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
286
|
+
if (x === "librarian" || x === "researcher")
|
|
287
|
+
m = x;
|
|
288
|
+
}
|
|
289
|
+
if (m && m !== mode)
|
|
290
|
+
await applyMode(ctx, m, false, false);
|
|
291
|
+
});
|
|
281
292
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
282
293
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
283
294
|
// The input event comes before pi expands a prompt template.
|
|
284
295
|
pi.on("input", async (event, ctx) => {
|
|
296
|
+
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
297
|
+
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
298
|
+
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
299
|
+
return { action: "handled" };
|
|
300
|
+
}
|
|
285
301
|
const why = promptBlocked(event.text, profile);
|
|
286
302
|
if (!why)
|
|
287
303
|
return { action: "continue" };
|
|
@@ -289,7 +305,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
289
305
|
return { action: "handled" };
|
|
290
306
|
});
|
|
291
307
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
292
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd
|
|
308
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
293
309
|
}));
|
|
294
310
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
295
311
|
const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
|
|
@@ -303,13 +319,16 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
303
319
|
coerceArgs(schema, input);
|
|
304
320
|
resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
|
|
305
321
|
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
306
|
-
const why = unavailableReason(name, registered(), toolOpts());
|
|
322
|
+
const why = unavailableReason(name, mode, registered(), toolOpts());
|
|
307
323
|
if (why) {
|
|
308
324
|
if (benchOut)
|
|
309
325
|
record({ kind: "wrong_mode", tool: name, input });
|
|
310
326
|
return { block: true, reason: why };
|
|
311
327
|
}
|
|
312
328
|
}
|
|
329
|
+
if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
|
|
330
|
+
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
331
|
+
}
|
|
313
332
|
if (name.startsWith("verify_"))
|
|
314
333
|
return verifyGate(name, input, ctx);
|
|
315
334
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
@@ -426,35 +445,18 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
426
445
|
`for ${user}.${shown}`,
|
|
427
446
|
};
|
|
428
447
|
}
|
|
429
|
-
const libraryNote = (on) => on
|
|
430
|
-
? "Sub-Sub: library changes on. Every change shows a preview first."
|
|
431
|
-
: "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
|
|
432
|
-
pi.registerCommand("library", {
|
|
433
|
-
description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
|
|
434
|
-
handler: async (args, ctx) => {
|
|
435
|
-
const a = args.trim().toLowerCase();
|
|
436
|
-
const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
|
|
437
|
-
if (on === undefined) {
|
|
438
|
-
ctx.ui.notify("Type /library on or /library off.", "warning");
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
applyLibrary(ctx, on, true);
|
|
442
|
-
ctx.ui.notify(libraryNote(on), "info");
|
|
443
|
-
},
|
|
444
|
-
});
|
|
445
|
-
// The two modes before 0.10, kept so that old habits and notes still work.
|
|
446
448
|
pi.registerCommand("librarian", {
|
|
447
|
-
description: "Sub-Sub:
|
|
449
|
+
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
448
450
|
handler: async (_args, ctx) => {
|
|
449
|
-
|
|
450
|
-
ctx.ui.notify(
|
|
451
|
+
await applyMode(ctx, "librarian", true);
|
|
452
|
+
ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
|
|
451
453
|
},
|
|
452
454
|
});
|
|
453
455
|
pi.registerCommand("researcher", {
|
|
454
|
-
description: "Sub-Sub:
|
|
456
|
+
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
455
457
|
handler: async (_args, ctx) => {
|
|
456
|
-
|
|
457
|
-
ctx.ui.notify(
|
|
458
|
+
await applyMode(ctx, "researcher", true);
|
|
459
|
+
ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
|
|
458
460
|
},
|
|
459
461
|
});
|
|
460
462
|
pi.registerCommand("profile", {
|
|
@@ -477,14 +479,14 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
477
479
|
catch (err) {
|
|
478
480
|
saved = ` Not saved: ${err.message}`;
|
|
479
481
|
}
|
|
480
|
-
|
|
482
|
+
await applyMode(ctx, mode, false, false);
|
|
481
483
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
482
484
|
},
|
|
483
485
|
});
|
|
484
486
|
pi.registerCommand("subsub", {
|
|
485
487
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
486
488
|
handler: async (_args, ctx) => {
|
|
487
|
-
const lines = [`
|
|
489
|
+
const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
488
490
|
if (!bridge) {
|
|
489
491
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
490
492
|
return;
|