@tiagojct/subsub 0.11.1 → 0.11.3
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 +2 -2
- package/dist/bridge.js +5 -1
- package/dist/config.js +15 -1
- package/dist/doctor.js +7 -3
- package/dist/init.js +15 -8
- package/dist/paths.js +14 -7
- package/dist/preview.js +7 -2
- package/dist/subsub.js +41 -5
- package/dist/web.js +18 -5
- package/package.json +1 -1
- package/src/bridge.ts +4 -1
- package/src/config.ts +14 -1
- package/src/doctor.ts +6 -2
- package/src/init.ts +18 -12
- package/src/paths.ts +14 -6
- package/src/preview.ts +8 -2
- package/src/subsub.ts +34 -8
- package/src/web.ts +15 -5
- package/web/app.css +1 -0
- package/web/app.js +19 -4
- package/web/i18n.js +10 -4
package/README.md
CHANGED
|
@@ -25,8 +25,8 @@ Each mode can have its own model. Switch with `/researcher` and `/librarian`, or
|
|
|
25
25
|
Every library change works the same way in both modes:
|
|
26
26
|
|
|
27
27
|
- The Zotero server computes the change first. Sub-Sub shows it to you (added tags in green, removed tags in red) and applies it only when you select Yes. The check is in the program, so the model cannot skip it.
|
|
28
|
-
- Each change sends the item's version, so
|
|
29
|
-
- Every change is journaled. `/undo` reverts it.
|
|
28
|
+
- If you edit an item in Zotero while the preview is open, Sub-Sub shows you the new preview before it changes anything. Each change also sends the item's version, so a field you edit at the moment of writing is not overwritten.
|
|
29
|
+
- Every change to an item is journaled. `/undo` reverts it. (A new collection is not; remove it in Zotero if you do not want it.)
|
|
30
30
|
- Nothing is deleted: items go to the Zotero trash. Only tags from your tag list can be added.
|
|
31
31
|
|
|
32
32
|
## Profiles
|
package/dist/bridge.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
import { refreshedArgs } from "./config.js";
|
|
9
10
|
function withTimeout(p, ms, what) {
|
|
10
11
|
return new Promise((resolve, reject) => {
|
|
11
12
|
const t = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s`)), ms);
|
|
@@ -56,13 +57,16 @@ export class Bridge {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
catch (err) {
|
|
59
|
-
this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
60
60
|
try {
|
|
61
61
|
await client.close();
|
|
62
62
|
}
|
|
63
63
|
catch {
|
|
64
64
|
/* ignore */
|
|
65
65
|
}
|
|
66
|
+
const retry = refreshedArgs(spec.args, stderr);
|
|
67
|
+
if (retry)
|
|
68
|
+
return this.startOne({ ...spec, args: retry }, timeoutMs);
|
|
69
|
+
this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
has(fullName) {
|
package/dist/config.js
CHANGED
|
@@ -33,7 +33,7 @@ export const DEFAULT_MODELS = {
|
|
|
33
33
|
};
|
|
34
34
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
35
35
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
36
|
-
export const SERVER_VERSION = "0.5.
|
|
36
|
+
export const SERVER_VERSION = "0.5.3";
|
|
37
37
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
38
38
|
export const STARBUCK_VERSION = "0.2.0";
|
|
39
39
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
@@ -171,6 +171,20 @@ export function serverCommand(cfg, program, args = [], env = process.env) {
|
|
|
171
171
|
}
|
|
172
172
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* uv caches the PyPI index; right after a release it may not know the pinned version yet
|
|
176
|
+
* ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
|
|
177
|
+
* the index for that package refreshed. Returns undefined when a retry would not help.
|
|
178
|
+
*/
|
|
179
|
+
export function refreshedArgs(args, stderr) {
|
|
180
|
+
if (!/there is no version of|no solution found/i.test(stderr))
|
|
181
|
+
return undefined;
|
|
182
|
+
const i = args.indexOf("--from");
|
|
183
|
+
if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package"))
|
|
184
|
+
return undefined;
|
|
185
|
+
const pkg = args[i + 1].split(/[=<>!~]/)[0];
|
|
186
|
+
return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
|
|
187
|
+
}
|
|
174
188
|
export function starbuckEnabled(cfg) {
|
|
175
189
|
return (cfg.addons ?? []).includes("starbuck");
|
|
176
190
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, } from "./config.js";
|
|
13
|
+
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, refreshedArgs, } from "./config.js";
|
|
14
14
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
|
|
15
15
|
import { profileSpec } from "./profiles.js";
|
|
16
16
|
export const defaultRunner = (command, args, env) => {
|
|
@@ -50,7 +50,11 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
50
50
|
out.push({ name: "uv", ok: true, detail: uvRun.stdout.trim() });
|
|
51
51
|
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
52
52
|
const local = cmd.args.includes("--directory");
|
|
53
|
-
const
|
|
53
|
+
const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
|
|
54
|
+
let res = run(cmd.command, cmd.args, serverEnv);
|
|
55
|
+
const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
|
|
56
|
+
if (retry)
|
|
57
|
+
res = run(cmd.command, retry, serverEnv);
|
|
54
58
|
let st;
|
|
55
59
|
try {
|
|
56
60
|
st = JSON.parse(res.stdout);
|
|
@@ -106,7 +110,7 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
106
110
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
107
111
|
out.push(hasLogin(agentDir)
|
|
108
112
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
|
|
109
|
-
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "
|
|
113
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." });
|
|
110
114
|
return out;
|
|
111
115
|
}
|
|
112
116
|
/** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
|
package/dist/init.js
CHANGED
|
@@ -88,12 +88,6 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
88
88
|
io.say("");
|
|
89
89
|
io.say(`${title}. ${text}`);
|
|
90
90
|
};
|
|
91
|
-
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
92
|
-
const setup = await io.choose("Setup", [
|
|
93
|
-
{ value: "standard", label: "Standard" },
|
|
94
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
95
|
-
], flags.setup ?? existing.setup ?? "standard");
|
|
96
|
-
const fmup = setup === "fmup";
|
|
97
91
|
explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
|
|
98
92
|
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
99
93
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
@@ -107,6 +101,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
107
101
|
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
108
102
|
if (notes !== answer)
|
|
109
103
|
io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
104
|
+
else if (notes !== folderDefault)
|
|
105
|
+
io.say(`Sub-Sub folder: ${notes}`);
|
|
110
106
|
// ---- files of the old layout
|
|
111
107
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
112
108
|
const moves = planMoves(from, notes);
|
|
@@ -128,6 +124,13 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
128
124
|
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
129
125
|
}
|
|
130
126
|
}
|
|
127
|
+
// Asked here, not first: it only changes the defaults of the next two questions.
|
|
128
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
|
|
129
|
+
const setup = await io.choose("Set-up", [
|
|
130
|
+
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
131
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
132
|
+
], flags.setup ?? existing.setup ?? "standard");
|
|
133
|
+
const fmup = setup === "fmup";
|
|
131
134
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
132
135
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
133
136
|
const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
@@ -232,6 +235,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
232
235
|
if (fmup)
|
|
233
236
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
234
237
|
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.");
|
|
238
|
+
if (tags === "any-field")
|
|
239
|
+
io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
235
240
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
236
241
|
}
|
|
237
242
|
export function parseFlags(args) {
|
|
@@ -287,8 +292,10 @@ function terminalIO() {
|
|
|
287
292
|
}
|
|
288
293
|
export async function initMain(args, env = process.env) {
|
|
289
294
|
const flags = parseFlags(args);
|
|
290
|
-
if (!process.stdin.isTTY)
|
|
291
|
-
flags.yes
|
|
295
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
296
|
+
flags.yes = "true";
|
|
297
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
298
|
+
}
|
|
292
299
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
293
300
|
try {
|
|
294
301
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
package/dist/paths.js
CHANGED
|
@@ -46,10 +46,14 @@ export function realish(abs) {
|
|
|
46
46
|
}
|
|
47
47
|
return tail.length ? join(real, ...tail) : real;
|
|
48
48
|
}
|
|
49
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
50
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
49
51
|
export function within(child, parent) {
|
|
50
52
|
if (!parent)
|
|
51
53
|
return false;
|
|
52
|
-
|
|
54
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
55
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
56
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
53
57
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
54
58
|
}
|
|
55
59
|
export function buildPolicy(opts) {
|
|
@@ -81,19 +85,22 @@ export function judgePath(target, policy) {
|
|
|
81
85
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
82
86
|
if (hit)
|
|
83
87
|
return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
88
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
89
|
+
if (!root)
|
|
90
|
+
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
84
91
|
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
85
|
-
|
|
92
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
93
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
86
94
|
if (hidden)
|
|
87
95
|
return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
88
|
-
|
|
89
|
-
return { ok: true };
|
|
90
|
-
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
96
|
+
return { ok: true };
|
|
91
97
|
}
|
|
92
98
|
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
93
99
|
export function secretPaths() {
|
|
94
100
|
const home = realish(homedir());
|
|
95
101
|
return [
|
|
96
102
|
join(home, ".subsub", "agent", "auth.json"),
|
|
103
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d) => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
97
104
|
join(home, ".pi", "agent", "auth.json"),
|
|
98
105
|
join(home, ".config", "zotero-local-mcp", "env"),
|
|
99
106
|
join(home, ".ssh"),
|
|
@@ -102,6 +109,6 @@ export function secretPaths() {
|
|
|
102
109
|
];
|
|
103
110
|
}
|
|
104
111
|
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
105
|
-
export function isSecret(target, recursive = false) {
|
|
106
|
-
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
112
|
+
export function isSecret(target, recursive = false, envFile) {
|
|
113
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
107
114
|
}
|
package/dist/preview.js
CHANGED
|
@@ -7,6 +7,11 @@ function s(v, n = 90) {
|
|
|
7
7
|
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
8
8
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
9
9
|
}
|
|
10
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
11
|
+
function whole(v, n = 3000) {
|
|
12
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
13
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
14
|
+
}
|
|
10
15
|
function list(rows, total) {
|
|
11
16
|
const out = rows.slice(0, MAX_LINES);
|
|
12
17
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -25,7 +30,7 @@ function describeChange(c) {
|
|
|
25
30
|
continue;
|
|
26
31
|
if (v && typeof v === "object" && "after" in v) {
|
|
27
32
|
const b = v.before;
|
|
28
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
33
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -69,7 +74,7 @@ export function formatPreview(tool, preview) {
|
|
|
69
74
|
if (p.item || p.parent)
|
|
70
75
|
lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
71
76
|
if (p.summary || p.note_preview)
|
|
72
|
-
lines.push(
|
|
77
|
+
lines.push(whole(p.summary ?? p.note_preview));
|
|
73
78
|
if (p.link)
|
|
74
79
|
lines.push(`Link: ${p.link}`);
|
|
75
80
|
}
|
package/dist/subsub.js
CHANGED
|
@@ -245,14 +245,20 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
245
245
|
registerTools(b);
|
|
246
246
|
}
|
|
247
247
|
let restored;
|
|
248
|
-
|
|
248
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
249
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
250
|
+
let hasModel = false;
|
|
251
|
+
let hasMessages = false;
|
|
249
252
|
for (const e of ctx.sessionManager.getBranch()) {
|
|
250
253
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
251
254
|
if (m === "librarian" || m === "researcher")
|
|
252
255
|
restored = m;
|
|
253
256
|
if (e.type === "model_change")
|
|
254
|
-
|
|
257
|
+
hasModel = true;
|
|
258
|
+
if (e.type === "message")
|
|
259
|
+
hasMessages = true;
|
|
255
260
|
}
|
|
261
|
+
const modelChosen = hasModel && hasMessages;
|
|
256
262
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
257
263
|
if (lookOn(ctx))
|
|
258
264
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
@@ -265,6 +271,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
265
271
|
else if (webView) {
|
|
266
272
|
void refreshLibrary();
|
|
267
273
|
}
|
|
274
|
+
if (!ctx.model && !webView)
|
|
275
|
+
ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
268
276
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
269
277
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
270
278
|
}
|
|
@@ -326,7 +334,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
326
334
|
return { block: true, reason: why };
|
|
327
335
|
}
|
|
328
336
|
}
|
|
329
|
-
|
|
337
|
+
// grep, find and ls search the working folder when no path is given.
|
|
338
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
330
339
|
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
331
340
|
}
|
|
332
341
|
if (name.startsWith("verify_"))
|
|
@@ -353,6 +362,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
353
362
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
354
363
|
}
|
|
355
364
|
if (kind === "confirm") {
|
|
365
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
366
|
+
if (typeof input.output_path === "string") {
|
|
367
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
368
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
369
|
+
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
370
|
+
}
|
|
356
371
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
357
372
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
358
373
|
}
|
|
@@ -368,8 +383,29 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
368
383
|
}
|
|
369
384
|
if (preview.isError)
|
|
370
385
|
return { block: true, reason: preview.text };
|
|
371
|
-
const
|
|
372
|
-
|
|
386
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
387
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
388
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
389
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
390
|
+
for (let round = 0; round < 3; round++) {
|
|
391
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
392
|
+
if (!ok)
|
|
393
|
+
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
394
|
+
let again;
|
|
395
|
+
try {
|
|
396
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return { block: true, reason: `Preview failed: ${err.message}` };
|
|
400
|
+
}
|
|
401
|
+
if (again.isError)
|
|
402
|
+
return { block: true, reason: again.text };
|
|
403
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
404
|
+
if (now === shown)
|
|
405
|
+
return undefined;
|
|
406
|
+
shown = now;
|
|
407
|
+
}
|
|
408
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
373
409
|
});
|
|
374
410
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
375
411
|
function literatureNoteProblem(name, target, input) {
|
package/dist/web.js
CHANGED
|
@@ -412,8 +412,12 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
412
412
|
for (const id of dialogs.keys())
|
|
413
413
|
broadcast({ type: "dialog_closed", id });
|
|
414
414
|
dialogs.clear();
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
try {
|
|
416
|
+
await agent.stop();
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
restarting = false;
|
|
420
|
+
}
|
|
417
421
|
busy = false;
|
|
418
422
|
startAgent();
|
|
419
423
|
broadcast(await snapshot());
|
|
@@ -520,7 +524,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
520
524
|
const message = String(body.message ?? "").trim();
|
|
521
525
|
if (!message)
|
|
522
526
|
return send(res, 400, { error: "empty message" });
|
|
523
|
-
|
|
527
|
+
try {
|
|
528
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
532
|
+
if (/no api key|no model|no models available/i.test(err.message))
|
|
533
|
+
return send(res, 409, { error: "nomodel" });
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
524
536
|
return send(res, 200, { ok: true });
|
|
525
537
|
}
|
|
526
538
|
case "POST /api/abort":
|
|
@@ -585,9 +597,10 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
585
597
|
const models = { ...saved, [mode]: spec };
|
|
586
598
|
if (body.both)
|
|
587
599
|
models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
588
|
-
saveConfig({ models, model: undefined }, env);
|
|
589
600
|
return withRestart(res, async () => {
|
|
590
|
-
|
|
601
|
+
saveConfig({ models, model: undefined }, env);
|
|
602
|
+
// The open conversation records the new model, so it keeps it after the restart.
|
|
603
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => { });
|
|
591
604
|
});
|
|
592
605
|
}
|
|
593
606
|
case "POST /api/addons": {
|
package/package.json
CHANGED
package/src/bridge.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
9
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { refreshedArgs } from "./config.ts";
|
|
10
11
|
|
|
11
12
|
export interface ServerSpec {
|
|
12
13
|
name: string; // tool prefix: "zotero" or "scholar"
|
|
@@ -85,12 +86,14 @@ export class Bridge {
|
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
} catch (err) {
|
|
88
|
-
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
89
89
|
try {
|
|
90
90
|
await client.close();
|
|
91
91
|
} catch {
|
|
92
92
|
/* ignore */
|
|
93
93
|
}
|
|
94
|
+
const retry = refreshedArgs(spec.args, stderr);
|
|
95
|
+
if (retry) return this.startOne({ ...spec, args: retry }, timeoutMs);
|
|
96
|
+
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
package/src/config.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
|
39
39
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
40
40
|
|
|
41
41
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
42
|
-
export const SERVER_VERSION = "0.5.
|
|
42
|
+
export const SERVER_VERSION = "0.5.3";
|
|
43
43
|
|
|
44
44
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
45
45
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -225,6 +225,19 @@ export function serverCommand(
|
|
|
225
225
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* uv caches the PyPI index; right after a release it may not know the pinned version yet
|
|
230
|
+
* ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
|
|
231
|
+
* the index for that package refreshed. Returns undefined when a retry would not help.
|
|
232
|
+
*/
|
|
233
|
+
export function refreshedArgs(args: string[], stderr: string): string[] | undefined {
|
|
234
|
+
if (!/there is no version of|no solution found/i.test(stderr)) return undefined;
|
|
235
|
+
const i = args.indexOf("--from");
|
|
236
|
+
if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package")) return undefined;
|
|
237
|
+
const pkg = args[i + 1].split(/[=<>!~]/)[0];
|
|
238
|
+
return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
|
|
239
|
+
}
|
|
240
|
+
|
|
228
241
|
export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
|
|
229
242
|
return (cfg.addons ?? []).includes("starbuck");
|
|
230
243
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
starbuckCommand,
|
|
21
21
|
starbuckEnabled,
|
|
22
22
|
type SubsubConfig,
|
|
23
|
+
refreshedArgs,
|
|
23
24
|
} from "./config.ts";
|
|
24
25
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
25
26
|
import { profileSpec } from "./profiles.ts";
|
|
@@ -92,7 +93,10 @@ export function runChecks(
|
|
|
92
93
|
|
|
93
94
|
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
94
95
|
const local = cmd.args.includes("--directory");
|
|
95
|
-
const
|
|
96
|
+
const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
|
|
97
|
+
let res = run(cmd.command, cmd.args, serverEnv);
|
|
98
|
+
const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
|
|
99
|
+
if (retry) res = run(cmd.command, retry, serverEnv);
|
|
96
100
|
let st: Record<string, any> | undefined;
|
|
97
101
|
try {
|
|
98
102
|
st = JSON.parse(res.stdout);
|
|
@@ -150,7 +154,7 @@ export function runChecks(
|
|
|
150
154
|
out.push(
|
|
151
155
|
hasLogin(agentDir)
|
|
152
156
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
|
|
153
|
-
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "
|
|
157
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." },
|
|
154
158
|
);
|
|
155
159
|
return out;
|
|
156
160
|
}
|
package/src/init.ts
CHANGED
|
@@ -128,17 +128,6 @@ export async function runInit(
|
|
|
128
128
|
io.say(`${title}. ${text}`);
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
-
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
132
|
-
const setup = await io.choose(
|
|
133
|
-
"Setup",
|
|
134
|
-
[
|
|
135
|
-
{ value: "standard", label: "Standard" },
|
|
136
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
137
|
-
],
|
|
138
|
-
flags.setup ?? existing.setup ?? "standard",
|
|
139
|
-
);
|
|
140
|
-
const fmup = setup === "fmup";
|
|
141
|
-
|
|
142
131
|
explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
|
|
143
132
|
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
144
133
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
@@ -155,6 +144,7 @@ export async function runInit(
|
|
|
155
144
|
const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
|
|
156
145
|
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
157
146
|
if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
147
|
+
else if (notes !== folderDefault) io.say(`Sub-Sub folder: ${notes}`);
|
|
158
148
|
|
|
159
149
|
// ---- files of the old layout
|
|
160
150
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
@@ -183,6 +173,18 @@ export async function runInit(
|
|
|
183
173
|
}
|
|
184
174
|
}
|
|
185
175
|
|
|
176
|
+
// Asked here, not first: it only changes the defaults of the next two questions.
|
|
177
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
|
|
178
|
+
const setup = await io.choose(
|
|
179
|
+
"Set-up",
|
|
180
|
+
[
|
|
181
|
+
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
182
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
183
|
+
],
|
|
184
|
+
flags.setup ?? existing.setup ?? "standard",
|
|
185
|
+
);
|
|
186
|
+
const fmup = setup === "fmup";
|
|
187
|
+
|
|
186
188
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
187
189
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
188
190
|
const profile = await io.choose(
|
|
@@ -301,6 +303,7 @@ export async function runInit(
|
|
|
301
303
|
if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
302
304
|
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
303
305
|
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.");
|
|
306
|
+
if (tags === "any-field") io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
304
307
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
305
308
|
}
|
|
306
309
|
|
|
@@ -354,7 +357,10 @@ function terminalIO(): InitIO & { close(): void } {
|
|
|
354
357
|
|
|
355
358
|
export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
356
359
|
const flags = parseFlags(args);
|
|
357
|
-
if (!process.stdin.isTTY
|
|
360
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
361
|
+
flags.yes = "true";
|
|
362
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
363
|
+
}
|
|
358
364
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
359
365
|
try {
|
|
360
366
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
package/src/paths.ts
CHANGED
|
@@ -45,9 +45,14 @@ export function realish(abs: string): string {
|
|
|
45
45
|
return tail.length ? join(real, ...tail) : real;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
49
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
50
|
+
|
|
48
51
|
export function within(child: string, parent: string | undefined): boolean {
|
|
49
52
|
if (!parent) return false;
|
|
50
|
-
|
|
53
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
54
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
55
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
51
56
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -92,11 +97,13 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
|
|
|
92
97
|
export function judgePath(target: string, policy: PathPolicy): PathVerdict {
|
|
93
98
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
94
99
|
if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
100
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
101
|
+
if (!root) return { ok: false, why: "it is outside the vault and the working folder" };
|
|
95
102
|
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
96
|
-
|
|
103
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
104
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
97
105
|
if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
98
|
-
|
|
99
|
-
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
106
|
+
return { ok: true };
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
@@ -104,6 +111,7 @@ export function secretPaths(): string[] {
|
|
|
104
111
|
const home = realish(homedir());
|
|
105
112
|
return [
|
|
106
113
|
join(home, ".subsub", "agent", "auth.json"),
|
|
114
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d): d is string => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
107
115
|
join(home, ".pi", "agent", "auth.json"),
|
|
108
116
|
join(home, ".config", "zotero-local-mcp", "env"),
|
|
109
117
|
join(home, ".ssh"),
|
|
@@ -113,6 +121,6 @@ export function secretPaths(): string[] {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
116
|
-
export function isSecret(target: string, recursive = false): boolean {
|
|
117
|
-
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
124
|
+
export function isSecret(target: string, recursive = false, envFile?: string): boolean {
|
|
125
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
118
126
|
}
|
package/src/preview.ts
CHANGED
|
@@ -12,6 +12,12 @@ function s(v: unknown, n = 90): string {
|
|
|
12
12
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
16
|
+
function whole(v: unknown, n = 3000): string {
|
|
17
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
18
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function list(rows: string[], total?: number): string[] {
|
|
16
22
|
const out = rows.slice(0, MAX_LINES);
|
|
17
23
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -27,7 +33,7 @@ function describeChange(c: Obj): string {
|
|
|
27
33
|
if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
|
|
28
34
|
if (v && typeof v === "object" && "after" in (v as Obj)) {
|
|
29
35
|
const b = (v as Obj).before;
|
|
30
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
36
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -83,7 +89,7 @@ export function formatPreview(tool: string, preview: unknown): string {
|
|
|
83
89
|
}
|
|
84
90
|
if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
|
|
85
91
|
if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
86
|
-
if (p.summary || p.note_preview) lines.push(
|
|
92
|
+
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
|
|
87
93
|
if (p.link) lines.push(`Link: ${p.link}`);
|
|
88
94
|
}
|
|
89
95
|
const skipped = p.skipped as Obj | undefined;
|
package/src/subsub.ts
CHANGED
|
@@ -276,12 +276,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
276
276
|
registerTools(b);
|
|
277
277
|
}
|
|
278
278
|
let restored: Mode | undefined;
|
|
279
|
-
|
|
279
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
280
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
281
|
+
let hasModel = false;
|
|
282
|
+
let hasMessages = false;
|
|
280
283
|
for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
|
|
281
284
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
282
285
|
if (m === "librarian" || m === "researcher") restored = m;
|
|
283
|
-
if (e.type === "model_change")
|
|
286
|
+
if (e.type === "model_change") hasModel = true;
|
|
287
|
+
if (e.type === "message") hasMessages = true;
|
|
284
288
|
}
|
|
289
|
+
const modelChosen = hasModel && hasMessages;
|
|
285
290
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
286
291
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
287
292
|
applyTheme(ctx);
|
|
@@ -292,6 +297,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
292
297
|
} else if (webView) {
|
|
293
298
|
void refreshLibrary();
|
|
294
299
|
}
|
|
300
|
+
if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
295
301
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
296
302
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
297
303
|
}
|
|
@@ -355,7 +361,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
355
361
|
return { block: true, reason: why };
|
|
356
362
|
}
|
|
357
363
|
}
|
|
358
|
-
|
|
364
|
+
// grep, find and ls search the working folder when no path is given.
|
|
365
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
359
366
|
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
360
367
|
}
|
|
361
368
|
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
@@ -376,6 +383,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
376
383
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
377
384
|
}
|
|
378
385
|
if (kind === "confirm") {
|
|
386
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
387
|
+
if (typeof input.output_path === "string") {
|
|
388
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
389
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
390
|
+
}
|
|
379
391
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
380
392
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
381
393
|
}
|
|
@@ -389,11 +401,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
389
401
|
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
390
402
|
}
|
|
391
403
|
if (preview.isError) return { block: true, reason: preview.text };
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
)
|
|
396
|
-
|
|
404
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
405
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
406
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
407
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
408
|
+
for (let round = 0; round < 3; round++) {
|
|
409
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
410
|
+
if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
411
|
+
let again;
|
|
412
|
+
try {
|
|
413
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
416
|
+
}
|
|
417
|
+
if (again.isError) return { block: true, reason: again.text };
|
|
418
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
419
|
+
if (now === shown) return undefined;
|
|
420
|
+
shown = now;
|
|
421
|
+
}
|
|
422
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
397
423
|
});
|
|
398
424
|
|
|
399
425
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
package/src/web.ts
CHANGED
|
@@ -444,8 +444,11 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
444
444
|
restarting = true;
|
|
445
445
|
for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
|
|
446
446
|
dialogs.clear();
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
try {
|
|
448
|
+
await agent.stop();
|
|
449
|
+
} finally {
|
|
450
|
+
restarting = false;
|
|
451
|
+
}
|
|
449
452
|
busy = false;
|
|
450
453
|
startAgent();
|
|
451
454
|
broadcast(await snapshot());
|
|
@@ -547,7 +550,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
547
550
|
const body = await readJson(req);
|
|
548
551
|
const message = String(body.message ?? "").trim();
|
|
549
552
|
if (!message) return send(res, 400, { error: "empty message" });
|
|
550
|
-
|
|
553
|
+
try {
|
|
554
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
557
|
+
if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
551
560
|
return send(res, 200, { ok: true });
|
|
552
561
|
}
|
|
553
562
|
case "POST /api/abort":
|
|
@@ -601,9 +610,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
601
610
|
const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
|
|
602
611
|
const models: Record<string, string> = { ...saved, [mode]: spec };
|
|
603
612
|
if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
604
|
-
saveConfig({ models, model: undefined }, env);
|
|
605
613
|
return withRestart(res, async () => {
|
|
606
|
-
|
|
614
|
+
saveConfig({ models, model: undefined }, env);
|
|
615
|
+
// The open conversation records the new model, so it keeps it after the restart.
|
|
616
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => {});
|
|
607
617
|
});
|
|
608
618
|
}
|
|
609
619
|
case "POST /api/addons": {
|
package/web/app.css
CHANGED
|
@@ -158,6 +158,7 @@ button:disabled { opacity: 0.45; cursor: default; }
|
|
|
158
158
|
.icon-btn:hover { color: var(--text); background: var(--surface); }
|
|
159
159
|
.icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
|
|
160
160
|
|
|
161
|
+
.model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
|
|
161
162
|
.chip-btn {
|
|
162
163
|
max-width: 16rem;
|
|
163
164
|
overflow: hidden;
|
package/web/app.js
CHANGED
|
@@ -183,7 +183,9 @@ function renderHeader() {
|
|
|
183
183
|
$("starbuck-toggle").checked = Boolean(state?.starbuck);
|
|
184
184
|
$("starbuck-label").title = t("starbuckHint");
|
|
185
185
|
const m = state?.model ?? session?.model;
|
|
186
|
-
|
|
186
|
+
const none = !m || !m.id || m.id === "unknown";
|
|
187
|
+
$("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
|
|
188
|
+
for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
|
|
187
189
|
const lib = $("library");
|
|
188
190
|
const txt = lib.querySelector(".library-text") ?? lib;
|
|
189
191
|
const L = state?.library;
|
|
@@ -205,8 +207,10 @@ function renderHeader() {
|
|
|
205
207
|
txt.textContent += ` · ${t("serversDown", { x: state.down.join(", ") })}`;
|
|
206
208
|
lib.classList.add("down");
|
|
207
209
|
}
|
|
208
|
-
lib.title = txt.textContent;
|
|
209
|
-
|
|
210
|
+
lib.title = L?.toReview ? `${txt.textContent}\n${t("toReviewHint")}` : txt.textContent;
|
|
211
|
+
// On a phone the long hint does not fit in the box.
|
|
212
|
+
const narrow = window.matchMedia("(max-width: 560px)").matches;
|
|
213
|
+
$("input").placeholder = narrow ? t("placeholderShort") : mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
|
|
210
214
|
if (log.querySelector(".empty")) {
|
|
211
215
|
clearEmpty();
|
|
212
216
|
renderEmpty();
|
|
@@ -862,7 +866,8 @@ function nextDialog() {
|
|
|
862
866
|
dlg.onkeydown = (e) => {
|
|
863
867
|
if (d.method === "confirm") {
|
|
864
868
|
if (e.repeat || performance.now() - openedAt < 700) {
|
|
865
|
-
|
|
869
|
+
// Escape still declines (through oncancel); Enter and Space would press the focused button.
|
|
870
|
+
if (e.key !== "Escape" && e.key !== "Tab") e.preventDefault();
|
|
866
871
|
return;
|
|
867
872
|
}
|
|
868
873
|
if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
|
|
@@ -981,6 +986,16 @@ async function sendMessage(text) {
|
|
|
981
986
|
return true;
|
|
982
987
|
} catch (err) {
|
|
983
988
|
if (isTemplate) sentTemplates.pop();
|
|
989
|
+
if (err.message === "nomodel") {
|
|
990
|
+
// Keep what the user wrote, and show where to connect a provider.
|
|
991
|
+
if (!input.value.trim()) {
|
|
992
|
+
input.value = message;
|
|
993
|
+
autosize();
|
|
994
|
+
}
|
|
995
|
+
note(t("noModelSend"), "warning");
|
|
996
|
+
showModels();
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
984
999
|
note(err.message, "error");
|
|
985
1000
|
return false;
|
|
986
1001
|
}
|
package/web/i18n.js
CHANGED
|
@@ -5,7 +5,8 @@ export const STRINGS = {
|
|
|
5
5
|
en: {
|
|
6
6
|
profile: "Profile",
|
|
7
7
|
model: "Model",
|
|
8
|
-
noModel: "
|
|
8
|
+
noModel: "Connect a model",
|
|
9
|
+
noModelSend: "No model is connected yet. Choose a provider and save its key; your message is still in the box.",
|
|
9
10
|
newConversation: "New conversation",
|
|
10
11
|
actions: "Actions",
|
|
11
12
|
conversations: "Conversations",
|
|
@@ -26,7 +27,8 @@ export const STRINGS = {
|
|
|
26
27
|
zoteroChecking: "Zotero: checking",
|
|
27
28
|
zoteroDown: "Zotero is not running",
|
|
28
29
|
items: "{n} items",
|
|
29
|
-
toReview: "{n} to
|
|
30
|
+
toReview: "{n} to check",
|
|
31
|
+
toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
|
|
30
32
|
serversDown: "Not running: {x}",
|
|
31
33
|
working: "Working",
|
|
32
34
|
using: "Using: {x}",
|
|
@@ -88,6 +90,7 @@ export const STRINGS = {
|
|
|
88
90
|
librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
|
|
89
91
|
placeholderResearcher: "Ask about your library or the literature, or type / for commands",
|
|
90
92
|
placeholderLibrarian: "Ask for a change to your library, or type / for commands",
|
|
93
|
+
placeholderShort: "Ask, or type / for commands",
|
|
91
94
|
emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
|
|
92
95
|
emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
|
|
93
96
|
forBoth: "Use it in both modes",
|
|
@@ -97,7 +100,8 @@ export const STRINGS = {
|
|
|
97
100
|
pt: {
|
|
98
101
|
profile: "Perfil",
|
|
99
102
|
model: "Modelo",
|
|
100
|
-
noModel: "
|
|
103
|
+
noModel: "Ligar um modelo",
|
|
104
|
+
noModelSend: "Ainda não há um modelo ligado. Escolha um fornecedor e guarde a chave; a sua mensagem continua na caixa.",
|
|
101
105
|
newConversation: "Nova conversa",
|
|
102
106
|
actions: "Ações",
|
|
103
107
|
conversations: "Conversas",
|
|
@@ -118,7 +122,8 @@ export const STRINGS = {
|
|
|
118
122
|
zoteroChecking: "Zotero: a verificar",
|
|
119
123
|
zoteroDown: "O Zotero não está aberto",
|
|
120
124
|
items: "{n} itens",
|
|
121
|
-
toReview: "{n} por
|
|
125
|
+
toReview: "{n} por verificar",
|
|
126
|
+
toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
|
|
122
127
|
serversDown: "Parados: {x}",
|
|
123
128
|
working: "A trabalhar",
|
|
124
129
|
using: "A usar: {x}",
|
|
@@ -180,6 +185,7 @@ export const STRINGS = {
|
|
|
180
185
|
librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
|
|
181
186
|
placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
|
|
182
187
|
placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
|
|
188
|
+
placeholderShort: "Pergunte, ou escreva / para os comandos",
|
|
183
189
|
emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
|
|
184
190
|
emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
|
|
185
191
|
forBoth: "Usar nos dois modos",
|