@tiagojct/subsub 0.14.0 → 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/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}?",