@tiagojct/subsub 0.6.0 → 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.
package/docs/Subsub.md CHANGED
@@ -58,8 +58,10 @@ Templates (type the name, then the argument):
58
58
  2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
59
59
  3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
60
60
  4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
61
- 5. The labels are in European Portuguese when the language setting is Portuguese.
62
- 6. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
61
+ 5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
62
+ 6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
63
+ 7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
64
+ 8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
63
65
 
64
66
  The log is in ~/.subsub/web.log.
65
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,9 +26,11 @@
26
26
  ]
27
27
  },
28
28
  "dependencies": {
29
- "@earendil-works/pi-coding-agent": "0.87.1",
30
29
  "@modelcontextprotocol/sdk": "^1.30.1"
31
30
  },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "^1.0.2"
33
+ },
32
34
  "engines": {
33
35
  "node": ">=22.19.0"
34
36
  },
@@ -55,9 +57,11 @@
55
57
  "src",
56
58
  "templates",
57
59
  "themes",
58
- "web"
60
+ "web",
61
+ "THIRD_PARTY_NOTICES"
59
62
  ],
60
63
  "devDependencies": {
64
+ "@earendil-works/pi-coding-agent": "1.0.2",
61
65
  "@types/node": "^24.19.0",
62
66
  "typescript": "^5.9.3"
63
67
  },
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: "Researcher: compare sources on a question (a source matrix: claim, evidence type, caveats, confidence)"
3
+ argument-hint: "<question, or citekeys, DOIs or PMIDs>"
4
+ ---
5
+ Compare the sources on: $ARGUMENTS
6
+
7
+ Make a short slug: lower case, hyphens, at most five words. Use today's date (YYYY-MM-DD).
8
+
9
+ 1. Find the sources.
10
+ - If the user gave citekeys, DOIs or PMIDs, use exactly those.
11
+ - Otherwise: library first (zotero_find_items), then scholar_search_multi with three phrasings. Choose 3 to 10 sources and say why.
12
+ 2. Read each source before you describe it: zotero_get_fulltext for library items, scholar_read_oa_fulltext (methods and results) for open-access works, scholar_get_work for the abstract of the others. Note whether you read the full text or only the abstract.
13
+ 3. Build the matrix, one row per source:
14
+ | # | Source | Claim on the question | Evidence type | Population and setting | Caveats | Confidence |
15
+ - Evidence type: for example randomised trial, cohort, case-control, cross-sectional, diagnostic accuracy, systematic review, guideline, expert opinion, modelling.
16
+ - Caveats: risk of bias, small sample, surrogate outcome, short follow-up, industry funding, abstract only.
17
+ - Confidence: high, moderate or low, with one reason.
18
+ 4. Below the matrix, write: where the sources agree; where they disagree and the most likely reason (population, design, outcome definition, analysis); what no source answers. Cite source numbers in every sentence. Write "inference:" before a statement that no single source makes.
19
+ 5. Check the sources: verify_check_references when the verify_ tools are available, otherwise scholar_get_work for each identifier. Never write "verified" for a check you did not run.
20
+ 6. Write one note: `Research/<date> <slug> comparison.md` in the notes folder (do not overwrite; add -2). Front matter: title, date, type: comparison, sources (the identifiers), verification (PASS, PASS WITH NOTES or BLOCKED), reference_check (path of the Starbuck report, or none). End with a Sources section: [@citekey] for library items, full reference with DOI and "(not in library)" for the others.
21
+ 7. If a step fails, finish the note anyway and mark the step "BLOCKED: <what failed>".
22
+
23
+ Give the user the path and three lines on what the comparison shows.
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: "Researcher: digest of the last days: new works from the alerts and what the recent notes changed"
3
+ argument-hint: "[days, default 7] [topic]"
4
+ ---
5
+ Write a digest of the last ${1:-7} days. Topic, if given: ${@:2}
6
+
7
+ 1. Collect, in the notes folder:
8
+ - the alert notes `Inbox/Literature alerts YYYY-MM-DD.md` dated in the period (the date is in the file name);
9
+ - the notes in `Research/` whose front matter `date:` is in the period;
10
+ - the literature notes whose front matter `created:` is in the period.
11
+ Use ls and find to list them, and read their front matter for the dates. If a topic is given, keep only what is about it.
12
+ 2. Read each note you use. Do not describe a work that you did not read in a note, an abstract (scholar_get_work) or a full text.
13
+ 3. Write `Research/Digest <today, YYYY-MM-DD>.md` (do not overwrite; add -2) with these sections, in this order:
14
+ - What changed in our understanding: what the period's notes and works add to or change in what the user knew, each point with its sources.
15
+ - New works worth reading: at most ten from the alerts, one line each on why, with DOI or PMID and whether it is in the library. Mark the ones the alert pre-screen excluded only if you keep them, and say why.
16
+ - Disagreements and open questions: between the new works, or between them and the earlier notes.
17
+ - Questions for the user: decisions that need the user (what to read, what to import, what to drop).
18
+ - Sources: [@citekey] for library items; full reference with DOI and "(not in library)" for the others.
19
+ Front matter: title, date, type: digest, period (from and to), sources (the notes and alert files used).
20
+ 4. If there is nothing in the period, say so in one line and do not write a note.
21
+ 5. Offer scholar_queue_imports for the works the user wants to add.
22
+
23
+ Give the user the path and the three most important points.
package/prompts/lit.md ADDED
@@ -0,0 +1,87 @@
1
+ ---
2
+ description: "Researcher: literature review on a health research question (library first, then PubMed, Europe PMC, OpenAlex)"
3
+ argument-hint: "<question or topic>"
4
+ ---
5
+ Do a literature review on: $ARGUMENTS
6
+
7
+ Make a short slug from the topic: lower case, hyphens, at most five words. Use today's date (YYYY-MM-DD).
8
+
9
+ ## 1. Plan
10
+
11
+ 1. Write the question in one sentence. If it is a clinical question, state population, exposure or intervention, comparison and outcome when they apply.
12
+ 2. Decide the scale:
13
+ - Narrow question ("what is X", one fact, one test): at most 10 tool calls, a short answer, no plan file.
14
+ - Review question: write the plan to `Research/.plans/<slug>.md` in the notes folder: the question, the search phrasings, the study designs that answer it (for example randomised trials, cohorts, diagnostic accuracy studies, systematic reviews), the years, and a task list.
15
+ 3. Tell the user the plan in three lines. Then continue at once. Do not wait for an answer unless the user asked to see the plan first.
16
+
17
+ ## 2. Gather
18
+
19
+ 1. Library first: zotero_find_items for the topic (and the topic tags). Note what the library already has.
20
+ 2. Outside: scholar_search_multi with three or four phrasings of the question (synonyms, the test or drug name, the condition name, MeSH terms). Give include_terms (the key concepts) and exclude_terms (populations or designs outside the question) so that the server pre-screens the results. Use year limits when the question needs recent work.
21
+ 3. Screen. For every work in the merged list, decide: read, maybe, or exclude, with a reason of a few words. Prefer systematic reviews, guidelines and primary studies with the right design. Write the screening log to `Research/.plans/<slug>-screening.md`: a table (#, work, identifier, pre-screen, decision, reason) and the counts for a flow diagram: found by the searches, unique works, pre-screen excluded, screened, read, used.
22
+ 4. Read before you summarise:
23
+ - Library items: zotero_get_fulltext, or the abstract when there is no full text.
24
+ - Open-access works: scholar_read_oa_fulltext, first the section list, then only the sections you need (usually methods and results).
25
+ - Other works: scholar_get_work for the abstract.
26
+ 5. Keep an evidence table as you read. Give each source a number that does not change: [1], [2], ... At most two rows from one paper, so that no single paper dominates. In the Read column write exactly one of: full text, abstract, metadata.
27
+
28
+ ## 3. Synthesise
29
+
30
+ - Separate what the sources agree on, where they disagree (and why: population, design, outcome definition, follow-up), and what is still open.
31
+ - Every factual sentence cites at least one source number. Write "inference:" before a statement that combines sources and is not stated in any of them.
32
+ - Say for each source whether you read the full text or only the abstract.
33
+ - Give numbers (effect sizes, accuracy, sample sizes) only from what you read, with the source.
34
+ - Do not draft text for the user's own manuscript.
35
+
36
+ ## 4. Check the draft for gaps
37
+
38
+ 1. Read your draft as a critical reviewer. Name at most three gaps that matter for the question (a population, outcome, design or recent period that the draft does not cover), each as one self-contained search question.
39
+ 2. Search only for those questions (scholar_search_multi), read what you add, and give the new sources the next numbers.
40
+ 3. Edit only the parts of the draft that a gap names. Do not shorten the draft by more than a tenth. If a search finds nothing new, say so in Methods.
41
+
42
+ ## 5. Verify
43
+
44
+ - If the verify_ tools are available: verify_check_references with the identifiers of every source in the evidence table. Fix the sources that fail. Report the ones that need checking.
45
+ - If not: check each DOI or PMID with scholar_get_work.
46
+ - Never write "verified" for a check you did not run.
47
+
48
+ ## 6. Deliver
49
+
50
+ Write one note: `Research/<date> <slug>.md` in the notes folder. Do not overwrite an existing note; add -2 to the name. Use this structure:
51
+
52
+ ```markdown
53
+ ---
54
+ title: "<question>"
55
+ date: <YYYY-MM-DD>
56
+ type: literature-review
57
+ question: "<question in one sentence>"
58
+ searches: ["<phrasing 1>", "<phrasing 2>", "<phrasing 3>"]
59
+ sources_found: <number of unique works>
60
+ sources_screened: <number>
61
+ sources_read: <number>
62
+ sources_used: <number>
63
+ screening_log: "Research/.plans/<slug>-screening.md"
64
+ verification: PASS | PASS WITH NOTES | BLOCKED
65
+ reference_check: "<path of the Starbuck report, or none>"
66
+ ---
67
+
68
+ ## Answer
69
+ (three to six sentences, with source numbers)
70
+
71
+ ## Evidence table
72
+ | # | Source | Design | Population | Key finding | Read | In library |
73
+ |---|---|---|---|---|---|---|
74
+
75
+ ## Agreement
76
+ ## Disagreement
77
+ ## Open questions
78
+ ## Methods
79
+ (where you searched, the phrasings, the pre-screen terms, the screening counts, the gap searches, what you included and left out, and why)
80
+
81
+ ## Sources
82
+ 1. [@citekey] for library items; for other works: Authors, year, title, journal. doi:... (not in library)
83
+ ```
84
+
85
+ If a step fails (a service does not answer, a full text cannot be read), do not stop. Finish the note, mark the step "BLOCKED: <what failed>", and set verification: BLOCKED.
86
+
87
+ Before you answer, read the note back to confirm that it exists. Then give the user the path, the answer in three lines, and the works worth adding to the library (offer scholar_queue_imports).
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: "Researcher: draft an email asking the corresponding author for a copy of a paper (you send it)"
3
+ argument-hint: "<citekey, DOI or PMID>"
4
+ ---
5
+ Draft an email to ask the corresponding author of $1 for a copy of the paper.
6
+
7
+ 1. Find the work: zotero_get_item for a citekey or Zotero key, otherwise scholar_get_work. If it is open access (open_access, or scholar_read_oa_fulltext gives the full text), tell the user where to read it and stop: no email is needed.
8
+ 2. Find the address with scholar_find_contact. Use only an address it returns. Never guess or build an address from a name. If it returns none, give the user the article link and say: look for "Correspondence" on the article page; then stop.
9
+ 3. Write the draft to `Inbox/Copy request <citekey or DOI>.md` in the notes folder (do not overwrite; add -2):
10
+
11
+ ```markdown
12
+ ---
13
+ title: "Copy request: <short title>"
14
+ date: <YYYY-MM-DD>
15
+ type: copy-request
16
+ work: "<DOI or PMID>"
17
+ to: "<address>"
18
+ status: draft
19
+ ---
20
+
21
+ [Open in the mail program](mailto:<address>?subject=<subject, URL-encoded>&body=<body, URL-encoded>)
22
+
23
+ To: <address> (<author name>, from the PubMed record)
24
+ Subject: Request for a copy of "<short title>"
25
+
26
+ Dear Dr <family name>,
27
+
28
+ <two or three sentences: who the user is (name, and the line about the user if set), why the paper matters for their work, and the request for a copy for personal research use.>
29
+
30
+ Best regards,
31
+ <the user's name>
32
+ ```
33
+
34
+ 4. Write the email in English unless the user asks for another language, or the author's affiliation and the user's language are both Portuguese. Keep it short and polite. Do not claim things about the user that you do not know.
35
+ 5. Do not send anything. Tell the user the path, and that the link opens the draft in their mail program.
@@ -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,10 +66,15 @@ 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
- export function expand(p: string): string {
63
- return resolve(p.replace(/^~(?=$|\/)/, homedir()));
75
+ export function expand(p: string, home?: string): string {
76
+ const base = home ?? process.env.HOME ?? homedir();
77
+ return resolve(p.replace(/^~(?=$|\/)/, base));
64
78
  }
65
79
 
66
80
  export function parseEnvFile(path: string): Record<string, string> {
@@ -81,7 +95,7 @@ export function parseEnvFile(path: string): Record<string, string> {
81
95
  }
82
96
 
83
97
  export function configPath(env: NodeJS.ProcessEnv = process.env): string {
84
- return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
98
+ return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
85
99
  }
86
100
 
87
101
  export function readConfigFile(file: string): Record<string, unknown> {
@@ -108,6 +122,7 @@ export function isProfile(x: unknown): x is Profile {
108
122
  }
109
123
 
110
124
  export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
125
+ const home = env.HOME;
111
126
  const file = configPath(env);
112
127
  let user: Partial<SubsubConfig> = {};
113
128
  if (existsSync(file)) {
@@ -117,16 +132,18 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
117
132
  throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
118
133
  }
119
134
  }
120
- const envCandidates = [
121
- user.envFile,
122
- env.ZOTERO_MCP_ENV,
123
- "~/.config/zotero-local-mcp/env",
124
- "~/.config/opencode/zotero.env",
125
- ].filter((x): x is string => Boolean(x));
126
- const envFile = envCandidates.map(expand).find((p) => existsSync(p));
127
- const envValues = envFile ? parseEnvFile(envFile) : {};
135
+ const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
136
+ const envFile = explicitEnv
137
+ ? expand(explicitEnv, home)
138
+ : [
139
+ "~/.config/zotero-local-mcp/env",
140
+ "~/.config/opencode/zotero.env",
141
+ ]
142
+ .map((p) => expand(p, home))
143
+ .find((p) => existsSync(p));
144
+ const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
128
145
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
129
- const vault = vaultRaw ? expand(vaultRaw) : undefined;
146
+ const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
130
147
  const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
131
148
  return {
132
149
  configFile: file,
@@ -135,7 +152,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
135
152
  about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
136
153
  language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
137
154
  setup: user.setup,
138
- serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
155
+ serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
139
156
  envFile,
140
157
  vault,
141
158
  models: user.models ?? {
@@ -143,27 +160,30 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
143
160
  researcher: "opencode-go/mimo-v2.6-pro",
144
161
  },
145
162
  defaultMode: mode,
146
- vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
163
+ vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
147
164
  sharedRules: user.sharedRules
148
- ? expand(user.sharedRules)
165
+ ? expand(user.sharedRules, home)
149
166
  : vault
150
167
  ? join(vault, "Systems", "Zotero agent.md")
151
168
  : undefined,
152
169
  startTimeout: user.startTimeout ?? 45,
153
- uvPath: user.uvPath ? expand(user.uvPath) : undefined,
170
+ uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
154
171
  look: user.look,
155
172
  quotes: user.quotes,
156
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),
157
176
  };
158
177
  }
159
178
 
160
179
  /** uv is often missing from PATH when pi is started outside a login shell. */
161
180
  export function findUv(cfg: Pick<SubsubConfig, "uvPath">, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
181
+ const home = env.HOME;
162
182
  const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
163
183
  const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
164
184
  const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
165
185
  .filter((x): x is string => Boolean(x))
166
- .map(expand);
186
+ .map((p) => expand(p, home));
167
187
  return candidates.find((p) => existsSync(p)) ?? "uv";
168
188
  }
169
189
 
@@ -185,6 +205,29 @@ export function serverCommand(
185
205
  return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
186
206
  }
187
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
+
188
231
  /** How prompts name the user. */
189
232
  export function who(cfg: Pick<SubsubConfig, "userName">): string {
190
233
  return cfg.userName ?? "the user";