@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.
Files changed (70) hide show
  1. package/README.md +110 -110
  2. package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/commands/lint.md +1 -1
  5. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
  6. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
  7. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
  8. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
  9. package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
  10. package/assets/knowledge-plugin-files/commands/lint.md +5 -0
  11. package/assets/knowledge-plugin-files/commands/query.md +5 -0
  12. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
  13. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
  14. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
  15. package/dist/core/agentSpec.d.ts +27 -2
  16. package/dist/core/extractText.d.ts +13 -0
  17. package/dist/core/extractText.js +147 -0
  18. package/dist/core/fileKnowledgeStore.d.ts +10 -0
  19. package/dist/core/fileKnowledgeStore.js +378 -0
  20. package/dist/core/hooks/fileScopeGate.d.ts +8 -0
  21. package/dist/core/hooks/fileScopeGate.js +20 -0
  22. package/dist/core/hooks/humanInput.d.ts +18 -0
  23. package/dist/core/hooks/humanInput.js +30 -2
  24. package/dist/core/hooks/planGate.js +14 -5
  25. package/dist/core/hooks/stepGate.js +6 -0
  26. package/dist/core/interaction.d.ts +20 -0
  27. package/dist/core/knowledge.d.ts +14 -4
  28. package/dist/core/knowledge.js +36 -6
  29. package/dist/core/knowledgeStore.d.ts +134 -0
  30. package/dist/core/knowledgeStore.js +84 -0
  31. package/dist/core/messages/de.js +65 -0
  32. package/dist/core/messages/en.d.ts +41 -0
  33. package/dist/core/messages/en.js +65 -0
  34. package/dist/core/messages/es.js +65 -0
  35. package/dist/core/messages/fr.js +65 -0
  36. package/dist/core/modeControl.d.ts +36 -0
  37. package/dist/core/modeControl.js +61 -0
  38. package/dist/core/session.d.ts +6 -31
  39. package/dist/core/session.js +76 -71
  40. package/dist/core/sources.d.ts +122 -0
  41. package/dist/core/sources.js +312 -0
  42. package/dist/core/todos.d.ts +23 -0
  43. package/dist/core/todos.js +33 -0
  44. package/dist/core/toolLabels.d.ts +1 -1
  45. package/dist/core/toolLabels.js +42 -2
  46. package/dist/core/tools/humanApproval.d.ts +9 -5
  47. package/dist/core/tools/humanApproval.js +39 -7
  48. package/dist/core/tools/knowledgeTools.d.ts +16 -0
  49. package/dist/core/tools/knowledgeTools.js +161 -0
  50. package/dist/core/tools/saveToSources.d.ts +27 -8
  51. package/dist/core/tools/saveToSources.js +203 -55
  52. package/dist/core/tools/time.d.ts +29 -0
  53. package/dist/core/tools/time.js +129 -0
  54. package/dist/index.d.ts +7 -4
  55. package/dist/index.js +4 -1
  56. package/dist/tui/chatTui.js +14 -1
  57. package/dist/tui/consoleRenderer.js +16 -2
  58. package/dist/tui/ink/SessionView.js +72 -17
  59. package/dist/tui/ink/inkInteraction.d.ts +4 -2
  60. package/dist/tui/ink/inkInteraction.js +20 -2
  61. package/dist/tui/ink/progressView.d.ts +5 -1
  62. package/dist/tui/ink/runChatInk.d.ts +16 -1
  63. package/dist/tui/ink/runChatInk.js +7 -0
  64. package/dist/tui/ink/sessionModel.d.ts +10 -3
  65. package/dist/tui/ink/sessionModel.js +26 -4
  66. package/dist/tui/ink/toolGroup.d.ts +23 -2
  67. package/dist/tui/ink/toolGroup.js +17 -4
  68. package/dist/tui/terminalInteraction.js +2 -0
  69. package/package.json +31 -1
  70. /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The SDK's `TodoWrite` tool: the model keeps a task list on long jobs, sending the whole
3
+ * list with every call (confirmed empirically: an `action` event whose input is
4
+ * `{ todos: [{ content, status, activeForm }] }`, and a result that only says it was saved).
5
+ * The chats show the list instead of the calls.
6
+ */
7
+ export type TodoStatus = "pending" | "in_progress" | "completed";
8
+ /** One task: what to do, in the imperative ("Prepare the slides"), and while it's done ("Preparing the slides"). */
9
+ export interface Todo {
10
+ content: string;
11
+ status: TodoStatus;
12
+ activeForm: string;
13
+ }
14
+ export declare const TODO_TOOL = "TodoWrite";
15
+ /** The list in a `TodoWrite` call's input, or `null` if it isn't one. */
16
+ export declare function parseTodos(input: unknown): Todo[] | null;
17
+ /** Whether any task is still to do. */
18
+ export declare function hasOpenTodos(todos: readonly Todo[] | null): boolean;
19
+ /** The tasks that started and the ones finished from `before` to `after`, matched by their text. */
20
+ export declare function todoChanges(before: readonly Todo[], after: readonly Todo[]): {
21
+ started: Todo[];
22
+ completed: Todo[];
23
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The SDK's `TodoWrite` tool: the model keeps a task list on long jobs, sending the whole
3
+ * list with every call (confirmed empirically: an `action` event whose input is
4
+ * `{ todos: [{ content, status, activeForm }] }`, and a result that only says it was saved).
5
+ * The chats show the list instead of the calls.
6
+ */
7
+ export const TODO_TOOL = "TodoWrite";
8
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
9
+ /** The list in a `TodoWrite` call's input, or `null` if it isn't one. */
10
+ export function parseTodos(input) {
11
+ const todos = input?.todos;
12
+ if (!Array.isArray(todos))
13
+ return null;
14
+ return todos
15
+ .filter((todo) => Boolean(todo) && typeof todo === "object")
16
+ .map((todo) => ({
17
+ content: String(todo.content ?? ""),
18
+ status: (STATUSES.has(String(todo.status)) ? todo.status : "pending"),
19
+ activeForm: String(todo.activeForm ?? todo.content ?? ""),
20
+ }));
21
+ }
22
+ /** Whether any task is still to do. */
23
+ export function hasOpenTodos(todos) {
24
+ return Boolean(todos?.some((todo) => todo.status !== "completed"));
25
+ }
26
+ /** The tasks that started and the ones finished from `before` to `after`, matched by their text. */
27
+ export function todoChanges(before, after) {
28
+ const previous = new Map(before.map((todo) => [todo.content, todo.status]));
29
+ return {
30
+ started: after.filter((todo) => todo.status === "in_progress" && previous.get(todo.content) !== "in_progress"),
31
+ completed: after.filter((todo) => todo.status === "completed" && previous.get(todo.content) !== "completed"),
32
+ };
33
+ }
@@ -21,7 +21,7 @@ export type ToolDescriber = (shortName: string, input: Record<string, unknown>)
21
21
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
22
22
  * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
23
23
  * kit's generic ones; `extraLocalServers` names any *additional* MCP server (beyond this
24
- * kit's own "approvals"/"manualLogin"/"sourceFiles") whose tools should be unwrapped
24
+ * kit's own "approvals"/"manualLogin"/"sourceFiles"/"time") whose tools should be unwrapped
25
25
  * without a "[server] " prefix, e.g. "playwright".
26
26
  */
27
27
  export declare function createFriendlyToolLabel(options?: {
@@ -27,8 +27,46 @@ function describeCore(shortName, input) {
27
27
  switch (shortName) {
28
28
  case "request_human_approval":
29
29
  return labels.askingApproval(truncate(text(input.summary, ""), 120));
30
+ case "ask_human":
31
+ return labels.askingHuman(truncate(text(input.question, ""), 100));
32
+ case "present_plan":
33
+ return labels.presentingPlan;
30
34
  case "request_manual_login":
31
35
  return labels.waitingManual;
36
+ case "current_time":
37
+ return labels.checkingTime;
38
+ case "date_math":
39
+ return labels.calculatingDates;
40
+ case "knowledge_index":
41
+ return labels.knowledgeIndex;
42
+ case "knowledge_search":
43
+ return labels.knowledgeSearch(truncate(text(input.query, ""), 60));
44
+ case "knowledge_read":
45
+ return labels.knowledgeRead(text(input.page, labels.aPage));
46
+ case "knowledge_create":
47
+ return labels.knowledgeCreate(`${text(input.type, "")}/${text(input.slug, "")}`);
48
+ case "knowledge_edit":
49
+ return labels.knowledgeEdit(text(input.page, labels.aPage));
50
+ case "knowledge_rewrite":
51
+ return labels.knowledgeRewrite(text(input.page, labels.aPage));
52
+ case "knowledge_supersede":
53
+ return labels.knowledgeSupersede(text(input.page, labels.aPage));
54
+ case "knowledge_retire":
55
+ return labels.knowledgeRetire(text(input.page, labels.aPage));
56
+ case "knowledge_log":
57
+ return labels.knowledgeLog;
58
+ case "knowledge_check":
59
+ return labels.knowledgeCheck;
60
+ case "list_sources":
61
+ return labels.listingSources;
62
+ case "extract_text":
63
+ return labels.extractingText(truncatePath(text(input.source, labels.aFile), 70));
64
+ case "download_to_sources":
65
+ return labels.downloading(truncate(text(input.url, labels.aPage), 80));
66
+ case "request_file":
67
+ return labels.requestingFile(truncate(text(input.description, ""), 100));
68
+ case "retire_source":
69
+ return labels.retiringSource(truncatePath(text(input.source, ""), 70));
32
70
  case "save_to_sources":
33
71
  return labels.savingToSources(truncatePath(text(input.destination, ""), 70));
34
72
  case "Read":
@@ -59,6 +97,8 @@ function describeCore(shortName, input) {
59
97
  // explicitly in `tools`). Confirmed empirically: its input is `{ skill: "name" }`.
60
98
  case "Skill":
61
99
  return labels.applyingSkill(text(input.skill, labels.aSkill));
100
+ case "TodoWrite":
101
+ return labels.updatingTasks;
62
102
  default:
63
103
  return undefined;
64
104
  }
@@ -67,11 +107,11 @@ function describeCore(shortName, input) {
67
107
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
68
108
  * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
69
109
  * kit's generic ones; `extraLocalServers` names any *additional* MCP server (beyond this
70
- * kit's own "approvals"/"manualLogin"/"sourceFiles") whose tools should be unwrapped
110
+ * kit's own "approvals"/"manualLogin"/"sourceFiles"/"time") whose tools should be unwrapped
71
111
  * without a "[server] " prefix, e.g. "playwright".
72
112
  */
73
113
  export function createFriendlyToolLabel(options = {}) {
74
- const localServers = new Set(["approvals", "manualLogin", "sourceFiles", ...(options.extraLocalServers ?? [])]);
114
+ const localServers = new Set(["approvals", "manualLogin", "sourceFiles", "time", "knowledge", ...(options.extraLocalServers ?? [])]);
75
115
  const describe = (shortName, input) => options.describe?.(shortName, input) ?? describeCore(shortName, input) ?? shortName.replace(/_/g, " ");
76
116
  return (toolName, toolInput) => {
77
117
  const input = toolInput && typeof toolInput === "object" ? toolInput : {};
@@ -1,3 +1,4 @@
1
+ import { type ModeControl } from "../modeControl.js";
1
2
  /** The approval tool's texts: its description (when the model should call it) and what it returns when approved or rejected. */
2
3
  export interface HumanApprovalTexts {
3
4
  description: string;
@@ -7,9 +8,12 @@ export interface HumanApprovalTexts {
7
8
  /** Domain-neutral defaults — a concrete agent should normally override these via `AgentSpec.humanApprovalTexts` with wording specific to what "publishing" means in its domain. */
8
9
  export declare const DEFAULT_HUMAN_APPROVAL_TEXTS: HumanApprovalTexts;
9
10
  /**
10
- * Checkpoint tool for "guided" mode: the agent explores freely, but
11
- * must call this tool and wait for human approval right before any action that publishes
12
- * something visible to others and that's hard to naturally undo. Everything else —
13
- * browsing and reading — doesn't go through this.
11
+ * The tools that ask the person, in every mode but autonomous: `request_human_approval`
12
+ * (right before an action that publishes something visible to others and that's hard to
13
+ * undo), `ask_human` (a choice between options, in a panel, within the turn) and, when the
14
+ * session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
15
+ * mode (ADR-023): the person runs the plan, keeps planning or cancels.
14
16
  */
15
- export declare function createHumanApprovalServer(runDir: string, texts?: HumanApprovalTexts): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
17
+ export declare function createHumanApprovalServer(runDir: string, texts?: HumanApprovalTexts, options?: {
18
+ modeControl?: ModeControl;
19
+ }): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -1,7 +1,8 @@
1
1
  import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { z } from "zod";
3
- import { askForDecision } from "../hooks/humanInput.js";
3
+ import { askForChoice, askForDecision, askForText } from "../hooks/humanInput.js";
4
4
  import { t } from "../messages/index.js";
5
+ import { togglePlanMode } from "../modeControl.js";
5
6
  const APPROVED_ANSWERS = new Set(["", "y", "yes"]);
6
7
  /** Domain-neutral defaults — a concrete agent should normally override these via `AgentSpec.humanApprovalTexts` with wording specific to what "publishing" means in its domain. */
7
8
  export const DEFAULT_HUMAN_APPROVAL_TEXTS = {
@@ -11,13 +12,15 @@ export const DEFAULT_HUMAN_APPROVAL_TEXTS = {
11
12
  approved: "Approved by the human. You may continue.",
12
13
  rejected: "Rejected by the human. Don't proceed with that; ask what to do differently.",
13
14
  };
15
+ const ok = (text) => ({ content: [{ type: "text", text }] });
14
16
  /**
15
- * Checkpoint tool for "guided" mode: the agent explores freely, but
16
- * must call this tool and wait for human approval right before any action that publishes
17
- * something visible to others and that's hard to naturally undo. Everything else —
18
- * browsing and reading — doesn't go through this.
17
+ * The tools that ask the person, in every mode but autonomous: `request_human_approval`
18
+ * (right before an action that publishes something visible to others and that's hard to
19
+ * undo), `ask_human` (a choice between options, in a panel, within the turn) and, when the
20
+ * session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
21
+ * mode (ADR-023): the person runs the plan, keeps planning or cancels.
19
22
  */
20
- export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL_TEXTS) {
23
+ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL_TEXTS, options = {}) {
21
24
  const requestHumanApproval = tool("request_human_approval", texts.description, {
22
25
  summary: z.string().describe("Brief summary of what you're about to submit or publish"),
23
26
  }, async (args) => {
@@ -30,9 +33,38 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
30
33
  content: [{ type: "text", text: approved ? texts.approved : texts.rejected }],
31
34
  };
32
35
  });
36
+ const askHuman = tool("ask_human", "Ask the person to choose between options, in a panel, and get the answer in this same turn, instead of ending your turn with a written question. Use it when you need their decision to go on (which approach, which of several items). They can also type their own answer.", {
37
+ question: z.string().describe("The question, in one sentence, in the person's language"),
38
+ options: z.array(z.string()).min(2).max(8).describe("The options, short, in the person's language (2 to 8; an \"Other\" for their own answer is added)"),
39
+ multiple: z.boolean().optional().describe("Whether they can pick several (default: one)"),
40
+ }, async (args) => {
41
+ const answer = await askForChoice(runDir, { title: t().questionTitle, lines: [args.question] }, args.options, args.multiple ?? false);
42
+ const parts = [
43
+ ...(answer.chosen.length ? [`The person chose: ${answer.chosen.join("; ")}.`] : []),
44
+ ...(answer.other ? [`Their own answer: ${answer.other}`] : []),
45
+ ];
46
+ return ok(parts.length ? parts.join("\n") : "The person didn't answer. Go on with what you can, or ask in your reply.");
47
+ });
48
+ const control = options.modeControl;
49
+ const presentPlan = tool("present_plan", "In plan mode, when the plan is ready: show it to the person, who decides to run it (plan mode ends and you carry it out right away, in this turn), to keep planning (with a comment on what to change) or to cancel. Outside plan mode it's refused.", { plan: z.string().describe("The whole plan, in markdown, in the person's language: steps, what changes, what to check") }, async (args) => {
50
+ if (!control || control.mode !== "plan")
51
+ return { ...ok("present_plan only works in plan mode: you aren't in it, so just go ahead."), isError: true };
52
+ const choices = [t().runPlan, t().keepPlanning, t().cancelPlan];
53
+ const answer = await askForChoice(runDir, { title: t().planTitle, lines: args.plan.split("\n"), markdown: true }, choices);
54
+ const picked = answer.chosen[0];
55
+ if (picked === t().runPlan) {
56
+ const mode = togglePlanMode(control) ?? control.mode;
57
+ control.takeNotice?.(); // told here, not again with the next message
58
+ return ok(`The person approved the plan. Plan mode is off (now ${mode} mode): carry the plan out now, step by step.${answer.other ? ` They added: ${answer.other}` : ""}`);
59
+ }
60
+ if (picked === t().cancelPlan)
61
+ return ok("The person cancelled the plan. Stop, and ask them what they want instead.");
62
+ const comment = answer.other ?? (await askForText(runDir, { title: t().planTitle, lines: [], question: t().planCommentQuestion }));
63
+ return ok(`The person wants to keep planning${comment ? `: ${comment}` : ""}. You're still in plan mode: revise the plan and present it again.`);
64
+ });
33
65
  return createSdkMcpServer({
34
66
  name: "approvals",
35
67
  version: "1.0.0",
36
- tools: [requestHumanApproval],
68
+ tools: [requestHumanApproval, askHuman, ...(control?.switchable.includes("plan") ? [presentPlan] : [])],
37
69
  });
38
70
  }
@@ -0,0 +1,16 @@
1
+ import type { KnowledgeStore } from "../knowledgeStore.js";
2
+ /** Options of `createKnowledgeServer()`. */
3
+ export interface KnowledgeToolsOptions {
4
+ /** This run's folder (for the response file of `knowledge_retire`'s approval). */
5
+ runDir: string;
6
+ /** A person can be asked (not autonomous): adds `knowledge_retire`, which asks first. */
7
+ interactive?: boolean;
8
+ /** The sources folder: summaries record their original's hash, and `knowledge_check` reports new, changed and missing originals. */
9
+ sourcesDir?: string;
10
+ /** The project root, for `list_sources`' paths. */
11
+ projectDir?: string;
12
+ /** The knowledge folder, for `list_sources`' ingest status. */
13
+ knowledgeDir?: string;
14
+ }
15
+ /** The `knowledge` MCP server over `store`. */
16
+ export declare function createKnowledgeServer(store: KnowledgeStore, options: KnowledgeToolsOptions): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -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;