@tiagojct/subsub 0.20.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.20.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",
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ // Writes the pi themes of Sub-Sub (themes/subsub-<name>-<scheme>.json) from the colours in the front matter of
3
+ // DESIGN.md and the role map in scripts/plate.json. The Researcher uses Prussian blue as its accent (glauca), the
4
+ // Librarian madder (try-works). Errors have no colour of their own, removals are muted ink (the strike-through is
5
+ // drawn by the code), and every meaning also has a glyph or a word, so the themes work for readers who do not see the washes.
6
+ //
7
+ // node scripts/make-themes.mjs
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+
12
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const SCHEMA = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json";
14
+
15
+ /** The `colors:` block of the DESIGN.md front matter as {token: "#rrggbb"}. */
16
+ export function designColors(markdown) {
17
+ const colors = {};
18
+ let inBlock = false;
19
+ for (const line of markdown.split("\n")) {
20
+ if (/^colors:\s*$/.test(line)) inBlock = true;
21
+ else if (inBlock && /^\S/.test(line)) break;
22
+ else if (inBlock) {
23
+ const m = line.match(/^\s+([a-z0-9-]+):\s*"(#[0-9a-fA-F]{6})"\s*$/);
24
+ if (m) colors[m[1]] = m[2].toLowerCase();
25
+ }
26
+ }
27
+ return colors;
28
+ }
29
+
30
+
31
+ const CUBE = [0, 95, 135, 175, 215, 255];
32
+ /** The RGB of an xterm-256 colour index from 16 to 255 (the 16 system colours depend on the terminal). */
33
+ export function xterm256(i) {
34
+ if (i >= 232) return [8 + (i - 232) * 10, 8 + (i - 232) * 10, 8 + (i - 232) * 10];
35
+ const n = i - 16;
36
+ return [CUBE[Math.floor(n / 36)], CUBE[Math.floor(n / 6) % 6], CUBE[n % 6]];
37
+ }
38
+ const toRgb = (h) => [1, 3, 5].map((k) => parseInt(h.slice(k, k + 2), 16));
39
+ const luminance = (rgb) => {
40
+ const [r, g, b] = rgb.map((v) => v / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
41
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
42
+ };
43
+ export function contrastRatio(a, b) {
44
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
45
+ return (hi + 0.05) / (lo + 0.05);
46
+ }
47
+
48
+ /** The xterm-256 index nearest to a colour that reaches `min` contrast against `against` (the closest index with enough contrast, or the one with the most). */
49
+ export function nearest256(hex, against, min) {
50
+ const want = toRgb(hex);
51
+ const ranked = [];
52
+ for (let i = 16; i < 256; i++) {
53
+ const c = xterm256(i);
54
+ ranked.push({ i, d: (c[0] - want[0]) ** 2 * 0.3 + (c[1] - want[1]) ** 2 * 0.59 + (c[2] - want[2]) ** 2 * 0.11, k: against ? contrastRatio(c, against) : Infinity });
55
+ }
56
+ ranked.sort((a, b) => a.d - b.d);
57
+ return (ranked.find((r) => r.k >= min) ?? ranked.sort((a, b) => b.k - a.k)[0]).i;
58
+ }
59
+
60
+ // Contrast that each colour variable needs against the terminal's own background: text 4.5, lines and bullets 3, rules none.
61
+ const NEEDS = { ink: 4.5, muted: 4.5, faint: 4.5, prussian: 4.5, madder: 4.5, sap: 4.5, gamboge: 4.5, mark: 3, rule: 0 };
62
+ // Roles whose background is left to the terminal in the 256-colour themes: a wash that is quantised to the cube loses its contrast.
63
+ const DEFAULT_BG = ["userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "searchMatchBg"];
64
+
65
+ /** The same theme with xterm-256 indexes, for terminals without truecolor. */
66
+ function to256(theme) {
67
+ const v = theme.vars;
68
+ const sheet = toRgb(v.sheet);
69
+ const vars = {};
70
+ for (const name of Object.keys(NEEDS)) vars[name] = nearest256(v[name], sheet, NEEDS[name]);
71
+ vars.accent = vars[Object.keys(NEEDS).find((n) => v[n] === v.accent)];
72
+ // The selection needs the text colour to stay readable on it.
73
+ vars.wash = nearest256(v.wash, xterm256(vars.ink), 4.5);
74
+ const colors = { ...theme.colors };
75
+ for (const r of DEFAULT_BG) colors[r] = "";
76
+ return { ...theme, name: `${theme.name}-256`, vars, colors, export: theme.export };
77
+ }
78
+
79
+ /** {"subsub-glauca-dark": theme, "subsub-glauca-dark-256": theme, ...} for every theme and scheme of the plate file. */
80
+ export function buildThemes(designMd, plate) {
81
+ const design = designColors(designMd);
82
+ const out = {};
83
+ for (const [name, mode] of Object.entries(plate.themes)) {
84
+ for (const scheme of ["dark", "light"]) {
85
+ const vars = {};
86
+ for (const [v, key] of Object.entries(plate.tokens[scheme])) {
87
+ if (!design[key]) throw new Error(`DESIGN.md has no colour "${key}" (${scheme} ${v})`);
88
+ vars[v] = design[key];
89
+ }
90
+ vars.accent = vars[mode.accent];
91
+ vars.wash = vars[mode.wash];
92
+ const colors = {};
93
+ for (const [role, ref] of Object.entries(plate.roles)) colors[role] = ref === "$accent" ? "accent" : ref === "$wash" ? "wash" : ref;
94
+ const id = `subsub-${name}-${scheme}`;
95
+ out[id] = { $schema: SCHEMA, name: id, vars, colors, export: { pageBg: vars.sheet, cardBg: vars.field, infoBg: vars.sunk } };
96
+ out[`${id}-256`] = to256(out[id]);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function main() {
103
+ const design = fs.readFileSync(path.join(ROOT, "DESIGN.md"), "utf8");
104
+ const plate = JSON.parse(fs.readFileSync(path.join(ROOT, "scripts", "plate.json"), "utf8"));
105
+ fs.mkdirSync(path.join(ROOT, "themes"), { recursive: true });
106
+ for (const [id, theme] of Object.entries(buildThemes(design, plate))) {
107
+ fs.writeFileSync(path.join(ROOT, "themes", `${id}.json`), JSON.stringify(theme, null, "\t") + "\n");
108
+ console.log(`themes/${id}.json`);
109
+ }
110
+ }
111
+
112
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();