@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.
- package/README.md +90 -0
- package/bin/subsub-bench.js +19 -0
- package/bin/subsub.js +14 -0
- package/dist/args.js +67 -0
- package/dist/bench.js +534 -0
- package/dist/bridge.js +107 -0
- package/dist/cli.js +209 -0
- package/dist/config.js +144 -0
- package/dist/doctor.js +131 -0
- package/dist/init.js +215 -0
- package/dist/look.js +107 -0
- package/dist/paths.js +86 -0
- package/dist/preview.js +94 -0
- package/dist/profiles.js +79 -0
- package/dist/prompt.js +72 -0
- package/dist/roles.js +73 -0
- package/dist/subsub.js +414 -0
- package/docs/Subsub.md +126 -0
- package/extensions/subsub.ts +6 -0
- package/package.json +73 -0
- package/prompts/alert.md +4 -0
- package/prompts/clean-tags.md +4 -0
- package/prompts/gaps.md +5 -0
- package/prompts/import-queue.md +5 -0
- package/prompts/lit-note.md +5 -0
- package/prompts/manuscript.md +5 -0
- package/prompts/synthesis.md +5 -0
- package/prompts/tag-batch.md +5 -0
- package/roles/librarian.md +46 -0
- package/roles/researcher.md +37 -0
- package/scripts/build.mjs +14 -0
- package/scripts/make-themes.py +116 -0
- package/src/args.ts +64 -0
- package/src/bench.ts +580 -0
- package/src/bridge.ts +134 -0
- package/src/cli.ts +201 -0
- package/src/config.ts +191 -0
- package/src/doctor.ts +171 -0
- package/src/init.ts +252 -0
- package/src/look.ts +141 -0
- package/src/paths.ts +100 -0
- package/src/preview.ts +107 -0
- package/src/profiles.ts +99 -0
- package/src/prompt.ts +81 -0
- package/src/roles.ts +83 -0
- package/src/subsub.ts +434 -0
- package/templates/Zotero agent.md +87 -0
- package/templates/vocabularies/any-field.md +52 -0
- package/templates/vocabularies/health-informatics.md +88 -0
- package/templates/vocabularies/health-sciences.md +95 -0
- package/themes/subsub-glauca-dark.json +91 -0
- package/themes/subsub-glauca-light.json +91 -0
- package/themes/subsub-try-works-dark.json +91 -0
- package/themes/subsub-try-works-light.json +91 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `subsub doctor`: check the setup and say how to fix each problem.
|
|
3
|
+
*
|
|
4
|
+
* Checks Node, the settings file, uv, the Zotero server (it runs
|
|
5
|
+
* `zotero-local-mcp --check`), Zotero itself, the tag list, the notes folder,
|
|
6
|
+
* the contact email and the model login. Exit code 1 when something needs a fix.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { findUv, loadConfig, SERVER_VERSION, serverCommand, type SubsubConfig } from "./config.ts";
|
|
14
|
+
import { profileSpec } from "./profiles.ts";
|
|
15
|
+
|
|
16
|
+
export interface Check {
|
|
17
|
+
name: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
detail: string;
|
|
20
|
+
fix?: string;
|
|
21
|
+
/** A warning: works, but something is missing. */
|
|
22
|
+
warn?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunResult {
|
|
26
|
+
status: number | null;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Runner = (command: string, args: string[], env: NodeJS.ProcessEnv) => RunResult;
|
|
33
|
+
|
|
34
|
+
export const defaultRunner: Runner = (command, args, env) => {
|
|
35
|
+
const r = spawnSync(command, args, { env, encoding: "utf8", timeout: 120_000 });
|
|
36
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error?.message };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ZOTERO_SETTING = 'In Zotero, open Settings > Advanced and turn on "Allow other applications on this computer to communicate with Zotero".';
|
|
40
|
+
|
|
41
|
+
function nodeOk(version: string): boolean {
|
|
42
|
+
const [major, minor] = version.split(".").map(Number);
|
|
43
|
+
return major > 22 || (major === 22 && minor >= 19);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasLogin(agentDir: string): boolean {
|
|
47
|
+
try {
|
|
48
|
+
const data = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
|
|
49
|
+
return Boolean(data) && typeof data === "object" && Object.keys(data).length > 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function runChecks(
|
|
56
|
+
cfg: SubsubConfig,
|
|
57
|
+
env: NodeJS.ProcessEnv,
|
|
58
|
+
deps: { run?: Runner; nodeVersion?: string; agentDir?: string } = {},
|
|
59
|
+
): Check[] {
|
|
60
|
+
const run = deps.run ?? defaultRunner;
|
|
61
|
+
const out: Check[] = [];
|
|
62
|
+
const node = deps.nodeVersion ?? process.versions.node;
|
|
63
|
+
out.push(
|
|
64
|
+
nodeOk(node)
|
|
65
|
+
? { name: "Node", ok: true, detail: node }
|
|
66
|
+
: { name: "Node", ok: false, detail: node, fix: "Install Node.js 22.19 or later (https://nodejs.org), or run the Sub-Sub installer again." },
|
|
67
|
+
);
|
|
68
|
+
out.push(
|
|
69
|
+
existsSync(cfg.configFile)
|
|
70
|
+
? { name: "Settings", ok: true, detail: `${cfg.configFile}, profile ${profileSpec(cfg.profile).label}, replies in ${cfg.language}` }
|
|
71
|
+
: { name: "Settings", ok: false, detail: `no ${cfg.configFile}`, fix: "Type subsub init." },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const uv = findUv(cfg, env);
|
|
75
|
+
const uvRun = run(uv, ["--version"], env);
|
|
76
|
+
if (uvRun.status !== 0) {
|
|
77
|
+
out.push({ name: "uv", ok: false, detail: uvRun.error ?? uvRun.stderr.trim(), fix: "Install uv (https://docs.astral.sh/uv/), or add \"uvPath\" to the settings file." });
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
out.push({ name: "uv", ok: true, detail: uvRun.stdout.trim() });
|
|
81
|
+
|
|
82
|
+
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
83
|
+
const local = cmd.args.includes("--directory");
|
|
84
|
+
const res = run(cmd.command, cmd.args, { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) });
|
|
85
|
+
let st: Record<string, any> | undefined;
|
|
86
|
+
try {
|
|
87
|
+
st = JSON.parse(res.stdout);
|
|
88
|
+
} catch {
|
|
89
|
+
st = undefined;
|
|
90
|
+
}
|
|
91
|
+
if (res.status !== 0 || !st) {
|
|
92
|
+
const text = (res.error ?? res.stderr).replace(/[^\x20-\x7e\n]/g, " ");
|
|
93
|
+
const first = text.trim().split("\n").slice(-3).join(" ").replace(/\s+/g, " ").trim();
|
|
94
|
+
const notPublished = /not found in the package registry/.test(text);
|
|
95
|
+
out.push({
|
|
96
|
+
name: "Zotero server",
|
|
97
|
+
ok: false,
|
|
98
|
+
detail: notPublished ? `zotero-local-mcp ${SERVER_VERSION} is not on PyPI` : first || "no answer",
|
|
99
|
+
fix: local
|
|
100
|
+
? `Type: cd "${cfg.serverDir}" && uv sync`
|
|
101
|
+
: notPublished
|
|
102
|
+
? 'Set "serverDir" in the settings file to a copy of the zotero-local-mcp repository.'
|
|
103
|
+
: `uv could not run zotero-local-mcp ${SERVER_VERSION} from PyPI. Check the internet connection and try again.`,
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
out.push({ name: "Zotero server", ok: true, detail: local ? `from ${cfg.serverDir}` : `zotero-local-mcp ${SERVER_VERSION}` });
|
|
108
|
+
out.push(
|
|
109
|
+
st.zotero === "reachable"
|
|
110
|
+
? { name: "Zotero", ok: true, detail: `reachable, write permission ${st.write_key_remembered ? "remembered" : "asked at the first change"}` }
|
|
111
|
+
: { name: "Zotero", ok: false, detail: String(st.error ?? "not reachable"), fix: `Start Zotero 10 or later. ${ZOTERO_SETTING}` },
|
|
112
|
+
);
|
|
113
|
+
const v = st.vocabulary ?? {};
|
|
114
|
+
if (v.loaded === false || !v.tags) {
|
|
115
|
+
out.push({ name: "Tag list", ok: false, detail: v.path ? `cannot read ${v.path}` : "not set", fix: "Type subsub init: it creates a starter tag list." });
|
|
116
|
+
} else {
|
|
117
|
+
const noTopics = Array.isArray(v.facets) && !v.facets.includes("topic");
|
|
118
|
+
out.push({
|
|
119
|
+
name: "Tag list",
|
|
120
|
+
ok: !(v.problems?.length),
|
|
121
|
+
warn: noTopics,
|
|
122
|
+
detail: `${v.tags} tags in ${v.path}${noTopics ? "; no topic/ tags yet" : ""}`,
|
|
123
|
+
fix: v.problems?.length
|
|
124
|
+
? `Fix these lines in the tag list: ${v.problems.slice(0, 3).join("; ")}`
|
|
125
|
+
: noTopics
|
|
126
|
+
? "In Sub-Sub (librarian), ask: propose topics for my tag list."
|
|
127
|
+
: undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
out.push(
|
|
131
|
+
cfg.vault && existsSync(cfg.vault)
|
|
132
|
+
? { name: "Notes folder", ok: true, detail: cfg.vault }
|
|
133
|
+
: { name: "Notes folder", ok: false, detail: cfg.vault ? `${cfg.vault} does not exist` : "not set", fix: "Type subsub init." },
|
|
134
|
+
);
|
|
135
|
+
out.push(
|
|
136
|
+
st.contact_email_set
|
|
137
|
+
? { name: "Contact email", ok: true, detail: "set" }
|
|
138
|
+
: { name: "Contact email", ok: true, warn: true, detail: "not set: open-access PDFs cannot be found", fix: "Type subsub init and give an email address." },
|
|
139
|
+
);
|
|
140
|
+
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
141
|
+
out.push(
|
|
142
|
+
hasLogin(agentDir)
|
|
143
|
+
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.values(cfg.models).join(", ") : "pi's default model" }
|
|
144
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
|
|
145
|
+
);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatChecks(checks: Check[]): string {
|
|
150
|
+
return checks
|
|
151
|
+
.map((c) => {
|
|
152
|
+
const tag = !c.ok ? "FIX " : c.warn ? "NOTE" : "ok ";
|
|
153
|
+
return `${tag} ${c.name.padEnd(14)} ${c.detail}${c.fix && (!c.ok || c.warn) ? `\n ${" ".repeat(14)} ${c.fix}` : ""}`;
|
|
154
|
+
})
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function doctorMain(env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
159
|
+
let cfg: SubsubConfig;
|
|
160
|
+
try {
|
|
161
|
+
cfg = loadConfig(env);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.log(`FIX Settings ${(err as Error).message}\n Fix the JSON, or move the file away and type subsub init.`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
const checks = runChecks(cfg, env);
|
|
167
|
+
console.log(formatChecks(checks));
|
|
168
|
+
const bad = checks.filter((c) => !c.ok).length;
|
|
169
|
+
console.log(bad ? `\n${bad} problem(s) to fix.` : "\nEverything needed is in place.");
|
|
170
|
+
return bad ? 1 : 0;
|
|
171
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
|
|
18
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { createInterface } from "node:readline/promises";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig } from "./config.ts";
|
|
24
|
+
import { PROFILE_SPECS } from "./profiles.ts";
|
|
25
|
+
|
|
26
|
+
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
27
|
+
|
|
28
|
+
export const STARTER_TAGS: Record<string, string> = {
|
|
29
|
+
"health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
|
|
30
|
+
"health-informatics": "Health informatics: information systems, data, AI, digital health",
|
|
31
|
+
"any-field": "Any field: methods and document types only; the librarian proposes topics from your library",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const TESTED_MODELS = { librarian: "opencode-go/glm-5.3-flash", researcher: "opencode-go/mimo-v2.6-pro" };
|
|
35
|
+
|
|
36
|
+
export interface InitIO {
|
|
37
|
+
ask(question: string, def: string): Promise<string>;
|
|
38
|
+
choose(question: string, options: Array<{ value: string; label: string }>, def: string): Promise<string>;
|
|
39
|
+
say(line: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InitResult {
|
|
43
|
+
configFile: string;
|
|
44
|
+
envFile: string;
|
|
45
|
+
notes: string;
|
|
46
|
+
created: string[];
|
|
47
|
+
zotero: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Set KEY=VALUE lines in an env file text; other lines and comments stay. */
|
|
51
|
+
export function upsertEnv(text: string, values: Record<string, string | undefined>): string {
|
|
52
|
+
const lines = text ? text.replace(/\n$/, "").split("\n") : [];
|
|
53
|
+
for (const [key, value] of Object.entries(values)) {
|
|
54
|
+
if (value === undefined || value === "") continue;
|
|
55
|
+
const i = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
|
|
56
|
+
const line = `${key}=${value}`;
|
|
57
|
+
if (i >= 0) lines[i] = line;
|
|
58
|
+
else lines.push(line);
|
|
59
|
+
}
|
|
60
|
+
return `${lines.join("\n")}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when pi's credentials (auth.json in the agent folder) name a provider that starts with prefix. */
|
|
64
|
+
export function hasProvider(agentDir: string, prefix: string): boolean {
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
|
|
67
|
+
return Object.keys(data ?? {}).some((k) => k.startsWith(prefix));
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function zoteroReachable(fetchFn: typeof fetch = fetch): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetchFn("http://127.0.0.1:23119/api/", { signal: AbortSignal.timeout(2000) });
|
|
76
|
+
return res.ok;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runInit(
|
|
83
|
+
io: InitIO,
|
|
84
|
+
flags: Record<string, string>,
|
|
85
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
+
deps: { fetch?: typeof fetch; home?: string } = {},
|
|
87
|
+
): Promise<InitResult> {
|
|
88
|
+
const home = deps.home ?? homedir();
|
|
89
|
+
const configFile = configPath(env);
|
|
90
|
+
const existing = readConfigFile(configFile) as Record<string, any>;
|
|
91
|
+
const current = loadConfig(env);
|
|
92
|
+
const envFile = current.envFile ?? expand(env.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"));
|
|
93
|
+
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
94
|
+
const created: string[] = [];
|
|
95
|
+
|
|
96
|
+
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.");
|
|
97
|
+
|
|
98
|
+
const setup = await io.choose(
|
|
99
|
+
"Setup",
|
|
100
|
+
[
|
|
101
|
+
{ value: "standard", label: "Standard" },
|
|
102
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
103
|
+
],
|
|
104
|
+
flags.setup ?? existing.setup ?? "standard",
|
|
105
|
+
);
|
|
106
|
+
const fmup = setup === "fmup";
|
|
107
|
+
const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
108
|
+
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
109
|
+
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";
|
|
110
|
+
const notes = expand(
|
|
111
|
+
(await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(),
|
|
112
|
+
);
|
|
113
|
+
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
114
|
+
const profile = await io.choose(
|
|
115
|
+
"Profile (change it later with /profile)",
|
|
116
|
+
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
117
|
+
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
121
|
+
let tags = "keep";
|
|
122
|
+
if (existsSync(vocab)) {
|
|
123
|
+
io.say(`Tag list: keeping ${vocab}.`);
|
|
124
|
+
} else {
|
|
125
|
+
tags = await io.choose(
|
|
126
|
+
"Starter tag list (edit it later in the notes folder)",
|
|
127
|
+
Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })),
|
|
128
|
+
flags.tags ?? (fmup ? "health-sciences" : "any-field"),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const email = (
|
|
132
|
+
await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")
|
|
133
|
+
).trim();
|
|
134
|
+
const modelOptions = [
|
|
135
|
+
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
136
|
+
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
137
|
+
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
138
|
+
];
|
|
139
|
+
const agentDir = env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
|
|
140
|
+
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
141
|
+
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
142
|
+
|
|
143
|
+
// ---- write
|
|
144
|
+
for (const dir of [notes, join(notes, "Inbox"), join(notes, "Systems")]) {
|
|
145
|
+
if (!existsSync(dir)) {
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
created.push(dir);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (tags !== "keep" && STARTER_TAGS[tags] && !existsSync(vocab)) {
|
|
151
|
+
mkdirSync(dirname(vocab), { recursive: true });
|
|
152
|
+
copyFileSync(join(TEMPLATES, "vocabularies", `${tags}.md`), vocab);
|
|
153
|
+
created.push(vocab);
|
|
154
|
+
}
|
|
155
|
+
const rules = join(notes, "Systems", "Zotero agent.md");
|
|
156
|
+
if (!existsSync(rules)) {
|
|
157
|
+
copyFileSync(join(TEMPLATES, "Zotero agent.md"), rules);
|
|
158
|
+
created.push(rules);
|
|
159
|
+
}
|
|
160
|
+
mkdirSync(dirname(envFile), { recursive: true });
|
|
161
|
+
const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
|
|
162
|
+
writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_CONTACT_EMAIL: email || undefined }));
|
|
163
|
+
|
|
164
|
+
const patch: Record<string, unknown> = {
|
|
165
|
+
profile,
|
|
166
|
+
userName: userName || undefined,
|
|
167
|
+
about: about || undefined,
|
|
168
|
+
language,
|
|
169
|
+
setup: fmup ? "fmup" : undefined,
|
|
170
|
+
vault: notes,
|
|
171
|
+
envFile,
|
|
172
|
+
};
|
|
173
|
+
if (models === "later") patch.models = {};
|
|
174
|
+
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
175
|
+
saveConfig(patch, env);
|
|
176
|
+
|
|
177
|
+
const zotero = await zoteroReachable(deps.fetch);
|
|
178
|
+
io.say("");
|
|
179
|
+
io.say(`Saved: ${configFile}`);
|
|
180
|
+
io.say(`Saved: ${envFile}`);
|
|
181
|
+
for (const c of created) io.say(`Created: ${c}`);
|
|
182
|
+
io.say(
|
|
183
|
+
zotero
|
|
184
|
+
? "Zotero: reachable."
|
|
185
|
+
: "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\".",
|
|
186
|
+
);
|
|
187
|
+
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, choose a model provider with /login.");
|
|
188
|
+
io.say("Next: type subsub. In Sub-Sub, type /login to connect a model provider. To check the setup, type subsub doctor.");
|
|
189
|
+
return { configFile, envFile, notes, created, zotero };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function parseFlags(args: string[]): Record<string, string> {
|
|
193
|
+
const out: Record<string, string> = {};
|
|
194
|
+
for (let i = 0; i < args.length; i++) {
|
|
195
|
+
const a = args[i];
|
|
196
|
+
if (!a.startsWith("--")) continue;
|
|
197
|
+
const key = a.slice(2);
|
|
198
|
+
const next = args[i + 1];
|
|
199
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
200
|
+
out[key] = next;
|
|
201
|
+
i++;
|
|
202
|
+
} else out[key] = "true";
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function defaultsIO(say: (l: string) => void): InitIO {
|
|
208
|
+
return {
|
|
209
|
+
ask: async (_q, def) => def,
|
|
210
|
+
choose: async (_q, _o, def) => def,
|
|
211
|
+
say,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function terminalIO(): InitIO & { close(): void } {
|
|
216
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
217
|
+
return {
|
|
218
|
+
async ask(q, def) {
|
|
219
|
+
const a = await rl.question(`${q}${def ? ` [${def}]` : ""}: `);
|
|
220
|
+
return a.trim() === "" ? def : a.trim();
|
|
221
|
+
},
|
|
222
|
+
async choose(q, options, def) {
|
|
223
|
+
console.log(`\n${q}`);
|
|
224
|
+
options.forEach((o, i) => console.log(` ${i + 1}. ${o.label}${o.value === def ? " (default)" : ""}`));
|
|
225
|
+
for (;;) {
|
|
226
|
+
const a = (await rl.question(`Number [${options.findIndex((o) => o.value === def) + 1}]: `)).trim();
|
|
227
|
+
if (a === "") return def;
|
|
228
|
+
const n = Number(a);
|
|
229
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
|
|
230
|
+
const byName = options.find((o) => o.value === a.toLowerCase());
|
|
231
|
+
if (byName) return byName.value;
|
|
232
|
+
console.log(`Type a number from 1 to ${options.length}.`);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
say: (l) => console.log(l),
|
|
236
|
+
close: () => rl.close(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
241
|
+
const flags = parseFlags(args);
|
|
242
|
+
const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
243
|
+
try {
|
|
244
|
+
await runInit(io, flags, env);
|
|
245
|
+
return 0;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`Sub-Sub init: ${(err as Error).message}`);
|
|
248
|
+
return 1;
|
|
249
|
+
} finally {
|
|
250
|
+
(io as { close?: () => void }).close?.();
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/look.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
|
|
7
|
+
import type { Mode } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
export const BANNER = [
|
|
10
|
+
"╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
|
|
11
|
+
"╚═╗║ ║╠╩╗───╚═╗║ ║╠╩╗",
|
|
12
|
+
"╚═╝╚═╝╚═╝ ╚═╝╚═╝╚═╝",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Short public-domain lines from Moby-Dick and its Extracts. */
|
|
16
|
+
export const QUOTES: Array<[string, string]> = [
|
|
17
|
+
["And God created great whales.", "Genesis, in the Extracts"],
|
|
18
|
+
["Leviathan maketh a path to shine after him; One would think the deep to be hoary.", "Job, in the Extracts"],
|
|
19
|
+
["There go the ships; there is that Leviathan whom thou hast made to play therein.", "Psalms, in the Extracts"],
|
|
20
|
+
["Now the Lord had prepared a great fish to swallow up Jonah.", "Jonah, in the Extracts"],
|
|
21
|
+
["Very like a whale.", "Hamlet, in the Extracts"],
|
|
22
|
+
["So fare thee well, poor devil of a Sub-Sub, whose commentator I am.", "Moby-Dick, Extracts"],
|
|
23
|
+
["Give it up, Sub-Subs!", "Moby-Dick, Extracts"],
|
|
24
|
+
["God keep me from ever completing anything.", "Moby-Dick, ch. 32"],
|
|
25
|
+
["Call me Ishmael.", "Moby-Dick, ch. 1"],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const MODE_THEMES: Record<Mode, string> = { librarian: "subsub-try-works", researcher: "subsub-glauca" };
|
|
29
|
+
|
|
30
|
+
export type Scheme = "dark" | "light";
|
|
31
|
+
|
|
32
|
+
/** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
|
|
33
|
+
export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
34
|
+
if (themeName?.endsWith("-light") || themeName === "light") return "light";
|
|
35
|
+
if (themeName?.endsWith("-dark") || themeName === "dark") return "dark";
|
|
36
|
+
const rgb = fgAnsi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
37
|
+
if (rgb) {
|
|
38
|
+
const [r, g, b] = rgb.slice(1).map(Number);
|
|
39
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128 ? "light" : "dark";
|
|
40
|
+
}
|
|
41
|
+
const idx = fgAnsi.match(/38;5;(\d+)/);
|
|
42
|
+
if (idx) {
|
|
43
|
+
const n = Number(idx[1]);
|
|
44
|
+
// 232-255 grey ramp, 0/16 black: dark text on a light terminal
|
|
45
|
+
if (n === 0 || n === 16 || (n >= 232 && n <= 243)) return "light";
|
|
46
|
+
}
|
|
47
|
+
return "dark";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function themeName(mode: Mode, scheme: Scheme, map: Partial<Record<Mode, string>> = MODE_THEMES): string | undefined {
|
|
51
|
+
const base = map[mode];
|
|
52
|
+
return base ? `${base}-${scheme}` : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LibraryState {
|
|
56
|
+
zotero: "checking" | "reachable" | "down";
|
|
57
|
+
items?: number;
|
|
58
|
+
toReview?: number;
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Paint {
|
|
63
|
+
mark(s: string): string;
|
|
64
|
+
text(s: string): string;
|
|
65
|
+
muted(s: string): string;
|
|
66
|
+
dim(s: string): string;
|
|
67
|
+
warn(s: string): string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const plain: Paint = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, warn: (s) => s };
|
|
71
|
+
|
|
72
|
+
function cut(s: string, width: number): string {
|
|
73
|
+
return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
|
|
77
|
+
if (st.zotero === "checking") return { text: "Zotero: checking...", warn: false };
|
|
78
|
+
if (st.zotero === "down") return { text: "Zotero is not running. Start it, then type /subsub.", warn: true };
|
|
79
|
+
const parts = [`${st.items ?? "?"} items`];
|
|
80
|
+
if (st.toReview) parts.push(`${st.toReview} to review`);
|
|
81
|
+
return { text: `Zotero: ${parts.join(", ")}`, warn: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
85
|
+
export function headerLines(opts: {
|
|
86
|
+
version: string;
|
|
87
|
+
mode: Mode;
|
|
88
|
+
/** Profile label, e.g. "Scholar". */
|
|
89
|
+
profile?: string;
|
|
90
|
+
model: string;
|
|
91
|
+
library: LibraryState;
|
|
92
|
+
quote?: [string, string];
|
|
93
|
+
width: number;
|
|
94
|
+
paint?: Paint;
|
|
95
|
+
}): string[] {
|
|
96
|
+
const p = opts.paint ?? plain;
|
|
97
|
+
const w = Math.max(10, opts.width);
|
|
98
|
+
const modeLabel = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
|
|
99
|
+
const lib = libraryLine(opts.library);
|
|
100
|
+
if (w < BANNER[0].length + 2) {
|
|
101
|
+
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))];
|
|
102
|
+
}
|
|
103
|
+
const out = ["", ...BANNER.map((l, i) => p.mark(l) + (i === 2 ? p.dim(cut(` ${opts.version}`, w - l.length)) : "")), ""];
|
|
104
|
+
const status = `${modeLabel} ${opts.model}`;
|
|
105
|
+
out.push(p.text(cut(status, w)) + (lib.warn ? p.warn : p.muted)(cut(` | ${lib.text}`, Math.max(0, w - status.length))));
|
|
106
|
+
if (opts.quote) {
|
|
107
|
+
const one = `"${opts.quote[0]}" (${opts.quote[1]})`;
|
|
108
|
+
if ([...one].length <= w) out.push(p.dim(one));
|
|
109
|
+
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
110
|
+
}
|
|
111
|
+
out.push(p.dim(cut("/researcher /librarian /profile /history /undo | / for all commands", w)), "");
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Colour the "+ added" and "- removed" parts of a preview; the rest gets the
|
|
117
|
+
* base colour (pi draws dialog text in the accent colour otherwise).
|
|
118
|
+
*/
|
|
119
|
+
export function paintPreview(
|
|
120
|
+
text: string,
|
|
121
|
+
add: (s: string) => string,
|
|
122
|
+
remove: (s: string) => string,
|
|
123
|
+
base: (s: string) => string = (x) => x,
|
|
124
|
+
): string {
|
|
125
|
+
const seg = /(^|: |; )([+-] [^;]+)/g;
|
|
126
|
+
return text
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => {
|
|
129
|
+
let out = "";
|
|
130
|
+
let last = 0;
|
|
131
|
+
for (const m of line.matchAll(seg)) {
|
|
132
|
+
const start = (m.index ?? 0) + m[1].length;
|
|
133
|
+
if (start > last) out += base(line.slice(last, start));
|
|
134
|
+
out += (m[2].startsWith("+") ? add : remove)(m[2]);
|
|
135
|
+
last = start + m[2].length;
|
|
136
|
+
}
|
|
137
|
+
if (last < line.length) out += base(line.slice(last));
|
|
138
|
+
return out;
|
|
139
|
+
})
|
|
140
|
+
.join("\n");
|
|
141
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
|
|
9
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export function normalizeToolPath(raw: string, cwd: string): string {
|
|
15
|
+
let p = String(raw ?? "").trim();
|
|
16
|
+
if (p.startsWith("@")) p = p.slice(1);
|
|
17
|
+
if (p.startsWith("file://")) {
|
|
18
|
+
try {
|
|
19
|
+
p = fileURLToPath(p);
|
|
20
|
+
} catch {
|
|
21
|
+
/* keep as is */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (p === "~" || p.startsWith("~/")) p = join(homedir(), p.slice(1));
|
|
25
|
+
return realish(resolve(cwd, p));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** realpath of the nearest existing ancestor, plus the part that does not exist yet. */
|
|
29
|
+
export function realish(abs: string): string {
|
|
30
|
+
let head = abs;
|
|
31
|
+
const tail: string[] = [];
|
|
32
|
+
while (!existsSync(head)) {
|
|
33
|
+
const parent = dirname(head);
|
|
34
|
+
if (parent === head) break;
|
|
35
|
+
tail.unshift(basename(head));
|
|
36
|
+
head = parent;
|
|
37
|
+
}
|
|
38
|
+
let real = head;
|
|
39
|
+
try {
|
|
40
|
+
real = realpathSync(head);
|
|
41
|
+
} catch {
|
|
42
|
+
/* keep */
|
|
43
|
+
}
|
|
44
|
+
return tail.length ? join(real, ...tail) : real;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function within(child: string, parent: string | undefined): boolean {
|
|
48
|
+
if (!parent) return false;
|
|
49
|
+
const rel = relative(parent, child);
|
|
50
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PathPolicy {
|
|
54
|
+
/** Folders where writes need no question. */
|
|
55
|
+
allowed: string[];
|
|
56
|
+
/** Files and folders that always need a question (rules, config, code). */
|
|
57
|
+
protectedPaths: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildPolicy(opts: { vault?: string; cwd: string; serverDir: string; packageDir: string }): PathPolicy {
|
|
61
|
+
const home = realish(homedir());
|
|
62
|
+
const cwd = realish(opts.cwd);
|
|
63
|
+
const allowed: string[] = [];
|
|
64
|
+
if (opts.vault) allowed.push(realish(opts.vault));
|
|
65
|
+
// The working folder counts only if it is a specific folder, not the home folder or above it.
|
|
66
|
+
if (cwd !== home && within(cwd, home) && !within(home, cwd)) allowed.push(cwd);
|
|
67
|
+
const protectedPaths = [
|
|
68
|
+
join(home, ".pi"),
|
|
69
|
+
join(home, ".subsub"),
|
|
70
|
+
join(home, ".config"),
|
|
71
|
+
join(home, ".ssh"),
|
|
72
|
+
join(home, ".zshrc"),
|
|
73
|
+
join(home, ".bashrc"),
|
|
74
|
+
realish(opts.serverDir),
|
|
75
|
+
realish(opts.packageDir),
|
|
76
|
+
];
|
|
77
|
+
if (opts.vault) {
|
|
78
|
+
const v = realish(opts.vault);
|
|
79
|
+
protectedPaths.push(
|
|
80
|
+
join(v, ".claude"),
|
|
81
|
+
join(v, ".opencode"),
|
|
82
|
+
join(v, ".obsidian"),
|
|
83
|
+
join(v, "Systems", "Zotero agent.md"),
|
|
84
|
+
join(v, "Systems", "Zotero tags.md"),
|
|
85
|
+
join(v, "Systems", "Zotero librarian.md"),
|
|
86
|
+
join(v, "Systems", "Zotero researcher.md"),
|
|
87
|
+
join(v, "Systems", "Literature alerts.md"),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { allowed, protectedPaths };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type PathVerdict = { ok: true } | { ok: false; why: string };
|
|
94
|
+
|
|
95
|
+
export function judgePath(target: string, policy: PathPolicy): PathVerdict {
|
|
96
|
+
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
97
|
+
if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
98
|
+
if (policy.allowed.some((a) => within(target, a))) return { ok: true };
|
|
99
|
+
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
100
|
+
}
|