@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/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. 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.
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
- 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.
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
- ## In the Reader profile: a reading list
110
+ ## A reading list
92
111
 
93
- When the profile is Reader, 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:
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. Then give the user the path, the answer in three lines, and the works worth adding to the library (offer to import them).
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.
@@ -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 (Author and Editor profiles)
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: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
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, profile, models)
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
- /profile to change the profile, /undo to undo the last change, and / for all commands.
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.5.5";
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` and `/profile`). */
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}, profile ${profileSpec(cfg.profile).label}, replies in ${cfg.language}` }
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
 
@@ -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): profile, name, language,
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, --profile, --tags health-sciences|health-informatics|any-field,
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 defaults of the next two questions.
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 and the Reader profile. Everyone else keeps the standard set-up.");
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, Reader profile (the FMUP model service is coming soon)" },
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 = (opts.mode === "librarian" ? "Librarian" : "Researcher") + (opts.profile ? `, ${opts.profile}` : "");
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 /profile /history /undo | / for all commands", w)), "");
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 = matchable(fullText);
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
- * Whether a passage is in the (matchable) source: whole, or as groups of three words in order with
103
- * short gaps, because a PDF's running header or footer can fall in the middle of a sentence.
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 appearsIn(source: string, passage: string, gap = 300): boolean {
106
- if (source.includes(matchable(passage))) return true;
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 false;
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 true;
130
+ if (ok) return start;
123
131
  }
124
- return false;
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), or remove the quote marks and say it in your own words. Then write the note again.",
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
- * profile rules, the shared Zotero rules from the Sub-Sub folder, the vault
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 and profile texts use placeholders: {{user}} / {{User}} (the user's name,
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" | "profile">): string {
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}}", String(profileSpec(cfg.profile).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" | "profile">): string {
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 profile and the language rule come last, so they win over the shared rules and the vault conventions.
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
  }