@tiagojct/subsub 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/look.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * Only used when Sub-Sub runs as its own command in the terminal UI.
5
5
  */
6
6
 
7
- import type { Mode } from "./config.ts";
8
7
 
9
8
  export const BANNER = [
10
9
  "╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
@@ -25,7 +24,8 @@ export const QUOTES: Array<[string, string]> = [
25
24
  ["Call me Ishmael.", "Moby-Dick, ch. 1"],
26
25
  ];
27
26
 
28
- export const MODE_THEMES: Record<Mode, string> = { librarian: "subsub-try-works", researcher: "subsub-glauca" };
27
+ /** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
28
+ export const DEFAULT_THEME = "subsub-glauca";
29
29
 
30
30
  export type Scheme = "dark" | "light";
31
31
 
@@ -47,8 +47,7 @@ export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
47
47
  return "dark";
48
48
  }
49
49
 
50
- export function themeName(mode: Mode, scheme: Scheme, map: Partial<Record<Mode, string>> = MODE_THEMES): string | undefined {
51
- const base = map[mode];
50
+ export function themeName(scheme: Scheme, base: string | undefined = DEFAULT_THEME): string | undefined {
52
51
  return base ? `${base}-${scheme}` : undefined;
53
52
  }
54
53
 
@@ -84,7 +83,7 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
84
83
  /** Header lines. Each line is cut to the width before it is coloured. */
85
84
  export function headerLines(opts: {
86
85
  version: string;
87
- mode: Mode;
86
+ mode: "librarian" | "researcher";
88
87
  /** Profile label, e.g. "Scholar". */
89
88
  profile?: string;
90
89
  model: string;
package/src/profiles.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - reader: explains each step; reading notes are quotes with page numbers and
7
7
  * questions; no summaries or syntheses; no bulk library changes.
8
- * - scholar: literature notes, syntheses, searches, alerts; the full librarian.
8
+ * - scholar: literature notes, syntheses, searches, alerts; all library changes.
9
9
  * - author: scholar plus manuscript work: citation check, bibliography, comments
10
10
  * on the argument. Does not write the manuscript's paragraphs.
11
11
  * - editor: everything, including drafting text for a manuscript on request.
@@ -46,13 +46,13 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
46
46
  "- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
47
47
  "- For searches, list works with one line each on what they study, not on what they conclude.",
48
48
  "- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
49
- "- Librarian: at most 10 items per review note. Explain each proposed tag in the Reason column.",
49
+ "- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
50
50
  ].join("\n"),
51
51
  },
52
52
  scholar: {
53
53
  name: "scholar",
54
54
  label: "Scholar",
55
- summary: "literature notes, syntheses, searches and alerts; the full librarian",
55
+ summary: "literature notes, syntheses, searches and alerts; all library changes",
56
56
  without: MANUSCRIPT,
57
57
  withoutPrompts: ["review"],
58
58
  batch: 25,
@@ -60,7 +60,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
60
60
  "Profile: Scholar.",
61
61
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
62
62
  "- Do not draft text for {{user}}'s manuscripts, theses or assignments. If asked, say that the Author and Editor profiles help with manuscripts (/profile).",
63
- "- Librarian: 25 items per review note.",
63
+ "- Tag review notes: 25 items.",
64
64
  ].join("\n"),
65
65
  },
66
66
  author: {
@@ -75,7 +75,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
75
75
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
76
76
  "- Help with {{user}}'s manuscripts: check citations against the library, export the bibliography, and comment on the argument, the structure and the evidence that is missing, with the sources that could support each point.",
77
77
  "- Do not write the manuscript's paragraphs. Say what is missing and where the support is; {{user}} writes the text. The Editor profile drafts text (/profile).",
78
- "- Librarian: 25 items per review note.",
78
+ "- Tag review notes: 25 items.",
79
79
  ].join("\n"),
80
80
  },
81
81
  editor: {
@@ -89,7 +89,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
89
89
  "Profile: Editor. All tools.",
90
90
  "- Write literature notes and syntheses from what you read, with a source for every claim.",
91
91
  "- When {{user}} asks, draft text for a manuscript: cite library items as [@citekey], use only claims from sources you read, and mark drafted text clearly so that {{user}} can check it.",
92
- "- Librarian: 25 items per review note unless {{user}} asks for more.",
92
+ "- Tag review notes: 25 items unless {{user}} asks for more.",
93
93
  ].join("\n"),
94
94
  },
95
95
  };
package/src/prompt.ts CHANGED
@@ -53,13 +53,13 @@ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
53
53
  }
54
54
 
55
55
  export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
56
- const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are the ${mode}.`;
56
+ const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
57
57
  return cfg ? fill(raw, cfg) : raw;
58
58
  }
59
59
 
60
60
  export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
61
61
  const user = who(cfg);
62
- const parts = [`# Sub-Sub: ${mode} mode`, roleText(mode, cfg)];
62
+ const parts = [`# Sub-Sub: ${mode === "librarian" ? "Librarian" : "Researcher"} mode`, roleText(mode, cfg)];
63
63
  const shared = readIf(cfg.sharedRules);
64
64
  if (shared) {
65
65
  parts.push(
package/src/roles.ts CHANGED
@@ -7,7 +7,7 @@ import { profileSpec } from "./profiles.ts";
7
7
 
8
8
  export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
9
9
 
10
- /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
10
+ /** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode and the profile for these). */
11
11
  export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
12
12
 
13
13
  export const ZOTERO_READ = [
@@ -48,27 +48,48 @@ export const PREVIEW_WRITES = new Set([
48
48
  /** Writes without a dry run that still need a yes. */
49
49
  export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
50
50
 
51
- export function toolsFor(mode: Mode, registered: string[], profile: Profile = "editor"): string[] {
51
+ /** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
52
+ export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map(
53
+ (n) => `zotero_${n}`,
54
+ );
55
+
56
+
57
+ export interface ToolOptions {
58
+ profile?: Profile;
59
+ /** The model test: also the bakeoff_ tools. */
60
+ bench?: boolean;
61
+ }
62
+
63
+ /**
64
+ * The tools of a mode. The Librarian manages the library: Zotero tools and files.
65
+ * The Researcher has the same, plus searches, notes and reference checks. The
66
+ * profile then removes tools. Every library change is still previewed (gateKind).
67
+ */
68
+ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
52
69
  const has = new Set(registered);
53
- const without = new Set(profileSpec(profile).without);
54
- const wanted =
55
- mode === "librarian"
56
- ? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
57
- : [
58
- ...ZOTERO_READ,
59
- ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
60
- ...FILE_TOOLS,
61
- ];
70
+ const without = new Set(profileSpec(opts.profile ?? "editor").without);
71
+ const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
72
+ const wanted = [
73
+ ...ZOTERO_READ,
74
+ ...ZOTERO_CHECKS,
75
+ ...zotero,
76
+ ...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
77
+ ...FILE_TOOLS,
78
+ ];
62
79
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
63
80
  }
64
81
 
65
82
  /** Why a Sub-Sub tool is not available now, or undefined when it is. */
66
- export function unavailableReason(name: string, mode: Mode, registered: string[], profile: Profile): string | undefined {
67
- if (toolsFor(mode, registered, profile).includes(name)) return undefined;
83
+ export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
84
+ if (toolsFor(mode, registered, opts).includes(name)) return undefined;
85
+ const profile = opts.profile ?? "editor";
68
86
  if (profileSpec(profile).without.includes(name)) {
69
87
  return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
70
88
  }
71
- return `${name} is not available in ${mode} mode. The user can switch with /${otherMode(mode)}.`;
89
+ if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
90
+ return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
91
+ }
92
+ return `${name} is not available.`;
72
93
  }
73
94
 
74
95
  export type GateKind = "preview" | "confirm" | "path" | null;
@@ -84,7 +105,3 @@ export function gateKind(toolName: string, input: Record<string, unknown>): Gate
84
105
  if (toolName === "write" || toolName === "edit") return "path";
85
106
  return null;
86
107
  }
87
-
88
- export function otherMode(mode: Mode): Mode {
89
- return mode === "librarian" ? "researcher" : "librarian";
90
- }
package/src/subsub.ts CHANGED
@@ -1,15 +1,16 @@
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
+ * - Two modes: the Librarian manages the library (Zotero tools only); the
5
+ * Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
6
+ * and writes notes. Each mode can have its own model.
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
11
  * - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
11
12
  * - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
12
- * ("verify") for reference checks; its tools belong to researcher mode.
13
+ * ("verify") for reference checks.
13
14
  *
14
15
  * The Python servers start in session_start (not in the factory) and stop in
15
16
  * session_shutdown. Tools are registered once and use the current bridge.
@@ -25,9 +26,9 @@ import {
25
26
  findUv,
26
27
  isProfile,
27
28
  loadConfig,
28
- type Mode,
29
29
  parseEnvFile,
30
30
  type Profile,
31
+ type Mode,
31
32
  saveConfig,
32
33
  serverCommand,
33
34
  starbuckCommand,
@@ -44,6 +45,8 @@ import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, sche
44
45
  import { systemAddition } from "./prompt.ts";
45
46
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
46
47
 
48
+ const MODE_ENTRY = "subsub-mode";
49
+
47
50
  export interface SubsubDeps {
48
51
  config?: SubsubConfig;
49
52
  /** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
@@ -52,7 +55,6 @@ export interface SubsubDeps {
52
55
  }
53
56
 
54
57
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
55
- const MODE_ENTRY = "subsub-mode";
56
58
  const BENCH_ENTRY = "subsub-bench";
57
59
 
58
60
  function ownVersion(): string {
@@ -127,9 +129,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
127
129
 
128
130
  if (deps.bridge) registerTools(deps.bridge);
129
131
 
130
- pi.registerFlag("librarian", { description: "Start Sub-Sub in librarian mode", type: "boolean", default: false });
132
+ pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
131
133
 
132
134
  let mode: Mode = cfg.defaultMode;
135
+ const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
136
+ const toolOpts = () => ({ profile, bench: benchRun });
133
137
  let profile: Profile = cfg.profile;
134
138
  const user = who(cfg);
135
139
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -172,7 +176,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
172
176
  function applyTheme(ctx: ExtensionContext): void {
173
177
  if (!lookOn(ctx) || cfg.themes === false) return;
174
178
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
175
- const name = themeName(mode, scheme, cfg.themes);
179
+ const name = themeName(scheme, cfg.themes || undefined);
176
180
  const theme = name ? ctx.ui.getTheme(name) : undefined;
177
181
  // A Theme object (not a name) changes the theme for this run only; the saved setting stays.
178
182
  if (theme) ctx.ui.setTheme(theme);
@@ -250,7 +254,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
250
254
 
251
255
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
252
256
  mode = next;
253
- pi.setActiveTools(toolsFor(mode, registered(), profile));
257
+ pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
254
258
  const spec = cfg.models[mode];
255
259
  if (spec) {
256
260
  const i = spec.indexOf("/");
@@ -259,7 +263,6 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
259
263
  if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
260
264
  }
261
265
  if (remember) pi.appendEntry(MODE_ENTRY, { mode });
262
- applyTheme(ctx);
263
266
  status(ctx);
264
267
  }
265
268
 
@@ -277,6 +280,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
277
280
  }
278
281
  // Judge light or dark before Sub-Sub changes the theme.
279
282
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
283
+ applyTheme(ctx);
280
284
  await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
281
285
  if (lookOn(ctx)) {
282
286
  setHeader(ctx);
@@ -323,7 +327,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
323
327
  if (schema) coerceArgs(schema, input);
324
328
  resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
325
329
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
326
- const why = unavailableReason(name, mode, registered(), profile);
330
+ const why = unavailableReason(name, mode, registered(), toolOpts());
327
331
  if (why) {
328
332
  if (benchOut) record({ kind: "wrong_mode", tool: name, input });
329
333
  return { block: true, reason: why };
@@ -438,18 +442,18 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
438
442
  }
439
443
 
440
444
  pi.registerCommand("librarian", {
441
- description: "Sub-Sub: switch to the librarian (changes the library, no web)",
445
+ description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
442
446
  handler: async (_args, ctx) => {
443
447
  await applyMode(ctx, "librarian", true);
444
- ctx.ui.notify("Sub-Sub: librarian mode", "info");
448
+ ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
445
449
  },
446
450
  });
447
451
 
448
452
  pi.registerCommand("researcher", {
449
- description: "Sub-Sub: switch to the researcher (search, notes; library read-only)",
453
+ description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
450
454
  handler: async (_args, ctx) => {
451
455
  await applyMode(ctx, "researcher", true);
452
- ctx.ui.notify("Sub-Sub: researcher mode", "info");
456
+ ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
453
457
  },
454
458
  });
455
459
 
package/src/web.ts CHANGED
@@ -568,12 +568,11 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
568
568
  if (busy) return send(res, 409, { error: "busy" });
569
569
  const body = await readJson(req);
570
570
  const spec = `${String(body.provider)}/${String(body.id)}`;
571
- // Sub-Sub keeps one model per mode: remember it for the current mode, then restart pi so the
572
- // extension reads the new setting.
571
+ // Remember the model for the current mode (or both), then restart pi so the extension reads it.
573
572
  const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
574
- const models = { ...(loadConfig(env).models ?? {}), [mode]: spec };
573
+ const models: Record<string, string> = { ...(loadConfig(env).models ?? {}), [mode]: spec };
575
574
  if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
576
- saveConfig({ models }, env);
575
+ saveConfig({ models, model: undefined }, env);
577
576
  await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
578
577
  await restartAgent();
579
578
  return send(res, 200, { ok: true });
@@ -597,7 +596,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
597
596
  const body = await readJson(req);
598
597
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
599
598
  const current = loadConfig(env);
600
- if (body.provider === "opencode-go" && Object.keys(current.models ?? {}).length === 0) saveConfig({ models: TESTED_MODELS }, env);
599
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
601
600
  await restartAgent();
602
601
  return send(res, 200, { ok: true });
603
602
  }
@@ -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 has two modes. The Librarian manages the library: tags, imports, metadata, PDFs, collections, notes in Zotero. The Researcher reads the library, searches PubMed, Europe PMC and OpenAlex, writes notes in this folder, and also does everything the Librarian does.
6
+ - Every library 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,7 +87,7 @@
87
87
  --warn: #d6a84e;
88
88
  }
89
89
 
90
- /* The mode sets the accent: blue for the researcher, rust for the librarian. */
90
+ /* The mode sets the accent: blue for the Researcher, rust for the Librarian. */
91
91
  body { --accent: var(--researcher); --accent-soft: var(--researcher-soft); }
92
92
  body[data-mode="librarian"] { --accent: var(--librarian); --accent-soft: var(--librarian-soft); }
93
93
 
@@ -316,7 +316,8 @@ select:hover { border-color: var(--rule-strong); }
316
316
  border-radius: 5px;
317
317
  }
318
318
  .actions button:hover { background: var(--bg); }
319
- .actions hr { border: 0; border-top: 1px solid var(--rule); margin: 0.4rem 0.5rem; }
319
+ .actions .group { margin: 0.55rem 0 0.15rem; padding: 0 0.5rem; font-size: 0.74rem; font-weight: 600; color: var(--muted); }
320
+ .actions .group:first-child { margin-top: 0; }
320
321
 
321
322
  .side .grow { flex: 1; display: flex; flex-direction: column; min-height: 8rem; }
322
323
  .side-subhead { display: flex; justify-content: space-between; align-items: baseline; padding-right: 0.3rem; }
package/web/app.js CHANGED
@@ -175,7 +175,10 @@ function mode() {
175
175
 
176
176
  function renderHeader() {
177
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
+ for (const b of document.querySelectorAll(".modes button")) {
179
+ b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
180
+ b.title = t(b.dataset.mode === "librarian" ? "librarianHint" : "researcherHint");
181
+ }
179
182
  if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
180
183
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
181
184
  $("starbuck-label").title = t("starbuckHint");
@@ -221,12 +224,18 @@ function hasCommand(name) {
221
224
  function renderActions() {
222
225
  const box = $("actions");
223
226
  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));
227
+ const shown = (a) =>
228
+ hasCommand(a.cmd) &&
229
+
230
+ (!a.needs || state?.[a.needs]) &&
231
+ (!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
232
+ const groups = mode() === "librarian" ? [["library", "groupLibrary"]] : [["research", "groupResearch"], ["library", "groupLibrary"]];
233
+ for (const [group, label] of groups) {
234
+ const list = ACTIONS[group].filter(shown);
235
+ if (!list.length) continue;
236
+ box.append(el("p", { class: "group", text: t(label) }));
237
+ for (const a of list) box.append(actionButton(a));
238
+ }
230
239
  }
231
240
 
232
241
  function actionButton(a) {
@@ -326,17 +335,17 @@ function exportConversation() {
326
335
  if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
327
336
  } else if (m.classList.contains("assistant")) {
328
337
  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`);
338
+ if (text) msgs.push(`### Sub-Sub\n\n${text}\n`);
330
339
  }
331
340
  }
332
341
  if (!msgs.length) return;
333
342
  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")}`;
343
+ const content = `# Sub-Sub Research Session (${date})\n\n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
335
344
  const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
336
345
  const url = URL.createObjectURL(blob);
337
346
  const a = document.createElement("a");
338
347
  a.href = url;
339
- a.download = `Sub-Sub-${mode()}-${date}.md`;
348
+ a.download = `Sub-Sub-${date}.md`;
340
349
  document.body.append(a);
341
350
  a.click();
342
351
  a.remove();
@@ -369,7 +378,7 @@ function clearEmpty() {
369
378
 
370
379
  function renderEmpty() {
371
380
  if (log.children.length) return;
372
- const s = SUGGESTIONS[mode()][lang] ?? SUGGESTIONS[mode()].en;
381
+ const s = SUGGESTIONS[mode()].map((x) => x[lang] ?? x.en);
373
382
  log.append(
374
383
  el(
375
384
  "div",
@@ -708,7 +717,8 @@ function onEvent(ev) {
708
717
  break;
709
718
  case "extension_ui_request":
710
719
  if (ev.method === "notify") {
711
- if (/^Sub-Sub: (librarian|researcher) mode$/.test(ev.message)) break;
720
+ // The mode buttons show the mode; these confirmations would only repeat it.
721
+ if (/^Sub-Sub: (Librarian|Researcher) mode\./.test(ev.message)) break;
712
722
  note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
713
723
  } else {
714
724
  dialogs.push(ev);
@@ -935,15 +945,17 @@ function autosize() {
935
945
 
936
946
  async function sendMessage(text) {
937
947
  const message = text.trim();
938
- if (!message) return;
948
+ if (!message) return false;
939
949
  const name = message.startsWith("/") ? message.slice(1).split(/\s/)[0] : "";
940
950
  const isTemplate = name && commands.some((c) => c.name === name && c.source === "prompt");
941
951
  if (isTemplate) sentTemplates.push(message);
942
952
  try {
943
953
  await api("/api/prompt", { message });
954
+ return true;
944
955
  } catch (err) {
945
956
  if (isTemplate) sentTemplates.pop();
946
957
  note(err.message, "error");
958
+ return false;
947
959
  }
948
960
  }
949
961
 
package/web/i18n.js CHANGED
@@ -3,8 +3,6 @@
3
3
 
4
4
  export const STRINGS = {
5
5
  en: {
6
- researcher: "Researcher",
7
- librarian: "Librarian",
8
6
  profile: "Profile",
9
7
  model: "Model",
10
8
  noModel: "No model",
@@ -14,8 +12,6 @@ export const STRINGS = {
14
12
  noConversations: "No earlier conversations.",
15
13
  send: "Send",
16
14
  stop: "Stop",
17
- placeholderResearcher: "Ask about your library, or type / for commands",
18
- placeholderLibrarian: "Ask for a change to your library, or type / for commands",
19
15
  yes: "Yes",
20
16
  no: "No",
21
17
  ok: "OK",
@@ -44,7 +40,6 @@ export const STRINGS = {
44
40
  connect: "Connect",
45
41
  modelTitle: "Model",
46
42
  chooseModel: "Choose a model",
47
- forBoth: "Use it in both modes",
48
43
  noModels: "No models yet. Connect a provider below.",
49
44
  connectProvider: "Connect a provider",
50
45
  provider: "Provider",
@@ -83,13 +78,20 @@ export const STRINGS = {
83
78
  starbuckOn: "Reference checks are on. Use /verify, or ask the researcher to check a manuscript's references.",
84
79
  starbuckOff: "Reference checks are off.",
85
80
  composerHint: "Enter sends. Shift+Enter starts a new line. / shows the commands.",
86
- emptyResearcher: "The researcher reads your library and searches PubMed, Europe PMC and OpenAlex. It cannot change your library.",
87
- emptyLibrarian: "The librarian changes your library. You see a preview of each change, and nothing changes until you approve it.",
88
81
  previewKicker: "Preview",
82
+ researcher: "Researcher",
83
+ librarian: "Librarian",
84
+ researcherHint: "Searches, reads and writes notes, and also changes the library (every change is previewed).",
85
+ librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
86
+ placeholderResearcher: "Ask about your library or the literature, or type / for commands",
87
+ placeholderLibrarian: "Ask for a change to your library, or type / for commands",
88
+ emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
89
+ emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
90
+ forBoth: "Use it in both modes",
91
+ groupResearch: "Research",
92
+ groupLibrary: "Library",
89
93
  },
90
94
  pt: {
91
- researcher: "Investigador",
92
- librarian: "Bibliotecário",
93
95
  profile: "Perfil",
94
96
  model: "Modelo",
95
97
  noModel: "Sem modelo",
@@ -99,8 +101,6 @@ export const STRINGS = {
99
101
  noConversations: "Ainda não há conversas anteriores.",
100
102
  send: "Enviar",
101
103
  stop: "Parar",
102
- placeholderResearcher: "Pergunte sobre a sua biblioteca, ou escreva / para ver os comandos",
103
- placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
104
104
  yes: "Sim",
105
105
  no: "Não",
106
106
  ok: "OK",
@@ -129,7 +129,6 @@ export const STRINGS = {
129
129
  connect: "Ligar",
130
130
  modelTitle: "Modelo",
131
131
  chooseModel: "Escolha um modelo",
132
- forBoth: "Usar nos dois modos",
133
132
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
134
133
  connectProvider: "Ligar um fornecedor",
135
134
  provider: "Fornecedor",
@@ -168,16 +167,27 @@ export const STRINGS = {
168
167
  starbuckOn: "A verificação de referências está ligada. Use /verify, ou peça ao investigador que verifique as referências de um manuscrito.",
169
168
  starbuckOff: "A verificação de referências está desligada.",
170
169
  composerHint: "Enter envia. Shift+Enter muda de linha. / mostra os comandos.",
171
- emptyResearcher: "O investigador lê a sua biblioteca e pesquisa na PubMed, Europe PMC e OpenAlex. Não pode alterar a biblioteca.",
172
- emptyLibrarian: "O bibliotecário altera a sua biblioteca. Vê uma pré-visualização de cada alteração, e nada muda até a aprovar.",
173
170
  previewKicker: "Pré-visualização",
171
+ researcher: "Investigador",
172
+ librarian: "Bibliotecário",
173
+ researcherHint: "Pesquisa, lê e escreve notas, e também altera a biblioteca (cada alteração é pré-visualizada).",
174
+ librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
175
+ placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
176
+ placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
177
+ emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
178
+ emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
179
+ forBoth: "Usar nos dois modos",
180
+ groupResearch: "Investigação",
181
+ groupLibrary: "Biblioteca",
174
182
  },
175
183
  };
176
184
 
177
- /** Buttons in the side panel. `fill` puts the command in the message box; `send` runs it at once. */
185
+ /**
186
+ * Buttons in the side panel, in two groups. `fill` puts the command in the message box; `send` runs it at once.
187
+ * The Librarian sees the Library group only. `profiles` and `needs` limit a button to some profiles or add-ons.
188
+ */
178
189
  export const ACTIONS = {
179
- researcher: [
180
- { cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
190
+ research: [
181
191
  { cmd: "lit", fill: true, profiles: ["scholar", "author", "editor"], en: "Literature review", pt: "Revisão da literatura" },
182
192
  { cmd: "compare", fill: true, profiles: ["scholar", "author", "editor"], en: "Compare sources", pt: "Comparar fontes" },
183
193
  { cmd: "lit-note", fill: true, en: "Literature note", pt: "Nota de leitura" },
@@ -190,28 +200,28 @@ export const ACTIONS = {
190
200
  { cmd: "request-copy", fill: true, en: "Ask an author for a copy", pt: "Pedir uma cópia ao autor" },
191
201
  { cmd: "digest", send: true, profiles: ["scholar", "author", "editor"], en: "Digest of the week", pt: "Resumo da semana" },
192
202
  ],
193
- librarian: [
203
+ library: [
194
204
  { cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
195
205
  { cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
196
206
  { cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
197
207
  { cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
198
- ],
199
- both: [
200
208
  { cmd: "history", send: true, en: "Recent changes", pt: "Alterações recentes" },
201
209
  { cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
202
210
  ],
203
211
  };
204
212
 
205
- /** Suggestions on an empty conversation. */
213
+ /** Suggestions on an empty conversation, per mode. */
206
214
  export const SUGGESTIONS = {
207
- researcher: {
208
- en: ["What do I have on ", "Find recent systematic reviews on ", "Which items have no PDF?"],
209
- pt: ["O que tenho sobre ", "Procure revisões sistemáticas recentes sobre ", "Que itens não têm PDF?"],
210
- },
211
- librarian: {
212
- en: ["Tag the next 10 items without a topic", "Import 10.1136/bmj.n71", "Find duplicates"],
213
- pt: ["Etiquete os próximos 10 itens sem tópico", "Importe 10.1136/bmj.n71", "Procure duplicados"],
214
- },
215
+ researcher: [
216
+ { en: "What do I have on ", pt: "O que tenho sobre " },
217
+ { en: "Find recent systematic reviews on ", pt: "Procure revisões sistemáticas recentes sobre " },
218
+ { en: "Which items have no PDF?", pt: "Que itens não têm PDF?" },
219
+ ],
220
+ librarian: [
221
+ { en: "Tag the next 10 items without a topic", pt: "Etiquete os próximos 10 itens sem tópico" },
222
+ { en: "Import 10.1136/bmj.n71", pt: "Importe 10.1136/bmj.n71" },
223
+ { en: "Find duplicates", pt: "Procure duplicados" },
224
+ ],
215
225
  };
216
226
 
217
227
  /** Plain names for the tools, shown while they run. Unknown tools keep their name. */
package/web/index.html CHANGED
@@ -110,7 +110,7 @@
110
110
  <button type="button" class="link-btn" id="model-close" data-i18n="close">Close</button>
111
111
  </div>
112
112
  <h3 data-i18n="chooseModel">Choose a model</h3>
113
- <label class="check"><input type="checkbox" id="model-both" checked> <span data-i18n="forBoth">Use it in both modes</span></label>
113
+ <label class="check"><input type="checkbox" id="model-both"> <span data-i18n="forBoth">Use it in both modes</span></label>
114
114
  <ul class="models" id="models"></ul>
115
115
  <h3 data-i18n="connectProvider">Connect a provider</h3>
116
116
  <form id="login-form" class="login">