@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/CHANGELOG.md +29 -0
- package/README.md +7 -0
- package/dist/bench.js +324 -10
- package/dist/notes.js +90 -0
- package/dist/roles.js +19 -0
- package/dist/statement.js +35 -0
- package/dist/subsub.js +86 -14
- package/package.json +1 -1
- package/prompts/lit.md +4 -0
- package/roles/librarian.md +5 -0
- package/roles/researcher.md +2 -1
- package/src/bench.ts +327 -10
- package/src/notes.ts +89 -0
- package/src/roles.ts +20 -0
- package/src/statement.ts +45 -0
- package/src/subsub.ts +98 -13
- package/web/app.js +5 -4
- package/web/i18n.js +2 -1
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 {
|
|
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)
|
|
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
|
-
/**
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
848
|
-
if ((m = raw.match(/^
|
|
849
|
-
if (
|
|
850
|
-
if (
|
|
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: "
|
|
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: [
|