@tiagojct/subsub 0.16.0 → 0.18.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/CHANGELOG.md +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/prompts/lit.md
CHANGED
|
@@ -12,7 +12,9 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
|
|
|
12
12
|
2. Decide the scale:
|
|
13
13
|
- Narrow question ("what is X", one fact, one test): at most 10 tool calls, a short answer, no plan file.
|
|
14
14
|
- Review question: write the plan to `Research/.plans/<slug>.md` in the Sub-Sub folder: the question, the search phrasings, the study designs that answer it (for example randomised trials, cohorts, diagnostic accuracy studies, systematic reviews), the years, and a task list.
|
|
15
|
-
3.
|
|
15
|
+
3. Rate how hard the search is, from 1 (one named test, drug or condition) to 10 (a broad question across populations, designs and fields). The rating sets the follow-up rounds of section 4: 1 to 3, none; 4 to 7, one; 8 to 10, two. The rounds are a limit, not a target.
|
|
16
|
+
4. Set one publication window from the question (for example 2015 onwards for current practice), or none when the question does not need one. Keep the same window in every search, the follow-up rounds included.
|
|
17
|
+
5. Tell the user the plan in three lines, with the rating and the window. Then continue at once. Do not wait for an answer unless the user asked to see the plan first.
|
|
16
18
|
|
|
17
19
|
## 2. Gather
|
|
18
20
|
|
|
@@ -23,7 +25,7 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
|
|
|
23
25
|
- Library items: zotero_get_fulltext, or the abstract when there is no full text.
|
|
24
26
|
- Open-access works: scholar_read_oa_fulltext, first the section list, then only the sections you need (usually methods and results).
|
|
25
27
|
- 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.
|
|
28
|
+
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. In the Design column, mark a preprint: for example "cohort (preprint)".
|
|
27
29
|
|
|
28
30
|
## 3. Synthesise
|
|
29
31
|
|
|
@@ -35,9 +37,11 @@ Make a short slug from the topic: lower case, hyphens, at most five words. Use t
|
|
|
35
37
|
|
|
36
38
|
## 4. Check the draft for gaps
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
Do as many rounds as section 1 allows (none, one or two). Stop early when the sources cover the question.
|
|
41
|
+
|
|
42
|
+
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. A gap is a new angle: do not search again with other words for what the first searches covered.
|
|
43
|
+
2. Search only for those questions (scholar_search_multi, with the window of section 1), read what you add, and give the new sources the next numbers.
|
|
44
|
+
3. Edit only the parts of the draft that a gap names. Do not shorten the draft by more than a tenth. In Methods, give the rating, the rounds you did and what each found (or that it found nothing new).
|
|
41
45
|
|
|
42
46
|
## 5. Verify
|
|
43
47
|
|
|
@@ -86,11 +90,26 @@ reference_check: "<path of the Starbuck report, or none>"
|
|
|
86
90
|
1. [@citekey] for library items; for other works: Authors, year, title, journal. doi:... (not in library)
|
|
87
91
|
```
|
|
88
92
|
|
|
93
|
+
Under Methods, after the text, draw the flow of works with the counts of the screening log, in this form (Obsidian draws it, and so does Sub-Sub's browser view):
|
|
94
|
+
|
|
95
|
+
```mermaid
|
|
96
|
+
flowchart TD
|
|
97
|
+
A["Found by the searches: <n>"] --> B["Unique works: <n>"]
|
|
98
|
+
B -.-> X1["Excluded by the pre-screen: <n>"]
|
|
99
|
+
B --> C["Screened: <n>"]
|
|
100
|
+
C -.-> X2["Excluded at screening: <n>"]
|
|
101
|
+
C --> D["Read: <n>"]
|
|
102
|
+
D -.-> X3["Read, not used: <n>"]
|
|
103
|
+
D --> E["Used in this note: <n>"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The counts must add up: unique works = pre-screen excluded + screened; screened = excluded at screening + read; read = not used + used.
|
|
107
|
+
|
|
89
108
|
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.
|
|
90
109
|
|
|
91
|
-
##
|
|
110
|
+
## A reading list
|
|
92
111
|
|
|
93
|
-
When the
|
|
112
|
+
When the user asks for a reading list (for example `/lit reading list: <topic>`, in any language), do sections 1, 2, 4 and 5 as written, but do not synthesise (skip section 3). Write the note with `type: reading-list` and this body in place of Answer, Agreement, Disagreement and Open questions:
|
|
94
113
|
|
|
95
114
|
```markdown
|
|
96
115
|
## How to read this list
|
|
@@ -107,6 +126,6 @@ When the profile is Reader, do sections 1, 2, 4 and 5 as written, but do not syn
|
|
|
107
126
|
## Sources
|
|
108
127
|
```
|
|
109
128
|
|
|
110
|
-
"What it studies" says what the work asks and how, never what it found. Do not state results, effect sizes or conclusions anywhere in the note. Then give the user the path, the first work to read and why, and the works worth adding to the library.
|
|
129
|
+
The Methods section has the same flow diagram, with "On the reading list: <n>" as the last box. "What it studies" says what the work asks and how, never what it found. Do not state results, effect sizes or conclusions anywhere in the note. Then give the user the path, the first work to read and why, and the works worth adding to the library.
|
|
111
130
|
|
|
112
|
-
Before you answer, read the note back to confirm that it exists.
|
|
131
|
+
Before you answer, read the note back to confirm that it exists. For a review, give the user the path, the answer in three lines, and the works worth adding to the library (offer to import them). For a reading list, give the user what the section above says, with no results.
|
package/roles/researcher.md
CHANGED
|
@@ -9,9 +9,11 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
9
9
|
- 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.
|
|
10
10
|
- Cite library items as [@citekey]. For works not in the library, give DOI or PMID and say that they are not in the library.
|
|
11
11
|
- Do not invent references, numbers or quotes. If you are not sure, say so.
|
|
12
|
+
- Copy quotes word for word, as complete sentences or passages. Mark every omission with [...]; never join two passages into one quote.
|
|
12
13
|
- 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.
|
|
13
|
-
- 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.
|
|
14
|
+
- 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, as (p. N) right after the quote: Sub-Sub links each one to that page of the PDF in Zotero. Sub-Sub refuses a note that breaks this rule.
|
|
14
15
|
- Mark inferences: write "inference:" before a statement that combines sources and that no single source makes.
|
|
16
|
+
- A preprint (type preprint in the search results, or a venue such as medRxiv, bioRxiv, Research Square, SSRN or arXiv) is not peer reviewed. Say so wherever you use it: "(preprint)" after its design in tables, "preprint, not peer reviewed" in the text. When the same work has a journal version, use and cite the journal version.
|
|
15
17
|
- Never write "verified" or "checked" for a check you did not run.
|
|
16
18
|
- 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>".
|
|
17
19
|
- 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.
|
|
@@ -35,7 +37,7 @@ You read the Zotero library (zotero_ tools), search PubMed, Europe PMC and OpenA
|
|
|
35
37
|
1. If the question is vague, clarify it in one line.
|
|
36
38
|
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.
|
|
37
39
|
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).
|
|
38
|
-
4. List the most relevant works not in the library, one line each on why it matters.
|
|
40
|
+
4. List the most relevant works not in the library, one line each on why it matters. When the user asks for a list (what to read, what is missing), give the ranked list and stop: read a work only when a claim needs it.
|
|
39
41
|
5. Ask which to add. Import the chosen works with zotero_import_identifiers (one preview for all), then offer to tag them. When {{user}} wants to choose later, put them in the import queue with scholar_queue_imports; {{user}} ticks the lines and you import them with zotero_import_queue.
|
|
40
42
|
|
|
41
43
|
## Imports, metadata, PDFs
|
|
@@ -90,16 +92,19 @@ If zotero_get_vocabulary has no topic/ tags, propose topics before tagging: read
|
|
|
90
92
|
|
|
91
93
|
- Follow the formats in the shared rules.
|
|
92
94
|
- After a literature note is written, offer scholar_attach_note with 3 to 4 lines of summary and the path of the note.
|
|
95
|
+
- When the zotero_get_figure tool is available and a figure or table of the paper carries a result that the note discusses, call it with the label of the caption (for example "Figure 2" or "Table 1") and put the Markdown line it returns in the note, with its page link. Quote the caption; do not describe what you cannot see. If it took the whole page (method: page), say so under the image.
|
|
93
96
|
- For a synthesis: show the candidate list first and let {{user}} choose. Read full texts only for the chosen items.
|
|
94
97
|
- For a synthesis of a tag ("what does my library say about X"): find the items with zotero_find_items tags=["topic/x"] and include every one. The table has one row per item; if you leave an item out, say which and why.
|
|
95
98
|
- Say for each item whether you used the full text or the abstract. Never state a number or a claim that is not in what you read.
|
|
96
99
|
|
|
97
|
-
## Manuscripts
|
|
100
|
+
## Manuscripts
|
|
98
101
|
|
|
99
102
|
1. scholar_check_manuscript on the file.
|
|
100
103
|
2. Propose fixes for missing keys. Do not edit the manuscript text without approval.
|
|
101
104
|
3. scholar_export_bibliography to the file named in the YAML header, or references.json next to the manuscript.
|
|
102
105
|
|
|
106
|
+
Draft text for {{user}}'s manuscripts or theses only when {{user}} asks. Cite library items as [@citekey], use only claims from sources you read, and mark drafted text clearly so that {{user}} can check it. Never draft text for an assignment (coursework that {{user}} hands in for a grade), even when asked: say so, and offer /read, quotes and questions instead.
|
|
107
|
+
|
|
103
108
|
## Reference checks (Starbuck, when the verify_ tools are available)
|
|
104
109
|
|
|
105
110
|
- 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.
|
package/src/bench.ts
CHANGED
|
@@ -901,7 +901,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
901
901
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
902
902
|
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
903
903
|
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
904
|
-
writeFileSync(cf, JSON.stringify({ ...userCfg, profile:
|
|
904
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: undefined, models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
905
905
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
906
906
|
n++;
|
|
907
907
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/src/cli.ts
CHANGED
|
@@ -140,7 +140,7 @@ Usage:
|
|
|
140
140
|
subsub Start Sub-Sub in the terminal
|
|
141
141
|
subsub --librarian Start in the Librarian, which only manages the library
|
|
142
142
|
subsub web Open Sub-Sub in the browser
|
|
143
|
-
subsub init Set up or change the settings (folder, tag list,
|
|
143
|
+
subsub init Set up or change the settings (folder, tag list, models)
|
|
144
144
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
145
145
|
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
146
146
|
subsub shortcut Add a Sub-Sub shortcut (--remove takes it away)
|
|
@@ -152,7 +152,7 @@ Options:
|
|
|
152
152
|
--here Work in the current folder, not in the Sub-Sub folder
|
|
153
153
|
|
|
154
154
|
In Sub-Sub, type /subsub for the state, /researcher or /librarian to switch modes,
|
|
155
|
-
/
|
|
155
|
+
/read to read a paper with guidance, /undo to undo the last change, and / for all commands.
|
|
156
156
|
Sub-Sub runs on pi: pi's options (for example --model, --resume) also work.
|
|
157
157
|
|
|
158
158
|
Docs: https://subsub.tiagojacinto.eu`;
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* (~/.config/subsub/config.json, or SUBSUB_CONFIG) overrides it.
|
|
4
4
|
*
|
|
5
5
|
* {
|
|
6
|
-
* "profile": "scholar",
|
|
7
6
|
* "userName": "Ana", "about": "a master's student in health informatics", "language": "English",
|
|
8
7
|
* "vault": "~/Documents/Sub-Sub",
|
|
9
8
|
* "models": { "librarian": "opencode-go/mimo-v2.6-flash", "researcher": "opencode-go/mimo-v2.6-pro" },
|
|
@@ -61,21 +60,17 @@ export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "test
|
|
|
61
60
|
export function uiLanguage(language: string | undefined): "pt" | "en" {
|
|
62
61
|
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
63
62
|
}
|
|
64
|
-
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
65
|
-
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
66
63
|
|
|
67
64
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
68
|
-
export const SERVER_VERSION = "0.
|
|
65
|
+
export const SERVER_VERSION = "0.6.0";
|
|
69
66
|
|
|
70
67
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
71
68
|
export const STARBUCK_VERSION = "0.2.0";
|
|
72
69
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
73
70
|
|
|
74
71
|
export interface SubsubConfig {
|
|
75
|
-
/** The settings file (written by `subsub init`
|
|
72
|
+
/** The settings file (written by `subsub init`). */
|
|
76
73
|
configFile: string;
|
|
77
|
-
/** How much Sub-Sub does for the user: reader, scholar, author or editor. */
|
|
78
|
-
profile: Profile;
|
|
79
74
|
/** How the prompts name the user ("the user" when not set). */
|
|
80
75
|
userName?: string;
|
|
81
76
|
/** One line about the user, e.g. "a master's student in health informatics". */
|
|
@@ -157,10 +152,6 @@ export function saveConfig(patch: Record<string, unknown>, env: NodeJS.ProcessEn
|
|
|
157
152
|
return file;
|
|
158
153
|
}
|
|
159
154
|
|
|
160
|
-
export function isProfile(x: unknown): x is Profile {
|
|
161
|
-
return typeof x === "string" && (PROFILES as string[]).includes(x);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
155
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
165
156
|
const home = env.HOME;
|
|
166
157
|
const file = configPath(env);
|
|
@@ -194,7 +185,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
194
185
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
195
186
|
return {
|
|
196
187
|
configFile: file,
|
|
197
|
-
profile: isProfile(user.profile) ? user.profile : "scholar",
|
|
198
188
|
userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
|
|
199
189
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
200
190
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
package/src/doctor.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
refreshedArgs,
|
|
24
24
|
} from "./config.ts";
|
|
25
25
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
26
|
-
import { profileSpec } from "./profiles.ts";
|
|
27
26
|
|
|
28
27
|
export interface Check {
|
|
29
28
|
name: string;
|
|
@@ -79,7 +78,7 @@ export function runChecks(
|
|
|
79
78
|
);
|
|
80
79
|
out.push(
|
|
81
80
|
existsSync(cfg.configFile)
|
|
82
|
-
? { name: "Settings", ok: true, detail: `${cfg.configFile},
|
|
81
|
+
? { name: "Settings", ok: true, detail: `${cfg.configFile}, replies in ${cfg.language}` }
|
|
83
82
|
: { name: "Settings", ok: false, detail: `no ${cfg.configFile}`, fix: "Type subsub init." },
|
|
84
83
|
);
|
|
85
84
|
|
package/src/feedback.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /feedback: a draft bug report or idea in the Inbox, for the user to read and post on GitHub.
|
|
3
|
+
* Sub-Sub sends nothing: the user decides what to share. The home and Sub-Sub folders, email
|
|
4
|
+
* addresses and keys are taken out before the draft is written.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const ISSUES_URL = "https://github.com/tiagojct/subsub/issues/new";
|
|
8
|
+
|
|
9
|
+
export interface FeedbackFacts {
|
|
10
|
+
what: string;
|
|
11
|
+
date: string;
|
|
12
|
+
/** For example "Sub-Sub 0.17.0, pi 1.0.4, Node v24.1.0, darwin arm64". */
|
|
13
|
+
versions: string;
|
|
14
|
+
/** Mode, model, language, add-ons, Zotero. */
|
|
15
|
+
setup: string[];
|
|
16
|
+
/** The output of subsub doctor. */
|
|
17
|
+
checks: string;
|
|
18
|
+
lastError?: string;
|
|
19
|
+
home: string;
|
|
20
|
+
vault?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Text made safe to post: the Sub-Sub and home folders, email addresses and keys taken out. */
|
|
24
|
+
export function redact(text: string, home: string, vault?: string): string {
|
|
25
|
+
let out = text;
|
|
26
|
+
if (vault) out = out.split(vault).join("<Sub-Sub folder>");
|
|
27
|
+
if (home) out = out.split(home).join("~");
|
|
28
|
+
return out
|
|
29
|
+
.replace(/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, "<email>")
|
|
30
|
+
.replace(/\b(?:sk-|AIza|ghp_|gho_|github_pat_|xox[bp]-)[\w-]{8,}/g, "<key>")
|
|
31
|
+
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, "<key>");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function feedbackNote(f: FeedbackFacts): string {
|
|
35
|
+
const clean = (s: string) => redact(s, f.home, f.vault);
|
|
36
|
+
return [
|
|
37
|
+
"---",
|
|
38
|
+
"type: feedback",
|
|
39
|
+
`created: ${f.date}`,
|
|
40
|
+
"---",
|
|
41
|
+
"",
|
|
42
|
+
"# Sub-Sub feedback",
|
|
43
|
+
"",
|
|
44
|
+
`Read this draft, take out anything you do not want to share, then post it at ${ISSUES_URL}. Sub-Sub has sent nothing.`,
|
|
45
|
+
"",
|
|
46
|
+
"## What happened",
|
|
47
|
+
"",
|
|
48
|
+
clean(f.what.trim()) || "(what you did, and what you saw)",
|
|
49
|
+
"",
|
|
50
|
+
"## What you expected",
|
|
51
|
+
"",
|
|
52
|
+
"(what should have happened)",
|
|
53
|
+
"",
|
|
54
|
+
"## Set-up",
|
|
55
|
+
"",
|
|
56
|
+
`- ${clean(f.versions)}`,
|
|
57
|
+
...f.setup.map((l) => `- ${clean(l)}`),
|
|
58
|
+
"",
|
|
59
|
+
"## subsub doctor",
|
|
60
|
+
"",
|
|
61
|
+
"```",
|
|
62
|
+
clean(f.checks.trim()),
|
|
63
|
+
"```",
|
|
64
|
+
"",
|
|
65
|
+
"## Last error",
|
|
66
|
+
"",
|
|
67
|
+
f.lastError ? ["```", clean(f.lastError.trim()).slice(0, 2000), "```"].join("\n") : "(none in this session)",
|
|
68
|
+
"",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* existing one. Press Enter to keep the value in brackets.
|
|
4
4
|
*
|
|
5
5
|
* Writes:
|
|
6
|
-
* - the settings file (~/.config/subsub/config.json):
|
|
6
|
+
* - the settings file (~/.config/subsub/config.json): name, language,
|
|
7
7
|
* Sub-Sub folder, models;
|
|
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.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
|
-
* yes|no, --
|
|
21
|
+
* yes|no, --tags health-sciences|health-informatics|any-field,
|
|
22
22
|
* --email, --starbuck on|off, --models keep|later|opencode-go|mistral-eu|google-free.
|
|
23
23
|
*/
|
|
24
24
|
|
|
@@ -33,10 +33,8 @@ import {
|
|
|
33
33
|
EU_MODELS,
|
|
34
34
|
FREE_MODELS,
|
|
35
35
|
expand,
|
|
36
|
-
isProfile,
|
|
37
36
|
loadConfig,
|
|
38
37
|
parseEnvFile,
|
|
39
|
-
PROFILES,
|
|
40
38
|
readConfigFile,
|
|
41
39
|
saveConfig,
|
|
42
40
|
starbuckEnabled,
|
|
@@ -46,7 +44,6 @@ import {
|
|
|
46
44
|
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
47
45
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
|
|
48
46
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
49
|
-
import { PROFILE_SPECS } from "./profiles.ts";
|
|
50
47
|
|
|
51
48
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
52
49
|
|
|
@@ -176,40 +173,18 @@ export async function runInit(
|
|
|
176
173
|
}
|
|
177
174
|
}
|
|
178
175
|
|
|
179
|
-
// Asked here, not first: it only changes the
|
|
180
|
-
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags
|
|
176
|
+
// Asked here, not first: it only changes the default of the tag list question.
|
|
177
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags. Everyone else keeps the standard set-up.");
|
|
181
178
|
const setup = await io.choose(
|
|
182
179
|
"Set-up",
|
|
183
180
|
[
|
|
184
181
|
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
185
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags
|
|
182
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags (the FMUP model service is coming soon)" },
|
|
186
183
|
],
|
|
187
184
|
flags.setup ?? existing.setup ?? "standard",
|
|
188
185
|
);
|
|
189
186
|
const fmup = setup === "fmup";
|
|
190
187
|
|
|
191
|
-
// Students start in Reader: Sub-Sub helps them read and does not write syntheses for them unless asked.
|
|
192
|
-
let student = fmup;
|
|
193
|
-
if (!flags.profile && !existing.profile && !fmup) {
|
|
194
|
-
explain("Who you are", "Students start with the Reader profile: Sub-Sub helps you read (notes with quotations, page numbers and questions) and does not write summaries or syntheses unless you ask. You can change the profile at any time.");
|
|
195
|
-
student = (await io.choose(
|
|
196
|
-
"You are",
|
|
197
|
-
[
|
|
198
|
-
{ value: "student", label: "A student" },
|
|
199
|
-
{ value: "researcher", label: "A researcher, teacher or clinician" },
|
|
200
|
-
],
|
|
201
|
-
flags.student === "no" ? "researcher" : "student",
|
|
202
|
-
)) === "student";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
206
|
-
const profileDefault = flags.profile ?? existing.profile ?? (student ? "reader" : "scholar");
|
|
207
|
-
const profile = await io.choose(
|
|
208
|
-
"Profile",
|
|
209
|
-
PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })),
|
|
210
|
-
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
211
|
-
);
|
|
212
|
-
|
|
213
188
|
// The tag list: a custom path in the server settings stays; otherwise Zotero/Zotero tags.md (moved, kept or new).
|
|
214
189
|
const vocabTarget = join(notes, SETTINGS_DIR, SETTINGS_FILES.tags);
|
|
215
190
|
const oldDefaults = new Set([join(from, "Systems", SETTINGS_FILES.tags), join(from, SETTINGS_DIR, SETTINGS_FILES.tags)]);
|
|
@@ -322,7 +297,7 @@ export async function runInit(
|
|
|
322
297
|
if (brave || hadBrave) chmodSync(envFile, 0o600); // the file holds a key
|
|
323
298
|
|
|
324
299
|
const patch: Record<string, unknown> = {
|
|
325
|
-
profile,
|
|
300
|
+
profile: undefined,
|
|
326
301
|
userName: userName || undefined,
|
|
327
302
|
about: about || undefined,
|
|
328
303
|
language,
|
package/src/look.ts
CHANGED
|
@@ -84,8 +84,6 @@ export function libraryLine(st: LibraryState): { text: string; warn: boolean } {
|
|
|
84
84
|
export function headerLines(opts: {
|
|
85
85
|
version: string;
|
|
86
86
|
mode: "librarian" | "researcher";
|
|
87
|
-
/** Profile label, e.g. "Scholar". */
|
|
88
|
-
profile?: string;
|
|
89
87
|
model: string;
|
|
90
88
|
library: LibraryState;
|
|
91
89
|
quote?: [string, string];
|
|
@@ -94,7 +92,7 @@ export function headerLines(opts: {
|
|
|
94
92
|
}): string[] {
|
|
95
93
|
const p = opts.paint ?? plain;
|
|
96
94
|
const w = Math.max(10, opts.width);
|
|
97
|
-
const modeLabel =
|
|
95
|
+
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
98
96
|
const lib = libraryLine(opts.library);
|
|
99
97
|
if (w < BANNER[0].length + 2) {
|
|
100
98
|
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))];
|
|
@@ -107,7 +105,7 @@ export function headerLines(opts: {
|
|
|
107
105
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
108
106
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
109
107
|
}
|
|
110
|
-
out.push(p.dim(cut("/researcher /librarian /
|
|
108
|
+
out.push(p.dim(cut("/researcher /librarian /read /history /undo | / for all commands", w)), "");
|
|
111
109
|
return out;
|
|
112
110
|
}
|
|
113
111
|
|
package/src/notes.ts
CHANGED
|
@@ -83,12 +83,17 @@ export function matchable(s: string): string {
|
|
|
83
83
|
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** The full text as matchable pages joined by "|": Zotero marks a page break with a form feed. */
|
|
87
|
+
export function sourceOf(fullText: string): string {
|
|
88
|
+
return fullText.split("\f").map(matchable).join("|");
|
|
89
|
+
}
|
|
90
|
+
|
|
86
91
|
/**
|
|
87
92
|
* The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
|
|
88
93
|
* square brackets (an editor's insertion) are left out of the comparison.
|
|
89
94
|
*/
|
|
90
95
|
export function missingQuotes(note: string, fullText: string, already: string[] = []): string[] {
|
|
91
|
-
const source =
|
|
96
|
+
const source = sourceOf(fullText);
|
|
92
97
|
const old = new Set(already);
|
|
93
98
|
return quotesIn(note)
|
|
94
99
|
.filter((q) => !old.has(q))
|
|
@@ -99,29 +104,58 @@ export function missingQuotes(note: string, fullText: string, already: string[]
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
107
|
+
* Where a passage starts in the source (from sourceOf), or -1. It must be there word for word.
|
|
108
|
+
* The only gap allowed is a page break with the running footer and header around it (groups of
|
|
109
|
+
* three words in order, at most `gap` characters apart), so that a quote cannot join two passages
|
|
110
|
+
* of one page without marking the omission.
|
|
104
111
|
*/
|
|
105
|
-
export function
|
|
106
|
-
|
|
112
|
+
export function locate(source: string, passage: string, gap = 300): number {
|
|
113
|
+
const whole = source.indexOf(matchable(passage));
|
|
114
|
+
if (whole >= 0) return whole;
|
|
107
115
|
const words = passage.split(/\s+/).map(matchable).filter(Boolean);
|
|
108
116
|
const groups: string[] = [];
|
|
109
117
|
for (let i = 0; i < words.length; i += 3) groups.push(words.slice(i, i + 3).join(""));
|
|
110
|
-
if (groups.length < 2) return
|
|
118
|
+
if (groups.length < 2) return -1;
|
|
111
119
|
for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
|
|
112
120
|
let end = start + groups[0].length;
|
|
113
121
|
let ok = true;
|
|
114
122
|
for (const g of groups.slice(1)) {
|
|
115
123
|
const at = source.indexOf(g, end);
|
|
116
|
-
if (at < 0 || at - end > gap) {
|
|
124
|
+
if (at < 0 || (at > end && (at - end > gap || !source.slice(end, at).includes("|")))) {
|
|
117
125
|
ok = false;
|
|
118
126
|
break;
|
|
119
127
|
}
|
|
120
128
|
end = at + g.length;
|
|
121
129
|
}
|
|
122
|
-
if (ok) return
|
|
130
|
+
if (ok) return start;
|
|
123
131
|
}
|
|
124
|
-
return
|
|
132
|
+
return -1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function appearsIn(source: string, passage: string, gap = 300): boolean {
|
|
136
|
+
return locate(source, passage, gap) >= 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The page of the PDF (1-based, as Zotero's reader counts) where a passage starts, or undefined. */
|
|
140
|
+
export function pageOf(source: string, passage: string): number | undefined {
|
|
141
|
+
const at = locate(source, passage);
|
|
142
|
+
return at < 0 ? undefined : source.slice(0, at).split("|").length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Each "(p. N)" after a quote becomes a link that opens the PDF in Zotero at the page where the quote
|
|
147
|
+
* is. The label stays as written (the page printed on the paper may differ from the PDF's count).
|
|
148
|
+
*/
|
|
149
|
+
export function withPageLinks(text: string, source: string, attachment: string): string {
|
|
150
|
+
const link = (quote: string, label: string) => {
|
|
151
|
+
const page = pageOf(source, quote.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/)[0]);
|
|
152
|
+
return page ? `([${label}](zotero://open-pdf/library/items/${attachment}?page=${page}))` : `(${label})`;
|
|
153
|
+
};
|
|
154
|
+
const PAGE = String.raw`\((pp?\.\s?\d+(?:\s?[-–]\s?\d+)?)\)`;
|
|
155
|
+
return text
|
|
156
|
+
.replace(new RegExp(String.raw`(")([^"\n]{25,600})("\s*)${PAGE}`, "g"), (_m, o, q, c, label) => `${o}${q}${c}${link(q, label)}`)
|
|
157
|
+
.replace(new RegExp(String.raw`(“)([^“”\n]{25,600})(”\s*)${PAGE}`, "g"), (_m, o, q, c, label) => `${o}${q}${c}${link(q, label)}`)
|
|
158
|
+
.replace(new RegExp(String.raw`^(>[ \t]*)(.{25,}?)(\s*)${PAGE}[ \t]*$`, "gm"), (_m, o, q, c, label) => `${o}${q}${c}${link(q.replace(/^["“]|["”]$/g, ""), label)}`);
|
|
125
159
|
}
|
|
126
160
|
|
|
127
161
|
export function quoteProblem(missing: string[]): string | undefined {
|
|
@@ -130,6 +164,6 @@ export function quoteProblem(missing: string[]): string | undefined {
|
|
|
130
164
|
return [
|
|
131
165
|
`${missing.length} quote(s) in this note are not in the item's full text:`,
|
|
132
166
|
...shown,
|
|
133
|
-
"Copy each quote word for word from the full text (zotero_get_fulltext)
|
|
167
|
+
"Copy each quote word for word from the full text (zotero_get_fulltext). Mark every omission with [...]: two passages joined without that mark are not a quote. Or remove the quote marks and say it in your own words. Then write the note again.",
|
|
134
168
|
].join("\n");
|
|
135
169
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Sub-Sub part of the system prompt: role text for the current mode, the
|
|
3
|
-
*
|
|
3
|
+
* shared Zotero rules from the Sub-Sub folder, the vault
|
|
4
4
|
* conventions when pi runs inside the Sub-Sub folder, and the language rule.
|
|
5
5
|
*
|
|
6
|
-
* Role
|
|
6
|
+
* Role texts use placeholders: {{user}} / {{User}} (the user's name,
|
|
7
7
|
* or "the user"), {{about}} (one line about the user) and {{batch}}.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -11,7 +11,6 @@ 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
13
|
import { type Mode, type SubsubConfig, who } from "./config.ts";
|
|
14
|
-
import { profileSpec } from "./profiles.ts";
|
|
15
14
|
|
|
16
15
|
const ROLE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "roles");
|
|
17
16
|
|
|
@@ -41,18 +40,18 @@ function capital(s: string): string {
|
|
|
41
40
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about"
|
|
43
|
+
export function fill(text: string, cfg: Pick<SubsubConfig, "userName" | "about">): string {
|
|
45
44
|
const user = who(cfg);
|
|
46
45
|
const about = cfg.about ? `${capital(user)} is ${cfg.about}.` : "";
|
|
47
46
|
return text
|
|
48
47
|
.replaceAll("{{User}}", capital(user))
|
|
49
48
|
.replaceAll("{{user}}", user)
|
|
50
49
|
.replaceAll("{{about}}", about)
|
|
51
|
-
.replaceAll("{{batch}}",
|
|
50
|
+
.replaceAll("{{batch}}", "25")
|
|
52
51
|
.replace(/[ \t]+\n/g, "\n");
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about"
|
|
54
|
+
export function roleText(mode: Mode, cfg?: Pick<SubsubConfig, "userName" | "about">): string {
|
|
56
55
|
const raw = readIf(join(ROLE_DIR, `${mode}.md`)) ?? `You are Sub-Sub, the ${mode}.`;
|
|
57
56
|
return cfg ? fill(raw, cfg) : raw;
|
|
58
57
|
}
|
|
@@ -86,8 +85,7 @@ export function systemAddition(mode: Mode, cfg: SubsubConfig, cwd: string): stri
|
|
|
86
85
|
`${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.`,
|
|
87
86
|
);
|
|
88
87
|
}
|
|
89
|
-
// The
|
|
90
|
-
parts.push("# Profile", fill(profileSpec(cfg.profile).rules, cfg));
|
|
88
|
+
// The language rule comes last, so it wins over the shared rules and the vault conventions.
|
|
91
89
|
parts.push("# Language", languageRule(cfg.language, user));
|
|
92
90
|
return parts.join("\n\n");
|
|
93
91
|
}
|