@tiagojct/subsub 0.15.0 → 0.17.0
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/CHANGELOG.md +22 -0
- package/README.md +15 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +0 -6
- package/dist/doctor.js +1 -2
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/prompt.js +4 -6
- package/dist/read.js +88 -0
- package/dist/roles.js +4 -10
- package/dist/statement.js +35 -0
- package/dist/subsub.js +102 -47
- package/dist/web.js +122 -9
- package/package.json +1 -1
- package/prompts/lit.md +6 -2
- package/roles/researcher.md +3 -1
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +1 -11
- package/src/doctor.ts +1 -2
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/prompt.ts +6 -8
- package/src/read.ts +107 -0
- package/src/roles.ts +5 -12
- package/src/statement.ts +45 -0
- package/src/subsub.ts +97 -48
- package/src/usage.ts +1 -1
- package/src/web.ts +114 -9
- package/templates/Zotero agent.md +1 -1
- package/web/app.css +61 -2
- package/web/app.js +215 -18
- package/web/i18n.js +56 -24
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/dist/subsub.js
CHANGED
|
@@ -6,22 +6,21 @@
|
|
|
6
6
|
* and writes notes. Each mode can have its own model.
|
|
7
7
|
* - An approval gate: every library change is previewed by the server (dry run)
|
|
8
8
|
* and shown to the user; nothing is applied without a yes.
|
|
9
|
-
* -
|
|
10
|
-
* tools it offers; /profile changes it.
|
|
11
|
-
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
9
|
+
* - Commands: /librarian, /researcher, /read, /subsub, /history, /undo.
|
|
12
10
|
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
13
11
|
* ("verify") for reference checks.
|
|
14
12
|
*
|
|
15
13
|
* The Python servers start in session_start (not in the factory) and stop in
|
|
16
14
|
* session_shutdown. Tools are registered once and use the current bridge.
|
|
17
15
|
*/
|
|
18
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
17
|
import { dirname, join } from "node:path";
|
|
20
18
|
import { fileURLToPath } from "node:url";
|
|
21
19
|
import { Bridge } from "./bridge.js";
|
|
22
|
-
import { expand, findUv, FREE_MODELS,
|
|
23
|
-
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
20
|
+
import { expand, findUv, FREE_MODELS, loadConfig, parseEnvFile, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
|
|
24
21
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
22
|
+
import { aiStatement } from "./statement.js";
|
|
23
|
+
import { PURPOSES, readArgs, readInstruction } from "./read.js";
|
|
25
24
|
import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.js";
|
|
26
25
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
27
26
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
|
|
@@ -113,8 +112,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
113
112
|
pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
|
|
114
113
|
let mode = cfg.defaultMode;
|
|
115
114
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
116
|
-
const toolOpts = () => ({
|
|
117
|
-
let profile = cfg.profile;
|
|
115
|
+
const toolOpts = () => ({ bench: benchRun });
|
|
118
116
|
const user = who(cfg);
|
|
119
117
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
120
118
|
const standalone = process.env.SUBSUB_CLI === "1";
|
|
@@ -141,8 +139,6 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
141
139
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
142
140
|
const state = {
|
|
143
141
|
mode,
|
|
144
|
-
profile,
|
|
145
|
-
profileLabel: profileSpec(profile).label,
|
|
146
142
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
147
143
|
down,
|
|
148
144
|
library,
|
|
@@ -204,7 +200,6 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
204
200
|
return headerLines({
|
|
205
201
|
version,
|
|
206
202
|
mode,
|
|
207
|
-
profile: profileSpec(profile).label,
|
|
208
203
|
model: modelId,
|
|
209
204
|
library,
|
|
210
205
|
quote,
|
|
@@ -225,7 +220,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
225
220
|
function status(ctx) {
|
|
226
221
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
227
222
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
228
|
-
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${
|
|
223
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
229
224
|
if (standalone)
|
|
230
225
|
ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
231
226
|
headerTui?.requestRender();
|
|
@@ -281,7 +276,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
281
276
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
282
277
|
applyTheme(ctx);
|
|
283
278
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
284
|
-
logUsage(cfg.usageLog, { ev: "session", mode,
|
|
279
|
+
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
285
280
|
if (lookOn(ctx)) {
|
|
286
281
|
setHeader(ctx);
|
|
287
282
|
void refreshLibrary();
|
|
@@ -306,6 +301,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
306
301
|
});
|
|
307
302
|
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
308
303
|
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
304
|
+
const researcherOnly = (cmd) => L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`);
|
|
309
305
|
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
310
306
|
pi.on("session_tree", async (_event, ctx) => {
|
|
311
307
|
let m;
|
|
@@ -351,8 +347,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
351
347
|
pi.sendMessage({ customType: "subsub-resume", content: "The model provider's per-minute limit was reached and has now passed. Continue the task where you stopped; do not repeat finished steps.", display: false }, { triggerTurn: true });
|
|
352
348
|
}, wait * 1000);
|
|
353
349
|
});
|
|
354
|
-
//
|
|
355
|
-
// The input event comes before pi expands a prompt template.
|
|
350
|
+
// Research prompts in the Librarian. The input event comes before pi expands a prompt template.
|
|
356
351
|
pi.on("input", async (event, ctx) => {
|
|
357
352
|
if (resumeTimer)
|
|
358
353
|
clearTimeout(resumeTimer);
|
|
@@ -363,21 +358,22 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
363
358
|
if (cmd !== "subsub-refresh")
|
|
364
359
|
logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
365
360
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
366
|
-
ctx.ui.notify(
|
|
361
|
+
ctx.ui.notify(researcherOnly(cmd), "warning");
|
|
367
362
|
return { action: "handled" };
|
|
368
363
|
}
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
364
|
+
// shortcut: a notice for the habit of 0.16 and earlier; drop it a few versions on.
|
|
365
|
+
if (cmd === "profile") {
|
|
366
|
+
ctx.ui.notify(L("Sub-Sub no longer has profiles: every command is in the Researcher. To read a paper with guidance, type /read.", "O Sub-Sub já não tem perfis: todos os comandos estão no Investigador. Para ler um artigo com orientação, escreva /read."), "info");
|
|
367
|
+
return { action: "handled" };
|
|
368
|
+
}
|
|
369
|
+
return { action: "continue" };
|
|
374
370
|
});
|
|
375
371
|
pi.on("agent_start", async () => {
|
|
376
372
|
turnStart = Date.now();
|
|
377
373
|
turnTools = 0;
|
|
378
374
|
});
|
|
379
375
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
380
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode,
|
|
376
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
|
|
381
377
|
}));
|
|
382
378
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
383
379
|
const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
|
|
@@ -612,34 +608,10 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
612
608
|
ctx.ui.notify(L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
|
|
613
609
|
},
|
|
614
610
|
});
|
|
615
|
-
pi.registerCommand("profile", {
|
|
616
|
-
description: "Sub-Sub: show or change the profile (reader, scholar, author, editor)",
|
|
617
|
-
handler: async (args, ctx) => {
|
|
618
|
-
const want = args.trim().toLowerCase();
|
|
619
|
-
if (!want) {
|
|
620
|
-
ctx.ui.notify(`Profile: ${profileSpec(profile).label}. To change it, type /profile <name>.\n${profileList(profile)}`, "info");
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
if (!isProfile(want)) {
|
|
624
|
-
ctx.ui.notify(`Unknown profile "${want}". Choose one of:\n${profileList(profile)}`, "warning");
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
profile = want;
|
|
628
|
-
let saved = "";
|
|
629
|
-
try {
|
|
630
|
-
saved = ` Saved in ${saveConfig({ profile })}.`;
|
|
631
|
-
}
|
|
632
|
-
catch (err) {
|
|
633
|
-
saved = ` Not saved: ${err.message}`;
|
|
634
|
-
}
|
|
635
|
-
await applyMode(ctx, mode, false, false);
|
|
636
|
-
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
637
|
-
},
|
|
638
|
-
});
|
|
639
611
|
pi.registerCommand("subsub", {
|
|
640
612
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
641
613
|
handler: async (_args, ctx) => {
|
|
642
|
-
const lines = [`Mode: ${mode}.
|
|
614
|
+
const lines = [`Mode: ${mode}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
643
615
|
if (!bridge) {
|
|
644
616
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
645
617
|
return;
|
|
@@ -673,6 +645,89 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
673
645
|
await refreshLibrary();
|
|
674
646
|
},
|
|
675
647
|
});
|
|
648
|
+
// The web view's library search: the result goes to the page through a status, like the library counts.
|
|
649
|
+
pi.registerCommand("subsub-find", {
|
|
650
|
+
description: "Sub-Sub: search the library for the web view",
|
|
651
|
+
handler: async (args, ctx) => {
|
|
652
|
+
if (ctx.mode !== "rpc")
|
|
653
|
+
return;
|
|
654
|
+
const q = String(args ?? "").trim();
|
|
655
|
+
let out;
|
|
656
|
+
try {
|
|
657
|
+
const r = await current().call("zotero_find_items", { query: q, limit: 25 });
|
|
658
|
+
out = r.isError ? { error: r.text } : { items: r.data?.items ?? [] };
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
out = { error: err.message };
|
|
662
|
+
}
|
|
663
|
+
ctx.ui.setStatus("subsub.find", JSON.stringify({ q, ...out }));
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
pi.registerCommand("ai-statement", {
|
|
667
|
+
description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
|
|
668
|
+
handler: async (args, ctx) => {
|
|
669
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
670
|
+
const text = aiStatement({ version: ownVersion(), models: cfg.models, uses: String(args ?? ""), date, starbuck: starbuckEnabled(cfg) });
|
|
671
|
+
const dir = join(cfg.vault ?? ctx.cwd, "Research");
|
|
672
|
+
let file = join(dir, `${date} AI use statement.md`);
|
|
673
|
+
for (let n = 2; existsSync(file); n++)
|
|
674
|
+
file = join(dir, `${date} AI use statement-${n}.md`);
|
|
675
|
+
try {
|
|
676
|
+
mkdirSync(dir, { recursive: true });
|
|
677
|
+
writeFileSync(file, text);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
ctx.ui.notify(`${text}\n${L("Could not save it", "Não foi possível guardar")}: ${err.message}`, "warning");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
ctx.ui.notify(L(`Sub-Sub: a draft statement on the use of AI is in ${file}. Complete the parts in brackets. To name the tasks, type /ai-statement followed by them, for example: /ai-statement literature searches and reading notes.`, `Sub-Sub: há um rascunho da declaração sobre o uso de IA em ${file}. Complete as partes entre parênteses retos. Para indicar as tarefas, escreva /ai-statement seguido delas, por exemplo: /ai-statement literature searches and reading notes.`), "info");
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
687
|
+
pi.registerCommand("read", {
|
|
688
|
+
description: "Sub-Sub: read a paper with guidance; Sub-Sub finds the passages and asks, you write the answers. /read [background|fact|extract|critique] <citekey, DOI, PMID or title words>",
|
|
689
|
+
handler: async (args, ctx) => {
|
|
690
|
+
if (mode === "librarian")
|
|
691
|
+
return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
692
|
+
let { purpose, ref } = readArgs(String(args ?? ""));
|
|
693
|
+
ref ||= (await ctx.ui.input(L("Which paper?", "Que artigo?"), L("citekey, DOI, PMID or title words", "citekey, DOI, PMID ou palavras do título")))?.trim() ?? "";
|
|
694
|
+
if (!ref)
|
|
695
|
+
return;
|
|
696
|
+
if (!purpose) {
|
|
697
|
+
const labels = Object.values(PURPOSES).map(([en, pt]) => L(en, pt));
|
|
698
|
+
purpose = Object.keys(PURPOSES)[labels.indexOf((await ctx.ui.select(L("Why are you reading it?", "Porque vai ler este artigo?"), labels)) ?? "")];
|
|
699
|
+
if (!purpose)
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const req = { ref, purpose, date: new Date().toISOString().slice(0, 10) };
|
|
703
|
+
if (purpose === "fact") {
|
|
704
|
+
req.fact = (await ctx.ui.input(L("Which fact are you looking for?", "Que dado procura?")))?.trim();
|
|
705
|
+
if (!req.fact)
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (purpose === "extract") {
|
|
709
|
+
let sheets = [];
|
|
710
|
+
try {
|
|
711
|
+
sheets = readdirSync(join(cfg.vault ?? ctx.cwd, "Research")).filter((f) => f.endsWith(" extraction.md")).sort();
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// No Research folder yet: the first sheet is a new one.
|
|
715
|
+
}
|
|
716
|
+
const fresh = L("New sheet", "Nova folha");
|
|
717
|
+
const pick = sheets.length ? await ctx.ui.select(L("Which extraction sheet?", "Que folha de extração?"), [...sheets, fresh]) : fresh;
|
|
718
|
+
if (!pick)
|
|
719
|
+
return;
|
|
720
|
+
if (pick === fresh) {
|
|
721
|
+
req.question = (await ctx.ui.input(L("The question of your review", "A pergunta da sua revisão")))?.trim();
|
|
722
|
+
if (!req.question)
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
else
|
|
726
|
+
req.sheet = `Research/${pick}`;
|
|
727
|
+
}
|
|
728
|
+
pi.sendUserMessage(readInstruction(req), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
729
|
+
},
|
|
730
|
+
});
|
|
676
731
|
pi.registerCommand("history", {
|
|
677
732
|
description: "Sub-Sub: recent library changes",
|
|
678
733
|
handler: async (_args, ctx) => {
|
package/dist/web.js
CHANGED
|
@@ -15,16 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
17
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
18
|
+
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
19
|
import { createServer } from "node:http";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
|
-
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
21
|
+
import { basename, dirname, 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
24
|
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
26
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
27
|
-
import { obsidianRoot } from "./layout.js";
|
|
27
|
+
import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.js";
|
|
28
|
+
import { field, frontMatter } from "./notes.js";
|
|
28
29
|
import { realish, within } from "./paths.js";
|
|
29
30
|
export function parseWebArgs(args) {
|
|
30
31
|
const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
|
|
@@ -181,8 +182,43 @@ const TYPES = {
|
|
|
181
182
|
".png": "image/png",
|
|
182
183
|
".ico": "image/x-icon",
|
|
183
184
|
};
|
|
185
|
+
/** Files that the notes pane shows. */
|
|
186
|
+
const NOTE_TYPES = new Set([".md", ".markdown"]);
|
|
184
187
|
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
185
188
|
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
189
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
190
|
+
export function allowedFile(raw, cwd, vault) {
|
|
191
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
192
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
193
|
+
const real = realish(target);
|
|
194
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
195
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
196
|
+
}
|
|
197
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
198
|
+
export function wikilinkTarget(name, root) {
|
|
199
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
200
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
201
|
+
}
|
|
202
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
203
|
+
export function listNotes(root, max = 300) {
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
206
|
+
let names = [];
|
|
207
|
+
try {
|
|
208
|
+
names = readdirSync(join(root, d), { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const n of names) {
|
|
214
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith(".")))
|
|
215
|
+
continue;
|
|
216
|
+
const path = join(root, d, n);
|
|
217
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
221
|
+
}
|
|
186
222
|
const CSP = [
|
|
187
223
|
"default-src 'none'",
|
|
188
224
|
"script-src 'self'",
|
|
@@ -194,6 +230,14 @@ const CSP = [
|
|
|
194
230
|
"form-action 'none'",
|
|
195
231
|
"frame-ancestors 'none'",
|
|
196
232
|
].join("; ");
|
|
233
|
+
function readText(path) {
|
|
234
|
+
try {
|
|
235
|
+
return readFileSync(path, "utf8");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
197
241
|
function same(a, b) {
|
|
198
242
|
const x = Buffer.from(a);
|
|
199
243
|
const y = Buffer.from(b);
|
|
@@ -263,6 +307,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
263
307
|
const agentDir = agentDirFor(env, home);
|
|
264
308
|
const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
|
|
265
309
|
const lang = uiLanguage(cfg.language);
|
|
310
|
+
const notesRoot = cfg.vault ?? cwd;
|
|
266
311
|
const token = randomBytes(24).toString("base64url");
|
|
267
312
|
const webDir = join(PACKAGE_DIR, "web");
|
|
268
313
|
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
@@ -274,6 +319,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
274
319
|
const clients = new Set();
|
|
275
320
|
const dialogs = new Map();
|
|
276
321
|
let subsubState = null;
|
|
322
|
+
let lastFind = null;
|
|
277
323
|
let busy = false;
|
|
278
324
|
let lastSeen = Date.now();
|
|
279
325
|
let restarting = false;
|
|
@@ -310,6 +356,14 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
310
356
|
}
|
|
311
357
|
broadcast({ type: "subsub_state", state: subsubState });
|
|
312
358
|
}
|
|
359
|
+
else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
|
|
360
|
+
try {
|
|
361
|
+
lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
lastFind = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
313
367
|
else if (ev.method === "notify") {
|
|
314
368
|
broadcast(ev);
|
|
315
369
|
}
|
|
@@ -353,13 +407,22 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
353
407
|
out.session = await agent.request({ type: "get_state" });
|
|
354
408
|
lastSession = out.session.sessionFile ?? lastSession;
|
|
355
409
|
out.messages = (await agent.request({ type: "get_messages" })).messages;
|
|
356
|
-
|
|
410
|
+
const commands = (await agent.request({ type: "get_commands" })).commands;
|
|
411
|
+
// RPC has no argument hints: read them from the prompt files.
|
|
412
|
+
out.commands = commands.map((c) => {
|
|
413
|
+
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
414
|
+
const hint = front && field(front, "argument-hint");
|
|
415
|
+
return hint ? { ...c, hint } : c;
|
|
416
|
+
});
|
|
357
417
|
}
|
|
358
418
|
catch (err) {
|
|
359
419
|
out.error = err.message;
|
|
360
420
|
}
|
|
361
421
|
return out;
|
|
362
422
|
}
|
|
423
|
+
async function currentSession() {
|
|
424
|
+
return (await agent.request({ type: "get_state" })).sessionFile;
|
|
425
|
+
}
|
|
363
426
|
async function sessions() {
|
|
364
427
|
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
365
428
|
const list = await SessionManager.list(cwd);
|
|
@@ -493,7 +556,8 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
493
556
|
if (!message)
|
|
494
557
|
return send(res, 400, { error: "empty message" });
|
|
495
558
|
try {
|
|
496
|
-
|
|
559
|
+
// A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
|
|
560
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
|
|
497
561
|
}
|
|
498
562
|
catch (err) {
|
|
499
563
|
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
@@ -599,14 +663,63 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
599
663
|
saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
600
664
|
});
|
|
601
665
|
}
|
|
666
|
+
case "GET /api/notes":
|
|
667
|
+
return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
|
|
668
|
+
case "GET /api/note": {
|
|
669
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
670
|
+
const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
|
|
671
|
+
// A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
|
|
672
|
+
const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
673
|
+
if (!file)
|
|
674
|
+
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
675
|
+
if (!NOTE_TYPES.has(extname(file).toLowerCase()))
|
|
676
|
+
return send(res, 415, { error: "not a note" });
|
|
677
|
+
if (statSync(file).size > 2_000_000)
|
|
678
|
+
return send(res, 413, { error: "too large to show" });
|
|
679
|
+
return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
|
|
680
|
+
}
|
|
681
|
+
case "GET /api/library": {
|
|
682
|
+
const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
683
|
+
if (!q)
|
|
684
|
+
return send(res, 200, { q, items: [] });
|
|
685
|
+
await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
|
|
686
|
+
return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
|
|
687
|
+
}
|
|
688
|
+
case "POST /api/session/name": {
|
|
689
|
+
const body = await readJson(req);
|
|
690
|
+
const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
|
|
691
|
+
if (!name || name.length > 120)
|
|
692
|
+
return send(res, 400, { error: "a name of 1 to 120 characters" });
|
|
693
|
+
const known = (await sessions());
|
|
694
|
+
if (!known.some((x) => x.path === body.path))
|
|
695
|
+
return send(res, 404, { error: "no such session" });
|
|
696
|
+
if (body.path === (await currentSession()))
|
|
697
|
+
await agent.request({ type: "set_session_name", name });
|
|
698
|
+
else {
|
|
699
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
700
|
+
SessionManager.open(String(body.path)).appendSessionInfo(name);
|
|
701
|
+
}
|
|
702
|
+
return send(res, 200, { ok: true });
|
|
703
|
+
}
|
|
704
|
+
case "POST /api/session/trash": {
|
|
705
|
+
const body = await readJson(req);
|
|
706
|
+
const known = (await sessions());
|
|
707
|
+
if (!known.some((x) => x.path === body.path))
|
|
708
|
+
return send(res, 404, { error: "no such session" });
|
|
709
|
+
if (body.path === (await currentSession()))
|
|
710
|
+
return send(res, 409, { error: "the open conversation" });
|
|
711
|
+
// Nothing is deleted: the file moves to a trash folder next to the agent folder.
|
|
712
|
+
const trash = join(dirname(agentDir), "trash");
|
|
713
|
+
mkdirSync(trash, { recursive: true });
|
|
714
|
+
renameSync(String(body.path), join(trash, basename(String(body.path))));
|
|
715
|
+
return send(res, 200, { ok: true });
|
|
716
|
+
}
|
|
602
717
|
case "POST /api/open": {
|
|
603
718
|
const body = await readJson(req);
|
|
604
|
-
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
605
719
|
const vault = cfg.vault;
|
|
606
720
|
// Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
if (!inside || !existsSync(target))
|
|
721
|
+
const target = allowedFile(String(body.path ?? ""), cwd, vault);
|
|
722
|
+
if (!target)
|
|
610
723
|
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
611
724
|
// Documents only: a file that the model wrote must never be run.
|
|
612
725
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|
package/package.json
CHANGED
package/prompts/lit.md
CHANGED
|
@@ -54,6 +54,7 @@ Write one note: `Research/<date> <slug>.md` in the Sub-Sub folder. Do not overwr
|
|
|
54
54
|
title: "<question>"
|
|
55
55
|
date: <YYYY-MM-DD>
|
|
56
56
|
type: literature-review
|
|
57
|
+
review_type: AI-assisted rapid review (not a systematic review)
|
|
57
58
|
question: "<question in one sentence>"
|
|
58
59
|
searches: ["<phrasing 1>", "<phrasing 2>", "<phrasing 3>"]
|
|
59
60
|
sources_found: <number of unique works>
|
|
@@ -78,15 +79,18 @@ reference_check: "<path of the Starbuck report, or none>"
|
|
|
78
79
|
## Methods
|
|
79
80
|
(where you searched, the phrasings, the pre-screen terms, the screening counts, the gap searches, what you included and left out, and why)
|
|
80
81
|
|
|
82
|
+
## What this review is
|
|
83
|
+
(copy this paragraph as it is) An AI-assisted rapid review: one AI screener, no second reviewer, no registered protocol and no formal risk-of-bias assessment (such as RoB 2 or ROBINS-I). Use it to scope a question, to write a background section or as a first search. For a systematic review, use the searches and the screening log as a starting point, then screen in duplicate, assess the risk of bias and report to PRISMA.
|
|
84
|
+
|
|
81
85
|
## Sources
|
|
82
86
|
1. [@citekey] for library items; for other works: Authors, year, title, journal. doi:... (not in library)
|
|
83
87
|
```
|
|
84
88
|
|
|
85
89
|
If a step fails (a service does not answer, a full text cannot be read), do not stop. Finish the note, mark the step "BLOCKED: <what failed>", and set verification: BLOCKED.
|
|
86
90
|
|
|
87
|
-
##
|
|
91
|
+
## A reading list
|
|
88
92
|
|
|
89
|
-
When the
|
|
93
|
+
When the user asks for a reading list (for example `/lit reading list: <topic>`, in any language), do sections 1, 2, 4 and 5 as written, but do not synthesise (skip section 3). Write the note with `type: reading-list` and this body in place of Answer, Agreement, Disagreement and Open questions:
|
|
90
94
|
|
|
91
95
|
```markdown
|
|
92
96
|
## How to read this list
|
package/roles/researcher.md
CHANGED
|
@@ -94,12 +94,14 @@ If zotero_get_vocabulary has no topic/ tags, propose topics before tagging: read
|
|
|
94
94
|
- For a synthesis of a tag ("what does my library say about X"): find the items with zotero_find_items tags=["topic/x"] and include every one. The table has one row per item; if you leave an item out, say which and why.
|
|
95
95
|
- Say for each item whether you used the full text or the abstract. Never state a number or a claim that is not in what you read.
|
|
96
96
|
|
|
97
|
-
## Manuscripts
|
|
97
|
+
## Manuscripts
|
|
98
98
|
|
|
99
99
|
1. scholar_check_manuscript on the file.
|
|
100
100
|
2. Propose fixes for missing keys. Do not edit the manuscript text without approval.
|
|
101
101
|
3. scholar_export_bibliography to the file named in the YAML header, or references.json next to the manuscript.
|
|
102
102
|
|
|
103
|
+
Draft text for {{user}}'s manuscripts or theses only when {{user}} asks. Cite library items as [@citekey], use only claims from sources you read, and mark drafted text clearly so that {{user}} can check it. Never draft text for an assignment (coursework that {{user}} hands in for a grade), even when asked: say so, and offer /read, quotes and questions instead.
|
|
104
|
+
|
|
103
105
|
## Reference checks (Starbuck, when the verify_ tools are available)
|
|
104
106
|
|
|
105
107
|
- verify_check_manuscript checks that each cited work exists, matches its citation and was not retracted. Use it on {{user}}'s manuscripts and on your own reports before you deliver them.
|
package/src/bench.ts
CHANGED
|
@@ -901,7 +901,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
901
901
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
902
902
|
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
903
903
|
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
904
|
-
writeFileSync(cf, JSON.stringify({ ...userCfg, profile:
|
|
904
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: undefined, models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
905
905
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
906
906
|
n++;
|
|
907
907
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/src/cli.ts
CHANGED
|
@@ -140,7 +140,7 @@ Usage:
|
|
|
140
140
|
subsub Start Sub-Sub in the terminal
|
|
141
141
|
subsub --librarian Start in the Librarian, which only manages the library
|
|
142
142
|
subsub web Open Sub-Sub in the browser
|
|
143
|
-
subsub init Set up or change the settings (folder, tag list,
|
|
143
|
+
subsub init Set up or change the settings (folder, tag list, models)
|
|
144
144
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
145
145
|
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
146
146
|
subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
|
|
@@ -152,7 +152,7 @@ Options:
|
|
|
152
152
|
--here Work in the current folder, not in the Sub-Sub folder
|
|
153
153
|
|
|
154
154
|
In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
|
|
155
|
-
/
|
|
155
|
+
/read to read a paper with guidance, /undo to undo the last change, and / for all commands.
|
|
156
156
|
Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
|
|
157
157
|
|
|
158
158
|
Docs: https://subsub.tiagojacinto.eu`;
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* (~/.config/subsub/config.json, or SUBSUB_CONFIG) overrides it.
|
|
4
4
|
*
|
|
5
5
|
* {
|
|
6
|
-
* "profile": "scholar",
|
|
7
6
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
7
|
* "vault": "~/Documents/Sub-Sub",
|
|
9
8
|
* "models": { "librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
@@ -61,8 +60,6 @@ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "test
|
|
|
61
60
|
export function uiLanguage(language: string | undefined): "pt" | "en" {
|
|
62
61
|
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
63
62
|
}
|
|
64
|
-
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
65
|
-
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
66
63
|
|
|
67
64
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
68
65
|
export const SERVER_VERSION = "0.5.5";
|
|
@@ -72,10 +69,8 @@ export const STARBUCK_VERSION = "0.2.0";
|
|
|
72
69
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
73
70
|
|
|
74
71
|
export interface SubsubConfig {
|
|
75
|
-
/** The settings file (written by `subsub init`
|
|
72
|
+
/** The settings file (written by `subsub init`). */
|
|
76
73
|
configFile: string;
|
|
77
|
-
/** How much Sub-Sub does for the user: reader, scholar, author or editor. */
|
|
78
|
-
profile: Profile;
|
|
79
74
|
/** How the prompts name the user ("the user" when not set). */
|
|
80
75
|
userName?: string;
|
|
81
76
|
/** One line about the user, e.g. "a master's student in health informatics". */
|
|
@@ -157,10 +152,6 @@ export function saveConfig(patch: Record<string, unknown>, env: NodeJS.ProcessEn
|
|
|
157
152
|
return file;
|
|
158
153
|
}
|
|
159
154
|
|
|
160
|
-
export function isProfile(x: unknown): x is Profile {
|
|
161
|
-
return typeof x === "string" && (PROFILES as string[]).includes(x);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
155
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
165
156
|
const home = env.HOME;
|
|
166
157
|
const file = configPath(env);
|
|
@@ -194,7 +185,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
194
185
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
195
186
|
return {
|
|
196
187
|
configFile: file,
|
|
197
|
-
profile: isProfile(user.profile) ? user.profile : "scholar",
|
|
198
188
|
userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
|
|
199
189
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
200
190
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
package/src/doctor.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
refreshedArgs,
|
|
24
24
|
} from "./config.ts";
|
|
25
25
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
26
|
-
import { profileSpec } from "./profiles.ts";
|
|
27
26
|
|
|
28
27
|
export interface Check {
|
|
29
28
|
name: string;
|
|
@@ -79,7 +78,7 @@ export function runChecks(
|
|
|
79
78
|
);
|
|
80
79
|
out.push(
|
|
81
80
|
existsSync(cfg.configFile)
|
|
82
|
-
? { name: "Settings", ok: true, detail: `${cfg.configFile},
|
|
81
|
+
? { name: "Settings", ok: true, detail: `${cfg.configFile}, replies in ${cfg.language}` }
|
|
83
82
|
: { name: "Settings", ok: false, detail: `no ${cfg.configFile}`, fix: "Type subsub init." },
|
|
84
83
|
);
|
|
85
84
|
|