@tiagojct/subsub 0.13.1 → 0.15.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/src/notes.ts CHANGED
@@ -44,3 +44,92 @@ export function applyEdits(current: string, edits: Array<{ oldText?: unknown; ne
44
44
  }
45
45
  return out;
46
46
  }
47
+
48
+ // ---------------------------------------------------------------- quotes against the full text
49
+
50
+ /** The Zotero item key in a note's front matter (zotero: zotero://select/library/items/KEY). */
51
+ export function itemKeyOf(text: string): string | undefined {
52
+ const front = frontMatter(text);
53
+ const z = front ? field(front, "zotero") : undefined;
54
+ return z ? /items\/([A-Z0-9]{8})\b/.exec(z)?.[1] : undefined;
55
+ }
56
+
57
+ /**
58
+ * Direct quotes in a note: text in double quotes, and blockquote lines, of 25 characters or more.
59
+ * Shorter quotes are usually terms, not passages, and are not checked.
60
+ */
61
+ export function quotesIn(text: string): string[] {
62
+ const body = text.replace(FRONT, "");
63
+ const out = new Set<string>();
64
+ // An opening mark follows a space or a bracket and a closing mark precedes one, so the text
65
+ // between one quote and the next ("a" (p. 3); then "b") is not taken for a quote.
66
+ for (const m of body.matchAll(/(?:^|[\s(\[—–])"([^"\s][^"\n]{23,598}[^"\s])"(?=[\s.,;:)\]!?]|$)/gm)) out.add(m[1].trim());
67
+ for (const m of body.matchAll(/“([^“”\n]{25,600})”/g)) out.add(m[1].trim());
68
+ for (const m of body.matchAll(/^>[ \t]*(.{25,})$/gm)) {
69
+ // A blockquote may end with its source: "... (p. 12)" or "... [@smith2026, p. 12]".
70
+ const q = m[1].replace(/\s*(?:\((?:p|pp)\.[^)]*\)|\[@[^\]]*\]|[-—–]\s*p\.?\s*\d+.*)\s*$/i, "").replace(/^["“]|["”]$/g, "").trim();
71
+ if (q.length >= 25) out.add(q);
72
+ }
73
+ return [...out];
74
+ }
75
+
76
+ /** Letters and digits only, lower case: PDF text breaks lines, hyphenates words and changes quote marks. */
77
+ export function matchable(s: string): string {
78
+ return s
79
+ .normalize("NFKD")
80
+ .replace(/[̀-ͯ]/g, "")
81
+ .replace(/(\w)-\s*\n\s*(\w)/g, "$1$2")
82
+ .toLowerCase()
83
+ .replace(/[^\p{L}\p{N}]+/gu, "");
84
+ }
85
+
86
+ /**
87
+ * The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
88
+ * square brackets (an editor's insertion) are left out of the comparison.
89
+ */
90
+ export function missingQuotes(note: string, fullText: string, already: string[] = []): string[] {
91
+ const source = matchable(fullText);
92
+ const old = new Set(already);
93
+ return quotesIn(note)
94
+ .filter((q) => !old.has(q))
95
+ .filter((q) => {
96
+ const parts = q.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/).map((p) => p.replace(/\[[^\]]*\]/g, " "));
97
+ return parts.filter((p) => matchable(p).length >= 12).some((p) => !appearsIn(source, p));
98
+ });
99
+ }
100
+
101
+ /**
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.
104
+ */
105
+ export function appearsIn(source: string, passage: string, gap = 300): boolean {
106
+ if (source.includes(matchable(passage))) return true;
107
+ const words = passage.split(/\s+/).map(matchable).filter(Boolean);
108
+ const groups: string[] = [];
109
+ for (let i = 0; i < words.length; i += 3) groups.push(words.slice(i, i + 3).join(""));
110
+ if (groups.length < 2) return false;
111
+ for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
112
+ let end = start + groups[0].length;
113
+ let ok = true;
114
+ for (const g of groups.slice(1)) {
115
+ const at = source.indexOf(g, end);
116
+ if (at < 0 || at - end > gap) {
117
+ ok = false;
118
+ break;
119
+ }
120
+ end = at + g.length;
121
+ }
122
+ if (ok) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export function quoteProblem(missing: string[]): string | undefined {
128
+ if (!missing.length) return undefined;
129
+ const shown = missing.slice(0, 5).map((q) => `- "${q.length > 160 ? `${q.slice(0, 160)}...` : q}"`);
130
+ return [
131
+ `${missing.length} quote(s) in this note are not in the item's full text:`,
132
+ ...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.",
134
+ ].join("\n");
135
+ }
package/src/profiles.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Scholar. A profile is the user's own choice (/profile changes it), not a lock.
5
5
  *
6
6
  * - reader: explains each step; reading notes are quotes with page numbers and
7
- * questions; no summaries or syntheses; no bulk library changes.
7
+ * questions; no summaries or syntheses; /lit gives a reading list;
8
+ * no bulk library changes.
8
9
  * - scholar: literature notes, syntheses, searches, alerts; all library changes.
9
10
  * - author: scholar plus manuscript work: citation check, bibliography, comments
10
11
  * on the argument. Does not write the manuscript's paragraphs.
@@ -36,15 +37,16 @@ export const PROFILE_SPECS: Record<Profile, ProfileSpec> = {
36
37
  reader: {
37
38
  name: "reader",
38
39
  label: "Reader",
39
- summary: "explains each step; reading notes are quotes and questions; no summaries; no bulk library changes",
40
+ summary: "explains each step; reading notes are quotes and questions; /lit gives a reading list; no summaries; no bulk library changes",
40
41
  without: [...BULK_LIBRARY, ...MANUSCRIPT],
41
- withoutPrompts: ["lit", "compare", "review", "digest"],
42
+ withoutPrompts: ["compare", "review", "digest"],
42
43
  batch: 10,
43
44
  rules: [
44
45
  "Profile: Reader. {{User}} is learning to work with the literature and wants to do the reading and the writing.",
45
46
  "- Before each step, say in one or two lines what you will do and why.",
46
47
  "- A literature note is a reading note: the full reference, then direct quotes with page numbers (or section names), and under each quote one question for {{user}} to answer. Do not write summaries, syntheses, conclusions or paragraphs that {{user}} could hand in as their own text. When a prompt or the shared rules ask for a literature note or a synthesis, make a reading note instead and say so.",
47
48
  "- For searches, list works with one line each on what they study, not on what they conclude.",
49
+ "- /lit gives a reading list, not a review: plan, search, screen and read as the prompt says, then write the note in the Reader form that the prompt gives (no answer, no agreement or disagreement, no conclusions).",
48
50
  "- If {{user}} asks for a summary, a synthesis or text for an assignment, say that the Reader profile does not write these, and offer quotes and questions. {{User}} can change the profile with /profile.",
49
51
  "- Tag review notes: at most 10 items. Explain each proposed tag in the Reason column.",
50
52
  ].join("\n"),
package/src/roles.ts CHANGED
@@ -116,3 +116,23 @@ export function gateKind(toolName: string, input: Record<string, unknown>): Gate
116
116
  if (toolName === "write" || toolName === "edit") return "path";
117
117
  return null;
118
118
  }
119
+
120
+ /** Tools that send a query to an outside service (PubMed, Europe PMC, OpenAlex, Crossref, a web search). */
121
+ const OUTBOUND = new Set(["scholar_search_pubmed", "scholar_search_openalex", "scholar_search_europepmc", "scholar_search_multi", "zotero_find_reference", "zotero_web_search"]);
122
+ export const MAX_QUERY_CHARS = 1500;
123
+
124
+ /**
125
+ * Why an outside search is refused: a query is search terms, so a query that is long or holds
126
+ * several lines is text from a note, a full text or the library, sent where it should not go
127
+ * (for example because a document asked for it).
128
+ */
129
+ export function outboundProblem(toolName: string, input: Record<string, unknown>): string | undefined {
130
+ if (!OUTBOUND.has(toolName)) return undefined;
131
+ const queries = [input.query, ...(Array.isArray(input.queries) ? input.queries : [])].filter((q): q is string => typeof q === "string");
132
+ for (const q of queries) {
133
+ if (q.length > MAX_QUERY_CHARS || q.split("\n").length > 3) {
134
+ return `This search query is ${q.length} characters on ${q.split("\n").length} lines. A query is search terms; send no passages of notes, full texts or library records to an outside service. Shorten it to the search terms.`;
135
+ }
136
+ }
137
+ return undefined;
138
+ }
package/src/subsub.ts CHANGED
@@ -24,6 +24,7 @@ import { Bridge, type ServerSpec } from "./bridge.ts";
24
24
  import {
25
25
  expand,
26
26
  findUv,
27
+ FREE_MODELS,
27
28
  isProfile,
28
29
  loadConfig,
29
30
  parseEnvFile,
@@ -39,14 +40,14 @@ import {
39
40
  } from "./config.ts";
40
41
  import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
41
42
  import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
42
- import { applyEdits, checkLiteratureNote } from "./notes.ts";
43
+ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.ts";
43
44
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
44
45
  import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
45
46
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
46
47
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
47
48
  import { systemAddition } from "./prompt.ts";
48
49
  import { logUsage } from "./usage.ts";
49
- import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
50
+ import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
50
51
 
51
52
  const MODE_ENTRY = "subsub-mode";
52
53
 
@@ -265,6 +266,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
265
266
  emitWeb();
266
267
  }
267
268
 
269
+ let freeNoted = false;
268
270
  /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
269
271
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
270
272
  mode = next;
@@ -274,7 +276,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
274
276
  const i = spec.indexOf("/");
275
277
  const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
276
278
  const ok = model ? await pi.setModel(model) : false;
277
- if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
279
+ if (!ok)
280
+ ctx.ui.notify(
281
+ L(
282
+ `Sub-Sub: cannot use ${spec}: no key for that provider, or the provider renamed or retired the model. Keeping the current model. Choose another with the model button (subsub doctor lists the settings).`,
283
+ `Sub-Sub: não é possível usar ${spec}: não há chave para esse fornecedor, ou o fornecedor mudou o nome do modelo ou retirou-o. Mantém-se o modelo atual. Escolha outro com o botão do modelo (o subsub doctor mostra as definições).`,
284
+ ),
285
+ "warning",
286
+ );
287
+ // The free model is for trying Sub-Sub: its research notes were thin in the model test.
288
+ else if (mode === "researcher" && spec === FREE_MODELS.researcher && !freeNoted) {
289
+ freeNoted = true;
290
+ ctx.ui.notify(
291
+ L(
292
+ "Sub-Sub: the free model is good for trying Sub-Sub and for library tasks. In our test its literature notes and reviews were thin; for research you rely on, choose a paid model (see the Models page).",
293
+ "Sub-Sub: o modelo gratuito serve para experimentar o Sub-Sub e para tarefas de biblioteca. No nosso teste, as suas notas de literatura e revisões foram pobres; para investigação em que precise de confiar, escolha um modelo pago (veja a página Modelos).",
294
+ ),
295
+ "info",
296
+ );
297
+ }
278
298
  }
279
299
  if (remember) pi.appendEntry(MODE_ENTRY, { mode });
280
300
  status(ctx);
@@ -419,6 +439,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
419
439
  return { block: true, reason: why };
420
440
  }
421
441
  }
442
+ const outbound = outboundProblem(name, input);
443
+ if (outbound) {
444
+ if (benchOut) record({ kind: "outbound_blocked", tool: name });
445
+ return { block: true, reason: outbound };
446
+ }
422
447
  // grep, find and ls search the working folder when no path is given.
423
448
  if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
424
449
  return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
@@ -429,7 +454,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
429
454
  if (benchOut) return benchGate(name, input, kind, ctx.cwd, ctx.signal);
430
455
  if (kind === "path") {
431
456
  const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
432
- const noteProblem = literatureNoteProblem(name, target, input);
457
+ const noteProblem = await literatureNoteProblem(name, target, input, ctx.signal);
433
458
  if (noteProblem) return { block: true, reason: noteProblem };
434
459
  const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
435
460
  if (verdict.ok) return undefined;
@@ -487,15 +512,49 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
487
512
  return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
488
513
  });
489
514
 
490
- /** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
491
- function literatureNoteProblem(name: string, target: string, input: Record<string, unknown>): string | undefined {
515
+ /**
516
+ * A literature note (front matter with a citekey) must state its evidence, and a note written
517
+ * from the full text may quote only what the full text says; see notes.ts.
518
+ */
519
+ async function literatureNoteProblem(name: string, target: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<string | undefined> {
492
520
  if (!target.toLowerCase().endsWith(".md")) return undefined;
493
- if (name === "write") return checkLiteratureNote(String(input.content ?? ""));
494
- if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
495
- const current = readFileSync(target, "utf8");
496
- return checkLiteratureNote(applyEdits(current, input.edits as Array<{ oldText?: unknown; newText?: unknown }>));
521
+ let text: string | undefined;
522
+ let before = "";
523
+ if (name === "write") text = String(input.content ?? "");
524
+ else if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
525
+ before = readFileSync(target, "utf8");
526
+ text = applyEdits(before, input.edits as Array<{ oldText?: unknown; newText?: unknown }>);
527
+ }
528
+ if (text === undefined) return undefined;
529
+ const problem = checkLiteratureNote(text);
530
+ if (problem) return problem;
531
+ const front = frontMatter(text);
532
+ const key = itemKeyOf(text);
533
+ if (!front || !key || field(front, "evidence")?.toLowerCase() !== "full text" || !quotesIn(text).length) return undefined;
534
+ const full = await fullTextOf(key, signal);
535
+ // Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
536
+ return full ? quoteProblem(missingQuotes(text, full, quotesIn(before))) : undefined;
537
+ }
538
+
539
+ /** The whole indexed full text of an item, read in parts, kept for the session. */
540
+ const fullTexts = new Map<string, string | null>();
541
+ async function fullTextOf(key: string, signal?: AbortSignal): Promise<string | undefined> {
542
+ if (fullTexts.has(key)) return fullTexts.get(key) ?? undefined;
543
+ let text = "";
544
+ let offset: number | null = 0;
545
+ try {
546
+ for (let part = 0; offset !== null && part < 20; part++) {
547
+ const res = await current().call("zotero_get_fulltext", { key, offset, max_chars: 60000 }, signal);
548
+ if (res.isError) break;
549
+ const data = (res.data ?? {}) as { text?: string; next_offset?: number | null };
550
+ text += data.text ?? "";
551
+ offset = data.next_offset ?? null;
552
+ }
553
+ } catch {
554
+ /* no full text: the check is skipped */
497
555
  }
498
- return undefined;
556
+ fullTexts.set(key, text || null);
557
+ return text || undefined;
499
558
  }
500
559
 
501
560
  /**
@@ -528,7 +587,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
528
587
  async function benchGate(name: string, input: Record<string, unknown>, kind: string, cwd: string, signal?: AbortSignal) {
529
588
  if (kind === "path") {
530
589
  const target = normalizeToolPath(String(input.path ?? ""), cwd);
531
- const noteProblem = literatureNoteProblem(name, target, input);
590
+ const noteProblem = await literatureNoteProblem(name, target, input, signal);
532
591
  if (noteProblem) {
533
592
  record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
534
593
  return { block: true, reason: noteProblem };
package/web/app.js CHANGED
@@ -844,10 +844,11 @@ async function refreshSession() {
844
844
  function dialogTitle(d) {
845
845
  const raw = String(d.title ?? "").replace(/^Sub-Sub:\s*/, "");
846
846
  let m;
847
- if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
848
- if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
849
- if (raw === "undo?") return t("undo");
850
- if ((m = raw.match(/^write the Starbuck report to (.+)\?$/))) return t("starbuckReport", { x: m[1] });
847
+ // The program sends the title in the user's language; both forms get the same friendly title.
848
+ if ((m = raw.match(/^(?:apply|aplicar) (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
849
+ if ((m = raw.match(/^(?:run|executar) (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
850
+ if (raw === "undo?" || raw === "anular?") return t("undo");
851
+ if ((m = raw.match(/^(?:write the Starbuck report to|escrever o relatório do Starbuck em) (.+)\?$/))) return t("starbuckReport", { x: m[1] });
851
852
  if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
852
853
  return raw;
853
854
  }
package/web/i18n.js CHANGED
@@ -143,7 +143,7 @@ export const STRINGS = {
143
143
  ok: "OK",
144
144
  cancel: "Cancelar",
145
145
  close: "Fechar",
146
- apply: "Aplicar {x}?",
146
+ apply: "Confirmar: {x}?",
147
147
  run: "Executar {x}?",
148
148
  undo: "Desfazer esta alteração?",
149
149
  write: "Permitir escrever em {x}?",
@@ -260,7 +260,7 @@ export const STRINGS = {
260
260
  */
261
261
  export const ACTIONS = {
262
262
  research: [
263
- { cmd: "lit", fill: true, profiles: ["scholar", "author", "editor"], en: "Literature review", pt: "Revisão da literatura" },
263
+ { cmd: "lit", fill: true, en: "Literature review", pt: "Revisão da literatura" },
264
264
  { cmd: "compare", fill: true, profiles: ["scholar", "author", "editor"], en: "Compare sources", pt: "Comparar fontes" },
265
265
  { cmd: "lit-note", fill: true, en: "Literature note", pt: "Nota de leitura" },
266
266
  { cmd: "synthesis", fill: true, en: "Synthesis", pt: "Síntese" },