@tiagojct/subsub 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +200 -61
- package/THIRD_PARTY_NOTICES +55 -0
- package/dist/bench.js +60 -5
- package/dist/config.js +50 -18
- package/dist/doctor.js +23 -2
- package/dist/init.js +26 -11
- package/dist/notes.js +42 -0
- package/dist/profiles.js +15 -0
- package/dist/prompt.js +3 -0
- package/dist/roles.js +7 -1
- package/dist/subsub.js +94 -9
- package/dist/web.js +17 -3
- package/docs/Subsub.md +4 -2
- package/package.json +7 -3
- package/prompts/compare.md +23 -0
- package/prompts/digest.md +23 -0
- package/prompts/lit.md +87 -0
- package/prompts/request-copy.md +35 -0
- package/prompts/review.md +31 -0
- package/prompts/verify.md +18 -0
- package/roles/researcher.md +18 -5
- package/src/bench.ts +63 -4
- package/src/config.ts +61 -18
- package/src/doctor.ts +32 -2
- package/src/init.ts +42 -11
- package/src/notes.ts +46 -0
- package/src/profiles.ts +17 -0
- package/src/prompt.ts +6 -0
- package/src/roles.ts +8 -1
- package/src/subsub.ts +100 -9
- package/src/web.ts +15 -3
- package/templates/Zotero agent.md +2 -0
- package/themes/subsub-glauca-dark.json +34 -34
- package/themes/subsub-glauca-light.json +32 -32
- package/themes/subsub-try-works-dark.json +34 -34
- package/themes/subsub-try-works-light.json +34 -34
- package/web/app.css +821 -131
- package/web/app.js +270 -26
- package/web/glauca.css +54 -49
- package/web/i18n.js +60 -0
- package/web/icon.svg +12 -1
- package/web/index.html +21 -4
package/dist/config.js
CHANGED
|
@@ -12,15 +12,24 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `subsub init` writes this file. Without "serverDir" (or when that folder does
|
|
14
14
|
* not exist), the Zotero servers run from PyPI with uv.
|
|
15
|
+
*
|
|
16
|
+
* Add-ons: "addons": ["starbuck"] starts Starbuck (reference checks) as a third
|
|
17
|
+
* server, "verify". It runs from "starbuckDir" when that folder has a
|
|
18
|
+
* pyproject.toml, otherwise from the pinned PyPI release (STARBUCK_SOURCE).
|
|
19
|
+
* `subsub init` and the web view turn it on and off.
|
|
15
20
|
*/
|
|
16
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
22
|
import { homedir } from "node:os";
|
|
18
23
|
import { dirname, join, resolve } from "node:path";
|
|
19
24
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
20
25
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
21
|
-
export const SERVER_VERSION = "0.
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
export const SERVER_VERSION = "0.5.0";
|
|
27
|
+
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
28
|
+
export const STARBUCK_VERSION = "0.2.0";
|
|
29
|
+
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
30
|
+
export function expand(p, home) {
|
|
31
|
+
const base = home ?? process.env.HOME ?? homedir();
|
|
32
|
+
return resolve(p.replace(/^~(?=$|\/)/, base));
|
|
24
33
|
}
|
|
25
34
|
export function parseEnvFile(path) {
|
|
26
35
|
const out = {};
|
|
@@ -41,7 +50,7 @@ export function parseEnvFile(path) {
|
|
|
41
50
|
return out;
|
|
42
51
|
}
|
|
43
52
|
export function configPath(env = process.env) {
|
|
44
|
-
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
|
|
53
|
+
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
|
|
45
54
|
}
|
|
46
55
|
export function readConfigFile(file) {
|
|
47
56
|
if (!existsSync(file))
|
|
@@ -68,6 +77,7 @@ export function isProfile(x) {
|
|
|
68
77
|
return typeof x === "string" && PROFILES.includes(x);
|
|
69
78
|
}
|
|
70
79
|
export function loadConfig(env = process.env) {
|
|
80
|
+
const home = env.HOME;
|
|
71
81
|
const file = configPath(env);
|
|
72
82
|
let user = {};
|
|
73
83
|
if (existsSync(file)) {
|
|
@@ -78,16 +88,18 @@ export function loadConfig(env = process.env) {
|
|
|
78
88
|
throw new Error(`Sub-Sub: cannot read ${file}: ${err.message}`);
|
|
79
89
|
}
|
|
80
90
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
|
|
92
|
+
const envFile = explicitEnv
|
|
93
|
+
? expand(explicitEnv, home)
|
|
94
|
+
: [
|
|
95
|
+
"~/.config/zotero-local-mcp/env",
|
|
96
|
+
"~/.config/opencode/zotero.env",
|
|
97
|
+
]
|
|
98
|
+
.map((p) => expand(p, home))
|
|
99
|
+
.find((p) => existsSync(p));
|
|
100
|
+
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
89
101
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
90
|
-
const vault = vaultRaw ? expand(vaultRaw) : undefined;
|
|
102
|
+
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
91
103
|
const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
|
|
92
104
|
return {
|
|
93
105
|
configFile: file,
|
|
@@ -96,7 +108,7 @@ export function loadConfig(env = process.env) {
|
|
|
96
108
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
97
109
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
|
98
110
|
setup: user.setup,
|
|
99
|
-
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
|
|
111
|
+
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
100
112
|
envFile,
|
|
101
113
|
vault,
|
|
102
114
|
models: user.models ?? {
|
|
@@ -104,26 +116,29 @@ export function loadConfig(env = process.env) {
|
|
|
104
116
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
105
117
|
},
|
|
106
118
|
defaultMode: mode,
|
|
107
|
-
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
|
|
119
|
+
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
|
|
108
120
|
sharedRules: user.sharedRules
|
|
109
|
-
? expand(user.sharedRules)
|
|
121
|
+
? expand(user.sharedRules, home)
|
|
110
122
|
: vault
|
|
111
123
|
? join(vault, "Systems", "Zotero agent.md")
|
|
112
124
|
: undefined,
|
|
113
125
|
startTimeout: user.startTimeout ?? 45,
|
|
114
|
-
uvPath: user.uvPath ? expand(user.uvPath) : undefined,
|
|
126
|
+
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
115
127
|
look: user.look,
|
|
116
128
|
quotes: user.quotes,
|
|
117
129
|
themes: user.themes,
|
|
130
|
+
addons: Array.isArray(user.addons) ? user.addons.filter((a) => typeof a === "string") : [],
|
|
131
|
+
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
118
132
|
};
|
|
119
133
|
}
|
|
120
134
|
/** uv is often missing from PATH when pi is started outside a login shell. */
|
|
121
135
|
export function findUv(cfg, env = process.env, platform = process.platform) {
|
|
136
|
+
const home = env.HOME;
|
|
122
137
|
const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
|
|
123
138
|
const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
|
|
124
139
|
const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
|
|
125
140
|
.filter((x) => Boolean(x))
|
|
126
|
-
.map(expand);
|
|
141
|
+
.map((p) => expand(p, home));
|
|
127
142
|
return candidates.find((p) => existsSync(p)) ?? "uv";
|
|
128
143
|
}
|
|
129
144
|
/**
|
|
@@ -138,6 +153,23 @@ export function serverCommand(cfg, program, args = [], env = process.env) {
|
|
|
138
153
|
}
|
|
139
154
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
140
155
|
}
|
|
156
|
+
export function starbuckEnabled(cfg) {
|
|
157
|
+
return (cfg.addons ?? []).includes("starbuck");
|
|
158
|
+
}
|
|
159
|
+
/** How to start Starbuck's MCP server: from the local folder when it exists, otherwise the pinned release. */
|
|
160
|
+
export function starbuckCommand(cfg, env = process.env) {
|
|
161
|
+
const uv = findUv(cfg, env);
|
|
162
|
+
if (cfg.starbuckDir && existsSync(join(cfg.starbuckDir, "pyproject.toml"))) {
|
|
163
|
+
return { command: uv, args: ["run", "--quiet", "--directory", cfg.starbuckDir, "starbuck-mcp"] };
|
|
164
|
+
}
|
|
165
|
+
return { command: uv, args: ["tool", "run", "--quiet", "--from", STARBUCK_SOURCE, "starbuck-mcp"] };
|
|
166
|
+
}
|
|
167
|
+
/** The addons setting with Starbuck turned on or off; other add-ons stay. Undefined (no setting) when empty. */
|
|
168
|
+
export function withStarbuck(addons, on) {
|
|
169
|
+
const rest = (addons ?? []).filter((a) => a !== "starbuck");
|
|
170
|
+
const out = on ? [...rest, "starbuck"] : rest;
|
|
171
|
+
return out.length ? out : undefined;
|
|
172
|
+
}
|
|
141
173
|
/** How prompts name the user. */
|
|
142
174
|
export function who(cfg) {
|
|
143
175
|
return cfg.userName ?? "the user";
|
package/dist/doctor.js
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Checks Node, the settings file, uv, the Zotero server (it runs
|
|
5
5
|
* `zotero-local-mcp --check`), Zotero itself, the tag list, the notes folder,
|
|
6
|
-
* the contact email and the model login. Exit
|
|
6
|
+
* the contact email, Starbuck (when the add-on is on) and the model login. Exit
|
|
7
|
+
* code 1 when something needs a fix.
|
|
7
8
|
*/
|
|
8
9
|
import { spawnSync } from "node:child_process";
|
|
9
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { join } from "node:path";
|
|
12
|
-
import { findUv, loadConfig, SERVER_VERSION, serverCommand } from "./config.js";
|
|
13
|
+
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, } from "./config.js";
|
|
13
14
|
import { profileSpec } from "./profiles.js";
|
|
14
15
|
export const defaultRunner = (command, args, env) => {
|
|
15
16
|
const r = spawnSync(command, args, { env, encoding: "utf8", timeout: 120_000 });
|
|
@@ -100,12 +101,32 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
100
101
|
out.push(st.contact_email_set
|
|
101
102
|
? { name: "Contact email", ok: true, detail: "set" }
|
|
102
103
|
: { 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." });
|
|
104
|
+
if (starbuckEnabled(cfg))
|
|
105
|
+
out.push(starbuckCheck(cfg, env, run));
|
|
103
106
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
104
107
|
out.push(hasLogin(agentDir)
|
|
105
108
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.values(cfg.models).join(", ") : "pi's default model" }
|
|
106
109
|
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." });
|
|
107
110
|
return out;
|
|
108
111
|
}
|
|
112
|
+
/** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
|
|
113
|
+
export function starbuckCheck(cfg, env, run) {
|
|
114
|
+
const cmd = starbuckCommand(cfg, env);
|
|
115
|
+
const local = cmd.args.includes("--directory");
|
|
116
|
+
const args = cmd.args.slice(0, -1).concat(["starbuck", "--version"]);
|
|
117
|
+
const res = run(cmd.command, args, env);
|
|
118
|
+
if (res.status === 0) {
|
|
119
|
+
return { name: "Starbuck", ok: true, detail: `${res.stdout.trim()} ${local ? `from ${cfg.starbuckDir}` : `from ${STARBUCK_SOURCE}`}` };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
name: "Starbuck",
|
|
123
|
+
ok: false,
|
|
124
|
+
detail: local ? `cannot start from ${cfg.starbuckDir}` : `cannot install ${STARBUCK_SOURCE} with uv`,
|
|
125
|
+
fix: local
|
|
126
|
+
? `Type: cd "${cfg.starbuckDir}" && uv sync`
|
|
127
|
+
: "Check the internet connection and run subsub doctor again. To turn reference checks off, type subsub init.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
109
130
|
export function formatChecks(checks) {
|
|
110
131
|
return checks
|
|
111
132
|
.map((c) => {
|
package/dist/init.js
CHANGED
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
14
14
|
* standard|fmup, --name, --about, --language, --notes, --profile, --tags
|
|
15
|
-
* health-sciences|health-informatics|any-field, --email, --
|
|
15
|
+
* health-sciences|health-informatics|any-field, --email, --starbuck on|off,
|
|
16
|
+
* --models keep|later|opencode-go.
|
|
16
17
|
*/
|
|
17
18
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
20
|
import { dirname, join, resolve } from "node:path";
|
|
20
21
|
import { createInterface } from "node:readline/promises";
|
|
21
22
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig } from "./config.js";
|
|
23
|
+
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
24
|
+
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
23
25
|
import { PROFILE_SPECS } from "./profiles.js";
|
|
24
26
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
25
27
|
export const STARTER_TAGS = {
|
|
@@ -63,11 +65,12 @@ export async function zoteroReachable(fetchFn = fetch) {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
66
|
-
const home = deps.home ?? homedir();
|
|
67
|
-
const
|
|
68
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
69
|
+
const initEnv = { ...env, HOME: home };
|
|
70
|
+
const configFile = configPath(initEnv);
|
|
68
71
|
const existing = readConfigFile(configFile);
|
|
69
|
-
const current = loadConfig(
|
|
70
|
-
const envFile = current.envFile ?? expand(
|
|
72
|
+
const current = loadConfig(initEnv);
|
|
73
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
71
74
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
72
75
|
const created = [];
|
|
73
76
|
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.");
|
|
@@ -79,10 +82,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
79
82
|
const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
80
83
|
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
84
|
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());
|
|
85
|
+
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(), home);
|
|
83
86
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
84
87
|
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");
|
|
88
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
86
89
|
let tags = "keep";
|
|
87
90
|
if (existsSync(vocab)) {
|
|
88
91
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -91,12 +94,16 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
91
94
|
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
95
|
}
|
|
93
96
|
const email = (await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
97
|
+
const starbuck = await io.choose("Reference checks (Starbuck): check that cited works exist, match their citation and were not retracted", [
|
|
98
|
+
{ value: "off", label: "Off" },
|
|
99
|
+
{ value: "on", label: "On (the researcher gets /verify; uses the email above)" },
|
|
100
|
+
], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
|
|
94
101
|
const modelOptions = [
|
|
95
102
|
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
96
103
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
97
104
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
98
105
|
];
|
|
99
|
-
const agentDir =
|
|
106
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
100
107
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
101
108
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
102
109
|
// ---- write
|
|
@@ -127,13 +134,15 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
127
134
|
setup: fmup ? "fmup" : undefined,
|
|
128
135
|
vault: notes,
|
|
129
136
|
envFile,
|
|
137
|
+
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
130
138
|
};
|
|
131
139
|
if (models === "later")
|
|
132
140
|
patch.models = {};
|
|
133
141
|
if (models === "opencode-go")
|
|
134
142
|
patch.models = TESTED_MODELS;
|
|
135
|
-
saveConfig(patch,
|
|
143
|
+
saveConfig(patch, initEnv);
|
|
136
144
|
const zotero = await zoteroReachable(deps.fetch);
|
|
145
|
+
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
137
146
|
io.say("");
|
|
138
147
|
io.say(`Saved: ${configFile}`);
|
|
139
148
|
io.say(`Saved: ${envFile}`);
|
|
@@ -142,6 +151,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
142
151
|
io.say(zotero
|
|
143
152
|
? "Zotero: reachable."
|
|
144
153
|
: "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\".");
|
|
154
|
+
if (starbuck === "on")
|
|
155
|
+
io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
145
156
|
if (fmup)
|
|
146
157
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
147
158
|
io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
|
|
@@ -202,7 +213,11 @@ export async function initMain(args, env = process.env) {
|
|
|
202
213
|
const flags = parseFlags(args);
|
|
203
214
|
const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
204
215
|
try {
|
|
205
|
-
await runInit(io, flags, env)
|
|
216
|
+
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
217
|
+
// Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
|
|
218
|
+
const c = starbuckCheck(cfg, env, defaultRunner);
|
|
219
|
+
return c.ok ? `Reference checks: on (${c.detail}).` : `Reference checks: on, but ${c.detail}. ${c.fix ?? ""}`.trim();
|
|
220
|
+
} });
|
|
206
221
|
return 0;
|
|
207
222
|
}
|
|
208
223
|
catch (err) {
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks on the literature notes the researcher writes (a note whose front matter has a
|
|
3
|
+
* citekey). Each note must say what it was written from (evidence: full text, abstract
|
|
4
|
+
* or metadata), and only a note written from the full text may give page numbers.
|
|
5
|
+
* The write gate calls these before a write or edit runs; a failed check blocks the call
|
|
6
|
+
* with a reason, so the model corrects the note.
|
|
7
|
+
*/
|
|
8
|
+
export const EVIDENCE = ["full text", "abstract", "metadata"];
|
|
9
|
+
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
10
|
+
const PAGES = /\b(?:pp?\.|pages?)\s?\d+/i;
|
|
11
|
+
export function frontMatter(text) {
|
|
12
|
+
return FRONT.exec(text)?.[1];
|
|
13
|
+
}
|
|
14
|
+
export function field(front, name) {
|
|
15
|
+
const m = new RegExp(`^${name}:[ \\t]*(.*)$`, "m").exec(front);
|
|
16
|
+
return m ? m[1].trim().replace(/^["']|["']$/g, "").trim() : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Why a new literature note cannot be written as it is, or undefined when it can. */
|
|
19
|
+
export function checkLiteratureNote(text) {
|
|
20
|
+
const m = FRONT.exec(text);
|
|
21
|
+
if (!m || !field(m[1], "citekey"))
|
|
22
|
+
return undefined;
|
|
23
|
+
const evidence = field(m[1], "evidence")?.toLowerCase();
|
|
24
|
+
if (!evidence || !EVIDENCE.includes(evidence)) {
|
|
25
|
+
return 'A literature note needs "evidence:" in its front matter, with one of: full text, abstract, metadata (what you read to write it). Add it and write the note again.';
|
|
26
|
+
}
|
|
27
|
+
const body = text.slice(m[0].length);
|
|
28
|
+
if (evidence !== "full text" && PAGES.test(body)) {
|
|
29
|
+
return `This note says evidence: ${evidence}, but it gives page numbers. Page numbers come only from the full text: remove them, or read the full text and set evidence: full text.`;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
/** The text of a file after pi's edit tool applies edits[] (each oldText matched against the original). */
|
|
34
|
+
export function applyEdits(current, edits) {
|
|
35
|
+
let out = current;
|
|
36
|
+
for (const e of edits) {
|
|
37
|
+
const oldText = String(e.oldText ?? "");
|
|
38
|
+
if (oldText && out.includes(oldText))
|
|
39
|
+
out = out.replace(oldText, () => String(e.newText ?? ""));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/dist/profiles.js
CHANGED
|
@@ -18,6 +18,7 @@ export const PROFILE_SPECS = {
|
|
|
18
18
|
label: "Reader",
|
|
19
19
|
summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
|
|
20
20
|
without: [...BULK_LIBRARY, ...MANUSCRIPT],
|
|
21
|
+
withoutPrompts: ["lit", "compare", "review", "digest"],
|
|
21
22
|
batch: 10,
|
|
22
23
|
rules: [
|
|
23
24
|
"Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
|
|
@@ -33,6 +34,7 @@ export const PROFILE_SPECS = {
|
|
|
33
34
|
label: "Scholar",
|
|
34
35
|
summary: "literature notes, syntheses, searches and alerts; the full librarian",
|
|
35
36
|
without: MANUSCRIPT,
|
|
37
|
+
withoutPrompts: ["review"],
|
|
36
38
|
batch: 25,
|
|
37
39
|
rules: [
|
|
38
40
|
"Profile: Scholar.",
|
|
@@ -46,6 +48,7 @@ export const PROFILE_SPECS = {
|
|
|
46
48
|
label: "Author",
|
|
47
49
|
summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
|
|
48
50
|
without: [],
|
|
51
|
+
withoutPrompts: [],
|
|
49
52
|
batch: 25,
|
|
50
53
|
rules: [
|
|
51
54
|
"Profile: Author.",
|
|
@@ -60,6 +63,7 @@ export const PROFILE_SPECS = {
|
|
|
60
63
|
label: "Editor",
|
|
61
64
|
summary: "everything, including drafting manuscript text on request",
|
|
62
65
|
without: [],
|
|
66
|
+
withoutPrompts: [],
|
|
63
67
|
batch: 25,
|
|
64
68
|
rules: [
|
|
65
69
|
"Profile: Editor. All tools.",
|
|
@@ -72,6 +76,17 @@ export const PROFILE_SPECS = {
|
|
|
72
76
|
export function profileSpec(p) {
|
|
73
77
|
return PROFILE_SPECS[p];
|
|
74
78
|
}
|
|
79
|
+
/** Why a prompt command does not run in this profile, or undefined when it does. */
|
|
80
|
+
export function promptBlocked(text, profile) {
|
|
81
|
+
const m = /^\/([\w-]+)(?:\s|$)/.exec(text.trim());
|
|
82
|
+
if (!m || !PROFILE_SPECS[profile].withoutPrompts.includes(m[1]))
|
|
83
|
+
return undefined;
|
|
84
|
+
const allowed = Object.values(PROFILE_SPECS)
|
|
85
|
+
.filter((s) => !s.withoutPrompts.includes(m[1]))
|
|
86
|
+
.map((s) => s.label)
|
|
87
|
+
.join(", ");
|
|
88
|
+
return `/${m[1]} is not part of the ${PROFILE_SPECS[profile].label} profile (it is in: ${allowed}). Change the profile with /profile.`;
|
|
89
|
+
}
|
|
75
90
|
export function profileList(current) {
|
|
76
91
|
return Object.values(PROFILE_SPECS)
|
|
77
92
|
.map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
|
package/dist/prompt.js
CHANGED
|
@@ -65,6 +65,9 @@ export function systemAddition(mode, cfg, cwd) {
|
|
|
65
65
|
parts.push(`# Vault conventions (${f})`, t);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
if (cfg.setup === "fmup") {
|
|
69
|
+
parts.push("# U.Porto", `${capital(user)} works at the University of Porto. Papers that are not open access often open through the university's subscriptions when ${user} uses Sub-Sub on the U.Porto network: say so when a work is not open access.`);
|
|
70
|
+
}
|
|
68
71
|
// The profile and the language rule come last, so they win over the shared rules and the vault conventions.
|
|
69
72
|
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
70
73
|
parts.push("# Language", languageRule(cfg.language, user));
|
package/dist/roles.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { profileSpec } from "./profiles.js";
|
|
5
5
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
6
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
|
|
7
|
+
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
6
8
|
export const ZOTERO_READ = [
|
|
7
9
|
"status",
|
|
8
10
|
"library_overview",
|
|
@@ -43,7 +45,11 @@ export function toolsFor(mode, registered, profile = "editor") {
|
|
|
43
45
|
const without = new Set(profileSpec(profile).without);
|
|
44
46
|
const wanted = mode === "librarian"
|
|
45
47
|
? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
|
|
46
|
-
: [
|
|
48
|
+
: [
|
|
49
|
+
...ZOTERO_READ,
|
|
50
|
+
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
51
|
+
...FILE_TOOLS,
|
|
52
|
+
];
|
|
47
53
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
48
54
|
}
|
|
49
55
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
package/dist/subsub.js
CHANGED
|
@@ -8,22 +8,25 @@
|
|
|
8
8
|
* - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
|
|
9
9
|
* tools it offers; /profile changes it.
|
|
10
10
|
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
11
|
+
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
12
|
+
* ("verify") for reference checks; its tools belong to researcher mode.
|
|
11
13
|
*
|
|
12
14
|
* The Python servers start in session_start (not in the factory) and stop in
|
|
13
15
|
* session_shutdown. Tools are registered once and use the current bridge.
|
|
14
16
|
*/
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
18
|
import { dirname, join } from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
18
20
|
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 { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
|
|
22
|
+
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
21
23
|
import { coerceArgs, loosenArrays } from "./args.js";
|
|
24
|
+
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
22
25
|
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
23
26
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
24
27
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
25
28
|
import { systemAddition } from "./prompt.js";
|
|
26
|
-
import { gateKind, toolsFor, unavailableReason } from "./roles.js";
|
|
29
|
+
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
27
30
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
31
|
const MODE_ENTRY = "subsub-mode";
|
|
29
32
|
const BENCH_ENTRY = "subsub-bench";
|
|
@@ -36,12 +39,32 @@ function ownVersion() {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
export { findUv };
|
|
39
|
-
export function serverSpecs(cfg) {
|
|
42
|
+
export function serverSpecs(cfg, processEnv = process.env) {
|
|
40
43
|
const env = cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {};
|
|
41
|
-
|
|
42
|
-
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp"), env },
|
|
43
|
-
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp"), env },
|
|
44
|
+
const specs = [
|
|
45
|
+
{ name: "zotero", ...serverCommand(cfg, "zotero-local-mcp", [], processEnv), env },
|
|
46
|
+
{ name: "scholar", ...serverCommand(cfg, "zotero-scholar-mcp", [], processEnv), env },
|
|
44
47
|
];
|
|
48
|
+
if (starbuckEnabled(cfg))
|
|
49
|
+
specs.push({ name: "verify", ...starbuckCommand(cfg, processEnv), env: starbuckEnv(cfg, processEnv) });
|
|
50
|
+
return specs;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Starbuck reads its contact address and API keys from the environment. Sub-Sub keeps
|
|
54
|
+
* them in the zotero-local-mcp env file, so they are passed on (the environment wins).
|
|
55
|
+
*/
|
|
56
|
+
export function starbuckEnv(cfg, processEnv = process.env) {
|
|
57
|
+
const file = cfg.envFile ? parseEnvFile(cfg.envFile) : {};
|
|
58
|
+
const out = {};
|
|
59
|
+
const email = processEnv.STARBUCK_EMAIL || file.STARBUCK_EMAIL || processEnv.ZOTERO_CONTACT_EMAIL || file.ZOTERO_CONTACT_EMAIL;
|
|
60
|
+
if (email)
|
|
61
|
+
out.STARBUCK_EMAIL = email;
|
|
62
|
+
for (const key of ["NCBI_API_KEY", "OPENALEX_API_KEY"]) {
|
|
63
|
+
const v = processEnv[key] || file[key];
|
|
64
|
+
if (v)
|
|
65
|
+
out[key] = v;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
45
68
|
}
|
|
46
69
|
export async function createSubsub(pi, deps = {}) {
|
|
47
70
|
const cfg = deps.config ?? loadConfig();
|
|
@@ -111,6 +134,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
111
134
|
language: cfg.language,
|
|
112
135
|
userName: cfg.userName ?? null,
|
|
113
136
|
vault: cfg.vault ?? null,
|
|
137
|
+
starbuck: starbuckEnabled(cfg),
|
|
114
138
|
};
|
|
115
139
|
try {
|
|
116
140
|
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
@@ -245,6 +269,15 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
245
269
|
await b?.close();
|
|
246
270
|
});
|
|
247
271
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
272
|
+
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
273
|
+
// The input event comes before pi expands a prompt template.
|
|
274
|
+
pi.on("input", async (event, ctx) => {
|
|
275
|
+
const why = promptBlocked(event.text, profile);
|
|
276
|
+
if (!why)
|
|
277
|
+
return { action: "continue" };
|
|
278
|
+
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
279
|
+
return { action: "handled" };
|
|
280
|
+
});
|
|
248
281
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
249
282
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
250
283
|
}));
|
|
@@ -258,7 +291,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
258
291
|
const schema = schemas.get(name);
|
|
259
292
|
if (schema)
|
|
260
293
|
coerceArgs(schema, input);
|
|
261
|
-
if (
|
|
294
|
+
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
262
295
|
const why = unavailableReason(name, mode, registered(), profile);
|
|
263
296
|
if (why) {
|
|
264
297
|
if (benchOut)
|
|
@@ -266,6 +299,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
266
299
|
return { block: true, reason: why };
|
|
267
300
|
}
|
|
268
301
|
}
|
|
302
|
+
if (name.startsWith("verify_"))
|
|
303
|
+
return verifyGate(name, input, ctx);
|
|
269
304
|
const kind = benchOut && name === "scholar_queue_imports" ? "confirm" : gateKind(name, input);
|
|
270
305
|
if (!kind)
|
|
271
306
|
return undefined;
|
|
@@ -273,6 +308,9 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
273
308
|
return benchGate(name, input, kind, ctx.cwd, ctx.signal);
|
|
274
309
|
if (kind === "path") {
|
|
275
310
|
const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
|
|
311
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
312
|
+
if (noteProblem)
|
|
313
|
+
return { block: true, reason: noteProblem };
|
|
276
314
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
277
315
|
if (verdict.ok)
|
|
278
316
|
return undefined;
|
|
@@ -303,9 +341,56 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
303
341
|
const ok = await ctx.ui.confirm(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, paintFor(ctx)(formatPreview(name, preview.data ?? preview.text)));
|
|
304
342
|
return ok ? undefined : { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
305
343
|
});
|
|
344
|
+
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
345
|
+
function literatureNoteProblem(name, target, input) {
|
|
346
|
+
if (!target.toLowerCase().endsWith(".md"))
|
|
347
|
+
return undefined;
|
|
348
|
+
if (name === "write")
|
|
349
|
+
return checkLiteratureNote(String(input.content ?? ""));
|
|
350
|
+
if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
|
|
351
|
+
const current = readFileSync(target, "utf8");
|
|
352
|
+
return checkLiteratureNote(applyEdits(current, input.edits));
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Starbuck writes its reports into a folder: report_dir, or _starbuck next to the
|
|
358
|
+
* manuscript. Inside the vault or the working folder that needs no approval; elsewhere
|
|
359
|
+
* Sub-Sub asks, as for any file write. record_claims gets the current model's name.
|
|
360
|
+
*/
|
|
361
|
+
async function verifyGate(name, input, ctx) {
|
|
362
|
+
if (name === "verify_record_claims") {
|
|
363
|
+
input.judged_by = ctx.model ? [ctx.model.provider, ctx.model.id].filter(Boolean).join("/") : "the agent's model";
|
|
364
|
+
}
|
|
365
|
+
if (name === "verify_check_references")
|
|
366
|
+
return undefined;
|
|
367
|
+
const dir = input.report_dir
|
|
368
|
+
? normalizeToolPath(String(input.report_dir), ctx.cwd)
|
|
369
|
+
: input.path
|
|
370
|
+
? join(dirname(normalizeToolPath(String(input.path), ctx.cwd)), "_starbuck")
|
|
371
|
+
: undefined;
|
|
372
|
+
if (!dir)
|
|
373
|
+
return undefined;
|
|
374
|
+
const verdict = judgePath(dir, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
375
|
+
if (verdict.ok)
|
|
376
|
+
return undefined;
|
|
377
|
+
if (benchOut) {
|
|
378
|
+
record({ kind: "path_blocked", tool: name, path: dir });
|
|
379
|
+
return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
|
|
380
|
+
}
|
|
381
|
+
if (!ctx.hasUI)
|
|
382
|
+
return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
383
|
+
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
384
|
+
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
385
|
+
}
|
|
306
386
|
async function benchGate(name, input, kind, cwd, signal) {
|
|
307
387
|
if (kind === "path") {
|
|
308
388
|
const target = normalizeToolPath(String(input.path ?? ""), cwd);
|
|
389
|
+
const noteProblem = literatureNoteProblem(name, target, input);
|
|
390
|
+
if (noteProblem) {
|
|
391
|
+
record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
|
|
392
|
+
return { block: true, reason: noteProblem };
|
|
393
|
+
}
|
|
309
394
|
if (within(target, realish(benchOut)))
|
|
310
395
|
return undefined;
|
|
311
396
|
record({ kind: "path_blocked", tool: name, path: target });
|
package/dist/web.js
CHANGED
|
@@ -21,9 +21,9 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { expand, loadConfig, saveConfig } from "./config.js";
|
|
24
|
+
import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
|
-
import { within } from "./paths.js";
|
|
26
|
+
import { realish, within } from "./paths.js";
|
|
27
27
|
export function parseWebArgs(args) {
|
|
28
28
|
const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
|
|
29
29
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -559,6 +559,17 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
559
559
|
await restartAgent();
|
|
560
560
|
return send(res, 200, { ok: true });
|
|
561
561
|
}
|
|
562
|
+
case "POST /api/addons": {
|
|
563
|
+
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
564
|
+
if (busy)
|
|
565
|
+
return send(res, 409, { error: "busy" });
|
|
566
|
+
const body = await readJson(req);
|
|
567
|
+
if (typeof body.starbuck !== "boolean")
|
|
568
|
+
return send(res, 400, { error: "starbuck must be true or false" });
|
|
569
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
570
|
+
await restartAgent();
|
|
571
|
+
return send(res, 200, { ok: true });
|
|
572
|
+
}
|
|
562
573
|
case "POST /api/restart":
|
|
563
574
|
if (agent.proc)
|
|
564
575
|
return send(res, 200, { ok: true });
|
|
@@ -580,7 +591,10 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
580
591
|
const body = await readJson(req);
|
|
581
592
|
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
582
593
|
const vault = cfg.vault;
|
|
583
|
-
|
|
594
|
+
// Judge the real file, so a link inside the notes folder cannot open a document outside it.
|
|
595
|
+
const real = realish(target);
|
|
596
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
597
|
+
if (!inside || !existsSync(target))
|
|
584
598
|
return send(res, 404, { error: "not found in the notes folder" });
|
|
585
599
|
// Documents only: a file that the model wrote must never be run.
|
|
586
600
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|