@tiagojct/subsub 0.19.0 → 0.21.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
@@ -13,9 +13,9 @@
13
13
  * The Python servers start in session_start (not in the factory) and stop in
14
14
  * session_shutdown. Tools are registered once and use the current bridge.
15
15
  */
16
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
- import { dirname, join } from "node:path";
18
+ import { dirname, extname, join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { Bridge } from "./bridge.js";
21
21
  import { expand, findUv, FREE_MODELS, loadConfig, parseEnvFile, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
@@ -28,7 +28,13 @@ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missing
28
28
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
29
29
  import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
30
30
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
31
- import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
31
+ import { headerLines, LIBRARIAN_THEME, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
32
+ import { footerLines, toolRenderers, usageTotals } from "./chrome.js";
33
+ import { ApprovalView } from "./dialogs.js";
34
+ import { allowedFile, listNotes, OPENABLE, openFile } from "./files.js";
35
+ import { LibraryView, NotesView } from "./screens.js";
36
+ import { glyphs, paintFromTheme, workingFrames } from "./skin.js";
37
+ import { loadWords, toolWord } from "./words.js";
32
38
  import { systemAddition } from "./prompt.js";
33
39
  import { logUsage } from "./usage.js";
34
40
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.js";
@@ -124,6 +130,14 @@ export async function createSubsub(pi, deps = {}) {
124
130
  let scheme;
125
131
  let modelId = "no model";
126
132
  let headerTui;
133
+ let footerTui;
134
+ /** True once the session started with the look on; the tool renderers are asked before a context exists. */
135
+ let lookActive = false;
136
+ let words;
137
+ const redraw = () => {
138
+ headerTui?.requestRender();
139
+ footerTui?.requestRender();
140
+ };
127
141
  const library = { zotero: "checking" };
128
142
  const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
129
143
  const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
@@ -163,15 +177,110 @@ export async function createSubsub(pi, deps = {}) {
163
177
  if (!lookOn(ctx) || cfg.themes === false)
164
178
  return;
165
179
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
166
- const name = themeName(scheme, cfg.themes || undefined);
180
+ // The Librarian has its own plate (madder); a theme named in the settings applies to both modes unless themes.librarian says otherwise.
181
+ const base = mode === "librarian" ? (cfg.themesLibrarian ?? cfg.themes ?? LIBRARIAN_THEME) : cfg.themes || undefined;
182
+ const name = themeName(scheme, base, ctx.ui.theme.getColorMode?.());
167
183
  const theme = name ? ctx.ui.getTheme(name) : undefined;
168
184
  // A Theme object (not a name) changes the theme for this run only; the saved setting stays.
169
185
  if (theme)
170
186
  ctx.ui.setTheme(theme);
187
+ // The working indicator takes the accent of the mode.
188
+ const mark = paintFromTheme(ctx.ui.theme).mark;
189
+ ctx.ui.setWorkingIndicator?.({ frames: workingFrames(glyphs(), Boolean(process.env.SUBSUB_REDUCE_MOTION && process.env.SUBSUB_REDUCE_MOTION !== "0")).map(mark), intervalMs: 160 });
190
+ }
191
+ /**
192
+ * Ask the user to approve a change. With the look on, the preview opens as an overlay with its own keys (see dialogs.ts); otherwise, and over RPC,
193
+ * it is pi's confirm dialog. Only a yes counts: anything else, including an overlay that cannot open, is a no.
194
+ */
195
+ async function approve(ctx, title, body) {
196
+ if (lookOn(ctx) && typeof ctx.ui.custom === "function") {
197
+ const answer = await ctx.ui.custom((tui, theme, _keys, done) => new ApprovalView({ title, body, lang, paint: paintFromTheme(theme), glyphs: glyphs(), done, rows: () => tui.terminal?.rows ?? 24, redraw: () => tui.requestRender() }), { overlay: true, overlayOptions: { width: "92%", minWidth: 40, maxHeight: "92%", anchor: "center", margin: 1 } });
198
+ return answer === true;
199
+ }
200
+ return (await ctx.ui.confirm(title, body)) === true;
201
+ }
202
+ /** The notes and library screens cover the terminal: the conversation is behind them and comes back when they close. */
203
+ const OVERLAY = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 } };
204
+ /** True when the terminal look can show a screen of its own; otherwise says where the same thing is. */
205
+ function screenOk(ctx) {
206
+ if (lookOn(ctx) && typeof ctx.ui.custom === "function")
207
+ return true;
208
+ ctx.ui.notify(L("Sub-Sub: this screen is part of the terminal look. In subsub web, use the Notes button and the library search.", "Sub-Sub: este ecrã faz parte do aspeto do terminal. Em subsub web, use o botão Notas e a pesquisa na biblioteca."), "info");
209
+ return false;
210
+ }
211
+ /** /notes: the notes in the Sub-Sub folder, newest first; the note under the cursor shows beside the list. */
212
+ async function openNotes(ctx) {
213
+ if (!screenOk(ctx))
214
+ return;
215
+ const root = cfg.vault ?? ctx.cwd;
216
+ const notes = listNotes(root);
217
+ const { Markdown } = await import("@earendil-works/pi-tui");
218
+ const { getMarkdownTheme } = await import("@earendil-works/pi-coding-agent");
219
+ await ctx.ui.custom((tui, theme, _keys, done) => new NotesView({
220
+ notes,
221
+ read: (path) => {
222
+ const file = allowedFile(path, root, cfg.vault);
223
+ return file && statSync(file).size < 2_000_000 ? readFileSync(file, "utf8") : "";
224
+ },
225
+ markdown: (text, width) => new Markdown(text, 1, 0, getMarkdownTheme()).render(width),
226
+ open: (path) => {
227
+ const file = allowedFile(path, root, cfg.vault);
228
+ if (file && OPENABLE.has(extname(file).toLowerCase()))
229
+ openFile(file, cfg.vault);
230
+ },
231
+ done: () => done(),
232
+ lang,
233
+ paint: paintFromTheme(theme),
234
+ glyphs: glyphs(),
235
+ rows: () => tui.terminal?.rows ?? 24,
236
+ redraw: () => tui.requestRender(),
237
+ }), OVERLAY);
238
+ }
239
+ /** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
240
+ async function openLibrary(ctx) {
241
+ if (!screenOk(ctx))
242
+ return;
243
+ const ref = (it) => it.citekey || it.key;
244
+ await ctx.ui.custom((tui, theme, _keys, done) => new LibraryView({
245
+ search: async (query) => {
246
+ const r = await current().call("zotero_find_items", { query, limit: 25 });
247
+ if (r.isError)
248
+ throw new Error(r.text);
249
+ return (r.data?.items ?? []).filter((it) => it && it.key);
250
+ },
251
+ choose: (it) => {
252
+ ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
253
+ done();
254
+ },
255
+ note: (it) => {
256
+ ctx.ui.setEditorText(`/lit-note ${ref(it)}`);
257
+ done();
258
+ },
259
+ done: () => done(),
260
+ lang,
261
+ paint: paintFromTheme(theme),
262
+ glyphs: glyphs(),
263
+ researcher: mode === "researcher",
264
+ rows: () => tui.terminal?.rows ?? 24,
265
+ redraw: () => tui.requestRender(),
266
+ }), OVERLAY);
267
+ }
268
+ /** /actions: the buttons of the web view as a list; the choice goes into the box, so the user adds what is missing and presses Enter. */
269
+ async function openActions(ctx) {
270
+ const w = words ?? (await loadWords());
271
+ const list = [...(mode === "researcher" ? w.ACTIONS.research : []), ...w.ACTIONS.library].filter((a) => a.needs !== "starbuck" || starbuckEnabled(cfg));
272
+ const labels = list.map((a) => `${a[lang]} /${a.cmd}`);
273
+ const pick = await ctx.ui.select(L("Actions", "Ações"), labels);
274
+ const a = list[labels.indexOf(pick ?? "")];
275
+ if (a)
276
+ ctx.ui.setEditorText(a.cmd.endsWith(" ") ? `/${a.cmd}` : `/${a.cmd} `);
171
277
  }
172
278
  function paintFor(ctx) {
173
279
  return (text) => ctx.mode === "tui"
174
- ? paintPreview(text, (x) => ctx.ui.theme.fg("success", x), (x) => ctx.ui.theme.fg("error", x), (x) => ctx.ui.theme.fg("text", x))
280
+ ? (() => {
281
+ const p = paintFromTheme(ctx.ui.theme);
282
+ return paintPreview(text, p.ok, p.strike, p.text);
283
+ })()
175
284
  : text;
176
285
  }
177
286
  async function refreshLibrary() {
@@ -190,7 +299,7 @@ export async function createSubsub(pi, deps = {}) {
190
299
  catch {
191
300
  library.zotero = "down";
192
301
  }
193
- headerTui?.requestRender();
302
+ redraw();
194
303
  emitWeb();
195
304
  }
196
305
  function setHeader(ctx) {
@@ -209,26 +318,45 @@ export async function createSubsub(pi, deps = {}) {
209
318
  library,
210
319
  quote,
211
320
  width,
212
- paint: {
213
- mark: (x) => th.fg("accent", x),
214
- text: (x) => th.fg("text", x),
215
- muted: (x) => th.fg("muted", x),
216
- dim: (x) => th.fg("dim", x),
217
- warn: (x) => th.fg("warning", x),
218
- },
321
+ paint: paintFromTheme(th),
322
+ lang,
323
+ glyphs: glyphs(),
219
324
  });
220
325
  },
221
326
  invalidate() { },
222
327
  };
223
328
  });
224
329
  }
330
+ function setFooter(ctx) {
331
+ if (!lookOn(ctx))
332
+ return;
333
+ ctx.ui.setFooter((tui) => {
334
+ footerTui = tui;
335
+ return {
336
+ render: (width) => {
337
+ try {
338
+ const usage = ctx.getContextUsage();
339
+ const totals = usageTotals(ctx.sessionManager.getEntries());
340
+ return footerLines({ mode, lang, library, model: modelId, percent: usage?.percent ?? null, window: usage?.contextWindow, tokens: totals.tokens, cost: totals.cost, down: bridge ? Object.keys(bridge.errors) : [] }, width, paintFromTheme(ctx.ui.theme), glyphs());
341
+ }
342
+ catch {
343
+ return []; // the context is gone after shutdown
344
+ }
345
+ },
346
+ invalidate() { },
347
+ dispose() {
348
+ footerTui = undefined;
349
+ },
350
+ };
351
+ });
352
+ }
225
353
  function status(ctx) {
226
354
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
227
355
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
228
356
  ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
229
357
  if (standalone)
230
358
  ctx.ui.setTitle(`Sub-Sub: ${mode}`);
231
- headerTui?.requestRender();
359
+ redraw();
232
360
  webCtx = ctx;
233
361
  emitWeb();
234
362
  }
@@ -252,6 +380,7 @@ export async function createSubsub(pi, deps = {}) {
252
380
  }
253
381
  if (remember)
254
382
  pi.appendEntry(MODE_ENTRY, { mode });
383
+ applyTheme(ctx);
255
384
  status(ctx);
256
385
  }
257
386
  pi.on("session_start", async (_event, ctx) => {
@@ -279,11 +408,14 @@ export async function createSubsub(pi, deps = {}) {
279
408
  // Judge light or dark before Sub-Sub changes the theme.
280
409
  if (lookOn(ctx))
281
410
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
282
- applyTheme(ctx);
411
+ lookActive = lookOn(ctx);
412
+ if (lookActive)
413
+ words = await loadWords().catch(() => undefined);
283
414
  await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
284
415
  logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
285
416
  if (lookOn(ctx)) {
286
417
  setHeader(ctx);
418
+ setFooter(ctx);
287
419
  void refreshLibrary();
288
420
  }
289
421
  else if (webView) {
@@ -319,6 +451,18 @@ export async function createSubsub(pi, deps = {}) {
319
451
  await applyMode(ctx, m, false, false);
320
452
  });
321
453
  pi.on("model_select", async (_event, ctx) => status(ctx));
454
+ // Plain-word tool lines for Sub-Sub's own tools (the look only); pi's tools keep pi's rows.
455
+ const rows = toolRenderers({ word: (name) => (words ? toolWord(words, lang, name) : name), lang, glyphs, paint: paintFromTheme });
456
+ pi.registerToolRenderer?.((toolName, next) => (lookActive && SERVER_PREFIXES.some((p) => toolName.startsWith(p)) ? rows(toolName) : next()));
457
+ pi.on("tool_execution_start", async (event, ctx) => {
458
+ if (lookActive && words && !event.parentToolCallId)
459
+ ctx.ui.setWorkingMessage?.(`${toolWord(words, lang, event.toolName)}...`);
460
+ });
461
+ pi.on("tool_execution_end", async (_event, ctx) => {
462
+ if (lookActive)
463
+ ctx.ui.setWorkingMessage?.();
464
+ });
465
+ pi.on("turn_end", async () => redraw());
322
466
  // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
323
467
  pi.on("message_end", async (event, ctx) => {
324
468
  const m = event.message;
@@ -433,7 +577,7 @@ export async function createSubsub(pi, deps = {}) {
433
577
  return undefined;
434
578
  if (!ctx.hasUI)
435
579
  return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
436
- const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
580
+ const ok = await approve(ctx, `Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
437
581
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
438
582
  }
439
583
  if (!ctx.hasUI) {
@@ -446,7 +590,7 @@ export async function createSubsub(pi, deps = {}) {
446
590
  if (!verdict.ok && /protected|hidden/.test(verdict.why))
447
591
  return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
448
592
  }
449
- const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
593
+ const ok = await approve(ctx, L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
450
594
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
451
595
  }
452
596
  // kind === "preview": the server computes the change first, then the user decides.
@@ -472,7 +616,7 @@ export async function createSubsub(pi, deps = {}) {
472
616
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
473
617
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
474
618
  for (let round = 0; round < 3; round++) {
475
- const ok = await ctx.ui.confirm(round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
619
+ const ok = await approve(ctx, round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
476
620
  logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
477
621
  if (!ok)
478
622
  return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
@@ -586,7 +730,7 @@ export async function createSubsub(pi, deps = {}) {
586
730
  }
587
731
  if (!ctx.hasUI)
588
732
  return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
589
- const ok = await ctx.ui.confirm(L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
733
+ const ok = await approve(ctx, L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
590
734
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
591
735
  }
592
736
  async function benchGate(name, input, kind, cwd, signal) {
@@ -621,20 +765,30 @@ export async function createSubsub(pi, deps = {}) {
621
765
  `for ${user}.${shown}`,
622
766
  };
623
767
  }
768
+ const switchTo = async (ctx, next) => {
769
+ await applyMode(ctx, next, true);
770
+ ctx.ui.notify(next === "librarian"
771
+ ? L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher.")
772
+ : 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");
773
+ };
624
774
  pi.registerCommand("librarian", {
625
775
  description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
626
- handler: async (_args, ctx) => {
627
- await applyMode(ctx, "librarian", true);
628
- ctx.ui.notify(L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher."), "info");
629
- },
776
+ handler: async (_args, ctx) => switchTo(ctx, "librarian"),
630
777
  });
631
778
  pi.registerCommand("researcher", {
632
779
  description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
633
- handler: async (_args, ctx) => {
634
- await applyMode(ctx, "researcher", true);
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");
636
- },
780
+ handler: async (_args, ctx) => switchTo(ctx, "researcher"),
637
781
  });
782
+ // Screens of the terminal look (the web view has its own panels, so these are only for the `subsub` command).
783
+ if (standalone) {
784
+ pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
785
+ pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
786
+ pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
787
+ pi.registerShortcut?.("alt+n", { description: "Sub-Sub: notes", handler: (ctx) => openNotes(ctx) });
788
+ pi.registerShortcut?.("alt+l", { description: "Sub-Sub: library search", handler: (ctx) => openLibrary(ctx) });
789
+ pi.registerShortcut?.("alt+a", { description: "Sub-Sub: actions", handler: (ctx) => openActions(ctx) });
790
+ pi.registerShortcut?.("alt+m", { description: "Sub-Sub: switch between the Researcher and the Librarian", handler: (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher") });
791
+ }
638
792
  pi.registerCommand("subsub", {
639
793
  description: "Sub-Sub: Zotero connection and library overview",
640
794
  handler: async (_args, ctx) => {
@@ -660,7 +814,7 @@ export async function createSubsub(pi, deps = {}) {
660
814
  : `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`);
661
815
  }
662
816
  }
663
- headerTui?.requestRender();
817
+ redraw();
664
818
  emitWeb();
665
819
  ctx.ui.notify(lines.join("\n"), "info");
666
820
  },
@@ -713,6 +867,12 @@ export async function createSubsub(pi, deps = {}) {
713
867
  // A registered command runs before the input hook, so /read checks the mode itself.
714
868
  pi.registerCommand("read", {
715
869
  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>",
870
+ getArgumentCompletions: (prefix) => {
871
+ if (/\s/.test(prefix.trim()))
872
+ return null;
873
+ const found = Object.keys(PURPOSES).filter((k) => k.startsWith(prefix.trim().toLowerCase()));
874
+ return found.length ? found.map((k) => ({ value: k, label: L(PURPOSES[k][0], PURPOSES[k][1]) })) : null;
875
+ },
716
876
  handler: async (args, ctx) => {
717
877
  if (mode === "librarian")
718
878
  return ctx.ui.notify(researcherOnly("read"), "warning");
@@ -840,7 +1000,7 @@ export async function createSubsub(pi, deps = {}) {
840
1000
  ctx.ui.notify(preview.text, "warning");
841
1001
  return;
842
1002
  }
843
- const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
1003
+ const ok = await approve(ctx, L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
844
1004
  if (!ok)
845
1005
  return;
846
1006
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
package/dist/web.js CHANGED
@@ -15,18 +15,16 @@
15
15
  */
16
16
  import { spawn } from "node:child_process";
17
17
  import { randomBytes, timingSafeEqual } from "node:crypto";
18
- import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
18
+ import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  import { createServer } from "node:http";
20
20
  import { homedir } from "node:os";
21
- import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
21
+ import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
- import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
24
+ import { configPath, 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 { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.js";
28
27
  import { field, frontMatter } from "./notes.js";
29
- import { realish, within } from "./paths.js";
30
28
  export function parseWebArgs(args) {
31
29
  const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
32
30
  for (let i = 0; i < args.length; i++) {
@@ -51,6 +49,9 @@ export function parseWebArgs(args) {
51
49
  export { uiLanguage } from "./config.js";
52
50
  // ---------------------------------------------------------------- providers (API keys)
53
51
  export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
52
+ // ---------------------------------------------------------------- files (shared with the terminal screens)
53
+ import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.js";
54
+ export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
54
55
  /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
55
56
  export class Agent {
56
57
  proc;
@@ -186,41 +187,6 @@ const TYPES = {
186
187
  const NOTE_TYPES = new Set([".md", ".markdown"]);
187
188
  /** Images that a note shows (a figure cut out of a PDF, for example). */
188
189
  const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
189
- /** Files that the Open button may hand to the system (never programs or scripts). */
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
- }
224
190
  const CSP = [
225
191
  "default-src 'none'",
226
192
  "script-src 'self'",
@@ -285,20 +251,6 @@ function alive(pid) {
285
251
  return err.code === "EPERM";
286
252
  }
287
253
  }
288
- /** Open a URL or a file with the system's default program. */
289
- export function openExternal(target) {
290
- const opts = { detached: true, stdio: "ignore", windowsHide: true };
291
- let child;
292
- if (process.platform === "darwin")
293
- child = spawn("open", [target], opts);
294
- // rundll32 hands the target to the default program without cmd.exe parsing it.
295
- else if (process.platform === "win32")
296
- child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
297
- else
298
- child = spawn("xdg-open", [target], opts);
299
- child.on("error", () => { });
300
- child.unref();
301
- }
302
254
  /**
303
255
  * Start the web server and pi. Resolves when both are listening; the caller
304
256
  * decides whether to open a browser. Used by `subsub web` and by the tests.
@@ -741,10 +693,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
741
693
  // Documents only: a file that the model wrote must never be run.
742
694
  if (!OPENABLE.has(extname(target).toLowerCase()))
743
695
  return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
744
- if (vault && within(target, vault) && obsidianRoot(vault))
745
- openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
746
- else
747
- openExternal(target);
696
+ openFile(target, vault);
748
697
  return send(res, 200, { ok: true });
749
698
  }
750
699
  case "POST /api/quit":
package/dist/words.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The plain words of the web view (actions, suggestions, names of tools, in English and Portuguese), for the terminal screens.
3
+ * web/i18n.js is a browser module with no imports; it is loaded here as it is, so the two views never differ.
4
+ */
5
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
6
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
7
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
8
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
9
+ });
10
+ }
11
+ return path;
12
+ };
13
+ import { join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { PACKAGE_DIR } from "./cli.js";
16
+ let words;
17
+ /** A dynamic import: a static one would pull a file outside src/ into the TypeScript build. */
18
+ export function loadWords() {
19
+ words ??= import(__rewriteRelativeImportExtension(pathToFileURL(join(PACKAGE_DIR, "web", "i18n.js")).href));
20
+ return words;
21
+ }
22
+ /** What a tool is called while it runs ("zotero_find_items" is "search the library"); unknown tools keep their name. */
23
+ export function toolWord(w, lang, name) {
24
+ const short = name.replace(/^(zotero|scholar)_/, "");
25
+ return w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",
@@ -8,7 +8,7 @@ Make a short slug: lower case, hyphens, at most five words. Use today's date (YY
8
8
 
9
9
  1. Find the sources.
10
10
  - If the user gave citekeys, DOIs or PMIDs, use exactly those.
11
- - Otherwise: library first (zotero_find_items), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
11
+ - Otherwise: library first (zotero_find_items, with the user's own words and then close synonyms), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
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 |
package/prompts/lit.md CHANGED
@@ -18,7 +18,7 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
18
18
 
19
19
  ## 2. Gather
20
20
 
21
- 1. Library first: zotero_find_items for the topic (and the topic tags). Note what the library already has.
21
+ 1. Library first: zotero_find_items for the topic, with the user's own words first and then with close synonyms and word forms, one call for each (and the topic tags). Note what the library already has.
22
22
  2. Outside: scholar_search_multi with three or four phrasings of the question (synonyms, the test or drug name, the condition name, MeSH terms). Give include_terms (the key concepts) and exclude_terms (populations or designs outside the question) so that the server pre-screens the results. Use year limits when the question needs recent work.
23
23
  3. Screen. For every work in the merged list, decide: read, maybe, or exclude, with a reason of a few words. Prefer systematic reviews, guidelines and primary studies with the right design. Write the screening log to `Research/.plans/<slug>-screening.md`: a table (#, work, identifier, pre-screen, decision, reason) and the counts for a flow diagram: found by the searches, unique works, pre-screen excluded, screened, read, used.
24
24
  4. Read before you summarise:
package/prompts/verify.md CHANGED
@@ -7,12 +7,12 @@ Check the references of $1 with Starbuck.
7
7
  1. If the verify_ tools are not available, tell the user that reference checks need Starbuck: add "addons": ["starbuck"] to the Sub-Sub settings file and start Sub-Sub again. Then stop.
8
8
  2. Call verify_check_manuscript with path $1.
9
9
  3. Report the summary: how many references failed, need checking, have notes and passed.
10
- 4. For each Fail and each Check, give the citation key, Starbuck's reason and its suggested action. Keep Starbuck's result; do not make it stronger or weaker.
10
+ 4. For each Fail and each Check, give the citation key, Starbuck's reason and its suggested action. Keep Starbuck's result; do not make it stronger or weaker. When the reason is that no record was found, write "not confirmed" and say that books, theses, reports, archives and works in Portuguese are often missing from Crossref and PubMed.
11
11
  5. List the cited keys without a bibliography entry.
12
12
  6. Give the path of the HTML report.
13
13
  7. If the second argument is "claims", or the user asks whether the sources support the text:
14
14
  1. Call verify_prepare_claims with path $1. Get all pages (offset, limit).
15
15
  2. Judge each claim with status ready from its passages only: supported, partly_supported, not_supported or cannot_assess. Copy the passage you rely on character for character into quote.
16
16
  3. Call verify_record_claims once, with the verdicts of all claims.
17
- 4. Report the claims that are not supported or only partly supported, with the line and the reason. Say for each whether the evidence was the full text or the abstract only.
17
+ 4. Report the claims that are not supported or only partly supported as sentences to check, not as errors, with the line and the reason. Say for each whether the evidence was the full text or the abstract only.
18
18
  8. Do not change the manuscript. Offer fixes and wait for the user's answer.
@@ -30,7 +30,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
30
30
 
31
31
  ## Questions about the library
32
32
 
33
- - "What do I have on X": zotero_find_items (query, tags, fulltext=true when needed). Answer in the chat: citekey, year, one line per item.
33
+ - "What do I have on X": search with zotero_find_items (query, tags, fulltext=true when needed). First search with {{user}}'s own words, as written. Then search again with close synonyms and other word forms (singular and plural, noun and adjective, spelling variants, an older or foreign name), one call for each. Join the results and list each item once. Answer in the chat: citekey, year, one line per item, and the words you searched.
34
34
 
35
35
  ## Outside search and gaps
36
36
 
@@ -103,10 +103,12 @@ If zotero_get_vocabulary has no topic/ tags, propose topics before tagging: read
103
103
  2. Propose fixes for missing keys. Do not edit the manuscript text without approval.
104
104
  3. scholar_export_bibliography to the file named in the YAML header, or references.json next to the manuscript.
105
105
 
106
- 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.
106
+ 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. Work from quotes. Read the full text (zotero_get_fulltext) of each item you will cite. First choose the quotes: for each sentence of the draft, one passage of one source, word for word, with its page or section. Then write each sentence so that it says no more than its quote says: one source for each sentence, no sentence that joins claims from two sources, no figure, date, name or cause that is not in the quote, and no stronger wording than the quote has. End every factual sentence (a fact, date, number, name, cause or comparison) with [@citekey]. If you have only the abstract or the metadata of an item, say so, and use it for no more than it says. After the draft, give a table "Quotes for the check" with one row for each factual sentence: its number, the citekey, the quote, and the page or section. A sentence that you cannot support with a quote is cut. 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.
107
107
 
108
108
  ## Reference checks (Starbuck, when the verify_ tools are available)
109
109
 
110
110
  - 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.
111
111
  - Report Starbuck's results as Starbuck states them. Do not turn a Check into a Fail, or a Pass into "verified". Give the path of the report.
112
+ - When Starbuck found no record for a reference (a Check with "not found by search"), say "not confirmed", not "wrong". The work may exist: books, theses, reports, archives and works in Portuguese are often not in Crossref or PubMed. Suggest a library catalogue or the publisher's page.
113
+ - Report claim verdicts as sentences for {{user}} to check. Do not change the text or the citation because of a verdict. For a verdict of not supported, write "not supported by the passages I saw" and give the passage.
112
114
  - Claim support (level 4) only when {{user}} asks: verify_prepare_claims, then judge each claim from its passages only, then verify_record_claims with all verdicts. Quote the passage you rely on character for character. With an abstract only, answer cannot_assess for details an abstract would not report.