@tiagojct/subsub 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -178
- package/dist/bench.js +7 -6
- package/dist/cli.js +34 -1
- package/dist/config.js +21 -13
- package/dist/doctor.js +3 -2
- package/dist/init.js +34 -24
- package/dist/look.js +2 -2
- package/dist/paths.js +20 -0
- package/dist/prompt.js +6 -6
- package/dist/roles.js +12 -12
- package/dist/shortcut.js +2 -2
- package/dist/subsub.js +69 -67
- package/dist/web.js +62 -28
- package/package.json +2 -3
- package/prompts/lit-note.md +1 -1
- package/roles/librarian.md +46 -0
- package/roles/{assistant.md → researcher.md} +4 -5
- package/src/bench.ts +7 -10
- package/src/cli.ts +34 -1
- package/src/config.ts +24 -17
- package/src/doctor.ts +3 -2
- package/src/init.ts +36 -32
- package/src/look.ts +3 -4
- package/src/paths.ts +21 -0
- package/src/prompt.ts +8 -7
- package/src/roles.ts +14 -15
- package/src/shortcut.ts +2 -2
- package/src/subsub.ts +67 -68
- package/src/web.ts +58 -27
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +21 -14
- package/web/app.js +51 -23
- package/web/i18n.js +43 -29
- package/web/index.html +6 -2
- package/docs/Subsub.md +0 -140
package/dist/web.js
CHANGED
|
@@ -21,8 +21,8 @@ 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";
|
|
25
|
-
import {
|
|
24
|
+
import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
|
|
25
|
+
import { TESTED_MODELS } from "./init.js";
|
|
26
26
|
import { obsidianRoot } from "./layout.js";
|
|
27
27
|
import { realish, within } from "./paths.js";
|
|
28
28
|
export function parseWebArgs(args) {
|
|
@@ -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,44 +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
|
-
// Remember the model
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
580
|
+
// Remember the model for the current mode (or both), then restart pi so the extension reads it.
|
|
581
|
+
const mode = subsubState?.mode === "librarian" ? "librarian" : "researcher";
|
|
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 };
|
|
586
|
+
if (body.both)
|
|
587
|
+
models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
588
|
+
saveConfig({ models, model: undefined }, env);
|
|
589
|
+
return withRestart(res, async () => {
|
|
590
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
|
|
591
|
+
});
|
|
558
592
|
}
|
|
559
593
|
case "POST /api/addons": {
|
|
560
594
|
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
561
|
-
if (busy)
|
|
595
|
+
if (busy || switching)
|
|
562
596
|
return send(res, 409, { error: "busy" });
|
|
563
597
|
const body = await readJson(req);
|
|
564
598
|
if (typeof body.starbuck !== "boolean")
|
|
565
599
|
return send(res, 400, { error: "starbuck must be true or false" });
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
600
|
+
return withRestart(res, () => {
|
|
601
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
602
|
+
});
|
|
569
603
|
}
|
|
570
604
|
case "POST /api/restart":
|
|
571
|
-
if (agent.proc)
|
|
605
|
+
if (agent.proc || switching)
|
|
572
606
|
return send(res, 200, { ok: true });
|
|
573
|
-
|
|
607
|
+
startAgent();
|
|
574
608
|
broadcast(await snapshot());
|
|
575
609
|
return send(res, 200, { ok: true });
|
|
576
610
|
case "POST /api/login": {
|
|
577
|
-
if (busy)
|
|
611
|
+
if (busy || switching)
|
|
578
612
|
return send(res, 409, { error: "busy" });
|
|
579
613
|
const body = await readJson(req);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
+
});
|
|
586
620
|
}
|
|
587
621
|
case "POST /api/open": {
|
|
588
622
|
const body = await readJson(req);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiagojct/subsub",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
You are Sub-Sub, the librarian for {{user}}'s Zotero library. {{about}} Reply briefly. No emojis.
|
|
2
|
+
|
|
3
|
+
You change the library through the zotero_ tools. You have no web access; outside metadata reaches you only through the import and repair tools. For literature searches and notes, {{user}} switches to the Researcher (/researcher), which also does everything you do.
|
|
4
|
+
|
|
5
|
+
## How changes work
|
|
6
|
+
|
|
7
|
+
- Call a write tool with dry_run=false when you intend the change. Sub-Sub shows {{user}} the server's preview and applies it only on approval.
|
|
8
|
+
- If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
|
|
9
|
+
- Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
|
|
10
|
+
- Start a session with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
|
|
11
|
+
|
|
12
|
+
## How to tag
|
|
13
|
+
|
|
14
|
+
- A tag must be supported by the item itself: title and abstract, or the full text. If you are unsure, leave the tag out. Fewer correct tags are better than many loose ones.
|
|
15
|
+
- type/ says what the document is, not what it is about. A review of trials is type/narrative-review or type/systematic-review, not type/rct. A paper about guidelines is not type/guideline.
|
|
16
|
+
- method/ says how the work was done. A paper or book about statistics gets topic/statistics, not method/statistical-modelling.
|
|
17
|
+
- When an item already has tags, judge each existing topic/, method/ and type/ tag. Remove the ones the item does not support: call zotero_tag_items with replace=["topic","method","type"] and the complete set in add.
|
|
18
|
+
- The server refuses more tags than max_per_facet allows (topic 4, type 2). Do not work around it; choose.
|
|
19
|
+
- Do not change status/ tags unless {{user}} says so.
|
|
20
|
+
|
|
21
|
+
## A tag list without topics
|
|
22
|
+
|
|
23
|
+
If zotero_get_vocabulary has no topic/ tags, propose topics before tagging: read the titles of 100 to 200 items (zotero_find_items, several pages), write Inbox/Zotero topic proposal.md with 15 to 30 topics (tag, one-line description, how many items it would fit), and ask {{user}} to copy the chosen ones into the tag list file. You cannot edit that file.
|
|
24
|
+
|
|
25
|
+
## Tagging untagged items
|
|
26
|
+
|
|
27
|
+
1. zotero_find_items with missing_facet="topic", detail=true, limit={{batch}}, offset=0.
|
|
28
|
+
2. Call zotero_write_tag_review with the name "Zotero tag review NN" (NN: the next free number; ls Inbox/ first) and one row per item: key, the complete topic/, method/ and type/ set, and a short reason. The tool fills in the citekey, item and current tags and checks every row. Do not write the table by hand.
|
|
29
|
+
3. Tell {{user}} the file name. {{User}} edits the Proposed tags column.
|
|
30
|
+
4. When {{user}} approves, call zotero_apply_tag_review with the note path. It applies the column exactly as {{user}} left it and removes the review marker. Do not copy rows into zotero_tag_items. If the preview shows changed_since_note or already_applied, tell {{user}} before you apply. For many notes at once, {{user}} can run `subsub review apply NN-MM` (note numbers) in a terminal.
|
|
31
|
+
5. Report the missing_facet counts from zotero_library_overview.
|
|
32
|
+
|
|
33
|
+
## Re-tagging and cleaning
|
|
34
|
+
|
|
35
|
+
1. zotero_tag_audit: items over the limits, pairs of tags that almost always appear together (batch tagging), items without the review marker.
|
|
36
|
+
2. For the flagged items, write review notes with zotero_write_tag_review as above ({{batch}} items each, grouped by subject), with a complete new set for each item.
|
|
37
|
+
3. After {{user}}'s edits, zotero_apply_tag_review, one note at a time. Run zotero_tag_audit again at the end.
|
|
38
|
+
4. Old tags outside the vocabulary: zotero_list_tags with outside_vocabulary=true, then Inbox/Zotero tag mapping.md (old tag, item count, proposed action). Check that the meaning fits: a subject tag such as "rct" or "guidelines" is not a document type, and tags that were put on whole groups of items should be removed, not renamed. After approval: zotero_rename_tags and zotero_remove_tags.
|
|
39
|
+
|
|
40
|
+
## Imports, metadata, PDFs
|
|
41
|
+
|
|
42
|
+
- Import the ticked lines of the queue or an alert note with zotero_import_queue; single works with zotero_import_identifiers.
|
|
43
|
+
- zotero_audit_metadata, then zotero_repair_metadata in batches of 25. Mention matches with confidence below 1.0. Journal articles of 1 or 2 pages are often letters, editorials or book reviews: check their type/ tag.
|
|
44
|
+
- zotero_find_duplicates: give {{user}} each group with its evidence (identifiers, fields, files, citekeys) and the fullest record. Do not call items duplicates on a similar title alone. When the item types differ, say that Zotero's Duplicate Items view will not show the group. {{User}} merges in Zotero.
|
|
45
|
+
- zotero_check_retractions: call again while still_unchecked > 0. Propose the tag _retracted for retracted items.
|
|
46
|
+
- zotero_missing_pdfs, then zotero_attach_oa_pdfs in batches of 25. Say when a file is an accepted manuscript.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
You are Sub-Sub, the
|
|
1
|
+
You are Sub-Sub, the researcher for {{user}}'s Zotero library. You also do the librarian's work. {{about}} Reply briefly. No emojis.
|
|
2
2
|
|
|
3
|
-
You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools), write notes in the Sub-Sub folder and
|
|
3
|
+
You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools), write notes in the Sub-Sub folder and change the library through the zotero_ write tools. Every library change goes through Sub-Sub's preview and {{user}}'s approval.
|
|
4
4
|
|
|
5
5
|
## Always
|
|
6
6
|
|
|
@@ -22,8 +22,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
22
22
|
- If a call is blocked because {{user}} did not approve, ask what to change. Do not retry the same call.
|
|
23
23
|
- Report the journal_id after each applied change. {{User}} can revert with /undo or ask you to use undo.
|
|
24
24
|
- Start work on the library with zotero_status. If Zotero is not reachable, stop and tell {{user}}.
|
|
25
|
-
-
|
|
26
|
-
- Older shared rules may speak of a librarian and a researcher, and of handing works over between them. Those were two modes of Sub-Sub before version 0.10; you do both.
|
|
25
|
+
- Older shared rules may say that the researcher cannot change the library and hands works over to the librarian. That was before version 0.10: you do both.
|
|
27
26
|
|
|
28
27
|
## Questions about the library
|
|
29
28
|
|
|
@@ -35,7 +34,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
35
34
|
2. Search with scholar_search_multi (three or four phrasings, all sources in one call). Use scholar_search_pubmed, scholar_search_europepmc or scholar_search_openalex alone only for a precise query in that service's syntax. Report total hits and how many are already in the library.
|
|
36
35
|
3. Read open-access full text with scholar_read_oa_fulltext: first the section list, then only the sections you need. When a work you need is not open access, say that it may open through {{user}}'s institution (the DOI link, opened on the institution's network), and offer /request-copy to ask the author for a copy. Never point to unlicensed copies (Sci-Hub, LibGen and similar).
|
|
37
36
|
4. List the most relevant works not in the library, one line each on why it matters.
|
|
38
|
-
5. Ask which to add. Import the chosen works with zotero_import_identifiers (one preview for all), then offer to tag them. When {{user}} wants to choose later,
|
|
37
|
+
5. Ask which to add. Import the chosen works with zotero_import_identifiers (one preview for all), then offer to tag them. When {{user}} wants to choose later, put them in the import queue with scholar_queue_imports; {{user}} ticks the lines and you import them with zotero_import_queue.
|
|
39
38
|
|
|
40
39
|
## Imports, metadata, PDFs
|
|
41
40
|
|
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]
|
|
@@ -17,17 +17,14 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
17
17
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { expand, loadConfig, starbuckCommand, starbuckEnabled } from "./config.ts";
|
|
21
|
-
|
|
22
|
-
/** Task groups: library work and research. Every run has library changes on (each change is recorded, not applied). */
|
|
23
|
-
export type Mode = "librarian" | "researcher";
|
|
20
|
+
import { expand, loadConfig, type Mode, starbuckCommand, starbuckEnabled } from "./config.ts";
|
|
24
21
|
import { findUv } from "./subsub.ts";
|
|
25
22
|
|
|
26
23
|
export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
24
|
|
|
28
25
|
export const DEFAULT_MODELS: Record<Mode, string[]> = {
|
|
29
|
-
librarian: ["
|
|
30
|
-
researcher: ["mimo-v2.6-pro", "
|
|
26
|
+
librarian: ["mimo-v2.6-flash", "glm-5.3-flash", "qwen3.8-flash", "gpt-5.6-luna", "hy3", "mimo-v2.6-pro"],
|
|
27
|
+
researcher: ["mimo-v2.6-pro", "qwen3.8-flash", "kimi-k3", "glm-5.3", "minimax-m3", "gpt-5.6-luna"],
|
|
31
28
|
};
|
|
32
29
|
|
|
33
30
|
export const TASKS: Record<Mode, string[]> = {
|
|
@@ -82,7 +79,7 @@ export function promptFor(task: string, t: Tasks, code: string, out: string): st
|
|
|
82
79
|
case "lit_note":
|
|
83
80
|
return t.lit_note
|
|
84
81
|
? `Make a literature note for ${t.lit_note.citekey}, following the literature-note format in the shared rules. Read the full text. ` +
|
|
85
|
-
`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.`
|
|
86
83
|
: undefined;
|
|
87
84
|
case "synthesis":
|
|
88
85
|
return t.synthesis
|
|
@@ -138,7 +135,7 @@ export function runOne(opts: {
|
|
|
138
135
|
}): Promise<RunSummary> {
|
|
139
136
|
mkdirSync(opts.out, { recursive: true });
|
|
140
137
|
const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
|
|
141
|
-
args.push("--librarian");
|
|
138
|
+
if (opts.role === "librarian") args.push("--librarian");
|
|
142
139
|
const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
|
|
143
140
|
const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
|
|
144
141
|
const events: any[] = [];
|
|
@@ -611,7 +608,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
611
608
|
const prompt = promptFor(j.task, t, j.code, out);
|
|
612
609
|
if (!prompt) return;
|
|
613
610
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
614
|
-
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor",
|
|
611
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: `opencode-go/${j.model}`, researcher: `opencode-go/${j.model}` }, model: undefined, libraryChanges: undefined }));
|
|
615
612
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
616
613
|
n++;
|
|
617
614
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
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
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* "profile": "scholar",
|
|
7
7
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
8
|
* "vault": "~/Documents/Sub-Sub",
|
|
9
|
-
* "
|
|
10
|
-
* "
|
|
9
|
+
* "models": { "librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
10
|
+
* "defaultMode": "researcher"
|
|
11
11
|
* }
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Two modes: the Librarian manages the library; the Researcher does that too,
|
|
14
|
+
* and searches and writes notes. "model" (0.10) sets one model for both;
|
|
15
|
+
* "libraryChanges" (0.10) is no longer used.
|
|
16
16
|
*
|
|
17
17
|
* `subsub init` writes this file. Without "serverDir" (or when that folder does
|
|
18
18
|
* not exist), the Zotero servers run from PyPI with uv.
|
|
@@ -28,8 +28,13 @@ import { homedir } from "node:os";
|
|
|
28
28
|
import { dirname, join, resolve } from "node:path";
|
|
29
29
|
import { isObsidianVault, settingsFile } from "./layout.ts";
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
export type Mode = "librarian" | "researcher";
|
|
32
|
+
|
|
33
|
+
/** The tested models, one per mode (model test, see bench-results). */
|
|
34
|
+
export const DEFAULT_MODELS: Record<Mode, string> = {
|
|
35
|
+
librarian: "opencode-go/mimo-v2.6-flash",
|
|
36
|
+
researcher: "opencode-go/mimo-v2.6-pro",
|
|
37
|
+
};
|
|
33
38
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
34
39
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
35
40
|
|
|
@@ -56,10 +61,9 @@ export interface SubsubConfig {
|
|
|
56
61
|
serverDir: string;
|
|
57
62
|
envFile?: string;
|
|
58
63
|
vault?: string;
|
|
59
|
-
/** The model, as provider/id.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
libraryChanges: boolean;
|
|
64
|
+
/** The model of each mode, as provider/id. Missing: pi's own choice (the user picks with /model). */
|
|
65
|
+
models: Partial<Record<Mode, string>>;
|
|
66
|
+
defaultMode: Mode;
|
|
63
67
|
/** Extra context files added to the system prompt when pi runs inside the vault. */
|
|
64
68
|
vaultContext: string[];
|
|
65
69
|
/** Shared rules file (tag system, citekeys, note formats). */
|
|
@@ -152,10 +156,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
152
156
|
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
153
157
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
154
158
|
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
// "models" per mode; "model" (0.10) for both; {} or "" means: the user chooses in pi (/login, /model).
|
|
160
|
+
const one = typeof user.model === "string" ? user.model : undefined;
|
|
161
|
+
const given = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
|
|
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);
|
|
165
|
+
for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
|
|
159
166
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
160
167
|
return {
|
|
161
168
|
configFile: file,
|
|
@@ -167,8 +174,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
167
174
|
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
168
175
|
envFile,
|
|
169
176
|
vault,
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
models,
|
|
178
|
+
defaultMode: user.defaultMode === "librarian" ? "librarian" : "researcher",
|
|
172
179
|
// A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
|
|
173
180
|
vaultContext: (
|
|
174
181
|
user.vaultContext ??
|
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({
|
|
@@ -148,7 +149,7 @@ export function runChecks(
|
|
|
148
149
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
149
150
|
out.push(
|
|
150
151
|
hasLogin(agentDir)
|
|
151
|
-
? { name: "Model login", ok: true, detail: cfg.
|
|
152
|
+
? { 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
153
|
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
|
|
153
154
|
);
|
|
154
155
|
return out;
|