@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/dist/init.js
CHANGED
|
@@ -103,10 +103,12 @@ 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
|
+
else if (notes !== folderDefault)
|
|
111
|
+
io.say(`Sub-Sub folder: ${notes}`);
|
|
110
112
|
// ---- files of the old layout
|
|
111
113
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
112
114
|
const moves = planMoves(from, notes);
|
|
@@ -119,8 +121,14 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
119
121
|
io.say(` and ${moves.length - 6} more`);
|
|
120
122
|
move = await io.choose("Move them", [
|
|
121
123
|
{ value: "yes", label: "Move them (recommended)" },
|
|
122
|
-
|
|
124
|
+
// In a new folder, files left behind would not be read: then "no" means keeping the old folder.
|
|
125
|
+
{ 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
126
|
], flags.move ?? (flags.yes ? "no" : "yes"));
|
|
127
|
+
// A folder given on the command line (--folder, --notes) is a deliberate choice and stays.
|
|
128
|
+
if (move === "no" && from !== notes && !flags.folder && !flags.notes) {
|
|
129
|
+
notes = from;
|
|
130
|
+
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
131
|
+
}
|
|
124
132
|
}
|
|
125
133
|
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
134
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
@@ -143,7 +151,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
143
151
|
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}.`);
|
|
144
152
|
tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
|
|
145
153
|
}
|
|
146
|
-
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it
|
|
154
|
+
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.");
|
|
147
155
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
148
156
|
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.");
|
|
149
157
|
const starbuck = await io.choose("Reference checks", [
|
|
@@ -226,6 +234,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
226
234
|
if (fmup)
|
|
227
235
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
228
236
|
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.");
|
|
237
|
+
if (tags === "any-field")
|
|
238
|
+
io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
229
239
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
230
240
|
}
|
|
231
241
|
export function parseFlags(args) {
|
|
@@ -281,8 +291,10 @@ function terminalIO() {
|
|
|
281
291
|
}
|
|
282
292
|
export async function initMain(args, env = process.env) {
|
|
283
293
|
const flags = parseFlags(args);
|
|
284
|
-
if (!process.stdin.isTTY)
|
|
285
|
-
flags.yes
|
|
294
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
295
|
+
flags.yes = "true";
|
|
296
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
297
|
+
}
|
|
286
298
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
287
299
|
try {
|
|
288
300
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
@@ -300,3 +312,12 @@ export async function initMain(args, env = process.env) {
|
|
|
300
312
|
io.close?.();
|
|
301
313
|
}
|
|
302
314
|
}
|
|
315
|
+
/** A path as typed, dragged from Finder (spaces escaped) or copied from Explorer (in quotes). */
|
|
316
|
+
export function cleanPath(raw) {
|
|
317
|
+
let p = raw.trim();
|
|
318
|
+
if (p.length > 1 && (p[0] === '"' || p[0] === "'") && p.at(-1) === p[0])
|
|
319
|
+
return p.slice(1, -1);
|
|
320
|
+
if (process.platform !== "win32")
|
|
321
|
+
p = p.replace(/\\([ '"()&])/g, "$1");
|
|
322
|
+
return p;
|
|
323
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -46,10 +46,14 @@ export function realish(abs) {
|
|
|
46
46
|
}
|
|
47
47
|
return tail.length ? join(real, ...tail) : real;
|
|
48
48
|
}
|
|
49
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
50
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
49
51
|
export function within(child, parent) {
|
|
50
52
|
if (!parent)
|
|
51
53
|
return false;
|
|
52
|
-
|
|
54
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
55
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
56
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
53
57
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
54
58
|
}
|
|
55
59
|
export function buildPolicy(opts) {
|
|
@@ -81,7 +85,30 @@ export function judgePath(target, policy) {
|
|
|
81
85
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
82
86
|
if (hit)
|
|
83
87
|
return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
89
|
+
if (!root)
|
|
90
|
+
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
91
|
+
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
92
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
93
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
94
|
+
if (hidden)
|
|
95
|
+
return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
99
|
+
export function secretPaths() {
|
|
100
|
+
const home = realish(homedir());
|
|
101
|
+
return [
|
|
102
|
+
join(home, ".subsub", "agent", "auth.json"),
|
|
103
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d) => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
104
|
+
join(home, ".pi", "agent", "auth.json"),
|
|
105
|
+
join(home, ".config", "zotero-local-mcp", "env"),
|
|
106
|
+
join(home, ".ssh"),
|
|
107
|
+
join(home, ".aws"),
|
|
108
|
+
join(home, ".netrc"),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
112
|
+
export function isSecret(target, recursive = false, envFile) {
|
|
113
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
87
114
|
}
|
package/dist/preview.js
CHANGED
|
@@ -7,6 +7,11 @@ function s(v, n = 90) {
|
|
|
7
7
|
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
8
8
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
9
9
|
}
|
|
10
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
11
|
+
function whole(v, n = 3000) {
|
|
12
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
13
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
14
|
+
}
|
|
10
15
|
function list(rows, total) {
|
|
11
16
|
const out = rows.slice(0, MAX_LINES);
|
|
12
17
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -25,7 +30,7 @@ function describeChange(c) {
|
|
|
25
30
|
continue;
|
|
26
31
|
if (v && typeof v === "object" && "after" in v) {
|
|
27
32
|
const b = v.before;
|
|
28
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
33
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -69,7 +74,7 @@ export function formatPreview(tool, preview) {
|
|
|
69
74
|
if (p.item || p.parent)
|
|
70
75
|
lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
71
76
|
if (p.summary || p.note_preview)
|
|
72
|
-
lines.push(
|
|
77
|
+
lines.push(whole(p.summary ?? p.note_preview));
|
|
73
78
|
if (p.link)
|
|
74
79
|
lines.push(`Link: ${p.link}`);
|
|
75
80
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -54,6 +54,9 @@ export function roleText(mode, cfg) {
|
|
|
54
54
|
export function systemAddition(mode, cfg, cwd) {
|
|
55
55
|
const user = who(cfg);
|
|
56
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
|
+
}
|
|
57
60
|
const shared = readIf(cfg.sharedRules);
|
|
58
61
|
if (shared) {
|
|
59
62
|
parts.push("# Shared Zotero rules (from the Sub-Sub folder)", shared, `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`);
|
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,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
|
|
@@ -23,7 +23,7 @@ import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, server
|
|
|
23
23
|
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
24
24
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
25
25
|
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
26
|
-
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
26
|
+
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
27
27
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
28
28
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
29
29
|
import { systemAddition } from "./prompt.js";
|
|
@@ -104,6 +104,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
104
104
|
if (deps.bridge)
|
|
105
105
|
registerTools(deps.bridge);
|
|
106
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 });
|
|
107
108
|
let mode = cfg.defaultMode;
|
|
108
109
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
109
110
|
const toolOpts = () => ({ profile, bench: benchRun });
|
|
@@ -220,11 +221,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
220
221
|
webCtx = ctx;
|
|
221
222
|
emitWeb();
|
|
222
223
|
}
|
|
223
|
-
|
|
224
|
+
/** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
|
|
225
|
+
async function applyMode(ctx, next, remember, switchModel = true) {
|
|
224
226
|
mode = next;
|
|
225
227
|
pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
|
|
226
228
|
const spec = cfg.models[mode];
|
|
227
|
-
if (spec) {
|
|
229
|
+
if (spec && switchModel) {
|
|
228
230
|
const i = spec.indexOf("/");
|
|
229
231
|
const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
|
|
230
232
|
const ok = model ? await pi.setModel(model) : false;
|
|
@@ -243,16 +245,25 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
243
245
|
registerTools(b);
|
|
244
246
|
}
|
|
245
247
|
let restored;
|
|
248
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
249
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
250
|
+
let hasModel = false;
|
|
251
|
+
let hasMessages = false;
|
|
246
252
|
for (const e of ctx.sessionManager.getBranch()) {
|
|
247
253
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
248
254
|
if (m === "librarian" || m === "researcher")
|
|
249
255
|
restored = m;
|
|
256
|
+
if (e.type === "model_change")
|
|
257
|
+
hasModel = true;
|
|
258
|
+
if (e.type === "message")
|
|
259
|
+
hasMessages = true;
|
|
250
260
|
}
|
|
261
|
+
const modelChosen = hasModel && hasMessages;
|
|
251
262
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
252
263
|
if (lookOn(ctx))
|
|
253
264
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
254
265
|
applyTheme(ctx);
|
|
255
|
-
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
|
|
266
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
256
267
|
if (lookOn(ctx)) {
|
|
257
268
|
setHeader(ctx);
|
|
258
269
|
void refreshLibrary();
|
|
@@ -260,6 +271,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
260
271
|
else if (webView) {
|
|
261
272
|
void refreshLibrary();
|
|
262
273
|
}
|
|
274
|
+
if (!ctx.model && !webView)
|
|
275
|
+
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");
|
|
263
276
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
264
277
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
265
278
|
}
|
|
@@ -271,10 +284,28 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
271
284
|
bridge = undefined;
|
|
272
285
|
await b?.close();
|
|
273
286
|
});
|
|
287
|
+
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
288
|
+
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
289
|
+
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
290
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
291
|
+
let m;
|
|
292
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
293
|
+
const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
294
|
+
if (x === "librarian" || x === "researcher")
|
|
295
|
+
m = x;
|
|
296
|
+
}
|
|
297
|
+
if (m && m !== mode)
|
|
298
|
+
await applyMode(ctx, m, false, false);
|
|
299
|
+
});
|
|
274
300
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
275
301
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
276
302
|
// The input event comes before pi expands a prompt template.
|
|
277
303
|
pi.on("input", async (event, ctx) => {
|
|
304
|
+
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
305
|
+
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
306
|
+
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
307
|
+
return { action: "handled" };
|
|
308
|
+
}
|
|
278
309
|
const why = promptBlocked(event.text, profile);
|
|
279
310
|
if (!why)
|
|
280
311
|
return { action: "continue" };
|
|
@@ -303,6 +334,10 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
303
334
|
return { block: true, reason: why };
|
|
304
335
|
}
|
|
305
336
|
}
|
|
337
|
+
// grep, find and ls search the working folder when no path is given.
|
|
338
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
339
|
+
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
340
|
+
}
|
|
306
341
|
if (name.startsWith("verify_"))
|
|
307
342
|
return verifyGate(name, input, ctx);
|
|
308
343
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
@@ -327,6 +362,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
327
362
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
328
363
|
}
|
|
329
364
|
if (kind === "confirm") {
|
|
365
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
366
|
+
if (typeof input.output_path === "string") {
|
|
367
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
368
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
369
|
+
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
370
|
+
}
|
|
330
371
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
331
372
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
332
373
|
}
|
|
@@ -342,8 +383,29 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
342
383
|
}
|
|
343
384
|
if (preview.isError)
|
|
344
385
|
return { block: true, reason: preview.text };
|
|
345
|
-
const
|
|
346
|
-
|
|
386
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
387
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
388
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
389
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
390
|
+
for (let round = 0; round < 3; round++) {
|
|
391
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
392
|
+
if (!ok)
|
|
393
|
+
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
394
|
+
let again;
|
|
395
|
+
try {
|
|
396
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return { block: true, reason: `Preview failed: ${err.message}` };
|
|
400
|
+
}
|
|
401
|
+
if (again.isError)
|
|
402
|
+
return { block: true, reason: again.text };
|
|
403
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
404
|
+
if (now === shown)
|
|
405
|
+
return undefined;
|
|
406
|
+
shown = now;
|
|
407
|
+
}
|
|
408
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
347
409
|
});
|
|
348
410
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
349
411
|
function literatureNoteProblem(name, target, input) {
|
|
@@ -453,7 +515,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
453
515
|
catch (err) {
|
|
454
516
|
saved = ` Not saved: ${err.message}`;
|
|
455
517
|
}
|
|
456
|
-
await applyMode(ctx, mode, false);
|
|
518
|
+
await applyMode(ctx, mode, false, false);
|
|
457
519
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
458
520
|
},
|
|
459
521
|
});
|
package/dist/web.js
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
|
|
24
|
+
import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
26
|
import { obsidianRoot } from "./layout.js";
|
|
27
27
|
import { realish, within } from "./paths.js";
|
|
@@ -300,7 +300,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
300
300
|
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
301
301
|
env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
|
|
302
302
|
const childEnv = { ...env, SUBSUB_WEB: "1" };
|
|
303
|
-
const baseArgs = [bin, "--mode", "rpc", "--here", ...
|
|
303
|
+
const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
|
|
304
304
|
const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
|
|
305
305
|
// ---- state shared with the pages
|
|
306
306
|
const clients = new Set();
|
|
@@ -309,7 +309,19 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
309
309
|
let busy = false;
|
|
310
310
|
let lastSeen = Date.now();
|
|
311
311
|
let restarting = false;
|
|
312
|
+
let switching = false; // a restart for a new model, add-on or key is under way
|
|
313
|
+
let lastSession;
|
|
312
314
|
let refreshAfterRun = false;
|
|
315
|
+
/** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
|
|
316
|
+
function modeArgs() {
|
|
317
|
+
const shown = subsubState?.mode;
|
|
318
|
+
if (shown === "librarian" || shown === "researcher")
|
|
319
|
+
return [`--${shown}`];
|
|
320
|
+
return opts.librarian ? ["--librarian"] : [];
|
|
321
|
+
}
|
|
322
|
+
function startAgent() {
|
|
323
|
+
agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
|
|
324
|
+
}
|
|
313
325
|
function broadcast(ev) {
|
|
314
326
|
const data = `data: ${JSON.stringify(ev)}\n\n`;
|
|
315
327
|
for (const c of clients)
|
|
@@ -352,11 +364,13 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
352
364
|
if (restarting)
|
|
353
365
|
return;
|
|
354
366
|
busy = false;
|
|
367
|
+
for (const id of dialogs.keys())
|
|
368
|
+
broadcast({ type: "dialog_closed", id });
|
|
355
369
|
dialogs.clear();
|
|
356
370
|
broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
|
|
357
371
|
log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
|
|
358
372
|
};
|
|
359
|
-
|
|
373
|
+
startAgent();
|
|
360
374
|
async function snapshot() {
|
|
361
375
|
const out = {
|
|
362
376
|
type: "hello",
|
|
@@ -369,6 +383,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
369
383
|
};
|
|
370
384
|
try {
|
|
371
385
|
out.session = await agent.request({ type: "get_state" });
|
|
386
|
+
lastSession = out.session.sessionFile ?? lastSession;
|
|
372
387
|
out.messages = (await agent.request({ type: "get_messages" })).messages;
|
|
373
388
|
out.commands = (await agent.request({ type: "get_commands" })).commands;
|
|
374
389
|
}
|
|
@@ -387,23 +402,40 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
387
402
|
.map((s) => ({ path: s.path, name: s.name ?? null, first: s.firstMessage.slice(0, 140), modified: s.modified, messages: s.messageCount }));
|
|
388
403
|
}
|
|
389
404
|
async function restartAgent() {
|
|
390
|
-
let sessionFile;
|
|
391
405
|
try {
|
|
392
|
-
|
|
406
|
+
lastSession = (await agent.request({ type: "get_state" })).sessionFile ?? lastSession;
|
|
393
407
|
}
|
|
394
408
|
catch {
|
|
395
|
-
/*
|
|
409
|
+
/* keep the last one known */
|
|
396
410
|
}
|
|
397
411
|
restarting = true;
|
|
398
412
|
for (const id of dialogs.keys())
|
|
399
413
|
broadcast({ type: "dialog_closed", id });
|
|
400
414
|
dialogs.clear();
|
|
401
|
-
|
|
402
|
-
|
|
415
|
+
try {
|
|
416
|
+
await agent.stop();
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
restarting = false;
|
|
420
|
+
}
|
|
403
421
|
busy = false;
|
|
404
|
-
|
|
422
|
+
startAgent();
|
|
405
423
|
broadcast(await snapshot());
|
|
406
424
|
}
|
|
425
|
+
/** Run a change that restarts pi, one at a time. */
|
|
426
|
+
async function withRestart(res, change) {
|
|
427
|
+
if (busy || switching)
|
|
428
|
+
return send(res, 409, { error: "busy" });
|
|
429
|
+
switching = true;
|
|
430
|
+
try {
|
|
431
|
+
await change();
|
|
432
|
+
await restartAgent();
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
switching = false;
|
|
436
|
+
}
|
|
437
|
+
return send(res, 200, { ok: true });
|
|
438
|
+
}
|
|
407
439
|
// ---- idle stop
|
|
408
440
|
let closing = false;
|
|
409
441
|
const idleTimer = setInterval(() => {
|
|
@@ -492,7 +524,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
492
524
|
const message = String(body.message ?? "").trim();
|
|
493
525
|
if (!message)
|
|
494
526
|
return send(res, 400, { error: "empty message" });
|
|
495
|
-
|
|
527
|
+
try {
|
|
528
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
532
|
+
if (/no api key|no model|no models available/i.test(err.message))
|
|
533
|
+
return send(res, 409, { error: "nomodel" });
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
496
536
|
return send(res, 200, { ok: true });
|
|
497
537
|
}
|
|
498
538
|
case "POST /api/abort":
|
|
@@ -545,47 +585,51 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
545
585
|
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
546
586
|
}
|
|
547
587
|
case "POST /api/model": {
|
|
548
|
-
if (busy)
|
|
588
|
+
if (busy || switching)
|
|
549
589
|
return send(res, 409, { error: "busy" });
|
|
550
590
|
const body = await readJson(req);
|
|
551
591
|
const spec = `${String(body.provider)}/${String(body.id)}`;
|
|
552
592
|
// Remember the model for the current mode (or both), then restart pi so the extension reads it.
|
|
553
593
|
const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
|
|
554
|
-
|
|
594
|
+
// Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
|
|
595
|
+
const raw = readConfigFile(configPath(env));
|
|
596
|
+
const saved = raw.models && typeof raw.models === "object" ? raw.models : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
|
|
597
|
+
const models = { ...saved, [mode]: spec };
|
|
555
598
|
if (body.both)
|
|
556
599
|
models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
600
|
+
return withRestart(res, async () => {
|
|
601
|
+
saveConfig({ models, model: undefined }, env);
|
|
602
|
+
// The open conversation records the new model, so it keeps it after the restart.
|
|
603
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => { });
|
|
604
|
+
});
|
|
561
605
|
}
|
|
562
606
|
case "POST /api/addons": {
|
|
563
607
|
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
564
|
-
if (busy)
|
|
608
|
+
if (busy || switching)
|
|
565
609
|
return send(res, 409, { error: "busy" });
|
|
566
610
|
const body = await readJson(req);
|
|
567
611
|
if (typeof body.starbuck !== "boolean")
|
|
568
612
|
return send(res, 400, { error: "starbuck must be true or false" });
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
613
|
+
return withRestart(res, () => {
|
|
614
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
615
|
+
});
|
|
572
616
|
}
|
|
573
617
|
case "POST /api/restart":
|
|
574
|
-
if (agent.proc)
|
|
618
|
+
if (agent.proc || switching)
|
|
575
619
|
return send(res, 200, { ok: true });
|
|
576
|
-
|
|
620
|
+
startAgent();
|
|
577
621
|
broadcast(await snapshot());
|
|
578
622
|
return send(res, 200, { ok: true });
|
|
579
623
|
case "POST /api/login": {
|
|
580
|
-
if (busy)
|
|
624
|
+
if (busy || switching)
|
|
581
625
|
return send(res, 409, { error: "busy" });
|
|
582
626
|
const body = await readJson(req);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
627
|
+
return withRestart(res, () => {
|
|
628
|
+
saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
|
|
629
|
+
const current = loadConfig(env);
|
|
630
|
+
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
|
|
631
|
+
saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
632
|
+
});
|
|
589
633
|
}
|
|
590
634
|
case "POST /api/open": {
|
|
591
635
|
const body = await readJson(req);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.11.
|
|
4
|
-
"description": "Sub-Sub: a
|
|
3
|
+
"version": "0.11.2",
|
|
4
|
+
"description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Tiago Jacinto",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"assets",
|
|
50
50
|
"bin",
|
|
51
51
|
"dist",
|
|
52
|
-
"docs",
|
|
53
52
|
"extensions",
|
|
54
53
|
"prompts",
|
|
55
54
|
"roles",
|
package/prompts/lit-note.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: "Literature note for one item"
|
|
3
3
|
argument-hint: "<citekey or title words>"
|
|
4
4
|
---
|
|
5
|
-
Make a literature note for $ARGUMENTS in
|
|
5
|
+
Make a literature note for $ARGUMENTS in Literature/ in the Sub-Sub folder, following the template in the shared rules. Read the full text if it is indexed. Then offer to link it in Zotero.
|
package/src/bench.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* subsub-bench: the same tasks for several models, against the real library,
|
|
3
|
-
* with no changes applied. See docs/
|
|
3
|
+
* with no changes applied. See docs/model-test.md.
|
|
4
4
|
*
|
|
5
5
|
* subsub-bench prepare [--seed 7] pick the tasks (read-only)
|
|
6
6
|
* subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
|
|
@@ -79,7 +79,7 @@ export function promptFor(task: string, t: Tasks, code: string, out: string): st
|
|
|
79
79
|
case "lit_note":
|
|
80
80
|
return t.lit_note
|
|
81
81
|
? `Make a literature note for ${t.lit_note.citekey}, following the literature-note format in the shared rules. Read the full text. ` +
|
|
82
|
-
`This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of
|
|
82
|
+
`This is a model test: save the note as ${join(out, `${t.lit_note.citekey}.md`)} instead of Literature/ in the Sub-Sub folder, and do not attach anything to Zotero.`
|
|
83
83
|
: undefined;
|
|
84
84
|
case "synthesis":
|
|
85
85
|
return t.synthesis
|
package/src/bridge.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
9
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { refreshedArgs } from "./config.ts";
|
|
10
11
|
|
|
11
12
|
export interface ServerSpec {
|
|
12
13
|
name: string; // tool prefix: "zotero" or "scholar"
|
|
@@ -85,12 +86,14 @@ export class Bridge {
|
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
} catch (err) {
|
|
88
|
-
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
89
89
|
try {
|
|
90
90
|
await client.close();
|
|
91
91
|
} catch {
|
|
92
92
|
/* ignore */
|
|
93
93
|
}
|
|
94
|
+
const retry = refreshedArgs(spec.args, stderr);
|
|
95
|
+
if (retry) return this.startOne({ ...spec, args: retry }, timeoutMs);
|
|
96
|
+
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|