@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/init.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `subsub init`: set Sub-Sub up for a new user, or change the settings of an
3
+ * existing one. Press Enter to keep the value in brackets.
4
+ *
5
+ * Writes:
6
+ * - the settings file (~/.config/subsub/config.json): profile, name, language,
7
+ * notes folder, models;
8
+ * - the server settings file (~/.config/zotero-local-mcp/env, or the one that
9
+ * already exists): notes folder, tag list, contact email. Other lines stay;
10
+ * - in the notes folder: Inbox/, Systems/Zotero tags.md (a starter list) and
11
+ * Systems/Zotero agent.md (note formats). Existing files are never replaced.
12
+ *
13
+ * Flags (for scripts and tests): --yes (take every default), --setup
14
+ * standard|fmup, --name, --about, --language, --notes, --profile, --tags
15
+ * health-sciences|health-informatics|any-field, --email, --models keep|later|opencode-go.
16
+ */
17
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
21
+ import { fileURLToPath } from "node:url";
22
+ import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig } from "./config.js";
23
+ import { PROFILE_SPECS } from "./profiles.js";
24
+ const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
25
+ export const STARTER_TAGS = {
26
+ "health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
27
+ "health-informatics": "Health informatics: information systems, data, AI, digital health",
28
+ "any-field": "Any field: methods and document types only; the librarian proposes topics from your library",
29
+ };
30
+ export const TESTED_MODELS = { librarian: "opencode-go/glm-5.3-flash", researcher: "opencode-go/mimo-v2.6-pro" };
31
+ /** Set KEY=VALUE lines in an env file text; other lines and comments stay. */
32
+ export function upsertEnv(text, values) {
33
+ const lines = text ? text.replace(/\n$/, "").split("\n") : [];
34
+ for (const [key, value] of Object.entries(values)) {
35
+ if (value === undefined || value === "")
36
+ continue;
37
+ const i = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
38
+ const line = `${key}=${value}`;
39
+ if (i >= 0)
40
+ lines[i] = line;
41
+ else
42
+ lines.push(line);
43
+ }
44
+ return `${lines.join("\n")}\n`;
45
+ }
46
+ /** True when pi's credentials (auth.json in the agent folder) name a provider that starts with prefix. */
47
+ export function hasProvider(agentDir, prefix) {
48
+ try {
49
+ const data = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
50
+ return Object.keys(data ?? {}).some((k) => k.startsWith(prefix));
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ export async function zoteroReachable(fetchFn = fetch) {
57
+ try {
58
+ const res = await fetchFn("http://127.0.0.1:23119/api/", { signal: AbortSignal.timeout(2000) });
59
+ return res.ok;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ export async function runInit(io, flags, env = process.env, deps = {}) {
66
+ const home = deps.home ?? homedir();
67
+ const configFile = configPath(env);
68
+ const existing = readConfigFile(configFile);
69
+ const current = loadConfig(env);
70
+ const envFile = current.envFile ?? expand(env.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"));
71
+ const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
72
+ const created = [];
73
+ io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
74
+ const setup = await io.choose("Setup", [
75
+ { value: "standard", label: "Standard" },
76
+ { value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
77
+ ], flags.setup ?? existing.setup ?? "standard");
78
+ const fmup = setup === "fmup";
79
+ const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
80
+ const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
81
+ const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
82
+ const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim());
83
+ const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
84
+ const profile = await io.choose("Profile (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
85
+ const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
86
+ let tags = "keep";
87
+ if (existsSync(vocab)) {
88
+ io.say(`Tag list: keeping ${vocab}.`);
89
+ }
90
+ else {
91
+ tags = await io.choose("Starter tag list (edit it later in the notes folder)", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
92
+ }
93
+ const email = (await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
94
+ const modelOptions = [
95
+ ...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
96
+ { value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
97
+ { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
98
+ ];
99
+ const agentDir = env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
100
+ const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
101
+ const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
102
+ // ---- write
103
+ for (const dir of [notes, join(notes, "Inbox"), join(notes, "Systems")]) {
104
+ if (!existsSync(dir)) {
105
+ mkdirSync(dir, { recursive: true });
106
+ created.push(dir);
107
+ }
108
+ }
109
+ if (tags !== "keep" && STARTER_TAGS[tags] && !existsSync(vocab)) {
110
+ mkdirSync(dirname(vocab), { recursive: true });
111
+ copyFileSync(join(TEMPLATES, "vocabularies", `${tags}.md`), vocab);
112
+ created.push(vocab);
113
+ }
114
+ const rules = join(notes, "Systems", "Zotero agent.md");
115
+ if (!existsSync(rules)) {
116
+ copyFileSync(join(TEMPLATES, "Zotero agent.md"), rules);
117
+ created.push(rules);
118
+ }
119
+ mkdirSync(dirname(envFile), { recursive: true });
120
+ const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
121
+ writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_CONTACT_EMAIL: email || undefined }));
122
+ const patch = {
123
+ profile,
124
+ userName: userName || undefined,
125
+ about: about || undefined,
126
+ language,
127
+ setup: fmup ? "fmup" : undefined,
128
+ vault: notes,
129
+ envFile,
130
+ };
131
+ if (models === "later")
132
+ patch.models = {};
133
+ if (models === "opencode-go")
134
+ patch.models = TESTED_MODELS;
135
+ saveConfig(patch, env);
136
+ const zotero = await zoteroReachable(deps.fetch);
137
+ io.say("");
138
+ io.say(`Saved: ${configFile}`);
139
+ io.say(`Saved: ${envFile}`);
140
+ for (const c of created)
141
+ io.say(`Created: ${c}`);
142
+ io.say(zotero
143
+ ? "Zotero: reachable."
144
+ : "Zotero: not reachable. Start Zotero 10, then in Zotero open Settings > Advanced and turn on \"Allow other applications on this computer to communicate with Zotero\".");
145
+ if (fmup)
146
+ io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, choose a model provider with /login.");
147
+ io.say("Next: type subsub. In Sub-Sub, type /login to connect a model provider. To check the setup, type subsub doctor.");
148
+ return { configFile, envFile, notes, created, zotero };
149
+ }
150
+ export function parseFlags(args) {
151
+ const out = {};
152
+ for (let i = 0; i < args.length; i++) {
153
+ const a = args[i];
154
+ if (!a.startsWith("--"))
155
+ continue;
156
+ const key = a.slice(2);
157
+ const next = args[i + 1];
158
+ if (next !== undefined && !next.startsWith("--")) {
159
+ out[key] = next;
160
+ i++;
161
+ }
162
+ else
163
+ out[key] = "true";
164
+ }
165
+ return out;
166
+ }
167
+ function defaultsIO(say) {
168
+ return {
169
+ ask: async (_q, def) => def,
170
+ choose: async (_q, _o, def) => def,
171
+ say,
172
+ };
173
+ }
174
+ function terminalIO() {
175
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
176
+ return {
177
+ async ask(q, def) {
178
+ const a = await rl.question(`${q}${def ? ` [${def}]` : ""}: `);
179
+ return a.trim() === "" ? def : a.trim();
180
+ },
181
+ async choose(q, options, def) {
182
+ console.log(`\n${q}`);
183
+ options.forEach((o, i) => console.log(` ${i + 1}. ${o.label}${o.value === def ? " (default)" : ""}`));
184
+ for (;;) {
185
+ const a = (await rl.question(`Number [${options.findIndex((o) => o.value === def) + 1}]: `)).trim();
186
+ if (a === "")
187
+ return def;
188
+ const n = Number(a);
189
+ if (Number.isInteger(n) && n >= 1 && n <= options.length)
190
+ return options[n - 1].value;
191
+ const byName = options.find((o) => o.value === a.toLowerCase());
192
+ if (byName)
193
+ return byName.value;
194
+ console.log(`Type a number from 1 to ${options.length}.`);
195
+ }
196
+ },
197
+ say: (l) => console.log(l),
198
+ close: () => rl.close(),
199
+ };
200
+ }
201
+ export async function initMain(args, env = process.env) {
202
+ const flags = parseFlags(args);
203
+ const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
204
+ try {
205
+ await runInit(io, flags, env);
206
+ return 0;
207
+ }
208
+ catch (err) {
209
+ console.error(`Sub-Sub init: ${err.message}`);
210
+ return 1;
211
+ }
212
+ finally {
213
+ io.close?.();
214
+ }
215
+ }
package/dist/look.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The look of the `subsub` command: banner, status line, quote, and the theme
3
+ * per mode (try-works for the librarian, Glauca for the researcher).
4
+ * Only used when Sub-Sub runs as its own command in the terminal UI.
5
+ */
6
+ export const BANNER = [
7
+ "╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
8
+ "╚═╗║ ║╠╩╗───╚═╗║ ║╠╩╗",
9
+ "╚═╝╚═╝╚═╝ ╚═╝╚═╝╚═╝",
10
+ ];
11
+ /** Short public-domain lines from Moby-Dick and its Extracts. */
12
+ export const QUOTES = [
13
+ ["And God created great whales.", "Genesis, in the Extracts"],
14
+ ["Leviathan maketh a path to shine after him; One would think the deep to be hoary.", "Job, in the Extracts"],
15
+ ["There go the ships; there is that Leviathan whom thou hast made to play therein.", "Psalms, in the Extracts"],
16
+ ["Now the Lord had prepared a great fish to swallow up Jonah.", "Jonah, in the Extracts"],
17
+ ["Very like a whale.", "Hamlet, in the Extracts"],
18
+ ["So fare thee well, poor devil of a Sub-Sub, whose commentator I am.", "Moby-Dick, Extracts"],
19
+ ["Give it up, Sub-Subs!", "Moby-Dick, Extracts"],
20
+ ["God keep me from ever completing anything.", "Moby-Dick, ch. 32"],
21
+ ["Call me Ishmael.", "Moby-Dick, ch. 1"],
22
+ ];
23
+ export const MODE_THEMES = { librarian: "subsub-try-works", researcher: "subsub-glauca" };
24
+ /** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
25
+ export function schemeFromAnsi(fgAnsi, themeName) {
26
+ if (themeName?.endsWith("-light") || themeName === "light")
27
+ return "light";
28
+ if (themeName?.endsWith("-dark") || themeName === "dark")
29
+ return "dark";
30
+ const rgb = fgAnsi.match(/38;2;(\d+);(\d+);(\d+)/);
31
+ if (rgb) {
32
+ const [r, g, b] = rgb.slice(1).map(Number);
33
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128 ? "light" : "dark";
34
+ }
35
+ const idx = fgAnsi.match(/38;5;(\d+)/);
36
+ if (idx) {
37
+ const n = Number(idx[1]);
38
+ // 232-255 grey ramp, 0/16 black: dark text on a light terminal
39
+ if (n === 0 || n === 16 || (n >= 232 && n <= 243))
40
+ return "light";
41
+ }
42
+ return "dark";
43
+ }
44
+ export function themeName(mode, scheme, map = MODE_THEMES) {
45
+ const base = map[mode];
46
+ return base ? `${base}-${scheme}` : undefined;
47
+ }
48
+ const plain = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, warn: (s) => s };
49
+ function cut(s, width) {
50
+ return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
51
+ }
52
+ export function libraryLine(st) {
53
+ if (st.zotero === "checking")
54
+ return { text: "Zotero: checking...", warn: false };
55
+ if (st.zotero === "down")
56
+ return { text: "Zotero is not running. Start it, then type /subsub.", warn: true };
57
+ const parts = [`${st.items ?? "?"} items`];
58
+ if (st.toReview)
59
+ parts.push(`${st.toReview} to review`);
60
+ return { text: `Zotero: ${parts.join(", ")}`, warn: false };
61
+ }
62
+ /** Header lines. Each line is cut to the width before it is coloured. */
63
+ export function headerLines(opts) {
64
+ const p = opts.paint ?? plain;
65
+ const w = Math.max(10, opts.width);
66
+ const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
67
+ const lib = libraryLine(opts.library);
68
+ if (w < BANNER[0].length + 2) {
69
+ return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
70
+ }
71
+ const out = ["", ...BANNER.map((l, i) => p.mark(l) + (i === 2 ? p.dim(cut(` ${opts.version}`, w - l.length)) : "")), ""];
72
+ const status = `${modeLabel} ${opts.model}`;
73
+ out.push(p.text(cut(status, w)) + (lib.warn ? p.warn : p.muted)(cut(` | ${lib.text}`, Math.max(0, w - status.length))));
74
+ if (opts.quote) {
75
+ const one = `"${opts.quote[0]}" (${opts.quote[1]})`;
76
+ if ([...one].length <= w)
77
+ out.push(p.dim(one));
78
+ else
79
+ out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
80
+ }
81
+ out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
82
+ return out;
83
+ }
84
+ /**
85
+ * Colour the "+ added" and "- removed" parts of a preview; the rest gets the
86
+ * base colour (pi draws dialog text in the accent colour otherwise).
87
+ */
88
+ export function paintPreview(text, add, remove, base = (x) => x) {
89
+ const seg = /(^|: |; )([+-] [^;]+)/g;
90
+ return text
91
+ .split("\n")
92
+ .map((line) => {
93
+ let out = "";
94
+ let last = 0;
95
+ for (const m of line.matchAll(seg)) {
96
+ const start = (m.index ?? 0) + m[1].length;
97
+ if (start > last)
98
+ out += base(line.slice(last, start));
99
+ out += (m[2].startsWith("+") ? add : remove)(m[2]);
100
+ last = start + m[2].length;
101
+ }
102
+ if (last < line.length)
103
+ out += base(line.slice(last));
104
+ return out;
105
+ })
106
+ .join("\n");
107
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Path gate for pi's write and edit tools.
3
+ *
4
+ * Paths are normalised the way pi's file tools read them (leading "@", "~/",
5
+ * file:// URLs), and symlinks are resolved through the nearest existing parent,
6
+ * so a link inside the vault that points elsewhere is judged by its target.
7
+ */
8
+ import { existsSync, realpathSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ export function normalizeToolPath(raw, cwd) {
13
+ let p = String(raw ?? "").trim();
14
+ if (p.startsWith("@"))
15
+ p = p.slice(1);
16
+ if (p.startsWith("file://")) {
17
+ try {
18
+ p = fileURLToPath(p);
19
+ }
20
+ catch {
21
+ /* keep as is */
22
+ }
23
+ }
24
+ if (p === "~" || p.startsWith("~/"))
25
+ p = join(homedir(), p.slice(1));
26
+ return realish(resolve(cwd, p));
27
+ }
28
+ /** realpath of the nearest existing ancestor, plus the part that does not exist yet. */
29
+ export function realish(abs) {
30
+ let head = abs;
31
+ const tail = [];
32
+ while (!existsSync(head)) {
33
+ const parent = dirname(head);
34
+ if (parent === head)
35
+ break;
36
+ tail.unshift(basename(head));
37
+ head = parent;
38
+ }
39
+ let real = head;
40
+ try {
41
+ real = realpathSync(head);
42
+ }
43
+ catch {
44
+ /* keep */
45
+ }
46
+ return tail.length ? join(real, ...tail) : real;
47
+ }
48
+ export function within(child, parent) {
49
+ if (!parent)
50
+ return false;
51
+ const rel = relative(parent, child);
52
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
53
+ }
54
+ export function buildPolicy(opts) {
55
+ const home = realish(homedir());
56
+ const cwd = realish(opts.cwd);
57
+ const allowed = [];
58
+ if (opts.vault)
59
+ allowed.push(realish(opts.vault));
60
+ // The working folder counts only if it is a specific folder, not the home folder or above it.
61
+ if (cwd !== home && within(cwd, home) && !within(home, cwd))
62
+ allowed.push(cwd);
63
+ const protectedPaths = [
64
+ join(home, ".pi"),
65
+ join(home, ".subsub"),
66
+ join(home, ".config"),
67
+ join(home, ".ssh"),
68
+ join(home, ".zshrc"),
69
+ join(home, ".bashrc"),
70
+ realish(opts.serverDir),
71
+ realish(opts.packageDir),
72
+ ];
73
+ if (opts.vault) {
74
+ const v = realish(opts.vault);
75
+ protectedPaths.push(join(v, ".claude"), join(v, ".opencode"), join(v, ".obsidian"), join(v, "Systems", "Zotero agent.md"), join(v, "Systems", "Zotero tags.md"), join(v, "Systems", "Zotero librarian.md"), join(v, "Systems", "Zotero researcher.md"), join(v, "Systems", "Literature alerts.md"));
76
+ }
77
+ return { allowed, protectedPaths };
78
+ }
79
+ export function judgePath(target, policy) {
80
+ const hit = policy.protectedPaths.find((p) => within(target, p));
81
+ if (hit)
82
+ return { ok: false, why: `it changes a protected file or folder (${hit})` };
83
+ if (policy.allowed.some((a) => within(target, a)))
84
+ return { ok: true };
85
+ return { ok: false, why: "it is outside the vault and the working folder" };
86
+ }
@@ -0,0 +1,94 @@
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
+ const MAX_LINES = 14;
6
+ function s(v, n = 90) {
7
+ const t = typeof v === "string" ? v : JSON.stringify(v);
8
+ return t.length > n ? `${t.slice(0, n - 3)}...` : t;
9
+ }
10
+ function list(rows, total) {
11
+ const out = rows.slice(0, MAX_LINES);
12
+ const rest = (total ?? rows.length) - out.length;
13
+ if (rest > 0)
14
+ out.push(`... and ${rest} more`);
15
+ return out;
16
+ }
17
+ function describeChange(c) {
18
+ const parts = [];
19
+ if (Array.isArray(c.added) && c.added.length)
20
+ parts.push(`+ ${c.added.join(", ")}`);
21
+ if (Array.isArray(c.removed) && c.removed.length)
22
+ parts.push(`- ${c.removed.join(", ")}`);
23
+ for (const [k, v] of Object.entries(c)) {
24
+ if (["key", "item", "added", "removed", "tags_after"].includes(k))
25
+ continue;
26
+ if (v && typeof v === "object" && "after" in v) {
27
+ const b = v.before;
28
+ parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${s(v.after, 60)}`);
29
+ }
30
+ }
31
+ return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
32
+ }
33
+ export function formatPreview(tool, preview) {
34
+ if (!preview || typeof preview !== "object")
35
+ return s(preview, 1500);
36
+ const p = preview;
37
+ const lines = [];
38
+ if (p.undoing && typeof p.undoing === "object") {
39
+ const u = p.undoing;
40
+ lines.push(`Undo ${u.op}: ${s(u.summary, 80)}`);
41
+ }
42
+ if (Array.isArray(p.already_applied) && p.already_applied.length) {
43
+ lines.push(`ALREADY APPLIED: ${p.already_applied.join(", ")}`);
44
+ }
45
+ const drift = p.changed_since_note;
46
+ if (drift && typeof drift === "object" && typeof drift.count === "number") {
47
+ lines.push(`${drift.count} item(s) changed after the note was written; the note overwrites them:`);
48
+ const items = Object.entries(drift.items ?? {}).map(([k, v]) => ` ${k} now: ${v.now?.join(", ") || "(none)"}`);
49
+ lines.push(...items.slice(0, 4));
50
+ }
51
+ if (typeof p.would_change === "number") {
52
+ lines.push(`${p.would_change} item(s) would change.`);
53
+ const changes = p.changes ?? [];
54
+ lines.push(...list(changes.map(describeChange), p.would_change));
55
+ }
56
+ if (Array.isArray(p.would_import)) {
57
+ lines.push(`${p.would_import.length} item(s) would be imported:`);
58
+ lines.push(...list(p.would_import.map((r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : " (no abstract)"}`)));
59
+ }
60
+ if (Array.isArray(p.would_attach)) {
61
+ lines.push(`${p.would_attach.length} PDF(s) would be attached:`);
62
+ lines.push(...list(p.would_attach.map((r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`)));
63
+ }
64
+ if (p.would_create && typeof p.would_create === "object") {
65
+ const w = p.would_create;
66
+ lines.push(`New collection: ${w.name}${w.parent ? ` (inside ${w.parent})` : ""}`);
67
+ }
68
+ if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
69
+ if (p.item || p.parent)
70
+ lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
71
+ if (p.summary || p.note_preview)
72
+ lines.push(s(p.summary ?? p.note_preview, 600));
73
+ if (p.link)
74
+ lines.push(`Link: ${p.link}`);
75
+ }
76
+ const skipped = p.skipped;
77
+ if (skipped && typeof skipped === "object" && Object.keys(skipped).length) {
78
+ lines.push(`Skipped: ${Object.keys(skipped).length}`);
79
+ lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`)).slice(0, 5));
80
+ }
81
+ const errors = p.errors;
82
+ if (errors && typeof errors === "object" && Object.keys(errors).length) {
83
+ lines.push(`Errors: ${Object.keys(errors).length}`);
84
+ lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
85
+ }
86
+ if (!lines.length)
87
+ lines.push(s(p, 1500));
88
+ return lines.join("\n");
89
+ }
90
+ export function describeArgs(input) {
91
+ return Object.entries(input)
92
+ .map(([k, v]) => `${k}: ${s(v, 120)}`)
93
+ .join("\n");
94
+ }
@@ -0,0 +1,79 @@
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
+ const BULK_LIBRARY = ["rename_tags", "remove_tags", "remove_automatic_tags", "trash_items", "update_fields", "repair_metadata"].map((n) => `zotero_${n}`);
14
+ const MANUSCRIPT = ["scholar_check_manuscript", "scholar_export_bibliography"];
15
+ export const PROFILE_SPECS = {
16
+ reader: {
17
+ name: "reader",
18
+ label: "Reader",
19
+ summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
20
+ without: [...BULK_LIBRARY, ...MANUSCRIPT],
21
+ batch: 10,
22
+ rules: [
23
+ "Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
24
+ "- Before each step, say in one or two lines what you will do and why.",
25
+ "- 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.",
26
+ "- For searches, list works with one line each on what they study, not on what they conclude.",
27
+ "- 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.",
28
+ "- Librarian: at most 10 items per review note. Explain each proposed tag in the Reason column.",
29
+ ].join("\n"),
30
+ },
31
+ scholar: {
32
+ name: "scholar",
33
+ label: "Scholar",
34
+ summary: "literature notes, syntheses, searches and alerts; the full librarian",
35
+ without: MANUSCRIPT,
36
+ batch: 25,
37
+ rules: [
38
+ "Profile: Scholar.",
39
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
40
+ "- Do not draft text for {{user}}'s manuscripts, theses or assignments. If asked, say that the Author and Editor profiles help with manuscripts (/profile).",
41
+ "- Librarian: 25 items per review note.",
42
+ ].join("\n"),
43
+ },
44
+ author: {
45
+ name: "author",
46
+ label: "Author",
47
+ summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
48
+ without: [],
49
+ batch: 25,
50
+ rules: [
51
+ "Profile: Author.",
52
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
53
+ "- 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.",
54
+ "- 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).",
55
+ "- Librarian: 25 items per review note.",
56
+ ].join("\n"),
57
+ },
58
+ editor: {
59
+ name: "editor",
60
+ label: "Editor",
61
+ summary: "everything, including drafting manuscript text on request",
62
+ without: [],
63
+ batch: 25,
64
+ rules: [
65
+ "Profile: Editor. All tools.",
66
+ "- Write literature notes and syntheses from what you read, with a source for every claim.",
67
+ "- 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.",
68
+ "- Librarian: 25 items per review note unless {{user}} asks for more.",
69
+ ].join("\n"),
70
+ },
71
+ };
72
+ export function profileSpec(p) {
73
+ return PROFILE_SPECS[p];
74
+ }
75
+ export function profileList(current) {
76
+ return Object.values(PROFILE_SPECS)
77
+ .map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
78
+ .join("\n");
79
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,72 @@
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
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { who } from "./config.js";
13
+ import { profileSpec } from "./profiles.js";
14
+ const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
15
+ function readIf(path) {
16
+ if (!path || !existsSync(path))
17
+ return undefined;
18
+ try {
19
+ return readFileSync(path, "utf8").trim();
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ export function languageRule(language, user = "the user") {
26
+ if (language.toLowerCase() === "auto") {
27
+ 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.`;
28
+ }
29
+ 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.`;
30
+ }
31
+ export function inside(child, parent) {
32
+ if (!parent)
33
+ return false;
34
+ const rel = relative(resolve(parent), resolve(child));
35
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
36
+ }
37
+ function capital(s) {
38
+ return s.charAt(0).toUpperCase() + s.slice(1);
39
+ }
40
+ export function fill(text, cfg) {
41
+ const user = who(cfg);
42
+ const about = cfg.about ? `${capital(user)} is ${cfg.about}.` : "";
43
+ return text
44
+ .replaceAll("{{User}}", capital(user))
45
+ .replaceAll("{{user}}", user)
46
+ .replaceAll("{{about}}", about)
47
+ .replaceAll("{{batch}}", String(profileSpec(cfg.profile).batch))
48
+ .replace(/[ \t]+\n/g, "\n");
49
+ }
50
+ export function roleText(mode, cfg) {
51
+ const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are the ${mode}.`;
52
+ return cfg ? fill(raw, cfg) : raw;
53
+ }
54
+ export function systemAddition(mode, cfg, cwd) {
55
+ const user = who(cfg);
56
+ const parts = [`# Sub-Sub: ${mode} mode`, roleText(mode, cfg)];
57
+ const shared = readIf(cfg.sharedRules);
58
+ if (shared) {
59
+ parts.push("# Shared Zotero rules (from the notes folder)", shared, `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.`);
60
+ }
61
+ if (inside(cwd, cfg.vault)) {
62
+ for (const f of cfg.vaultContext) {
63
+ const t = readIf(f);
64
+ if (t)
65
+ parts.push(`# Vault conventions (${f})`, t);
66
+ }
67
+ }
68
+ // The profile and the language rule come last, so they win over the shared rules and the vault conventions.
69
+ parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
70
+ parts.push("# Language", languageRule(cfg.language, user));
71
+ return parts.join("\n\n");
72
+ }