@tiagojct/subsub 0.21.0 → 0.22.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
@@ -14,20 +14,15 @@
14
14
  * stops by itself, so a shortcut can start it without a terminal.
15
15
  */
16
16
 
17
- import { spawn, type ChildProcess } from "node:child_process";
18
17
  import { randomBytes, timingSafeEqual } from "node:crypto";
19
18
  import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
19
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
20
  import { homedir } from "node:os";
22
21
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
23
- import { StringDecoder } from "node:string_decoder";
24
22
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
23
+ import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
26
24
  import { TESTED_MODELS } from "./init.ts";
27
25
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
28
- import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
29
- import { field, frontMatter } from "./notes.ts";
30
- import { realish, within } from "./paths.ts";
31
26
 
32
27
  // ---------------------------------------------------------------- options
33
28
 
@@ -68,130 +63,10 @@ export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
68
63
  import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.ts";
69
64
  export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
70
65
 
71
- // ---------------------------------------------------------------- pi in RPC mode
72
-
73
- type Listener = (ev: any) => void;
74
-
75
- /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
76
- export class Agent {
77
- proc?: ChildProcess;
78
- private decoder = new StringDecoder("utf8");
79
- private buf = "";
80
- private n = 0;
81
- private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
82
- private listeners = new Set<Listener>();
83
- stderr = "";
84
- onExit?: (code: number | null) => void;
85
-
86
- private readonly command: string;
87
- private readonly args: string[];
88
- private readonly cwd: string;
89
- private readonly env: NodeJS.ProcessEnv;
90
-
91
- constructor(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv) {
92
- this.command = command;
93
- this.args = args;
94
- this.cwd = cwd;
95
- this.env = env;
96
- }
97
-
98
- start(extra: string[] = []): void {
99
- this.buf = "";
100
- this.decoder = new StringDecoder("utf8");
101
- const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
102
- this.proc = proc;
103
- proc.stdout!.on("data", (chunk: Buffer) => this.feed(this.decoder.write(chunk)));
104
- proc.stderr!.on("data", (d: Buffer) => {
105
- this.stderr = (this.stderr + d.toString()).slice(-20_000);
106
- });
107
- proc.once("exit", (code) => {
108
- if (this.proc !== proc) return;
109
- this.proc = undefined;
110
- for (const p of this.pending.values()) {
111
- clearTimeout(p.timer);
112
- p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
113
- }
114
- this.pending.clear();
115
- this.onExit?.(code);
116
- });
117
- }
118
-
119
- private feed(text: string): void {
120
- this.buf += text;
121
- let i: number;
122
- while ((i = this.buf.indexOf("\n")) >= 0) {
123
- const line = this.buf.slice(0, i).replace(/\r$/, "");
124
- this.buf = this.buf.slice(i + 1);
125
- if (!line.trim()) continue;
126
- let rec: any;
127
- try {
128
- rec = JSON.parse(line);
129
- } catch {
130
- continue;
131
- }
132
- if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
133
- const p = this.pending.get(rec.id)!;
134
- this.pending.delete(rec.id);
135
- clearTimeout(p.timer);
136
- if (rec.success === false) p.reject(new Error(rec.error ?? `${rec.command} failed`));
137
- else p.resolve(rec.data);
138
- continue;
139
- }
140
- for (const l of this.listeners) l(rec);
141
- }
142
- }
143
-
144
- on(l: Listener): () => void {
145
- this.listeners.add(l);
146
- return () => this.listeners.delete(l);
147
- }
148
-
149
- write(rec: unknown): void {
150
- const stdin = this.proc?.stdin;
151
- if (!stdin || !stdin.writable) throw new Error("pi is not running");
152
- stdin.write(`${JSON.stringify(rec)}\n`);
153
- }
154
-
155
- request<T = any>(cmd: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
156
- const id = `w${++this.n}`;
157
- return new Promise<T>((resolvePromise, reject) => {
158
- const timer = setTimeout(() => {
159
- this.pending.delete(id);
160
- reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
161
- }, timeoutMs);
162
- this.pending.set(id, { resolve: resolvePromise, reject, timer });
163
- try {
164
- this.write({ ...cmd, id });
165
- } catch (err) {
166
- clearTimeout(timer);
167
- this.pending.delete(id);
168
- reject(err as Error);
169
- }
170
- });
171
- }
66
+ // ---------------------------------------------------------------- pi in RPC mode (src/rpc.ts)
172
67
 
173
- async stop(): Promise<void> {
174
- const proc = this.proc;
175
- if (!proc) return;
176
- this.proc = undefined;
177
- for (const p of this.pending.values()) {
178
- clearTimeout(p.timer);
179
- p.reject(new Error("pi was stopped"));
180
- }
181
- this.pending.clear();
182
- await new Promise<void>((done) => {
183
- const t = setTimeout(() => {
184
- proc.kill("SIGKILL");
185
- done();
186
- }, 3000);
187
- proc.once("exit", () => {
188
- clearTimeout(t);
189
- done();
190
- });
191
- proc.kill();
192
- });
193
- }
194
- }
68
+ import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
69
+ export { Agent };
195
70
 
196
71
  // ---------------------------------------------------------------- helpers
197
72
 
@@ -223,14 +98,6 @@ const CSP = [
223
98
  "frame-ancestors 'none'",
224
99
  ].join("; ");
225
100
 
226
- function readText(path: string): string {
227
- try {
228
- return readFileSync(path, "utf8");
229
- } catch {
230
- return "";
231
- }
232
- }
233
-
234
101
  function same(a: string, b: string): boolean {
235
102
  const x = Buffer.from(a);
236
103
  const y = Buffer.from(b);
@@ -319,7 +186,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
319
186
  const clients = new Set<ServerResponse>();
320
187
  const dialogs = new Map<string, any>();
321
188
  let subsubState: unknown = null;
322
- let lastFind: { q?: string } | null = null;
189
+ const side = new Side();
323
190
  let busy = false;
324
191
  let lastSeen = Date.now();
325
192
  let restarting = false;
@@ -344,22 +211,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
344
211
 
345
212
  agent.on((ev) => {
346
213
  if (ev.type === "extension_ui_request") {
347
- if (["select", "confirm", "input", "editor"].includes(ev.method)) {
214
+ if (isDialog(ev)) {
348
215
  dialogs.set(ev.id, ev);
349
216
  broadcast(ev);
350
- } else if (ev.method === "setStatus" && ev.statusKey === "subsub.web") {
351
- try {
352
- subsubState = ev.statusText ? JSON.parse(ev.statusText) : null;
353
- } catch {
354
- subsubState = null;
355
- }
217
+ } else if (side.take(ev) === "state") {
218
+ subsubState = side.state;
356
219
  broadcast({ type: "subsub_state", state: subsubState });
357
- } else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
358
- try {
359
- lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
360
- } catch {
361
- lastFind = null;
362
- }
363
220
  } else if (ev.method === "notify") {
364
221
  broadcast(ev);
365
222
  }
@@ -401,13 +258,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
401
258
  out.session = await agent.request({ type: "get_state" });
402
259
  lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
403
260
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
404
- const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
405
- // RPC has no argument hints: read them from the prompt files.
406
- out.commands = commands.map((c) => {
407
- const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
408
- const hint = front && field(front, "argument-hint");
409
- return hint ? { ...c, hint } : c;
410
- });
261
+ out.commands = withHints(((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands);
411
262
  } catch (err) {
412
263
  out.error = (err as Error).message;
413
264
  }
@@ -658,8 +509,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
658
509
  case "GET /api/library": {
659
510
  const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
660
511
  if (!q) return send(res, 200, { q, items: [] });
661
- await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
662
- return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
512
+ return send(res, 200, await findLibrary(agent, side, q));
663
513
  }
664
514
  case "POST /api/session/name": {
665
515
  const body = await readJson(req);