@tiagojct/subsub 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ A Zotero librarian and research assistant that runs on your computer, in your br
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
21
  * 📚 **The Librarian**: Cleans tags, imports references (DOI, PMID, ISBN), deduplicates, fixes metadata, retrieves open-access PDFs, creates collections, and checks retractions. *Runs completely offline with no web search access.*
22
- * 🔬 **The Researcher**: Reads items, searches PubMed and OpenAlex, finds gaps, checks citations in manuscripts, and writes structured Markdown notes in your notes vault (e.g. Obsidian). *Cannot modify library records.*
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 notes folder (`Inbox/` and `Systems/`), sets up starter tag vocabularies, and writes note templates (`Systems/Zotero agent.md`).
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 Markdown notes folder / Obsidian vault | `$ZOTERO_VAULT` |
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 (notes folder path, tag vocabulary path, Unpaywall email) reside in `~/.config/zotero-local-mcp/env`.
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 notes 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.
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
 
@@ -53,3 +53,101 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
53
53
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
54
54
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
55
55
  SOFTWARE.
56
+
57
+
58
+ web/fonts/ and the website use IBM Plex Sans, IBM Plex Serif and IBM Plex Mono,
59
+ https://github.com/IBM/plex. Their licence:
60
+
61
+ Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
62
+
63
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
64
+
65
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
66
+
67
+
68
+ -----------------------------------------------------------
69
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
70
+ -----------------------------------------------------------
71
+
72
+ PREAMBLE
73
+ The goals of the Open Font License (OFL) are to stimulate worldwide
74
+ development of collaborative font projects, to support the font creation
75
+ efforts of academic and linguistic communities, and to provide a free and
76
+ open framework in which fonts may be shared and improved in partnership
77
+ with others.
78
+
79
+ The OFL allows the licensed fonts to be used, studied, modified and
80
+ redistributed freely as long as they are not sold by themselves. The
81
+ fonts, including any derivative works, can be bundled, embedded,
82
+ redistributed and/or sold with any software provided that any reserved
83
+ names are not used by derivative works. The fonts and derivatives,
84
+ however, cannot be released under any other type of license. The
85
+ requirement for fonts to remain under this license does not apply
86
+ to any document created using the fonts or their derivatives.
87
+
88
+ DEFINITIONS
89
+ "Font Software" refers to the set of files released by the Copyright
90
+ Holder(s) under this license and clearly marked as such. This may
91
+ include source files, build scripts and documentation.
92
+
93
+ "Reserved Font Name" refers to any names specified as such after the
94
+ copyright statement(s).
95
+
96
+ "Original Version" refers to the collection of Font Software components as
97
+ distributed by the Copyright Holder(s).
98
+
99
+ "Modified Version" refers to any derivative made by adding to, deleting,
100
+ or substituting -- in part or in whole -- any of the components of the
101
+ Original Version, by changing formats or by porting the Font Software to a
102
+ new environment.
103
+
104
+ "Author" refers to any designer, engineer, programmer, technical
105
+ writer or other person who contributed to the Font Software.
106
+
107
+ PERMISSION & CONDITIONS
108
+ Permission is hereby granted, free of charge, to any person obtaining
109
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
110
+ redistribute, and sell modified and unmodified copies of the Font
111
+ Software, subject to the following conditions:
112
+
113
+ 1) Neither the Font Software nor any of its individual components,
114
+ in Original or Modified Versions, may be sold by itself.
115
+
116
+ 2) Original or Modified Versions of the Font Software may be bundled,
117
+ redistributed and/or sold with any software, provided that each copy
118
+ contains the above copyright notice and this license. These can be
119
+ included either as stand-alone text files, human-readable headers or
120
+ in the appropriate machine-readable metadata fields within text or
121
+ binary files as long as those fields can be easily viewed by the user.
122
+
123
+ 3) No Modified Version of the Font Software may use the Reserved Font
124
+ Name(s) unless explicit written permission is granted by the corresponding
125
+ Copyright Holder. This restriction only applies to the primary font name as
126
+ presented to the users.
127
+
128
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
129
+ Software shall not be used to promote, endorse or advertise any
130
+ Modified Version, except to acknowledge the contribution(s) of the
131
+ Copyright Holder(s) and the Author(s) or with their explicit written
132
+ permission.
133
+
134
+ 5) The Font Software, modified or unmodified, in part or in whole,
135
+ must be distributed entirely under this license, and must not be
136
+ distributed under any other license. The requirement for fonts to
137
+ remain under this license does not apply to any document created
138
+ using the Font Software.
139
+
140
+ TERMINATION
141
+ This license becomes null and void if any of the above conditions are
142
+ not met.
143
+
144
+ DISCLAIMER
145
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
146
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
147
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
148
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
149
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
150
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
151
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
152
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
153
+ OTHER DEALINGS IN THE FONT SOFTWARE.
package/assets/icon.icns CHANGED
Binary file
package/assets/icon.ico CHANGED
Binary file
package/assets/icon.png CHANGED
Binary file
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": "~/Notes",
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,6 +21,7 @@
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
27
  export const SERVER_VERSION = "0.5.0";
@@ -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
- vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
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
- ? join(vault, "Systems", "Zotero agent.md")
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(cfg.vault && existsSync(cfg.vault)
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): notes folder, tag list, contact email. Other lines stay;
10
- * - in the notes folder: Inbox/, Systems/Zotero tags.md (a starter list) and
11
- * Systems/Zotero agent.md (note formats). Existing files are never replaced.
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, --notes, --profile, --tags
15
- * health-sciences|health-informatics|any-field, --email, --starbuck on|off,
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
- io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
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
- const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
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
- const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(), home);
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 (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
88
- const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
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
- tags = await io.choose("Starter tag list (edit it later in the notes folder)", Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })), flags.tags ?? (fmup ? "health-sciences" : "any-field"));
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
- const email = (await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
97
- const starbuck = await io.choose("Reference checks (Starbuck): check that cited works exist, match their citation and were not retracted", [
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 (the researcher gets /verify; uses the email above)" },
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 (/login, then /model)" },
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
- for (const dir of [notes, join(notes, "Inbox"), join(notes, "Systems")]) {
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 rules = join(notes, "Systems", "Zotero agent.md");
122
- if (!existsSync(rules)) {
123
- copyFileSync(join(TEMPLATES, "Zotero agent.md"), rules);
124
- created.push(rules);
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
- const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
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,91 @@
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 folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
28
+ export function subsubFolder(answer) {
29
+ return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
30
+ }
31
+ /** The path of a settings file: Zotero/<file>, or Systems/<file> while only the old one exists. */
32
+ export function settingsFile(folder, which) {
33
+ const name = SETTINGS_FILES[which];
34
+ const current = join(folder, SETTINGS_DIR, name);
35
+ const legacy = join(folder, LEGACY_SETTINGS_DIR, name);
36
+ return !existsSync(current) && existsSync(legacy) ? legacy : current;
37
+ }
38
+ /** Every place a settings file may be, for the write gate. */
39
+ export function settingsPaths(folder) {
40
+ return Object.values(SETTINGS_FILES).flatMap((n) => [join(folder, SETTINGS_DIR, n), join(folder, LEGACY_SETTINGS_DIR, n)]);
41
+ }
42
+ /** Inbox notes that Sub-Sub and the Zotero server write. */
43
+ const SUBSUB_INBOX = /^(Zotero |Literature alerts|Copy request )/;
44
+ /**
45
+ * What to move from the old layout (`from`: the old notes folder) to the new one
46
+ * (`to`: the Sub-Sub folder). Only files that Sub-Sub made, and never over an
47
+ * existing file:
48
+ * - the settings files, from Systems/ to Zotero/;
49
+ * - when the Sub-Sub folder is new (the old folder was a whole vault): Sub-Sub's
50
+ * notes in Inbox/. Literature/, Syntheses/ and Research/ of a vault may hold the
51
+ * user's own notes, so they stay; the user moves them.
52
+ */
53
+ export function planMoves(from, to) {
54
+ const moves = [];
55
+ const add = (src, dest) => {
56
+ if (existsSync(src) && !existsSync(dest) && !moves.some((m) => m.to === dest))
57
+ moves.push({ from: src, to: dest });
58
+ };
59
+ for (const name of Object.values(SETTINGS_FILES)) {
60
+ add(join(from, LEGACY_SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
61
+ if (from !== to)
62
+ add(join(from, SETTINGS_DIR, name), join(to, SETTINGS_DIR, name));
63
+ }
64
+ if (from !== to) {
65
+ const inbox = join(from, "Inbox");
66
+ if (existsSync(inbox)) {
67
+ for (const f of readdirSync(inbox).sort()) {
68
+ if (SUBSUB_INBOX.test(f) && statSync(join(inbox, f)).isFile())
69
+ add(join(inbox, f), join(to, "Inbox", f));
70
+ }
71
+ }
72
+ }
73
+ return moves;
74
+ }
75
+ /** Folders of the old layout that may hold Sub-Sub's notes but are left for the user. */
76
+ export function leftBehind(from, to) {
77
+ if (from === to)
78
+ return [];
79
+ return ["Literature", "Syntheses", "Research"].map((d) => join(from, d)).filter((d) => existsSync(d) && readdirSync(d).length > 0);
80
+ }
81
+ /** Do the moves. An old Systems/ folder that is now empty is removed. */
82
+ export function applyMoves(moves) {
83
+ for (const m of moves) {
84
+ mkdirSync(dirname(m.to), { recursive: true });
85
+ renameSync(m.from, m.to);
86
+ }
87
+ for (const dir of new Set(moves.map((m) => dirname(m.from)))) {
88
+ if (basename(dir) === LEGACY_SETTINGS_DIR && existsSync(dir) && readdirSync(dir).length === 0)
89
+ rmdirSync(dir);
90
+ }
91
+ }
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"), join(v, "Systems", "Zotero agent.md"), join(v, "Systems", "Zotero tags.md"), join(v, "Systems", "Zotero librarian.md"), join(v, "Systems", "Zotero researcher.md"), join(v, "Systems", "Literature alerts.md"));
76
+ 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 notes folder, the vault
4
- * conventions when pi runs inside the notes folder, and the language rule.
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 notes folder)", shared, `Sub-Sub note: in Sub-Sub you do not need a separate dry run before a change. Call the write tool with dry_run=false; Sub-Sub shows ${user} the server's preview and applies it only on approval. This replaces the dry-run steps above.`);
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/web.js CHANGED
@@ -591,11 +591,11 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
591
591
  const body = await readJson(req);
592
592
  const target = resolve(cwd, expand(String(body.path ?? "")));
593
593
  const vault = cfg.vault;
594
- // Judge the real file, so a link inside the notes folder cannot open a document outside it.
594
+ // Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
595
595
  const real = realish(target);
596
596
  const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
597
597
  if (!inside || !existsSync(target))
598
- return send(res, 404, { error: "not found in the notes folder" });
598
+ return send(res, 404, { error: "not found in the Sub-Sub folder" });
599
599
  // Documents only: a file that the model wrote must never be run.
600
600
  if (!OPENABLE.has(extname(target).toLowerCase()))
601
601
  return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });