@tiagojct/subsub 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: "Researcher: critical review of a manuscript (FATAL, MAJOR, MINOR issues, annotations, revision plan); comments only"
3
+ argument-hint: "<path to .qmd, .md or .docx>"
4
+ ---
5
+ Review the manuscript $1 as a critical reader before submission. Comment only: do not rewrite the user's paragraphs and do not change the file.
6
+
7
+ 1. Read the whole manuscript (the read tool; for .docx, ask the user to convert it with `quarto pandoc $1 -o draft.md`). Identify the study design and the reporting guideline that applies: CONSORT (randomised trials), STROBE (observational studies), PRISMA (systematic reviews), STARD (diagnostic accuracy), TRIPOD or TRIPOD+AI (prediction models), CHEERS (economic evaluations), SRQR or COREQ (qualitative research). Say which one you used and why.
8
+ 2. Check, with the line or section where each issue is:
9
+ - Question and aims: clear, and matched by the design.
10
+ - Reporting: the items of the guideline that are missing or incomplete.
11
+ - Risk of bias: selection, confounding, measurement, missing data, selective reporting.
12
+ - Outcomes: defined before analysis, measured the same way in all groups, clinically meaningful.
13
+ - Statistics: sample size, the right test or model, effect sizes with confidence intervals (not only P values), multiple comparisons, handling of missing data.
14
+ - Claims beyond the data: causal language for associations, results generalised past the population studied, conclusions not supported by the results.
15
+ - References: if the verify_ tools are available, run verify_check_manuscript on $1 and include its Fail and Check results. Do not judge whether a source supports a claim unless you read the source.
16
+ 3. Rank every issue:
17
+ - FATAL: the main conclusion does not follow, or the study cannot answer its question.
18
+ - MAJOR: needs new analysis, new data or substantial rewriting.
19
+ - MINOR: wording, presentation, small reporting gaps.
20
+ 4. Write the review next to the manuscript as `<name>-review.md` (do not overwrite; add -2). Structure:
21
+ - Summary (three to five sentences: design, main finding, overall judgement)
22
+ - Strengths
23
+ - FATAL issues, MAJOR issues, MINOR issues: each with its location, a short quotation of the text, the problem, and what would fix it
24
+ - Reporting checklist: the guideline items missing or incomplete
25
+ - Reference check (Starbuck results, or "not run")
26
+ - Revision plan: the order in which to fix the issues
27
+ Front matter: title, date, type: manuscript-review, manuscript (path), guideline, verification (PASS, PASS WITH NOTES or BLOCKED).
28
+ 5. Quote the manuscript exactly. Do not invent line numbers: use section names when the file has no line numbers in view.
29
+ 6. If a check cannot run, write "BLOCKED: <what failed>" in that section and keep going.
30
+
31
+ Give the user the path, the number of FATAL, MAJOR and MINOR issues, and the three most important ones.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: "Researcher: check the references of a manuscript or of a Sub-Sub report"
3
+ argument-hint: "<path to .qmd or .md> [claims]"
4
+ ---
5
+ Check the references of $1 with Starbuck.
6
+
7
+ 1. If the verify_ tools are not available, tell the user that reference checks need Starbuck: add "addons": ["starbuck"] to the Sub-Sub settings file and start Sub-Sub again. Then stop.
8
+ 2. Call verify_check_manuscript with path $1.
9
+ 3. Report the summary: how many references failed, need checking, have notes and passed.
10
+ 4. For each Fail and each Check, give the citation key, Starbuck's reason and its suggested action. Keep Starbuck's result; do not make it stronger or weaker.
11
+ 5. List the cited keys without a bibliography entry.
12
+ 6. Give the path of the HTML report.
13
+ 7. If the second argument is "claims", or the user asks whether the sources support the text:
14
+ 1. Call verify_prepare_claims with path $1. Get all pages (offset, limit).
15
+ 2. Judge each claim with status ready from its passages only: supported, partly_supported, not_supported or cannot_assess. Copy the passage you rely on character for character into quote.
16
+ 3. Call verify_record_claims once, with the verdicts of all claims.
17
+ 4. Report the claims that are not supported or only partly supported, with the line and the reason. Say for each whether the evidence was the full text or the abstract only.
18
+ 8. Do not change the manuscript. Offer fixes and wait for the user's answer.
@@ -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 and OpenAlex (scholar_ tools) and write notes in the notes 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.
3
+ You read the Zotero library (zotero_ read tools), search PubMed, Europe PMC and OpenAlex (scholar_ tools) and write notes in the notes 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
 
@@ -8,7 +8,13 @@ You read the Zotero library (zotero_ read tools), search PubMed and OpenAlex (sc
8
8
  - Every claim about a paper needs a source you actually read: the library (zotero_get_item, zotero_get_fulltext) or an outside record (scholar_get_work). Say which one.
9
9
  - Cite library items as [@citekey]. For works not in the library, give DOI or PMID and say that they are not in the library.
10
10
  - Do not invent references, numbers or quotes. If you are not sure, say so.
11
- - Prefer PubMed for clinical and physiology questions, OpenAlex for informatics, education and books.
11
+ - Read before you summarise. Do not describe a work's methods or results from its title, or from memory. If you read only the abstract, say so.
12
+ - Every literature note (front matter with a citekey) has evidence: full text, abstract or metadata. Give page numbers only when you read the full text. Sub-Sub refuses a note that breaks this rule.
13
+ - Mark inferences: write "inference:" before a statement that combines sources and that no single source makes.
14
+ - Never write "verified" or "checked" for a check you did not run.
15
+ - If a step fails (a service does not answer, a full text cannot be read), do not stop: finish the work, and mark the failed step "BLOCKED: <what failed>".
16
+ - Source order: the library first, then PubMed, Europe PMC and OpenAlex. Prefer PubMed and Europe PMC for clinical and physiology questions, OpenAlex for informatics, education and books.
17
+ - In reviews and comparisons, keep an evidence table with a number for each source that does not change ([1], [2]), and cite those numbers.
12
18
  - For scholar_attach_note and scholar_export_bibliography, Sub-Sub asks {{user}} before anything is written.
13
19
 
14
20
  ## Questions about the library
@@ -18,9 +24,10 @@ You read the Zotero library (zotero_ read tools), search PubMed and OpenAlex (sc
18
24
  ## Outside search and gaps
19
25
 
20
26
  1. If the question is vague, clarify it in one line.
21
- 2. Search PubMed and/or OpenAlex. Report total hits and how many are already in the library.
22
- 3. List the most relevant works not in the library, one line each on why it matters.
23
- 4. Ask which to queue, then call scholar_queue_imports.
27
+ 2. Search with scholar_search_multi (three or four phrasings, all sources in one call). Use scholar_search_pubmed, scholar_search_europepmc or scholar_search_openalex alone only for a precise query in that service's syntax. Report total hits and how many are already in the library.
28
+ 3. Read open-access full text with scholar_read_oa_fulltext: first the section list, then only the sections you need. When a work you need is not open access, say that it may open through {{user}}'s institution (the DOI link, opened on the institution's network), and offer /request-copy to ask the author for a copy. Never point to unlicensed copies (Sci-Hub, LibGen and similar).
29
+ 4. List the most relevant works not in the library, one line each on why it matters.
30
+ 5. Ask which to queue, then call scholar_queue_imports.
24
31
 
25
32
  ## Literature notes and syntheses
26
33
 
@@ -35,3 +42,9 @@ You read the Zotero library (zotero_ read tools), search PubMed and OpenAlex (sc
35
42
  1. scholar_check_manuscript on the file.
36
43
  2. Propose fixes for missing keys. Do not edit the manuscript text without approval.
37
44
  3. scholar_export_bibliography to the file named in the YAML header, or references.json next to the manuscript.
45
+
46
+ ## Reference checks (Starbuck, when the verify_ tools are available)
47
+
48
+ - verify_check_manuscript checks that each cited work exists, matches its citation and was not retracted. Use it on {{user}}'s manuscripts and on your own reports before you deliver them.
49
+ - Report Starbuck's results as Starbuck states them. Do not turn a Check into a Fail, or a Pass into "verified". Give the path of the report.
50
+ - Claim support (level 4) only when {{user}} asks: verify_prepare_claims, then judge each claim from its passages only, then verify_record_claims with all verdicts. Quote the passage you rely on character for character. With an abstract only, answer cannot_assess for details an abstract would not report.
package/src/bench.ts CHANGED
@@ -16,7 +16,7 @@ import { spawn, spawnSync } from "node:child_process";
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import { expand, loadConfig, type Mode } from "./config.ts";
19
+ import { expand, loadConfig, type Mode, starbuckCommand, starbuckEnabled } from "./config.ts";
20
20
  import { findUv } from "./subsub.ts";
21
21
 
22
22
  export const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -28,7 +28,7 @@ export const DEFAULT_MODELS: Record<Mode, string[]> = {
28
28
 
29
29
  export const TASKS: Record<Mode, string[]> = {
30
30
  librarian: ["tagging", "facet_fix", "import"],
31
- researcher: ["lit_note", "synthesis", "search"],
31
+ researcher: ["lit_note", "synthesis", "search", "lit"],
32
32
  };
33
33
 
34
34
  export interface Tasks {
@@ -85,6 +85,12 @@ export function promptFor(task: string, t: Tasks, code: string, out: string): st
85
85
  ? `Write a synthesis of what my library says about ${about(t)}, following the synthesis format in the shared rules. ` +
86
86
  `This is a model test: save it as ${join(out, "Synthesis.md")}.`
87
87
  : undefined;
88
+ case "lit":
89
+ // The /lit template, with the model-test paths in its arguments.
90
+ return t.synthesis
91
+ ? `/lit ${about(t)}. This is a model test: write the review as ${join(out, "Lit.md")}, the plan and the screening log ` +
92
+ `in ${join(out, ".plans")}, nothing in Research/, and do not queue imports.`
93
+ : undefined;
88
94
  case "search":
89
95
  return t.synthesis
90
96
  ? `Find up to 5 important papers published from ${t.search.year_from} onwards on ${about(t)} that are not yet in my library. ` +
@@ -300,7 +306,33 @@ export function citekeysIn(text: string): string[] {
300
306
 
301
307
  // ---------------------------------------------------------------- score one run
302
308
 
303
- export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[], blindRef: Record<string, string[]>): Record<string, any> {
309
+ /** Starbuck's verdicts on a file: runs `starbuck check` and reads its JSON report (undefined when it cannot run). */
310
+ export type StarbuckScorer = (file: string) => Record<string, any> | undefined;
311
+
312
+ export function starbuckScorer(cfg = loadConfig(), env: NodeJS.ProcessEnv = process.env): StarbuckScorer | undefined {
313
+ if (!starbuckEnabled(cfg)) return undefined;
314
+ const cmd = starbuckCommand(cfg, env);
315
+ return (file) => {
316
+ const claims = env.STARBUCK_JUDGE_URL && env.STARBUCK_JUDGE_MODEL ? ["--claims"] : [];
317
+ const args = [...cmd.args.slice(0, -1), "starbuck", "check", file, "--to", "", "--quiet", ...claims];
318
+ const r = spawnSync(cmd.command, args, { env, encoding: "utf8", timeout: 20 * 60_000 });
319
+ const report = join(dirname(file), "_starbuck", `${file.split(/[\\/]/).pop()!.replace(/\.[^.]+$/, "")}-references.json`);
320
+ if (!existsSync(report)) return { error: (r.stderr || r.error?.message || "no report").trim().slice(0, 300) };
321
+ const res = JSON.parse(readFileSync(report, "utf8"));
322
+ const run = res.claims_run ?? {};
323
+ return {
324
+ references: res.summary?.references_checked,
325
+ fail: res.summary?.fail,
326
+ check: res.summary?.warn,
327
+ uncited_sentences: res.summary?.uncited_sentences,
328
+ citation_recall: run.citation_recall?.value,
329
+ citation_precision: run.citation_precision?.value,
330
+ };
331
+ };
332
+ }
333
+
334
+ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[], blindRef: Record<string, string[]>,
335
+ starbuck?: StarbuckScorer): Record<string, any> {
304
336
  const events = readEvents(dir);
305
337
  const calls = toolCalls(events);
306
338
  const entries = benchEntries(events);
@@ -410,6 +442,29 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
410
442
  }
411
443
  return res;
412
444
  }
445
+ if (task === "lit") {
446
+ const text = readFile("Lit.md") ?? "";
447
+ const seen = calls.map((c) => c.result).join("\n").toLowerCase();
448
+ const dois = [...new Set([...text.matchAll(DOI_RE)].map((m) => normDoi(m[0])))];
449
+ const pmids = [...new Set([...text.matchAll(PMID_RE)].map((m) => m[1]))];
450
+ const rows = text.split("\n").filter((l) => /^\|\s*\[?\d+\]?\s*\|/.test(l));
451
+ const readCol = rows.map((l) => l.split("|").map((c) => c.trim().toLowerCase())).map((c) => c.find((x) => /^(full text|abstract|metadata)$/.test(x)));
452
+ const plans = join(dir, ".plans");
453
+ return {
454
+ ...base,
455
+ written: !!text,
456
+ words: text ? text.split(/\s+/).filter(Boolean).length : 0,
457
+ evidence_rows: rows.length,
458
+ evidence_labelled: readCol.filter(Boolean).length,
459
+ dois: dois.length,
460
+ pmids: pmids.length,
461
+ ungrounded: [...dois.filter((d) => !seen.includes(d)), ...pmids.filter((p) => !seen.includes(p))],
462
+ screening_log: existsSync(plans) && readdirSync(plans).some((f) => /screening/i.test(f)),
463
+ multi_searches: calls.filter((c) => c.name === "scholar_search_multi").length,
464
+ fulltext_reads: calls.filter((c) => c.name === "scholar_read_oa_fulltext" || c.name === "zotero_get_fulltext").length,
465
+ starbuck: text && starbuck ? starbuck(join(dir, "Lit.md")) : undefined,
466
+ };
467
+ }
413
468
  if (task === "search") {
414
469
  const text = readFile("Search.md") ?? "";
415
470
  const seen = calls.map((c) => c.result).join("\n").toLowerCase();
@@ -453,6 +508,9 @@ export function renderResults(rows: Array<{ model: string; role: Mode; task: str
453
508
  lit_note: ["written", "words", "read_fulltext", "cites_itself", "citekeys_unknown"],
454
509
  synthesis: ["written", "words", "topic_items", "topic_items_cited", "citekeys_unknown"],
455
510
  search: ["written", "dois", "pmids", "ungrounded", "already_in_library", "queued", "searches"],
511
+ lit: ["written", "words", "evidence_rows", "evidence_labelled", "dois", "pmids", "ungrounded", "screening_log",
512
+ "multi_searches", "fulltext_reads", "starbuck.fail", "starbuck.check", "starbuck.uncited_sentences",
513
+ "starbuck.citation_recall", "starbuck.citation_precision"],
456
514
  };
457
515
  const common = ["seconds", "tool_calls", "tool_errors", "wrong_mode", "tokens_in", "tokens_out", "cost", "error"];
458
516
  for (const task of Object.keys(cols)) {
@@ -562,12 +620,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
562
620
  const key: Record<string, string> = JSON.parse(readFileSync(join(dir, "key.json"), "utf8"));
563
621
  const blind = existsSync(join(dir, "reference.json")) ? JSON.parse(readFileSync(join(dir, "reference.json"), "utf8")) : {};
564
622
  const rows: Array<{ model: string; role: Mode; task: string; s: Record<string, any> }> = [];
623
+ const scorer = starbuckScorer(cfg);
565
624
  for (const code of Object.keys(key).sort()) {
566
625
  for (const role of ["librarian", "researcher"] as Mode[])
567
626
  for (const task of TASKS[role]) {
568
627
  const d = join(dir, "runs", code, task);
569
628
  if (!existsSync(join(d, "summary.json"))) continue;
570
- rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, blind) });
629
+ rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, blind, scorer) });
571
630
  }
572
631
  }
573
632
  writeFileSync(join(dir, "results.json"), JSON.stringify(rows, null, 1));
package/src/config.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  *
13
13
  * `subsub init` writes this file. Without "serverDir" (or when that folder does
14
14
  * not exist), the Zotero servers run from PyPI with uv.
15
+ *
16
+ * Add-ons: "addons": ["starbuck"] starts Starbuck (reference checks) as a third
17
+ * server, "verify". It runs from "starbuckDir" when that folder has a
18
+ * pyproject.toml, otherwise from the pinned PyPI release (STARBUCK_SOURCE).
19
+ * `subsub init` and the web view turn it on and off.
15
20
  */
16
21
 
17
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -23,7 +28,11 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
23
28
  export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
24
29
 
25
30
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
26
- export const SERVER_VERSION = "0.4.1";
31
+ export const SERVER_VERSION = "0.5.0";
32
+
33
+ /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
34
+ export const STARBUCK_VERSION = "0.2.0";
35
+ export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
27
36
 
28
37
  export interface SubsubConfig {
29
38
  /** The settings file (written by `subsub init` and `/profile`). */
@@ -57,6 +66,10 @@ export interface SubsubConfig {
57
66
  quotes?: boolean;
58
67
  /** Theme per mode, without -dark/-light; false keeps your pi theme. */
59
68
  themes?: Partial<Record<Mode, string>> | false;
69
+ /** Optional servers to start, e.g. ["starbuck"]. */
70
+ addons?: string[];
71
+ /** Local Starbuck folder (used when it has a pyproject.toml). */
72
+ starbuckDir?: string;
60
73
  }
61
74
 
62
75
  export function expand(p: string, home?: string): string {
@@ -158,6 +171,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
158
171
  look: user.look,
159
172
  quotes: user.quotes,
160
173
  themes: user.themes,
174
+ addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
175
+ starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
161
176
  };
162
177
  }
163
178
 
@@ -190,6 +205,29 @@ export function serverCommand(
190
205
  return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
191
206
  }
192
207
 
208
+ export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
209
+ return (cfg.addons ?? []).includes("starbuck");
210
+ }
211
+
212
+ /** How to start Starbuck's MCP server: from the local folder when it exists, otherwise the pinned release. */
213
+ export function starbuckCommand(
214
+ cfg: Pick<SubsubConfig, "starbuckDir" | "uvPath">,
215
+ env: NodeJS.ProcessEnv = process.env,
216
+ ): { command: string; args: string[] } {
217
+ const uv = findUv(cfg, env);
218
+ if (cfg.starbuckDir && existsSync(join(cfg.starbuckDir, "pyproject.toml"))) {
219
+ return { command: uv, args: ["run", "--quiet", "--directory", cfg.starbuckDir, "starbuck-mcp"] };
220
+ }
221
+ return { command: uv, args: ["tool", "run", "--quiet", "--from", STARBUCK_SOURCE, "starbuck-mcp"] };
222
+ }
223
+
224
+ /** The addons setting with Starbuck turned on or off; other add-ons stay. Undefined (no setting) when empty. */
225
+ export function withStarbuck(addons: string[] | undefined, on: boolean): string[] | undefined {
226
+ const rest = (addons ?? []).filter((a) => a !== "starbuck");
227
+ const out = on ? [...rest, "starbuck"] : rest;
228
+ return out.length ? out : undefined;
229
+ }
230
+
193
231
  /** How prompts name the user. */
194
232
  export function who(cfg: Pick<SubsubConfig, "userName">): string {
195
233
  return cfg.userName ?? "the user";
package/src/doctor.ts CHANGED
@@ -3,14 +3,24 @@
3
3
  *
4
4
  * Checks Node, the settings file, uv, the Zotero server (it runs
5
5
  * `zotero-local-mcp --check`), Zotero itself, the tag list, the notes folder,
6
- * the contact email and the model login. Exit code 1 when something needs a fix.
6
+ * the contact email, Starbuck (when the add-on is on) and the model login. Exit
7
+ * code 1 when something needs a fix.
7
8
  */
8
9
 
9
10
  import { spawnSync } from "node:child_process";
10
11
  import { existsSync, readFileSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { join } from "node:path";
13
- import { findUv, loadConfig, SERVER_VERSION, serverCommand, type SubsubConfig } from "./config.ts";
14
+ import {
15
+ findUv,
16
+ loadConfig,
17
+ SERVER_VERSION,
18
+ serverCommand,
19
+ STARBUCK_SOURCE,
20
+ starbuckCommand,
21
+ starbuckEnabled,
22
+ type SubsubConfig,
23
+ } from "./config.ts";
14
24
  import { profileSpec } from "./profiles.ts";
15
25
 
16
26
  export interface Check {
@@ -137,6 +147,7 @@ export function runChecks(
137
147
  ? { name: "Contact email", ok: true, detail: "set" }
138
148
  : { 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." },
139
149
  );
150
+ if (starbuckEnabled(cfg)) out.push(starbuckCheck(cfg, env, run));
140
151
  const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
141
152
  out.push(
142
153
  hasLogin(agentDir)
@@ -146,6 +157,25 @@ export function runChecks(
146
157
  return out;
147
158
  }
148
159
 
160
+ /** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
161
+ export function starbuckCheck(cfg: SubsubConfig, env: NodeJS.ProcessEnv, run: Runner): Check {
162
+ const cmd = starbuckCommand(cfg, env);
163
+ const local = cmd.args.includes("--directory");
164
+ const args = cmd.args.slice(0, -1).concat(["starbuck", "--version"]);
165
+ const res = run(cmd.command, args, env);
166
+ if (res.status === 0) {
167
+ return { name: "Starbuck", ok: true, detail: `${res.stdout.trim()} ${local ? `from ${cfg.starbuckDir}` : `from ${STARBUCK_SOURCE}`}` };
168
+ }
169
+ return {
170
+ name: "Starbuck",
171
+ ok: false,
172
+ detail: local ? `cannot start from ${cfg.starbuckDir}` : `cannot install ${STARBUCK_SOURCE} with uv`,
173
+ fix: local
174
+ ? `Type: cd "${cfg.starbuckDir}" && uv sync`
175
+ : "Check the internet connection and run subsub doctor again. To turn reference checks off, type subsub init.",
176
+ };
177
+ }
178
+
149
179
  export function formatChecks(checks: Check[]): string {
150
180
  return checks
151
181
  .map((c) => {
package/src/init.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Flags (for scripts and tests): --yes (take every default), --setup
14
14
  * standard|fmup, --name, --about, --language, --notes, --profile, --tags
15
- * health-sciences|health-informatics|any-field, --email, --models keep|later|opencode-go.
15
+ * health-sciences|health-informatics|any-field, --email, --starbuck on|off,
16
+ * --models keep|later|opencode-go.
16
17
  */
17
18
 
18
19
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -20,7 +21,20 @@ import { homedir } from "node:os";
20
21
  import { dirname, join, resolve } from "node:path";
21
22
  import { createInterface } from "node:readline/promises";
22
23
  import { fileURLToPath } from "node:url";
23
- import { configPath, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig } from "./config.ts";
24
+ import {
25
+ configPath,
26
+ expand,
27
+ isProfile,
28
+ loadConfig,
29
+ parseEnvFile,
30
+ PROFILES,
31
+ readConfigFile,
32
+ saveConfig,
33
+ starbuckEnabled,
34
+ type SubsubConfig,
35
+ withStarbuck,
36
+ } from "./config.ts";
37
+ import { defaultRunner, starbuckCheck } from "./doctor.ts";
24
38
  import { PROFILE_SPECS } from "./profiles.ts";
25
39
 
26
40
  const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
@@ -83,7 +97,7 @@ export async function runInit(
83
97
  io: InitIO,
84
98
  flags: Record<string, string>,
85
99
  env: NodeJS.ProcessEnv = process.env,
86
- deps: { fetch?: typeof fetch; home?: string } = {},
100
+ deps: { fetch?: typeof fetch; home?: string; prepareStarbuck?: (cfg: SubsubConfig) => string } = {},
87
101
  ): Promise<InitResult> {
88
102
  const home = deps.home ?? env.HOME ?? homedir();
89
103
  const initEnv: NodeJS.ProcessEnv = { ...env, HOME: home };
@@ -133,6 +147,14 @@ export async function runInit(
133
147
  const email = (
134
148
  await io.ask("Email for Unpaywall and Crossref, to find open-access PDFs (optional)", flags.email ?? envValues.ZOTERO_CONTACT_EMAIL ?? "")
135
149
  ).trim();
150
+ const starbuck = await io.choose(
151
+ "Reference checks (Starbuck): check that cited works exist, match their citation and were not retracted",
152
+ [
153
+ { value: "off", label: "Off" },
154
+ { value: "on", label: "On (the researcher gets /verify; uses the email above)" },
155
+ ],
156
+ flags.starbuck ?? (starbuckEnabled(current) ? "on" : "off"),
157
+ );
136
158
  const modelOptions = [
137
159
  ...(existing.models ? [{ value: "keep", label: `Keep: ${JSON.stringify(existing.models)}` }] : []),
138
160
  { value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
@@ -171,12 +193,14 @@ export async function runInit(
171
193
  setup: fmup ? "fmup" : undefined,
172
194
  vault: notes,
173
195
  envFile,
196
+ addons: withStarbuck(current.addons, starbuck === "on"),
174
197
  };
175
198
  if (models === "later") patch.models = {};
176
199
  if (models === "opencode-go") patch.models = TESTED_MODELS;
177
200
  saveConfig(patch, initEnv);
178
201
 
179
202
  const zotero = await zoteroReachable(deps.fetch);
203
+ const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
180
204
  io.say("");
181
205
  io.say(`Saved: ${configFile}`);
182
206
  io.say(`Saved: ${envFile}`);
@@ -186,6 +210,7 @@ export async function runInit(
186
210
  ? "Zotero: reachable."
187
211
  : "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\".",
188
212
  );
213
+ if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
189
214
  if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
190
215
  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.");
191
216
  return { configFile, envFile, notes, created, zotero };
@@ -243,7 +268,11 @@ export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.
243
268
  const flags = parseFlags(args);
244
269
  const io = flags.yes || !process.stdin.isTTY ? defaultsIO((l) => console.log(l)) : terminalIO();
245
270
  try {
246
- await runInit(io, flags, env);
271
+ await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
272
+ // Download Starbuck now, so that the first start of Sub-Sub does not wait for it.
273
+ const c = starbuckCheck(cfg, env, defaultRunner);
274
+ return c.ok ? `Reference checks: on (${c.detail}).` : `Reference checks: on, but ${c.detail}. ${c.fix ?? ""}`.trim();
275
+ } });
247
276
  return 0;
248
277
  } catch (err) {
249
278
  console.error(`Sub-Sub init: ${(err as Error).message}`);
package/src/notes.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Checks on the literature notes the researcher writes (a note whose front matter has a
3
+ * citekey). Each note must say what it was written from (evidence: full text, abstract
4
+ * or metadata), and only a note written from the full text may give page numbers.
5
+ * The write gate calls these before a write or edit runs; a failed check blocks the call
6
+ * with a reason, so the model corrects the note.
7
+ */
8
+
9
+ export const EVIDENCE = ["full text", "abstract", "metadata"] as const;
10
+
11
+ const FRONT = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
12
+ const PAGES = /\b(?:pp?\.|pages?)\s?\d+/i;
13
+
14
+ export function frontMatter(text: string): string | undefined {
15
+ return FRONT.exec(text)?.[1];
16
+ }
17
+
18
+ export function field(front: string, name: string): string | undefined {
19
+ const m = new RegExp(`^${name}:[ \\t]*(.*)$`, "m").exec(front);
20
+ return m ? m[1].trim().replace(/^["']|["']$/g, "").trim() : undefined;
21
+ }
22
+
23
+ /** Why a new literature note cannot be written as it is, or undefined when it can. */
24
+ export function checkLiteratureNote(text: string): string | undefined {
25
+ const m = FRONT.exec(text);
26
+ if (!m || !field(m[1], "citekey")) return undefined;
27
+ const evidence = field(m[1], "evidence")?.toLowerCase();
28
+ if (!evidence || !(EVIDENCE as readonly string[]).includes(evidence)) {
29
+ return 'A literature note needs "evidence:" in its front matter, with one of: full text, abstract, metadata (what you read to write it). Add it and write the note again.';
30
+ }
31
+ const body = text.slice(m[0].length);
32
+ if (evidence !== "full text" && PAGES.test(body)) {
33
+ return `This note says evidence: ${evidence}, but it gives page numbers. Page numbers come only from the full text: remove them, or read the full text and set evidence: full text.`;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** The text of a file after pi's edit tool applies edits[] (each oldText matched against the original). */
39
+ export function applyEdits(current: string, edits: Array<{ oldText?: unknown; newText?: unknown }>): string {
40
+ let out = current;
41
+ for (const e of edits) {
42
+ const oldText = String(e.oldText ?? "");
43
+ if (oldText && out.includes(oldText)) out = out.replace(oldText, () => String(e.newText ?? ""));
44
+ }
45
+ return out;
46
+ }
package/src/profiles.ts CHANGED
@@ -19,6 +19,8 @@ export interface ProfileSpec {
19
19
  summary: string;
20
20
  /** Tools this profile does not offer (full names). */
21
21
  without: string[];
22
+ /** Prompt commands this profile does not run (names without the slash). */
23
+ withoutPrompts: string[];
22
24
  /** Items per review note and per batch. */
23
25
  batch: number;
24
26
  /** Added to the system prompt. {{user}} is the user's name or "the user". */
@@ -36,6 +38,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
36
38
  label: "Reader",
37
39
  summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
38
40
  without: [...BULK_LIBRARY, ...MANUSCRIPT],
41
+ withoutPrompts: ["lit", "compare", "review", "digest"],
39
42
  batch: 10,
40
43
  rules: [
41
44
  "Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
@@ -51,6 +54,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
51
54
  label: "Scholar",
52
55
  summary: "literature notes, syntheses, searches and alerts; the full librarian",
53
56
  without: MANUSCRIPT,
57
+ withoutPrompts: ["review"],
54
58
  batch: 25,
55
59
  rules: [
56
60
  "Profile: Scholar.",
@@ -64,6 +68,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
64
68
  label: "Author",
65
69
  summary: "Scholar plus manuscripts: citation check, bibliography, comments on the argument",
66
70
  without: [],
71
+ withoutPrompts: [],
67
72
  batch: 25,
68
73
  rules: [
69
74
  "Profile: Author.",
@@ -78,6 +83,7 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
78
83
  label: "Editor",
79
84
  summary: "everything, including drafting manuscript text on request",
80
85
  without: [],
86
+ withoutPrompts: [],
81
87
  batch: 25,
82
88
  rules: [
83
89
  "Profile: Editor. All tools.",
@@ -92,6 +98,17 @@ export function profileSpec(p: Profile): ProfileSpec {
92
98
  return PROFILE_SPECS[p];
93
99
  }
94
100
 
101
+ /** Why a prompt command does not run in this profile, or undefined when it does. */
102
+ export function promptBlocked(text: string, profile: Profile): string | undefined {
103
+ const m = /^\/([\w-]+)(?:\s|$)/.exec(text.trim());
104
+ if (!m || !PROFILE_SPECS[profile].withoutPrompts.includes(m[1])) return undefined;
105
+ const allowed = Object.values(PROFILE_SPECS)
106
+ .filter((s) => !s.withoutPrompts.includes(m[1]))
107
+ .map((s) => s.label)
108
+ .join(", ");
109
+ return `/${m[1]} is not part of the ${PROFILE_SPECS[profile].label} profile (it is in: ${allowed}). Change the profile with /profile.`;
110
+ }
111
+
95
112
  export function profileList(current?: Profile): string {
96
113
  return Object.values(PROFILE_SPECS)
97
114
  .map((s) => `${s.name === current ? "*" : " "} ${s.label.padEnd(8)} ${s.summary}`)
package/src/prompt.ts CHANGED
@@ -74,6 +74,12 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
74
74
  if (t) parts.push(`# Vault conventions (${f})`, t);
75
75
  }
76
76
  }
77
+ if (cfg.setup === "fmup") {
78
+ parts.push(
79
+ "# U.Porto",
80
+ `${capital(user)} works at the University of Porto. Papers that are not open access often open through the university's subscriptions when ${user} uses Sub-Sub on the U.Porto network: say so when a work is not open access.`,
81
+ );
82
+ }
77
83
  // The profile and the language rule come last, so they win over the shared rules and the vault conventions.
78
84
  parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
79
85
  parts.push("# Language", languageRule(cfg.language, user));
package/src/roles.ts CHANGED
@@ -7,6 +7,9 @@ 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 mode for these). */
11
+ export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
12
+
10
13
  export const ZOTERO_READ = [
11
14
  "status",
12
15
  "library_overview",
@@ -51,7 +54,11 @@ export function toolsFor(mode: Mode, registered: string[], profile: Profile = "e
51
54
  const wanted =
52
55
  mode === "librarian"
53
56
  ? [...registered.filter((n) => n.startsWith("zotero_")), ...FILE_TOOLS]
54
- : [...ZOTERO_READ, ...registered.filter((n) => n.startsWith("scholar_")), ...FILE_TOOLS];
57
+ : [
58
+ ...ZOTERO_READ,
59
+ ...registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")),
60
+ ...FILE_TOOLS,
61
+ ];
55
62
  return [...new Set(wanted)].filter((n) => has.has(n) && !without.has(n));
56
63
  }
57
64