@tiagojct/subsub 0.16.0 → 0.18.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 +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/dist/subsub.js
CHANGED
|
@@ -6,24 +6,25 @@
|
|
|
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, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
19
18
|
import { dirname, join } from "node:path";
|
|
20
19
|
import { fileURLToPath } from "node:url";
|
|
21
20
|
import { Bridge } from "./bridge.js";
|
|
22
|
-
import { expand, findUv, FREE_MODELS,
|
|
23
|
-
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
21
|
+
import { expand, findUv, FREE_MODELS, loadConfig, parseEnvFile, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
|
|
24
22
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
25
23
|
import { aiStatement } from "./statement.js";
|
|
26
|
-
import {
|
|
24
|
+
import { feedbackNote, ISSUES_URL } from "./feedback.js";
|
|
25
|
+
import { formatChecks, runChecks } from "./doctor.js";
|
|
26
|
+
import { PURPOSES, readArgs, readInstruction } from "./read.js";
|
|
27
|
+
import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn, sourceOf, withPageLinks } from "./notes.js";
|
|
27
28
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
28
29
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
|
|
29
30
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
|
|
@@ -114,8 +115,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
114
115
|
pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
|
|
115
116
|
let mode = cfg.defaultMode;
|
|
116
117
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
117
|
-
const toolOpts = () => ({
|
|
118
|
-
let profile = cfg.profile;
|
|
118
|
+
const toolOpts = () => ({ bench: benchRun });
|
|
119
119
|
const user = who(cfg);
|
|
120
120
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
121
121
|
const standalone = process.env.SUBSUB_CLI === "1";
|
|
@@ -130,6 +130,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
130
130
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
131
131
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
132
132
|
let lastError;
|
|
133
|
+
/** The last provider or tool error of the session, for /feedback. */
|
|
134
|
+
let recentError;
|
|
133
135
|
let resumes = 0;
|
|
134
136
|
let resumeTimer;
|
|
135
137
|
let turnStart = 0;
|
|
@@ -142,8 +144,6 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
142
144
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
143
145
|
const state = {
|
|
144
146
|
mode,
|
|
145
|
-
profile,
|
|
146
|
-
profileLabel: profileSpec(profile).label,
|
|
147
147
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
148
148
|
down,
|
|
149
149
|
library,
|
|
@@ -205,7 +205,6 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
205
205
|
return headerLines({
|
|
206
206
|
version,
|
|
207
207
|
mode,
|
|
208
|
-
profile: profileSpec(profile).label,
|
|
209
208
|
model: modelId,
|
|
210
209
|
library,
|
|
211
210
|
quote,
|
|
@@ -226,7 +225,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
226
225
|
function status(ctx) {
|
|
227
226
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
228
227
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
229
|
-
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${
|
|
228
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
230
229
|
if (standalone)
|
|
231
230
|
ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
232
231
|
headerTui?.requestRender();
|
|
@@ -282,7 +281,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
282
281
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
283
282
|
applyTheme(ctx);
|
|
284
283
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
285
|
-
logUsage(cfg.usageLog, { ev: "session", mode,
|
|
284
|
+
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
286
285
|
if (lookOn(ctx)) {
|
|
287
286
|
setHeader(ctx);
|
|
288
287
|
void refreshLibrary();
|
|
@@ -307,6 +306,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
307
306
|
});
|
|
308
307
|
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
309
308
|
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
309
|
+
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.`);
|
|
310
310
|
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
311
311
|
pi.on("session_tree", async (_event, ctx) => {
|
|
312
312
|
let m;
|
|
@@ -324,13 +324,19 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
324
324
|
const m = event.message;
|
|
325
325
|
if (m.role !== "assistant" || m.stopReason !== "error")
|
|
326
326
|
return;
|
|
327
|
-
lastError = m.errorMessage;
|
|
327
|
+
lastError = recentError = m.errorMessage;
|
|
328
328
|
if (webView || perMinuteWait(m.errorMessage) !== undefined)
|
|
329
329
|
return;
|
|
330
330
|
const kind = providerTrouble(m.errorMessage);
|
|
331
331
|
if (kind)
|
|
332
332
|
ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
|
|
333
333
|
});
|
|
334
|
+
pi.on("tool_result", async (event) => {
|
|
335
|
+
if (!event.isError)
|
|
336
|
+
return;
|
|
337
|
+
const text = event.content.map((c) => (c.type === "text" ? c.text : "")).join(" ");
|
|
338
|
+
recentError = `${event.toolName ?? "tool"}: ${text}`.slice(0, 2000);
|
|
339
|
+
});
|
|
334
340
|
// A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
|
|
335
341
|
// up to three times per request of the user. Anything the user types cancels the wait.
|
|
336
342
|
pi.on("agent_end", async (_event, ctx) => {
|
|
@@ -352,8 +358,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
352
358
|
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 });
|
|
353
359
|
}, wait * 1000);
|
|
354
360
|
});
|
|
355
|
-
//
|
|
356
|
-
// The input event comes before pi expands a prompt template.
|
|
361
|
+
// Research prompts in the Librarian. The input event comes before pi expands a prompt template.
|
|
357
362
|
pi.on("input", async (event, ctx) => {
|
|
358
363
|
if (resumeTimer)
|
|
359
364
|
clearTimeout(resumeTimer);
|
|
@@ -364,21 +369,22 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
364
369
|
if (cmd !== "subsub-refresh")
|
|
365
370
|
logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
366
371
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
367
|
-
ctx.ui.notify(
|
|
372
|
+
ctx.ui.notify(researcherOnly(cmd), "warning");
|
|
373
|
+
return { action: "handled" };
|
|
374
|
+
}
|
|
375
|
+
// shortcut: a notice for the habit of 0.16 and earlier; drop it a few versions on.
|
|
376
|
+
if (cmd === "profile") {
|
|
377
|
+
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");
|
|
368
378
|
return { action: "handled" };
|
|
369
379
|
}
|
|
370
|
-
|
|
371
|
-
if (!why)
|
|
372
|
-
return { action: "continue" };
|
|
373
|
-
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
374
|
-
return { action: "handled" };
|
|
380
|
+
return { action: "continue" };
|
|
375
381
|
});
|
|
376
382
|
pi.on("agent_start", async () => {
|
|
377
383
|
turnStart = Date.now();
|
|
378
384
|
turnTools = 0;
|
|
379
385
|
});
|
|
380
386
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
381
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode,
|
|
387
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
|
|
382
388
|
}));
|
|
383
389
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
384
390
|
const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
|
|
@@ -512,14 +518,28 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
512
518
|
return undefined;
|
|
513
519
|
const full = await fullTextOf(key, signal);
|
|
514
520
|
// Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
|
|
515
|
-
|
|
521
|
+
if (!full)
|
|
522
|
+
return undefined;
|
|
523
|
+
const missing = quoteProblem(missingQuotes(text, full.text, quotesIn(before)));
|
|
524
|
+
// A note that passes is written with its page references linked to the PDF.
|
|
525
|
+
if (!missing && full.attachment) {
|
|
526
|
+
const link = (s) => withPageLinks(s, sourceOf(full.text), full.attachment);
|
|
527
|
+
if (name === "write")
|
|
528
|
+
input.content = link(String(input.content ?? ""));
|
|
529
|
+
else
|
|
530
|
+
for (const e of input.edits)
|
|
531
|
+
if (typeof e.newText === "string")
|
|
532
|
+
e.newText = link(e.newText);
|
|
533
|
+
}
|
|
534
|
+
return missing;
|
|
516
535
|
}
|
|
517
|
-
/** The whole indexed full text of an item, read in parts, kept for the session. */
|
|
536
|
+
/** The whole indexed full text of an item and its attachment, read in parts, kept for the session. */
|
|
518
537
|
const fullTexts = new Map();
|
|
519
538
|
async function fullTextOf(key, signal) {
|
|
520
539
|
if (fullTexts.has(key))
|
|
521
540
|
return fullTexts.get(key) ?? undefined;
|
|
522
541
|
let text = "";
|
|
542
|
+
let attachment;
|
|
523
543
|
let offset = 0;
|
|
524
544
|
try {
|
|
525
545
|
for (let part = 0; offset !== null && part < 20; part++) {
|
|
@@ -528,14 +548,16 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
528
548
|
break;
|
|
529
549
|
const data = (res.data ?? {});
|
|
530
550
|
text += data.text ?? "";
|
|
551
|
+
attachment ??= data.attachment;
|
|
531
552
|
offset = data.next_offset ?? null;
|
|
532
553
|
}
|
|
533
554
|
}
|
|
534
555
|
catch {
|
|
535
556
|
/* no full text: the check is skipped */
|
|
536
557
|
}
|
|
537
|
-
|
|
538
|
-
|
|
558
|
+
const full = text ? { text, attachment } : null;
|
|
559
|
+
fullTexts.set(key, full);
|
|
560
|
+
return full ?? undefined;
|
|
539
561
|
}
|
|
540
562
|
/**
|
|
541
563
|
* Starbuck writes its reports into a folder: report_dir, or _starbuck next to the
|
|
@@ -613,34 +635,10 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
613
635
|
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");
|
|
614
636
|
},
|
|
615
637
|
});
|
|
616
|
-
pi.registerCommand("profile", {
|
|
617
|
-
description: "Sub-Sub: show or change the profile (reader, scholar, author, editor)",
|
|
618
|
-
handler: async (args, ctx) => {
|
|
619
|
-
const want = args.trim().toLowerCase();
|
|
620
|
-
if (!want) {
|
|
621
|
-
ctx.ui.notify(`Profile: ${profileSpec(profile).label}. To change it, type /profile <name>.\n${profileList(profile)}`, "info");
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
if (!isProfile(want)) {
|
|
625
|
-
ctx.ui.notify(`Unknown profile "${want}". Choose one of:\n${profileList(profile)}`, "warning");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
profile = want;
|
|
629
|
-
let saved = "";
|
|
630
|
-
try {
|
|
631
|
-
saved = ` Saved in ${saveConfig({ profile })}.`;
|
|
632
|
-
}
|
|
633
|
-
catch (err) {
|
|
634
|
-
saved = ` Not saved: ${err.message}`;
|
|
635
|
-
}
|
|
636
|
-
await applyMode(ctx, mode, false, false);
|
|
637
|
-
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
638
|
-
},
|
|
639
|
-
});
|
|
640
638
|
pi.registerCommand("subsub", {
|
|
641
639
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
642
640
|
handler: async (_args, ctx) => {
|
|
643
|
-
const lines = [`Mode: ${mode}.
|
|
641
|
+
const lines = [`Mode: ${mode}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
644
642
|
if (!bridge) {
|
|
645
643
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
646
644
|
return;
|
|
@@ -674,6 +672,24 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
674
672
|
await refreshLibrary();
|
|
675
673
|
},
|
|
676
674
|
});
|
|
675
|
+
// The web view's library search: the result goes to the page through a status, like the library counts.
|
|
676
|
+
pi.registerCommand("subsub-find", {
|
|
677
|
+
description: "Sub-Sub: search the library for the web view",
|
|
678
|
+
handler: async (args, ctx) => {
|
|
679
|
+
if (ctx.mode !== "rpc")
|
|
680
|
+
return;
|
|
681
|
+
const q = String(args ?? "").trim();
|
|
682
|
+
let out;
|
|
683
|
+
try {
|
|
684
|
+
const r = await current().call("zotero_find_items", { query: q, limit: 25 });
|
|
685
|
+
out = r.isError ? { error: r.text } : { items: r.data?.items ?? [] };
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
out = { error: err.message };
|
|
689
|
+
}
|
|
690
|
+
ctx.ui.setStatus("subsub.find", JSON.stringify({ q, ...out }));
|
|
691
|
+
},
|
|
692
|
+
});
|
|
677
693
|
pi.registerCommand("ai-statement", {
|
|
678
694
|
description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
|
|
679
695
|
handler: async (args, ctx) => {
|
|
@@ -694,6 +710,98 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
694
710
|
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");
|
|
695
711
|
},
|
|
696
712
|
});
|
|
713
|
+
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
714
|
+
pi.registerCommand("read", {
|
|
715
|
+
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>",
|
|
716
|
+
handler: async (args, ctx) => {
|
|
717
|
+
if (mode === "librarian")
|
|
718
|
+
return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
719
|
+
let { purpose, ref } = readArgs(String(args ?? ""));
|
|
720
|
+
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() ?? "";
|
|
721
|
+
if (!ref)
|
|
722
|
+
return;
|
|
723
|
+
if (!purpose) {
|
|
724
|
+
const labels = Object.values(PURPOSES).map(([en, pt]) => L(en, pt));
|
|
725
|
+
purpose = Object.keys(PURPOSES)[labels.indexOf((await ctx.ui.select(L("Why are you reading it?", "Porque vai ler este artigo?"), labels)) ?? "")];
|
|
726
|
+
if (!purpose)
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const req = { ref, purpose, date: new Date().toISOString().slice(0, 10) };
|
|
730
|
+
if (purpose === "fact") {
|
|
731
|
+
req.fact = (await ctx.ui.input(L("Which fact are you looking for?", "Que dado procura?")))?.trim();
|
|
732
|
+
if (!req.fact)
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (purpose === "extract") {
|
|
736
|
+
let sheets = [];
|
|
737
|
+
try {
|
|
738
|
+
sheets = readdirSync(join(cfg.vault ?? ctx.cwd, "Research")).filter((f) => f.endsWith(" extraction.md")).sort();
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// No Research folder yet: the first sheet is a new one.
|
|
742
|
+
}
|
|
743
|
+
const fresh = L("New sheet", "Nova folha");
|
|
744
|
+
const pick = sheets.length ? await ctx.ui.select(L("Which extraction sheet?", "Que folha de extração?"), [...sheets, fresh]) : fresh;
|
|
745
|
+
if (!pick)
|
|
746
|
+
return;
|
|
747
|
+
if (pick === fresh) {
|
|
748
|
+
req.question = (await ctx.ui.input(L("The question of your review", "A pergunta da sua revisão")))?.trim();
|
|
749
|
+
if (!req.question)
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
else
|
|
753
|
+
req.sheet = `Research/${pick}`;
|
|
754
|
+
}
|
|
755
|
+
pi.sendUserMessage(readInstruction(req), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
pi.registerCommand("feedback", {
|
|
759
|
+
description: "Sub-Sub: a draft bug report or idea in Inbox/, to read and post on GitHub yourself; Sub-Sub sends nothing [what happened]",
|
|
760
|
+
handler: async (args, ctx) => {
|
|
761
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
762
|
+
let piVersion = "";
|
|
763
|
+
try {
|
|
764
|
+
piVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..", "package.json"), "utf8")).version;
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
/* not needed for the report */
|
|
768
|
+
}
|
|
769
|
+
let checks;
|
|
770
|
+
try {
|
|
771
|
+
checks = deps.doctor ? deps.doctor() : formatChecks(runChecks(cfg, process.env));
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
checks = `subsub doctor failed: ${err.message}`;
|
|
775
|
+
}
|
|
776
|
+
const text = feedbackNote({
|
|
777
|
+
what: String(args ?? ""),
|
|
778
|
+
date,
|
|
779
|
+
versions: `Sub-Sub ${ownVersion()}${piVersion ? `, pi ${piVersion}` : ""}, Node ${process.version}, ${process.platform} ${process.arch}`,
|
|
780
|
+
setup: [
|
|
781
|
+
`Mode: ${mode}. Model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none"}.`,
|
|
782
|
+
`Language: ${cfg.language}. Reference checks (Starbuck): ${starbuckEnabled(cfg) ? "on" : "off"}.`,
|
|
783
|
+
`Zotero: ${library.zotero}.${bridge && Object.keys(bridge.errors).length ? ` Servers not running: ${Object.keys(bridge.errors).join(", ")}.` : ""}`,
|
|
784
|
+
],
|
|
785
|
+
checks,
|
|
786
|
+
lastError: recentError,
|
|
787
|
+
home: homedir(),
|
|
788
|
+
vault: cfg.vault,
|
|
789
|
+
});
|
|
790
|
+
const dir = join(cfg.vault ?? ctx.cwd, "Inbox");
|
|
791
|
+
let file = join(dir, `${date} Sub-Sub feedback.md`);
|
|
792
|
+
for (let n = 2; existsSync(file); n++)
|
|
793
|
+
file = join(dir, `${date} Sub-Sub feedback-${n}.md`);
|
|
794
|
+
try {
|
|
795
|
+
mkdirSync(dir, { recursive: true });
|
|
796
|
+
writeFileSync(file, text);
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
ctx.ui.notify(`${L("Could not save it", "Não foi possível guardar")}: ${err.message}`, "warning");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
ctx.ui.notify(L(`Sub-Sub: a draft report is in ${file}. Read it, add what you expected, take out anything you do not want to share, and post it at ${ISSUES_URL}. Sub-Sub has sent nothing.`, `Sub-Sub: há um rascunho do relatório em ${file}. Leia-o, acrescente o que esperava, retire o que não quer partilhar e publique-o em ${ISSUES_URL}. O Sub-Sub não enviou nada.`), "info");
|
|
803
|
+
},
|
|
804
|
+
});
|
|
697
805
|
pi.registerCommand("history", {
|
|
698
806
|
description: "Sub-Sub: recent library changes",
|
|
699
807
|
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, relative, 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,45 @@ 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"]);
|
|
187
|
+
/** Images that a note shows (a figure cut out of a PDF, for example). */
|
|
188
|
+
const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
184
189
|
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
185
190
|
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
191
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
192
|
+
export function allowedFile(raw, cwd, vault) {
|
|
193
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
194
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
195
|
+
const real = realish(target);
|
|
196
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
197
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
198
|
+
}
|
|
199
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
200
|
+
export function wikilinkTarget(name, root) {
|
|
201
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
202
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
203
|
+
}
|
|
204
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
205
|
+
export function listNotes(root, max = 300) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
208
|
+
let names = [];
|
|
209
|
+
try {
|
|
210
|
+
names = readdirSync(join(root, d), { recursive: true });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
for (const n of names) {
|
|
216
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith(".")))
|
|
217
|
+
continue;
|
|
218
|
+
const path = join(root, d, n);
|
|
219
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
223
|
+
}
|
|
186
224
|
const CSP = [
|
|
187
225
|
"default-src 'none'",
|
|
188
226
|
"script-src 'self'",
|
|
@@ -194,6 +232,14 @@ const CSP = [
|
|
|
194
232
|
"form-action 'none'",
|
|
195
233
|
"frame-ancestors 'none'",
|
|
196
234
|
].join("; ");
|
|
235
|
+
function readText(path) {
|
|
236
|
+
try {
|
|
237
|
+
return readFileSync(path, "utf8");
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
197
243
|
function same(a, b) {
|
|
198
244
|
const x = Buffer.from(a);
|
|
199
245
|
const y = Buffer.from(b);
|
|
@@ -263,6 +309,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
263
309
|
const agentDir = agentDirFor(env, home);
|
|
264
310
|
const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
|
|
265
311
|
const lang = uiLanguage(cfg.language);
|
|
312
|
+
const notesRoot = cfg.vault ?? cwd;
|
|
266
313
|
const token = randomBytes(24).toString("base64url");
|
|
267
314
|
const webDir = join(PACKAGE_DIR, "web");
|
|
268
315
|
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
@@ -274,6 +321,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
274
321
|
const clients = new Set();
|
|
275
322
|
const dialogs = new Map();
|
|
276
323
|
let subsubState = null;
|
|
324
|
+
let lastFind = null;
|
|
277
325
|
let busy = false;
|
|
278
326
|
let lastSeen = Date.now();
|
|
279
327
|
let restarting = false;
|
|
@@ -310,6 +358,14 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
310
358
|
}
|
|
311
359
|
broadcast({ type: "subsub_state", state: subsubState });
|
|
312
360
|
}
|
|
361
|
+
else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
|
|
362
|
+
try {
|
|
363
|
+
lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
lastFind = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
313
369
|
else if (ev.method === "notify") {
|
|
314
370
|
broadcast(ev);
|
|
315
371
|
}
|
|
@@ -353,13 +409,22 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
353
409
|
out.session = await agent.request({ type: "get_state" });
|
|
354
410
|
lastSession = out.session.sessionFile ?? lastSession;
|
|
355
411
|
out.messages = (await agent.request({ type: "get_messages" })).messages;
|
|
356
|
-
|
|
412
|
+
const commands = (await agent.request({ type: "get_commands" })).commands;
|
|
413
|
+
// RPC has no argument hints: read them from the prompt files.
|
|
414
|
+
out.commands = commands.map((c) => {
|
|
415
|
+
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
416
|
+
const hint = front && field(front, "argument-hint");
|
|
417
|
+
return hint ? { ...c, hint } : c;
|
|
418
|
+
});
|
|
357
419
|
}
|
|
358
420
|
catch (err) {
|
|
359
421
|
out.error = err.message;
|
|
360
422
|
}
|
|
361
423
|
return out;
|
|
362
424
|
}
|
|
425
|
+
async function currentSession() {
|
|
426
|
+
return (await agent.request({ type: "get_state" })).sessionFile;
|
|
427
|
+
}
|
|
363
428
|
async function sessions() {
|
|
364
429
|
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
365
430
|
const list = await SessionManager.list(cwd);
|
|
@@ -493,7 +558,8 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
493
558
|
if (!message)
|
|
494
559
|
return send(res, 400, { error: "empty message" });
|
|
495
560
|
try {
|
|
496
|
-
|
|
561
|
+
// A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
|
|
562
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
|
|
497
563
|
}
|
|
498
564
|
catch (err) {
|
|
499
565
|
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
@@ -599,14 +665,78 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
599
665
|
saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
600
666
|
});
|
|
601
667
|
}
|
|
668
|
+
case "GET /api/notes":
|
|
669
|
+
return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
|
|
670
|
+
case "GET /api/note": {
|
|
671
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
672
|
+
const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
|
|
673
|
+
// A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
|
|
674
|
+
const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
675
|
+
if (!file)
|
|
676
|
+
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
677
|
+
if (!NOTE_TYPES.has(extname(file).toLowerCase()))
|
|
678
|
+
return send(res, 415, { error: "not a note" });
|
|
679
|
+
if (statSync(file).size > 2_000_000)
|
|
680
|
+
return send(res, 413, { error: "too large to show" });
|
|
681
|
+
return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
|
|
682
|
+
}
|
|
683
|
+
case "GET /api/note-file": {
|
|
684
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
685
|
+
const file = allowedFile(raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
686
|
+
if (!file)
|
|
687
|
+
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
688
|
+
const type = IMAGE_TYPES[extname(file).toLowerCase()];
|
|
689
|
+
if (!type)
|
|
690
|
+
return send(res, 415, { error: "not an image" });
|
|
691
|
+
if (statSync(file).size > 20_000_000)
|
|
692
|
+
return send(res, 413, { error: "too large to show" });
|
|
693
|
+
res.writeHead(200, { ...common, "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
694
|
+
return void createReadStream(file).pipe(res);
|
|
695
|
+
}
|
|
696
|
+
case "GET /api/library": {
|
|
697
|
+
const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
698
|
+
if (!q)
|
|
699
|
+
return send(res, 200, { q, items: [] });
|
|
700
|
+
await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
|
|
701
|
+
return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
|
|
702
|
+
}
|
|
703
|
+
case "POST /api/session/name": {
|
|
704
|
+
const body = await readJson(req);
|
|
705
|
+
const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
|
|
706
|
+
if (!name || name.length > 120)
|
|
707
|
+
return send(res, 400, { error: "a name of 1 to 120 characters" });
|
|
708
|
+
const known = (await sessions());
|
|
709
|
+
if (!known.some((x) => x.path === body.path))
|
|
710
|
+
return send(res, 404, { error: "no such session" });
|
|
711
|
+
if (body.path === (await currentSession()))
|
|
712
|
+
await agent.request({ type: "set_session_name", name });
|
|
713
|
+
else {
|
|
714
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
715
|
+
SessionManager.open(String(body.path)).appendSessionInfo(name);
|
|
716
|
+
}
|
|
717
|
+
return send(res, 200, { ok: true });
|
|
718
|
+
}
|
|
719
|
+
case "POST /api/session/trash": {
|
|
720
|
+
const body = await readJson(req);
|
|
721
|
+
const known = (await sessions());
|
|
722
|
+
if (!known.some((x) => x.path === body.path))
|
|
723
|
+
return send(res, 404, { error: "no such session" });
|
|
724
|
+
if (body.path === (await currentSession()))
|
|
725
|
+
return send(res, 409, { error: "the open conversation" });
|
|
726
|
+
// Nothing is deleted: the file moves to a trash folder next to the agent folder, in the same
|
|
727
|
+
// subfolder as under sessions/, so that moving it back is one step.
|
|
728
|
+
const rel = relative(join(agentDir, "sessions"), String(body.path));
|
|
729
|
+
const to = join(dirname(agentDir), "trash", rel.startsWith("..") ? basename(rel) : rel);
|
|
730
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
731
|
+
renameSync(String(body.path), to);
|
|
732
|
+
return send(res, 200, { ok: true });
|
|
733
|
+
}
|
|
602
734
|
case "POST /api/open": {
|
|
603
735
|
const body = await readJson(req);
|
|
604
|
-
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
605
736
|
const vault = cfg.vault;
|
|
606
737
|
// 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))
|
|
738
|
+
const target = allowedFile(String(body.path ?? ""), cwd, vault);
|
|
739
|
+
if (!target)
|
|
610
740
|
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
611
741
|
// Documents only: a file that the model wrote must never be run.
|
|
612
742
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|
package/package.json
CHANGED
package/prompts/compare.md
CHANGED
|
@@ -12,7 +12,7 @@ Make a short slug: lower case, hyphens, at most five words. Use today's date (YY
|
|
|
12
12
|
2. Read each source before you describe it: zotero_get_fulltext for library items, scholar_read_oa_fulltext (methods and results) for open-access works, scholar_get_work for the abstract of the others. Note whether you read the full text or only the abstract.
|
|
13
13
|
3. Build the matrix, one row per source:
|
|
14
14
|
| # | Source | Claim on the question | Evidence type | Population and setting | Caveats | Confidence |
|
|
15
|
-
- Evidence type: for example randomised trial, cohort, case-control, cross-sectional, diagnostic accuracy, systematic review, guideline, expert opinion, modelling.
|
|
15
|
+
- Evidence type: for example randomised trial, cohort, case-control, cross-sectional, diagnostic accuracy, systematic review, guideline, expert opinion, modelling. Add "(preprint)" to a work that is not peer reviewed yet.
|
|
16
16
|
- Caveats: risk of bias, small sample, surrogate outcome, short follow-up, industry funding, abstract only.
|
|
17
17
|
- Confidence: high, moderate or low, with one reason.
|
|
18
18
|
4. Below the matrix, write: where the sources agree; where they disagree and the most likely reason (population, design, outcome definition, analysis); what no source answers. Cite source numbers in every sentence. Write "inference:" before a statement that no single source makes.
|
package/prompts/gaps.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
description: "What am I missing on a topic"
|
|
3
3
|
argument-hint: "<topic or question>"
|
|
4
4
|
---
|
|
5
|
-
Search PubMed and OpenAlex for: $ARGUMENTS. Tell me how many results are already in my library and list the most relevant ones that are missing, one line each. Ask before you import or queue anything.
|
|
5
|
+
Search PubMed and OpenAlex for: $ARGUMENTS. Tell me how many results are already in my library and list the 5 to 15 most relevant ones that are missing, the best fit first, one line each. This is a list to choose from: do not read the works, and do not search again unless the first search found almost nothing. Ask before you import or queue anything.
|