@tiagojct/subsub 0.17.0 → 0.19.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/dist/web.js CHANGED
@@ -18,7 +18,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  import { createServer } from "node:http";
20
20
  import { homedir } from "node:os";
21
- import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
21
+ import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
22
22
  import { StringDecoder } from "node:string_decoder";
23
23
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
24
  import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
@@ -184,6 +184,8 @@ const TYPES = {
184
184
  };
185
185
  /** Files that the notes pane shows. */
186
186
  const NOTE_TYPES = new Set([".md", ".markdown"]);
187
+ /** Images that a note shows (a figure cut out of a PDF, for example). */
188
+ const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
187
189
  /** Files that the Open button may hand to the system (never programs or scripts). */
188
190
  export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
189
191
  /** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
@@ -678,6 +680,19 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
678
680
  return send(res, 413, { error: "too large to show" });
679
681
  return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
680
682
  }
683
+ case "GET /api/note-file": {
684
+ const raw = url.searchParams.get("path") ?? "";
685
+ const file = allowedFile(raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
686
+ if (!file)
687
+ return send(res, 404, { error: "not found in the Sub-Sub folder" });
688
+ const type = IMAGE_TYPES[extname(file).toLowerCase()];
689
+ if (!type)
690
+ return send(res, 415, { error: "not an image" });
691
+ if (statSync(file).size > 20_000_000)
692
+ return send(res, 413, { error: "too large to show" });
693
+ res.writeHead(200, { ...common, "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
694
+ return void createReadStream(file).pipe(res);
695
+ }
681
696
  case "GET /api/library": {
682
697
  const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
683
698
  if (!q)
@@ -708,10 +723,12 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
708
723
  return send(res, 404, { error: "no such session" });
709
724
  if (body.path === (await currentSession()))
710
725
  return send(res, 409, { error: "the open conversation" });
711
- // Nothing is deleted: the file moves to a trash folder next to the agent folder.
712
- const trash = join(dirname(agentDir), "trash");
713
- mkdirSync(trash, { recursive: true });
714
- renameSync(String(body.path), join(trash, basename(String(body.path))));
726
+ // Nothing is deleted: the file moves to a trash folder next to the agent folder, in the same
727
+ // subfolder as under sessions/, so that moving it back is one step.
728
+ const rel = relative(join(agentDir, "sessions"), String(body.path));
729
+ const to = join(dirname(agentDir), "trash", rel.startsWith("..") ? basename(rel) : rel);
730
+ mkdirSync(dirname(to), { recursive: true });
731
+ renameSync(String(body.path), to);
715
732
  return send(res, 200, { ok: true });
716
733
  }
717
734
  case "POST /api/open": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@ Make a short slug: lower case, hyphens, at most five words. Use today's date (YY
12
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
13
  3. Build the matrix, one row per source:
14
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.
15
+ - Evidence type: for example randomised trial, cohort, case-control, cross-sectional, diagnostic accuracy, systematic review, guideline, expert opinion, modelling. Add "(preprint)" to a work that is not peer reviewed yet.
16
16
  - Caveats: risk of bias, small sample, surrogate outcome, short follow-up, industry funding, abstract only.
17
17
  - Confidence: high, moderate or low, with one reason.
18
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.
package/prompts/gaps.md CHANGED
@@ -2,4 +2,4 @@
2
2
  description: "What am I missing on a topic"
3
3
  argument-hint: "<topic or question>"
4
4
  ---
5
- Search PubMed and OpenAlex for: $ARGUMENTS. Tell me how many results are already in my library and list the most relevant ones that are missing, one line each. Ask before you import or queue anything.
5
+ Search PubMed and OpenAlex for: $ARGUMENTS. Tell me how many results are already in my library and list the 5 to 15 most relevant ones that are missing, the best fit first, one line each. This is a list to choose from: do not read the works, and do not search again unless the first search found almost nothing. Ask before you import or queue anything.
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,6 +90,21 @@ 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
@@ -107,6 +126,6 @@ When the user asks for a reading list (for example `/lit reading list: <topic>`,
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,6 +92,7 @@ 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.
package/src/config.ts CHANGED
@@ -62,7 +62,7 @@ export function uiLanguage(language: string | undefined): "pt" | "en" {
62
62
  }
63
63
 
64
64
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
65
- export const SERVER_VERSION = "0.5.5";
65
+ export const SERVER_VERSION = "0.6.0";
66
66
 
67
67
  /** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
68
68
  export const STARBUCK_VERSION = "0.2.0";
@@ -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/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/read.ts CHANGED
@@ -77,6 +77,7 @@ function depth(r: ReadRequest): string[] {
77
77
  "Depth: the whole paper, section by section. A critique needs the full text: if you can read only the abstract, say so, write what the abstract allows, and offer /request-copy to ask the authors for the paper.",
78
78
  'Under "## Methods", "## Results" and "## Discussion": the passages that matter to judge the study, quoted with their place, each with one question for the user (for example: how were the participants selected? what was compared? what else could explain the result?).',
79
79
  `Under "## Appraisal": name the design and the reporting guideline that applies (${GUIDELINES}), and tell the user to check the paper against it item by item. Do not copy the checklist. Then five to eight questions on the risk of bias for this design (selection, confounding, measurement, missing data, selective reporting), each with the passage to look at.`,
80
+ "If the zotero_get_figure tool is available, show the table or figure with the main result: call it with the label of its caption and put the Markdown line it returns under the passage that cites it, with the page link, and a question about what the user sees in it.",
80
81
  ];
81
82
  }
82
83
  }
@@ -98,7 +99,7 @@ export function readInstruction(r: ReadRequest): string {
98
99
  "",
99
100
  "Rules:",
100
101
  "- State no results, effect sizes, summaries or conclusions, in the note or in the chat. Quote the paper and ask; the user writes the answers.",
101
- "- Copy quotes word for word from what you read. After each quote give its place: the page only with evidence: full text, otherwise the section (Abstract, Methods, Results).",
102
+ "- Copy quotes word for word from what you read, and mark every omission with [...]. After each quote give its place: (p. N) only with evidence: full text (Sub-Sub links it to that page of the PDF), otherwise the section (Abstract, Methods, Results).",
102
103
  `- The note is Literature/<citekey>.md in the Sub-Sub folder, with the front matter of a literature note from the shared rules plus type: reading-note and purpose: ${r.purpose}; evidence says what you read. If the note exists, do not overwrite it: add a section "## Reading ${r.date}: ${r.purpose}" at the end with edit, with the content below one heading level lower.`,
103
104
  '- Start with "## Why I am reading this" (the reason, in the user\'s words when they gave them) and end with one line "Next step:" (what to read or do next).',
104
105
  "",
package/src/subsub.ts CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
19
20
  import { dirname, join } from "node:path";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { Bridge, type ServerSpec } from "./bridge.ts";
@@ -35,8 +36,10 @@ import {
35
36
  } from "./config.ts";
36
37
  import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
37
38
  import { aiStatement } from "./statement.ts";
39
+ import { feedbackNote, ISSUES_URL } from "./feedback.ts";
40
+ import { formatChecks, runChecks } from "./doctor.ts";
38
41
  import { PURPOSES, type Purpose, readArgs, readInstruction, type ReadRequest } from "./read.ts";
39
- import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.ts";
42
+ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn, sourceOf, withPageLinks } from "./notes.ts";
40
43
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
41
44
  import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
42
45
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
@@ -52,6 +55,8 @@ export interface SubsubDeps {
52
55
  /** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
53
56
  bridge?: Bridge;
54
57
  specs?: ServerSpec[];
58
+ /** The subsub doctor lines for /feedback (tests give their own). */
59
+ doctor?: () => string;
55
60
  }
56
61
 
57
62
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -154,6 +159,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
154
159
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
155
160
  const webView = process.env.SUBSUB_WEB === "1";
156
161
  let lastError: string | undefined;
162
+ /** The last provider or tool error of the session, for /feedback. */
163
+ let recentError: string | undefined;
157
164
  let resumes = 0;
158
165
  let resumeTimer: ReturnType<typeof setTimeout> | undefined;
159
166
  let turnStart = 0;
@@ -355,12 +362,18 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
355
362
  pi.on("message_end", async (event, ctx) => {
356
363
  const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
357
364
  if (m.role !== "assistant" || m.stopReason !== "error") return;
358
- lastError = m.errorMessage;
365
+ lastError = recentError = m.errorMessage;
359
366
  if (webView || perMinuteWait(m.errorMessage) !== undefined) return;
360
367
  const kind = providerTrouble(m.errorMessage);
361
368
  if (kind) ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
362
369
  });
363
370
 
371
+ pi.on("tool_result", async (event) => {
372
+ if (!event.isError) return;
373
+ const text = event.content.map((c) => (c.type === "text" ? c.text : "")).join(" ");
374
+ recentError = `${(event as { toolName?: string }).toolName ?? "tool"}: ${text}`.slice(0, 2000);
375
+ });
376
+
364
377
  // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
365
378
  // up to three times per request of the user. Anything the user types cancels the wait.
366
379
  pi.on("agent_end", async (_event, ctx) => {
@@ -527,28 +540,39 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
527
540
  if (!front || !key || field(front, "evidence")?.toLowerCase() !== "full text" || !quotesIn(text).length) return undefined;
528
541
  const full = await fullTextOf(key, signal);
529
542
  // Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
530
- return full ? quoteProblem(missingQuotes(text, full, quotesIn(before))) : undefined;
543
+ if (!full) return undefined;
544
+ const missing = quoteProblem(missingQuotes(text, full.text, quotesIn(before)));
545
+ // A note that passes is written with its page references linked to the PDF.
546
+ if (!missing && full.attachment) {
547
+ const link = (s: string) => withPageLinks(s, sourceOf(full.text), full.attachment!);
548
+ if (name === "write") input.content = link(String(input.content ?? ""));
549
+ else for (const e of input.edits as Array<{ newText?: unknown }>) if (typeof e.newText === "string") e.newText = link(e.newText);
550
+ }
551
+ return missing;
531
552
  }
532
553
 
533
- /** The whole indexed full text of an item, read in parts, kept for the session. */
534
- const fullTexts = new Map<string, string | null>();
535
- async function fullTextOf(key: string, signal?: AbortSignal): Promise<string | undefined> {
554
+ /** The whole indexed full text of an item and its attachment, read in parts, kept for the session. */
555
+ const fullTexts = new Map<string, { text: string; attachment?: string } | null>();
556
+ async function fullTextOf(key: string, signal?: AbortSignal): Promise<{ text: string; attachment?: string } | undefined> {
536
557
  if (fullTexts.has(key)) return fullTexts.get(key) ?? undefined;
537
558
  let text = "";
559
+ let attachment: string | undefined;
538
560
  let offset: number | null = 0;
539
561
  try {
540
562
  for (let part = 0; offset !== null && part < 20; part++) {
541
563
  const res = await current().call("zotero_get_fulltext", { key, offset, max_chars: 60000 }, signal);
542
564
  if (res.isError) break;
543
- const data = (res.data ?? {}) as { text?: string; next_offset?: number | null };
565
+ const data = (res.data ?? {}) as { text?: string; next_offset?: number | null; attachment?: string };
544
566
  text += data.text ?? "";
567
+ attachment ??= data.attachment;
545
568
  offset = data.next_offset ?? null;
546
569
  }
547
570
  } catch {
548
571
  /* no full text: the check is skipped */
549
572
  }
550
- fullTexts.set(key, text || null);
551
- return text || undefined;
573
+ const full = text ? { text, attachment } : null;
574
+ fullTexts.set(key, full);
575
+ return full ?? undefined;
552
576
  }
553
577
 
554
578
  /**
@@ -744,6 +768,56 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
744
768
  },
745
769
  });
746
770
 
771
+ pi.registerCommand("feedback", {
772
+ description: "Sub-Sub: a draft bug report or idea in Inbox/, to read and post on GitHub yourself; Sub-Sub sends nothing [what happened]",
773
+ handler: async (args, ctx) => {
774
+ const date = new Date().toISOString().slice(0, 10);
775
+ let piVersion = "";
776
+ try {
777
+ piVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..", "package.json"), "utf8")).version;
778
+ } catch {
779
+ /* not needed for the report */
780
+ }
781
+ let checks: string;
782
+ try {
783
+ checks = deps.doctor ? deps.doctor() : formatChecks(runChecks(cfg, process.env));
784
+ } catch (err) {
785
+ checks = `subsub doctor failed: ${(err as Error).message}`;
786
+ }
787
+ const text = feedbackNote({
788
+ what: String(args ?? ""),
789
+ date,
790
+ versions: `Sub-Sub ${ownVersion()}${piVersion ? `, pi ${piVersion}` : ""}, Node ${process.version}, ${process.platform} ${process.arch}`,
791
+ setup: [
792
+ `Mode: ${mode}. Model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none"}.`,
793
+ `Language: ${cfg.language}. Reference checks (Starbuck): ${starbuckEnabled(cfg) ? "on" : "off"}.`,
794
+ `Zotero: ${library.zotero}.${bridge && Object.keys(bridge.errors).length ? ` Servers not running: ${Object.keys(bridge.errors).join(", ")}.` : ""}`,
795
+ ],
796
+ checks,
797
+ lastError: recentError,
798
+ home: homedir(),
799
+ vault: cfg.vault,
800
+ });
801
+ const dir = join(cfg.vault ?? ctx.cwd, "Inbox");
802
+ let file = join(dir, `${date} Sub-Sub feedback.md`);
803
+ for (let n = 2; existsSync(file); n++) file = join(dir, `${date} Sub-Sub feedback-${n}.md`);
804
+ try {
805
+ mkdirSync(dir, { recursive: true });
806
+ writeFileSync(file, text);
807
+ } catch (err) {
808
+ ctx.ui.notify(`${L("Could not save it", "Não foi possível guardar")}: ${(err as Error).message}`, "warning");
809
+ return;
810
+ }
811
+ ctx.ui.notify(
812
+ L(
813
+ `Sub-Sub: a draft report is in ${file}. Read it, add what you expected, take out anything you do not want to share, and post it at ${ISSUES_URL}. Sub-Sub has sent nothing.`,
814
+ `Sub-Sub: há um rascunho do relatório em ${file}. Leia-o, acrescente o que esperava, retire o que não quer partilhar e publique-o em ${ISSUES_URL}. O Sub-Sub não enviou nada.`,
815
+ ),
816
+ "info",
817
+ );
818
+ },
819
+ });
820
+
747
821
  pi.registerCommand("history", {
748
822
  description: "Sub-Sub: recent library changes",
749
823
  handler: async (_args, ctx) => {
package/src/web.ts CHANGED
@@ -19,7 +19,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
19
19
  import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
20
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
21
  import { homedir } from "node:os";
22
- import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
22
+ import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
23
23
  import { StringDecoder } from "node:string_decoder";
24
24
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
25
  import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
@@ -203,6 +203,9 @@ const TYPES: Record<string, string> = {
203
203
  /** Files that the notes pane shows. */
204
204
  const NOTE_TYPES = new Set([".md", ".markdown"]);
205
205
 
206
+ /** Images that a note shows (a figure cut out of a PDF, for example). */
207
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
208
+
206
209
  /** Files that the Open button may hand to the system (never programs or scripts). */
207
210
  export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
208
211
 
@@ -686,6 +689,16 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
686
689
  if (statSync(file).size > 2_000_000) return send(res, 413, { error: "too large to show" });
687
690
  return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
688
691
  }
692
+ case "GET /api/note-file": {
693
+ const raw = url.searchParams.get("path") ?? "";
694
+ const file = allowedFile(raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
695
+ if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
696
+ const type = IMAGE_TYPES[extname(file).toLowerCase()];
697
+ if (!type) return send(res, 415, { error: "not an image" });
698
+ if (statSync(file).size > 20_000_000) return send(res, 413, { error: "too large to show" });
699
+ res.writeHead(200, { ...common, "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
700
+ return void createReadStream(file).pipe(res);
701
+ }
689
702
  case "GET /api/library": {
690
703
  const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
691
704
  if (!q) return send(res, 200, { q, items: [] });
@@ -710,10 +723,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
710
723
  const known = (await sessions()) as Array<{ path: string }>;
711
724
  if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
712
725
  if (body.path === (await currentSession())) return send(res, 409, { error: "the open conversation" });
713
- // Nothing is deleted: the file moves to a trash folder next to the agent folder.
714
- const trash = join(dirname(agentDir), "trash");
715
- mkdirSync(trash, { recursive: true });
716
- renameSync(String(body.path), join(trash, basename(String(body.path))));
726
+ // Nothing is deleted: the file moves to a trash folder next to the agent folder, in the same
727
+ // subfolder as under sessions/, so that moving it back is one step.
728
+ const rel = relative(join(agentDir, "sessions"), String(body.path));
729
+ const to = join(dirname(agentDir), "trash", rel.startsWith("..") ? basename(rel) : rel);
730
+ mkdirSync(dirname(to), { recursive: true });
731
+ renameSync(String(body.path), to);
717
732
  return send(res, 200, { ok: true });
718
733
  }
719
734
  case "POST /api/open": {
@@ -41,7 +41,7 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
41
41
  - Create a note only when the user asks. Ask before creating more than 10 notes.
42
42
  - Write from the full text (get_fulltext) when it exists. Otherwise use the abstract and the Zotero notes, and write "(from abstract)" after the summary.
43
43
  - Front matter field evidence: full text, abstract or metadata (what you read). Page numbers only with evidence: full text. Sub-Sub refuses a literature note without this field.
44
- - Do not copy the abstract in full. Give page numbers for quotes when the text shows them.
44
+ - Do not copy the abstract in full. Give the page of a quote as (p. N) right after it, when you read the full text; Sub-Sub links it to that page of the PDF in Zotero.
45
45
  - If a note for the citekey exists, do not overwrite it. Add a dated section or ask.
46
46
  - After the note is written, offer to link it in Zotero with attach_note.
47
47
  - /read writes a reading note instead: quotes with their place and questions, which the user answers.