@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/src/preview.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Turn a dry-run result from the Zotero servers into a short text that the user
3
+ * reads in the approval dialog. Plain ASCII, at most about 20 lines.
4
+ */
5
+
6
+ const MAX_LINES = 14;
7
+
8
+ type Obj = Record<string, unknown>;
9
+
10
+ function s(v: unknown, n = 90): string {
11
+ const t = typeof v === "string" ? v : JSON.stringify(v);
12
+ return t.length > n ? `${t.slice(0, n - 3)}...` : t;
13
+ }
14
+
15
+ function list(rows: string[], total?: number): string[] {
16
+ const out = rows.slice(0, MAX_LINES);
17
+ const rest = (total ?? rows.length) - out.length;
18
+ if (rest > 0) out.push(`... and ${rest} more`);
19
+ return out;
20
+ }
21
+
22
+ function describeChange(c: Obj): string {
23
+ const parts: string[] = [];
24
+ if (Array.isArray(c.added) && c.added.length) parts.push(`+ ${c.added.join(", ")}`);
25
+ if (Array.isArray(c.removed) && c.removed.length) parts.push(`- ${c.removed.join(", ")}`);
26
+ for (const [k, v] of Object.entries(c)) {
27
+ if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
28
+ if (v && typeof v === "object" && "after" in (v as Obj)) {
29
+ const b = (v as Obj).before;
30
+ parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${s((v as Obj).after, 60)}`);
31
+ }
32
+ }
33
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
34
+ }
35
+
36
+ export function formatPreview(tool: string, preview: unknown): string {
37
+ if (!preview || typeof preview !== "object") return s(preview, 1500);
38
+ const p = preview as Obj;
39
+ const lines: string[] = [];
40
+ if (p.undoing && typeof p.undoing === "object") {
41
+ const u = p.undoing as Obj;
42
+ lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
43
+ }
44
+ if (Array.isArray(p.already_applied) && p.already_applied.length) {
45
+ lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
46
+ }
47
+ const drift = p.changed_since_note as Obj | undefined;
48
+ if (drift && typeof drift === "object" && typeof drift.count === "number") {
49
+ lines.push(`${drift.count} item(s) changed after the note was written; the note overwrites them:`);
50
+ const items = Object.entries((drift.items as Obj) ?? {}).map(
51
+ ([k, v]) => ` ${k} now: ${((v as Obj).now as string[] | undefined)?.join(", ") || "(none)"}`,
52
+ );
53
+ lines.push(...items.slice(0, 4));
54
+ }
55
+ if (typeof p.would_change === "number") {
56
+ lines.push(`${p.would_change} item(s) would change.`);
57
+ const changes = (p.changes as Obj[]) ?? [];
58
+ lines.push(...list(changes.map(describeChange), p.would_change as number));
59
+ }
60
+ if (Array.isArray(p.would_import)) {
61
+ lines.push(`${p.would_import.length} item(s) would be imported:`);
62
+ lines.push(
63
+ ...list(
64
+ (p.would_import as Obj[]).map(
65
+ (r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : " (no abstract)"}`,
66
+ ),
67
+ ),
68
+ );
69
+ }
70
+ if (Array.isArray(p.would_attach)) {
71
+ lines.push(`${p.would_attach.length} PDF(s) would be attached:`);
72
+ lines.push(
73
+ ...list(
74
+ (p.would_attach as Obj[]).map(
75
+ (r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`,
76
+ ),
77
+ ),
78
+ );
79
+ }
80
+ if (p.would_create && typeof p.would_create === "object") {
81
+ const w = p.would_create as Obj;
82
+ lines.push(`New collection: ${w.name}${w.parent ? ` (inside ${w.parent})` : ""}`);
83
+ }
84
+ if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
85
+ if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
86
+ if (p.summary || p.note_preview) lines.push(s(p.summary ?? p.note_preview, 600));
87
+ if (p.link) lines.push(`Link: ${p.link}`);
88
+ }
89
+ const skipped = p.skipped as Obj | undefined;
90
+ if (skipped && typeof skipped === "object" && Object.keys(skipped).length) {
91
+ lines.push(`Skipped: ${Object.keys(skipped).length}`);
92
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`)).slice(0, 5));
93
+ }
94
+ const errors = p.errors as Obj | undefined;
95
+ if (errors && typeof errors === "object" && Object.keys(errors).length) {
96
+ lines.push(`Errors: ${Object.keys(errors).length}`);
97
+ lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
98
+ }
99
+ if (!lines.length) lines.push(s(p, 1500));
100
+ return lines.join("\n");
101
+ }
102
+
103
+ export function describeArgs(input: Record<string, unknown>): string {
104
+ return Object.entries(input)
105
+ .map(([k, v]) => `${k}: ${s(v, 120)}`)
106
+ .join("\n");
107
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Profiles: how much Sub-Sub does for the user. Named after the work, not the
3
+ * person: a professor new to a field can choose Reader, a student can choose
4
+ * Scholar. A profile is the user's own choice (/profile changes it), not a lock.
5
+ *
6
+ * - reader: explains each step; reading notes are quotes with page numbers and
7
+ * questions; no summaries or syntheses; no bulk library changes.
8
+ * - scholar: literature notes, syntheses, searches, alerts; the full librarian.
9
+ * - author: scholar plus manuscript work: citation check, bibliography, comments
10
+ * on the argument. Does not write the manuscript's paragraphs.
11
+ * - editor: everything, including drafting text for a manuscript on request.
12
+ */
13
+
14
+ import type { Profile } from "./config.ts";
15
+
16
+ export interface ProfileSpec {
17
+ name: Profile;
18
+ label: string;
19
+ summary: string;
20
+ /** Tools this profile does not offer (full names). */
21
+ without: string[];
22
+ /** Items per review note and per batch. */
23
+ batch: number;
24
+ /** Added to the system prompt. {{user}} is the user's name or "the user". */
25
+ rules: string;
26
+ }
27
+
28
+ const BULK_LIBRARY = ["rename_tags", "remove_tags", "remove_automatic_tags", "trash_items", "update_fields", "repair_metadata"].map(
29
+ (n) => `zotero_${n}`,
30
+ );
31
+ const MANUSCRIPT = ["scholar_check_manuscript", "scholar_export_bibliography"];
32
+
33
+ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
34
+ reader: {
35
+ name: "reader",
36
+ label: "Reader",
37
+ summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
38
+ without: [...BULK_LIBRARY, ...MANUSCRIPT],
39
+ batch: 10,
40
+ rules: [
41
+ "Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
42
+ "- Before each step, say in one or two lines what you will do and why.",
43
+ "- 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.",
44
+ "- For searches, list works with one line each on what they study, not on what they conclude.",
45
+ "- 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.",
46
+ "- Librarian: at most 10 items per review note. Explain each proposed tag in the Reason column.",
47
+ ].join("\n"),
48
+ },
49
+ scholar: {
50
+ name: "scholar",
51
+ label: "Scholar",
52
+ summary: "literature notes, syntheses, searches and alerts; the full librarian",
53
+ without: MANUSCRIPT,
54
+ batch: 25,
55
+ rules: [
56
+ "Profile: Scholar.",
57
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
58
+ "- Do not draft text for {{user}}'s manuscripts, theses or assignments. If asked, say that the Author and Editor profiles help with manuscripts (/profile).",
59
+ "- Librarian: 25 items per review note.",
60
+ ].join("\n"),
61
+ },
62
+ author: {
63
+ name: "author",
64
+ label: "Author",
65
+ summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
66
+ without: [],
67
+ batch: 25,
68
+ rules: [
69
+ "Profile: Author.",
70
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
71
+ "- 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.",
72
+ "- 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).",
73
+ "- Librarian: 25 items per review note.",
74
+ ].join("\n"),
75
+ },
76
+ editor: {
77
+ name: "editor",
78
+ label: "Editor",
79
+ summary: "everything, including drafting manuscript text on request",
80
+ without: [],
81
+ batch: 25,
82
+ rules: [
83
+ "Profile: Editor. All tools.",
84
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
85
+ "- 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.",
86
+ "- Librarian: 25 items per review note unless {{user}} asks for more.",
87
+ ].join("\n"),
88
+ },
89
+ };
90
+
91
+ export function profileSpec(p: Profile): ProfileSpec {
92
+ return PROFILE_SPECS[p];
93
+ }
94
+
95
+ export function profileList(current?: Profile): string {
96
+ return Object.values(PROFILE_SPECS)
97
+ .map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
98
+ .join("\n");
99
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The Sub-Sub part of the system prompt: role text for the current mode, the
3
+ * profile rules, the shared Zotero rules from the notes folder, the vault
4
+ * conventions when pi runs inside the notes folder, and the language rule.
5
+ *
6
+ * Role and profile texts use placeholders: {{user}} / {{User}} (the user's name,
7
+ * or "the user"), {{about}} (one line about the user) and {{batch}}.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { type Mode, type SubsubConfig, who } from "./config.ts";
14
+ import { profileSpec } from "./profiles.ts";
15
+
16
+ const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
17
+
18
+ function readIf(path: string | undefined): string | undefined {
19
+ if (!path || !existsSync(path)) return undefined;
20
+ try {
21
+ return readFileSync(path, "utf8").trim();
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+
27
+ export function languageRule(language: string, user = "the user"): string {
28
+ if (language.toLowerCase() === "auto") {
29
+ return `Reply in the language ${user} writes in. Write notes in the language of ${user}'s other notes; if unsure, ask once. Keep quotations, titles of works and proper names in their original language.`;
30
+ }
31
+ return `Always reply in ${language}, even when ${user} writes in another language and even when the sources or notes you read are in another language. Write the notes you create in ${language} too, including their titles. Keep quotations, titles of works and proper names in their original language.`;
32
+ }
33
+
34
+ export function inside(child: string, parent: string | undefined): boolean {
35
+ if (!parent) return false;
36
+ const rel = relative(resolve(parent), resolve(child));
37
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
38
+ }
39
+
40
+ function capital(s: string): string {
41
+ return s.charAt(0).toUpperCase() + s.slice(1);
42
+ }
43
+
44
+ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
45
+ const user = who(cfg);
46
+ const about = cfg.about ? `${capital(user)} is ${cfg.about}.` : "";
47
+ return text
48
+ .replaceAll("{{User}}", capital(user))
49
+ .replaceAll("{{user}}", user)
50
+ .replaceAll("{{about}}", about)
51
+ .replaceAll("{{batch}}", String(profileSpec(cfg.profile).batch))
52
+ .replace(/[ \t]+\n/g, "\n");
53
+ }
54
+
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}.`;
57
+ return cfg ? fill(raw, cfg) : raw;
58
+ }
59
+
60
+ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): string {
61
+ const user = who(cfg);
62
+ const parts = [`# Sub-Sub: ${mode} mode`, roleText(mode, cfg)];
63
+ const shared = readIf(cfg.sharedRules);
64
+ if (shared) {
65
+ parts.push(
66
+ "# Shared Zotero rules (from the notes folder)",
67
+ shared,
68
+ `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`,
69
+ );
70
+ }
71
+ if (inside(cwd, cfg.vault)) {
72
+ for (const f of cfg.vaultContext) {
73
+ const t = readIf(f);
74
+ if (t) parts.push(`# Vault conventions (${f})`, t);
75
+ }
76
+ }
77
+ // The profile and the language rule come last, so they win over the shared rules and the vault conventions.
78
+ parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
79
+ parts.push("# Language", languageRule(cfg.language, user));
80
+ return parts.join("\n\n");
81
+ }
package/src/roles.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Which tools each mode may use, and which calls need the user's approval.
3
+ */
4
+
5
+ import type { Mode, Profile } from "./config.ts";
6
+ import { profileSpec } from "./profiles.ts";
7
+
8
+ export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
9
+
10
+ export const ZOTERO_READ = [
11
+ "status",
12
+ "library_overview",
13
+ "find_items",
14
+ "get_item",
15
+ "get_fulltext",
16
+ "list_tags",
17
+ "get_vocabulary",
18
+ "list_collections",
19
+ "history",
20
+ ].map((n) => `zotero_${n}`);
21
+
22
+ /** Write tools that support dry_run: the gate previews them and asks. */
23
+ export const PREVIEW_WRITES = new Set([
24
+ ...[
25
+ "tag_items",
26
+ "apply_tag_review",
27
+ "rename_tags",
28
+ "remove_tags",
29
+ "remove_automatic_tags",
30
+ "set_citekeys",
31
+ "update_fields",
32
+ "file_items",
33
+ "create_collection",
34
+ "create_note",
35
+ "trash_items",
36
+ "undo",
37
+ "import_identifiers",
38
+ "import_queue",
39
+ "repair_metadata",
40
+ "attach_oa_pdfs",
41
+ ].map((n) => `zotero_${n}`),
42
+ "scholar_attach_note",
43
+ ]);
44
+
45
+ /** Writes without a dry run that still need a yes. */
46
+ export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
47
+
48
+ export function toolsFor(mode: Mode, registered: string[], profile: Profile = "editor"): string[] {
49
+ const has = new Set(registered);
50
+ const without = new Set(profileSpec(profile).without);
51
+ const wanted =
52
+ mode === "librarian"
53
+ ? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
54
+ : [...ZOTERO_READ, ...registered.filter((n) => n.startsWith("scholar_")), ...FILE_TOOLS];
55
+ return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
56
+ }
57
+
58
+ /** Why a Sub-Sub tool is not available now, or undefined when it is. */
59
+ export function unavailableReason(name: string, mode: Mode, registered: string[], profile: Profile): string | undefined {
60
+ if (toolsFor(mode, registered, profile).includes(name)) return undefined;
61
+ if (profileSpec(profile).without.includes(name)) {
62
+ return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
63
+ }
64
+ return `${name} is not available in ${mode} mode. The user can switch with /${otherMode(mode)}.`;
65
+ }
66
+
67
+ export type GateKind = "preview" | "confirm" | "path" | null;
68
+
69
+ /** A dry run only when dry_run is absent (server default true) or clearly true. */
70
+ export function isDryRun(value: unknown): boolean {
71
+ return value === undefined || value === true || value === "true";
72
+ }
73
+
74
+ export function gateKind(toolName: string, input: Record<string, unknown>): GateKind {
75
+ if (PREVIEW_WRITES.has(toolName)) return isDryRun(input.dry_run) ? null : "preview";
76
+ if (CONFIRM_WRITES.has(toolName)) return "confirm";
77
+ if (toolName === "write" || toolName === "edit") return "path";
78
+ return null;
79
+ }
80
+
81
+ export function otherMode(mode: Mode): Mode {
82
+ return mode === "librarian" ? "researcher" : "librarian";
83
+ }