@tiagojct/subsub 0.11.0 → 0.11.2

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
@@ -22,7 +22,7 @@ import { homedir } from "node:os";
22
22
  import { 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
- import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
25
+ import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
26
  import { TESTED_MODELS } from "./init.ts";
27
27
  import { obsidianRoot } from "./layout.ts";
28
28
  import { realish, within } from "./paths.ts";
@@ -336,7 +336,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
336
336
  env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
337
337
 
338
338
  const childEnv: NodeJS.ProcessEnv = { ...env, SUBSUB_WEB: "1" };
339
- const baseArgs = [bin, "--mode", "rpc", "--here", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
339
+ const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
340
340
  const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
341
341
 
342
342
  // ---- state shared with the pages
@@ -346,8 +346,20 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
346
346
  let busy = false;
347
347
  let lastSeen = Date.now();
348
348
  let restarting = false;
349
+ let switching = false; // a restart for a new model, add-on or key is under way
350
+ let lastSession: string | undefined;
349
351
  let refreshAfterRun = false;
350
352
 
353
+ /** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
354
+ function modeArgs(): string[] {
355
+ const shown = (subsubState as { mode?: string } | null)?.mode;
356
+ if (shown === "librarian" || shown === "researcher") return [`--${shown}`];
357
+ return opts.librarian ? ["--librarian"] : [];
358
+ }
359
+ function startAgent(): void {
360
+ agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
361
+ }
362
+
351
363
  function broadcast(ev: unknown): void {
352
364
  const data = `data: ${JSON.stringify(ev)}\n\n`;
353
365
  for (const c of clients) c.write(data);
@@ -384,12 +396,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
384
396
  agent.onExit = (code) => {
385
397
  if (restarting) return;
386
398
  busy = false;
399
+ for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
387
400
  dialogs.clear();
388
401
  broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
389
402
  log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
390
403
  };
391
404
 
392
- agent.start();
405
+ startAgent();
393
406
 
394
407
  async function snapshot(): Promise<Record<string, unknown>> {
395
408
  const out: Record<string, unknown> = {
@@ -403,6 +416,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
403
416
  };
404
417
  try {
405
418
  out.session = await agent.request({ type: "get_state" });
419
+ lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
406
420
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
407
421
  out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
408
422
  } catch (err) {
@@ -422,22 +436,37 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
422
436
  }
423
437
 
424
438
  async function restartAgent(): Promise<void> {
425
- let sessionFile: string | undefined;
426
439
  try {
427
- sessionFile = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
440
+ lastSession = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile ?? lastSession;
428
441
  } catch {
429
- /* start fresh */
442
+ /* keep the last one known */
430
443
  }
431
444
  restarting = true;
432
445
  for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
433
446
  dialogs.clear();
434
- await agent.stop();
435
- restarting = false;
447
+ try {
448
+ await agent.stop();
449
+ } finally {
450
+ restarting = false;
451
+ }
436
452
  busy = false;
437
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
453
+ startAgent();
438
454
  broadcast(await snapshot());
439
455
  }
440
456
 
457
+ /** Run a change that restarts pi, one at a time. */
458
+ async function withRestart(res: ServerResponse, change: () => Promise<void> | void): Promise<void> {
459
+ if (busy || switching) return send(res, 409, { error: "busy" });
460
+ switching = true;
461
+ try {
462
+ await change();
463
+ await restartAgent();
464
+ } finally {
465
+ switching = false;
466
+ }
467
+ return send(res, 200, { ok: true });
468
+ }
469
+
441
470
  // ---- idle stop
442
471
  let closing = false;
443
472
  const idleTimer = setInterval(() => {
@@ -521,7 +550,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
521
550
  const body = await readJson(req);
522
551
  const message = String(body.message ?? "").trim();
523
552
  if (!message) return send(res, 400, { error: "empty message" });
524
- await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
553
+ try {
554
+ await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
555
+ } catch (err) {
556
+ // No provider yet: the page opens the model dialog instead of showing pi's text.
557
+ if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
558
+ throw err;
559
+ }
525
560
  return send(res, 200, { ok: true });
526
561
  }
527
562
  case "POST /api/abort":
@@ -565,40 +600,44 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
565
600
  return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
566
601
  }
567
602
  case "POST /api/model": {
568
- if (busy) return send(res, 409, { error: "busy" });
603
+ if (busy || switching) return send(res, 409, { error: "busy" });
569
604
  const body = await readJson(req);
570
605
  const spec = `${String(body.provider)}/${String(body.id)}`;
571
606
  // Remember the model for the current mode (or both), then restart pi so the extension reads it.
572
607
  const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
573
- const models: Record<string, string> = { ...(loadConfig(env).models ?? {}), [mode]: spec };
608
+ // Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
609
+ const raw = readConfigFile(configPath(env));
610
+ const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
611
+ const models: Record<string, string> = { ...saved, [mode]: spec };
574
612
  if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
575
- saveConfig({ models, model: undefined }, env);
576
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
577
- await restartAgent();
578
- return send(res, 200, { ok: true });
613
+ return withRestart(res, async () => {
614
+ saveConfig({ models, model: undefined }, env);
615
+ // The open conversation records the new model, so it keeps it after the restart.
616
+ await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => {});
617
+ });
579
618
  }
580
619
  case "POST /api/addons": {
581
620
  // Turn an add-on on or off, then restart pi so the extension starts or stops its server.
582
- if (busy) return send(res, 409, { error: "busy" });
621
+ if (busy || switching) return send(res, 409, { error: "busy" });
583
622
  const body = await readJson(req);
584
623
  if (typeof body.starbuck !== "boolean") return send(res, 400, { error: "starbuck must be true or false" });
585
- saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
586
- await restartAgent();
587
- return send(res, 200, { ok: true });
624
+ return withRestart(res, () => {
625
+ saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
626
+ });
588
627
  }
589
628
  case "POST /api/restart":
590
- if (agent.proc) return send(res, 200, { ok: true });
591
- agent.start();
629
+ if (agent.proc || switching) return send(res, 200, { ok: true });
630
+ startAgent();
592
631
  broadcast(await snapshot());
593
632
  return send(res, 200, { ok: true });
594
633
  case "POST /api/login": {
595
- if (busy) return send(res, 409, { error: "busy" });
634
+ if (busy || switching) return send(res, 409, { error: "busy" });
596
635
  const body = await readJson(req);
597
- saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
598
- const current = loadConfig(env);
599
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
600
- await restartAgent();
601
- return send(res, 200, { ok: true });
636
+ return withRestart(res, () => {
637
+ saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
638
+ const current = loadConfig(env);
639
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
640
+ });
602
641
  }
603
642
  case "POST /api/open": {
604
643
  const body = await readJson(req);
package/web/app.css CHANGED
@@ -158,6 +158,7 @@ button:disabled { opacity: 0.45; cursor: default; }
158
158
  .icon-btn:hover { color: var(--text); background: var(--surface); }
159
159
  .icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
160
160
 
161
+ .model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
161
162
  .chip-btn {
162
163
  max-width: 16rem;
163
164
  overflow: hidden;
package/web/app.js CHANGED
@@ -183,7 +183,9 @@ function renderHeader() {
183
183
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
184
184
  $("starbuck-label").title = t("starbuckHint");
185
185
  const m = state?.model ?? session?.model;
186
- $("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
186
+ const none = !m || !m.id || m.id === "unknown";
187
+ $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
188
+ for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
187
189
  const lib = $("library");
188
190
  const txt = lib.querySelector(".library-text") ?? lib;
189
191
  const L = state?.library;
@@ -650,6 +652,7 @@ function onEvent(ev) {
650
652
  setBusy(!!ev.busy);
651
653
  dialogs.length = 0;
652
654
  for (const d of ev.dialogs ?? []) dialogs.push(d);
655
+ dropStaleDialog();
653
656
  nextDialog();
654
657
  loadSessions();
655
658
  break;
@@ -743,6 +746,8 @@ function onEvent(ev) {
743
746
  break;
744
747
  case "agent_exit":
745
748
  setBusy(false);
749
+ dialogs.length = 0;
750
+ dropStaleDialog();
746
751
  log.append(
747
752
  el(
748
753
  "div",
@@ -776,6 +781,7 @@ function dialogTitle(d) {
776
781
  if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
777
782
  if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
778
783
  if (raw === "undo?") return t("undo");
784
+ if ((m = raw.match(/^write the Starbuck report to (.+)\?$/))) return t("starbuckReport", { x: m[1] });
779
785
  if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
780
786
  return raw;
781
787
  }
@@ -797,6 +803,14 @@ function paintPreview(pre, text) {
797
803
  }
798
804
  }
799
805
 
806
+ /** Close the dialog on screen if Sub-Sub no longer waits for it (pi restarted, or another tab answered). */
807
+ function dropStaleDialog() {
808
+ if (shownDialog && !dialogs.some((d) => d.id === shownDialog.id)) {
809
+ shownDialog = null;
810
+ if ($("dialog").open) $("dialog").close();
811
+ }
812
+ }
813
+
800
814
  function nextDialog() {
801
815
  if (shownDialog || !dialogs.length) return;
802
816
  const d = dialogs[0];
@@ -821,6 +835,7 @@ function nextDialog() {
821
835
  note(err.message, "error");
822
836
  }
823
837
  };
838
+ $("dialog-form").onsubmit = (e) => e.preventDefault();
824
839
  if (d.method === "confirm") {
825
840
  buttons.append(
826
841
  el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
@@ -834,14 +849,26 @@ function nextDialog() {
834
849
  const field = d.method === "editor" ? el("textarea", {}) : el("input", { type: "text", placeholder: d.placeholder ?? "" });
835
850
  field.value = d.prefill ?? "";
836
851
  body.append(field);
852
+ $("dialog-form").onsubmit = (e) => {
853
+ e.preventDefault();
854
+ answer({ value: field.value });
855
+ };
837
856
  buttons.append(
838
857
  el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }),
839
858
  el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
840
859
  );
841
860
  }
861
+ // Keys typed just before the dialog opened (the user was writing a message) must not answer it.
862
+ const openedAt = performance.now();
863
+ const yesKeys = lang === "pt" ? ["s", "S", "y", "Y"] : ["y", "Y"];
842
864
  dlg.onkeydown = (e) => {
843
865
  if (d.method === "confirm") {
844
- if (e.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
866
+ if (e.repeat || performance.now() - openedAt < 700) {
867
+ // Escape still declines (through oncancel); Enter and Space would press the focused button.
868
+ if (e.key !== "Escape" && e.key !== "Tab") e.preventDefault();
869
+ return;
870
+ }
871
+ if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
845
872
  e.preventDefault();
846
873
  answer({ confirmed: true });
847
874
  } else if (e.key === "n" || e.key === "N") {
@@ -867,6 +894,9 @@ function showModels() {
867
894
 
868
895
  async function openModels() {
869
896
  const dlg = $("model-dialog");
897
+ // Name the mode the choice is saved for; "both modes" is a choice for this time only.
898
+ dlg.querySelector("h2").textContent = t(mode() === "librarian" ? "modelTitleLibrarian" : "modelTitleResearcher");
899
+ $("model-both").checked = false;
870
900
  const ul = $("models");
871
901
  ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
872
902
  const sel = $("login-provider");
@@ -954,6 +984,16 @@ async function sendMessage(text) {
954
984
  return true;
955
985
  } catch (err) {
956
986
  if (isTemplate) sentTemplates.pop();
987
+ if (err.message === "nomodel") {
988
+ // Keep what the user wrote, and show where to connect a provider.
989
+ if (!input.value.trim()) {
990
+ input.value = message;
991
+ autosize();
992
+ }
993
+ note(t("noModelSend"), "warning");
994
+ showModels();
995
+ return false;
996
+ }
957
997
  note(err.message, "error");
958
998
  return false;
959
999
  }
package/web/i18n.js CHANGED
@@ -5,7 +5,8 @@ export const STRINGS = {
5
5
  en: {
6
6
  profile: "Profile",
7
7
  model: "Model",
8
- noModel: "No model",
8
+ noModel: "Connect a model",
9
+ noModelSend: "No model is connected yet. Choose a provider and save its key; your message is still in the box.",
9
10
  newConversation: "New conversation",
10
11
  actions: "Actions",
11
12
  conversations: "Conversations",
@@ -21,6 +22,7 @@ export const STRINGS = {
21
22
  run: "Run {x}?",
22
23
  undo: "Undo this change?",
23
24
  write: "Allow writing to {x}?",
25
+ starbuckReport: "Write the Starbuck report to {x}?",
24
26
  approveHint: "Nothing changes until you select Yes.",
25
27
  zoteroChecking: "Zotero: checking",
26
28
  zoteroDown: "Zotero is not running",
@@ -39,6 +41,8 @@ export const STRINGS = {
39
41
  connectModel: "Connect a model to start.",
40
42
  connect: "Connect",
41
43
  modelTitle: "Model",
44
+ modelTitleResearcher: "Model for the Researcher",
45
+ modelTitleLibrarian: "Model for the Librarian",
42
46
  chooseModel: "Choose a model",
43
47
  noModels: "No models yet. Connect a provider below.",
44
48
  connectProvider: "Connect a provider",
@@ -94,7 +98,8 @@ export const STRINGS = {
94
98
  pt: {
95
99
  profile: "Perfil",
96
100
  model: "Modelo",
97
- noModel: "Sem modelo",
101
+ noModel: "Ligar um modelo",
102
+ noModelSend: "Ainda não há um modelo ligado. Escolha um fornecedor e guarde a chave; a sua mensagem continua na caixa.",
98
103
  newConversation: "Nova conversa",
99
104
  actions: "Ações",
100
105
  conversations: "Conversas",
@@ -110,6 +115,7 @@ export const STRINGS = {
110
115
  run: "Executar {x}?",
111
116
  undo: "Desfazer esta alteração?",
112
117
  write: "Permitir escrever em {x}?",
118
+ starbuckReport: "Escrever o relatório do Starbuck em {x}?",
113
119
  approveHint: "Nada muda até selecionar Sim.",
114
120
  zoteroChecking: "Zotero: a verificar",
115
121
  zoteroDown: "O Zotero não está aberto",
@@ -128,6 +134,8 @@ export const STRINGS = {
128
134
  connectModel: "Ligue um modelo para começar.",
129
135
  connect: "Ligar",
130
136
  modelTitle: "Modelo",
137
+ modelTitleResearcher: "Modelo do Investigador",
138
+ modelTitleLibrarian: "Modelo do Bibliotecário",
131
139
  chooseModel: "Escolha um modelo",
132
140
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
133
141
  connectProvider: "Ligar um fornecedor",
@@ -161,7 +169,7 @@ export const STRINGS = {
161
169
  searchConversations: "Procurar conversas",
162
170
  export: "Exportar",
163
171
  exportTitle: "Exportar conversa",
164
- confirmShortcutHint: "Y para aprovar, N para recusar",
172
+ confirmShortcutHint: "S para aprovar, N para recusar",
165
173
  starbuck: "Verificação de referências (Starbuck)",
166
174
  starbuckHint: "Verifica se as obras citadas existem, correspondem à citação e não foram retratadas. O Sub-Sub reinicia quando muda esta opção.",
167
175
  starbuckOn: "A verificação de referências está ligada. Use /verify, ou peça ao investigador que verifique as referências de um manuscrito.",
package/docs/Subsub.md DELETED
@@ -1,140 +0,0 @@
1
- Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses the Python servers of zotero-local-mcp for the library: previews, version checks, journal and undo, the tag vocabulary.
2
-
3
- - Code: ~/Projects/subsub (git). Servers: ~/Projects/zotero-local-mcp
4
- - Shared rules: [[Zotero agent]]. Tag vocabulary: [[Zotero tags]]
5
- - Settings: ~/.config/subsub/config.json (written by `subsub init`) and ~/.config/zotero-local-mcp/env (also read by the weekly alerts in launchd)
6
- - Sub-Sub folder: ~/Obsidian/Powerslave/Sub-Sub, with Inbox/, Literature/, Syntheses/, Research/ and Zotero/ (tag list, note formats, alert searches). Sub-Sub writes only there without asking.
7
- - Models: Librarian opencode-go/mimo-v2.6-flash, Researcher opencode-go/mimo-v2.6-pro
8
- - Public docs: the README in ~/Projects/subsub (source for subsub.tiagojacinto.eu)
9
-
10
- ## Safety
11
-
12
- - Approval is enforced in code. Before any library change, Sub-Sub asks the server for the preview and shows it in a dialog. The change runs only after you select Yes. The model cannot skip this.
13
- - The model does not need to do a dry run first. You see one dialog per change.
14
- - Library tools run one at a time, so each preview includes the effect of earlier changes in the same step.
15
- - The Librarian has no search tools. The Researcher has all tools; every library change still needs a Yes.
16
- - bash is off. File writes outside the Sub-Sub folder, and writes to the rule files, config folders and code folders, need a Yes.
17
-
18
- ## Install
19
-
20
- 1. Make sure that Node is version 22.19 or later: type `node --version`.
21
- 2. Type `cd ~/Projects/subsub && npm install --omit=dev`.
22
- 3. Type `npm link`. This adds the `subsub` command.
23
- 4. Optional: type `pi remove ~/Projects/subsub`. Then plain `pi` stays a plain coding agent without Sub-Sub.
24
- 5. Type `subsub`. If pi has no OpenCode Go key yet, type `/login`, select OpenCode Zen and Go, and paste the key.
25
- 6. Type `/subsub`. Make sure that the result shows "Zotero: reachable" and the model for the mode (mimo-v2.6-pro in the Researcher).
26
- 7. Type `subsub init` once. Keep the Sub-Sub folder, the tag list, the email and the models. For the profile, select Editor.
27
- 8. Type `subsub doctor`. Make sure that no line shows FIX.
28
-
29
- If you change the Node version with fnm, do step 3 again.
30
-
31
- ## Use
32
-
33
- 1. Start Zotero.
34
- 2. Type `subsub`. From the home folder, Sub-Sub starts in the Sub-Sub folder. From a different folder (for example a manuscript folder), it starts in that folder. To stay in the home folder, type `subsub --here`.
35
- 3. Sub-Sub starts in the Researcher. The Researcher reads, searches, writes notes and changes the library. The Librarian only manages the library. To start in the Librarian, type `subsub --librarian`.
36
- 4. To change the mode, type `/librarian` or `/researcher`. The mode also changes the model. After research, ask the Researcher to import the works you choose. It imports them in the same conversation, with one preview, and offers to tag them.
37
- 5. When a dialog shows a preview, read it. Select Yes to apply the change, or No to stop it. After No, tell Sub-Sub what to change.
38
- 6. To continue the last session, type `subsub -c`. To select an older session, type `subsub -r`.
39
-
40
- Sub-Sub always replies in English and writes its notes in English. Quotations and titles of works stay in their original language.
41
-
42
- Sub-Sub keeps its pi settings, sessions and packages in ~/.subsub/agent, separate from plain pi (~/.pi/agent).
43
-
44
- Commands:
45
-
46
- - `/subsub`: Zotero connection and library overview
47
- - `/profile`: shows or changes the profile
48
- - `/history`: recent library changes
49
- - `/undo`: shows the last change, asks, and reverts it. `/undo <id>` reverts a specific change from `/history`.
50
-
51
- Templates (type the name, then the argument):
52
-
53
- - Library (both modes): `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
54
- - Research (Researcher): `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
55
-
56
- ## Web view
57
-
58
- 1. Type `subsub web`. Sub-Sub opens in the browser, with the same profiles, tools and approvals. The mode buttons are at the top. The side panel shows the actions of the mode. The approval dialog shows the server preview; the change runs only after Yes.
59
- 2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
60
- 3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
61
- 4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
62
- 5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
63
- 6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
64
- 7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
65
- 8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`). It saves the model for the current mode. To use the model in both modes, select "Use it in both modes".
66
-
67
- The log is in ~/.subsub/web.log.
68
-
69
- ## Profiles
70
-
71
- The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To change it, type `/profile reader` (or scholar, author, editor). The change is saved.
72
-
73
- - Reader: explains each step. Reading notes are quotes with page numbers, plus questions. No summaries or syntheses. No bulk library changes.
74
- - Scholar: literature notes, syntheses, searches and alerts. All library changes.
75
- - Author: Scholar plus manuscripts: citation check, bibliography, comments on the argument. It does not write the paragraphs.
76
- - Editor: everything, including drafted manuscript text when you ask.
77
-
78
- ## Tag review notes
79
-
80
- Sub-Sub writes proposed tags in review notes in Inbox/ ("Zotero tag review NN.md"). Edit the Proposed tags column. Write skip to leave an item as it is.
81
-
82
- To apply one note, in either mode, type: apply the tag review Inbox/Zotero tag review NN.md.
83
-
84
- To apply many notes:
85
-
86
- 1. Start Zotero.
87
- 2. Type `subsub review preview 13-31` (the numbers of the notes). This shows what each note will change. Nothing changes.
88
- 3. Type `subsub review apply 13-31`. Read the preview, then type y.
89
- 4. If Zotero asks for permission, select Always Allow.
90
-
91
- Use note numbers, not a pattern such as "*.md": a pattern also takes older notes that have no Applied line, and applying an old note again replaces later tag changes.
92
-
93
- Notes that were already applied are left out. Each applied note gets an "Applied" line with the journal ID. To revert a note, type `/undo <journal ID>` in Sub-Sub. If a note shows "changed since the note was written", the tags of those items changed in Zotero after the note was made; applying the note replaces those changes.
94
-
95
- ## Look
96
-
97
- When you start Sub-Sub with the `subsub` command, it shows a banner, a status line (mode, model, Zotero items, items to review) and a line from Moby-Dick. The theme is Glauca, in the dark or light version that matches your terminal (set "themes": "subsub-try-works" for the other one). The theme changes for the session only; your pi theme setting stays.
98
-
99
- To change this, add to ~/.config/subsub/config.json:
100
-
101
- - `"quotes": false`: no Moby-Dick line.
102
- - `"themes": false`: keep your own pi theme.
103
- - `"look": false`: no banner and no themes.
104
-
105
- The themes are in ~/Projects/subsub/themes. To make them again after a change in Gam, see scripts/make-themes.py.
106
-
107
- ## Model test
108
-
109
- The model test runs several models on the same tasks against your library and compares them. Nothing in the library changes: each library change is recorded with its preview and then blocked. Notes go to ~/Projects/subsub/bench-results/<date>/, not to the Sub-Sub folder.
110
-
111
- Tasks:
112
-
113
- - Library tasks: tag 20 items against a blind reference (reference.md shows the items without their tags; the tags go in reference.json); fix the items without topic or status; import identifiers, one of them already in the library.
114
- - Research tasks: a literature note on one item with full text; a synthesis on one topic; a search for recent papers that are not in the library.
115
-
116
- Procedure:
117
-
118
- 1. Start Zotero.
119
- 2. Type `subsub-bench prepare`. This picks the tasks.
120
- 3. Type `caffeinate -i subsub-bench run`. This takes 1 to 2 hours. caffeinate keeps the Mac awake. If the run stops, type the same command again: finished runs are not repeated.
121
- 4. Type `subsub-bench score`. The result is results.md in the results folder.
122
-
123
- Options for `run`: `--models a,b` for all tasks, or `--librarian a,b` and `--researcher c,d` for each group of tasks (OpenCode Go model ids), `--only tagging,import`, `--parallel 3`, `--timeout 900`. Library tasks run in the Librarian. Research tasks run in the Researcher.
124
-
125
- Each model gets a code (for example M417), so the notes can be judged blind. key.json has the codes.
126
-
127
- ## Problems
128
-
129
- - To find a setup problem, type `subsub doctor`. Each line marked FIX says what to do.
130
- - "the zotero server did not start": run `cd ~/Projects/zotero-local-mcp && uv sync`. If pi does not find uv, add `"uvPath": "/path/to/uv"` to ~/.config/subsub/config.json (type `which uv` to see the path).
131
- - "cannot use opencode-go/...": the OpenCode Go key is missing. In Sub-Sub, type `/login` again.
132
- - "command not found: subsub": type `cd ~/Projects/subsub && npm link`.
133
- - To update Sub-Sub after new code arrives, type `cd ~/Projects/subsub && npm install --omit=dev`. Do not use `subsub update`.
134
- - A tool call is refused with "part of the Researcher": type `/researcher`.
135
- - `subsub review` says "No note matches": check the note numbers in Inbox/. A path is written from the Sub-Sub folder, in quotes (for example "Inbox/Zotero tag review 13.md").
136
-
137
- ## Tests
138
-
139
- - `cd ~/Projects/subsub && npm install && npm test`: unit tests.
140
- - `npm run test:e2e`: real pi and the `subsub` command with a scripted model and a fake Zotero.