@tiagojct/subsub 0.8.0 → 0.9.1
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 +6 -6
- package/dist/args.js +23 -0
- package/dist/config.js +7 -4
- package/dist/doctor.js +17 -3
- package/dist/init.js +90 -25
- package/dist/layout.js +100 -0
- package/dist/paths.js +2 -1
- package/dist/prompt.js +3 -3
- package/dist/subsub.js +2 -1
- package/dist/web.js +4 -3
- package/docs/Subsub.md +10 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/digest.md +1 -1
- package/prompts/lit.md +2 -2
- package/prompts/request-copy.md +1 -1
- package/roles/researcher.md +2 -2
- package/src/args.ts +24 -0
- package/src/config.ts +9 -4
- package/src/doctor.ts +18 -5
- package/src/init.ts +102 -29
- package/src/layout.ts +111 -0
- package/src/paths.ts +2 -5
- package/src/prompt.ts +3 -3
- package/src/subsub.ts +2 -1
- package/src/web.ts +4 -3
package/README.md
CHANGED
|
@@ -18,8 +18,8 @@ A Zotero librarian and research assistant that runs on your computer, in your br
|
|
|
18
18
|
* **Code-Enforced Safety Gate**: The model **cannot** modify your library without your explicit approval. Every change is calculated first as a dry-run preview (+added in green, -removed in red) and executed only when you confirm.
|
|
19
19
|
* **Full Journal & Reversible Edits**: Every library modification is recorded with a unique journal ID and can be reverted anytime using `/undo`.
|
|
20
20
|
* **Two Dedicated Roles**:
|
|
21
|
-
* 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *
|
|
22
|
-
* 🔬 **The Researcher**: Reads items, searches PubMed and OpenAlex, finds gaps, checks citations in manuscripts, and writes structured Markdown notes in your
|
|
21
|
+
* 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *Has no web search tools; imports and PDFs come from Crossref, Unpaywall and Open Library.*
|
|
22
|
+
* 🔬 **The Researcher**: Reads items, searches PubMed and OpenAlex, finds gaps, checks citations in manuscripts, and writes structured Markdown notes in your Sub-Sub folder (it can sit inside an Obsidian vault). *Cannot modify library records.*
|
|
23
23
|
* **Local & Private**: Listens strictly on `127.0.0.1` protected by an authenticated single-origin session cookie. Your library contents remain on your computer.
|
|
24
24
|
* **Modern Web Interface & Terminal UI**: Use Sub-Sub in your browser with desktop shortcuts (`~/Applications/Sub-Sub.app`, Start menu, or `.desktop`), or launch it right in your terminal with adaptive color themes.
|
|
25
25
|
|
|
@@ -77,7 +77,7 @@ Automated installers handle Node.js, uv, and desktop shortcuts:
|
|
|
77
77
|
```sh
|
|
78
78
|
subsub init
|
|
79
79
|
```
|
|
80
|
-
Answer the interactive prompts (press <kbd>Enter</kbd> to accept defaults). This creates your
|
|
80
|
+
Answer the interactive prompts (press <kbd>Enter</kbd> to accept defaults). This creates your Sub-Sub folder (`~/Documents/Sub-Sub`, or `Sub-Sub` inside your Obsidian vault) with `Inbox/`, `Literature/`, `Syntheses/`, `Research/` and `Zotero/`, sets up a starter tag list (`Zotero/Zotero tags.md`), and writes the note formats (`Zotero/Zotero agent.md`).
|
|
81
81
|
3. Verify your environment:
|
|
82
82
|
```sh
|
|
83
83
|
subsub doctor
|
|
@@ -176,7 +176,7 @@ subsub review apply 13-31
|
|
|
176
176
|
| `userName` | string | Name used by the agent in replies | `"the user"` |
|
|
177
177
|
| `about` | string | Brief background context about your research domain | `null` |
|
|
178
178
|
| `language` | string | Response language, or `"auto"` to match your input | `"English"` |
|
|
179
|
-
| `vault` | string | Path to your
|
|
179
|
+
| `vault` | string | Path to your Sub-Sub folder | `$ZOTERO_VAULT` |
|
|
180
180
|
| `models` | object | Model overrides per mode (e.g. `{"librarian": "opencode-go/glm-5.3-flash"}`) | Current model |
|
|
181
181
|
| `defaultMode`| string | Default launch mode (`"researcher"` or `"librarian"`) | `"researcher"` |
|
|
182
182
|
| `serverDir` | string | Local development path for `zotero-local-mcp` | PyPI package |
|
|
@@ -186,7 +186,7 @@ subsub review apply 13-31
|
|
|
186
186
|
| `addons` | array | Optional servers; `["starbuck"]` adds reference checks | `[]` |
|
|
187
187
|
| `starbuckDir` | string | Local Starbuck folder (used when it has a `pyproject.toml`) | `~/Projects/starbuck` |
|
|
188
188
|
|
|
189
|
-
Server-level configurations (
|
|
189
|
+
Server-level configurations (Sub-Sub folder, tag list, alerts file, Unpaywall email) reside in `~/.config/zotero-local-mcp/env`.
|
|
190
190
|
|
|
191
191
|
### Papers that are not open access
|
|
192
192
|
|
|
@@ -203,7 +203,7 @@ Sub-Sub finds open-access copies (Unpaywall, Europe PMC). For other papers, use
|
|
|
203
203
|
2. Optional: set `"starbuckDir"` to a local copy of Starbuck. Without it, Sub-Sub runs the pinned Starbuck release with uv.
|
|
204
204
|
3. Start Sub-Sub again and run `subsub doctor`. The line `Starbuck` must show `ok`.
|
|
205
205
|
|
|
206
|
-
The researcher gets four tools: `verify_check_manuscript`, `verify_check_references`, `verify_prepare_claims` and `verify_record_claims`. Starbuck writes its report to a `_starbuck` folder next to the manuscript. Inside the
|
|
206
|
+
The researcher gets four tools: `verify_check_manuscript`, `verify_check_references`, `verify_prepare_claims` and `verify_record_claims`. Starbuck writes its report to a `_starbuck` folder next to the manuscript. Inside the Sub-Sub folder or the working folder this needs no approval; elsewhere Sub-Sub asks first. Starbuck gets the contact email from the zotero-local-mcp env file. For the claim check, Sub-Sub's own model judges; Starbuck checks that every quoted passage is in the source.
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
package/dist/args.js
CHANGED
|
@@ -65,3 +65,26 @@ export function coerceArgs(original, input) {
|
|
|
65
65
|
}
|
|
66
66
|
return input;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Arguments that name a file in the user's working folder (a manuscript, a report
|
|
70
|
+
* folder, a bibliography to write). The servers run in their own working folder, so
|
|
71
|
+
* Sub-Sub makes these paths absolute against its own, as pi's file tools read them.
|
|
72
|
+
* Note paths (tag reviews, the import queue, linked notes) are not here: the servers
|
|
73
|
+
* read those from the Sub-Sub folder.
|
|
74
|
+
*/
|
|
75
|
+
export const CWD_PATH_ARGS = {
|
|
76
|
+
scholar_check_manuscript: ["path"],
|
|
77
|
+
scholar_export_bibliography: ["output_path"],
|
|
78
|
+
verify_check_manuscript: ["path", "report_dir"],
|
|
79
|
+
verify_prepare_claims: ["path", "report_dir"],
|
|
80
|
+
verify_record_claims: ["path", "report_dir"],
|
|
81
|
+
};
|
|
82
|
+
/** Make the working-folder paths of a tool call absolute. Changes input in place. */
|
|
83
|
+
export function resolvePathArgs(tool, input, toAbsolute) {
|
|
84
|
+
for (const k of CWD_PATH_ARGS[tool] ?? []) {
|
|
85
|
+
const v = input[k];
|
|
86
|
+
if (typeof v === "string" && v.trim())
|
|
87
|
+
input[k] = toAbsolute(v);
|
|
88
|
+
}
|
|
89
|
+
return input;
|
|
90
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* {
|
|
6
6
|
* "profile": "scholar",
|
|
7
7
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
|
-
* "vault": "~/
|
|
8
|
+
* "vault": "~/Documents/Sub-Sub",
|
|
9
9
|
* "models": { "librarian": "opencode-go/glm-5.3-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
10
10
|
* "defaultMode": "researcher"
|
|
11
11
|
* }
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { isObsidianVault, settingsFile } from "./layout.js";
|
|
24
25
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
25
26
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
26
|
-
export const SERVER_VERSION = "0.5.
|
|
27
|
+
export const SERVER_VERSION = "0.5.1";
|
|
27
28
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
28
29
|
export const STARBUCK_VERSION = "0.2.0";
|
|
29
30
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
@@ -116,11 +117,13 @@ export function loadConfig(env = process.env) {
|
|
|
116
117
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
117
118
|
},
|
|
118
119
|
defaultMode: mode,
|
|
119
|
-
|
|
120
|
+
// A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
|
|
121
|
+
vaultContext: (user.vaultContext ??
|
|
122
|
+
(vault ? [join(vault, ".claude", "CLAUDE.md"), ...(isObsidianVault(dirname(vault)) ? [join(dirname(vault), ".claude", "CLAUDE.md")] : [])] : [])).map((p) => expand(p, home)),
|
|
120
123
|
sharedRules: user.sharedRules
|
|
121
124
|
? expand(user.sharedRules, home)
|
|
122
125
|
: vault
|
|
123
|
-
?
|
|
126
|
+
? settingsFile(vault, "rules")
|
|
124
127
|
: undefined,
|
|
125
128
|
startTimeout: user.startTimeout ?? 45,
|
|
126
129
|
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
package/dist/doctor.js
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, } from "./config.js";
|
|
14
|
+
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
|
|
14
15
|
import { profileSpec } from "./profiles.js";
|
|
15
16
|
export const defaultRunner = (command, args, env) => {
|
|
16
17
|
const r = spawnSync(command, args, { env, encoding: "utf8", timeout: 120_000 });
|
|
@@ -95,9 +96,7 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
95
96
|
: undefined,
|
|
96
97
|
});
|
|
97
98
|
}
|
|
98
|
-
out.push(
|
|
99
|
-
? { name: "Notes folder", ok: true, detail: cfg.vault }
|
|
100
|
-
: { name: "Notes folder", ok: false, detail: cfg.vault ? `${cfg.vault} does not exist` : "not set", fix: "Type subsub init." });
|
|
99
|
+
out.push(folderCheck(cfg.vault));
|
|
101
100
|
out.push(st.contact_email_set
|
|
102
101
|
? { name: "Contact email", ok: true, detail: "set" }
|
|
103
102
|
: { 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." });
|
|
@@ -150,3 +149,18 @@ export async function doctorMain(env = process.env) {
|
|
|
150
149
|
console.log(bad ? `\n${bad} problem(s) to fix.` : "\nEverything needed is in place.");
|
|
151
150
|
return bad ? 1 : 0;
|
|
152
151
|
}
|
|
152
|
+
/** The Sub-Sub folder: it exists, it is not a whole vault, and its settings files are in Zotero/. */
|
|
153
|
+
export function folderCheck(folder) {
|
|
154
|
+
const name = "Sub-Sub folder";
|
|
155
|
+
if (!folder || !existsSync(folder)) {
|
|
156
|
+
return { name, ok: false, detail: folder ? `${folder} does not exist` : "not set", fix: "Type subsub init." };
|
|
157
|
+
}
|
|
158
|
+
if (isObsidianVault(folder)) {
|
|
159
|
+
return { name, ok: true, warn: true, detail: `${folder} is a whole Obsidian vault`, fix: "Type subsub init: it moves Sub-Sub's files into a Sub-Sub folder inside the vault." };
|
|
160
|
+
}
|
|
161
|
+
const legacy = ["rules", "tags", "alerts"].some((w) => settingsFile(folder, w) === join(folder, LEGACY_SETTINGS_DIR, SETTINGS_FILES[w]));
|
|
162
|
+
if (legacy) {
|
|
163
|
+
return { name, ok: true, warn: true, detail: `${folder}; the settings files are still in ${LEGACY_SETTINGS_DIR}/`, fix: "Type subsub init: it moves them to Zotero/." };
|
|
164
|
+
}
|
|
165
|
+
return { name, ok: true, detail: folder };
|
|
166
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -6,14 +6,20 @@
|
|
|
6
6
|
* - the settings file (~/.config/subsub/config.json): profile, name, language,
|
|
7
7
|
* notes folder, models;
|
|
8
8
|
* - the server settings file (~/.config/zotero-local-mcp/env, or the one that
|
|
9
|
-
* already exists):
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* already exists): the Sub-Sub folder, tag list, alerts file, contact email.
|
|
10
|
+
* Other lines stay;
|
|
11
|
+
* - the Sub-Sub folder (see layout.ts): Inbox/, Literature/, Syntheses/,
|
|
12
|
+
* Research/, and Zotero/ with Zotero tags.md (a starter list) and Zotero
|
|
13
|
+
* agent.md (note formats). Existing files are never replaced.
|
|
14
|
+
*
|
|
15
|
+
* An Obsidian vault given as the folder becomes <vault>/Sub-Sub. Files of the old
|
|
16
|
+
* layout (Systems/, or Sub-Sub's notes in the Inbox/ of a whole vault) are moved
|
|
17
|
+
* when the user agrees.
|
|
12
18
|
*
|
|
13
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
14
|
-
* standard|fmup, --name, --about, --language, --
|
|
15
|
-
* health-sciences|health-informatics|any-field,
|
|
16
|
-
* --models keep|later|opencode-go.
|
|
20
|
+
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
|
+
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
17
23
|
*/
|
|
18
24
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
25
|
import { homedir } from "node:os";
|
|
@@ -22,6 +28,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
22
28
|
import { fileURLToPath } from "node:url";
|
|
23
29
|
import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
24
30
|
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
31
|
+
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
|
|
25
32
|
import { PROFILE_SPECS } from "./profiles.js";
|
|
26
33
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
27
34
|
export const STARTER_TAGS = {
|
|
@@ -73,41 +80,89 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
73
80
|
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
74
81
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
75
82
|
const created = [];
|
|
76
|
-
|
|
83
|
+
const first = !existsSync(configFile);
|
|
84
|
+
io.say(first
|
|
85
|
+
? "Sub-Sub setup. A few questions; press Enter to take the value in brackets. To change an answer later, type subsub init again."
|
|
86
|
+
: `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.`);
|
|
87
|
+
const explain = (title, text) => {
|
|
88
|
+
io.say("");
|
|
89
|
+
io.say(`${title}. ${text}`);
|
|
90
|
+
};
|
|
91
|
+
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
77
92
|
const setup = await io.choose("Setup", [
|
|
78
93
|
{ value: "standard", label: "Standard" },
|
|
79
94
|
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
80
95
|
], flags.setup ?? existing.setup ?? "standard");
|
|
81
96
|
const fmup = setup === "fmup";
|
|
82
|
-
|
|
97
|
+
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
|
+
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
83
99
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
84
100
|
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";
|
|
85
|
-
|
|
101
|
+
explain("Sub-Sub folder", `Sub-Sub keeps everything it writes in one folder: Inbox/ (review notes, the import queue, alerts), Literature/, Syntheses/, Research/, and ${SETTINGS_DIR}/ (your tag list and note formats). If you use Obsidian, give the vault folder: Sub-Sub then works in a ${FOLDER_NAME} folder inside the vault and leaves the rest of the vault alone.`);
|
|
102
|
+
const oldFolder = current.vault;
|
|
103
|
+
// Without questions (--yes), an existing folder stays exactly as it is.
|
|
104
|
+
const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
|
|
105
|
+
const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
|
|
106
|
+
const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)).trim() || folderDefault, home);
|
|
107
|
+
const notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
108
|
+
if (notes !== answer)
|
|
109
|
+
io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
110
|
+
// ---- files of the old layout
|
|
111
|
+
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
112
|
+
const moves = planMoves(from, notes);
|
|
113
|
+
let move = "no";
|
|
114
|
+
if (moves.length) {
|
|
115
|
+
explain("Earlier files", `${moves.length} file(s) from an earlier Sub-Sub layout can move into ${notes}: the settings files go to ${SETTINGS_DIR}/, and Sub-Sub's review notes, alerts and import queue go to Inbox/. Nothing is replaced.`);
|
|
116
|
+
for (const m of moves.slice(0, 6))
|
|
117
|
+
io.say(` ${m.from}`);
|
|
118
|
+
if (moves.length > 6)
|
|
119
|
+
io.say(` and ${moves.length - 6} more`);
|
|
120
|
+
move = await io.choose("Move them", [
|
|
121
|
+
{ value: "yes", label: "Move them (recommended)" },
|
|
122
|
+
{ value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : "Leave them where they are" },
|
|
123
|
+
], flags.move ?? (flags.yes ? "no" : "yes"));
|
|
124
|
+
}
|
|
125
|
+
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
86
126
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
87
|
-
const profile = await io.choose("Profile
|
|
88
|
-
|
|
127
|
+
const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
128
|
+
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
129
|
+
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
130
|
+
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
131
|
+
const envVocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : undefined;
|
|
132
|
+
const customVocab = envVocab && existsSync(envVocab) && !oldDefaults.has(envVocab) && envVocab !== vocabTarget ? envVocab : undefined;
|
|
133
|
+
const movingVocab = move === "yes" && moves.some((m) => m.to === vocabTarget);
|
|
134
|
+
// An old tag list counts only in the same folder; a new folder gets the moved list or a new one.
|
|
135
|
+
const legacyVocab = !movingVocab && from === notes && [...oldDefaults].find((p) => existsSync(p));
|
|
136
|
+
const vocab = customVocab ?? (movingVocab || existsSync(vocabTarget) ? vocabTarget : legacyVocab || vocabTarget);
|
|
89
137
|
let tags = "keep";
|
|
90
|
-
if (existsSync(vocab)) {
|
|
138
|
+
if (existsSync(vocab) || movingVocab) {
|
|
139
|
+
io.say("");
|
|
91
140
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
92
141
|
}
|
|
93
142
|
else {
|
|
94
|
-
|
|
143
|
+
explain("Tag list", `The librarian tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
|
|
144
|
+
tags = await io.choose("Starter tag list", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
|
|
95
145
|
}
|
|
96
|
-
|
|
97
|
-
const
|
|
146
|
+
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it only to Unpaywall, Crossref, OpenAlex and PubMed, as they ask.");
|
|
147
|
+
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
148
|
+
explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. The researcher then has /verify.");
|
|
149
|
+
const starbuck = await io.choose("Reference checks", [
|
|
98
150
|
{ value: "off", label: "Off" },
|
|
99
|
-
{ value: "on", label: "On (
|
|
151
|
+
{ value: "on", label: "On (downloads Starbuck now; uses the email above)" },
|
|
100
152
|
], flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"));
|
|
153
|
+
explain("Models", "Sub-Sub needs an account with a model provider. You can connect one later in Sub-Sub with the model button (or /login in the terminal).");
|
|
101
154
|
const modelOptions = [
|
|
102
155
|
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
103
|
-
{ value: "later", label: "Choose later in Sub-Sub
|
|
156
|
+
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
104
157
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
105
158
|
];
|
|
106
159
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
107
160
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
108
161
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
109
162
|
// ---- write
|
|
110
|
-
|
|
163
|
+
if (move === "yes")
|
|
164
|
+
applyMoves(moves);
|
|
165
|
+
for (const dir of [notes, ...NOTE_DIRS.map((d) => join(notes, d)), join(notes, SETTINGS_DIR)]) {
|
|
111
166
|
if (!existsSync(dir)) {
|
|
112
167
|
mkdirSync(dir, { recursive: true });
|
|
113
168
|
created.push(dir);
|
|
@@ -118,14 +173,18 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
118
173
|
copyFileSync(join(TEMPLATES, "vocabularies", `${tags}.md`), vocab);
|
|
119
174
|
created.push(vocab);
|
|
120
175
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
const rulesTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.rules);
|
|
177
|
+
const legacyRules = join(notes, "Systems", SETTINGS_FILES.rules);
|
|
178
|
+
if (!existsSync(rulesTarget) && !existsSync(legacyRules)) {
|
|
179
|
+
copyFileSync(join(TEMPLATES, "Zotero agent.md"), rulesTarget);
|
|
180
|
+
created.push(rulesTarget);
|
|
125
181
|
}
|
|
182
|
+
const alertsTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.alerts);
|
|
183
|
+
const legacyAlerts = join(notes, "Systems", SETTINGS_FILES.alerts);
|
|
184
|
+
const alerts = !existsSync(alertsTarget) && existsSync(legacyAlerts) ? legacyAlerts : alertsTarget;
|
|
126
185
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
127
186
|
const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
|
|
128
|
-
writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_CONTACT_EMAIL: email || undefined }));
|
|
187
|
+
writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined }));
|
|
129
188
|
const patch = {
|
|
130
189
|
profile,
|
|
131
190
|
userName: userName || undefined,
|
|
@@ -148,6 +207,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
148
207
|
io.say(`Saved: ${envFile}`);
|
|
149
208
|
for (const c of created)
|
|
150
209
|
io.say(`Created: ${c}`);
|
|
210
|
+
if (move === "yes")
|
|
211
|
+
io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
212
|
+
for (const d of leftBehind(from, notes))
|
|
213
|
+
io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
|
|
151
214
|
io.say(zotero
|
|
152
215
|
? "Zotero: reachable."
|
|
153
216
|
: "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\".");
|
|
@@ -156,7 +219,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
156
219
|
if (fmup)
|
|
157
220
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
158
221
|
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.");
|
|
159
|
-
return { configFile, envFile, notes, created, zotero };
|
|
222
|
+
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
160
223
|
}
|
|
161
224
|
export function parseFlags(args) {
|
|
162
225
|
const out = {};
|
|
@@ -211,7 +274,9 @@ function terminalIO() {
|
|
|
211
274
|
}
|
|
212
275
|
export async function initMain(args, env = process.env) {
|
|
213
276
|
const flags = parseFlags(args);
|
|
214
|
-
|
|
277
|
+
if (!process.stdin.isTTY)
|
|
278
|
+
flags.yes ??= "true";
|
|
279
|
+
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
215
280
|
try {
|
|
216
281
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
217
282
|
// Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sub-Sub folder: everything Sub-Sub writes lives in one folder, by default
|
|
3
|
+
* "Sub-Sub" (~/Documents/Sub-Sub, or <vault>/Sub-Sub inside an Obsidian vault):
|
|
4
|
+
*
|
|
5
|
+
* Sub-Sub/
|
|
6
|
+
* Inbox/ tag review notes, the import queue, alerts, drafts
|
|
7
|
+
* Literature/ one note per item
|
|
8
|
+
* Syntheses/ notes on several items
|
|
9
|
+
* Research/ literature reviews, comparisons, digests (.plans/ for plans and logs)
|
|
10
|
+
* Zotero/ Zotero tags.md, Zotero agent.md, Literature alerts.md
|
|
11
|
+
*
|
|
12
|
+
* Before 0.9, the settings files were in Systems/ and the folder could be a whole
|
|
13
|
+
* vault. Those files are still found there until they are moved (subsub init).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
export const FOLDER_NAME = "Sub-Sub";
|
|
18
|
+
export const SETTINGS_DIR = "Zotero";
|
|
19
|
+
export const LEGACY_SETTINGS_DIR = "Systems";
|
|
20
|
+
export const NOTE_DIRS = ["Inbox", "Literature", "Syntheses", "Research"];
|
|
21
|
+
/** The files in Zotero/ that Sub-Sub and the Zotero server read. */
|
|
22
|
+
export const SETTINGS_FILES = { tags: "Zotero tags.md", rules: "Zotero agent.md", alerts: "Literature alerts.md" };
|
|
23
|
+
/** True when the folder is the root of an Obsidian vault. */
|
|
24
|
+
export function isObsidianVault(dir) {
|
|
25
|
+
return existsSync(join(dir, ".obsidian"));
|
|
26
|
+
}
|
|
27
|
+
/** The Obsidian vault that holds a folder: the folder itself or the nearest parent with .obsidian/. */
|
|
28
|
+
export function obsidianRoot(dir) {
|
|
29
|
+
for (let d = dir;; d = dirname(d)) {
|
|
30
|
+
if (isObsidianVault(d))
|
|
31
|
+
return d;
|
|
32
|
+
if (dirname(d) === d)
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
|
|
37
|
+
export function subsubFolder(answer) {
|
|
38
|
+
return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
|
|
39
|
+
}
|
|
40
|
+
/** The path of a settings file: Zotero/<file>, or Systems/<file> while only the old one exists. */
|
|
41
|
+
export function settingsFile(folder, which) {
|
|
42
|
+
const name = SETTINGS_FILES[which];
|
|
43
|
+
const current = join(folder, SETTINGS_DIR, name);
|
|
44
|
+
const legacy = join(folder, LEGACY_SETTINGS_DIR, name);
|
|
45
|
+
return !existsSync(current) && existsSync(legacy) ? legacy : current;
|
|
46
|
+
}
|
|
47
|
+
/** Every place a settings file may be, for the write gate. */
|
|
48
|
+
export function settingsPaths(folder) {
|
|
49
|
+
return Object.values(SETTINGS_FILES).flatMap((n) => [join(folder, SETTINGS_DIR, n), join(folder, LEGACY_SETTINGS_DIR, n)]);
|
|
50
|
+
}
|
|
51
|
+
/** Inbox notes that Sub-Sub and the Zotero server write. */
|
|
52
|
+
const SUBSUB_INBOX = /^(Zotero |Literature alerts|Copy request )/;
|
|
53
|
+
/**
|
|
54
|
+
* What to move from the old layout (`from`: the old notes folder) to the new one
|
|
55
|
+
* (`to`: the Sub-Sub folder). Only files that Sub-Sub made, and never over an
|
|
56
|
+
* existing file:
|
|
57
|
+
* - the settings files, from Systems/ to Zotero/;
|
|
58
|
+
* - when the Sub-Sub folder is new (the old folder was a whole vault): Sub-Sub's
|
|
59
|
+
* notes in Inbox/. Literature/, Syntheses/ and Research/ of a vault may hold the
|
|
60
|
+
* user's own notes, so they stay; the user moves them.
|
|
61
|
+
*/
|
|
62
|
+
export function planMoves(from, to) {
|
|
63
|
+
const moves = [];
|
|
64
|
+
const add = (src, dest) => {
|
|
65
|
+
if (existsSync(src) && !existsSync(dest) && !moves.some((m) => m.to === dest))
|
|
66
|
+
moves.push({ from: src, to: dest });
|
|
67
|
+
};
|
|
68
|
+
for (const name of Object.values(SETTINGS_FILES)) {
|
|
69
|
+
add(join(from, LEGACY_SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
|
|
70
|
+
if (from !== to)
|
|
71
|
+
add(join(from, SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
|
|
72
|
+
}
|
|
73
|
+
if (from !== to) {
|
|
74
|
+
const inbox = join(from, "Inbox");
|
|
75
|
+
if (existsSync(inbox)) {
|
|
76
|
+
for (const f of readdirSync(inbox).sort()) {
|
|
77
|
+
if (SUBSUB_INBOX.test(f) && statSync(join(inbox, f)).isFile())
|
|
78
|
+
add(join(inbox, f), join(to, "Inbox", f));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return moves;
|
|
83
|
+
}
|
|
84
|
+
/** Folders of the old layout that may hold Sub-Sub's notes but are left for the user. */
|
|
85
|
+
export function leftBehind(from, to) {
|
|
86
|
+
if (from === to)
|
|
87
|
+
return [];
|
|
88
|
+
return ["Literature", "Syntheses", "Research"].map((d) => join(from, d)).filter((d) => existsSync(d) && readdirSync(d).length > 0);
|
|
89
|
+
}
|
|
90
|
+
/** Do the moves. An old Systems/ folder that is now empty is removed. */
|
|
91
|
+
export function applyMoves(moves) {
|
|
92
|
+
for (const m of moves) {
|
|
93
|
+
mkdirSync(dirname(m.to), { recursive: true });
|
|
94
|
+
renameSync(m.from, m.to);
|
|
95
|
+
}
|
|
96
|
+
for (const dir of new Set(moves.map((m) => dirname(m.from)))) {
|
|
97
|
+
if (basename(dir) === LEGACY_SETTINGS_DIR && existsSync(dir) && readdirSync(dir).length === 0)
|
|
98
|
+
rmdirSync(dir);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { settingsPaths } from "./layout.js";
|
|
12
13
|
export function normalizeToolPath(raw, cwd) {
|
|
13
14
|
let p = String(raw ?? "").trim();
|
|
14
15
|
if (p.startsWith("@"))
|
|
@@ -72,7 +73,7 @@ export function buildPolicy(opts) {
|
|
|
72
73
|
];
|
|
73
74
|
if (opts.vault) {
|
|
74
75
|
const v = realish(opts.vault);
|
|
75
|
-
protectedPaths.push(join(v, ".claude"), join(v, ".opencode"), join(v, ".obsidian"),
|
|
76
|
+
protectedPaths.push(join(v, ".claude"), join(v, ".opencode"), join(v, ".obsidian"), ...settingsPaths(v));
|
|
76
77
|
}
|
|
77
78
|
return { allowed, protectedPaths };
|
|
78
79
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Sub-Sub part of the system prompt: role text for the current mode, the
|
|
3
|
-
* profile rules, the shared Zotero rules from the
|
|
4
|
-
* conventions when pi runs inside the
|
|
3
|
+
* profile rules, the shared Zotero rules from the Sub-Sub folder, the vault
|
|
4
|
+
* conventions when pi runs inside the Sub-Sub folder, and the language rule.
|
|
5
5
|
*
|
|
6
6
|
* Role and profile texts use placeholders: {{user}} / {{User}} (the user's name,
|
|
7
7
|
* or "the user"), {{about}} (one line about the user) and {{batch}}.
|
|
@@ -56,7 +56,7 @@ export function systemAddition(mode, cfg, cwd) {
|
|
|
56
56
|
const parts = [`# Sub-Sub: ${mode} mode`, roleText(mode, cfg)];
|
|
57
57
|
const shared = readIf(cfg.sharedRules);
|
|
58
58
|
if (shared) {
|
|
59
|
-
parts.push("# Shared Zotero rules (from the
|
|
59
|
+
parts.push("# Shared Zotero rules (from the Sub-Sub folder)", shared, `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`);
|
|
60
60
|
}
|
|
61
61
|
if (inside(cwd, cfg.vault)) {
|
|
62
62
|
for (const f of cfg.vaultContext) {
|
package/dist/subsub.js
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { Bridge } from "./bridge.js";
|
|
21
21
|
import { expand, findUv, isProfile, loadConfig, parseEnvFile, saveConfig, serverCommand, starbuckCommand, starbuckEnabled, who, } from "./config.js";
|
|
22
22
|
import { profileList, profileSpec, promptBlocked } from "./profiles.js";
|
|
23
|
-
import { coerceArgs, loosenArrays } from "./args.js";
|
|
23
|
+
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
24
24
|
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
25
25
|
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
26
26
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
@@ -291,6 +291,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
291
291
|
const schema = schemas.get(name);
|
|
292
292
|
if (schema)
|
|
293
293
|
coerceArgs(schema, input);
|
|
294
|
+
resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
|
|
294
295
|
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
295
296
|
const why = unavailableReason(name, mode, registered(), profile);
|
|
296
297
|
if (why) {
|
package/dist/web.js
CHANGED
|
@@ -23,6 +23,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
24
|
import { expand, loadConfig, saveConfig, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
|
+
import { obsidianRoot } from "./layout.js";
|
|
26
27
|
import { realish, within } from "./paths.js";
|
|
27
28
|
export function parseWebArgs(args) {
|
|
28
29
|
const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
|
|
@@ -591,15 +592,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
591
592
|
const body = await readJson(req);
|
|
592
593
|
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
593
594
|
const vault = cfg.vault;
|
|
594
|
-
// Judge the real file, so a link inside the
|
|
595
|
+
// Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
|
|
595
596
|
const real = realish(target);
|
|
596
597
|
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
597
598
|
if (!inside || !existsSync(target))
|
|
598
|
-
return send(res, 404, { error: "not found in the
|
|
599
|
+
return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
599
600
|
// Documents only: a file that the model wrote must never be run.
|
|
600
601
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|
|
601
602
|
return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
602
|
-
if (vault && within(target, vault) &&
|
|
603
|
+
if (vault && within(target, vault) && obsidianRoot(vault))
|
|
603
604
|
openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
604
605
|
else
|
|
605
606
|
openExternal(target);
|
package/docs/Subsub.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses the
|
|
1
|
+
Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses the Python servers of zotero-local-mcp for the library: previews, version checks, journal and undo, the tag vocabulary.
|
|
2
2
|
|
|
3
3
|
- Code: ~/Projects/subsub (git). Servers: ~/Projects/zotero-local-mcp
|
|
4
4
|
- Shared rules: [[Zotero agent]]. Tag vocabulary: [[Zotero tags]]
|
|
5
|
-
- Settings: ~/.config/subsub/config.json (written by `subsub init`) and ~/.config/
|
|
5
|
+
- Settings: ~/.config/subsub/config.json (written by `subsub init`) and ~/.config/zotero-local-mcp/env (also read by the weekly alerts in launchd)
|
|
6
|
+
- Sub-Sub folder: ~/Obsidian/Powerslave/Sub-Sub, with Inbox/, Literature/, Syntheses/, Research/ and Zotero/ (tag list, note formats, alert searches). Sub-Sub writes only there without asking.
|
|
6
7
|
- Models: librarian opencode-go/glm-5.3-flash, researcher opencode-go/mimo-v2.6-pro
|
|
7
8
|
- Public docs: the README in ~/Projects/subsub (source for subsub.tiagojacinto.eu)
|
|
8
9
|
|
|
9
|
-
##
|
|
10
|
+
## Safety
|
|
10
11
|
|
|
11
12
|
- Approval is enforced in code. Before any library change, Sub-Sub asks the server for the preview and shows it in a dialog. The change runs only after you select Yes. The model cannot skip this.
|
|
12
13
|
- The model does not need to do a dry run first. You see one dialog per change.
|
|
13
14
|
- Library tools run one at a time, so each preview includes the effect of earlier changes in the same step.
|
|
14
15
|
- The researcher does not have the library write tools at all. It can only add a linked note (with a dialog) and add works to the import queue.
|
|
15
|
-
- bash is off in both modes. File writes outside the
|
|
16
|
+
- bash is off in both modes. File writes outside the Sub-Sub folder, and writes to the rule files, config folders and code folders, need a Yes.
|
|
16
17
|
|
|
17
18
|
## Install
|
|
18
19
|
|
|
@@ -20,9 +21,9 @@ Sub-Sub is a Zotero librarian and research assistant that runs in pi. It uses th
|
|
|
20
21
|
2. Type `cd ~/Projects/subsub && npm install --omit=dev`.
|
|
21
22
|
3. Type `npm link`. This adds the `subsub` command.
|
|
22
23
|
4. Optional: type `pi remove ~/Projects/subsub`. Then plain `pi` stays a plain coding agent without Sub-Sub.
|
|
23
|
-
5. Type `subsub`. If pi
|
|
24
|
+
5. Type `subsub`. If pi has no OpenCode Go key yet, type `/login`, select OpenCode Zen and Go, and paste the key.
|
|
24
25
|
6. Type `/subsub`. Make sure that the result shows "Zotero: reachable" and the model mimo-v2.6-pro.
|
|
25
|
-
7. Type `subsub init` once. Keep the
|
|
26
|
+
7. Type `subsub init` once. Keep the Sub-Sub folder, the tag list, the email and the models. For the profile, select Editor.
|
|
26
27
|
8. Type `subsub doctor`. Make sure that no line shows FIX.
|
|
27
28
|
|
|
28
29
|
If you change the Node version with fnm, do step 3 again.
|
|
@@ -30,7 +31,7 @@ If you change the Node version with fnm, do step 3 again.
|
|
|
30
31
|
## Use
|
|
31
32
|
|
|
32
33
|
1. Start Zotero.
|
|
33
|
-
2. Type `subsub`. From the home folder, Sub-Sub starts in the
|
|
34
|
+
2. Type `subsub`. From the home folder, Sub-Sub starts in the Sub-Sub folder. From a different folder (for example a manuscript folder), it starts in that folder. To stay in the home folder, type `subsub --here`.
|
|
34
35
|
3. Sub-Sub starts in the researcher mode. To start in the librarian mode, type `subsub --librarian`.
|
|
35
36
|
4. To change the mode, type `/librarian` or `/researcher`. The mode also changes the model.
|
|
36
37
|
5. When a dialog shows a preview, read it. Select Yes to apply the change, or No to stop it. After No, tell Sub-Sub what to change.
|
|
@@ -105,7 +106,7 @@ The themes are in ~/Projects/subsub/themes. To make them again after a change in
|
|
|
105
106
|
|
|
106
107
|
## Model test
|
|
107
108
|
|
|
108
|
-
The model test runs several models on the same tasks against your library and compares them. Nothing in the library changes: each library change is recorded with its preview and then blocked. Notes go to ~/Projects/subsub/bench-results/<date>/, not to the
|
|
109
|
+
The model test runs several models on the same tasks against your library and compares them. Nothing in the library changes: each library change is recorded with its preview and then blocked. Notes go to ~/Projects/subsub/bench-results/<date>/, not to the Sub-Sub folder.
|
|
109
110
|
|
|
110
111
|
Tasks:
|
|
111
112
|
|
|
@@ -131,7 +132,7 @@ Each model gets a code (for example M417), so the notes can be judged blind. key
|
|
|
131
132
|
- "command not found: subsub": type `cd ~/Projects/subsub && npm link`.
|
|
132
133
|
- To update Sub-Sub after new code arrives, type `cd ~/Projects/subsub && npm install --omit=dev`. Do not use `subsub update`.
|
|
133
134
|
- A tool call is refused with "not available in researcher mode": type `/librarian`.
|
|
134
|
-
- `subsub review` says "No note matches": check the note numbers in Inbox/. A path is written from the
|
|
135
|
+
- `subsub review` says "No note matches": check the note numbers in Inbox/. A path is written from the Sub-Sub folder, in quotes (for example "Inbox/Zotero tag review 13.md").
|
|
135
136
|
|
|
136
137
|
## Tests
|
|
137
138
|
|
package/package.json
CHANGED
package/prompts/compare.md
CHANGED
|
@@ -17,7 +17,7 @@ Make a short slug: lower case, hyphens, at most five words. Use today's date (YY
|
|
|
17
17
|
- Confidence: high, moderate or low, with one reason.
|
|
18
18
|
4. Below the matrix, write: where the sources agree; where they disagree and the most likely reason (population, design, outcome definition, analysis); what no source answers. Cite source numbers in every sentence. Write "inference:" before a statement that no single source makes.
|
|
19
19
|
5. Check the sources: verify_check_references when the verify_ tools are available, otherwise scholar_get_work for each identifier. Never write "verified" for a check you did not run.
|
|
20
|
-
6. Write one note: `Research/<date> <slug> comparison.md` in the
|
|
20
|
+
6. Write one note: `Research/<date> <slug> comparison.md` in the Sub-Sub folder (do not overwrite; add -2). Front matter: title, date, type: comparison, sources (the identifiers), verification (PASS, PASS WITH NOTES or BLOCKED), reference_check (path of the Starbuck report, or none). End with a Sources section: [@citekey] for library items, full reference with DOI and "(not in library)" for the others.
|
|
21
21
|
7. If a step fails, finish the note anyway and mark the step "BLOCKED: <what failed>".
|
|
22
22
|
|
|
23
23
|
Give the user the path and three lines on what the comparison shows.
|
package/prompts/digest.md
CHANGED
|
@@ -4,7 +4,7 @@ argument-hint: "[days, default 7] [topic]"
|
|
|
4
4
|
---
|
|
5
5
|
Write a digest of the last ${1:-7} days. Topic, if given: ${@:2}
|
|
6
6
|
|
|
7
|
-
1. Collect, in the
|
|
7
|
+
1. Collect, in the Sub-Sub folder:
|
|
8
8
|
- the alert notes `Inbox/Literature alerts YYYY-MM-DD.md` dated in the period (the date is in the file name);
|
|
9
9
|
- the notes in `Research/` whose front matter `date:` is in the period;
|
|
10
10
|
- the literature notes whose front matter `created:` is in the period.
|
package/prompts/lit.md
CHANGED
|
@@ -11,7 +11,7 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
|
|
|
11
11
|
1. Write the question in one sentence. If it is a clinical question, state population, exposure or intervention, comparison and outcome when they apply.
|
|
12
12
|
2. Decide the scale:
|
|
13
13
|
- Narrow question ("what is X", one fact, one test): at most 10 tool calls, a short answer, no plan file.
|
|
14
|
-
- Review question: write the plan to `Research/.plans/<slug>.md` in the
|
|
14
|
+
- Review question: write the plan to `Research/.plans/<slug>.md` in the Sub-Sub folder: the question, the search phrasings, the study designs that answer it (for example randomised trials, cohorts, diagnostic accuracy studies, systematic reviews), the years, and a task list.
|
|
15
15
|
3. Tell the user the plan in three lines. Then continue at once. Do not wait for an answer unless the user asked to see the plan first.
|
|
16
16
|
|
|
17
17
|
## 2. Gather
|
|
@@ -47,7 +47,7 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
|
|
|
47
47
|
|
|
48
48
|
## 6. Deliver
|
|
49
49
|
|
|
50
|
-
Write one note: `Research/<date> <slug>.md` in the
|
|
50
|
+
Write one note: `Research/<date> <slug>.md` in the Sub-Sub folder. Do not overwrite an existing note; add -2 to the name. Use this structure:
|
|
51
51
|
|
|
52
52
|
```markdown
|
|
53
53
|
---
|
package/prompts/request-copy.md
CHANGED
|
@@ -6,7 +6,7 @@ Draft an email to ask the corresponding author of $1 for a copy of the paper.
|
|
|
6
6
|
|
|
7
7
|
1. Find the work: zotero_get_item for a citekey or Zotero key, otherwise scholar_get_work. If it is open access (open_access, or scholar_read_oa_fulltext gives the full text), tell the user where to read it and stop: no email is needed.
|
|
8
8
|
2. Find the address with scholar_find_contact. Use only an address it returns. Never guess or build an address from a name. If it returns none, give the user the article link and say: look for "Correspondence" on the article page; then stop.
|
|
9
|
-
3. Write the draft to `Inbox/Copy request <citekey or DOI>.md` in the
|
|
9
|
+
3. Write the draft to `Inbox/Copy request <citekey or DOI>.md` in the Sub-Sub folder (do not overwrite; add -2):
|
|
10
10
|
|
|
11
11
|
```markdown
|
|
12
12
|
---
|
package/roles/researcher.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
You are Sub-Sub, the research assistant for {{user}}. {{about}} Reply briefly. No emojis.
|
|
2
2
|
|
|
3
|
-
You read the Zotero library (zotero_ read tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools) and write notes in the
|
|
3
|
+
You read the Zotero library (zotero_ read tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools) and write notes in the Sub-Sub folder. You cannot change the library, with one exception: scholar_attach_note adds a short child note (summary plus obsidian:// link). To add works, use scholar_queue_imports; {{user}} ticks them and the librarian (/librarian) imports them.
|
|
4
4
|
|
|
5
5
|
## Always
|
|
6
6
|
|
|
@@ -32,7 +32,7 @@ You read the Zotero library (zotero_ read tools), search PubMed, Europe PMC and
|
|
|
32
32
|
## Literature notes and syntheses
|
|
33
33
|
|
|
34
34
|
- Follow the formats in the shared rules.
|
|
35
|
-
- After a literature note is written, offer scholar_attach_note with 3 to 4 lines of summary and the path of the
|
|
35
|
+
- After a literature note is written, offer scholar_attach_note with 3 to 4 lines of summary and the path of the note.
|
|
36
36
|
- For a synthesis: show the candidate list first and let {{user}} choose. Read full texts only for the chosen items.
|
|
37
37
|
- For a synthesis of a tag ("what does my library say about X"): find the items with zotero_find_items tags=["topic/x"] and include every one. The table has one row per item; if you leave an item out, say which and why.
|
|
38
38
|
- Say for each item whether you used the full text or the abstract. Never state a number or a claim that is not in what you read.
|
package/src/args.ts
CHANGED
|
@@ -62,3 +62,27 @@ export function coerceArgs(original: Schema, input: Record<string, unknown>): Re
|
|
|
62
62
|
}
|
|
63
63
|
return input;
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Arguments that name a file in the user's working folder (a manuscript, a report
|
|
68
|
+
* folder, a bibliography to write). The servers run in their own working folder, so
|
|
69
|
+
* Sub-Sub makes these paths absolute against its own, as pi's file tools read them.
|
|
70
|
+
* Note paths (tag reviews, the import queue, linked notes) are not here: the servers
|
|
71
|
+
* read those from the Sub-Sub folder.
|
|
72
|
+
*/
|
|
73
|
+
export const CWD_PATH_ARGS: Record<string, string[]> = {
|
|
74
|
+
scholar_check_manuscript: ["path"],
|
|
75
|
+
scholar_export_bibliography: ["output_path"],
|
|
76
|
+
verify_check_manuscript: ["path", "report_dir"],
|
|
77
|
+
verify_prepare_claims: ["path", "report_dir"],
|
|
78
|
+
verify_record_claims: ["path", "report_dir"],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Make the working-folder paths of a tool call absolute. Changes input in place. */
|
|
82
|
+
export function resolvePathArgs(tool: string, input: Record<string, unknown>, toAbsolute: (p: string) => string): Record<string, unknown> {
|
|
83
|
+
for (const k of CWD_PATH_ARGS[tool] ?? []) {
|
|
84
|
+
const v = input[k];
|
|
85
|
+
if (typeof v === "string" && v.trim()) input[k] = toAbsolute(v);
|
|
86
|
+
}
|
|
87
|
+
return input;
|
|
88
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* {
|
|
6
6
|
* "profile": "scholar",
|
|
7
7
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
|
-
* "vault": "~/
|
|
8
|
+
* "vault": "~/Documents/Sub-Sub",
|
|
9
9
|
* "models": { "librarian": "opencode-go/glm-5.3-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
10
10
|
* "defaultMode": "researcher"
|
|
11
11
|
* }
|
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { isObsidianVault, settingsFile } from "./layout.ts";
|
|
25
26
|
|
|
26
27
|
export type Mode = "librarian" | "researcher";
|
|
27
28
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
28
29
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
29
30
|
|
|
30
31
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
31
|
-
export const SERVER_VERSION = "0.5.
|
|
32
|
+
export const SERVER_VERSION = "0.5.1";
|
|
32
33
|
|
|
33
34
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
34
35
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -160,11 +161,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
160
161
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
161
162
|
},
|
|
162
163
|
defaultMode: mode,
|
|
163
|
-
|
|
164
|
+
// A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
|
|
165
|
+
vaultContext: (
|
|
166
|
+
user.vaultContext ??
|
|
167
|
+
(vault ? [join(vault, ".claude", "CLAUDE.md"), ...(isObsidianVault(dirname(vault)) ? [join(dirname(vault), ".claude", "CLAUDE.md")] : [])] : [])
|
|
168
|
+
).map((p) => expand(p, home)),
|
|
164
169
|
sharedRules: user.sharedRules
|
|
165
170
|
? expand(user.sharedRules, home)
|
|
166
171
|
: vault
|
|
167
|
-
?
|
|
172
|
+
? settingsFile(vault, "rules")
|
|
168
173
|
: undefined,
|
|
169
174
|
startTimeout: user.startTimeout ?? 45,
|
|
170
175
|
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
package/src/doctor.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
starbuckEnabled,
|
|
22
22
|
type SubsubConfig,
|
|
23
23
|
} from "./config.ts";
|
|
24
|
+
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
24
25
|
import { profileSpec } from "./profiles.ts";
|
|
25
26
|
|
|
26
27
|
export interface Check {
|
|
@@ -137,11 +138,7 @@ export function runChecks(
|
|
|
137
138
|
: undefined,
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
|
-
out.push(
|
|
141
|
-
cfg.vault && existsSync(cfg.vault)
|
|
142
|
-
? { name: "Notes folder", ok: true, detail: cfg.vault }
|
|
143
|
-
: { name: "Notes folder", ok: false, detail: cfg.vault ? `${cfg.vault} does not exist` : "not set", fix: "Type subsub init." },
|
|
144
|
-
);
|
|
141
|
+
out.push(folderCheck(cfg.vault));
|
|
145
142
|
out.push(
|
|
146
143
|
st.contact_email_set
|
|
147
144
|
? { name: "Contact email", ok: true, detail: "set" }
|
|
@@ -199,3 +196,19 @@ export async function doctorMain(env: NodeJS.ProcessEnv = process.env): Promise<
|
|
|
199
196
|
console.log(bad ? `\n${bad} problem(s) to fix.` : "\nEverything needed is in place.");
|
|
200
197
|
return bad ? 1 : 0;
|
|
201
198
|
}
|
|
199
|
+
|
|
200
|
+
/** The Sub-Sub folder: it exists, it is not a whole vault, and its settings files are in Zotero/. */
|
|
201
|
+
export function folderCheck(folder: string | undefined): Check {
|
|
202
|
+
const name = "Sub-Sub folder";
|
|
203
|
+
if (!folder || !existsSync(folder)) {
|
|
204
|
+
return { name, ok: false, detail: folder ? `${folder} does not exist` : "not set", fix: "Type subsub init." };
|
|
205
|
+
}
|
|
206
|
+
if (isObsidianVault(folder)) {
|
|
207
|
+
return { name, ok: true, warn: true, detail: `${folder} is a whole Obsidian vault`, fix: "Type subsub init: it moves Sub-Sub's files into a Sub-Sub folder inside the vault." };
|
|
208
|
+
}
|
|
209
|
+
const legacy = (["rules", "tags", "alerts"] as const).some((w) => settingsFile(folder, w) === join(folder, LEGACY_SETTINGS_DIR, SETTINGS_FILES[w]));
|
|
210
|
+
if (legacy) {
|
|
211
|
+
return { name, ok: true, warn: true, detail: `${folder}; the settings files are still in ${LEGACY_SETTINGS_DIR}/`, fix: "Type subsub init: it moves them to Zotero/." };
|
|
212
|
+
}
|
|
213
|
+
return { name, ok: true, detail: folder };
|
|
214
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -6,14 +6,20 @@
|
|
|
6
6
|
* - the settings file (~/.config/subsub/config.json): profile, name, language,
|
|
7
7
|
* notes folder, models;
|
|
8
8
|
* - the server settings file (~/.config/zotero-local-mcp/env, or the one that
|
|
9
|
-
* already exists):
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* already exists): the Sub-Sub folder, tag list, alerts file, contact email.
|
|
10
|
+
* Other lines stay;
|
|
11
|
+
* - the Sub-Sub folder (see layout.ts): Inbox/, Literature/, Syntheses/,
|
|
12
|
+
* Research/, and Zotero/ with Zotero tags.md (a starter list) and Zotero
|
|
13
|
+
* agent.md (note formats). Existing files are never replaced.
|
|
14
|
+
*
|
|
15
|
+
* An Obsidian vault given as the folder becomes <vault>/Sub-Sub. Files of the old
|
|
16
|
+
* layout (Systems/, or Sub-Sub's notes in the Inbox/ of a whole vault) are moved
|
|
17
|
+
* when the user agrees.
|
|
12
18
|
*
|
|
13
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
14
|
-
* standard|fmup, --name, --about, --language, --
|
|
15
|
-
* health-sciences|health-informatics|any-field,
|
|
16
|
-
* --models keep|later|opencode-go.
|
|
20
|
+
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
|
+
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -35,6 +41,7 @@ import {
|
|
|
35
41
|
withStarbuck,
|
|
36
42
|
} from "./config.ts";
|
|
37
43
|
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
44
|
+
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
|
|
38
45
|
import { PROFILE_SPECS } from "./profiles.ts";
|
|
39
46
|
|
|
40
47
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
@@ -58,6 +65,7 @@ export interface InitResult {
|
|
|
58
65
|
envFile: string;
|
|
59
66
|
notes: string;
|
|
60
67
|
created: string[];
|
|
68
|
+
moved: number;
|
|
61
69
|
zotero: boolean;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -108,8 +116,18 @@ export async function runInit(
|
|
|
108
116
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
109
117
|
const created: string[] = [];
|
|
110
118
|
|
|
111
|
-
|
|
119
|
+
const first = !existsSync(configFile);
|
|
120
|
+
io.say(
|
|
121
|
+
first
|
|
122
|
+
? "Sub-Sub setup. A few questions; press Enter to take the value in brackets. To change an answer later, type subsub init again."
|
|
123
|
+
: `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.`,
|
|
124
|
+
);
|
|
125
|
+
const explain = (title: string, text: string) => {
|
|
126
|
+
io.say("");
|
|
127
|
+
io.say(`${title}. ${text}`);
|
|
128
|
+
};
|
|
112
129
|
|
|
130
|
+
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
113
131
|
const setup = await io.choose(
|
|
114
132
|
"Setup",
|
|
115
133
|
[
|
|
@@ -119,45 +137,92 @@ export async function runInit(
|
|
|
119
137
|
flags.setup ?? existing.setup ?? "standard",
|
|
120
138
|
);
|
|
121
139
|
const fmup = setup === "fmup";
|
|
122
|
-
|
|
140
|
+
|
|
141
|
+
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.");
|
|
142
|
+
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
123
143
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
124
144
|
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";
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
|
|
146
|
+
explain(
|
|
147
|
+
"Sub-Sub folder",
|
|
148
|
+
`Sub-Sub keeps everything it writes in one folder: Inbox/ (review notes, the import queue, alerts), Literature/, Syntheses/, Research/, and ${SETTINGS_DIR}/ (your tag list and note formats). If you use Obsidian, give the vault folder: Sub-Sub then works in a ${FOLDER_NAME} folder inside the vault and leaves the rest of the vault alone.`,
|
|
128
149
|
);
|
|
150
|
+
const oldFolder = current.vault;
|
|
151
|
+
// Without questions (--yes), an existing folder stays exactly as it is.
|
|
152
|
+
const keepAsIs = Boolean(flags.yes) && !flags.folder && !flags.notes && oldFolder !== undefined;
|
|
153
|
+
const folderDefault = flags.folder ?? flags.notes ?? (oldFolder ? (keepAsIs ? oldFolder : subsubFolder(oldFolder)) : join(home, "Documents", FOLDER_NAME));
|
|
154
|
+
const answer = expand((await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)).trim() || folderDefault, home);
|
|
155
|
+
const notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
156
|
+
if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
157
|
+
|
|
158
|
+
// ---- files of the old layout
|
|
159
|
+
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
160
|
+
const moves = planMoves(from, notes);
|
|
161
|
+
let move = "no";
|
|
162
|
+
if (moves.length) {
|
|
163
|
+
explain(
|
|
164
|
+
"Earlier files",
|
|
165
|
+
`${moves.length} file(s) from an earlier Sub-Sub layout can move into ${notes}: the settings files go to ${SETTINGS_DIR}/, and Sub-Sub's review notes, alerts and import queue go to Inbox/. Nothing is replaced.`,
|
|
166
|
+
);
|
|
167
|
+
for (const m of moves.slice(0, 6)) io.say(` ${m.from}`);
|
|
168
|
+
if (moves.length > 6) io.say(` and ${moves.length - 6} more`);
|
|
169
|
+
move = await io.choose(
|
|
170
|
+
"Move them",
|
|
171
|
+
[
|
|
172
|
+
{ value: "yes", label: "Move them (recommended)" },
|
|
173
|
+
{ value: "no", label: from === notes ? "Leave them; Sub-Sub still reads them from Systems/" : "Leave them where they are" },
|
|
174
|
+
],
|
|
175
|
+
flags.move ?? (flags.yes ? "no" : "yes"),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
129
180
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
130
181
|
const profile = await io.choose(
|
|
131
|
-
"Profile
|
|
182
|
+
"Profile",
|
|
132
183
|
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
133
184
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
134
185
|
);
|
|
135
186
|
|
|
136
|
-
|
|
187
|
+
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
188
|
+
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
189
|
+
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
190
|
+
const envVocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : undefined;
|
|
191
|
+
const customVocab = envVocab && existsSync(envVocab) && !oldDefaults.has(envVocab) && envVocab !== vocabTarget ? envVocab : undefined;
|
|
192
|
+
const movingVocab = move === "yes" && moves.some((m) => m.to === vocabTarget);
|
|
193
|
+
// An old tag list counts only in the same folder; a new folder gets the moved list or a new one.
|
|
194
|
+
const legacyVocab = !movingVocab && from === notes && [...oldDefaults].find((p) => existsSync(p));
|
|
195
|
+
const vocab = customVocab ?? (movingVocab || existsSync(vocabTarget) ? vocabTarget : legacyVocab || vocabTarget);
|
|
137
196
|
let tags = "keep";
|
|
138
|
-
if (existsSync(vocab)) {
|
|
197
|
+
if (existsSync(vocab) || movingVocab) {
|
|
198
|
+
io.say("");
|
|
139
199
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
140
200
|
} else {
|
|
201
|
+
explain("Tag list", `The librarian tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
|
|
141
202
|
tags = await io.choose(
|
|
142
|
-
"Starter tag list
|
|
203
|
+
"Starter tag list",
|
|
143
204
|
Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })),
|
|
144
205
|
flags.tags ?? (fmup ? "health-sciences" : "any-field"),
|
|
145
206
|
);
|
|
146
207
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
).trim();
|
|
208
|
+
|
|
209
|
+
explain("Contact email", "Unpaywall needs an email address to find open-access PDFs. Sub-Sub sends it only to Unpaywall, Crossref, OpenAlex and PubMed, as they ask.");
|
|
210
|
+
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
211
|
+
|
|
212
|
+
explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. The researcher then has /verify.");
|
|
150
213
|
const starbuck = await io.choose(
|
|
151
|
-
"Reference checks
|
|
214
|
+
"Reference checks",
|
|
152
215
|
[
|
|
153
216
|
{ value: "off", label: "Off" },
|
|
154
|
-
{ value: "on", label: "On (
|
|
217
|
+
{ value: "on", label: "On (downloads Starbuck now; uses the email above)" },
|
|
155
218
|
],
|
|
156
219
|
flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
|
|
157
220
|
);
|
|
221
|
+
|
|
222
|
+
explain("Models", "Sub-Sub needs an account with a model provider. You can connect one later in Sub-Sub with the model button (or /login in the terminal).");
|
|
158
223
|
const modelOptions = [
|
|
159
224
|
...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
|
|
160
|
-
{ value: "later", label: "Choose later in Sub-Sub
|
|
225
|
+
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
161
226
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
162
227
|
];
|
|
163
228
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
@@ -165,7 +230,8 @@ export async function runInit(
|
|
|
165
230
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
166
231
|
|
|
167
232
|
// ---- write
|
|
168
|
-
|
|
233
|
+
if (move === "yes") applyMoves(moves);
|
|
234
|
+
for (const dir of [notes, ...NOTE_DIRS.map((d) => join(notes, d)), join(notes, SETTINGS_DIR)]) {
|
|
169
235
|
if (!existsSync(dir)) {
|
|
170
236
|
mkdirSync(dir, { recursive: true });
|
|
171
237
|
created.push(dir);
|
|
@@ -176,14 +242,18 @@ export async function runInit(
|
|
|
176
242
|
copyFileSync(join(TEMPLATES, "vocabularies", `${tags}.md`), vocab);
|
|
177
243
|
created.push(vocab);
|
|
178
244
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
245
|
+
const rulesTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.rules);
|
|
246
|
+
const legacyRules = join(notes, "Systems", SETTINGS_FILES.rules);
|
|
247
|
+
if (!existsSync(rulesTarget) && !existsSync(legacyRules)) {
|
|
248
|
+
copyFileSync(join(TEMPLATES, "Zotero agent.md"), rulesTarget);
|
|
249
|
+
created.push(rulesTarget);
|
|
183
250
|
}
|
|
251
|
+
const alertsTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.alerts);
|
|
252
|
+
const legacyAlerts = join(notes, "Systems", SETTINGS_FILES.alerts);
|
|
253
|
+
const alerts = !existsSync(alertsTarget) && existsSync(legacyAlerts) ? legacyAlerts : alertsTarget;
|
|
184
254
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
185
255
|
const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "# Settings for the Sub-Sub Zotero servers (zotero-local-mcp). Written by subsub init.\n";
|
|
186
|
-
writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_CONTACT_EMAIL: email || undefined }));
|
|
256
|
+
writeFileSync(envFile, upsertEnv(before, { ZOTERO_VAULT: notes, ZOTERO_VOCAB: vocab, ZOTERO_ALERTS: alerts, ZOTERO_CONTACT_EMAIL: email || undefined }));
|
|
187
257
|
|
|
188
258
|
const patch: Record<string, unknown> = {
|
|
189
259
|
profile,
|
|
@@ -205,6 +275,8 @@ export async function runInit(
|
|
|
205
275
|
io.say(`Saved: ${configFile}`);
|
|
206
276
|
io.say(`Saved: ${envFile}`);
|
|
207
277
|
for (const c of created) io.say(`Created: ${c}`);
|
|
278
|
+
if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
279
|
+
for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
|
|
208
280
|
io.say(
|
|
209
281
|
zotero
|
|
210
282
|
? "Zotero: reachable."
|
|
@@ -213,7 +285,7 @@ export async function runInit(
|
|
|
213
285
|
if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
214
286
|
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
215
287
|
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.");
|
|
216
|
-
return { configFile, envFile, notes, created, zotero };
|
|
288
|
+
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
217
289
|
}
|
|
218
290
|
|
|
219
291
|
export function parseFlags(args: string[]): Record<string, string> {
|
|
@@ -266,7 +338,8 @@ function terminalIO(): InitIO & { close(): void } {
|
|
|
266
338
|
|
|
267
339
|
export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
268
340
|
const flags = parseFlags(args);
|
|
269
|
-
|
|
341
|
+
if (!process.stdin.isTTY) flags.yes ??= "true";
|
|
342
|
+
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
270
343
|
try {
|
|
271
344
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
|
272
345
|
// Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sub-Sub folder: everything Sub-Sub writes lives in one folder, by default
|
|
3
|
+
* "Sub-Sub" (~/Documents/Sub-Sub, or <vault>/Sub-Sub inside an Obsidian vault):
|
|
4
|
+
*
|
|
5
|
+
* Sub-Sub/
|
|
6
|
+
* Inbox/ tag review notes, the import queue, alerts, drafts
|
|
7
|
+
* Literature/ one note per item
|
|
8
|
+
* Syntheses/ notes on several items
|
|
9
|
+
* Research/ literature reviews, comparisons, digests (.plans/ for plans and logs)
|
|
10
|
+
* Zotero/ Zotero tags.md, Zotero agent.md, Literature alerts.md
|
|
11
|
+
*
|
|
12
|
+
* Before 0.9, the settings files were in Systems/ and the folder could be a whole
|
|
13
|
+
* vault. Those files are still found there until they are moved (subsub init).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const FOLDER_NAME = "Sub-Sub";
|
|
20
|
+
export const SETTINGS_DIR = "Zotero";
|
|
21
|
+
export const LEGACY_SETTINGS_DIR = "Systems";
|
|
22
|
+
export const NOTE_DIRS = ["Inbox", "Literature", "Syntheses", "Research"];
|
|
23
|
+
|
|
24
|
+
/** The files in Zotero/ that Sub-Sub and the Zotero server read. */
|
|
25
|
+
export const SETTINGS_FILES = { tags: "Zotero tags.md", rules: "Zotero agent.md", alerts: "Literature alerts.md" } as const;
|
|
26
|
+
type SettingsFile = keyof typeof SETTINGS_FILES;
|
|
27
|
+
|
|
28
|
+
/** True when the folder is the root of an Obsidian vault. */
|
|
29
|
+
export function isObsidianVault(dir: string): boolean {
|
|
30
|
+
return existsSync(join(dir, ".obsidian"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The Obsidian vault that holds a folder: the folder itself or the nearest parent with .obsidian/. */
|
|
34
|
+
export function obsidianRoot(dir: string): string | undefined {
|
|
35
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
36
|
+
if (isObsidianVault(d)) return d;
|
|
37
|
+
if (dirname(d) === d) return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
|
|
42
|
+
export function subsubFolder(answer: string): string {
|
|
43
|
+
return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The path of a settings file: Zotero/<file>, or Systems/<file> while only the old one exists. */
|
|
47
|
+
export function settingsFile(folder: string, which: SettingsFile): string {
|
|
48
|
+
const name = SETTINGS_FILES[which];
|
|
49
|
+
const current = join(folder, SETTINGS_DIR, name);
|
|
50
|
+
const legacy = join(folder, LEGACY_SETTINGS_DIR, name);
|
|
51
|
+
return !existsSync(current) && existsSync(legacy) ? legacy : current;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every place a settings file may be, for the write gate. */
|
|
55
|
+
export function settingsPaths(folder: string): string[] {
|
|
56
|
+
return Object.values(SETTINGS_FILES).flatMap((n) => [join(folder, SETTINGS_DIR, n), join(folder, LEGACY_SETTINGS_DIR, n)]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Move {
|
|
60
|
+
from: string;
|
|
61
|
+
to: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Inbox notes that Sub-Sub and the Zotero server write. */
|
|
65
|
+
const SUBSUB_INBOX = /^(Zotero |Literature alerts|Copy request )/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What to move from the old layout (`from`: the old notes folder) to the new one
|
|
69
|
+
* (`to`: the Sub-Sub folder). Only files that Sub-Sub made, and never over an
|
|
70
|
+
* existing file:
|
|
71
|
+
* - the settings files, from Systems/ to Zotero/;
|
|
72
|
+
* - when the Sub-Sub folder is new (the old folder was a whole vault): Sub-Sub's
|
|
73
|
+
* notes in Inbox/. Literature/, Syntheses/ and Research/ of a vault may hold the
|
|
74
|
+
* user's own notes, so they stay; the user moves them.
|
|
75
|
+
*/
|
|
76
|
+
export function planMoves(from: string, to: string): Move[] {
|
|
77
|
+
const moves: Move[] = [];
|
|
78
|
+
const add = (src: string, dest: string) => {
|
|
79
|
+
if (existsSync(src) && !existsSync(dest) && !moves.some((m) => m.to === dest)) moves.push({ from: src, to: dest });
|
|
80
|
+
};
|
|
81
|
+
for (const name of Object.values(SETTINGS_FILES)) {
|
|
82
|
+
add(join(from, LEGACY_SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
|
|
83
|
+
if (from !== to) add(join(from, SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
|
|
84
|
+
}
|
|
85
|
+
if (from !== to) {
|
|
86
|
+
const inbox = join(from, "Inbox");
|
|
87
|
+
if (existsSync(inbox)) {
|
|
88
|
+
for (const f of readdirSync(inbox).sort()) {
|
|
89
|
+
if (SUBSUB_INBOX.test(f) && statSync(join(inbox, f)).isFile()) add(join(inbox, f), join(to, "Inbox", f));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return moves;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Folders of the old layout that may hold Sub-Sub's notes but are left for the user. */
|
|
97
|
+
export function leftBehind(from: string, to: string): string[] {
|
|
98
|
+
if (from === to) return [];
|
|
99
|
+
return ["Literature", "Syntheses", "Research"].map((d) => join(from, d)).filter((d) => existsSync(d) && readdirSync(d).length > 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Do the moves. An old Systems/ folder that is now empty is removed. */
|
|
103
|
+
export function applyMoves(moves: Move[]): void {
|
|
104
|
+
for (const m of moves) {
|
|
105
|
+
mkdirSync(dirname(m.to), { recursive: true });
|
|
106
|
+
renameSync(m.from, m.to);
|
|
107
|
+
}
|
|
108
|
+
for (const dir of new Set(moves.map((m) => dirname(m.from)))) {
|
|
109
|
+
if (basename(dir) === LEGACY_SETTINGS_DIR && existsSync(dir) && readdirSync(dir).length === 0) rmdirSync(dir);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { settingsPaths } from "./layout.ts";
|
|
13
14
|
|
|
14
15
|
export function normalizeToolPath(raw: string, cwd: string): string {
|
|
15
16
|
let p = String(raw ?? "").trim();
|
|
@@ -80,11 +81,7 @@ export function buildPolicy(opts: { vault?: string; cwd: string; serverDir: stri
|
|
|
80
81
|
join(v, ".claude"),
|
|
81
82
|
join(v, ".opencode"),
|
|
82
83
|
join(v, ".obsidian"),
|
|
83
|
-
|
|
84
|
-
join(v, "Systems", "Zotero tags.md"),
|
|
85
|
-
join(v, "Systems", "Zotero librarian.md"),
|
|
86
|
-
join(v, "Systems", "Zotero researcher.md"),
|
|
87
|
-
join(v, "Systems", "Literature alerts.md"),
|
|
84
|
+
...settingsPaths(v),
|
|
88
85
|
);
|
|
89
86
|
}
|
|
90
87
|
return { allowed, protectedPaths };
|
package/src/prompt.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Sub-Sub part of the system prompt: role text for the current mode, the
|
|
3
|
-
* profile rules, the shared Zotero rules from the
|
|
4
|
-
* conventions when pi runs inside the
|
|
3
|
+
* profile rules, the shared Zotero rules from the Sub-Sub folder, the vault
|
|
4
|
+
* conventions when pi runs inside the Sub-Sub folder, and the language rule.
|
|
5
5
|
*
|
|
6
6
|
* Role and profile texts use placeholders: {{user}} / {{User}} (the user's name,
|
|
7
7
|
* or "the user"), {{about}} (one line about the user) and {{batch}}.
|
|
@@ -63,7 +63,7 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
|
|
|
63
63
|
const shared = readIf(cfg.sharedRules);
|
|
64
64
|
if (shared) {
|
|
65
65
|
parts.push(
|
|
66
|
-
"# Shared Zotero rules (from the
|
|
66
|
+
"# Shared Zotero rules (from the Sub-Sub folder)",
|
|
67
67
|
shared,
|
|
68
68
|
`Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`,
|
|
69
69
|
);
|
package/src/subsub.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
who,
|
|
37
37
|
} from "./config.ts";
|
|
38
38
|
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
39
|
-
import { coerceArgs, loosenArrays } from "./args.ts";
|
|
39
|
+
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
40
40
|
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
41
41
|
import { buildPolicy, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
42
42
|
import { describeArgs, formatPreview } from "./preview.ts";
|
|
@@ -321,6 +321,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
321
321
|
// Previews and the real call must see the same, repaired arguments.
|
|
322
322
|
const schema = schemas.get(name);
|
|
323
323
|
if (schema) coerceArgs(schema, input);
|
|
324
|
+
resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
|
|
324
325
|
if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
|
|
325
326
|
const why = unavailableReason(name, mode, registered(), profile);
|
|
326
327
|
if (why) {
|
package/src/web.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
25
|
import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
26
26
|
import { TESTED_MODELS } from "./init.ts";
|
|
27
|
+
import { obsidianRoot } from "./layout.ts";
|
|
27
28
|
import { realish, within } from "./paths.ts";
|
|
28
29
|
|
|
29
30
|
// ---------------------------------------------------------------- options
|
|
@@ -604,13 +605,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
604
605
|
const body = await readJson(req);
|
|
605
606
|
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
606
607
|
const vault = cfg.vault;
|
|
607
|
-
// Judge the real file, so a link inside the
|
|
608
|
+
// Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
|
|
608
609
|
const real = realish(target);
|
|
609
610
|
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
610
|
-
if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the
|
|
611
|
+
if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
611
612
|
// Documents only: a file that the model wrote must never be run.
|
|
612
613
|
if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
613
|
-
if (vault && within(target, vault) &&
|
|
614
|
+
if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
614
615
|
else openExternal(target);
|
|
615
616
|
return send(res, 200, { ok: true });
|
|
616
617
|
}
|