@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/src/web.ts CHANGED
@@ -16,16 +16,17 @@
16
16
 
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomBytes, timingSafeEqual } from "node:crypto";
19
- import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
19
+ import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
20
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
21
  import { homedir } from "node:os";
22
- import { extname, join, normalize, resolve, sep } from "node:path";
22
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
25
  import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
27
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
28
- import { obsidianRoot } from "./layout.ts";
28
+ import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
29
+ import { field, frontMatter } from "./notes.ts";
29
30
  import { realish, within } from "./paths.ts";
30
31
 
31
32
  // ---------------------------------------------------------------- options
@@ -199,9 +200,46 @@ const TYPES: Record<string, string> = {
199
200
  ".ico": "image/x-icon",
200
201
  };
201
202
 
203
+ /** Files that the notes pane shows. */
204
+ const NOTE_TYPES = new Set([".md", ".markdown"]);
205
+
202
206
  /** Files that the Open button may hand to the system (never programs or scripts). */
203
207
  export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
204
208
 
209
+ /** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
210
+ export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
211
+ // expand() resolves against the process folder: use it for "~" only.
212
+ const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
213
+ const real = realish(target);
214
+ const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
215
+ return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
216
+ }
217
+
218
+ /** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
219
+ export function wikilinkTarget(name: string, root: string): string | undefined {
220
+ const file = name.endsWith(".md") ? name : `${name}.md`;
221
+ return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
222
+ }
223
+
224
+ /** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
225
+ export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
226
+ const out: Array<{ path: string; rel: string; modified: number }> = [];
227
+ for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
228
+ let names: string[] = [];
229
+ try {
230
+ names = readdirSync(join(root, d), { recursive: true }) as string[];
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (const n of names) {
235
+ if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
236
+ const path = join(root, d, n);
237
+ out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
238
+ }
239
+ }
240
+ return out.sort((a, b) => b.modified - a.modified).slice(0, max);
241
+ }
242
+
205
243
  const CSP = [
206
244
  "default-src 'none'",
207
245
  "script-src 'self'",
@@ -214,6 +252,14 @@ const CSP = [
214
252
  "frame-ancestors 'none'",
215
253
  ].join("; ");
216
254
 
255
+ function readText(path: string): string {
256
+ try {
257
+ return readFileSync(path, "utf8");
258
+ } catch {
259
+ return "";
260
+ }
261
+ }
262
+
217
263
  function same(a: string, b: string): boolean {
218
264
  const x = Buffer.from(a);
219
265
  const y = Buffer.from(b);
@@ -300,6 +346,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
300
346
  const agentDir = agentDirFor(env, home);
301
347
  const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
302
348
  const lang = uiLanguage(cfg.language);
349
+ const notesRoot = cfg.vault ?? cwd;
303
350
  const token = randomBytes(24).toString("base64url");
304
351
  const webDir = join(PACKAGE_DIR, "web");
305
352
  const bin = join(PACKAGE_DIR, "bin", "subsub.js");
@@ -313,6 +360,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
313
360
  const clients = new Set<ServerResponse>();
314
361
  const dialogs = new Map<string, any>();
315
362
  let subsubState: unknown = null;
363
+ let lastFind: { q?: string } | null = null;
316
364
  let busy = false;
317
365
  let lastSeen = Date.now();
318
366
  let restarting = false;
@@ -347,6 +395,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
347
395
  subsubState = null;
348
396
  }
349
397
  broadcast({ type: "subsub_state", state: subsubState });
398
+ } else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
399
+ try {
400
+ lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
401
+ } catch {
402
+ lastFind = null;
403
+ }
350
404
  } else if (ev.method === "notify") {
351
405
  broadcast(ev);
352
406
  }
@@ -388,13 +442,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
388
442
  out.session = await agent.request({ type: "get_state" });
389
443
  lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
390
444
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
391
- out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
445
+ const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
446
+ // RPC has no argument hints: read them from the prompt files.
447
+ out.commands = commands.map((c) => {
448
+ const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
449
+ const hint = front && field(front, "argument-hint");
450
+ return hint ? { ...c, hint } : c;
451
+ });
392
452
  } catch (err) {
393
453
  out.error = (err as Error).message;
394
454
  }
395
455
  return out;
396
456
  }
397
457
 
458
+ async function currentSession(): Promise<string | undefined> {
459
+ return ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
460
+ }
461
+
398
462
  async function sessions(): Promise<unknown[]> {
399
463
  const { SessionManager } = await import("@earendil-works/pi-coding-agent");
400
464
  const list = await SessionManager.list(cwd);
@@ -521,7 +585,8 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
521
585
  const message = String(body.message ?? "").trim();
522
586
  if (!message) return send(res, 400, { error: "empty message" });
523
587
  try {
524
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
588
+ // A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
589
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
525
590
  } catch (err) {
526
591
  // No provider yet: the page opens the model dialog instead of showing pi's text.
527
592
  if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
@@ -609,14 +674,54 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
609
674
  if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
610
675
  });
611
676
  }
677
+ case "GET /api/notes":
678
+ return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
679
+ case "GET /api/note": {
680
+ const raw = url.searchParams.get("path") ?? "";
681
+ const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
682
+ // A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
683
+ const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
684
+ if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
685
+ if (!NOTE_TYPES.has(extname(file).toLowerCase())) return send(res, 415, { error: "not a note" });
686
+ if (statSync(file).size > 2_000_000) return send(res, 413, { error: "too large to show" });
687
+ return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
688
+ }
689
+ case "GET /api/library": {
690
+ const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
691
+ if (!q) return send(res, 200, { q, items: [] });
692
+ await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
693
+ return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
694
+ }
695
+ case "POST /api/session/name": {
696
+ const body = await readJson(req);
697
+ const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
698
+ if (!name || name.length > 120) return send(res, 400, { error: "a name of 1 to 120 characters" });
699
+ const known = (await sessions()) as Array<{ path: string }>;
700
+ if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
701
+ if (body.path === (await currentSession())) await agent.request({ type: "set_session_name", name });
702
+ else {
703
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
704
+ SessionManager.open(String(body.path)).appendSessionInfo(name);
705
+ }
706
+ return send(res, 200, { ok: true });
707
+ }
708
+ case "POST /api/session/trash": {
709
+ const body = await readJson(req);
710
+ const known = (await sessions()) as Array<{ path: string }>;
711
+ if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
712
+ if (body.path === (await currentSession())) return send(res, 409, { error: "the open conversation" });
713
+ // Nothing is deleted: the file moves to a trash folder next to the agent folder.
714
+ const trash = join(dirname(agentDir), "trash");
715
+ mkdirSync(trash, { recursive: true });
716
+ renameSync(String(body.path), join(trash, basename(String(body.path))));
717
+ return send(res, 200, { ok: true });
718
+ }
612
719
  case "POST /api/open": {
613
720
  const body = await readJson(req);
614
- const target = resolve(cwd, expand(String(body.path ?? "")));
615
721
  const vault = cfg.vault;
616
722
  // Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
617
- const real = realish(target);
618
- const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
619
- if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
723
+ const target = allowedFile(String(body.path ?? ""), cwd, vault);
724
+ if (!target) return send(res, 404, { error: "not found in the Sub-Sub folder" });
620
725
  // Documents only: a file that the model wrote must never be run.
621
726
  if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
622
727
  if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
@@ -44,7 +44,7 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
44
44
  - Do not copy the abstract in full. Give page numbers for quotes when the text shows them.
45
45
  - If a note for the citekey exists, do not overwrite it. Add a dated section or ask.
46
46
  - After the note is written, offer to link it in Zotero with attach_note.
47
- - The profile can change this format: the Reader profile writes quotes and questions instead of a summary.
47
+ - /read writes a reading note instead: quotes with their place and questions, which the user answers.
48
48
 
49
49
  Template:
50
50
 
package/web/app.css CHANGED
@@ -195,6 +195,8 @@ select:hover { border-color: var(--rule-strong); }
195
195
  }
196
196
  .app.side-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
197
197
  .app.side-collapsed .side { display: none; }
198
+ .app.note-open { grid-template-columns: var(--side-w) minmax(0, 1fr) minmax(20rem, 36%); }
199
+ .app.note-open.side-collapsed { grid-template-columns: 0 minmax(0, 1fr) minmax(20rem, 40%); }
198
200
 
199
201
  /* ---------------------------------------------------------------- top bar */
200
202
 
@@ -274,6 +276,8 @@ select:hover { border-color: var(--rule-strong); }
274
276
  /* ---------------------------------------------------------------- side panel */
275
277
 
276
278
  .side {
279
+ /* The containing block of the switch's hidden input: without it, the page itself scrolls. */
280
+ position: relative;
277
281
  border-right: 1px solid var(--rule);
278
282
  background: var(--surface);
279
283
  padding: 0.9rem 0.75rem 0.75rem;
@@ -736,7 +740,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
736
740
  padding: 0.3rem;
737
741
  z-index: 5;
738
742
  }
739
- .suggest button { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
743
+ .suggest button, .suggest .hint-row { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
740
744
  .suggest button[aria-selected="true"], .suggest button:hover { background: var(--accent-soft); }
741
745
  .suggest .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); white-space: nowrap; }
742
746
  .suggest .desc { color: var(--muted); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -792,6 +796,10 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
792
796
 
793
797
  .modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
794
798
  .modal-buttons button { min-width: 5.5rem; }
799
+ /* Long choices (the reasons of /read) read better as a list than as a row of buttons. */
800
+ .modal-buttons.choices { flex-direction: column; align-items: stretch; }
801
+ .modal-buttons.choices button { text-align: left; }
802
+ .modal-buttons.choices .ghost { align-self: flex-end; }
795
803
  .modal-shortcut-hint { font-size: 0.78rem; color: var(--faint); margin-right: auto; }
796
804
  .modal-head { display: flex; justify-content: space-between; align-items: center; }
797
805
  .modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select {
@@ -845,9 +853,58 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
845
853
  .side .phone-only { display: none; }
846
854
  .settings { display: grid; gap: 0.6rem; padding: 0 0.3rem; }
847
855
  .settings label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: var(--muted); }
848
- .settings select, .settings button { width: 100%; text-align: left; color: var(--text); }
856
+ .settings button { width: 100%; text-align: left; color: var(--text); }
849
857
  .settings .model-btn { font-family: var(--mono); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
850
858
 
859
+ /* ---------------------------------------------------------------- library search and notes */
860
+
861
+ #notes-btn { font-family: var(--sans); }
862
+ #notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
863
+
864
+ .items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
865
+ .items:empty { display: none; }
866
+ .items > li { padding: 0.4rem 0.5rem; border-radius: 5px; }
867
+ .items > li:hover { background: var(--bg); }
868
+ .items > li.muted { font-size: 0.82rem; }
869
+ .item-title { font-size: 0.86rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
870
+ .item-meta { font-size: 0.72rem; color: var(--faint); margin-top: 0.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
871
+ .item-actions { display: flex; gap: 0.6rem; margin-top: 0.2rem; }
872
+ .item-actions button, .item-actions a { font-size: 0.76rem; color: var(--accent); padding: 0; text-decoration: none; }
873
+ .item-actions button:hover, .item-actions a:hover { text-decoration: underline; text-underline-offset: 0.18em; }
874
+
875
+ .sessions li { position: relative; }
876
+ .session-tools { position: absolute; top: 0.3rem; right: 0.3rem; display: none; gap: 0.3rem; background: var(--raised); border-radius: 4px; }
877
+ .sessions li:hover .session-tools, .sessions li:focus-within .session-tools { display: flex; }
878
+ /* No hover on a touch screen: the buttons stay under the conversation. */
879
+ @media (hover: none) { .session-tools { display: flex; position: static; justify-content: flex-end; background: none; } }
880
+ .session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
881
+ .session-tools button:hover { color: var(--text); background: var(--bg); }
882
+
883
+ .note-pane {
884
+ grid-row: 2;
885
+ grid-column: 3;
886
+ border-left: 1px solid var(--rule);
887
+ background: var(--surface);
888
+ display: flex;
889
+ flex-direction: column;
890
+ min-height: 0;
891
+ min-width: 0;
892
+ }
893
+ .note-pane[hidden] { display: none; }
894
+ .note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
895
+ .note-name { flex: 1; min-width: 0; font-size: 0.84rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
896
+ .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
897
+ .note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
898
+ .note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
899
+ .note-body .front summary { cursor: pointer; }
900
+ .note-body .front pre { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.5rem 0.7rem; margin: 0.4rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
901
+ .note-list { list-style: none; margin: 0; padding: 0; }
902
+ .note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 5px; display: grid; gap: 0.1rem; }
903
+ .note-list button:hover { background: var(--bg); }
904
+ .note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
905
+ .note-list .when { font-size: 0.72rem; color: var(--faint); }
906
+ a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
907
+
851
908
  @media (max-width: 64rem) {
852
909
  .composer-hint { display: none; }
853
910
  }
@@ -865,6 +922,8 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
865
922
  box-shadow: var(--shadow);
866
923
  }
867
924
  .side.open { transform: none; }
925
+ .app.note-open, .app.note-open.side-collapsed { grid-template-columns: minmax(0, 1fr); }
926
+ .note-pane { position: fixed; z-index: 25; inset: 0; border-left: 0; }
868
927
  .top { padding: 0.5rem 0.75rem; gap: 0.5rem; }
869
928
  .wide-only { display: none !important; }
870
929
  .library .library-text { display: none; }