@tiagojct/subsub 0.16.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/dist/subsub.js CHANGED
@@ -6,23 +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
- * - 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";
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, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
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";
25
22
  import { aiStatement } from "./statement.js";
23
+ import { PURPOSES, readArgs, readInstruction } from "./read.js";
26
24
  import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.js";
27
25
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
28
26
  import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
@@ -114,8 +112,7 @@ export async function createSubsub(pi, deps = {}) {
114
112
  pi.registerFlag("researcher", { description: "Start Sub-Sub in the Researcher mode", type: "boolean", default: false });
115
113
  let mode = cfg.defaultMode;
116
114
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
117
- const toolOpts = () => ({ profile, bench: benchRun });
118
- let profile = cfg.profile;
115
+ const toolOpts = () => ({ bench: benchRun });
119
116
  const user = who(cfg);
120
117
  /** True when started with the `subsub` command (not as a package inside plain pi). */
121
118
  const standalone = process.env.SUBSUB_CLI === "1";
@@ -142,8 +139,6 @@ export async function createSubsub(pi, deps = {}) {
142
139
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
143
140
  const state = {
144
141
  mode,
145
- profile,
146
- profileLabel: profileSpec(profile).label,
147
142
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
148
143
  down,
149
144
  library,
@@ -205,7 +200,6 @@ export async function createSubsub(pi, deps = {}) {
205
200
  return headerLines({
206
201
  version,
207
202
  mode,
208
- profile: profileSpec(profile).label,
209
203
  model: modelId,
210
204
  library,
211
205
  quote,
@@ -226,7 +220,7 @@ export async function createSubsub(pi, deps = {}) {
226
220
  function status(ctx) {
227
221
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
228
222
  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(", ")}` : ""}`);
223
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
230
224
  if (standalone)
231
225
  ctx.ui.setTitle(`Sub-Sub: ${mode}`);
232
226
  headerTui?.requestRender();
@@ -282,7 +276,7 @@ export async function createSubsub(pi, deps = {}) {
282
276
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
283
277
  applyTheme(ctx);
284
278
  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 });
279
+ logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
286
280
  if (lookOn(ctx)) {
287
281
  setHeader(ctx);
288
282
  void refreshLibrary();
@@ -307,6 +301,7 @@ export async function createSubsub(pi, deps = {}) {
307
301
  });
308
302
  /** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
309
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.`);
310
305
  // /tree moves to another branch: take that branch's mode (pi restores its model itself).
311
306
  pi.on("session_tree", async (_event, ctx) => {
312
307
  let m;
@@ -352,8 +347,7 @@ export async function createSubsub(pi, deps = {}) {
352
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 });
353
348
  }, wait * 1000);
354
349
  });
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.
350
+ // Research prompts in the Librarian. The input event comes before pi expands a prompt template.
357
351
  pi.on("input", async (event, ctx) => {
358
352
  if (resumeTimer)
359
353
  clearTimeout(resumeTimer);
@@ -364,21 +358,22 @@ export async function createSubsub(pi, deps = {}) {
364
358
  if (cmd !== "subsub-refresh")
365
359
  logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
366
360
  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");
361
+ ctx.ui.notify(researcherOnly(cmd), "warning");
368
362
  return { action: "handled" };
369
363
  }
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" };
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" };
375
370
  });
376
371
  pi.on("agent_start", async () => {
377
372
  turnStart = Date.now();
378
373
  turnTools = 0;
379
374
  });
380
375
  pi.on("before_agent_start", async (event, ctx) => ({
381
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
376
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
382
377
  }));
383
378
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
384
379
  const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
@@ -613,34 +608,10 @@ export async function createSubsub(pi, deps = {}) {
613
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");
614
609
  },
615
610
  });
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
611
  pi.registerCommand("subsub", {
641
612
  description: "Sub-Sub: Zotero connection and library overview",
642
613
  handler: async (_args, ctx) => {
643
- const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
614
+ const lines = [`Mode: ${mode}. Model: ${ctx.model?.id ?? "none"}.`];
644
615
  if (!bridge) {
645
616
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
646
617
  return;
@@ -674,6 +645,24 @@ export async function createSubsub(pi, deps = {}) {
674
645
  await refreshLibrary();
675
646
  },
676
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
+ });
677
666
  pi.registerCommand("ai-statement", {
678
667
  description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
679
668
  handler: async (args, ctx) => {
@@ -694,6 +683,51 @@ export async function createSubsub(pi, deps = {}) {
694
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");
695
684
  },
696
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
+ });
697
731
  pi.registerCommand("history", {
698
732
  description: "Sub-Sub: recent library changes",
699
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
- out.commands = (await agent.request({ type: "get_commands" })).commands;
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
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
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 real = realish(target);
608
- const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
package/prompts/lit.md CHANGED
@@ -88,9 +88,9 @@ reference_check: "<path of the Starbuck report, or none>"
88
88
 
89
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.
90
90
 
91
- ## In the Reader profile: a reading list
91
+ ## A reading list
92
92
 
93
- When the profile is Reader, 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:
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:
94
94
 
95
95
  ```markdown
96
96
  ## How to read this list
@@ -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 (Author and Editor profiles)
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: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
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, profile, models)
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
- /profile to change the profile, /undo to undo the last change, and / for all commands.
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` and `/profile`). */
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}, profile ${profileSpec(cfg.profile).label}, replies in ${cfg.language}` }
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