@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/src/init.ts
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,7 +19,7 @@
|
|
|
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
|
|
|
25
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -29,7 +29,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import {
|
|
31
31
|
configPath,
|
|
32
|
-
|
|
32
|
+
DEFAULT_MODELS,
|
|
33
33
|
expand,
|
|
34
34
|
isProfile,
|
|
35
35
|
loadConfig,
|
|
@@ -53,7 +53,7 @@ export const STARTER_TAGS: Record<string, string> = {
|
|
|
53
53
|
"any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
export const
|
|
56
|
+
export const TESTED_MODELS = DEFAULT_MODELS;
|
|
57
57
|
|
|
58
58
|
export interface InitIO {
|
|
59
59
|
ask(question: string, def: string): Promise<string>;
|
|
@@ -152,8 +152,8 @@ export async function runInit(
|
|
|
152
152
|
// Without questions (--yes), an existing folder stays exactly as it is.
|
|
153
153
|
const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
|
|
154
154
|
const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
|
|
155
|
-
const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault))
|
|
156
|
-
|
|
155
|
+
const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
|
|
156
|
+
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
157
157
|
if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
158
158
|
|
|
159
159
|
// ---- files of the old layout
|
|
@@ -171,10 +171,16 @@ export async function runInit(
|
|
|
171
171
|
"Move them",
|
|
172
172
|
[
|
|
173
173
|
{ value: "yes", label: "Move them (recommended)" },
|
|
174
|
-
|
|
174
|
+
// In a new folder, files left behind would not be read: then "no" means keeping the old folder.
|
|
175
|
+
{ 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}` },
|
|
175
176
|
],
|
|
176
177
|
flags.move ?? (flags.yes ? "no" : "yes"),
|
|
177
178
|
);
|
|
179
|
+
// A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
|
|
180
|
+
if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
|
|
181
|
+
notes = from;
|
|
182
|
+
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
183
|
+
}
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
@@ -185,19 +191,6 @@ export async function runInit(
|
|
|
185
191
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
186
192
|
);
|
|
187
193
|
|
|
188
|
-
explain(
|
|
189
|
-
"Library changes",
|
|
190
|
-
"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.",
|
|
191
|
-
);
|
|
192
|
-
const library = await io.choose(
|
|
193
|
-
"Library changes",
|
|
194
|
-
[
|
|
195
|
-
{ value: "on", label: "On (recommended): every change shows a preview first" },
|
|
196
|
-
{ value: "off", label: "Off: read, search and write notes only" },
|
|
197
|
-
],
|
|
198
|
-
flags.library ?? (existing.libraryChanges === false ? "off" : "on"),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
194
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
202
195
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
203
196
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -220,7 +213,7 @@ export async function runInit(
|
|
|
220
213
|
);
|
|
221
214
|
}
|
|
222
215
|
|
|
223
|
-
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it
|
|
216
|
+
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.");
|
|
224
217
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
225
218
|
|
|
226
219
|
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.");
|
|
@@ -233,16 +226,20 @@ export async function runInit(
|
|
|
233
226
|
flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
|
|
234
227
|
);
|
|
235
228
|
|
|
236
|
-
explain(
|
|
229
|
+
explain(
|
|
230
|
+
"Models",
|
|
231
|
+
"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).",
|
|
232
|
+
);
|
|
237
233
|
const hadModel = "model" in existing || "models" in existing;
|
|
234
|
+
const show = (m: Partial<Record<string, string>>) => (Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k} ${v}`).join(", ") : "choose in Sub-Sub");
|
|
238
235
|
const modelOptions = [
|
|
239
|
-
...(hadModel ? [{ value: "keep", label: `Keep: ${current.
|
|
236
|
+
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
240
237
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
241
|
-
{ value: "opencode-go", label: `OpenCode Go, tested: ${
|
|
238
|
+
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
242
239
|
];
|
|
243
240
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
244
241
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
245
|
-
const models = await io.choose("
|
|
242
|
+
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
246
243
|
|
|
247
244
|
// ---- write
|
|
248
245
|
if (move === "yes") applyMoves(moves);
|
|
@@ -278,15 +275,14 @@ export async function runInit(
|
|
|
278
275
|
setup: fmup ? "fmup" : undefined,
|
|
279
276
|
vault: notes,
|
|
280
277
|
envFile,
|
|
281
|
-
libraryChanges:
|
|
282
|
-
defaultMode: undefined,
|
|
278
|
+
libraryChanges: undefined,
|
|
283
279
|
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
284
280
|
};
|
|
285
|
-
//
|
|
286
|
-
patch.
|
|
287
|
-
if (models === "keep") patch.
|
|
288
|
-
if (models === "later") patch.
|
|
289
|
-
if (models === "opencode-go") patch.
|
|
281
|
+
// A model per mode; "model" (0.10, one for both) is replaced.
|
|
282
|
+
patch.model = undefined;
|
|
283
|
+
if (models === "keep") patch.models = current.models;
|
|
284
|
+
if (models === "later") patch.models = {};
|
|
285
|
+
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
290
286
|
saveConfig(patch, initEnv);
|
|
291
287
|
|
|
292
288
|
const zotero = await zoteroReachable(deps.fetch);
|
|
@@ -374,3 +370,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
|
|
|
374
370
|
(io as { close?: () => void }).close?.();
|
|
375
371
|
}
|
|
376
372
|
}
|
|
373
|
+
|
|
374
|
+
/** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
|
|
375
|
+
export function cleanPath(raw: string): string {
|
|
376
|
+
let p = raw.trim();
|
|
377
|
+
if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0]) return p.slice(1, -1);
|
|
378
|
+
if (process.platform !== "win32") p = p.replace(/\\([ '"()&])/g, "$1");
|
|
379
|
+
return p;
|
|
380
|
+
}
|
package/src/look.ts
CHANGED
|
@@ -83,8 +83,7 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
|
|
|
83
83
|
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
84
84
|
export function headerLines(opts: {
|
|
85
85
|
version: string;
|
|
86
|
-
|
|
87
|
-
libraryChanges: boolean;
|
|
86
|
+
mode: "librarian" | "researcher";
|
|
88
87
|
/** Profile label, e.g. "Scholar". */
|
|
89
88
|
profile?: string;
|
|
90
89
|
model: string;
|
|
@@ -95,7 +94,7 @@ export function headerLines(opts: {
|
|
|
95
94
|
}): string[] {
|
|
96
95
|
const p = opts.paint ?? plain;
|
|
97
96
|
const w = Math.max(10, opts.width);
|
|
98
|
-
const modeLabel =
|
|
97
|
+
const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
|
|
99
98
|
const lib = libraryLine(opts.library);
|
|
100
99
|
if (w < BANNER[0].length + 2) {
|
|
101
100
|
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))];
|
|
@@ -108,7 +107,7 @@ export function headerLines(opts: {
|
|
|
108
107
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
109
108
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
110
109
|
}
|
|
111
|
-
out.push(p.dim(cut("/
|
|
110
|
+
out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
|
|
112
111
|
return out;
|
|
113
112
|
}
|
|
114
113
|
|
package/src/paths.ts
CHANGED
|
@@ -92,6 +92,27 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
|
|
|
92
92
|
export function judgePath(target: string, policy: PathPolicy): PathVerdict {
|
|
93
93
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
94
94
|
if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
95
|
+
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
96
|
+
const hidden = target.split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
97
|
+
if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
95
98
|
if (policy.allowed.some((a) => within(target, a))) return { ok: true };
|
|
96
99
|
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
97
100
|
}
|
|
101
|
+
|
|
102
|
+
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
103
|
+
export function secretPaths(): string[] {
|
|
104
|
+
const home = realish(homedir());
|
|
105
|
+
return [
|
|
106
|
+
join(home, ".subsub", "agent", "auth.json"),
|
|
107
|
+
join(home, ".pi", "agent", "auth.json"),
|
|
108
|
+
join(home, ".config", "zotero-local-mcp", "env"),
|
|
109
|
+
join(home, ".ssh"),
|
|
110
|
+
join(home, ".aws"),
|
|
111
|
+
join(home, ".netrc"),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
116
|
+
export function isSecret(target: string, recursive = false): boolean {
|
|
117
|
+
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
118
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { type SubsubConfig, who } from "./config.ts";
|
|
13
|
+
import { type Mode, type SubsubConfig, who } from "./config.ts";
|
|
14
14
|
import { profileSpec } from "./profiles.ts";
|
|
15
15
|
|
|
16
16
|
const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
|
|
@@ -52,17 +52,18 @@ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
|
|
|
52
52
|
.replace(/[ \t]+\n/g, "\n");
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function roleText(cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
|
|
56
|
-
const raw = readIf(join(ROLE_DIR,
|
|
55
|
+
export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
|
|
56
|
+
const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
|
|
57
57
|
return cfg ? fill(raw, cfg) : raw;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function systemAddition(cfg: SubsubConfig, cwd: string
|
|
60
|
+
export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
|
|
61
61
|
const user = who(cfg);
|
|
62
|
-
const parts = [
|
|
63
|
-
if (
|
|
62
|
+
const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
|
|
63
|
+
if (cfg.vault) {
|
|
64
64
|
parts.push(
|
|
65
|
-
|
|
65
|
+
"# The Sub-Sub folder",
|
|
66
|
+
`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.`,
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
const shared = readIf(cfg.sharedRules);
|
package/src/roles.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Which tools
|
|
2
|
+
* Which tools each mode may use, and which calls need the user's approval.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Profile } from "./config.ts";
|
|
5
|
+
import type { Mode, Profile } from "./config.ts";
|
|
6
6
|
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
|
|
10
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
|
|
11
11
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
12
12
|
|
|
13
13
|
export const ZOTERO_READ = [
|
|
@@ -53,42 +53,41 @@ export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates",
|
|
|
53
53
|
(n) => `zotero_${n}`,
|
|
54
54
|
);
|
|
55
55
|
|
|
56
|
+
|
|
56
57
|
export interface ToolOptions {
|
|
57
|
-
/** Library changes on: the Zotero write tools are offered (each still previewed). */
|
|
58
|
-
libraryChanges: boolean;
|
|
59
58
|
profile?: Profile;
|
|
60
59
|
/** The model test: also the bakeoff_ tools. */
|
|
61
60
|
bench?: boolean;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
/**
|
|
65
|
-
* The
|
|
66
|
-
*
|
|
67
|
-
* then removes tools.
|
|
64
|
+
* The tools of a mode. The Librarian manages the library: Zotero tools and files.
|
|
65
|
+
* The Researcher has the same, plus searches, notes and reference checks. The
|
|
66
|
+
* profile then removes tools. Every library change is still previewed (gateKind).
|
|
68
67
|
*/
|
|
69
|
-
export function toolsFor(registered: string[], opts: ToolOptions): string[] {
|
|
68
|
+
export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
|
|
70
69
|
const has = new Set(registered);
|
|
71
70
|
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
72
71
|
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
73
72
|
const wanted = [
|
|
74
73
|
...ZOTERO_READ,
|
|
75
74
|
...ZOTERO_CHECKS,
|
|
76
|
-
...
|
|
77
|
-
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
75
|
+
...zotero,
|
|
76
|
+
...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
|
|
78
77
|
...FILE_TOOLS,
|
|
79
78
|
];
|
|
80
79
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
84
|
-
export function unavailableReason(name: string, registered: string[], opts: ToolOptions): string | undefined {
|
|
85
|
-
if (toolsFor(registered, opts).includes(name)) return undefined;
|
|
83
|
+
export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
|
|
84
|
+
if (toolsFor(mode, registered, opts).includes(name)) return undefined;
|
|
86
85
|
const profile = opts.profile ?? "editor";
|
|
87
86
|
if (profileSpec(profile).without.includes(name)) {
|
|
88
87
|
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
89
88
|
}
|
|
90
|
-
if (
|
|
91
|
-
return `${name}
|
|
89
|
+
if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
|
|
90
|
+
return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
|
|
92
91
|
}
|
|
93
92
|
return `${name} is not available.`;
|
|
94
93
|
}
|
package/src/shortcut.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function desktopEntry(p: ShortcutPlan): string {
|
|
|
81
81
|
"[Desktop Entry]",
|
|
82
82
|
"Type=Application",
|
|
83
83
|
"Name=Sub-Sub",
|
|
84
|
-
"Comment=
|
|
84
|
+
"Comment=Research assistant for your Zotero library",
|
|
85
85
|
`Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
|
|
86
86
|
`Path=${p.home}`,
|
|
87
87
|
`Icon=${join(p.packageDir, "assets", "icon.png")}`,
|
|
@@ -103,7 +103,7 @@ export function windowsScript(p: ShortcutPlan): string {
|
|
|
103
103
|
` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
|
|
104
104
|
` $s.WorkingDirectory = ${psQuote(p.home)}`,
|
|
105
105
|
` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
|
|
106
|
-
" $s.Description = 'Sub-Sub:
|
|
106
|
+
" $s.Description = 'Sub-Sub: research assistant for your Zotero library'",
|
|
107
107
|
" $s.WindowStyle = 7",
|
|
108
108
|
" $s.Save()",
|
|
109
109
|
" Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
|
package/src/subsub.ts
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
|
*
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
loadConfig,
|
|
30
29
|
parseEnvFile,
|
|
31
30
|
type Profile,
|
|
31
|
+
type Mode,
|
|
32
32
|
saveConfig,
|
|
33
33
|
serverCommand,
|
|
34
34
|
starbuckCommand,
|
|
@@ -39,12 +39,14 @@ import {
|
|
|
39
39
|
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
40
40
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
41
41
|
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
42
|
-
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
42
|
+
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
43
43
|
import { describeArgs, formatPreview } from "./preview.ts";
|
|
44
44
|
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
45
45
|
import { systemAddition } from "./prompt.ts";
|
|
46
46
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
|
|
47
47
|
|
|
48
|
+
const MODE_ENTRY = "subsub-mode";
|
|
49
|
+
|
|
48
50
|
export interface SubsubDeps {
|
|
49
51
|
config?: SubsubConfig;
|
|
50
52
|
/** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
|
|
@@ -127,12 +129,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
127
129
|
|
|
128
130
|
if (deps.bridge) registerTools(deps.bridge);
|
|
129
131
|
|
|
130
|
-
pi.registerFlag("
|
|
131
|
-
pi.registerFlag("
|
|
132
|
+
pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
|
|
133
|
+
pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
|
|
132
134
|
|
|
133
|
-
let
|
|
135
|
+
let mode: Mode = cfg.defaultMode;
|
|
134
136
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
135
|
-
const toolOpts = () => ({
|
|
137
|
+
const toolOpts = () => ({ profile, bench: benchRun });
|
|
136
138
|
let profile: Profile = cfg.profile;
|
|
137
139
|
const user = who(cfg);
|
|
138
140
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
@@ -154,7 +156,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
154
156
|
if (!webView || !ctx || ctx.mode !== "rpc") return;
|
|
155
157
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
156
158
|
const state = {
|
|
157
|
-
|
|
159
|
+
mode,
|
|
158
160
|
profile,
|
|
159
161
|
profileLabel: profileSpec(profile).label,
|
|
160
162
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
@@ -221,7 +223,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
221
223
|
const th = ctx.ui.theme;
|
|
222
224
|
return headerLines({
|
|
223
225
|
version,
|
|
224
|
-
|
|
226
|
+
mode,
|
|
225
227
|
profile: profileSpec(profile).label,
|
|
226
228
|
model: modelId,
|
|
227
229
|
library,
|
|
@@ -244,37 +246,28 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
244
246
|
function status(ctx: ExtensionContext): void {
|
|
245
247
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
246
248
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
247
|
-
|
|
248
|
-
ctx.ui.
|
|
249
|
-
if (standalone) ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
|
|
249
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
250
|
+
if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
250
251
|
headerTui?.requestRender();
|
|
251
252
|
webCtx = ctx;
|
|
252
253
|
emitWeb();
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
/** Set the tools
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
pi.setActiveTools(toolsFor(registered(), toolOpts()));
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
256
|
+
/** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
|
|
257
|
+
async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
|
|
258
|
+
mode = next;
|
|
259
|
+
pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
|
|
260
|
+
const spec = cfg.models[mode];
|
|
261
|
+
if (spec && switchModel) {
|
|
262
|
+
const i = spec.indexOf("/");
|
|
263
|
+
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
264
|
+
const ok = model ? await pi.setModel(model) : false;
|
|
265
|
+
if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
|
|
265
266
|
}
|
|
267
|
+
if (remember) pi.appendEntry(MODE_ENTRY, { mode });
|
|
266
268
|
status(ctx);
|
|
267
269
|
}
|
|
268
270
|
|
|
269
|
-
async function applyModel(ctx: ExtensionContext): Promise<void> {
|
|
270
|
-
const spec = cfg.model;
|
|
271
|
-
if (!spec) return;
|
|
272
|
-
const i = spec.indexOf("/");
|
|
273
|
-
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
274
|
-
const ok = model ? await pi.setModel(model) : false;
|
|
275
|
-
if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
|
|
276
|
-
}
|
|
277
|
-
|
|
278
271
|
pi.on("session_start", async (_event, ctx) => {
|
|
279
272
|
if (!bridge) {
|
|
280
273
|
const b = new Bridge();
|
|
@@ -282,12 +275,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
282
275
|
bridge = b;
|
|
283
276
|
registerTools(b);
|
|
284
277
|
}
|
|
278
|
+
let restored: Mode | undefined;
|
|
279
|
+
let modelChosen = false; // the session already has a model: a resumed conversation keeps it
|
|
280
|
+
for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
|
|
281
|
+
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
282
|
+
if (m === "librarian" || m === "researcher") restored = m;
|
|
283
|
+
if (e.type === "model_change") modelChosen = true;
|
|
284
|
+
}
|
|
285
285
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
286
286
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
287
|
-
await applyModel(ctx);
|
|
288
287
|
applyTheme(ctx);
|
|
289
|
-
|
|
290
|
-
applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
|
|
288
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
291
289
|
if (lookOn(ctx)) {
|
|
292
290
|
setHeader(ctx);
|
|
293
291
|
void refreshLibrary();
|
|
@@ -306,11 +304,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
306
304
|
await b?.close();
|
|
307
305
|
});
|
|
308
306
|
|
|
307
|
+
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
308
|
+
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
309
|
+
|
|
310
|
+
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
311
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
312
|
+
let m: Mode | undefined;
|
|
313
|
+
for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
|
|
314
|
+
const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
315
|
+
if (x === "librarian" || x === "researcher") m = x;
|
|
316
|
+
}
|
|
317
|
+
if (m && m !== mode) await applyMode(ctx, m, false, false);
|
|
318
|
+
});
|
|
319
|
+
|
|
309
320
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
310
321
|
|
|
311
322
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
312
323
|
// The input event comes before pi expands a prompt template.
|
|
313
324
|
pi.on("input", async (event, ctx) => {
|
|
325
|
+
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
326
|
+
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
327
|
+
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
328
|
+
return { action: "handled" };
|
|
329
|
+
}
|
|
314
330
|
const why = promptBlocked(event.text, profile);
|
|
315
331
|
if (!why) return { action: "continue" };
|
|
316
332
|
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
@@ -318,7 +334,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
318
334
|
});
|
|
319
335
|
|
|
320
336
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
321
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd
|
|
337
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
322
338
|
}));
|
|
323
339
|
|
|
324
340
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
@@ -333,12 +349,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
333
349
|
if (schema) coerceArgs(schema, input);
|
|
334
350
|
resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
|
|
335
351
|
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
336
|
-
const why = unavailableReason(name, registered(), toolOpts());
|
|
352
|
+
const why = unavailableReason(name, mode, registered(), toolOpts());
|
|
337
353
|
if (why) {
|
|
338
354
|
if (benchOut) record({ kind: "wrong_mode", tool: name, input });
|
|
339
355
|
return { block: true, reason: why };
|
|
340
356
|
}
|
|
341
357
|
}
|
|
358
|
+
if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
|
|
359
|
+
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
360
|
+
}
|
|
342
361
|
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
343
362
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
344
363
|
if (!kind) return undefined;
|
|
@@ -447,39 +466,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
447
466
|
};
|
|
448
467
|
}
|
|
449
468
|
|
|
450
|
-
const libraryNote = (on: boolean) =>
|
|
451
|
-
on
|
|
452
|
-
? "Sub-Sub: library changes on. Every change shows a preview first."
|
|
453
|
-
: "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
|
|
454
|
-
|
|
455
|
-
pi.registerCommand("library", {
|
|
456
|
-
description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
|
|
457
|
-
handler: async (args, ctx) => {
|
|
458
|
-
const a = args.trim().toLowerCase();
|
|
459
|
-
const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
|
|
460
|
-
if (on === undefined) {
|
|
461
|
-
ctx.ui.notify("Type /library on or /library off.", "warning");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
applyLibrary(ctx, on, true);
|
|
465
|
-
ctx.ui.notify(libraryNote(on), "info");
|
|
466
|
-
},
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
// The two modes before 0.10, kept so that old habits and notes still work.
|
|
470
469
|
pi.registerCommand("librarian", {
|
|
471
|
-
description: "Sub-Sub:
|
|
470
|
+
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
472
471
|
handler: async (_args, ctx) => {
|
|
473
|
-
|
|
474
|
-
ctx.ui.notify(
|
|
472
|
+
await applyMode(ctx, "librarian", true);
|
|
473
|
+
ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
|
|
475
474
|
},
|
|
476
475
|
});
|
|
477
476
|
|
|
478
477
|
pi.registerCommand("researcher", {
|
|
479
|
-
description: "Sub-Sub:
|
|
478
|
+
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
480
479
|
handler: async (_args, ctx) => {
|
|
481
|
-
|
|
482
|
-
ctx.ui.notify(
|
|
480
|
+
await applyMode(ctx, "researcher", true);
|
|
481
|
+
ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
|
|
483
482
|
},
|
|
484
483
|
});
|
|
485
484
|
|
|
@@ -502,7 +501,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
502
501
|
} catch (err) {
|
|
503
502
|
saved = ` Not saved: ${(err as Error).message}`;
|
|
504
503
|
}
|
|
505
|
-
|
|
504
|
+
await applyMode(ctx, mode, false, false);
|
|
506
505
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
507
506
|
},
|
|
508
507
|
});
|
|
@@ -510,7 +509,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
510
509
|
pi.registerCommand("subsub", {
|
|
511
510
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
512
511
|
handler: async (_args, ctx) => {
|
|
513
|
-
const lines = [`
|
|
512
|
+
const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
514
513
|
if (!bridge) {
|
|
515
514
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
516
515
|
return;
|