@tiagojct/subsub 0.16.0 → 0.18.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 +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/src/read.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /read: guided reading of one paper, for people who are learning to read the literature.
|
|
3
|
+
* Sub-Sub asks why the user reads the paper and reads only as deep as that reason needs. It finds
|
|
4
|
+
* the passages (quotes with their place) and asks questions; the user writes the answers. It states
|
|
5
|
+
* no results: /lit-note is the note that Sub-Sub writes for you.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The reasons to read, with the labels of the choice (English, Portuguese). */
|
|
9
|
+
export const PURPOSES = {
|
|
10
|
+
background: ["Background: is it worth reading? (title and abstract, keep or drop)", "Contexto: vale a pena ler? (título e resumo, manter ou pôr de parte)"],
|
|
11
|
+
fact: ["One fact: find a specific detail", "Um dado: encontrar um pormenor concreto"],
|
|
12
|
+
extract: ["Extraction: one row for my review (question, design, population, setting, finding)", "Extração: uma linha para a minha revisão (pergunta, desenho, população, contexto, resultado)"],
|
|
13
|
+
critique: ["Critique: read it closely (peer review, the paper closest to mine)", "Crítica: ler com atenção (revisão por pares, o artigo mais próximo do meu)"],
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type Purpose = keyof typeof PURPOSES;
|
|
17
|
+
|
|
18
|
+
export interface ReadRequest {
|
|
19
|
+
ref: string;
|
|
20
|
+
purpose: Purpose;
|
|
21
|
+
date: string;
|
|
22
|
+
/** fact: what the user looks for. */
|
|
23
|
+
fact?: string;
|
|
24
|
+
/** extract: an existing sheet, relative to the Sub-Sub folder. */
|
|
25
|
+
sheet?: string;
|
|
26
|
+
/** extract: the review question of a new sheet. */
|
|
27
|
+
question?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "/read extract smith2026" -> purpose and reference; the first word is a purpose only when it is one exactly. */
|
|
31
|
+
export function readArgs(args: string): { purpose?: Purpose; ref: string } {
|
|
32
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
const first = words[0]?.toLowerCase();
|
|
34
|
+
if (first && Object.hasOwn(PURPOSES, first)) return { purpose: first as Purpose, ref: words.slice(1).join(" ") };
|
|
35
|
+
return { ref: words.join(" ") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GUIDELINES =
|
|
39
|
+
"CONSORT for randomised trials, STROBE for observational studies, PRISMA for systematic reviews, STARD for diagnostic accuracy, TRIPOD or TRIPOD+AI for prediction models, CHEERS for economic evaluations, SRQR or COREQ for qualitative research";
|
|
40
|
+
|
|
41
|
+
function depth(r: ReadRequest): string[] {
|
|
42
|
+
switch (r.purpose) {
|
|
43
|
+
case "background":
|
|
44
|
+
return [
|
|
45
|
+
"Depth: the title and the abstract only. Do not read the full text.",
|
|
46
|
+
'Under "## The paper at a glance":',
|
|
47
|
+
"- What it asks: the sentence of the title or abstract that states the question or aim, quoted.",
|
|
48
|
+
"- How: the sentence that names the design and the population, quoted.",
|
|
49
|
+
'Under "## Keep or drop": the question "Does this paper fit your reason? Look at the population, the design and the outcome.", then the lines "- [ ] Keep", "- [ ] Drop" and "Reason:", empty for the user.',
|
|
50
|
+
];
|
|
51
|
+
case "fact":
|
|
52
|
+
return [
|
|
53
|
+
`The user looks for: ${r.fact}`,
|
|
54
|
+
"Depth: only the passages that hold this fact. Get the section list first and read only the sections you need.",
|
|
55
|
+
'Under "## Where the paper says it": each passage, quoted, with its place. Under each passage, one question that makes the user read it closely (for example: what exactly does it say, for which population, at what time?) and an empty line "Your answer:".',
|
|
56
|
+
"If the paper does not say it, write that you did not find it and where you looked.",
|
|
57
|
+
];
|
|
58
|
+
case "extract": {
|
|
59
|
+
const sheet = r.sheet
|
|
60
|
+
? `the sheet ${r.sheet}: read it first for its columns, then add the row under its table with edit.`
|
|
61
|
+
: `a new sheet Research/<slug> extraction.md (slug: the review question in lower case with hyphens, at most five words; do not overwrite an existing file). Front matter: title, question: "${r.question}", type: extraction-sheet, created: ${r.date}. Then a table with the columns | Study | Question | Design | Population | Setting | Outcome | Main finding |.`;
|
|
62
|
+
return [
|
|
63
|
+
"Depth: the methods and the results (the full text when there is one).",
|
|
64
|
+
'Under "## Extraction": for each field below, the passage that answers it, quoted with its place, then an empty line "Your answer:".',
|
|
65
|
+
"- The question or aim",
|
|
66
|
+
"- The design",
|
|
67
|
+
"- The population (who, how many)",
|
|
68
|
+
"- The setting (where, when)",
|
|
69
|
+
"- The outcome and how it was measured",
|
|
70
|
+
"- The main finding: quote the sentence of the results that answers the question. Do not restate it.",
|
|
71
|
+
`Then add one row for this paper to ${sheet}`,
|
|
72
|
+
"The row: [[citekey]] in the Study column, and in every other column only where the user finds it (the page or the section). Leave the content of the cells for the user.",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
case "critique":
|
|
76
|
+
return [
|
|
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
|
+
'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
|
+
`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.",
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The instruction for the model. The first line is the command itself: the chat shows a long
|
|
87
|
+
* message folded under its first line, so the user sees what they asked for.
|
|
88
|
+
*/
|
|
89
|
+
export function readInstruction(r: ReadRequest): string {
|
|
90
|
+
return [
|
|
91
|
+
`/read ${r.purpose} ${r.ref}`,
|
|
92
|
+
"",
|
|
93
|
+
`Guided reading of ${r.ref}. The user is learning to read papers: you find the passages and ask; the user reads and writes the answers.`,
|
|
94
|
+
"",
|
|
95
|
+
"1. Find the work: zotero_find_items first. For a DOI or PMID that is not in the library, scholar_get_work, and offer to import it.",
|
|
96
|
+
"2. Read only what the depth below needs: zotero_get_fulltext for a library item; otherwise scholar_read_oa_fulltext (the section list first, then only the sections you need); otherwise scholar_get_work for the abstract.",
|
|
97
|
+
"3. Write the reading note as described below.",
|
|
98
|
+
"4. Give the user the path of the note and the first question to answer. Do not answer the questions.",
|
|
99
|
+
"",
|
|
100
|
+
"Rules:",
|
|
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.",
|
|
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).",
|
|
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.`,
|
|
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).',
|
|
105
|
+
"",
|
|
106
|
+
...depth(r),
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
package/src/roles.ts
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
* Which tools each mode may use, and which calls need the user's approval.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Mode
|
|
6
|
-
import { profileSpec } from "./profiles.ts";
|
|
5
|
+
import type { Mode } from "./config.ts";
|
|
7
6
|
|
|
8
7
|
export const FILE_TOOLS = ["read", "edit", "write", "grep", "find", "ls"];
|
|
9
8
|
|
|
10
|
-
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode
|
|
9
|
+
/** Prefixes of the tools that Sub-Sub's servers provide (the gate checks the mode for these). */
|
|
11
10
|
export const SERVER_PREFIXES = ["zotero_", "scholar_", "verify_"];
|
|
12
11
|
|
|
13
12
|
export const ZOTERO_READ = [
|
|
@@ -66,19 +65,17 @@ export const ZOTERO_CHECKS = [
|
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
export interface ToolOptions {
|
|
69
|
-
profile?: Profile;
|
|
70
68
|
/** The model test: also the bakeoff_ tools. */
|
|
71
69
|
bench?: boolean;
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
73
|
* The tools of a mode. The Librarian manages the library: Zotero tools and files.
|
|
76
|
-
* The Researcher has the same, plus searches, notes and reference checks.
|
|
77
|
-
*
|
|
74
|
+
* The Researcher has the same, plus searches, notes and reference checks. Every
|
|
75
|
+
* library change is still previewed (gateKind).
|
|
78
76
|
*/
|
|
79
77
|
export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {}): string[] {
|
|
80
78
|
const has = new Set(registered);
|
|
81
|
-
const without = new Set(profileSpec(opts.profile ?? "editor").without);
|
|
82
79
|
const zotero = registered.filter((n) => n.startsWith("zotero_") && (opts.bench || !n.startsWith("zotero_bakeoff_")));
|
|
83
80
|
const wanted = [
|
|
84
81
|
...ZOTERO_READ,
|
|
@@ -87,16 +84,12 @@ export function toolsFor(mode: Mode, registered: string[], opts: ToolOptions = {
|
|
|
87
84
|
...(mode === "researcher" ? registered.filter((n) => n.startsWith("scholar_") || n.startsWith("verify_")) : []),
|
|
88
85
|
...FILE_TOOLS,
|
|
89
86
|
];
|
|
90
|
-
return [...new Set(wanted)].filter((n) => has.has(n)
|
|
87
|
+
return [...new Set(wanted)].filter((n) => has.has(n));
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
/** Why a Sub-Sub tool is not available now, or undefined when it is. */
|
|
94
91
|
export function unavailableReason(name: string, mode: Mode, registered: string[], opts: ToolOptions = {}): string | undefined {
|
|
95
92
|
if (toolsFor(mode, registered, opts).includes(name)) return undefined;
|
|
96
|
-
const profile = opts.profile ?? "editor";
|
|
97
|
-
if (profileSpec(profile).without.includes(name)) {
|
|
98
|
-
return `${name} is not part of the ${profileSpec(profile).label} profile. The user can change the profile with /profile.`;
|
|
99
|
-
}
|
|
100
93
|
if (mode === "librarian" && toolsFor("researcher", registered, opts).includes(name)) {
|
|
101
94
|
return `${name} is part of the Researcher, which also does everything the Librarian does. The user can switch with /researcher.`;
|
|
102
95
|
}
|
package/src/subsub.ts
CHANGED
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* and writes notes. Each mode can have its own model.
|
|
7
7
|
* - An approval gate: every library change is previewed by the server (dry run)
|
|
8
8
|
* and shown to the user; nothing is applied without a yes.
|
|
9
|
-
* -
|
|
10
|
-
* tools it offers; /profile changes it.
|
|
11
|
-
* - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
|
|
9
|
+
* - Commands: /librarian, /researcher, /read, /subsub, /history, /undo.
|
|
12
10
|
* - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
|
|
13
11
|
* ("verify") for reference checks.
|
|
14
12
|
*
|
|
@@ -17,7 +15,8 @@
|
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
21
20
|
import { dirname, join } from "node:path";
|
|
22
21
|
import { fileURLToPath } from "node:url";
|
|
23
22
|
import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
@@ -25,12 +24,9 @@ import {
|
|
|
25
24
|
expand,
|
|
26
25
|
findUv,
|
|
27
26
|
FREE_MODELS,
|
|
28
|
-
isProfile,
|
|
29
27
|
loadConfig,
|
|
30
28
|
parseEnvFile,
|
|
31
|
-
type Profile,
|
|
32
29
|
type Mode,
|
|
33
|
-
saveConfig,
|
|
34
30
|
serverCommand,
|
|
35
31
|
starbuckCommand,
|
|
36
32
|
starbuckEnabled,
|
|
@@ -38,10 +34,12 @@ import {
|
|
|
38
34
|
uiLanguage,
|
|
39
35
|
who,
|
|
40
36
|
} from "./config.ts";
|
|
41
|
-
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
42
37
|
import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
43
38
|
import { aiStatement } from "./statement.ts";
|
|
44
|
-
import {
|
|
39
|
+
import { feedbackNote, ISSUES_URL } from "./feedback.ts";
|
|
40
|
+
import { formatChecks, runChecks } from "./doctor.ts";
|
|
41
|
+
import { PURPOSES, type Purpose, readArgs, readInstruction, type ReadRequest } from "./read.ts";
|
|
42
|
+
import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missingQuotes, quoteProblem, quotesIn, sourceOf, withPageLinks } from "./notes.ts";
|
|
45
43
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
46
44
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
47
45
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
@@ -57,6 +55,8 @@ export interface SubsubDeps {
|
|
|
57
55
|
/** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
|
|
58
56
|
bridge?: Bridge;
|
|
59
57
|
specs?: ServerSpec[];
|
|
58
|
+
/** The subsub doctor lines for /feedback (tests give their own). */
|
|
59
|
+
doctor?: () => string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -143,8 +143,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
143
143
|
|
|
144
144
|
let mode: Mode = cfg.defaultMode;
|
|
145
145
|
const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
|
|
146
|
-
const toolOpts = () => ({
|
|
147
|
-
let profile: Profile = cfg.profile;
|
|
146
|
+
const toolOpts = () => ({ bench: benchRun });
|
|
148
147
|
const user = who(cfg);
|
|
149
148
|
/** True when started with the `subsub` command (not as a package inside plain pi). */
|
|
150
149
|
const standalone = process.env.SUBSUB_CLI === "1";
|
|
@@ -160,6 +159,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
160
159
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
161
160
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
162
161
|
let lastError: string | undefined;
|
|
162
|
+
/** The last provider or tool error of the session, for /feedback. */
|
|
163
|
+
let recentError: string | undefined;
|
|
163
164
|
let resumes = 0;
|
|
164
165
|
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
165
166
|
let turnStart = 0;
|
|
@@ -171,8 +172,6 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
171
172
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
172
173
|
const state = {
|
|
173
174
|
mode,
|
|
174
|
-
profile,
|
|
175
|
-
profileLabel: profileSpec(profile).label,
|
|
176
175
|
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
|
|
177
176
|
down,
|
|
178
177
|
library,
|
|
@@ -238,7 +237,6 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
238
237
|
return headerLines({
|
|
239
238
|
version,
|
|
240
239
|
mode,
|
|
241
|
-
profile: profileSpec(profile).label,
|
|
242
240
|
model: modelId,
|
|
243
241
|
library,
|
|
244
242
|
quote,
|
|
@@ -260,7 +258,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
260
258
|
function status(ctx: ExtensionContext): void {
|
|
261
259
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
262
260
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
263
|
-
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${
|
|
261
|
+
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
264
262
|
if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
265
263
|
headerTui?.requestRender();
|
|
266
264
|
webCtx = ctx;
|
|
@@ -324,7 +322,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
324
322
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
325
323
|
applyTheme(ctx);
|
|
326
324
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
327
|
-
logUsage(cfg.usageLog, { ev: "session", mode,
|
|
325
|
+
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
328
326
|
if (lookOn(ctx)) {
|
|
329
327
|
setHeader(ctx);
|
|
330
328
|
void refreshLibrary();
|
|
@@ -347,6 +345,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
347
345
|
|
|
348
346
|
/** Prompt commands for the Researcher; the Librarian has the library ones (tag-batch, clean-tags, import-queue). */
|
|
349
347
|
const RESEARCH_PROMPTS = ["lit", "compare", "lit-note", "synthesis", "gaps", "manuscript", "verify", "review", "alert", "request-copy", "digest"];
|
|
348
|
+
const researcherOnly = (cmd: string) => L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`);
|
|
350
349
|
|
|
351
350
|
// /tree moves to another branch: take that branch's mode (pi restores its model itself).
|
|
352
351
|
pi.on("session_tree", async (_event, ctx) => {
|
|
@@ -363,12 +362,18 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
363
362
|
pi.on("message_end", async (event, ctx) => {
|
|
364
363
|
const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
|
|
365
364
|
if (m.role !== "assistant" || m.stopReason !== "error") return;
|
|
366
|
-
lastError = m.errorMessage;
|
|
365
|
+
lastError = recentError = m.errorMessage;
|
|
367
366
|
if (webView || perMinuteWait(m.errorMessage) !== undefined) return;
|
|
368
367
|
const kind = providerTrouble(m.errorMessage);
|
|
369
368
|
if (kind) ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
|
|
370
369
|
});
|
|
371
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
|
+
|
|
372
377
|
// A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
|
|
373
378
|
// up to three times per request of the user. Anything the user types cancels the wait.
|
|
374
379
|
pi.on("agent_end", async (_event, ctx) => {
|
|
@@ -393,8 +398,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
393
398
|
});
|
|
394
399
|
|
|
395
400
|
|
|
396
|
-
//
|
|
397
|
-
// The input event comes before pi expands a prompt template.
|
|
401
|
+
// Research prompts in the Librarian. The input event comes before pi expands a prompt template.
|
|
398
402
|
pi.on("input", async (event, ctx) => {
|
|
399
403
|
if (resumeTimer) clearTimeout(resumeTimer);
|
|
400
404
|
resumeTimer = undefined;
|
|
@@ -403,13 +407,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
403
407
|
// The usage log keeps the kind of request and its length, never its text.
|
|
404
408
|
if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
405
409
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
406
|
-
ctx.ui.notify(
|
|
410
|
+
ctx.ui.notify(researcherOnly(cmd), "warning");
|
|
411
|
+
return { action: "handled" };
|
|
412
|
+
}
|
|
413
|
+
// shortcut: a notice for the habit of 0.16 and earlier; drop it a few versions on.
|
|
414
|
+
if (cmd === "profile") {
|
|
415
|
+
ctx.ui.notify(L("Sub-Sub no longer has profiles: every command is in the Researcher. To read a paper with guidance, type /read.", "O Sub-Sub já não tem perfis: todos os comandos estão no Investigador. Para ler um artigo com orientação, escreva /read."), "info");
|
|
407
416
|
return { action: "handled" };
|
|
408
417
|
}
|
|
409
|
-
|
|
410
|
-
if (!why) return { action: "continue" };
|
|
411
|
-
ctx.ui.notify(`Sub-Sub: ${why}`, "warning");
|
|
412
|
-
return { action: "handled" };
|
|
418
|
+
return { action: "continue" };
|
|
413
419
|
});
|
|
414
420
|
|
|
415
421
|
pi.on("agent_start", async () => {
|
|
@@ -418,7 +424,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
418
424
|
});
|
|
419
425
|
|
|
420
426
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
421
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode,
|
|
427
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
|
|
422
428
|
}));
|
|
423
429
|
|
|
424
430
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
@@ -534,28 +540,39 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
534
540
|
if (!front || !key || field(front, "evidence")?.toLowerCase() !== "full text" || !quotesIn(text).length) return undefined;
|
|
535
541
|
const full = await fullTextOf(key, signal);
|
|
536
542
|
// Without the full text (no PDF indexed, Zotero closed) there is nothing to check against.
|
|
537
|
-
|
|
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;
|
|
538
552
|
}
|
|
539
553
|
|
|
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> {
|
|
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> {
|
|
543
557
|
if (fullTexts.has(key)) return fullTexts.get(key) ?? undefined;
|
|
544
558
|
let text = "";
|
|
559
|
+
let attachment: string | undefined;
|
|
545
560
|
let offset: number | null = 0;
|
|
546
561
|
try {
|
|
547
562
|
for (let part = 0; offset !== null && part < 20; part++) {
|
|
548
563
|
const res = await current().call("zotero_get_fulltext", { key, offset, max_chars: 60000 }, signal);
|
|
549
564
|
if (res.isError) break;
|
|
550
|
-
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 };
|
|
551
566
|
text += data.text ?? "";
|
|
567
|
+
attachment ??= data.attachment;
|
|
552
568
|
offset = data.next_offset ?? null;
|
|
553
569
|
}
|
|
554
570
|
} catch {
|
|
555
571
|
/* no full text: the check is skipped */
|
|
556
572
|
}
|
|
557
|
-
|
|
558
|
-
|
|
573
|
+
const full = text ? { text, attachment } : null;
|
|
574
|
+
fullTexts.set(key, full);
|
|
575
|
+
return full ?? undefined;
|
|
559
576
|
}
|
|
560
577
|
|
|
561
578
|
/**
|
|
@@ -633,34 +650,10 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
633
650
|
},
|
|
634
651
|
});
|
|
635
652
|
|
|
636
|
-
pi.registerCommand("profile", {
|
|
637
|
-
description: "Sub-Sub: show or change the profile (reader, scholar, author, editor)",
|
|
638
|
-
handler: async (args, ctx) => {
|
|
639
|
-
const want = args.trim().toLowerCase();
|
|
640
|
-
if (!want) {
|
|
641
|
-
ctx.ui.notify(`Profile: ${profileSpec(profile).label}. To change it, type /profile <name>.\n${profileList(profile)}`, "info");
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
if (!isProfile(want)) {
|
|
645
|
-
ctx.ui.notify(`Unknown profile "${want}". Choose one of:\n${profileList(profile)}`, "warning");
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
profile = want;
|
|
649
|
-
let saved = "";
|
|
650
|
-
try {
|
|
651
|
-
saved = ` Saved in ${saveConfig({ profile })}.`;
|
|
652
|
-
} catch (err) {
|
|
653
|
-
saved = ` Not saved: ${(err as Error).message}`;
|
|
654
|
-
}
|
|
655
|
-
await applyMode(ctx, mode, false, false);
|
|
656
|
-
ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
|
|
657
|
-
},
|
|
658
|
-
});
|
|
659
|
-
|
|
660
653
|
pi.registerCommand("subsub", {
|
|
661
654
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
662
655
|
handler: async (_args, ctx) => {
|
|
663
|
-
const lines = [`Mode: ${mode}.
|
|
656
|
+
const lines = [`Mode: ${mode}. Model: ${ctx.model?.id ?? "none"}.`];
|
|
664
657
|
if (!bridge) {
|
|
665
658
|
ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
|
|
666
659
|
return;
|
|
@@ -696,6 +689,23 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
696
689
|
},
|
|
697
690
|
});
|
|
698
691
|
|
|
692
|
+
// The web view's library search: the result goes to the page through a status, like the library counts.
|
|
693
|
+
pi.registerCommand("subsub-find", {
|
|
694
|
+
description: "Sub-Sub: search the library for the web view",
|
|
695
|
+
handler: async (args, ctx) => {
|
|
696
|
+
if (ctx.mode !== "rpc") return;
|
|
697
|
+
const q = String(args ?? "").trim();
|
|
698
|
+
let out: Record<string, unknown>;
|
|
699
|
+
try {
|
|
700
|
+
const r = await current().call("zotero_find_items", { query: q, limit: 25 });
|
|
701
|
+
out = r.isError ? { error: r.text } : { items: (r.data as { items?: unknown[] })?.items ?? [] };
|
|
702
|
+
} catch (err) {
|
|
703
|
+
out = { error: (err as Error).message };
|
|
704
|
+
}
|
|
705
|
+
ctx.ui.setStatus("subsub.find", JSON.stringify({ q, ...out }));
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
699
709
|
pi.registerCommand("ai-statement", {
|
|
700
710
|
description: "Sub-Sub: a draft statement on the use of AI for a manuscript (what journals ask for)",
|
|
701
711
|
handler: async (args, ctx) => {
|
|
@@ -721,6 +731,93 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
721
731
|
},
|
|
722
732
|
});
|
|
723
733
|
|
|
734
|
+
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
735
|
+
pi.registerCommand("read", {
|
|
736
|
+
description: "Sub-Sub: read a paper with guidance; Sub-Sub finds the passages and asks, you write the answers. /read [background|fact|extract|critique] <citekey, DOI, PMID or title words>",
|
|
737
|
+
handler: async (args, ctx) => {
|
|
738
|
+
if (mode === "librarian") return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
739
|
+
let { purpose, ref } = readArgs(String(args ?? ""));
|
|
740
|
+
ref ||= (await ctx.ui.input(L("Which paper?", "Que artigo?"), L("citekey, DOI, PMID or title words", "citekey, DOI, PMID ou palavras do título")))?.trim() ?? "";
|
|
741
|
+
if (!ref) return;
|
|
742
|
+
if (!purpose) {
|
|
743
|
+
const labels = Object.values(PURPOSES).map(([en, pt]) => L(en, pt));
|
|
744
|
+
purpose = (Object.keys(PURPOSES) as Purpose[])[labels.indexOf((await ctx.ui.select(L("Why are you reading it?", "Porque vai ler este artigo?"), labels)) ?? "")];
|
|
745
|
+
if (!purpose) return;
|
|
746
|
+
}
|
|
747
|
+
const req: ReadRequest = { ref, purpose, date: new Date().toISOString().slice(0, 10) };
|
|
748
|
+
if (purpose === "fact") {
|
|
749
|
+
req.fact = (await ctx.ui.input(L("Which fact are you looking for?", "Que dado procura?")))?.trim();
|
|
750
|
+
if (!req.fact) return;
|
|
751
|
+
}
|
|
752
|
+
if (purpose === "extract") {
|
|
753
|
+
let sheets: string[] = [];
|
|
754
|
+
try {
|
|
755
|
+
sheets = readdirSync(join(cfg.vault ?? ctx.cwd, "Research")).filter((f) => f.endsWith(" extraction.md")).sort();
|
|
756
|
+
} catch {
|
|
757
|
+
// No Research folder yet: the first sheet is a new one.
|
|
758
|
+
}
|
|
759
|
+
const fresh = L("New sheet", "Nova folha");
|
|
760
|
+
const pick = sheets.length ? await ctx.ui.select(L("Which extraction sheet?", "Que folha de extração?"), [...sheets, fresh]) : fresh;
|
|
761
|
+
if (!pick) return;
|
|
762
|
+
if (pick === fresh) {
|
|
763
|
+
req.question = (await ctx.ui.input(L("The question of your review", "A pergunta da sua revisão")))?.trim();
|
|
764
|
+
if (!req.question) return;
|
|
765
|
+
} else req.sheet = `Research/${pick}`;
|
|
766
|
+
}
|
|
767
|
+
pi.sendUserMessage(readInstruction(req), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
768
|
+
},
|
|
769
|
+
});
|
|
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
|
+
|
|
724
821
|
pi.registerCommand("history", {
|
|
725
822
|
description: "Sub-Sub: recent library changes",
|
|
726
823
|
handler: async (_args, ctx) => {
|
package/src/usage.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
export type UsageEvent =
|
|
13
|
-
| { ev: "session"; mode: string;
|
|
13
|
+
| { ev: "session"; mode: string; model?: string }
|
|
14
14
|
| { ev: "ask"; kind: string; words: number }
|
|
15
15
|
| { ev: "turn"; seconds: number; tools: number; error?: string }
|
|
16
16
|
| { ev: "change"; tool: string; approved: boolean };
|