@falkenslab/agent-kit 0.15.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.
Files changed (65) hide show
  1. package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
  2. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  3. package/assets/knowledge-plugin/commands/lint.md +1 -1
  4. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
  5. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
  6. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
  7. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
  8. package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
  9. package/assets/knowledge-plugin-files/commands/lint.md +5 -0
  10. package/assets/knowledge-plugin-files/commands/query.md +5 -0
  11. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
  12. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
  13. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
  14. package/dist/core/agentSpec.d.ts +27 -2
  15. package/dist/core/extractText.d.ts +13 -0
  16. package/dist/core/extractText.js +147 -0
  17. package/dist/core/fileKnowledgeStore.d.ts +10 -0
  18. package/dist/core/fileKnowledgeStore.js +378 -0
  19. package/dist/core/hooks/fileScopeGate.d.ts +8 -0
  20. package/dist/core/hooks/fileScopeGate.js +20 -0
  21. package/dist/core/hooks/humanInput.d.ts +18 -0
  22. package/dist/core/hooks/humanInput.js +30 -2
  23. package/dist/core/hooks/planGate.js +14 -5
  24. package/dist/core/hooks/stepGate.js +6 -0
  25. package/dist/core/interaction.d.ts +20 -0
  26. package/dist/core/knowledge.d.ts +14 -4
  27. package/dist/core/knowledge.js +36 -6
  28. package/dist/core/knowledgeStore.d.ts +134 -0
  29. package/dist/core/knowledgeStore.js +84 -0
  30. package/dist/core/messages/de.js +64 -0
  31. package/dist/core/messages/en.d.ts +39 -0
  32. package/dist/core/messages/en.js +64 -0
  33. package/dist/core/messages/es.js +64 -0
  34. package/dist/core/messages/fr.js +64 -0
  35. package/dist/core/modeControl.d.ts +36 -0
  36. package/dist/core/modeControl.js +61 -0
  37. package/dist/core/session.d.ts +6 -31
  38. package/dist/core/session.js +76 -71
  39. package/dist/core/sources.d.ts +122 -0
  40. package/dist/core/sources.js +312 -0
  41. package/dist/core/todos.d.ts +23 -0
  42. package/dist/core/todos.js +33 -0
  43. package/dist/core/toolLabels.d.ts +1 -1
  44. package/dist/core/toolLabels.js +42 -2
  45. package/dist/core/tools/humanApproval.d.ts +9 -5
  46. package/dist/core/tools/humanApproval.js +39 -7
  47. package/dist/core/tools/knowledgeTools.d.ts +16 -0
  48. package/dist/core/tools/knowledgeTools.js +161 -0
  49. package/dist/core/tools/saveToSources.d.ts +27 -8
  50. package/dist/core/tools/saveToSources.js +203 -55
  51. package/dist/core/tools/time.d.ts +29 -0
  52. package/dist/core/tools/time.js +129 -0
  53. package/dist/index.d.ts +6 -3
  54. package/dist/index.js +4 -1
  55. package/dist/tui/chatTui.js +14 -1
  56. package/dist/tui/consoleRenderer.js +16 -2
  57. package/dist/tui/ink/SessionView.js +67 -17
  58. package/dist/tui/ink/inkInteraction.d.ts +4 -2
  59. package/dist/tui/ink/inkInteraction.js +20 -2
  60. package/dist/tui/ink/runChatInk.js +5 -0
  61. package/dist/tui/ink/sessionModel.d.ts +3 -0
  62. package/dist/tui/ink/sessionModel.js +14 -1
  63. package/dist/tui/terminalInteraction.js +2 -0
  64. package/package.json +31 -1
  65. /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
@@ -0,0 +1,161 @@
1
+ import path from "node:path";
2
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
3
+ import { z } from "zod";
4
+ import { listSources, loadManifest, recordIngest } from "../sources.js";
5
+ import { askForDecision } from "../hooks/humanInput.js";
6
+ import { t } from "../messages/index.js";
7
+ /**
8
+ * The `knowledge_*` tools: the only way the agent reaches the built-in knowledge base
9
+ * (ADR-024). They work on pages by id (`concept/bowline`) over a `KnowledgeStore`, never on
10
+ * files, so the storage can change underneath; the store keeps the index and the backlinks.
11
+ */
12
+ const ok = (text) => ({ content: [{ type: "text", text }] });
13
+ const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
14
+ const message = (error) => (error instanceof Error ? error.message : String(error));
15
+ /** Runs a tool's work, turning a thrown error into an answer the model can act on. */
16
+ async function attempt(work) {
17
+ try {
18
+ return ok(await work());
19
+ }
20
+ catch (error) {
21
+ return fail(message(error));
22
+ }
23
+ }
24
+ const OVERVIEW = "overview";
25
+ /** The `knowledge` MCP server over `store`. */
26
+ export function createKnowledgeServer(store, options) {
27
+ const typeNames = store.types().map((t) => t.type);
28
+ // A list of pairs, not z.record(): a record in a tool's schema makes the SDK drop every
29
+ // tool of the server, silently (confirmed empirically).
30
+ const fields = z
31
+ .array(z.object({ name: z.string(), value: z.string().nullable() }))
32
+ .optional()
33
+ .describe('Frontmatter fields to set (value null removes one), e.g. [{"name": "aliases", "value": "tide range, tidal range"}]; for a summary, "file" is its original relative to sources/');
34
+ const changes = (pairs) => pairs ? Object.fromEntries(pairs.map(({ name, value }) => [name, value])) : undefined;
35
+ /** A summary written from an original: record its hash, so "changed" means changed after this. */
36
+ async function ingested(id) {
37
+ if (!options.sourcesDir || !id.startsWith("summary/"))
38
+ return;
39
+ const page = await store.read(id);
40
+ const file = page?.fields.file;
41
+ if (!file || /^[a-z]+:\/\//i.test(file))
42
+ return;
43
+ const knowledgeDir = options.knowledgeDir;
44
+ const original = knowledgeDir ? path.resolve(knowledgeDir, "summaries", file) : path.resolve(options.sourcesDir, file);
45
+ await recordIngest(options.sourcesDir, path.relative(options.sourcesDir, original));
46
+ }
47
+ const index = tool("knowledge_index", "The knowledge base's catalog: every page, one line each (title, id, what it is), by section. Start here, then read only the pages the task needs.", {}, async () => attempt(async () => (await store.list()).length ? await store.index() : "The knowledge base is empty."), { annotations: { readOnlyHint: true } });
48
+ const search = tool("knowledge_search", "Find pages by words: in titles, aliases and content, best first, with the line that matched. Search before creating a page, so you don't duplicate one under another name.", { query: z.string().describe("Words to look for"), limit: z.number().int().positive().max(50).optional() }, async (args) => attempt(async () => {
49
+ const hits = await store.search(args.query, args.limit ?? 10);
50
+ return hits.length ? hits.map((hit) => `- ${hit.id} — ${hit.title}: ${hit.snippet}`).join("\n") : "No page matches.";
51
+ }), { annotations: { readOnlyHint: true } });
52
+ const read = tool("knowledge_read", 'A page by its id ("concept/bowline"), or "overview": its fields, its content (links to other pages as ids) and the pages that link to it.', { page: z.string().describe('The page id, or "overview"') }, async (args) => attempt(async () => {
53
+ if (args.page === OVERVIEW)
54
+ return (await store.overview()) || "There's no overview yet: write it with knowledge_rewrite (page \"overview\").";
55
+ const page = await store.read(args.page);
56
+ if (!page)
57
+ throw new Error(`There's no page "${args.page}". knowledge_search or knowledge_index finds the right one.`);
58
+ const fieldLines = Object.entries(page.fields).map(([key, value]) => `${key}: ${value}`);
59
+ return [`id: ${page.id}`, `title: ${page.title}`, ...fieldLines, `linked from: ${page.linkedFrom.join(", ") || "(nothing)"}`, "", page.content].join("\n");
60
+ }), { annotations: { readOnlyHint: true } });
61
+ const create = tool("knowledge_create", `Create a page. Types: ${store
62
+ .types()
63
+ .map((type) => `${type.type} (${type.description})`)
64
+ .join("; ")}. Without content, returns the type's template: fill it and call again. Links to other pages are ids, [Bowline](concept/bowline), and must exist, or be created in the same call (\`also\`); the index is kept for you.`, {
65
+ type: z.enum(typeNames),
66
+ slug: z.string().describe('Lowercase ASCII words joined by hyphens, e.g. "spring-tides"'),
67
+ title: z.string().describe("The page's title, in the person's language"),
68
+ content: z.string().optional().describe("The page's markdown body (under its title), following the type's template"),
69
+ fields,
70
+ also: z
71
+ .array(z.object({
72
+ type: z.enum(typeNames),
73
+ slug: z.string(),
74
+ title: z.string(),
75
+ content: z.string(),
76
+ fields,
77
+ }))
78
+ .optional()
79
+ .describe("More pages to create in the same call, which may link to each other and to this one (a summary and the new concepts it feeds): all are created, or none"),
80
+ }, async (args) => attempt(async () => {
81
+ const type = store.types().find((t) => t.type === args.type);
82
+ if (!args.content)
83
+ return `Template for a ${type.type} page (${type.description}):\n\n${type.template}\n\nFill it in the person's language and call knowledge_create again with content.`;
84
+ const ids = await store.createMany([
85
+ { type: args.type, slug: args.slug, title: args.title, content: args.content, fields: changes(args.fields) },
86
+ ...(args.also ?? []).map((page) => ({ ...page, fields: changes(page.fields) })),
87
+ ]);
88
+ for (const id of ids)
89
+ await ingested(id);
90
+ return `Created ${ids.join(", ")}.`;
91
+ }));
92
+ const edit = tool("knowledge_edit", "Change one fragment of a page: oldText (exactly as knowledge_read returned it, unique in the page) becomes newText. Prefer it to rewriting a page.", { page: z.string(), oldText: z.string(), newText: z.string(), fields }, async (args) => attempt(async () => {
93
+ await store.edit(args.page, args.oldText, args.newText, changes(args.fields));
94
+ await ingested(args.page);
95
+ return `Edited ${args.page}.`;
96
+ }));
97
+ const rewrite = tool("knowledge_rewrite", 'Replace a page\'s whole content, keeping its id and the links to it: for a page redone from a better source, or the "overview" (the living synthesis of the whole knowledge base). For small changes use knowledge_edit.', { page: z.string().describe('The page id, or "overview"'), content: z.string(), fields }, async (args) => attempt(async () => {
98
+ if (args.page === OVERVIEW) {
99
+ await store.writeOverview(args.content);
100
+ return "Rewrote the overview.";
101
+ }
102
+ await store.rewrite(args.page, args.content, changes(args.fields));
103
+ await ingested(args.page);
104
+ return `Rewrote ${args.page}.`;
105
+ }));
106
+ const supersede = tool("knowledge_supersede", "Mark a page superseded by another one (it stays, with a notice pointing to the new one). Pages are never deleted or renamed.", { page: z.string(), by: z.string().describe("The page that replaces it"), reason: z.string().optional() }, async (args) => attempt(async () => {
107
+ await store.supersede(args.page, args.by, args.reason);
108
+ return `${args.page} is now superseded by ${args.by}. Repoint the links that should go to the new page.`;
109
+ }));
110
+ const retire = tool("knowledge_retire", "Retire a page whose knowledge was wrong (e.g. learned from a wrong original), after the person approves: it leaves the index and the search but is kept. Then fix the pages that link to it (knowledge_check lists them).", { page: z.string(), reason: z.string().describe("Why, in one sentence, in the person's language") }, async (args) => attempt(async () => {
111
+ const page = await store.read(args.page);
112
+ if (!page)
113
+ throw new Error(`There's no page "${args.page}".`);
114
+ const answer = await askForDecision(options.runDir, { title: t().retirePageTitle, lines: [`${page.title} (${page.id})`, args.reason] });
115
+ if (answer === "q")
116
+ return "The person stopped: don't retire it, and stop what you were doing.";
117
+ if (answer !== "" && answer !== "y" && answer !== "yes")
118
+ return "The person said no: the page stays.";
119
+ await store.retire(args.page, args.reason);
120
+ return `Retired ${args.page}. Now fix the pages that link to it (knowledge_check).`;
121
+ }));
122
+ const log = tool("knowledge_log", "Add an entry to the knowledge base's log, dated today: once an operation is done (an ingest, a query filed back, a lint, an update), what it was and the pages it touched.", {
123
+ operation: z.string().describe('"ingest", "query", "lint" or "update"'),
124
+ what: z.string().describe("What, in one line"),
125
+ pages: z.array(z.string()).optional().describe("The ids of the pages created or changed"),
126
+ }, async (args) => attempt(async () => {
127
+ await store.log(args.operation, args.what, args.pages ?? []);
128
+ return "Logged.";
129
+ }));
130
+ const check = tool("knowledge_check", "The knowledge base's mechanical problems in one call: broken links, orphan pages, links to retired pages, summaries whose original is gone, originals that are new, changed or missing, and the passages that cite a retired original. Fix what's mechanical; report what needs the person.", {}, async () => attempt(async () => {
131
+ const report = { ...(await store.check()) };
132
+ if (options.sourcesDir) {
133
+ const sources = await listSources(options.sourcesDir, options.knowledgeDir, options.projectDir ?? path.dirname(options.sourcesDir));
134
+ report.newOriginals = sources.filter((s) => s.status === "new").map((s) => s.path);
135
+ report.changedOriginals = sources.filter((s) => s.status === "changed").map((s) => ({ path: s.path, summaries: s.summaries }));
136
+ report.missingFromSources = sources.filter((s) => s.status === "missing").map((s) => s.path);
137
+ const retired = (await loadManifest(options.sourcesDir)).retired;
138
+ if (retired.length) {
139
+ const citations = [];
140
+ for (const info of await store.list()) {
141
+ const page = await store.read(info.id);
142
+ for (const original of retired) {
143
+ const name = path.basename(original.path);
144
+ for (const line of (page?.content ?? "").split(/\r?\n/))
145
+ if (line.includes(name))
146
+ citations.push({ original: original.path, page: info.id, passage: line.trim() });
147
+ if (page?.fields.file?.endsWith(name) && info.status === "active")
148
+ citations.push({ original: original.path, page: info.id, passage: `file: ${page.fields.file}` });
149
+ }
150
+ }
151
+ report.citesRetiredOriginals = citations;
152
+ }
153
+ }
154
+ return JSON.stringify(report, null, 2);
155
+ }), { annotations: { readOnlyHint: true } });
156
+ return createSdkMcpServer({
157
+ name: "knowledge",
158
+ version: "1.0.0",
159
+ tools: [index, search, read, create, edit, rewrite, supersede, ...(options.interactive ? [retire] : []), log, check],
160
+ });
161
+ }
@@ -1,10 +1,29 @@
1
+ /** The most `download_to_sources` and `request_file` copy in. */
2
+ export declare const MAX_SOURCE_BYTES: number;
1
3
  /**
2
- * `save_to_sources`: the only way the agent adds to `sourcesDir` (Write/Edit are scoped
3
- * to the notes folder, see hooks/fileScopeGate.ts), so the originals stay as obtained.
4
- * Deliberately just an fs.copyFile with COPYFILE_EXCL — no parsing, no format conversion,
5
- * no overwriting. A document downloaded via a browser-automation tool typically lands in
6
- * this run's own folder, which the file tools can't reach; this tool's only job is getting
7
- * it into `sourcesDir`, where the SDK's own Read tool already knows how to interpret it
8
- * (PDF/DOCX text extraction, multimodal images).
4
+ * The main content of a web page as markdown, with the optional libraries
5
+ * (`@mozilla/readability`, `linkedom`, `turndown`); null when they aren't installed or the
6
+ * page has no readable content.
9
7
  */
10
- export declare function createSaveToSourcesServer(runDir: string, sourcesDir: string, description?: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
8
+ export declare function htmlToMarkdown(html: string, url: string): Promise<string | null>;
9
+ /** Downloads `url` into `file`, refusing other schemes and anything over `maxBytes`; returns its content type. */
10
+ export declare function downloadToFile(url: string, file: string, maxBytes: number): Promise<string>;
11
+ /** Options of `createSaveToSourcesServer()`. */
12
+ export interface SourceToolsOptions {
13
+ /** The knowledge folder, to tell which originals have a summary page (`list_sources`). */
14
+ knowledgeDir?: string;
15
+ /** The project root: paths in the tools' answers are relative to it. Defaults to the parent of `sourcesDir`. */
16
+ projectDir?: string;
17
+ /** A person can be asked (not autonomous): adds `request_file` and `retire_source`, which ask first. */
18
+ interactive?: boolean;
19
+ /** The most `download_to_sources` and `request_file` copy in; 50 MB by default. */
20
+ maxBytes?: number;
21
+ }
22
+ /**
23
+ * The tools that keep `sourcesDir`, the originals the agent reads but never edits (Write/Edit
24
+ * are scoped to the notes folder, see hooks/fileScopeGate.ts): `save_to_sources`,
25
+ * `list_sources` and `download_to_sources`, plus `request_file` and `retire_source` when a
26
+ * person can be asked. Nothing is ever overwritten or deleted; the bookkeeping is in
27
+ * sources.ts.
28
+ */
29
+ export declare function createSaveToSourcesServer(runDir: string, sourcesDir: string, description?: string, options?: SourceToolsOptions): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -1,13 +1,11 @@
1
1
  import path from "node:path";
2
- import { constants, copyFile, mkdir, readdir, stat } from "node:fs/promises";
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
- /** Resolves `candidate` against `base`; returns the absolute path only if it stays inside it. */
6
- function resolveWithin(base, candidate) {
7
- const resolved = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(base, candidate);
8
- const relative = path.relative(base, resolved);
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. It becomes readable there with the SDK's own Read tool, which already " +
43
- "extracts text from PDF/DOCX and interprets images directly. This tool does no parsing " +
44
- "or conversion itself, it only relocates the file, and it never overwrites a file " +
45
- "that's already in sources/. Use it right after downloading a document you're about to " +
46
- "study in depth, so it's still there for later sessions instead of only existing for " +
47
- "this run.";
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
- * `save_to_sources`: the only way the agent adds to `sourcesDir` (Write/Edit are scoped
50
- * to the notes folder, see hooks/fileScopeGate.ts), so the originals stay as obtained.
51
- * Deliberately just an fs.copyFile with COPYFILE_EXCL — no parsing, no format conversion,
52
- * no overwriting. A document downloaded via a browser-automation tool typically lands in
53
- * this run's own folder, which the file tools can't reach; this tool's only job is getting
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
- content: [{ type: "text", text: `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.` }],
73
- isError: true,
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
- const resolvedDestination = resolveWithin(sourcesDir, args.destination);
77
- if (!resolvedDestination) {
78
- return {
79
- content: [{ type: "text", text: `"${args.destination}" isn't inside sources/ - refusing to write there.` }],
80
- isError: true,
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 mkdir(path.dirname(resolvedDestination), { recursive: true });
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
- const exists = error.code === "EEXIST";
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
- return createSdkMcpServer({ name: "sourceFiles", version: "1.0.0", tools: [saveToSources] });
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;