@tiagojct/subsub 0.11.0 → 0.11.2
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 -179
- package/dist/bench.js +2 -2
- package/dist/bridge.js +5 -1
- package/dist/cli.js +34 -1
- package/dist/config.js +18 -2
- package/dist/doctor.js +9 -4
- package/dist/init.js +27 -6
- package/dist/paths.js +31 -4
- package/dist/preview.js +7 -2
- package/dist/prompt.js +3 -0
- package/dist/shortcut.js +2 -2
- package/dist/subsub.js +70 -8
- package/dist/web.js +73 -29
- package/package.json +2 -3
- package/prompts/lit-note.md +1 -1
- package/src/bench.ts +2 -2
- package/src/bridge.ts +4 -1
- package/src/cli.ts +34 -1
- package/src/config.ts +17 -2
- package/src/doctor.ts +8 -3
- package/src/init.ts +24 -5
- package/src/paths.ts +32 -3
- package/src/preview.ts +8 -2
- package/src/prompt.ts +6 -0
- package/src/shortcut.ts +2 -2
- package/src/subsub.ts +63 -12
- package/src/web.ts +67 -28
- package/web/app.css +1 -0
- package/web/app.js +42 -2
- package/web/i18n.js +11 -3
- package/docs/Subsub.md +0 -140
package/src/cli.ts
CHANGED
|
@@ -134,12 +134,38 @@ export function reviewCommand(args: string[], cfg: SubsubConfig, env: NodeJS.Pro
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
const HELP = `Sub-Sub: your research assistant, working from your own Zotero library.
|
|
138
|
+
|
|
139
|
+
Usage:
|
|
140
|
+
subsub Start Sub-Sub in the terminal
|
|
141
|
+
subsub --librarian Start in the Librarian, which only manages the library
|
|
142
|
+
subsub web Open Sub-Sub in the browser
|
|
143
|
+
subsub init Set up or change the settings (folder, tag list, profile, models)
|
|
144
|
+
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
145
|
+
subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
|
|
146
|
+
subsub review preview|apply <note>
|
|
147
|
+
Check or apply a tag review note from Inbox/
|
|
148
|
+
subsub --version Show the versions
|
|
149
|
+
|
|
150
|
+
Options:
|
|
151
|
+
--here Work in the current folder, not in the Sub-Sub folder
|
|
152
|
+
|
|
153
|
+
In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
|
|
154
|
+
/profile to change the profile, /undo to undo the last change, and / for all commands.
|
|
155
|
+
Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
|
|
156
|
+
|
|
157
|
+
Docs: https://subsub.tiagojacinto.eu`;
|
|
158
|
+
|
|
137
159
|
export async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
138
160
|
const env = process.env;
|
|
139
161
|
const home = homedir();
|
|
140
162
|
const here = argv.includes("--here");
|
|
141
163
|
const args = argv.filter((a) => a !== "--here");
|
|
142
164
|
|
|
165
|
+
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
166
|
+
console.log(HELP);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
143
169
|
if (args[0] === "--version" || args[0] === "-v") {
|
|
144
170
|
const piPkg = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..");
|
|
145
171
|
console.log(`subsub ${packageVersion()} (pi ${packageVersion(piPkg)})`);
|
|
@@ -173,7 +199,12 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
|
|
|
173
199
|
return;
|
|
174
200
|
}
|
|
175
201
|
if (isSelfUpdate(args)) {
|
|
176
|
-
|
|
202
|
+
if (PACKAGE_DIR.split(/[\\/]/).includes("node_modules")) {
|
|
203
|
+
console.log("Sub-Sub is not updated with \"update\". To update it, run the installer again (https://subsub.tiagojacinto.eu/install/);");
|
|
204
|
+
console.log("if you installed it with npm, type: npm install -g @tiagojct/subsub");
|
|
205
|
+
} else {
|
|
206
|
+
console.log(`Sub-Sub is not updated with "update". Its code is in ${PACKAGE_DIR}; after new code arrives, run "npm install" there.`);
|
|
207
|
+
}
|
|
177
208
|
console.log('To update packages added to Sub-Sub, use "subsub update --extensions".');
|
|
178
209
|
process.exitCode = 1;
|
|
179
210
|
return;
|
|
@@ -206,6 +237,8 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
|
|
|
206
237
|
env.SUBSUB_CLI = "1";
|
|
207
238
|
// pi's update notice would point to the global pi, not to this copy.
|
|
208
239
|
env.PI_SKIP_VERSION_CHECK ??= "1";
|
|
240
|
+
// pi reports a first install to pi.dev and names itself to some providers; Sub-Sub sends no usage data.
|
|
241
|
+
env.PI_TELEMETRY ??= "0";
|
|
209
242
|
|
|
210
243
|
process.argv = [process.argv[0], process.argv[1], ...args];
|
|
211
244
|
const index = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
package/src/config.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
|
39
39
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
40
40
|
|
|
41
41
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
42
|
-
export const SERVER_VERSION = "0.5.
|
|
42
|
+
export const SERVER_VERSION = "0.5.2";
|
|
43
43
|
|
|
44
44
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
45
45
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -159,7 +159,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
159
159
|
// "models" per mode; "model" (0.10) for both; {} or "" means: the user chooses in pi (/login, /model).
|
|
160
160
|
const one = typeof user.model === "string" ? user.model : undefined;
|
|
161
161
|
const given = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
|
|
162
|
-
|
|
162
|
+
// Without a settings file (no subsub init yet), the user chooses in pi: no default that may lack a key.
|
|
163
|
+
const fallback = existsSync(file) ? { ...DEFAULT_MODELS } : {};
|
|
164
|
+
const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : fallback);
|
|
163
165
|
for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
|
|
164
166
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
165
167
|
return {
|
|
@@ -223,6 +225,19 @@ export function serverCommand(
|
|
|
223
225
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
/**
|
|
229
|
+
* uv caches the PyPI index; right after a release it may not know the pinned version yet
|
|
230
|
+
* ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
|
|
231
|
+
* the index for that package refreshed. Returns undefined when a retry would not help.
|
|
232
|
+
*/
|
|
233
|
+
export function refreshedArgs(args: string[], stderr: string): string[] | undefined {
|
|
234
|
+
if (!/there is no version of|no solution found/i.test(stderr)) return undefined;
|
|
235
|
+
const i = args.indexOf("--from");
|
|
236
|
+
if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package")) return undefined;
|
|
237
|
+
const pkg = args[i + 1].split(/[=<>!~]/)[0];
|
|
238
|
+
return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
|
|
239
|
+
}
|
|
240
|
+
|
|
226
241
|
export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
|
|
227
242
|
return (cfg.addons ?? []).includes("starbuck");
|
|
228
243
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
starbuckCommand,
|
|
21
21
|
starbuckEnabled,
|
|
22
22
|
type SubsubConfig,
|
|
23
|
+
refreshedArgs,
|
|
23
24
|
} from "./config.ts";
|
|
24
25
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
25
26
|
import { profileSpec } from "./profiles.ts";
|
|
@@ -92,7 +93,10 @@ export function runChecks(
|
|
|
92
93
|
|
|
93
94
|
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
94
95
|
const local = cmd.args.includes("--directory");
|
|
95
|
-
const
|
|
96
|
+
const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
|
|
97
|
+
let res = run(cmd.command, cmd.args, serverEnv);
|
|
98
|
+
const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
|
|
99
|
+
if (retry) res = run(cmd.command, retry, serverEnv);
|
|
96
100
|
let st: Record<string, any> | undefined;
|
|
97
101
|
try {
|
|
98
102
|
st = JSON.parse(res.stdout);
|
|
@@ -123,7 +127,8 @@ export function runChecks(
|
|
|
123
127
|
);
|
|
124
128
|
const v = st.vocabulary ?? {};
|
|
125
129
|
if (v.loaded === false || !v.tags) {
|
|
126
|
-
|
|
130
|
+
const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
|
|
131
|
+
out.push({ name: "Tag list", ok: false, detail: path ? `cannot read ${path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
|
|
127
132
|
} else {
|
|
128
133
|
const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
|
|
129
134
|
out.push({
|
|
@@ -149,7 +154,7 @@ export function runChecks(
|
|
|
149
154
|
out.push(
|
|
150
155
|
hasLogin(agentDir)
|
|
151
156
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
|
|
152
|
-
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "
|
|
157
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." },
|
|
153
158
|
);
|
|
154
159
|
return out;
|
|
155
160
|
}
|
package/src/init.ts
CHANGED
|
@@ -152,9 +152,10 @@ 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
|
+
else if (notes !== folderDefault) io.say(`Sub-Sub folder: ${notes}`);
|
|
158
159
|
|
|
159
160
|
// ---- files of the old layout
|
|
160
161
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
@@ -171,10 +172,16 @@ export async function runInit(
|
|
|
171
172
|
"Move them",
|
|
172
173
|
[
|
|
173
174
|
{ value: "yes", label: "Move them (recommended)" },
|
|
174
|
-
|
|
175
|
+
// In a new folder, files left behind would not be read: then "no" means keeping the old folder.
|
|
176
|
+
{ 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
177
|
],
|
|
176
178
|
flags.move ?? (flags.yes ? "no" : "yes"),
|
|
177
179
|
);
|
|
180
|
+
// A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
|
|
181
|
+
if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
|
|
182
|
+
notes = from;
|
|
183
|
+
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
184
|
+
}
|
|
178
185
|
}
|
|
179
186
|
|
|
180
187
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
@@ -207,7 +214,7 @@ export async function runInit(
|
|
|
207
214
|
);
|
|
208
215
|
}
|
|
209
216
|
|
|
210
|
-
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it
|
|
217
|
+
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.");
|
|
211
218
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
212
219
|
|
|
213
220
|
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.");
|
|
@@ -295,6 +302,7 @@ export async function runInit(
|
|
|
295
302
|
if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
296
303
|
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
297
304
|
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.");
|
|
305
|
+
if (tags === "any-field") io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
298
306
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
299
307
|
}
|
|
300
308
|
|
|
@@ -348,7 +356,10 @@ function terminalIO(): InitIO & { close(): void } {
|
|
|
348
356
|
|
|
349
357
|
export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
350
358
|
const flags = parseFlags(args);
|
|
351
|
-
if (!process.stdin.isTTY
|
|
359
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
360
|
+
flags.yes = "true";
|
|
361
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
362
|
+
}
|
|
352
363
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
353
364
|
try {
|
|
354
365
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
@@ -364,3 +375,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
|
|
|
364
375
|
(io as { close?: () => void }).close?.();
|
|
365
376
|
}
|
|
366
377
|
}
|
|
378
|
+
|
|
379
|
+
/** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
|
|
380
|
+
export function cleanPath(raw: string): string {
|
|
381
|
+
let p = raw.trim();
|
|
382
|
+
if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0]) return p.slice(1, -1);
|
|
383
|
+
if (process.platform !== "win32") p = p.replace(/\\([ '"()&])/g, "$1");
|
|
384
|
+
return p;
|
|
385
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -45,9 +45,14 @@ export function realish(abs: string): string {
|
|
|
45
45
|
return tail.length ? join(real, ...tail) : real;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
49
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
50
|
+
|
|
48
51
|
export function within(child: string, parent: string | undefined): boolean {
|
|
49
52
|
if (!parent) return false;
|
|
50
|
-
|
|
53
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
54
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
55
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
51
56
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -92,6 +97,30 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
|
|
|
92
97
|
export function judgePath(target: string, policy: PathPolicy): PathVerdict {
|
|
93
98
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
94
99
|
if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
95
|
-
|
|
96
|
-
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
100
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
101
|
+
if (!root) return { ok: false, why: "it is outside the vault and the working folder" };
|
|
102
|
+
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
103
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
104
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
105
|
+
if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
110
|
+
export function secretPaths(): string[] {
|
|
111
|
+
const home = realish(homedir());
|
|
112
|
+
return [
|
|
113
|
+
join(home, ".subsub", "agent", "auth.json"),
|
|
114
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d): d is string => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
115
|
+
join(home, ".pi", "agent", "auth.json"),
|
|
116
|
+
join(home, ".config", "zotero-local-mcp", "env"),
|
|
117
|
+
join(home, ".ssh"),
|
|
118
|
+
join(home, ".aws"),
|
|
119
|
+
join(home, ".netrc"),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
124
|
+
export function isSecret(target: string, recursive = false, envFile?: string): boolean {
|
|
125
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
97
126
|
}
|
package/src/preview.ts
CHANGED
|
@@ -12,6 +12,12 @@ function s(v: unknown, n = 90): string {
|
|
|
12
12
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
16
|
+
function whole(v: unknown, n = 3000): string {
|
|
17
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
18
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function list(rows: string[], total?: number): string[] {
|
|
16
22
|
const out = rows.slice(0, MAX_LINES);
|
|
17
23
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -27,7 +33,7 @@ function describeChange(c: Obj): string {
|
|
|
27
33
|
if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
|
|
28
34
|
if (v && typeof v === "object" && "after" in (v as Obj)) {
|
|
29
35
|
const b = (v as Obj).before;
|
|
30
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
36
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -83,7 +89,7 @@ export function formatPreview(tool: string, preview: unknown): string {
|
|
|
83
89
|
}
|
|
84
90
|
if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
|
|
85
91
|
if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
86
|
-
if (p.summary || p.note_preview) lines.push(
|
|
92
|
+
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
|
|
87
93
|
if (p.link) lines.push(`Link: ${p.link}`);
|
|
88
94
|
}
|
|
89
95
|
const skipped = p.skipped as Obj | undefined;
|
package/src/prompt.ts
CHANGED
|
@@ -60,6 +60,12 @@ export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "abou
|
|
|
60
60
|
export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
|
|
61
61
|
const user = who(cfg);
|
|
62
62
|
const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
|
|
63
|
+
if (cfg.vault) {
|
|
64
|
+
parts.push(
|
|
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.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
63
69
|
const shared = readIf(cfg.sharedRules);
|
|
64
70
|
if (shared) {
|
|
65
71
|
parts.push(
|
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,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sub-Sub: a
|
|
2
|
+
* Sub-Sub: a research assistant for pi that works from your own Zotero library.
|
|
3
3
|
*
|
|
4
4
|
* - Two modes: the Librarian manages the library (Zotero tools only); the
|
|
5
5
|
* Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
|
|
@@ -39,7 +39,7 @@ 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";
|
|
@@ -130,6 +130,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
130
130
|
if (deps.bridge) registerTools(deps.bridge);
|
|
131
131
|
|
|
132
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 });
|
|
133
134
|
|
|
134
135
|
let mode: Mode = cfg.defaultMode;
|
|
135
136
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
@@ -252,11 +253,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
252
253
|
emitWeb();
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
|
|
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> {
|
|
256
258
|
mode = next;
|
|
257
259
|
pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
|
|
258
260
|
const spec = cfg.models[mode];
|
|
259
|
-
if (spec) {
|
|
261
|
+
if (spec && switchModel) {
|
|
260
262
|
const i = spec.indexOf("/");
|
|
261
263
|
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
262
264
|
const ok = model ? await pi.setModel(model) : false;
|
|
@@ -274,20 +276,28 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
274
276
|
registerTools(b);
|
|
275
277
|
}
|
|
276
278
|
let restored: Mode | undefined;
|
|
277
|
-
|
|
279
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
280
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
281
|
+
let hasModel = false;
|
|
282
|
+
let hasMessages = false;
|
|
283
|
+
for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
|
|
278
284
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
279
285
|
if (m === "librarian" || m === "researcher") restored = m;
|
|
286
|
+
if (e.type === "model_change") hasModel = true;
|
|
287
|
+
if (e.type === "message") hasMessages = true;
|
|
280
288
|
}
|
|
289
|
+
const modelChosen = hasModel && hasMessages;
|
|
281
290
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
282
291
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
283
292
|
applyTheme(ctx);
|
|
284
|
-
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
|
|
293
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
285
294
|
if (lookOn(ctx)) {
|
|
286
295
|
setHeader(ctx);
|
|
287
296
|
void refreshLibrary();
|
|
288
297
|
} else if (webView) {
|
|
289
298
|
void refreshLibrary();
|
|
290
299
|
}
|
|
300
|
+
if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
291
301
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
292
302
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
293
303
|
}
|
|
@@ -300,11 +310,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
300
310
|
await b?.close();
|
|
301
311
|
});
|
|
302
312
|
|
|
313
|
+
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
314
|
+
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
315
|
+
|
|
316
|
+
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
317
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
318
|
+
let m: Mode | undefined;
|
|
319
|
+
for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
|
|
320
|
+
const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
321
|
+
if (x === "librarian" || x === "researcher") m = x;
|
|
322
|
+
}
|
|
323
|
+
if (m && m !== mode) await applyMode(ctx, m, false, false);
|
|
324
|
+
});
|
|
325
|
+
|
|
303
326
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
304
327
|
|
|
305
328
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
306
329
|
// The input event comes before pi expands a prompt template.
|
|
307
330
|
pi.on("input", async (event, ctx) => {
|
|
331
|
+
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
332
|
+
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
333
|
+
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
334
|
+
return { action: "handled" };
|
|
335
|
+
}
|
|
308
336
|
const why = promptBlocked(event.text, profile);
|
|
309
337
|
if (!why) return { action: "continue" };
|
|
310
338
|
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
@@ -333,6 +361,10 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
333
361
|
return { block: true, reason: why };
|
|
334
362
|
}
|
|
335
363
|
}
|
|
364
|
+
// grep, find and ls search the working folder when no path is given.
|
|
365
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
366
|
+
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
367
|
+
}
|
|
336
368
|
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
337
369
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
338
370
|
if (!kind) return undefined;
|
|
@@ -351,6 +383,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
351
383
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
352
384
|
}
|
|
353
385
|
if (kind === "confirm") {
|
|
386
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
387
|
+
if (typeof input.output_path === "string") {
|
|
388
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
389
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
390
|
+
}
|
|
354
391
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
355
392
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
356
393
|
}
|
|
@@ -364,11 +401,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
364
401
|
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
365
402
|
}
|
|
366
403
|
if (preview.isError) return { block: true, reason: preview.text };
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
)
|
|
371
|
-
|
|
404
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
405
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
406
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
407
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
408
|
+
for (let round = 0; round < 3; round++) {
|
|
409
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
410
|
+
if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
411
|
+
let again;
|
|
412
|
+
try {
|
|
413
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
416
|
+
}
|
|
417
|
+
if (again.isError) return { block: true, reason: again.text };
|
|
418
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
419
|
+
if (now === shown) return undefined;
|
|
420
|
+
shown = now;
|
|
421
|
+
}
|
|
422
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
372
423
|
});
|
|
373
424
|
|
|
374
425
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
@@ -476,7 +527,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
476
527
|
} catch (err) {
|
|
477
528
|
saved = ` Not saved: ${(err as Error).message}`;
|
|
478
529
|
}
|
|
479
|
-
await applyMode(ctx, mode, false);
|
|
530
|
+
await applyMode(ctx, mode, false, false);
|
|
480
531
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
481
532
|
},
|
|
482
533
|
});
|