@tiagojct/subsub 0.9.0 → 0.10.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 +14 -16
- package/dist/args.js +23 -0
- package/dist/bench.js +8 -7
- package/dist/bridge.js +2 -2
- package/dist/config.js +17 -10
- package/dist/doctor.js +2 -2
- package/dist/init.js +25 -13
- package/dist/layout.js +9 -0
- package/dist/look.js +5 -5
- package/dist/profiles.js +6 -6
- package/dist/prompt.js +7 -4
- package/dist/roles.js +26 -17
- package/dist/subsub.js +71 -43
- package/dist/web.js +8 -11
- package/docs/Subsub.md +16 -16
- package/package.json +1 -1
- package/prompts/alert.md +1 -1
- package/prompts/clean-tags.md +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/digest.md +2 -2
- package/prompts/gaps.md +2 -2
- package/prompts/import-queue.md +1 -1
- package/prompts/lit-note.md +1 -1
- package/prompts/lit.md +2 -2
- package/prompts/manuscript.md +1 -1
- package/prompts/request-copy.md +1 -1
- package/prompts/review.md +1 -1
- package/prompts/synthesis.md +1 -1
- package/prompts/tag-batch.md +1 -1
- package/prompts/verify.md +1 -1
- package/roles/assistant.md +95 -0
- package/src/args.ts +24 -0
- package/src/bench.ts +12 -7
- package/src/bridge.ts +2 -2
- package/src/config.ts +25 -17
- package/src/doctor.ts +2 -2
- package/src/init.ts +32 -12
- package/src/layout.ts +8 -0
- package/src/look.ts +7 -7
- package/src/profiles.ts +6 -6
- package/src/prompt.ts +10 -5
- package/src/roles.ts +38 -20
- package/src/subsub.ts +72 -41
- package/src/web.ts +7 -9
- package/templates/Zotero agent.md +6 -6
- package/web/app.css +16 -22
- package/web/app.js +37 -26
- package/web/i18n.js +37 -35
- package/web/index.html +2 -6
- package/roles/librarian.md +0 -46
- package/roles/researcher.md +0 -50
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/bench.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* with no changes applied. See docs/Subsub.md ("Model test").
|
|
4
4
|
*
|
|
5
5
|
* subsub-bench prepare [--seed 7] pick the tasks (read-only)
|
|
6
|
-
* subsub-bench run [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
|
|
6
|
+
* subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
|
|
7
|
+
* (--models sets the models for both task groups.)
|
|
7
8
|
* subsub-bench score objective checks -> results.md
|
|
8
9
|
*
|
|
9
10
|
* Results go to ~/Projects/subsub/bench-results/<date>/ (or --dir). Each model gets
|
|
@@ -16,7 +17,10 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
16
17
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
18
|
import { dirname, join, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { expand, loadConfig,
|
|
20
|
+
import { expand, loadConfig, starbuckCommand, starbuckEnabled } from "./config.ts";
|
|
21
|
+
|
|
22
|
+
/** Task groups: library work and research. Every run has library changes on (each change is recorded, not applied). */
|
|
23
|
+
export type Mode = "librarian" | "researcher";
|
|
20
24
|
import { findUv } from "./subsub.ts";
|
|
21
25
|
|
|
22
26
|
export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -134,7 +138,7 @@ export function runOne(opts: {
|
|
|
134
138
|
}): Promise<RunSummary> {
|
|
135
139
|
mkdirSync(opts.out, { recursive: true });
|
|
136
140
|
const args = ["--mode", "rpc", "--no-session", "--provider", opts.provider ?? "opencode-go", "--model", opts.model];
|
|
137
|
-
|
|
141
|
+
args.push("--librarian"); // library changes on for every task
|
|
138
142
|
const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
|
|
139
143
|
const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
|
|
140
144
|
const events: any[] = [];
|
|
@@ -584,9 +588,10 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
584
588
|
if (!existsSync(tasksFile)) throw new Error(`No tasks in ${dir}. Run: subsub-bench prepare`);
|
|
585
589
|
if (!cfg.vault) throw new Error("No vault configured (ZOTERO_VAULT).");
|
|
586
590
|
const t: Tasks = JSON.parse(readFileSync(tasksFile, "utf8"));
|
|
591
|
+
const both = arg(argv, "models")?.split(",").filter(Boolean);
|
|
587
592
|
const lists: Record<Mode, string[]> = {
|
|
588
|
-
librarian: arg(argv, "librarian")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.librarian,
|
|
589
|
-
researcher: arg(argv, "researcher")?.split(",").filter(Boolean) ?? DEFAULT_MODELS.researcher,
|
|
593
|
+
librarian: arg(argv, "librarian")?.split(",").filter(Boolean) ?? both ?? DEFAULT_MODELS.librarian,
|
|
594
|
+
researcher: arg(argv, "researcher")?.split(",").filter(Boolean) ?? both ?? DEFAULT_MODELS.researcher,
|
|
590
595
|
};
|
|
591
596
|
const only = arg(argv, "only")?.split(",");
|
|
592
597
|
const codes = codesFor([...new Set([...lists.librarian, ...lists.researcher])], dir);
|
|
@@ -606,7 +611,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
606
611
|
const prompt = promptFor(j.task, t, j.code, out);
|
|
607
612
|
if (!prompt) return;
|
|
608
613
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
609
|
-
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor",
|
|
614
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", model: `opencode-go/${j.model}`, models: undefined, libraryChanges: true }));
|
|
610
615
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
611
616
|
n++;
|
|
612
617
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
|
@@ -634,6 +639,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
634
639
|
console.log(`Wrote ${join(dir, "results.md")}`);
|
|
635
640
|
return;
|
|
636
641
|
}
|
|
637
|
-
console.log("Usage: subsub-bench prepare | run [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
|
|
642
|
+
console.log("Usage: subsub-bench prepare | run [--models a,b] [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
|
|
638
643
|
}
|
|
639
644
|
|
package/src/bridge.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bridge to the Python MCP servers of zotero-local-mcp (
|
|
3
|
-
*
|
|
2
|
+
* Bridge to the Python MCP servers of zotero-local-mcp ("zotero" for the library,
|
|
3
|
+
* "scholar" for searches and notes). Starts them over stdio, lists their tools, and calls them.
|
|
4
4
|
* All safety logic (dry runs, version checks, journal, vocabulary, the researcher's
|
|
5
5
|
* note-only client) stays in the Python servers.
|
|
6
6
|
*/
|
package/src/config.ts
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* "profile": "scholar",
|
|
7
7
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
8
|
* "vault": "~/Documents/Sub-Sub",
|
|
9
|
-
* "
|
|
10
|
-
* "
|
|
9
|
+
* "model": "opencode-go/mimo-v2.6-pro",
|
|
10
|
+
* "libraryChanges": true
|
|
11
11
|
* }
|
|
12
12
|
*
|
|
13
|
+
* Before 0.10 there were two modes, each with a model ("models": {"librarian",
|
|
14
|
+
* "researcher"}) and "defaultMode". Those keys are still read: the researcher's
|
|
15
|
+
* model becomes the model.
|
|
16
|
+
*
|
|
13
17
|
* `subsub init` writes this file. Without "serverDir" (or when that folder does
|
|
14
18
|
* not exist), the Zotero servers run from PyPI with uv.
|
|
15
19
|
*
|
|
@@ -24,12 +28,13 @@ import { homedir } from "node:os";
|
|
|
24
28
|
import { dirname, join, resolve } from "node:path";
|
|
25
29
|
import { isObsidianVault, settingsFile } from "./layout.ts";
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
/** The tested model (September 2026 model test). */
|
|
32
|
+
export const DEFAULT_MODEL = "opencode-go/mimo-v2.6-pro";
|
|
28
33
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
29
34
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
30
35
|
|
|
31
36
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
32
|
-
export const SERVER_VERSION = "0.5.
|
|
37
|
+
export const SERVER_VERSION = "0.5.1";
|
|
33
38
|
|
|
34
39
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
35
40
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -51,8 +56,10 @@ export interface SubsubConfig {
|
|
|
51
56
|
serverDir: string;
|
|
52
57
|
envFile?: string;
|
|
53
58
|
vault?: string;
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
/** The model, as provider/id. Undefined: pi's own choice (the user picks with /model). */
|
|
60
|
+
model?: string;
|
|
61
|
+
/** Library changes on: the Zotero write tools are offered, each with a preview. */
|
|
62
|
+
libraryChanges: boolean;
|
|
56
63
|
/** Extra context files added to the system prompt when pi runs inside the vault. */
|
|
57
64
|
vaultContext: string[];
|
|
58
65
|
/** Shared rules file (tag system, citekeys, note formats). */
|
|
@@ -65,8 +72,8 @@ export interface SubsubConfig {
|
|
|
65
72
|
look?: boolean;
|
|
66
73
|
/** A line from Moby-Dick under the banner (default true). */
|
|
67
74
|
quotes?: boolean;
|
|
68
|
-
/** Theme
|
|
69
|
-
themes?:
|
|
75
|
+
/** Theme, without -dark/-light; false keeps your pi theme. */
|
|
76
|
+
themes?: string | false;
|
|
70
77
|
/** Optional servers to start, e.g. ["starbuck"]. */
|
|
71
78
|
addons?: string[];
|
|
72
79
|
/** Local Starbuck folder (used when it has a pyproject.toml). */
|
|
@@ -125,7 +132,7 @@ export function isProfile(x: unknown): x is Profile {
|
|
|
125
132
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
126
133
|
const home = env.HOME;
|
|
127
134
|
const file = configPath(env);
|
|
128
|
-
let user:
|
|
135
|
+
let user: Record<string, any> = {};
|
|
129
136
|
if (existsSync(file)) {
|
|
130
137
|
try {
|
|
131
138
|
user = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -145,7 +152,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
145
152
|
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
146
153
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
147
154
|
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
148
|
-
|
|
155
|
+
// Before 0.10: a model per mode, and themes per mode.
|
|
156
|
+
const legacyModels = user.models && typeof user.models === "object" ? (user.models as Record<string, string>) : undefined;
|
|
157
|
+
// "model": "" means: the user chooses in pi (/login, /model).
|
|
158
|
+
const model = "model" in user ? (typeof user.model === "string" && user.model) || undefined : legacyModels ? legacyModels.researcher || legacyModels.librarian || undefined : DEFAULT_MODEL;
|
|
159
|
+
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
149
160
|
return {
|
|
150
161
|
configFile: file,
|
|
151
162
|
profile: isProfile(user.profile) ? user.profile : "scholar",
|
|
@@ -156,16 +167,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
156
167
|
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
157
168
|
envFile,
|
|
158
169
|
vault,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
researcher: "opencode-go/mimo-v2.6-pro",
|
|
162
|
-
},
|
|
163
|
-
defaultMode: mode,
|
|
170
|
+
model,
|
|
171
|
+
libraryChanges: user.libraryChanges !== false,
|
|
164
172
|
// A Sub-Sub folder inside an Obsidian vault also reads the vault's context file.
|
|
165
173
|
vaultContext: (
|
|
166
174
|
user.vaultContext ??
|
|
167
175
|
(vault ? [join(vault, ".claude", "CLAUDE.md"), ...(isObsidianVault(dirname(vault)) ? [join(dirname(vault), ".claude", "CLAUDE.md")] : [])] : [])
|
|
168
|
-
).map((p) => expand(p, home)),
|
|
176
|
+
).map((p: string) => expand(p, home)),
|
|
169
177
|
sharedRules: user.sharedRules
|
|
170
178
|
? expand(user.sharedRules, home)
|
|
171
179
|
: vault
|
|
@@ -175,7 +183,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
175
183
|
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
176
184
|
look: user.look,
|
|
177
185
|
quotes: user.quotes,
|
|
178
|
-
themes
|
|
186
|
+
themes,
|
|
179
187
|
addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
|
|
180
188
|
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
181
189
|
};
|
package/src/doctor.ts
CHANGED
|
@@ -134,7 +134,7 @@ export function runChecks(
|
|
|
134
134
|
fix: v.problems?.length
|
|
135
135
|
? `Fix these lines in the tag list: ${v.problems.slice(0, 3).join("; ")}`
|
|
136
136
|
: noTopics
|
|
137
|
-
? "In Sub-Sub
|
|
137
|
+
? "In Sub-Sub, ask: propose topics for my tag list."
|
|
138
138
|
: undefined,
|
|
139
139
|
});
|
|
140
140
|
}
|
|
@@ -148,7 +148,7 @@ export function runChecks(
|
|
|
148
148
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
149
149
|
out.push(
|
|
150
150
|
hasLogin(agentDir)
|
|
151
|
-
? { name: "Model login", ok: true, detail:
|
|
151
|
+
? { name: "Model login", ok: true, detail: cfg.model ?? "pi's default model" }
|
|
152
152
|
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Type subsub, then /login." },
|
|
153
153
|
);
|
|
154
154
|
return out;
|
package/src/init.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Writes:
|
|
6
6
|
* - the settings file (~/.config/subsub/config.json): profile, name, language,
|
|
7
|
-
*
|
|
7
|
+
* Sub-Sub folder, library changes, model;
|
|
8
8
|
* - the server settings file (~/.config/zotero-local-mcp/env, or the one that
|
|
9
9
|
* already exists): the Sub-Sub folder, tag list, alerts file, contact email.
|
|
10
10
|
* Other lines stay;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
21
|
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
-
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
22
|
+
* --library on|off, --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -29,6 +29,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import {
|
|
31
31
|
configPath,
|
|
32
|
+
DEFAULT_MODEL,
|
|
32
33
|
expand,
|
|
33
34
|
isProfile,
|
|
34
35
|
loadConfig,
|
|
@@ -49,10 +50,10 @@ const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templa
|
|
|
49
50
|
export const STARTER_TAGS: Record<string, string> = {
|
|
50
51
|
"health-sciences": "Health sciences: clinical areas, public health, methods and study designs",
|
|
51
52
|
"health-informatics": "Health informatics: information systems, data, AI, digital health",
|
|
52
|
-
"any-field": "Any field: methods and document types only;
|
|
53
|
+
"any-field": "Any field: methods and document types only; Sub-Sub proposes topics from your library",
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
export const
|
|
56
|
+
export const TESTED_MODEL = DEFAULT_MODEL;
|
|
56
57
|
|
|
57
58
|
export interface InitIO {
|
|
58
59
|
ask(question: string, def: string): Promise<string>;
|
|
@@ -184,6 +185,19 @@ export async function runInit(
|
|
|
184
185
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
185
186
|
);
|
|
186
187
|
|
|
188
|
+
explain(
|
|
189
|
+
"Library changes",
|
|
190
|
+
"With library changes on, Sub-Sub can import, tag and repair items in Zotero. It shows a preview of every change, and nothing changes until you select Yes. With them off, it only reads, searches and writes notes. Switch at any time with /library on or /library off.",
|
|
191
|
+
);
|
|
192
|
+
const library = await io.choose(
|
|
193
|
+
"Library changes",
|
|
194
|
+
[
|
|
195
|
+
{ value: "on", label: "On (recommended): every change shows a preview first" },
|
|
196
|
+
{ value: "off", label: "Off: read, search and write notes only" },
|
|
197
|
+
],
|
|
198
|
+
flags.library ?? (existing.libraryChanges === false ? "off" : "on"),
|
|
199
|
+
);
|
|
200
|
+
|
|
187
201
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
188
202
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
189
203
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -198,7 +212,7 @@ export async function runInit(
|
|
|
198
212
|
io.say("");
|
|
199
213
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
200
214
|
} else {
|
|
201
|
-
explain("Tag list", `
|
|
215
|
+
explain("Tag list", `Sub-Sub tags items only with the tags in this list. Pick a start; you edit the list later in ${SETTINGS_DIR}/${SETTINGS_FILES.tags}.`);
|
|
202
216
|
tags = await io.choose(
|
|
203
217
|
"Starter tag list",
|
|
204
218
|
Object.entries(STARTER_TAGS).map(([value, label]) => ({ value, label })),
|
|
@@ -209,7 +223,7 @@ export async function runInit(
|
|
|
209
223
|
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
224
|
const email = (await io.ask("Email (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")).trim();
|
|
211
225
|
|
|
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.
|
|
226
|
+
explain("Reference checks (Starbuck)", "An optional add-on for manuscripts: it checks that each cited work exists, matches its citation and was not retracted. Sub-Sub then has /verify.");
|
|
213
227
|
const starbuck = await io.choose(
|
|
214
228
|
"Reference checks",
|
|
215
229
|
[
|
|
@@ -220,14 +234,15 @@ export async function runInit(
|
|
|
220
234
|
);
|
|
221
235
|
|
|
222
236
|
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).");
|
|
237
|
+
const hadModel = "model" in existing || "models" in existing;
|
|
223
238
|
const modelOptions = [
|
|
224
|
-
...(
|
|
239
|
+
...(hadModel ? [{ value: "keep", label: `Keep: ${current.model ?? "choose in Sub-Sub"}` }] : []),
|
|
225
240
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
226
|
-
{ value: "opencode-go", label: `OpenCode Go, tested: ${
|
|
241
|
+
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODEL}` },
|
|
227
242
|
];
|
|
228
243
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
229
|
-
const modelDefault =
|
|
230
|
-
const models = await io.choose("
|
|
244
|
+
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
245
|
+
const models = await io.choose("Model", modelOptions, flags.models ?? modelDefault);
|
|
231
246
|
|
|
232
247
|
// ---- write
|
|
233
248
|
if (move === "yes") applyMoves(moves);
|
|
@@ -263,10 +278,15 @@ export async function runInit(
|
|
|
263
278
|
setup: fmup ? "fmup" : undefined,
|
|
264
279
|
vault: notes,
|
|
265
280
|
envFile,
|
|
281
|
+
libraryChanges: library === "on",
|
|
282
|
+
defaultMode: undefined,
|
|
266
283
|
addons: withStarbuck(current.addons, starbuck === "on"),
|
|
267
284
|
};
|
|
268
|
-
|
|
269
|
-
|
|
285
|
+
// One model since 0.10: the per-mode models are replaced.
|
|
286
|
+
patch.models = undefined;
|
|
287
|
+
if (models === "keep") patch.model = current.model ?? "";
|
|
288
|
+
if (models === "later") patch.model = "";
|
|
289
|
+
if (models === "opencode-go") patch.model = TESTED_MODEL;
|
|
270
290
|
saveConfig(patch, initEnv);
|
|
271
291
|
|
|
272
292
|
const zotero = await zoteroReachable(deps.fetch);
|
package/src/layout.ts
CHANGED
|
@@ -30,6 +30,14 @@ export function isObsidianVault(dir: string): boolean {
|
|
|
30
30
|
return existsSync(join(dir, ".obsidian"));
|
|
31
31
|
}
|
|
32
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
|
+
|
|
33
41
|
/** The folder that a user's answer means: a vault root becomes <vault>/Sub-Sub. */
|
|
34
42
|
export function subsubFolder(answer: string): string {
|
|
35
43
|
return isObsidianVault(answer) && basename(answer) !== FOLDER_NAME ? join(answer, FOLDER_NAME) : answer;
|
package/src/look.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Only used when Sub-Sub runs as its own command in the terminal UI.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { Mode } from "./config.ts";
|
|
8
7
|
|
|
9
8
|
export const BANNER = [
|
|
10
9
|
"╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
|
|
@@ -25,7 +24,8 @@ export const QUOTES: Array<[string, string]> = [
|
|
|
25
24
|
["Call me Ishmael.", "Moby-Dick, ch. 1"],
|
|
26
25
|
];
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
/** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
|
|
28
|
+
export const DEFAULT_THEME = "subsub-glauca";
|
|
29
29
|
|
|
30
30
|
export type Scheme = "dark" | "light";
|
|
31
31
|
|
|
@@ -47,8 +47,7 @@ export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
|
47
47
|
return "dark";
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function themeName(
|
|
51
|
-
const base = map[mode];
|
|
50
|
+
export function themeName(scheme: Scheme, base: string | undefined = DEFAULT_THEME): string | undefined {
|
|
52
51
|
return base ? `${base}-${scheme}` : undefined;
|
|
53
52
|
}
|
|
54
53
|
|
|
@@ -84,7 +83,8 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
|
|
|
84
83
|
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
85
84
|
export function headerLines(opts: {
|
|
86
85
|
version: string;
|
|
87
|
-
|
|
86
|
+
/** Library changes on (false: read only). */
|
|
87
|
+
libraryChanges: boolean;
|
|
88
88
|
/** Profile label, e.g. "Scholar". */
|
|
89
89
|
profile?: string;
|
|
90
90
|
model: string;
|
|
@@ -95,7 +95,7 @@ export function headerLines(opts: {
|
|
|
95
95
|
}): string[] {
|
|
96
96
|
const p = opts.paint ?? plain;
|
|
97
97
|
const w = Math.max(10, opts.width);
|
|
98
|
-
const modeLabel =
|
|
98
|
+
const modeLabel = `Assistant${opts.profile ? `, ${opts.profile}` : ""}${opts.libraryChanges ? "" : ", read only"}`;
|
|
99
99
|
const lib = libraryLine(opts.library);
|
|
100
100
|
if (w < BANNER[0].length + 2) {
|
|
101
101
|
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), (lib.warn ? p.warn : p.muted)(cut(lib.text, w))];
|
|
@@ -108,7 +108,7 @@ export function headerLines(opts: {
|
|
|
108
108
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
109
109
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
110
110
|
}
|
|
111
|
-
out.push(p.dim(cut("/
|
|
111
|
+
out.push(p.dim(cut("/library /profile /history /undo | / for all commands", w)), "");
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
114
|
|
package/src/profiles.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - reader: explains each step; reading notes are quotes with page numbers and
|
|
7
7
|
* questions; no summaries or syntheses; no bulk library changes.
|
|
8
|
-
* - scholar: literature notes, syntheses, searches, alerts;
|
|
8
|
+
* - scholar: literature notes, syntheses, searches, alerts; all library changes.
|
|
9
9
|
* - author: scholar plus manuscript work: citation check, bibliography, comments
|
|
10
10
|
* on the argument. Does not write the manuscript's paragraphs.
|
|
11
11
|
* - editor: everything, including drafting text for a manuscript on request.
|
|
@@ -46,13 +46,13 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
46
46
|
"- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
|
|
47
47
|
"- For searches, list works with one line each on what they study, not on what they conclude.",
|
|
48
48
|
"- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
|
|
49
|
-
"-
|
|
49
|
+
"- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
|
|
50
50
|
].join("\n"),
|
|
51
51
|
},
|
|
52
52
|
scholar: {
|
|
53
53
|
name: "scholar",
|
|
54
54
|
label: "Scholar",
|
|
55
|
-
summary: "literature notes, syntheses, searches and alerts;
|
|
55
|
+
summary: "literature notes, syntheses, searches and alerts; all library changes",
|
|
56
56
|
without: MANUSCRIPT,
|
|
57
57
|
withoutPrompts: ["review"],
|
|
58
58
|
batch: 25,
|
|
@@ -60,7 +60,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
60
60
|
"Profile: Scholar.",
|
|
61
61
|
"- Write literature notes and syntheses from what you read, with a source for every claim.",
|
|
62
62
|
"- Do not draft text for {{user}}'s manuscripts, theses or assignments. If asked, say that the Author and Editor profiles help with manuscripts (/profile).",
|
|
63
|
-
"-
|
|
63
|
+
"- Tag review notes: 25 items.",
|
|
64
64
|
].join("\n"),
|
|
65
65
|
},
|
|
66
66
|
author: {
|
|
@@ -75,7 +75,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
75
75
|
"- Write literature notes and syntheses from what you read, with a source for every claim.",
|
|
76
76
|
"- Help with {{user}}'s manuscripts: check citations against the library, export the bibliography, and comment on the argument, the structure and the evidence that is missing, with the sources that could support each point.",
|
|
77
77
|
"- Do not write the manuscript's paragraphs. Say what is missing and where the support is; {{user}} writes the text. The Editor profile drafts text (/profile).",
|
|
78
|
-
"-
|
|
78
|
+
"- Tag review notes: 25 items.",
|
|
79
79
|
].join("\n"),
|
|
80
80
|
},
|
|
81
81
|
editor: {
|
|
@@ -89,7 +89,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
|
|
|
89
89
|
"Profile: Editor. All tools.",
|
|
90
90
|
"- Write literature notes and syntheses from what you read, with a source for every claim.",
|
|
91
91
|
"- When {{user}} asks, draft text for a manuscript: cite library items as [@citekey], use only claims from sources you read, and mark drafted text clearly so that {{user}} can check it.",
|
|
92
|
-
"-
|
|
92
|
+
"- Tag review notes: 25 items unless {{user}} asks for more.",
|
|
93
93
|
].join("\n"),
|
|
94
94
|
},
|
|
95
95
|
};
|
package/src/prompt.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { type
|
|
13
|
+
import { type SubsubConfig, who } from "./config.ts";
|
|
14
14
|
import { profileSpec } from "./profiles.ts";
|
|
15
15
|
|
|
16
16
|
const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
|
|
@@ -52,14 +52,19 @@ export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
|
|
|
52
52
|
.replace(/[ \t]+\n/g, "\n");
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function roleText(
|
|
56
|
-
const raw = readIf(join(ROLE_DIR,
|
|
55
|
+
export function roleText(cfg?: Pick<SubsubConfig, "userName" | "about" | "profile">): string {
|
|
56
|
+
const raw = readIf(join(ROLE_DIR, "assistant.md")) ?? "You are Sub-Sub, a Zotero librarian and research assistant.";
|
|
57
57
|
return cfg ? fill(raw, cfg) : raw;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function systemAddition(
|
|
60
|
+
export function systemAddition(cfg: SubsubConfig, cwd: string, libraryChanges: boolean = cfg.libraryChanges): string {
|
|
61
61
|
const user = who(cfg);
|
|
62
|
-
const parts = [
|
|
62
|
+
const parts = ["# Sub-Sub", roleText(cfg)];
|
|
63
|
+
if (!libraryChanges) {
|
|
64
|
+
parts.push(
|
|
65
|
+
`Library changes are off: you read the library, search, write notes and queue imports. To import, tag or change items, ask ${user} to turn library changes on (/library on, or the switch in the browser view).`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
63
68
|
const shared = readIf(cfg.sharedRules);
|
|
64
69
|
if (shared) {
|
|
65
70
|
parts.push(
|
package/src/roles.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Which tools
|
|
2
|
+
* Which tools the Assistant may use, and which calls need the user's approval.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { Profile } from "./config.ts";
|
|
6
6
|
import { profileSpec } from "./profiles.ts";
|
|
7
7
|
|
|
8
8
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
9
9
|
|
|
10
|
-
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the
|
|
10
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the switch and the profile for these). */
|
|
11
11
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
12
12
|
|
|
13
13
|
export const ZOTERO_READ = [
|
|
@@ -48,27 +48,49 @@ export const PREVIEW_WRITES = new Set([
|
|
|
48
48
|
/** Writes without a dry run that still need a yes. */
|
|
49
49
|
export const CONFIRM_WRITES = new Set(["scholar_export_bibliography"]);
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/** Zotero tools that look at the library or write only a note in the Sub-Sub folder. */
|
|
52
|
+
export const ZOTERO_CHECKS = ["tag_audit", "audit_metadata", "find_duplicates", "check_retractions", "missing_pdfs", "write_tag_review"].map(
|
|
53
|
+
(n) => `zotero_${n}`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export interface ToolOptions {
|
|
57
|
+
/** Library changes on: the Zotero write tools are offered (each still previewed). */
|
|
58
|
+
libraryChanges: boolean;
|
|
59
|
+
profile?: Profile;
|
|
60
|
+
/** The model test: also the bakeoff_ tools. */
|
|
61
|
+
bench?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Assistant's tools: reading the library, searching, notes and reference
|
|
66
|
+
* checks always; the Zotero write tools when library changes are on. The profile
|
|
67
|
+
* then removes tools.
|
|
68
|
+
*/
|
|
69
|
+
export function toolsFor(registered: string[], opts: ToolOptions): string[] {
|
|
52
70
|
const has = new Set(registered);
|
|
53
|
-
const without = new Set(profileSpec(profile).without);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
72
|
+
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
73
|
+
const wanted = [
|
|
74
|
+
...ZOTERO_READ,
|
|
75
|
+
...ZOTERO_CHECKS,
|
|
76
|
+
...(opts.libraryChanges ? zotero : []),
|
|
77
|
+
...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
|
|
78
|
+
...FILE_TOOLS,
|
|
79
|
+
];
|
|
62
80
|
return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
66
|
-
export function unavailableReason(name: string,
|
|
67
|
-
if (toolsFor(
|
|
84
|
+
export function unavailableReason(name: string, registered: string[], opts: ToolOptions): string | undefined {
|
|
85
|
+
if (toolsFor(registered, opts).includes(name)) return undefined;
|
|
86
|
+
const profile = opts.profile ?? "editor";
|
|
68
87
|
if (profileSpec(profile).without.includes(name)) {
|
|
69
88
|
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
70
89
|
}
|
|
71
|
-
|
|
90
|
+
if (!opts.libraryChanges && toolsFor(registered, { ...opts, libraryChanges: true }).includes(name)) {
|
|
91
|
+
return `${name} changes the library, and library changes are off. The user can turn them on with /library on.`;
|
|
92
|
+
}
|
|
93
|
+
return `${name} is not available.`;
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
export type GateKind = "preview" | "confirm" | "path" | null;
|
|
@@ -84,7 +106,3 @@ export function gateKind(toolName: string, input: Record<string, unknown>): Gate
|
|
|
84
106
|
if (toolName === "write" || toolName === "edit") return "path";
|
|
85
107
|
return null;
|
|
86
108
|
}
|
|
87
|
-
|
|
88
|
-
export function otherMode(mode: Mode): Mode {
|
|
89
|
-
return mode === "librarian" ? "researcher" : "librarian";
|
|
90
|
-
}
|