@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/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
- * - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
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, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
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 { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.js";
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 = () => ({ profile, bench: benchRun });
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} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
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, profile, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
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
- // Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
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(L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`), "warning");
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
- const why = promptBlocked(event.text, profile);
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, { ...cfg, profile }, ctx.cwd)}`,
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
- return full ? quoteProblem(missingQuotes(text, full, quotesIn(before))) : undefined;
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
- fullTexts.set(key, text || null);
538
- return text || undefined;
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}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
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
- out.commands = (await agent.request({ type: "get_commands" })).commands;
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
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
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 real = realish(target);
608
- const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
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",
@@ -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.