@tiagojct/subsub 0.9.0 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +14 -16
  2. package/dist/args.js +23 -0
  3. package/dist/bench.js +8 -7
  4. package/dist/bridge.js +2 -2
  5. package/dist/config.js +17 -10
  6. package/dist/doctor.js +2 -2
  7. package/dist/init.js +25 -13
  8. package/dist/layout.js +9 -0
  9. package/dist/look.js +5 -5
  10. package/dist/profiles.js +6 -6
  11. package/dist/prompt.js +7 -4
  12. package/dist/roles.js +26 -17
  13. package/dist/subsub.js +71 -43
  14. package/dist/web.js +8 -11
  15. package/docs/Subsub.md +16 -16
  16. package/package.json +1 -1
  17. package/prompts/alert.md +1 -1
  18. package/prompts/clean-tags.md +1 -1
  19. package/prompts/compare.md +1 -1
  20. package/prompts/digest.md +2 -2
  21. package/prompts/gaps.md +2 -2
  22. package/prompts/import-queue.md +1 -1
  23. package/prompts/lit-note.md +1 -1
  24. package/prompts/lit.md +2 -2
  25. package/prompts/manuscript.md +1 -1
  26. package/prompts/request-copy.md +1 -1
  27. package/prompts/review.md +1 -1
  28. package/prompts/synthesis.md +1 -1
  29. package/prompts/tag-batch.md +1 -1
  30. package/prompts/verify.md +1 -1
  31. package/roles/assistant.md +95 -0
  32. package/src/args.ts +24 -0
  33. package/src/bench.ts +12 -7
  34. package/src/bridge.ts +2 -2
  35. package/src/config.ts +25 -17
  36. package/src/doctor.ts +2 -2
  37. package/src/init.ts +32 -12
  38. package/src/layout.ts +8 -0
  39. package/src/look.ts +7 -7
  40. package/src/profiles.ts +6 -6
  41. package/src/prompt.ts +10 -5
  42. package/src/roles.ts +38 -20
  43. package/src/subsub.ts +72 -41
  44. package/src/web.ts +7 -9
  45. package/templates/Zotero agent.md +6 -6
  46. package/web/app.css +16 -22
  47. package/web/app.js +37 -26
  48. package/web/i18n.js +37 -35
  49. package/web/index.html +2 -6
  50. package/roles/librarian.md +0 -46
  51. package/roles/researcher.md +0 -50
package/src/subsub.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Sub-Sub: a Zotero librarian and research assistant for pi.
3
3
  *
4
- * - Two modes with different tool sets: librarian (changes the library, no web)
5
- * and researcher (reads the library, searches PubMed/OpenAlex, writes vault notes).
4
+ * - One assistant: it reads the library, searches PubMed, Europe PMC and
5
+ * OpenAlex, writes notes in the Sub-Sub folder and, with library changes on,
6
+ * changes the library. /library on|off is the switch (saved in the settings).
6
7
  * - An approval gate: every library change is previewed by the server (dry run)
7
8
  * and shown to the user; nothing is applied without a yes.
8
9
  * - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
9
10
  * tools it offers; /profile changes it.
10
- * - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
11
+ * - Commands: /library, /profile, /subsub, /history, /undo. /librarian and
12
+ * /researcher (the two modes before 0.10) turn library changes on and off.
11
13
  * - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
12
- * ("verify") for reference checks; its tools belong to researcher mode.
14
+ * ("verify") for reference checks.
13
15
  *
14
16
  * The Python servers start in session_start (not in the factory) and stop in
15
17
  * session_shutdown. Tools are registered once and use the current bridge.
@@ -25,7 +27,6 @@ import {
25
27
  findUv,
26
28
  isProfile,
27
29
  loadConfig,
28
- type Mode,
29
30
  parseEnvFile,
30
31
  type Profile,
31
32
  saveConfig,
@@ -36,7 +37,7 @@ import {
36
37
  who,
37
38
  } from "./config.ts";
38
39
  import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
39
- import { coerceArgs, loosenArrays } from "./args.ts";
40
+ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
40
41
  import { applyEdits, checkLiteratureNote } from "./notes.ts";
41
42
  import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
42
43
  import { describeArgs, formatPreview } from "./preview.ts";
@@ -52,7 +53,6 @@ export interface SubsubDeps {
52
53
  }
53
54
 
54
55
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
55
- const MODE_ENTRY = "subsub-mode";
56
56
  const BENCH_ENTRY = "subsub-bench";
57
57
 
58
58
  function ownVersion(): string {
@@ -127,9 +127,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
127
127
 
128
128
  if (deps.bridge) registerTools(deps.bridge);
129
129
 
130
- pi.registerFlag("librarian", { description: "Start Sub-Sub in librarian mode", type: "boolean", default: false });
130
+ pi.registerFlag("read-only", { description: "Start Sub-Sub with library changes off (this run only)", type: "boolean", default: false });
131
+ pi.registerFlag("librarian", { description: "Start Sub-Sub with library changes on (this run only)", type: "boolean", default: false });
131
132
 
132
- let mode: Mode = cfg.defaultMode;
133
+ let libraryChanges = cfg.libraryChanges;
134
+ const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
135
+ const toolOpts = () => ({ libraryChanges, profile, bench: benchRun });
133
136
  let profile: Profile = cfg.profile;
134
137
  const user = who(cfg);
135
138
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -151,7 +154,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
151
154
  if (!webView || !ctx || ctx.mode !== "rpc") return;
152
155
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
153
156
  const state = {
154
- mode,
157
+ libraryChanges,
155
158
  profile,
156
159
  profileLabel: profileSpec(profile).label,
157
160
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
@@ -172,7 +175,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
172
175
  function applyTheme(ctx: ExtensionContext): void {
173
176
  if (!lookOn(ctx) || cfg.themes === false) return;
174
177
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
175
- const name = themeName(mode, scheme, cfg.themes);
178
+ const name = themeName(scheme, cfg.themes || undefined);
176
179
  const theme = name ? ctx.ui.getTheme(name) : undefined;
177
180
  // A Theme object (not a name) changes the theme for this run only; the saved setting stays.
178
181
  if (theme) ctx.ui.setTheme(theme);
@@ -218,7 +221,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
218
221
  const th = ctx.ui.theme;
219
222
  return headerLines({
220
223
  version,
221
- mode,
224
+ libraryChanges,
222
225
  profile: profileSpec(profile).label,
223
226
  model: modelId,
224
227
  library,
@@ -241,28 +244,37 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
241
244
  function status(ctx: ExtensionContext): void {
242
245
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
243
246
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
244
- ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
245
- if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
247
+ const lib = libraryChanges ? "library changes on" : "read only";
248
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${lib} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
249
+ if (standalone) ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
246
250
  headerTui?.requestRender();
247
251
  webCtx = ctx;
248
252
  emitWeb();
249
253
  }
250
254
 
251
- async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
252
- mode = next;
253
- pi.setActiveTools(toolsFor(mode, registered(), profile));
254
- const spec = cfg.models[mode];
255
- if (spec) {
256
- const i = spec.indexOf("/");
257
- const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
258
- const ok = model ? await pi.setModel(model) : false;
259
- if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
255
+ /** Set the tools for the switch and the profile; save the switch when asked. */
256
+ function applyLibrary(ctx: ExtensionContext, on: boolean, save: boolean): void {
257
+ libraryChanges = on;
258
+ pi.setActiveTools(toolsFor(registered(), toolOpts()));
259
+ if (save) {
260
+ try {
261
+ saveConfig({ libraryChanges: on });
262
+ } catch (err) {
263
+ ctx.ui.notify(`Sub-Sub: the setting was not saved: ${(err as Error).message}`, "warning");
264
+ }
260
265
  }
261
- if (remember) pi.appendEntry(MODE_ENTRY, { mode });
262
- applyTheme(ctx);
263
266
  status(ctx);
264
267
  }
265
268
 
269
+ async function applyModel(ctx: ExtensionContext): Promise<void> {
270
+ const spec = cfg.model;
271
+ if (!spec) return;
272
+ const i = spec.indexOf("/");
273
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
274
+ const ok = model ? await pi.setModel(model) : false;
275
+ if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
276
+ }
277
+
266
278
  pi.on("session_start", async (_event, ctx) => {
267
279
  if (!bridge) {
268
280
  const b = new Bridge();
@@ -270,14 +282,12 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
270
282
  bridge = b;
271
283
  registerTools(b);
272
284
  }
273
- let restored: Mode | undefined;
274
- for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
275
- const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
276
- if (m === "librarian" || m === "researcher") restored = m;
277
- }
278
285
  // Judge light or dark before Sub-Sub changes the theme.
279
286
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
280
- await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
287
+ await applyModel(ctx);
288
+ applyTheme(ctx);
289
+ // The flags change this run only; the saved setting stays.
290
+ applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
281
291
  if (lookOn(ctx)) {
282
292
  setHeader(ctx);
283
293
  void refreshLibrary();
@@ -308,7 +318,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
308
318
  });
309
319
 
310
320
  pi.on("before_agent_start", async (event, ctx) => ({
311
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
321
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd, libraryChanges)}`,
312
322
  }));
313
323
 
314
324
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
@@ -321,8 +331,9 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
321
331
  // Previews and the real call must see the same, repaired arguments.
322
332
  const schema = schemas.get(name);
323
333
  if (schema) coerceArgs(schema, input);
334
+ resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
324
335
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
325
- const why = unavailableReason(name, mode, registered(), profile);
336
+ const why = unavailableReason(name, registered(), toolOpts());
326
337
  if (why) {
327
338
  if (benchOut) record({ kind: "wrong_mode", tool: name, input });
328
339
  return { block: true, reason: why };
@@ -436,19 +447,39 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
436
447
  };
437
448
  }
438
449
 
450
+ const libraryNote = (on: boolean) =>
451
+ on
452
+ ? "Sub-Sub: library changes on. Every change shows a preview first."
453
+ : "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
454
+
455
+ pi.registerCommand("library", {
456
+ description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
457
+ handler: async (args, ctx) => {
458
+ const a = args.trim().toLowerCase();
459
+ const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
460
+ if (on === undefined) {
461
+ ctx.ui.notify("Type /library on or /library off.", "warning");
462
+ return;
463
+ }
464
+ applyLibrary(ctx, on, true);
465
+ ctx.ui.notify(libraryNote(on), "info");
466
+ },
467
+ });
468
+
469
+ // The two modes before 0.10, kept so that old habits and notes still work.
439
470
  pi.registerCommand("librarian", {
440
- description: "Sub-Sub: switch to the librarian (changes the library, no web)",
471
+ description: "Sub-Sub: same as /library on",
441
472
  handler: async (_args, ctx) => {
442
- await applyMode(ctx, "librarian", true);
443
- ctx.ui.notify("Sub-Sub: librarian mode", "info");
473
+ applyLibrary(ctx, true, true);
474
+ ctx.ui.notify(`${libraryNote(true)} (/librarian is now /library on.)`, "info");
444
475
  },
445
476
  });
446
477
 
447
478
  pi.registerCommand("researcher", {
448
- description: "Sub-Sub: switch to the researcher (search, notes; library read-only)",
479
+ description: "Sub-Sub: same as /library off",
449
480
  handler: async (_args, ctx) => {
450
- await applyMode(ctx, "researcher", true);
451
- ctx.ui.notify("Sub-Sub: researcher mode", "info");
481
+ applyLibrary(ctx, false, true);
482
+ ctx.ui.notify(`${libraryNote(false)} (/researcher is now /library off.)`, "info");
452
483
  },
453
484
  });
454
485
 
@@ -471,7 +502,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
471
502
  } catch (err) {
472
503
  saved = ` Not saved: ${(err as Error).message}`;
473
504
  }
474
- await applyMode(ctx, mode, false);
505
+ applyLibrary(ctx, libraryChanges, false);
475
506
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
476
507
  },
477
508
  });
@@ -479,7 +510,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
479
510
  pi.registerCommand("subsub", {
480
511
  description: "Sub-Sub: Zotero connection and library overview",
481
512
  handler: async (_args, ctx) => {
482
- const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
513
+ const lines = [`Library changes: ${libraryChanges ? "on" : "off"}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
483
514
  if (!bridge) {
484
515
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
485
516
  return;
package/src/web.ts CHANGED
@@ -23,7 +23,8 @@ 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
25
  import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
- import { TESTED_MODELS } from "./init.ts";
26
+ import { TESTED_MODEL } from "./init.ts";
27
+ import { obsidianRoot } from "./layout.ts";
27
28
  import { realish, within } from "./paths.ts";
28
29
 
29
30
  // ---------------------------------------------------------------- options
@@ -567,12 +568,9 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
567
568
  if (busy) return send(res, 409, { error: "busy" });
568
569
  const body = await readJson(req);
569
570
  const spec = `${String(body.provider)}/${String(body.id)}`;
570
- // Sub-Sub keeps one model per mode: remember it for the current mode, then restart pi so the
571
- // extension reads the new setting.
572
- const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
573
- const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
574
- if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
575
- saveConfig({ models }, env);
571
+ // Remember the model (and drop the per-mode models of versions before 0.10), then restart pi
572
+ // so the extension reads the new setting.
573
+ saveConfig({ model: spec, models: undefined }, env);
576
574
  await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
577
575
  await restartAgent();
578
576
  return send(res, 200, { ok: true });
@@ -596,7 +594,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
596
594
  const body = await readJson(req);
597
595
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
598
596
  const current = loadConfig(env);
599
- if (body.provider === "opencode-go" && Object.keys(current.models ?? {}).length === 0) saveConfig({ models: TESTED_MODELS }, env);
597
+ if (body.provider === "opencode-go" && !current.model) saveConfig({ model: TESTED_MODEL, models: undefined }, env);
600
598
  await restartAgent();
601
599
  return send(res, 200, { ok: true });
602
600
  }
@@ -610,7 +608,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
610
608
  if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
611
609
  // Documents only: a file that the model wrote must never be run.
612
610
  if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
613
- if (vault && within(target, vault) && existsSync(join(vault, ".obsidian"))) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
611
+ if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
614
612
  else openExternal(target);
615
613
  return send(res, 200, { ok: true });
616
614
  }
@@ -1,10 +1,10 @@
1
- Shared rules for the Sub-Sub librarian and researcher. Sub-Sub reads this file at the start of every session. Edit it to change how notes look or where they go. Tag list: [[Zotero tags]].
1
+ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session. Edit it to change how notes look or where they go. Tag list: [[Zotero tags]].
2
2
 
3
- ## Roles
3
+ ## What Sub-Sub does
4
4
 
5
- - The librarian changes the library: tags, imports, metadata, PDFs, collections, notes in Zotero. It does not search the web.
6
- - The researcher searches PubMed and OpenAlex, reads the library and writes notes in this folder. It cannot change the library, with one exception: it can add a short linked note to an item (attach_note).
7
- - Hand-off: the researcher adds works to Inbox/Zotero import queue.md. The user ticks the lines. The librarian imports the ticked lines.
5
+ - Sub-Sub reads the library, searches PubMed, Europe PMC and OpenAlex, and writes notes in this folder.
6
+ - With library changes on, it also changes the library: tags, imports, metadata, PDFs, collections, notes in Zotero. Every change shows a preview and needs the user's yes.
7
+ - Works found in a search are imported directly when the user chooses them. For a list to choose from later, Sub-Sub writes Inbox/Zotero import queue.md; the user ticks the lines and Sub-Sub imports them.
8
8
 
9
9
  ## Safety
10
10
 
@@ -85,5 +85,5 @@ created: YYYY-MM-DD
85
85
  - Structure: scope and question; synthesis by theme; disagreements; gaps; a table of included items (citekey, year, design, main finding, source: full text or abstract).
86
86
  - For a tag ("what does my library say about X"), include every item with the tag, one table row each. If an item is left out, say which and why.
87
87
  - Cite with [@citekey] after each claim. Every claim must come from an included item.
88
- - Works that are not in the library go in a separate section "Not in the library", with DOI or PMID, and are proposed for the import queue.
88
+ - Works that are not in the library go in a separate section "Not in the library", with DOI or PMID, and are proposed for import.
89
89
  - Link existing literature notes as [[citekey]].
package/web/app.css CHANGED
@@ -87,9 +87,8 @@
87
87
  --warn: #d6a84e;
88
88
  }
89
89
 
90
- /* The mode sets the accent: blue for the researcher, rust for the librarian. */
90
+ /* One accent, blue. Rust marks the switch that lets Sub-Sub change the library. */
91
91
  body { --accent: var(--researcher); --accent-soft: var(--researcher-soft); }
92
- body[data-mode="librarian"] { --accent: var(--librarian); --accent-soft: var(--librarian-soft); }
93
92
 
94
93
  /* ---------------------------------------------------------------- base */
95
94
 
@@ -220,27 +219,20 @@ select:hover { border-color: var(--rule-strong); }
220
219
  .brand .mark { color: var(--accent); }
221
220
  .brand .hyphen { color: var(--accent); }
222
221
 
223
- .modes {
224
- display: inline-flex;
225
- padding: 3px;
226
- border-radius: 8px;
227
- background: var(--surface);
222
+ /* The library switch in the top bar: rust when Sub-Sub may change the library. */
223
+ .lib-switch {
224
+ padding: 0.3rem 0.7rem 0.3rem 0.5rem;
228
225
  border: 1px solid var(--rule);
229
- }
230
- .modes button {
231
- padding: 0.28rem 0.8rem;
232
- border-radius: 5px;
233
- font-size: 0.86rem;
226
+ border-radius: 999px;
227
+ background: var(--surface);
228
+ font-size: 0.84rem;
234
229
  color: var(--muted);
235
- border: 0;
236
- }
237
- .modes button:hover { color: var(--text); }
238
- .modes button[aria-pressed="true"] {
239
- background: var(--raised);
240
- color: var(--accent);
241
- font-weight: 600;
242
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), 0 0 0 1px var(--rule);
230
+ white-space: nowrap;
243
231
  }
232
+ .lib-switch:hover { border-color: var(--rule-strong); color: var(--text); }
233
+ .top .lib-switch input:checked + .track { background: var(--librarian); }
234
+ body[data-library="on"] .lib-switch { color: var(--text); }
235
+ body[data-library="off"] .top { border-top-color: var(--rule-strong); }
244
236
 
245
237
  .status {
246
238
  margin-left: auto;
@@ -316,7 +308,8 @@ select:hover { border-color: var(--rule-strong); }
316
308
  border-radius: 5px;
317
309
  }
318
310
  .actions button:hover { background: var(--bg); }
319
- .actions hr { border: 0; border-top: 1px solid var(--rule); margin: 0.4rem 0.5rem; }
311
+ .actions .group { margin: 0.55rem 0 0.15rem; padding: 0 0.5rem; font-size: 0.74rem; font-weight: 600; color: var(--muted); }
312
+ .actions .group:first-child { margin-top: 0; }
320
313
 
321
314
  .side .grow { flex: 1; display: flex; flex-direction: column; min-height: 8rem; }
322
315
  .side-subhead { display: flex; justify-content: space-between; align-items: baseline; padding-right: 0.3rem; }
@@ -862,7 +855,8 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
862
855
 
863
856
  @media (max-width: 30rem) {
864
857
  .brand > span { display: none; }
865
- .modes button { padding: 0.28rem 0.6rem; }
858
+ .lib-switch > span:last-child { display: none; }
859
+ .lib-switch { padding: 0.35rem 0.5rem; }
866
860
  }
867
861
 
868
862
  @media (prefers-reduced-motion: reduce) {
package/web/app.js CHANGED
@@ -7,7 +7,7 @@ import { ACTIONS, STRINGS, SUGGESTIONS, TOOLS } from "/i18n.js";
7
7
  // ---------------------------------------------------------------- state
8
8
 
9
9
  let lang = "en";
10
- let state = null; // Sub-Sub's own state: mode, profile, model, library, down
10
+ let state = null; // Sub-Sub's own state: libraryChanges, profile, model, library, down
11
11
  let session = null; // pi's session state
12
12
  let busy = false;
13
13
  let commands = [];
@@ -169,13 +169,15 @@ function applyLang() {
169
169
  updateLayoutUI();
170
170
  }
171
171
 
172
- function mode() {
173
- return state?.mode === "librarian" ? "librarian" : "researcher";
172
+ /** Library changes on (the default until the state arrives). */
173
+ function libOn() {
174
+ return state?.libraryChanges !== false;
174
175
  }
175
176
 
176
177
  function renderHeader() {
177
- document.body.dataset.mode = mode();
178
- for (const b of document.querySelectorAll(".modes button")) b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
178
+ document.body.dataset.library = libOn() ? "on" : "off";
179
+ $("library-switch").checked = libOn();
180
+ $("library-switch-label").title = t("libraryHint");
179
181
  if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
180
182
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
181
183
  $("starbuck-label").title = t("starbuckHint");
@@ -203,7 +205,7 @@ function renderHeader() {
203
205
  lib.classList.add("down");
204
206
  }
205
207
  lib.title = txt.textContent;
206
- $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
208
+ $("input").placeholder = libOn() ? t("placeholder") : t("placeholderReadOnly");
207
209
  if (log.querySelector(".empty")) {
208
210
  clearEmpty();
209
211
  renderEmpty();
@@ -221,12 +223,17 @@ function hasCommand(name) {
221
223
  function renderActions() {
222
224
  const box = $("actions");
223
225
  box.replaceChildren();
224
- const list = [...ACTIONS[mode()]].filter(
225
- (a) => hasCommand(a.cmd) && (!a.needs || state?.[a.needs]) && (!a.profiles || a.profiles.includes(state?.profile ?? "scholar")),
226
- );
227
- for (const a of list) box.append(actionButton(a));
228
- box.append(el("hr"));
229
- for (const a of ACTIONS.both.filter((x) => hasCommand(x.cmd))) box.append(actionButton(a));
226
+ const shown = (a) =>
227
+ hasCommand(a.cmd) &&
228
+ (!a.changes || libOn()) &&
229
+ (!a.needs || state?.[a.needs]) &&
230
+ (!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
231
+ for (const [group, label] of [["research", "groupResearch"], ["library", "groupLibrary"]]) {
232
+ const list = ACTIONS[group].filter(shown);
233
+ if (!list.length) continue;
234
+ box.append(el("p", { class: "group", text: t(label) }));
235
+ for (const a of list) box.append(actionButton(a));
236
+ }
230
237
  }
231
238
 
232
239
  function actionButton(a) {
@@ -326,17 +333,17 @@ function exportConversation() {
326
333
  if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
327
334
  } else if (m.classList.contains("assistant")) {
328
335
  const text = Array.from(m.querySelectorAll(".md")).map((d) => d.innerText).join("\n\n");
329
- if (text) msgs.push(`### Sub-Sub (${mode()})\n\n${text}\n`);
336
+ if (text) msgs.push(`### Sub-Sub\n\n${text}\n`);
330
337
  }
331
338
  }
332
339
  if (!msgs.length) return;
333
340
  const date = new Date().toISOString().slice(0, 10);
334
- const content = `# Sub-Sub Research Session (${date})\n\n**Mode**: ${mode()} \n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
341
+ const content = `# Sub-Sub Research Session (${date})\n\n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
335
342
  const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
336
343
  const url = URL.createObjectURL(blob);
337
344
  const a = document.createElement("a");
338
345
  a.href = url;
339
- a.download = `Sub-Sub-${mode()}-${date}.md`;
346
+ a.download = `Sub-Sub-${date}.md`;
340
347
  document.body.append(a);
341
348
  a.click();
342
349
  a.remove();
@@ -369,14 +376,14 @@ function clearEmpty() {
369
376
 
370
377
  function renderEmpty() {
371
378
  if (log.children.length) return;
372
- const s = SUGGESTIONS[mode()][lang] ?? SUGGESTIONS[mode()].en;
379
+ const s = SUGGESTIONS.filter((x) => !x.changes || libOn()).map((x) => x[lang] ?? x.en);
373
380
  log.append(
374
381
  el(
375
382
  "div",
376
383
  { class: "empty" },
377
- el("p", { class: "kicker", text: t(mode()) }),
384
+ el("p", { class: "kicker", text: libOn() ? t("assistant") : `${t("assistant")} · ${t("readOnly")}` }),
378
385
  el("h1", { text: t("emptyTitle") }),
379
- el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
386
+ el("p", { class: "lede", text: t(libOn() ? "emptyOn" : "emptyOff") }),
380
387
  el(
381
388
  "div",
382
389
  { class: "chips" },
@@ -708,7 +715,8 @@ function onEvent(ev) {
708
715
  break;
709
716
  case "extension_ui_request":
710
717
  if (ev.method === "notify") {
711
- if (/^Sub-Sub: (librarian|researcher) mode$/.test(ev.message)) break;
718
+ // The switch shows the state; these confirmations would only repeat it.
719
+ if (/^Sub-Sub: library changes (on|off)\./.test(ev.message)) break;
712
720
  note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
713
721
  } else {
714
722
  dialogs.push(ev);
@@ -893,7 +901,7 @@ async function openModels() {
893
901
  onclick: async () => {
894
902
  if (busy) return note(t("busy"), "warning");
895
903
  try {
896
- await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
904
+ await api("/api/model", { provider: m.provider, id: m.id });
897
905
  dlg.close();
898
906
  } catch (err) {
899
907
  note(err.message, "error");
@@ -935,15 +943,17 @@ function autosize() {
935
943
 
936
944
  async function sendMessage(text) {
937
945
  const message = text.trim();
938
- if (!message) return;
946
+ if (!message) return false;
939
947
  const name = message.startsWith("/") ? message.slice(1).split(/\s/)[0] : "";
940
948
  const isTemplate = name && commands.some((c) => c.name === name && c.source === "prompt");
941
949
  if (isTemplate) sentTemplates.push(message);
942
950
  try {
943
951
  await api("/api/prompt", { message });
952
+ return true;
944
953
  } catch (err) {
945
954
  if (isTemplate) sentTemplates.pop();
946
955
  note(err.message, "error");
956
+ return false;
947
957
  }
948
958
  }
949
959
 
@@ -1032,11 +1042,12 @@ $("new-btn").addEventListener("click", async () => {
1032
1042
  note(err.message, "error");
1033
1043
  }
1034
1044
  });
1035
- for (const b of document.querySelectorAll(".modes button")) {
1036
- b.addEventListener("click", () => {
1037
- if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
1038
- });
1039
- }
1045
+ $("library-switch").addEventListener("change", async (e) => {
1046
+ const on = e.target.checked;
1047
+ if (on === libOn()) return;
1048
+ const ok = await sendMessage(`/library ${on ? "on" : "off"}`);
1049
+ if (!ok) e.target.checked = libOn();
1050
+ });
1040
1051
  $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1041
1052
  $("starbuck-toggle").addEventListener("change", async (e) => {
1042
1053
  const on = e.target.checked;