@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/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/dist/chrome.js +171 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/screens.js +314 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +189 -29
- package/dist/web.js +7 -58
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/screens.ts +374 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +206 -34
- package/src/web.ts +6 -51
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
@@ -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();
|