@tiagojct/subsub 0.11.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 -179
- package/dist/bench.js +2 -2
- package/dist/cli.js +34 -1
- package/dist/config.js +3 -1
- package/dist/doctor.js +2 -1
- package/dist/init.js +19 -4
- package/dist/paths.js +20 -0
- package/dist/prompt.js +3 -0
- package/dist/shortcut.js +2 -2
- package/dist/subsub.js +32 -6
- package/dist/web.js +56 -25
- package/package.json +2 -3
- package/prompts/lit-note.md +1 -1
- package/src/bench.ts +2 -2
- package/src/cli.ts +34 -1
- package/src/config.ts +3 -1
- package/src/doctor.ts +2 -1
- package/src/init.ts +18 -4
- package/src/paths.ts +21 -0
- package/src/prompt.ts +6 -0
- package/src/shortcut.ts +2 -2
- package/src/subsub.ts +32 -7
- package/src/web.ts +53 -24
- package/web/app.js +28 -1
- package/web/i18n.js +7 -1
- package/docs/Subsub.md +0 -140
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,19 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
243
245
|
registerTools(b);
|
|
244
246
|
}
|
|
245
247
|
let restored;
|
|
248
|
+
let modelChosen = false; // the session already has a model: a resumed conversation keeps it
|
|
246
249
|
for (const e of ctx.sessionManager.getBranch()) {
|
|
247
250
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
248
251
|
if (m === "librarian" || m === "researcher")
|
|
249
252
|
restored = m;
|
|
253
|
+
if (e.type === "model_change")
|
|
254
|
+
modelChosen = true;
|
|
250
255
|
}
|
|
251
256
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
252
257
|
if (lookOn(ctx))
|
|
253
258
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
254
259
|
applyTheme(ctx);
|
|
255
|
-
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
|
|
260
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
256
261
|
if (lookOn(ctx)) {
|
|
257
262
|
setHeader(ctx);
|
|
258
263
|
void refreshLibrary();
|
|
@@ -271,10 +276,28 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
271
276
|
bridge = undefined;
|
|
272
277
|
await b?.close();
|
|
273
278
|
});
|
|
279
|
+
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
280
|
+
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
281
|
+
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
282
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
283
|
+
let m;
|
|
284
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
285
|
+
const x = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
286
|
+
if (x === "librarian" || x === "researcher")
|
|
287
|
+
m = x;
|
|
288
|
+
}
|
|
289
|
+
if (m && m !== mode)
|
|
290
|
+
await applyMode(ctx, m, false, false);
|
|
291
|
+
});
|
|
274
292
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
275
293
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
276
294
|
// The input event comes before pi expands a prompt template.
|
|
277
295
|
pi.on("input", async (event, ctx) => {
|
|
296
|
+
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
297
|
+
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
298
|
+
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
299
|
+
return { action: "handled" };
|
|
300
|
+
}
|
|
278
301
|
const why = promptBlocked(event.text, profile);
|
|
279
302
|
if (!why)
|
|
280
303
|
return { action: "continue" };
|
|
@@ -303,6 +326,9 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
303
326
|
return { block: true, reason: why };
|
|
304
327
|
}
|
|
305
328
|
}
|
|
329
|
+
if (["read", "grep", "find", "ls"].includes(name) && typeof input.path === "string" && isSecret(normalizeToolPath(input.path, ctx.cwd), name === "grep")) {
|
|
330
|
+
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
331
|
+
}
|
|
306
332
|
if (name.startsWith("verify_"))
|
|
307
333
|
return verifyGate(name, input, ctx);
|
|
308
334
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
@@ -453,7 +479,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
453
479
|
catch (err) {
|
|
454
480
|
saved = ` Not saved: ${err.message}`;
|
|
455
481
|
}
|
|
456
|
-
await applyMode(ctx, mode, false);
|
|
482
|
+
await applyMode(ctx, mode, false, false);
|
|
457
483
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
458
484
|
},
|
|
459
485
|
});
|
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,12 +402,11 @@ 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())
|
|
@@ -401,9 +415,23 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
401
415
|
await agent.stop();
|
|
402
416
|
restarting = false;
|
|
403
417
|
busy = false;
|
|
404
|
-
|
|
418
|
+
startAgent();
|
|
405
419
|
broadcast(await snapshot());
|
|
406
420
|
}
|
|
421
|
+
/** Run a change that restarts pi, one at a time. */
|
|
422
|
+
async function withRestart(res, change) {
|
|
423
|
+
if (busy || switching)
|
|
424
|
+
return send(res, 409, { error: "busy" });
|
|
425
|
+
switching = true;
|
|
426
|
+
try {
|
|
427
|
+
await change();
|
|
428
|
+
await restartAgent();
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
switching = false;
|
|
432
|
+
}
|
|
433
|
+
return send(res, 200, { ok: true });
|
|
434
|
+
}
|
|
407
435
|
// ---- idle stop
|
|
408
436
|
let closing = false;
|
|
409
437
|
const idleTimer = setInterval(() => {
|
|
@@ -545,47 +573,50 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
545
573
|
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
546
574
|
}
|
|
547
575
|
case "POST /api/model": {
|
|
548
|
-
if (busy)
|
|
576
|
+
if (busy || switching)
|
|
549
577
|
return send(res, 409, { error: "busy" });
|
|
550
578
|
const body = await readJson(req);
|
|
551
579
|
const spec = `${String(body.provider)}/${String(body.id)}`;
|
|
552
580
|
// Remember the model for the current mode (or both), then restart pi so the extension reads it.
|
|
553
581
|
const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
|
|
554
|
-
|
|
582
|
+
// Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
|
|
583
|
+
const raw = readConfigFile(configPath(env));
|
|
584
|
+
const saved = raw.models && typeof raw.models === "object" ? raw.models : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
|
|
585
|
+
const models = { ...saved, [mode]: spec };
|
|
555
586
|
if (body.both)
|
|
556
587
|
models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
557
588
|
saveConfig({ models, model: undefined }, env);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
589
|
+
return withRestart(res, async () => {
|
|
590
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
|
|
591
|
+
});
|
|
561
592
|
}
|
|
562
593
|
case "POST /api/addons": {
|
|
563
594
|
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
564
|
-
if (busy)
|
|
595
|
+
if (busy || switching)
|
|
565
596
|
return send(res, 409, { error: "busy" });
|
|
566
597
|
const body = await readJson(req);
|
|
567
598
|
if (typeof body.starbuck !== "boolean")
|
|
568
599
|
return send(res, 400, { error: "starbuck must be true or false" });
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
600
|
+
return withRestart(res, () => {
|
|
601
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
602
|
+
});
|
|
572
603
|
}
|
|
573
604
|
case "POST /api/restart":
|
|
574
|
-
if (agent.proc)
|
|
605
|
+
if (agent.proc || switching)
|
|
575
606
|
return send(res, 200, { ok: true });
|
|
576
|
-
|
|
607
|
+
startAgent();
|
|
577
608
|
broadcast(await snapshot());
|
|
578
609
|
return send(res, 200, { ok: true });
|
|
579
610
|
case "POST /api/login": {
|
|
580
|
-
if (busy)
|
|
611
|
+
if (busy || switching)
|
|
581
612
|
return send(res, 409, { error: "busy" });
|
|
582
613
|
const body = await readJson(req);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
614
|
+
return withRestart(res, () => {
|
|
615
|
+
saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
|
|
616
|
+
const current = loadConfig(env);
|
|
617
|
+
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0)
|
|
618
|
+
saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
619
|
+
});
|
|
589
620
|
}
|
|
590
621
|
case "POST /api/open": {
|
|
591
622
|
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.1",
|
|
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/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
|
@@ -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 {
|
package/src/doctor.ts
CHANGED
|
@@ -123,7 +123,8 @@ export function runChecks(
|
|
|
123
123
|
);
|
|
124
124
|
const v = st.vocabulary ?? {};
|
|
125
125
|
if (v.loaded === false || !v.tags) {
|
|
126
|
-
|
|
126
|
+
const path = v.path && v.path !== "None" ? v.path : undefined; // the server writes Python's None when unset
|
|
127
|
+
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
128
|
} else {
|
|
128
129
|
const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
|
|
129
130
|
out.push({
|
package/src/init.ts
CHANGED
|
@@ -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.");
|
|
@@ -207,7 +213,7 @@ export async function runInit(
|
|
|
207
213
|
);
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
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.");
|
|
211
217
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
212
218
|
|
|
213
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.");
|
|
@@ -364,3 +370,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
|
|
|
364
370
|
(io as { close?: () => void }).close?.();
|
|
365
371
|
}
|
|
366
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/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
|
@@ -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,14 +276,16 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
274
276
|
registerTools(b);
|
|
275
277
|
}
|
|
276
278
|
let restored: Mode | undefined;
|
|
277
|
-
|
|
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 } }>) {
|
|
278
281
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
279
282
|
if (m === "librarian" || m === "researcher") restored = m;
|
|
283
|
+
if (e.type === "model_change") modelChosen = true;
|
|
280
284
|
}
|
|
281
285
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
282
286
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
283
287
|
applyTheme(ctx);
|
|
284
|
-
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
|
|
288
|
+
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
285
289
|
if (lookOn(ctx)) {
|
|
286
290
|
setHeader(ctx);
|
|
287
291
|
void refreshLibrary();
|
|
@@ -300,11 +304,29 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
300
304
|
await b?.close();
|
|
301
305
|
});
|
|
302
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
|
+
|
|
303
320
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
304
321
|
|
|
305
322
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
306
323
|
// The input event comes before pi expands a prompt template.
|
|
307
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
|
+
}
|
|
308
330
|
const why = promptBlocked(event.text, profile);
|
|
309
331
|
if (!why) return { action: "continue" };
|
|
310
332
|
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
@@ -333,6 +355,9 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
333
355
|
return { block: true, reason: why };
|
|
334
356
|
}
|
|
335
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
|
+
}
|
|
336
361
|
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
337
362
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
338
363
|
if (!kind) return undefined;
|
|
@@ -476,7 +501,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
476
501
|
} catch (err) {
|
|
477
502
|
saved = ` Not saved: ${(err as Error).message}`;
|
|
478
503
|
}
|
|
479
|
-
await applyMode(ctx, mode, false);
|
|
504
|
+
await applyMode(ctx, mode, false, false);
|
|
480
505
|
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
481
506
|
},
|
|
482
507
|
});
|