@tiagojct/subsub 0.5.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 (54) hide show
  1. package/README.md +90 -0
  2. package/bin/subsub-bench.js +19 -0
  3. package/bin/subsub.js +14 -0
  4. package/dist/args.js +67 -0
  5. package/dist/bench.js +534 -0
  6. package/dist/bridge.js +107 -0
  7. package/dist/cli.js +209 -0
  8. package/dist/config.js +144 -0
  9. package/dist/doctor.js +131 -0
  10. package/dist/init.js +215 -0
  11. package/dist/look.js +107 -0
  12. package/dist/paths.js +86 -0
  13. package/dist/preview.js +94 -0
  14. package/dist/profiles.js +79 -0
  15. package/dist/prompt.js +72 -0
  16. package/dist/roles.js +73 -0
  17. package/dist/subsub.js +414 -0
  18. package/docs/Subsub.md +126 -0
  19. package/extensions/subsub.ts +6 -0
  20. package/package.json +73 -0
  21. package/prompts/alert.md +4 -0
  22. package/prompts/clean-tags.md +4 -0
  23. package/prompts/gaps.md +5 -0
  24. package/prompts/import-queue.md +5 -0
  25. package/prompts/lit-note.md +5 -0
  26. package/prompts/manuscript.md +5 -0
  27. package/prompts/synthesis.md +5 -0
  28. package/prompts/tag-batch.md +5 -0
  29. package/roles/librarian.md +46 -0
  30. package/roles/researcher.md +37 -0
  31. package/scripts/build.mjs +14 -0
  32. package/scripts/make-themes.py +116 -0
  33. package/src/args.ts +64 -0
  34. package/src/bench.ts +580 -0
  35. package/src/bridge.ts +134 -0
  36. package/src/cli.ts +201 -0
  37. package/src/config.ts +191 -0
  38. package/src/doctor.ts +171 -0
  39. package/src/init.ts +252 -0
  40. package/src/look.ts +141 -0
  41. package/src/paths.ts +100 -0
  42. package/src/preview.ts +107 -0
  43. package/src/profiles.ts +99 -0
  44. package/src/prompt.ts +81 -0
  45. package/src/roles.ts +83 -0
  46. package/src/subsub.ts +434 -0
  47. package/templates/Zotero agent.md +87 -0
  48. package/templates/vocabularies/any-field.md +52 -0
  49. package/templates/vocabularies/health-informatics.md +88 -0
  50. package/templates/vocabularies/health-sciences.md +95 -0
  51. package/themes/subsub-glauca-dark.json +91 -0
  52. package/themes/subsub-glauca-light.json +91 -0
  53. package/themes/subsub-try-works-dark.json +91 -0
  54. package/themes/subsub-try-works-light.json +91 -0
package/dist/roles.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Which tools each mode may use, and which calls need the user's approval.
3
+ */
4
+ import { profileSpec } from "./profiles.js";
5
+ export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
6
+ export const ZOTERO_READ = [
7
+ "status",
8
+ "library_overview",
9
+ "find_items",
10
+ "get_item",
11
+ "get_fulltext",
12
+ "list_tags",
13
+ "get_vocabulary",
14
+ "list_collections",
15
+ "history",
16
+ ].map((n) => `zotero_${n}`);
17
+ /** Write tools that support dry_run: the gate previews them and asks. */
18
+ export const PREVIEW_WRITES = new Set([
19
+ ...[
20
+ "tag_items",
21
+ "apply_tag_review",
22
+ "rename_tags",
23
+ "remove_tags",
24
+ "remove_automatic_tags",
25
+ "set_citekeys",
26
+ "update_fields",
27
+ "file_items",
28
+ "create_collection",
29
+ "create_note",
30
+ "trash_items",
31
+ "undo",
32
+ "import_identifiers",
33
+ "import_queue",
34
+ "repair_metadata",
35
+ "attach_oa_pdfs",
36
+ ].map((n) => `zotero_${n}`),
37
+ "scholar_attach_note",
38
+ ]);
39
+ /** Writes without a dry run that still need a yes. */
40
+ export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
41
+ export function toolsFor(mode, registered, profile = "editor") {
42
+ const has = new Set(registered);
43
+ const without = new Set(profileSpec(profile).without);
44
+ const wanted = mode === "librarian"
45
+ ? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
46
+ : [...ZOTERO_READ, ...registered.filter((n) => n.startsWith("scholar_")), ...FILE_TOOLS];
47
+ return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
48
+ }
49
+ /** Why a Sub-Sub tool is not available now, or undefined when it is. */
50
+ export function unavailableReason(name, mode, registered, profile) {
51
+ if (toolsFor(mode, registered, profile).includes(name))
52
+ return undefined;
53
+ if (profileSpec(profile).without.includes(name)) {
54
+ return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
55
+ }
56
+ return `${name} is not available in ${mode} mode. The user can switch with /${otherMode(mode)}.`;
57
+ }
58
+ /** A dry run only when dry_run is absent (server default true) or clearly true. */
59
+ export function isDryRun(value) {
60
+ return value === undefined || value === true || value === "true";
61
+ }
62
+ export function gateKind(toolName, input) {
63
+ if (PREVIEW_WRITES.has(toolName))
64
+ return isDryRun(input.dry_run) ? null : "preview";
65
+ if (CONFIRM_WRITES.has(toolName))
66
+ return "confirm";
67
+ if (toolName === "write" || toolName === "edit")
68
+ return "path";
69
+ return null;
70
+ }
71
+ export function otherMode(mode) {
72
+ return mode === "librarian" ? "researcher" : "librarian";
73
+ }
package/dist/subsub.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Sub-Sub: a Zotero librarian and research assistant for pi.
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).
6
+ * - An approval gate: every library change is previewed by the server (dry run)
7
+ * and shown to the user; nothing is applied without a yes.
8
+ * - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
9
+ * tools it offers; /profile changes it.
10
+ * - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
11
+ *
12
+ * The Python servers start in session_start (not in the factory) and stop in
13
+ * session_shutdown. Tools are registered once and use the current bridge.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { Bridge } from "./bridge.js";
19
+ import { expand, findUv, isProfile, loadConfig, saveConfig, serverCommand, who } from "./config.js";
20
+ import { profileList, profileSpec } from "./profiles.js";
21
+ import { coerceArgs, loosenArrays } from "./args.js";
22
+ import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
23
+ import { describeArgs, formatPreview } from "./preview.js";
24
+ import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
25
+ import { systemAddition } from "./prompt.js";
26
+ import { gateKind, toolsFor, unavailableReason } from "./roles.js";
27
+ const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
28
+ const MODE_ENTRY = "subsub-mode";
29
+ const BENCH_ENTRY = "subsub-bench";
30
+ function ownVersion() {
31
+ try {
32
+ return JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")).version ?? "";
33
+ }
34
+ catch {
35
+ return "";
36
+ }
37
+ }
38
+ export { findUv };
39
+ export function serverSpecs(cfg) {
40
+ const env = cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {};
41
+ return [
42
+ { name: "zotero", ...serverCommand(cfg, "zotero-local-mcp"), env },
43
+ { name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp"), env },
44
+ ];
45
+ }
46
+ export async function createSubsub(pi, deps = {}) {
47
+ const cfg = deps.config ?? loadConfig();
48
+ let bridge = deps.bridge;
49
+ const toolNames = new Set();
50
+ /** Original input schemas, to turn "[...]" strings back into lists. */
51
+ const schemas = new Map();
52
+ function current() {
53
+ if (!bridge)
54
+ throw new Error("The Zotero servers are not running. Try /reload.");
55
+ return bridge;
56
+ }
57
+ function registerTools(b) {
58
+ for (const t of b.tools) {
59
+ if (toolNames.has(t.fullName))
60
+ continue;
61
+ toolNames.add(t.fullName);
62
+ schemas.set(t.fullName, t.inputSchema);
63
+ pi.registerTool({
64
+ name: t.fullName,
65
+ label: `${t.server} ${t.name}`,
66
+ description: t.description,
67
+ parameters: loosenArrays(t.inputSchema),
68
+ // One at a time, so a preview always sees the effect of earlier calls in the same message.
69
+ executionMode: "sequential",
70
+ async execute(_id, params, signal) {
71
+ const args = coerceArgs(t.inputSchema, { ...(params ?? {}) });
72
+ const res = await current().call(t.fullName, args, signal);
73
+ if (res.isError)
74
+ throw new Error(res.text || `${t.fullName} failed`);
75
+ return { content: [{ type: "text", text: res.text }], details: { tool: t.fullName } };
76
+ },
77
+ });
78
+ }
79
+ }
80
+ if (deps.bridge)
81
+ registerTools(deps.bridge);
82
+ pi.registerFlag("librarian", { description: "Start Sub-Sub in librarian mode", type: "boolean", default: false });
83
+ let mode = cfg.defaultMode;
84
+ let profile = cfg.profile;
85
+ const user = who(cfg);
86
+ /** True when started with the `subsub` command (not as a package inside plain pi). */
87
+ const standalone = process.env.SUBSUB_CLI === "1";
88
+ const registered = () => pi.getAllTools().map((t) => t.name);
89
+ // ---- look (only for the `subsub` command in the terminal UI)
90
+ let scheme;
91
+ let modelId = "no model";
92
+ let headerTui;
93
+ const library = { zotero: "checking" };
94
+ const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
95
+ const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
96
+ function applyTheme(ctx) {
97
+ if (!lookOn(ctx) || cfg.themes === false)
98
+ return;
99
+ scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
100
+ const name = themeName(mode, scheme, cfg.themes);
101
+ const theme = name ? ctx.ui.getTheme(name) : undefined;
102
+ // A Theme object (not a name) changes the theme for this run only; the saved setting stays.
103
+ if (theme)
104
+ ctx.ui.setTheme(theme);
105
+ }
106
+ function paintFor(ctx) {
107
+ return (text) => ctx.mode === "tui"
108
+ ? paintPreview(text, (x) => ctx.ui.theme.fg("success", x), (x) => ctx.ui.theme.fg("error", x), (x) => ctx.ui.theme.fg("text", x))
109
+ : text;
110
+ }
111
+ async function refreshLibrary() {
112
+ try {
113
+ const st = await current().call("zotero_status", {});
114
+ const d = (st.data ?? {});
115
+ if (d.zotero !== "reachable") {
116
+ Object.assign(library, { zotero: "down", error: String(d.error ?? "") });
117
+ }
118
+ else {
119
+ const ov = await current().call("zotero_library_overview", {});
120
+ const o = (ov.data ?? {});
121
+ Object.assign(library, { zotero: "reachable", items: o.items, toReview: o.awaiting_review });
122
+ }
123
+ }
124
+ catch {
125
+ library.zotero = "down";
126
+ }
127
+ headerTui?.requestRender();
128
+ }
129
+ function setHeader(ctx) {
130
+ if (!lookOn(ctx))
131
+ return;
132
+ const version = ownVersion();
133
+ ctx.ui.setHeader((tui) => {
134
+ headerTui = tui;
135
+ return {
136
+ render: (width) => {
137
+ const th = ctx.ui.theme;
138
+ return headerLines({
139
+ version,
140
+ mode,
141
+ profile: profileSpec(profile).label,
142
+ model: modelId,
143
+ library,
144
+ quote,
145
+ width,
146
+ paint: {
147
+ mark: (x) => th.fg("accent", x),
148
+ text: (x) => th.fg("text", x),
149
+ muted: (x) => th.fg("muted", x),
150
+ dim: (x) => th.fg("dim", x),
151
+ warn: (x) => th.fg("warning", x),
152
+ },
153
+ });
154
+ },
155
+ invalidate() { },
156
+ };
157
+ });
158
+ }
159
+ function status(ctx) {
160
+ modelId = ctx.model ? `${ctx.model.id}` : "no model";
161
+ const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
162
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
163
+ if (standalone)
164
+ ctx.ui.setTitle(`Sub-Sub: ${mode}`);
165
+ headerTui?.requestRender();
166
+ }
167
+ async function applyMode(ctx, next, remember) {
168
+ mode = next;
169
+ pi.setActiveTools(toolsFor(mode, registered(), profile));
170
+ const spec = cfg.models[mode];
171
+ if (spec) {
172
+ const i = spec.indexOf("/");
173
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
174
+ const ok = model ? await pi.setModel(model) : false;
175
+ if (!ok)
176
+ ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
177
+ }
178
+ if (remember)
179
+ pi.appendEntry(MODE_ENTRY, { mode });
180
+ applyTheme(ctx);
181
+ status(ctx);
182
+ }
183
+ pi.on("session_start", async (_event, ctx) => {
184
+ if (!bridge) {
185
+ const b = new Bridge();
186
+ await b.start(deps.specs ?? serverSpecs(cfg), cfg.startTimeout * 1000);
187
+ bridge = b;
188
+ registerTools(b);
189
+ }
190
+ let restored;
191
+ for (const e of ctx.sessionManager.getBranch()) {
192
+ const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
193
+ if (m === "librarian" || m === "researcher")
194
+ restored = m;
195
+ }
196
+ // Judge light or dark before Sub-Sub changes the theme.
197
+ if (lookOn(ctx))
198
+ scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
199
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
200
+ if (lookOn(ctx)) {
201
+ setHeader(ctx);
202
+ void refreshLibrary();
203
+ }
204
+ for (const [name, err] of Object.entries(bridge.errors)) {
205
+ ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
206
+ }
207
+ });
208
+ pi.on("session_shutdown", async () => {
209
+ if (deps.bridge)
210
+ return;
211
+ const b = bridge;
212
+ bridge = undefined;
213
+ await b?.close();
214
+ });
215
+ pi.on("model_select", async (_event, ctx) => status(ctx));
216
+ pi.on("before_agent_start", async (event, ctx) => ({
217
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
218
+ }));
219
+ /** Model test (subsub-bench): record every change instead of asking, and apply none. */
220
+ const benchOut = process.env.SUBSUB_BENCH_OUT ? expand(process.env.SUBSUB_BENCH_OUT) : undefined;
221
+ const record = (data) => pi.appendEntry(BENCH_ENTRY, data);
222
+ pi.on("tool_call", async (event, ctx) => {
223
+ const name = event.toolName;
224
+ const input = (event.input ?? {});
225
+ // Previews and the real call must see the same, repaired arguments.
226
+ const schema = schemas.get(name);
227
+ if (schema)
228
+ coerceArgs(schema, input);
229
+ if (name.startsWith("zotero_") || name.startsWith("scholar_")) {
230
+ const why = unavailableReason(name, mode, registered(), profile);
231
+ if (why) {
232
+ if (benchOut)
233
+ record({ kind: "wrong_mode", tool: name, input });
234
+ return { block: true, reason: why };
235
+ }
236
+ }
237
+ const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
238
+ if (!kind)
239
+ return undefined;
240
+ if (benchOut)
241
+ return benchGate(name, input, kind, ctx.cwd, ctx.signal);
242
+ if (kind === "path") {
243
+ const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
244
+ const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
245
+ if (verdict.ok)
246
+ return undefined;
247
+ if (!ctx.hasUI)
248
+ return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
249
+ const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, `Asking because ${verdict.why}.`);
250
+ return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
251
+ }
252
+ if (!ctx.hasUI) {
253
+ return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
254
+ }
255
+ if (kind === "confirm") {
256
+ const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
257
+ return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
258
+ }
259
+ // kind === "preview": the server computes the change first, then the user decides.
260
+ // Normalise dry_run so that what runs is exactly what was previewed and approved.
261
+ input.dry_run = false;
262
+ let preview;
263
+ try {
264
+ preview = await current().call(name, { ...input, dry_run: true }, ctx.signal);
265
+ }
266
+ catch (err) {
267
+ return { block: true, reason: `Preview failed: ${err.message}` };
268
+ }
269
+ if (preview.isError)
270
+ return { block: true, reason: preview.text };
271
+ const ok = await ctx.ui.confirm(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)));
272
+ return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
273
+ });
274
+ async function benchGate(name, input, kind, cwd, signal) {
275
+ if (kind === "path") {
276
+ const target = normalizeToolPath(String(input.path ?? ""), cwd);
277
+ if (within(target, realish(benchOut)))
278
+ return undefined;
279
+ record({ kind: "path_blocked", tool: name, path: target });
280
+ return { block: true, reason: `Model test: write only inside ${benchOut}.` };
281
+ }
282
+ let preview;
283
+ if (kind === "preview") {
284
+ try {
285
+ const p = await current().call(name, { ...input, dry_run: true }, signal);
286
+ preview = p.isError ? { error: p.text } : (p.data ?? p.text);
287
+ }
288
+ catch (err) {
289
+ preview = { error: err.message };
290
+ }
291
+ }
292
+ record({ kind: "change", tool: name, input, preview });
293
+ const shown = preview === undefined ? "" : ` What the change would do (server preview): ${JSON.stringify(preview).slice(0, 4000)}`;
294
+ return {
295
+ block: true,
296
+ reason: "Model test: this change was recorded and not applied. Continue as if it had been applied: make any other " +
297
+ "changes the task needs, do not repeat this call, do not ask for approval, and finish with a short summary " +
298
+ `for ${user}.${shown}`,
299
+ };
300
+ }
301
+ pi.registerCommand("librarian", {
302
+ description: "Sub-Sub: switch to the librarian (changes the library, no web)",
303
+ handler: async (_args, ctx) => {
304
+ await applyMode(ctx, "librarian", true);
305
+ ctx.ui.notify("Sub-Sub: librarian mode", "info");
306
+ },
307
+ });
308
+ pi.registerCommand("researcher", {
309
+ description: "Sub-Sub: switch to the researcher (search, notes; library read-only)",
310
+ handler: async (_args, ctx) => {
311
+ await applyMode(ctx, "researcher", true);
312
+ ctx.ui.notify("Sub-Sub: researcher mode", "info");
313
+ },
314
+ });
315
+ pi.registerCommand("profile", {
316
+ description: "Sub-Sub: show or change the profile (reader, scholar, author, editor)",
317
+ handler: async (args, ctx) => {
318
+ const want = args.trim().toLowerCase();
319
+ if (!want) {
320
+ ctx.ui.notify(`Profile: ${profileSpec(profile).label}. To change it, type /profile <name>.\n${profileList(profile)}`, "info");
321
+ return;
322
+ }
323
+ if (!isProfile(want)) {
324
+ ctx.ui.notify(`Unknown profile "${want}". Choose one of:\n${profileList(profile)}`, "warning");
325
+ return;
326
+ }
327
+ profile = want;
328
+ let saved = "";
329
+ try {
330
+ saved = ` Saved in ${saveConfig({ profile })}.`;
331
+ }
332
+ catch (err) {
333
+ saved = ` Not saved: ${err.message}`;
334
+ }
335
+ await applyMode(ctx, mode, false);
336
+ ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
337
+ },
338
+ });
339
+ pi.registerCommand("subsub", {
340
+ description: "Sub-Sub: Zotero connection and library overview",
341
+ handler: async (_args, ctx) => {
342
+ const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
343
+ if (!bridge) {
344
+ ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
345
+ return;
346
+ }
347
+ for (const [name, err] of Object.entries(bridge.errors))
348
+ lines.push(`${name} server not running: ${err.split("\n")[0]}`);
349
+ if (bridge.has("zotero_status")) {
350
+ const st = await bridge.call("zotero_status", {});
351
+ const d = (st.data ?? {});
352
+ lines.push(`Zotero: ${d.zotero ?? "?"}${d.error ? ` (${d.error})` : ""}. Write key remembered: ${d.write_key_remembered ?? "?"}.`);
353
+ library.zotero = d.zotero === "reachable" ? "reachable" : "down";
354
+ if (d.zotero === "reachable") {
355
+ const ov = await bridge.call("zotero_library_overview", {});
356
+ const o = (ov.data ?? {});
357
+ if (!ov.isError)
358
+ Object.assign(library, { items: o.items, toReview: o.awaiting_review });
359
+ lines.push(ov.isError
360
+ ? ov.text
361
+ : `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`);
362
+ }
363
+ }
364
+ headerTui?.requestRender();
365
+ ctx.ui.notify(lines.join("\n"), "info");
366
+ },
367
+ });
368
+ pi.registerCommand("history", {
369
+ description: "Sub-Sub: recent library changes",
370
+ handler: async (_args, ctx) => {
371
+ let res;
372
+ try {
373
+ res = await current().call("zotero_history", { limit: 10 });
374
+ }
375
+ catch (err) {
376
+ ctx.ui.notify(err.message, "warning");
377
+ return;
378
+ }
379
+ if (res.isError) {
380
+ ctx.ui.notify(res.text, "warning");
381
+ return;
382
+ }
383
+ const rows = res.data ?? [];
384
+ const text = rows.length
385
+ ? rows.map((r) => `${r.id} ${r.op} ${r.items} item(s)${r.fully_undone ? " (undone)" : ""}`).join("\n")
386
+ : "No changes yet.";
387
+ ctx.ui.notify(text, "info");
388
+ },
389
+ });
390
+ pi.registerCommand("undo", {
391
+ description: "Sub-Sub: revert the last library change (asks first)",
392
+ handler: async (args, ctx) => {
393
+ const input = args.trim() ? { journal_id: args.trim() } : {};
394
+ let preview;
395
+ try {
396
+ preview = await current().call("zotero_undo", { ...input, dry_run: true });
397
+ }
398
+ catch (err) {
399
+ ctx.ui.notify(err.message, "warning");
400
+ return;
401
+ }
402
+ if (preview.isError) {
403
+ ctx.ui.notify(preview.text, "warning");
404
+ return;
405
+ }
406
+ const ok = await ctx.ui.confirm("Sub-Sub: undo?", paintFor(ctx)(formatPreview("zotero_undo", preview.data)));
407
+ if (!ok)
408
+ return;
409
+ const res = await current().call("zotero_undo", { ...input, dry_run: false });
410
+ const d = (res.data ?? {});
411
+ ctx.ui.notify(res.isError ? res.text : `Undone: ${d.applied} item(s).`, res.isError ? "error" : "info");
412
+ },
413
+ });
414
+ }
package/docs/Subsub.md ADDED
@@ -0,0 +1,126 @@
1
+ Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses the same Python servers as the OpenCode setup ([[Zotero MCP]]), so the library safety is the same: previews, version checks, journal and undo, the tag vocabulary. The OpenCode setup keeps working in parallel.
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/opencode/zotero.env (same file as OpenCode)
6
+ - Models: librarian opencode-go/glm-5.3-flash, researcher opencode-go/mimo-v2.6-pro
7
+ - Public docs: the README in ~/Projects/subsub (source for subsub.tiagojacinto.eu)
8
+
9
+ ## Differences from the OpenCode setup
10
+
11
+ - 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.
12
+ - The model does not need to do a dry run first. You see one dialog per change.
13
+ - Library tools run one at a time, so each preview includes the effect of earlier changes in the same step.
14
+ - The researcher does not have the library write tools at all. It can only add a linked note (with a dialog) and add works to the import queue.
15
+ - bash is off in both modes. File writes outside the vault, and writes to the rule files, config folders and code folders, need a Yes.
16
+
17
+ ## Install
18
+
19
+ 1. Make sure that Node is version 22.19 or later: type `node --version`.
20
+ 2. Type `cd ~/Projects/subsub && npm install --omit=dev`.
21
+ 3. Type `npm link`. This adds the `subsub` command.
22
+ 4. Optional: type `pi remove ~/Projects/subsub`. Then plain `pi` stays a plain coding agent without Sub-Sub.
23
+ 5. Type `subsub`. If pi already has your OpenCode Go key, Sub-Sub uses the same key. If not, type `/login`, select OpenCode Zen and Go, and paste the key.
24
+ 6. Type `/subsub`. Make sure that the result shows "Zotero: reachable" and the model mimo-v2.6-pro.
25
+ 7. Type `subsub init` once. Keep the notes folder (the vault), the tag list, the email and the models. For the profile, select Editor.
26
+ 8. Type `subsub doctor`. Make sure that no line shows FIX.
27
+
28
+ If you change the Node version with fnm, do step 3 again.
29
+
30
+ ## Use
31
+
32
+ 1. Start Zotero.
33
+ 2. Type `subsub`. From the home folder, Sub-Sub starts in the vault. From a different folder (for example a manuscript folder), it starts in that folder. To stay in the home folder, type `subsub --here`.
34
+ 3. Sub-Sub starts in the researcher mode. To start in the librarian mode, type `subsub --librarian`.
35
+ 4. To change the mode, type `/librarian` or `/researcher`. The mode also changes the model.
36
+ 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.
37
+ 6. To continue the last session, type `subsub -c`. To select an older session, type `subsub -r`.
38
+
39
+ Sub-Sub always replies in English and writes its notes in English. Quotations and titles of works stay in their original language.
40
+
41
+ Sub-Sub keeps its pi settings, sessions and packages in ~/.subsub/agent, separate from plain pi (~/.pi/agent).
42
+
43
+ Commands:
44
+
45
+ - `/subsub`: Zotero connection and library overview
46
+ - `/profile`: shows or changes the profile
47
+ - `/history`: recent library changes
48
+ - `/undo`: shows the last change, asks, and reverts it. `/undo <id>` reverts a specific change from `/history`.
49
+
50
+ Templates (type the name, then the argument):
51
+
52
+ - Librarian: `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
53
+ - Researcher: `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
54
+
55
+ ## Profiles
56
+
57
+ 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.
58
+
59
+ - Reader: explains each step. Reading notes are quotes with page numbers, plus questions. No summaries or syntheses. No bulk library changes.
60
+ - Scholar: literature notes, syntheses, searches and alerts. The full librarian.
61
+ - Author: Scholar plus manuscripts: citation check, bibliography, comments on the argument. It does not write the paragraphs.
62
+ - Editor: everything, including drafted manuscript text when you ask.
63
+
64
+ ## Tag review notes
65
+
66
+ The librarian 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.
67
+
68
+ To apply one note, in the librarian mode, type: apply the tag review Inbox/Zotero tag review NN.md.
69
+
70
+ To apply many notes:
71
+
72
+ 1. Start Zotero.
73
+ 2. Type `subsub review preview 13-31` (the numbers of the notes). This shows what each note will change. Nothing changes.
74
+ 3. Type `subsub review apply 13-31`. Read the preview, then type y.
75
+ 4. If Zotero asks for permission, select Always Allow.
76
+
77
+ 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.
78
+
79
+ 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.
80
+
81
+ ## Look
82
+
83
+ 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. Each mode has its own theme: try-works for the librarian, Glauca for the researcher, in the dark or light version that matches your terminal. The theme changes for the session only; your pi theme setting stays.
84
+
85
+ To change this, add to ~/.config/subsub/config.json:
86
+
87
+ - `"quotes": false`: no Moby-Dick line.
88
+ - `"themes": false`: keep your own pi theme.
89
+ - `"look": false`: no banner and no themes.
90
+
91
+ The themes are in ~/Projects/subsub/themes. To make them again after a change in Gam, see scripts/make-themes.py.
92
+
93
+ ## Model test
94
+
95
+ 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 vault.
96
+
97
+ Tasks:
98
+
99
+ - Librarian: 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.
100
+ - Researcher: 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.
101
+
102
+ Procedure:
103
+
104
+ 1. Start Zotero.
105
+ 2. Type `subsub-bench prepare`. This picks the tasks.
106
+ 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.
107
+ 4. Type `subsub-bench score`. The result is results.md in the results folder.
108
+
109
+ Options for `run`: `--librarian a,b` and `--researcher c,d` (OpenCode Go model ids), `--only tagging,import`, `--parallel 3`, `--timeout 900`.
110
+
111
+ Each model gets a code (for example M417), so the notes can be judged blind. key.json has the codes.
112
+
113
+ ## Problems
114
+
115
+ - To find a setup problem, type `subsub doctor`. Each line marked FIX says what to do.
116
+ - "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).
117
+ - "cannot use opencode-go/...": the OpenCode Go key is missing. In Sub-Sub, type `/login` again.
118
+ - "command not found: subsub": type `cd ~/Projects/subsub && npm link`.
119
+ - To update Sub-Sub after new code arrives, type `cd ~/Projects/subsub && npm install --omit=dev`. Do not use `subsub update`.
120
+ - A tool call is refused with "not available in researcher mode": type `/librarian`.
121
+ - `subsub review` says "No note matches": check the note numbers in Inbox/. A path is written from the vault folder, in quotes (for example "Inbox/Zotero tag review 13.md").
122
+
123
+ ## Tests
124
+
125
+ - `cd ~/Projects/subsub && npm install && npm test`: unit tests.
126
+ - `npm run test:e2e`: real pi and the `subsub` command with a scripted model and a fake Zotero.
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createSubsub } from "../src/subsub.ts";
3
+
4
+ export default async function subsub(pi: ExtensionAPI): Promise<void> {
5
+ await createSubsub(pi);
6
+ }