@tiagojct/subsub 0.14.0 → 0.16.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/subsub.ts CHANGED
@@ -17,13 +17,14 @@
17
17
  */
18
18
 
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
- import { existsSync, readFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  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,15 @@ 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 { aiStatement } from "./statement.ts";
44
+ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn } from "./notes.ts";
43
45
  import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
44
46
  import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
45
47
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
46
48
  import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
47
49
  import { systemAddition } from "./prompt.ts";
48
50
  import { logUsage } from "./usage.ts";
49
- import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
51
+ import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
50
52
 
51
53
  const MODE_ENTRY = "subsub-mode";
52
54
 
@@ -265,6 +267,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
265
267
  emitWeb();
266
268
  }
267
269
 
270
+ let freeNoted = false;
268
271
  /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
269
272
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
270
273
  mode = next;
@@ -274,7 +277,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
274
277
  const i = spec.indexOf("/");
275
278
  const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
276
279
  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");
280
+ if (!ok)
281
+ ctx.ui.notify(
282
+ L(
283
+ `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).`,
284
+ `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).`,
285
+ ),
286
+ "warning",
287
+ );
288
+ // The free model is for trying Sub-Sub: its research notes were thin in the model test.
289
+ else if (mode === "researcher" && spec === FREE_MODELS.researcher && !freeNoted) {
290
+ freeNoted = true;
291
+ ctx.ui.notify(
292
+ L(
293
+ "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).",
294
+ "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).",
295
+ ),
296
+ "info",
297
+ );
298
+ }
278
299
  }
279
300
  if (remember) pi.appendEntry(MODE_ENTRY, { mode });
280
301
  status(ctx);
@@ -419,6 +440,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
419
440
  return { block: true, reason: why };
420
441
  }
421
442
  }
443
+ const outbound = outboundProblem(name, input);
444
+ if (outbound) {
445
+ if (benchOut) record({ kind: "outbound_blocked", tool: name });
446
+ return { block: true, reason: outbound };
447
+ }
422
448
  // grep, find and ls search the working folder when no path is given.
423
449
  if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
424
450
  return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
@@ -429,7 +455,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
429
455
  if (benchOut) return benchGate(name, input, kind, ctx.cwd, ctx.signal);
430
456
  if (kind === "path") {
431
457
  const target = normalizeToolPath(String(input.path ?? ""), ctx.cwd);
432
- const noteProblem = literatureNoteProblem(name, target, input);
458
+ const noteProblem = await literatureNoteProblem(name, target, input, ctx.signal);
433
459
  if (noteProblem) return { block: true, reason: noteProblem };
434
460
  const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
435
461
  if (verdict.ok) return undefined;
@@ -487,15 +513,49 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
487
513
  return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
488
514
  });
489
515
 
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 {
516
+ /**
517
+ * A literature note (front matter with a citekey) must state its evidence, and a note written
518
+ * from the full text may quote only what the full text says; see notes.ts.
519
+ */
520
+ async function literatureNoteProblem(name: string, target: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<string | undefined> {
492
521
  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 }>));
522
+ let text: string | undefined;
523
+ let before = "";
524
+ if (name === "write") text = String(input.content ?? "");
525
+ else if (name === "edit" && Array.isArray(input.edits) && existsSync(target)) {
526
+ before = readFileSync(target, "utf8");
527
+ text = applyEdits(before, input.edits as Array<{ oldText?: unknown; newText?: unknown }>);
528
+ }
529
+ if (text === undefined) return undefined;
530
+ const problem = checkLiteratureNote(text);
531
+ if (problem) return problem;
532
+ const front = frontMatter(text);
533
+ const key = itemKeyOf(text);
534
+ if (!front || !key || field(front, "evidence")?.toLowerCase() !== "full text" || !quotesIn(text).length) return undefined;
535
+ const full = await fullTextOf(key, signal);
536
+ // Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
537
+ return full ? quoteProblem(missingQuotes(text, full, quotesIn(before))) : undefined;
538
+ }
539
+
540
+ /** The whole indexed full text of an item, read in parts, kept for the session. */
541
+ const fullTexts = new Map<string, string | null>();
542
+ async function fullTextOf(key: string, signal?: AbortSignal): Promise<string | undefined> {
543
+ if (fullTexts.has(key)) return fullTexts.get(key) ?? undefined;
544
+ let text = "";
545
+ let offset: number | null = 0;
546
+ try {
547
+ for (let part = 0; offset !== null && part < 20; part++) {
548
+ const res = await current().call("zotero_get_fulltext", { key, offset, max_chars: 60000 }, signal);
549
+ if (res.isError) break;
550
+ const data = (res.data ?? {}) as { text?: string; next_offset?: number | null };
551
+ text += data.text ?? "";
552
+ offset = data.next_offset ?? null;
553
+ }
554
+ } catch {
555
+ /* no full text: the check is skipped */
497
556
  }
498
- return undefined;
557
+ fullTexts.set(key, text || null);
558
+ return text || undefined;
499
559
  }
500
560
 
501
561
  /**
@@ -528,7 +588,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
528
588
  async function benchGate(name: string, input: Record<string, unknown>, kind: string, cwd: string, signal?: AbortSignal) {
529
589
  if (kind === "path") {
530
590
  const target = normalizeToolPath(String(input.path ?? ""), cwd);
531
- const noteProblem = literatureNoteProblem(name, target, input);
591
+ const noteProblem = await literatureNoteProblem(name, target, input, signal);
532
592
  if (noteProblem) {
533
593
  record({ kind: "note_blocked", tool: name, path: target, reason: noteProblem });
534
594
  return { block: true, reason: noteProblem };
@@ -636,6 +696,31 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
636
696
  },
637
697
  });
638
698
 
699
+ pi.registerCommand("ai-statement", {
700
+ description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
701
+ handler: async (args, ctx) => {
702
+ const date = new Date().toISOString().slice(0, 10);
703
+ const text = aiStatement({ version: ownVersion(), models: cfg.models, uses: String(args ?? ""), date, starbuck: starbuckEnabled(cfg) });
704
+ const dir = join(cfg.vault ?? ctx.cwd, "Research");
705
+ let file = join(dir, `${date} AI use statement.md`);
706
+ for (let n = 2; existsSync(file); n++) file = join(dir, `${date} AI use statement-${n}.md`);
707
+ try {
708
+ mkdirSync(dir, { recursive: true });
709
+ writeFileSync(file, text);
710
+ } catch (err) {
711
+ ctx.ui.notify(`${text}\n${L("Could not save it", "Não foi possível guardar")}: ${(err as Error).message}`, "warning");
712
+ return;
713
+ }
714
+ ctx.ui.notify(
715
+ L(
716
+ `Sub-Sub: a draft statement on the use of AI is in ${file}. Complete the parts in brackets. To name the tasks, type /ai-statement followed by them, for example: /ai-statement literature searches and reading notes.`,
717
+ `Sub-Sub: há um rascunho da declaração sobre o uso de IA em ${file}. Complete as partes entre parênteses retos. Para indicar as tarefas, escreva /ai-statement seguido delas, por exemplo: /ai-statement literature searches and reading notes.`,
718
+ ),
719
+ "info",
720
+ );
721
+ },
722
+ });
723
+
639
724
  pi.registerCommand("history", {
640
725
  description: "Sub-Sub: recent library changes",
641
726
  handler: async (_args, ctx) => {
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}?",
@@ -270,6 +270,7 @@ export const ACTIONS = {
270
270
  { cmd: "review", fill: true, profiles: ["author", "editor"], en: "Review a manuscript", pt: "Rever um manuscrito" },
271
271
  { cmd: "alert", send: true, en: "New publications", pt: "Novas publicações" },
272
272
  { cmd: "request-copy", fill: true, en: "Ask an author for a copy", pt: "Pedir uma cópia ao autor" },
273
+ { cmd: "ai-statement", fill: true, en: "Statement on AI use", pt: "Declaração de uso de IA" },
273
274
  { cmd: "digest", send: true, profiles: ["scholar", "author", "editor"], en: "Digest of the week", pt: "Resumo da semana" },
274
275
  ],
275
276
  library: [