@falkenslab/agent-kit 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/README.md +110 -110
- package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
- package/assets/knowledge-plugin/commands/ingest.md +1 -1
- package/assets/knowledge-plugin/commands/lint.md +1 -1
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
- package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
- package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
- package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
- package/assets/knowledge-plugin-files/commands/lint.md +5 -0
- package/assets/knowledge-plugin-files/commands/query.md +5 -0
- package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
- package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
- package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
- package/dist/core/agentSpec.d.ts +27 -2
- package/dist/core/extractText.d.ts +13 -0
- package/dist/core/extractText.js +147 -0
- package/dist/core/fileKnowledgeStore.d.ts +10 -0
- package/dist/core/fileKnowledgeStore.js +378 -0
- package/dist/core/hooks/fileScopeGate.d.ts +8 -0
- package/dist/core/hooks/fileScopeGate.js +20 -0
- package/dist/core/hooks/humanInput.d.ts +18 -0
- package/dist/core/hooks/humanInput.js +30 -2
- package/dist/core/hooks/planGate.js +14 -5
- package/dist/core/hooks/stepGate.js +6 -0
- package/dist/core/interaction.d.ts +20 -0
- package/dist/core/knowledge.d.ts +14 -4
- package/dist/core/knowledge.js +36 -6
- package/dist/core/knowledgeStore.d.ts +134 -0
- package/dist/core/knowledgeStore.js +84 -0
- package/dist/core/messages/de.js +65 -0
- package/dist/core/messages/en.d.ts +41 -0
- package/dist/core/messages/en.js +65 -0
- package/dist/core/messages/es.js +65 -0
- package/dist/core/messages/fr.js +65 -0
- package/dist/core/modeControl.d.ts +36 -0
- package/dist/core/modeControl.js +61 -0
- package/dist/core/session.d.ts +6 -31
- package/dist/core/session.js +76 -71
- package/dist/core/sources.d.ts +122 -0
- package/dist/core/sources.js +312 -0
- package/dist/core/todos.d.ts +23 -0
- package/dist/core/todos.js +33 -0
- package/dist/core/toolLabels.d.ts +1 -1
- package/dist/core/toolLabels.js +42 -2
- package/dist/core/tools/humanApproval.d.ts +9 -5
- package/dist/core/tools/humanApproval.js +39 -7
- package/dist/core/tools/knowledgeTools.d.ts +16 -0
- package/dist/core/tools/knowledgeTools.js +161 -0
- package/dist/core/tools/saveToSources.d.ts +27 -8
- package/dist/core/tools/saveToSources.js +203 -55
- package/dist/core/tools/time.d.ts +29 -0
- package/dist/core/tools/time.js +129 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +4 -1
- package/dist/tui/chatTui.js +14 -1
- package/dist/tui/consoleRenderer.js +16 -2
- package/dist/tui/ink/SessionView.js +72 -17
- package/dist/tui/ink/inkInteraction.d.ts +4 -2
- package/dist/tui/ink/inkInteraction.js +20 -2
- package/dist/tui/ink/progressView.d.ts +5 -1
- package/dist/tui/ink/runChatInk.d.ts +16 -1
- package/dist/tui/ink/runChatInk.js +7 -0
- package/dist/tui/ink/sessionModel.d.ts +10 -3
- package/dist/tui/ink/sessionModel.js +26 -4
- package/dist/tui/ink/toolGroup.d.ts +23 -2
- package/dist/tui/ink/toolGroup.js +17 -4
- package/dist/tui/terminalInteraction.js +2 -0
- package/package.json +31 -1
- /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : undefined;
|
|
10
|
-
}
|
|
5
|
+
import { addSource, listSources, resolveWithin, retireSource } from "../sources.js";
|
|
6
|
+
import { askForDecision, askForText } from "../hooks/humanInput.js";
|
|
7
|
+
import { t } from "../messages/index.js";
|
|
8
|
+
import { EXTRACTABLE, extractText } from "../extractText.js";
|
|
11
9
|
/**
|
|
12
10
|
* `resolvedPath` as given if it exists; otherwise, a same-directory sibling whose
|
|
13
11
|
* filename is Unicode-equal under NFC normalization. Needed because a file downloaded via
|
|
@@ -37,69 +35,219 @@ async function resolveExistingFileToleratingNormalization(resolvedPath) {
|
|
|
37
35
|
const match = entries.find((name) => name.normalize("NFC") === wantName);
|
|
38
36
|
return match ? path.join(dir, match) : undefined;
|
|
39
37
|
}
|
|
38
|
+
// Confirmed empirically: the SDK's Read tool reads PDFs and images, but refuses DOCX and
|
|
39
|
+
// PPTX ("This tool cannot read binary files").
|
|
40
40
|
const DEFAULT_DESCRIPTION = "Copy a file from this run's own folder (e.g. something just downloaded) into a folder " +
|
|
41
41
|
"under sources/, where original files are kept as they were obtained, apart from your " +
|
|
42
|
-
"own notes.
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"study in depth, so
|
|
47
|
-
|
|
42
|
+
"own notes. Read reads PDFs and images there; DOCX, PPTX and XLSX are read with extract_text. This tool does " +
|
|
43
|
+
"no conversion, never overwrites a file already in sources/, and doesn't copy a file " +
|
|
44
|
+
"identical to one already there (it returns that one's path). For a new version of an " +
|
|
45
|
+
"original, save it under a new name with `replaces` set to the old one. Use it right after " +
|
|
46
|
+
"downloading a document you're about to study in depth, so later sessions have it too.";
|
|
47
|
+
/** The most `download_to_sources` and `request_file` copy in. */
|
|
48
|
+
export const MAX_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
49
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
50
|
+
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
51
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
52
|
+
function added(result, extra = "") {
|
|
53
|
+
return result.duplicateOf
|
|
54
|
+
? `An identical file is already in sources/${result.duplicateOf}: nothing was copied. Use that one.`
|
|
55
|
+
: `Saved to sources/${result.path}.${extra}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The main content of a web page as markdown, with the optional libraries
|
|
59
|
+
* (`@mozilla/readability`, `linkedom`, `turndown`); null when they aren't installed or the
|
|
60
|
+
* page has no readable content.
|
|
61
|
+
*/
|
|
62
|
+
export async function htmlToMarkdown(html, url) {
|
|
63
|
+
try {
|
|
64
|
+
const [{ Readability }, { parseHTML }, { default: TurndownService }] = await Promise.all([
|
|
65
|
+
import("@mozilla/readability"),
|
|
66
|
+
import("linkedom"),
|
|
67
|
+
import("turndown"),
|
|
68
|
+
]);
|
|
69
|
+
const { document } = parseHTML(html);
|
|
70
|
+
const article = new Readability(document).parse();
|
|
71
|
+
if (!article?.content)
|
|
72
|
+
return null;
|
|
73
|
+
const markdown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" }).turndown(article.content);
|
|
74
|
+
return `# ${article.title ?? url}\n\nSource: ${url}\n\n${markdown}\n`;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Downloads `url` into `file`, refusing other schemes and anything over `maxBytes`; returns its content type. */
|
|
81
|
+
export async function downloadToFile(url, file, maxBytes) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(url);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error(`"${url}" isn't a URL.`);
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
90
|
+
throw new Error(`Only http and https URLs can be downloaded, not "${parsed.protocol}".`);
|
|
91
|
+
const response = await fetch(parsed, { redirect: "follow" });
|
|
92
|
+
if (!response.ok || !response.body)
|
|
93
|
+
throw new Error(`The download failed: HTTP ${response.status}.`);
|
|
94
|
+
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
95
|
+
if (declared > maxBytes)
|
|
96
|
+
throw new Error(`The file is ${declared} bytes, over the ${maxBytes}-byte limit.`);
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
for await (const chunk of response.body) {
|
|
100
|
+
size += chunk.byteLength;
|
|
101
|
+
if (size > maxBytes)
|
|
102
|
+
throw new Error(`The file is over the ${maxBytes}-byte limit.`);
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
}
|
|
105
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
106
|
+
await writeFile(file, Buffer.concat(chunks));
|
|
107
|
+
return response.headers.get("content-type") ?? "";
|
|
108
|
+
}
|
|
48
109
|
/**
|
|
49
|
-
* `
|
|
50
|
-
* to the notes folder, see hooks/fileScopeGate.ts)
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* it into `sourcesDir`, where the SDK's own Read tool already knows how to interpret it
|
|
55
|
-
* (PDF/DOCX text extraction, multimodal images).
|
|
110
|
+
* The tools that keep `sourcesDir`, the originals the agent reads but never edits (Write/Edit
|
|
111
|
+
* are scoped to the notes folder, see hooks/fileScopeGate.ts): `save_to_sources`,
|
|
112
|
+
* `list_sources` and `download_to_sources`, plus `request_file` and `retire_source` when a
|
|
113
|
+
* person can be asked. Nothing is ever overwritten or deleted; the bookkeeping is in
|
|
114
|
+
* sources.ts.
|
|
56
115
|
*/
|
|
57
|
-
export function createSaveToSourcesServer(runDir, sourcesDir, description = DEFAULT_DESCRIPTION) {
|
|
116
|
+
export function createSaveToSourcesServer(runDir, sourcesDir, description = DEFAULT_DESCRIPTION, options = {}) {
|
|
117
|
+
const projectDir = options.projectDir ?? path.dirname(sourcesDir);
|
|
118
|
+
const maxBytes = options.maxBytes ?? MAX_SOURCE_BYTES;
|
|
119
|
+
const replaces = z.string().optional().describe("For a new version: the original it replaces, relative to sources/ (kept, never overwritten)");
|
|
58
120
|
const saveToSources = tool("save_to_sources", description, {
|
|
59
121
|
source: z.string().describe("Path to the file, relative to this run's own folder - not a URL"),
|
|
60
|
-
destination: z.string().describe('Path to save it under, relative to sources/, e.g. "topic-3/slides.pdf"'),
|
|
122
|
+
destination: z.string().describe('Path to save it under, relative to sources/, e.g. "topic-3/slides.pdf" (a folder, "topic-3/", keeps its own name)'),
|
|
123
|
+
replaces,
|
|
61
124
|
}, async (args) => {
|
|
62
125
|
const candidate = resolveWithin(runDir, args.source);
|
|
63
|
-
if (!candidate)
|
|
64
|
-
return {
|
|
65
|
-
content: [{ type: "text", text: `"${args.source}" isn't inside this run's folder - refusing to read it.` }],
|
|
66
|
-
isError: true,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
126
|
+
if (!candidate)
|
|
127
|
+
return fail(`"${args.source}" isn't inside this run's folder - refusing to read it.`);
|
|
69
128
|
const resolvedSource = await resolveExistingFileToleratingNormalization(candidate);
|
|
70
129
|
if (!resolvedSource) {
|
|
71
|
-
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
return fail(`Couldn't find "${args.source}" in this run's folder (checked byte-for-byte and Unicode-normalized names) - double check the exact path, e.g. with Glob.`);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const relative = path.relative(runDir, resolvedSource).split(path.sep).join("/");
|
|
134
|
+
return ok(added(await addSource(sourcesDir, resolvedSource, args.destination, { kind: "run", from: relative }, { replaces: args.replaces })));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return fail(message(error));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const listSourcesTool = tool("list_sources", "List the originals in sources/ with their status: new (no summary page points to it: ingest it), ingested, changed (it changed after its ingest: update its summary), missing (deleted by hand) or present (no knowledge base to compare with); with type, size, pages of a PDF or slides of a PPTX (read long ones in parts), provenance, the version it replaces and its summary pages. Use it instead of listing the folder.", {}, async () => {
|
|
141
|
+
try {
|
|
142
|
+
return ok(JSON.stringify(await listSources(sourcesDir, options.knowledgeDir, projectDir), null, 2));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return fail(message(error));
|
|
146
|
+
}
|
|
147
|
+
}, { annotations: { readOnlyHint: true } });
|
|
148
|
+
const extractTextTool = tool("extract_text", "Read a Word (DOCX), PowerPoint (PPTX) or Excel (XLSX) original in sources/ as markdown, which Read can't: a document's headings, paragraphs, lists and tables; a deck's slides with their speaker notes (`from`/`to` pick slides of a long one; list_sources gives the count); a workbook's sheets as tables. Images are left out.", {
|
|
149
|
+
source: z.string().describe("The original, relative to sources/"),
|
|
150
|
+
from: z.number().int().positive().optional().describe("First slide (PPTX)"),
|
|
151
|
+
to: z.number().int().positive().optional().describe("Last slide (PPTX)"),
|
|
152
|
+
}, async (args) => {
|
|
153
|
+
const file = resolveWithin(sourcesDir, args.source);
|
|
154
|
+
if (!file || !(await stat(file).catch(() => null))?.isFile())
|
|
155
|
+
return fail(`"${args.source}" isn't an original in sources/ (list_sources lists them).`);
|
|
156
|
+
if (!EXTRACTABLE.has(path.extname(file).slice(1).toLowerCase()))
|
|
157
|
+
return fail(`extract_text reads DOCX, PPTX and XLSX; read "${args.source}" with Read.`);
|
|
158
|
+
try {
|
|
159
|
+
return ok(await extractText(file, args.from, args.to));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return fail(message(error));
|
|
163
|
+
}
|
|
164
|
+
}, { annotations: { readOnlyHint: true } });
|
|
165
|
+
const downloadToSources = tool("download_to_sources", `Download an original (a PDF, an image, a web page...) from an http(s) URL straight into sources/, without its content going through your context; up to ${Math.round(maxBytes / 1024 / 1024)} MB, never overwriting, and not at all if an identical file is already there. A web page is also kept as its main content in markdown next to it (when the kit's optional libraries are installed), to quote literally and read again: WebFetch only gives a summary.`, {
|
|
166
|
+
url: z.string().describe("The http or https URL"),
|
|
167
|
+
destination: z.string().describe('Path to save it under, relative to sources/, e.g. "topic-3/syllabus.pdf" or "web/rules.html"'),
|
|
168
|
+
replaces,
|
|
169
|
+
}, async (args) => {
|
|
170
|
+
const temp = path.join(runDir, "downloads", `${Date.now()}-${path.basename(args.destination)}`);
|
|
171
|
+
try {
|
|
172
|
+
const type = await downloadToFile(args.url, temp, maxBytes);
|
|
173
|
+
const name = decodeURIComponent(new URL(args.url).pathname.split("/").filter(Boolean).pop() ?? "download");
|
|
174
|
+
const result = await addSource(sourcesDir, temp, args.destination, { kind: "url", from: args.url }, { replaces: args.replaces, name });
|
|
175
|
+
let extra = "";
|
|
176
|
+
if (!result.duplicateOf && /html/i.test(type)) {
|
|
177
|
+
const html = await readFile(temp, "utf8");
|
|
178
|
+
const markdown = await htmlToMarkdown(html, args.url);
|
|
179
|
+
if (markdown) {
|
|
180
|
+
const mdTemp = `${temp}.md`;
|
|
181
|
+
await writeFile(mdTemp, markdown);
|
|
182
|
+
const mdDestination = result.path.replace(/\.[^./]+$/, "") + ".md";
|
|
183
|
+
try {
|
|
184
|
+
const md = await addSource(sourcesDir, mdTemp, mdDestination, { kind: "url", from: args.url }, { derivedFrom: result.path });
|
|
185
|
+
extra = md.duplicateOf ? "" : ` Its main content is in sources/${md.path}: read that one.`;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
extra = ` (Its markdown wasn't kept: ${message(error)})`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
extra = " (No markdown copy: the page had no readable content, or the kit's optional libraries for it aren't installed.)";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return ok(added(result, extra));
|
|
75
196
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
};
|
|
197
|
+
catch (error) {
|
|
198
|
+
return fail(message(error));
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await rm(temp, { force: true });
|
|
202
|
+
await rm(`${temp}.md`, { force: true });
|
|
82
203
|
}
|
|
204
|
+
});
|
|
205
|
+
const requestFile = tool("request_file", "Ask the person for a file you need (a syllabus, a document they have) and copy it into sources/ with its provenance. They give a path or say they don't have it; never overwrites, and doesn't copy a file identical to one already there.", {
|
|
206
|
+
description: z.string().describe("What file you need and why, in one or two sentences, in the person's language"),
|
|
207
|
+
destination: z.string().optional().describe("Path to save it under, relative to sources/; the file's own name if omitted"),
|
|
208
|
+
replaces,
|
|
209
|
+
}, async (args) => {
|
|
210
|
+
const answer = (await askForText(runDir, { title: t().fileRequestTitle, lines: [args.description], question: t().fileRequestQuestion })).replace(/^["']|["']$/g, "").trim();
|
|
211
|
+
if (!answer)
|
|
212
|
+
return ok("The person doesn't have that file (or didn't give one). Go on without it, or ask for something else.");
|
|
213
|
+
const file = path.resolve(answer);
|
|
214
|
+
const info = await stat(file).catch(() => null);
|
|
215
|
+
if (!info?.isFile())
|
|
216
|
+
return fail(`"${answer}" isn't a file the person has here. Ask again if needed.`);
|
|
217
|
+
if (info.size > maxBytes)
|
|
218
|
+
return fail(`"${answer}" is ${info.size} bytes, over the ${maxBytes}-byte limit.`);
|
|
83
219
|
try {
|
|
84
|
-
await
|
|
85
|
-
await copyFile(resolvedSource, resolvedDestination, constants.COPYFILE_EXCL);
|
|
220
|
+
return ok(added(await addSource(sourcesDir, file, args.destination ?? path.basename(file), { kind: "person", from: path.basename(file) }, { replaces: args.replaces })));
|
|
86
221
|
}
|
|
87
222
|
catch (error) {
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
content: [{
|
|
91
|
-
type: "text",
|
|
92
|
-
text: exists
|
|
93
|
-
? `"${args.destination}" already exists in sources/ - originals are never overwritten; pick another destination name.`
|
|
94
|
-
: `Couldn't copy "${args.source}" to sources/: ${error instanceof Error ? error.message : String(error)}`,
|
|
95
|
-
}],
|
|
96
|
-
isError: true,
|
|
97
|
-
};
|
|
223
|
+
return fail(message(error));
|
|
98
224
|
}
|
|
99
|
-
const savedAs = path.relative(sourcesDir, resolvedDestination).split(path.sep).join("/");
|
|
100
|
-
return {
|
|
101
|
-
content: [{ type: "text", text: `Saved to sources/${savedAs} - read it from there with Read.` }],
|
|
102
|
-
};
|
|
103
225
|
});
|
|
104
|
-
|
|
226
|
+
const retire = tool("retire_source", 'Retire an original that was wrong ("wrong") or replaced by a better one ("replaced"), after the person approves it: it\'s moved to sources/.agent-kit/retired/ (never deleted), and its summary page is marked retired (wrong) or superseded, pointing to the replacement\'s summary (replaced). Afterwards, update the index and correct or re-ground the pages that cited it.', {
|
|
227
|
+
source: z.string().describe("The original, relative to sources/"),
|
|
228
|
+
why: z.enum(["wrong", "replaced"]),
|
|
229
|
+
reason: z.string().describe("Why, in one sentence, in the person's language"),
|
|
230
|
+
replacedBy: z.string().optional().describe('For "replaced": the original that replaces it, relative to sources/'),
|
|
231
|
+
}, async (args) => {
|
|
232
|
+
const answer = await askForDecision(runDir, { title: t().retireTitle, lines: t().retireLines(args.source, args.why, args.reason, args.replacedBy) });
|
|
233
|
+
if (answer !== "" && answer !== "y" && answer !== "yes")
|
|
234
|
+
return ok(answer === "q" ? "The person stopped: don't retire it, and stop what you were doing." : "The person said no: the original stays.");
|
|
235
|
+
try {
|
|
236
|
+
const result = await retireSource(sourcesDir, options.knowledgeDir, projectDir, args.source, args.why, args.reason, args.replacedBy);
|
|
237
|
+
const pages = result.summaries.map((s) => `${s.page} marked ${s.marked}${s.supersededBy ? ` (see ${s.supersededBy})` : ""}`);
|
|
238
|
+
return ok([
|
|
239
|
+
`Retired: moved to sources/${result.movedTo}.`,
|
|
240
|
+
...(pages.length ? pages : ["It had no summary page."]),
|
|
241
|
+
"Now update index.md, and correct or re-ground the pages that cite it (search the knowledge base for its file name).",
|
|
242
|
+
].join("\n"));
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return fail(message(error));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return createSdkMcpServer({
|
|
249
|
+
name: "sourceFiles",
|
|
250
|
+
version: "1.0.0",
|
|
251
|
+
tools: [saveToSources, listSourcesTool, extractTextTool, downloadToSources, ...(options.interactive ? [requestFile, retire] : [])],
|
|
252
|
+
});
|
|
105
253
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** "now" in `timeZone`, as calendar parts. */
|
|
2
|
+
export declare function nowIn(timeZone: string, now?: Date): {
|
|
3
|
+
date: string;
|
|
4
|
+
time: string;
|
|
5
|
+
weekday: string;
|
|
6
|
+
timeZone: string;
|
|
7
|
+
utcOffset: string;
|
|
8
|
+
utc: string;
|
|
9
|
+
};
|
|
10
|
+
/** Days, weeks and working days (Monday to Friday) from `from` to `to`; negative when `to` is earlier. */
|
|
11
|
+
export declare function dateDifference(from: string, to: string): {
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
days: number;
|
|
15
|
+
weeks: number;
|
|
16
|
+
remainderDays: number;
|
|
17
|
+
workingDays: number;
|
|
18
|
+
toWeekday: string;
|
|
19
|
+
};
|
|
20
|
+
export type DateUnit = "days" | "weeks" | "months" | "working_days";
|
|
21
|
+
/** `date` plus `amount` (negative to go back) `unit`s; months keep the day, or the month's last one. */
|
|
22
|
+
export declare function dateAdd(date: string, amount: number, unit: DateUnit): {
|
|
23
|
+
date: string;
|
|
24
|
+
weekday: string;
|
|
25
|
+
};
|
|
26
|
+
/** The time zone the tools answer in: `timeZone` if valid, otherwise the system's. */
|
|
27
|
+
export declare function resolveTimeZone(timeZone?: string): string;
|
|
28
|
+
/** `current_time` and `date_math`, in `timeZone` (an IANA name; the system's if not given). */
|
|
29
|
+
export declare function createTimeServer(timeZone?: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* The date and time, and date arithmetic, for every agent: the kit puts no date in the
|
|
5
|
+
* prompt and only subagents have Bash, so without these an agent can't know what day it
|
|
6
|
+
* is, and models get "days until" and "three weeks from" wrong often enough to matter
|
|
7
|
+
* (deadlines, a course's calendar). Only reads: the plan gate lets the server through.
|
|
8
|
+
*/
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
11
|
+
/** "now" in `timeZone`, as calendar parts. */
|
|
12
|
+
export function nowIn(timeZone, now = new Date()) {
|
|
13
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
|
|
14
|
+
timeZone,
|
|
15
|
+
year: "numeric",
|
|
16
|
+
month: "2-digit",
|
|
17
|
+
day: "2-digit",
|
|
18
|
+
hour: "2-digit",
|
|
19
|
+
minute: "2-digit",
|
|
20
|
+
second: "2-digit",
|
|
21
|
+
hourCycle: "h23",
|
|
22
|
+
timeZoneName: "longOffset",
|
|
23
|
+
})
|
|
24
|
+
.formatToParts(now)
|
|
25
|
+
.map((part) => [part.type, part.value]));
|
|
26
|
+
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
27
|
+
return {
|
|
28
|
+
date,
|
|
29
|
+
time: `${parts.hour}:${parts.minute}:${parts.second}`,
|
|
30
|
+
weekday: WEEKDAYS[toDay(date).getUTCDay()],
|
|
31
|
+
timeZone,
|
|
32
|
+
utcOffset: parts.timeZoneName.replace("GMT", "") || "+00:00",
|
|
33
|
+
utc: now.toISOString(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** A YYYY-MM-DD calendar date as midnight UTC, so arithmetic never meets daylight saving. */
|
|
37
|
+
function toDay(date) {
|
|
38
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
39
|
+
if (!match)
|
|
40
|
+
throw new Error(`"${date}" isn't a date in YYYY-MM-DD form.`);
|
|
41
|
+
const day = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
|
42
|
+
if (day.toISOString().slice(0, 10) !== date)
|
|
43
|
+
throw new Error(`"${date}" isn't a real date.`);
|
|
44
|
+
return day;
|
|
45
|
+
}
|
|
46
|
+
const fromDay = (day) => day.toISOString().slice(0, 10);
|
|
47
|
+
const isWorkingDay = (day) => day.getUTCDay() !== 0 && day.getUTCDay() !== 6;
|
|
48
|
+
/** Days, weeks and working days (Monday to Friday) from `from` to `to`; negative when `to` is earlier. */
|
|
49
|
+
export function dateDifference(from, to) {
|
|
50
|
+
const start = toDay(from);
|
|
51
|
+
const end = toDay(to);
|
|
52
|
+
const days = Math.round((end.getTime() - start.getTime()) / DAY_MS);
|
|
53
|
+
// Working days counted from the day after the earlier date up to the later one included, as
|
|
54
|
+
// "in N working days"; the same count, negative, when `to` is earlier.
|
|
55
|
+
const [first, last] = days < 0 ? [end, start] : [start, end];
|
|
56
|
+
let workingDays = 0;
|
|
57
|
+
for (let day = first.getTime() + DAY_MS; day <= last.getTime(); day += DAY_MS)
|
|
58
|
+
if (isWorkingDay(new Date(day)))
|
|
59
|
+
workingDays++;
|
|
60
|
+
return { from, to, days, weeks: Math.trunc(days / 7), remainderDays: days % 7, workingDays: days < 0 ? -workingDays : workingDays, toWeekday: WEEKDAYS[end.getUTCDay()] };
|
|
61
|
+
}
|
|
62
|
+
/** `date` plus `amount` (negative to go back) `unit`s; months keep the day, or the month's last one. */
|
|
63
|
+
export function dateAdd(date, amount, unit) {
|
|
64
|
+
if (!Number.isInteger(amount))
|
|
65
|
+
throw new Error("The amount must be a whole number.");
|
|
66
|
+
const start = toDay(date);
|
|
67
|
+
let result;
|
|
68
|
+
if (unit === "days" || unit === "weeks") {
|
|
69
|
+
result = new Date(start.getTime() + amount * (unit === "weeks" ? 7 : 1) * DAY_MS);
|
|
70
|
+
}
|
|
71
|
+
else if (unit === "months") {
|
|
72
|
+
const target = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + amount, 1));
|
|
73
|
+
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
74
|
+
result = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth(), Math.min(start.getUTCDate(), lastDay)));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
result = start;
|
|
78
|
+
const step = Math.sign(amount);
|
|
79
|
+
for (let left = Math.abs(amount); left > 0;) {
|
|
80
|
+
result = new Date(result.getTime() + step * DAY_MS);
|
|
81
|
+
if (isWorkingDay(result))
|
|
82
|
+
left--;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { date: fromDay(result), weekday: WEEKDAYS[result.getUTCDay()] };
|
|
86
|
+
}
|
|
87
|
+
const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
|
|
88
|
+
const failure = (error) => ({ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true });
|
|
89
|
+
/** The time zone the tools answer in: `timeZone` if valid, otherwise the system's. */
|
|
90
|
+
export function resolveTimeZone(timeZone) {
|
|
91
|
+
const system = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
92
|
+
if (!timeZone)
|
|
93
|
+
return system;
|
|
94
|
+
try {
|
|
95
|
+
new Intl.DateTimeFormat("en", { timeZone });
|
|
96
|
+
return timeZone;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error(`Unknown time zone "${timeZone}" (an IANA name, e.g. "Europe/Madrid").`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** `current_time` and `date_math`, in `timeZone` (an IANA name; the system's if not given). */
|
|
103
|
+
export function createTimeServer(timeZone) {
|
|
104
|
+
const zone = resolveTimeZone(timeZone);
|
|
105
|
+
const currentTime = tool("current_time", "The current date and time: the local date (YYYY-MM-DD), time, weekday, time zone and UTC offset, and the instant in UTC. Use it whenever you need today's date or the time; don't guess them.", {}, async () => text(nowIn(zone)), { annotations: { readOnlyHint: true } });
|
|
106
|
+
const dateMath = tool("date_math", 'Exact date arithmetic on calendar dates (YYYY-MM-DD, local). "difference": days, weeks and working days (Monday to Friday) from `from` to `to`. "add": `from` plus `amount` (negative to go back) days, weeks, months or working days. `from` defaults to today. Use it instead of counting dates yourself.', {
|
|
107
|
+
operation: z.enum(["difference", "add"]),
|
|
108
|
+
from: z.string().optional().describe("Start date, YYYY-MM-DD; today if omitted"),
|
|
109
|
+
to: z.string().optional().describe('End date, YYYY-MM-DD (for "difference")'),
|
|
110
|
+
amount: z.number().int().optional().describe('How many units to add, negative to subtract (for "add")'),
|
|
111
|
+
unit: z.enum(["days", "weeks", "months", "working_days"]).optional().describe('The unit of `amount` (for "add"); days if omitted'),
|
|
112
|
+
}, async (args) => {
|
|
113
|
+
try {
|
|
114
|
+
const from = args.from ?? nowIn(zone).date;
|
|
115
|
+
if (args.operation === "difference") {
|
|
116
|
+
if (!args.to)
|
|
117
|
+
throw new Error('"difference" needs `to`.');
|
|
118
|
+
return text(dateDifference(from, args.to));
|
|
119
|
+
}
|
|
120
|
+
if (args.amount === undefined)
|
|
121
|
+
throw new Error('"add" needs `amount`.');
|
|
122
|
+
return text({ from, ...dateAdd(from, args.amount, args.unit ?? "days") });
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return failure(error);
|
|
126
|
+
}
|
|
127
|
+
}, { annotations: { readOnlyHint: true } });
|
|
128
|
+
return createSdkMcpServer({ name: "time", version: "1.0.0", tools: [currentTime, dateMath] });
|
|
129
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export type { Mode, BaseSessionConfig, AgentSpec, PlanModeSpec } from "./core/ag
|
|
|
10
10
|
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode, type ModeControl } from "./core/session.js";
|
|
11
11
|
export { runQuery, type AgentEvent, type AgentRun, type SessionUsage, type ContextUsage } from "./core/runner.js";
|
|
12
12
|
export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
|
|
13
|
+
export { BUILT_IN_PAGE_TYPES, isSlug, parseId, linkedIds, type KnowledgeStore, type PageType, type KnowledgePage, type PageInfo, type SearchHit, type CheckReport, type FieldChanges, type NewPage, } from "./core/knowledgeStore.js";
|
|
14
|
+
export { createFileKnowledgeStore, type FileKnowledgeStoreOptions } from "./core/fileKnowledgeStore.js";
|
|
15
|
+
export { createKnowledgeServer, type KnowledgeToolsOptions } from "./core/tools/knowledgeTools.js";
|
|
13
16
|
export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, type Language, type LanguageSources, type ResolvedLanguage, } from "./core/language.js";
|
|
14
17
|
export { setLanguage, getLanguage, messagesFor, type Messages } from "./core/messages/index.js";
|
|
15
18
|
export { createRunStore, createRunFolder, listRuns, readConversation, type RunFolder, type RunSummary, type ConversationMessage, } from "./core/runs.js";
|
|
@@ -22,17 +25,17 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
|
22
25
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
23
26
|
export { createFileScopeGate, checkFileScope, type FileScope } from "./core/hooks/fileScopeGate.js";
|
|
24
27
|
export { createPlanGate, checkPlanScope, type PlanScope } from "./core/hooks/planGate.js";
|
|
25
|
-
export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
|
|
26
|
-
export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt } from "./core/interaction.js";
|
|
28
|
+
export { askForDecision, askForManualIntervention, askForChoice, askForText, type ChoiceAnswer } from "./core/hooks/humanInput.js";
|
|
29
|
+
export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt, type ChoiceSettings } from "./core/interaction.js";
|
|
27
30
|
export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
|
|
28
31
|
export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS, type HumanApprovalTexts } from "./core/tools/humanApproval.js";
|
|
29
32
|
export { createManualLoginServer, type ManualInterventionTexts } from "./core/tools/manualLogin.js";
|
|
30
|
-
export { createSaveToSourcesServer } from "./core/tools/saveToSources.js";
|
|
33
|
+
export { createSaveToSourcesServer, type SourceToolsOptions } from "./core/tools/saveToSources.js";
|
|
31
34
|
export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
|
|
32
35
|
export type { SessionOpener } from "./tui/runs.js";
|
|
33
36
|
export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
|
|
34
37
|
export { runChatInk, type InkChatOptions, type HeaderInfo } from "./tui/ink/runChatInk.js";
|
|
35
|
-
export type { ToolPhrase } from "./tui/ink/toolGroup.js";
|
|
38
|
+
export type { ToolPhrase, ToolDetail, ResultFormatter } from "./tui/ink/toolGroup.js";
|
|
36
39
|
export type { RenderApproval } from "./tui/ink/SessionView.js";
|
|
37
40
|
export { createProgressView, type ProgressView, type ProgressViewOptions } from "./tui/ink/progressView.js";
|
|
38
41
|
export { runWizard, type WizardStep, type SelectStep, type InputStep, type PasswordStep, type ConfirmStep, type WizardAnswers, type WizardOptions, } from "./tui/ink/wizard.js";
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,9 @@ export { createFriendlyToolLabel, truncate, truncatePath, } from "./core/toolLab
|
|
|
11
11
|
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode } from "./core/session.js";
|
|
12
12
|
export { runQuery } from "./core/runner.js";
|
|
13
13
|
export { knowledgePluginRoot, knowledgePromptSection } from "./core/knowledge.js";
|
|
14
|
+
export { BUILT_IN_PAGE_TYPES, isSlug, parseId, linkedIds, } from "./core/knowledgeStore.js";
|
|
15
|
+
export { createFileKnowledgeStore } from "./core/fileKnowledgeStore.js";
|
|
16
|
+
export { createKnowledgeServer } from "./core/tools/knowledgeTools.js";
|
|
14
17
|
export { resolveLanguage, detectLanguage, SUPPORTED_LANGUAGES, } from "./core/language.js";
|
|
15
18
|
export { setLanguage, getLanguage, messagesFor } from "./core/messages/index.js";
|
|
16
19
|
export { createRunStore, createRunFolder, listRuns, readConversation, } from "./core/runs.js";
|
|
@@ -24,7 +27,7 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
|
24
27
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
25
28
|
export { createFileScopeGate, checkFileScope } from "./core/hooks/fileScopeGate.js";
|
|
26
29
|
export { createPlanGate, checkPlanScope } from "./core/hooks/planGate.js";
|
|
27
|
-
export { askForDecision, askForManualIntervention } from "./core/hooks/humanInput.js";
|
|
30
|
+
export { askForDecision, askForManualIntervention, askForChoice, askForText } from "./core/hooks/humanInput.js";
|
|
28
31
|
export { setInteractionPort, getInteractionPort } from "./core/interaction.js";
|
|
29
32
|
export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
|
|
30
33
|
// The checkpoints answer on the terminal by default, as they always have; a non-terminal
|
package/dist/tui/chatTui.js
CHANGED
|
@@ -188,6 +188,14 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
188
188
|
},
|
|
189
189
|
});
|
|
190
190
|
const { writeLine } = renderer;
|
|
191
|
+
// The mode can change from inside the session (the agent leaving plan mode with
|
|
192
|
+
// present_plan): say so, as /plan does.
|
|
193
|
+
let unsubscribeMode;
|
|
194
|
+
const watchMode = () => {
|
|
195
|
+
unsubscribeMode?.();
|
|
196
|
+
unsubscribeMode = modeControl()?.subscribe?.((mode) => writeLine(ui.dim(`⏵⏵ ${t().mode(mode)}`)));
|
|
197
|
+
};
|
|
198
|
+
watchMode();
|
|
191
199
|
// Cached lazily (not fetched until the first "/..." line, and only once — the SDK docs
|
|
192
200
|
// don't promise this list changes mid-session, and re-fetching per line would add a
|
|
193
201
|
// round-trip to the CLI subprocess for every turn): every registered command name/alias,
|
|
@@ -261,6 +269,7 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
261
269
|
queue = createInputQueue({ modeControl: modeControl() });
|
|
262
270
|
run = runQuery(queue.iterable, current.options);
|
|
263
271
|
events = run.events[Symbol.asyncIterator]();
|
|
272
|
+
watchMode();
|
|
264
273
|
knownCommandTokens = null;
|
|
265
274
|
await showConversation(current.run);
|
|
266
275
|
}
|
|
@@ -304,7 +313,11 @@ export async function runChatTui(options, tuiOptions = {}) {
|
|
|
304
313
|
if (line.toLowerCase() === "/plan") {
|
|
305
314
|
const control = modeControl();
|
|
306
315
|
const next = control ? togglePlanMode(control) : null;
|
|
307
|
-
|
|
316
|
+
// The new mode's line comes from watchMode(); a control without subscribe() says it here.
|
|
317
|
+
if (!next)
|
|
318
|
+
writeLine(ui.dim(t().modeLocked(control?.mode)));
|
|
319
|
+
else if (!control?.subscribe)
|
|
320
|
+
writeLine(ui.dim(`⏵⏵ ${t().mode(next)}`));
|
|
308
321
|
continue;
|
|
309
322
|
}
|
|
310
323
|
// A line that *looks* like a slash command but matches nothing registered (a typo,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stdout } from "node:process";
|
|
2
2
|
import { createFriendlyToolLabel } from "../core/toolLabels.js";
|
|
3
|
+
import { parseTodos, todoChanges, TODO_TOOL } from "../core/todos.js";
|
|
3
4
|
import * as ui from "./ui.js";
|
|
4
5
|
import { t } from "../core/messages/index.js";
|
|
5
6
|
/**
|
|
@@ -14,6 +15,8 @@ export function createConsoleRenderer(options = {}) {
|
|
|
14
15
|
const output = options.output ?? ((text) => void stdout.write(text));
|
|
15
16
|
let atLineStart = true;
|
|
16
17
|
let labelPrinted = false;
|
|
18
|
+
// The main agent's task list, to print only what changed in each TodoWrite call.
|
|
19
|
+
let todos = [];
|
|
17
20
|
function write(text) {
|
|
18
21
|
if (text.length === 0)
|
|
19
22
|
return;
|
|
@@ -39,9 +42,20 @@ export function createConsoleRenderer(options = {}) {
|
|
|
39
42
|
}
|
|
40
43
|
write(ui.agent(event.text));
|
|
41
44
|
return;
|
|
42
|
-
case "action":
|
|
43
|
-
|
|
45
|
+
case "action": {
|
|
46
|
+
const next = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
|
|
47
|
+
if (!next) {
|
|
48
|
+
writeLine(ui.action(`[action] ${formatAction(event.toolName, event.input)}`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { started, completed } = todoChanges(todos, next);
|
|
52
|
+
todos = next;
|
|
53
|
+
for (const todo of completed)
|
|
54
|
+
writeLine(ui.action(`[task] ☑ ${todo.content}`));
|
|
55
|
+
for (const todo of started)
|
|
56
|
+
writeLine(ui.action(`[task] ◼ ${todo.activeForm}`));
|
|
44
57
|
return;
|
|
58
|
+
}
|
|
45
59
|
case "subagent-action":
|
|
46
60
|
case "tool-result":
|
|
47
61
|
case "prompt-suggestion":
|