@falkenslab/agent-kit 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +3 -3
  2. package/assets/agent-help/SKILL.md +57 -0
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +8 -3
  5. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +12 -5
  6. package/dist/core/agentHelp.d.ts +30 -0
  7. package/dist/core/agentHelp.js +55 -0
  8. package/dist/core/agentSpec.d.ts +37 -16
  9. package/dist/core/fileKnowledgeStore.d.ts +1 -3
  10. package/dist/core/fileKnowledgeStore.js +23 -29
  11. package/dist/core/hooks/fileScopeGate.d.ts +17 -2
  12. package/dist/core/hooks/fileScopeGate.js +45 -10
  13. package/dist/core/knowledge.d.ts +20 -17
  14. package/dist/core/knowledge.js +32 -60
  15. package/dist/core/knowledgeStore.d.ts +4 -9
  16. package/dist/core/knowledgeStore.js +16 -2
  17. package/dist/core/language.js +1 -1
  18. package/dist/core/messages/de.js +2 -2
  19. package/dist/core/messages/en.d.ts +2 -1
  20. package/dist/core/messages/en.js +2 -2
  21. package/dist/core/messages/es.js +2 -2
  22. package/dist/core/messages/fr.js +2 -2
  23. package/dist/core/runner.d.ts +5 -0
  24. package/dist/core/runner.js +1 -0
  25. package/dist/core/session.d.ts +12 -0
  26. package/dist/core/session.js +121 -40
  27. package/dist/core/sources.d.ts +24 -31
  28. package/dist/core/sources.js +45 -108
  29. package/dist/core/tools/humanApproval.js +3 -3
  30. package/dist/core/tools/knowledgeTools.d.ts +6 -6
  31. package/dist/core/tools/knowledgeTools.js +18 -53
  32. package/dist/core/tools/saveToSources.d.ts +6 -2
  33. package/dist/core/tools/saveToSources.js +17 -12
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js +1 -1
  36. package/dist/tui/ink/SessionView.d.ts +1 -0
  37. package/dist/tui/ink/SessionView.js +17 -4
  38. package/dist/tui/ink/runChatInk.d.ts +7 -0
  39. package/dist/tui/ink/runChatInk.js +6 -4
  40. package/package.json +1 -1
  41. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
  42. package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
  43. package/assets/knowledge-plugin-files/commands/lint.md +0 -5
  44. package/assets/knowledge-plugin-files/commands/query.md +0 -5
  45. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
  46. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
  47. package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
  48. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
@@ -34,8 +34,8 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
34
34
  };
35
35
  });
36
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)"),
37
+ question: z.string().describe("The question, in one sentence, in the language you reply in"),
38
+ options: z.array(z.string()).min(2).max(8).describe("The options, short, in the language you reply in (2 to 8; an \"Other\" for their own answer is added)"),
39
39
  multiple: z.boolean().optional().describe("Whether they can pick several (default: one)"),
40
40
  }, async (args) => {
41
41
  const answer = await askForChoice(runDir, { title: t().questionTitle, lines: [args.question] }, args.options, args.multiple ?? false);
@@ -46,7 +46,7 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
46
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
47
  });
48
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) => {
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 language you reply in: steps, what changes, what to check") }, async (args) => {
50
50
  if (!control || control.mode !== "plan")
51
51
  return { ...ok("present_plan only works in plan mode: you aren't in it, so just go ahead."), isError: true };
52
52
  const choices = [t().runPlan, t().keepPlanning, t().cancelPlan];
@@ -1,16 +1,16 @@
1
1
  import type { KnowledgeStore } from "../knowledgeStore.js";
2
+ /**
3
+ * Text as the model meant it: a body that arrives with escaped newlines ("\\n") and no real
4
+ * one gets them back. A model sometimes double-escapes a long markdown argument, and the page
5
+ * would be stored as one line with literal "\n"s (found by a real session).
6
+ */
7
+ export declare function unescapedText(text: string): string;
2
8
  /** Options of `createKnowledgeServer()`. */
3
9
  export interface KnowledgeToolsOptions {
4
10
  /** This run's folder (for the response file of `knowledge_retire`'s approval). */
5
11
  runDir: string;
6
12
  /** A person can be asked (not autonomous): adds `knowledge_retire`, which asks first. */
7
13
  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
14
  }
15
15
  /** The `knowledge` MCP server over `store`. */
16
16
  export declare function createKnowledgeServer(store: KnowledgeStore, options: KnowledgeToolsOptions): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -1,7 +1,5 @@
1
- import path from "node:path";
2
1
  import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
3
2
  import { z } from "zod";
4
- import { listSources, loadManifest, recordIngest } from "../sources.js";
5
3
  import { askForDecision } from "../hooks/humanInput.js";
6
4
  import { t } from "../messages/index.js";
7
5
  /**
@@ -21,6 +19,14 @@ async function attempt(work) {
21
19
  return fail(message(error));
22
20
  }
23
21
  }
22
+ /**
23
+ * Text as the model meant it: a body that arrives with escaped newlines ("\\n") and no real
24
+ * one gets them back. A model sometimes double-escapes a long markdown argument, and the page
25
+ * would be stored as one line with literal "\n"s (found by a real session).
26
+ */
27
+ export function unescapedText(text) {
28
+ return !text.includes("\n") && text.includes("\\n") ? text.replace(/\\r\\n|\\n/g, "\n").replace(/\\t/g, "\t") : text;
29
+ }
24
30
  const OVERVIEW = "overview";
25
31
  const LOG = "log";
26
32
  /** The `knowledge` MCP server over `store`. */
@@ -31,20 +37,8 @@ export function createKnowledgeServer(store, options) {
31
37
  const fields = z
32
38
  .array(z.object({ name: z.string(), value: z.string().nullable() }))
33
39
  .optional()
34
- .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/');
40
+ .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 as list_sources names it (its "ingested" is set for you)');
35
41
  const changes = (pairs) => pairs ? Object.fromEntries(pairs.map(({ name, value }) => [name, value])) : undefined;
36
- /** A summary written from an original: record its hash, so "changed" means changed after this. */
37
- async function ingested(id) {
38
- if (!options.sourcesDir || !id.startsWith("summary/"))
39
- return;
40
- const page = await store.read(id);
41
- const file = page?.fields.file;
42
- if (!file || /^[a-z]+:\/\//i.test(file))
43
- return;
44
- const knowledgeDir = options.knowledgeDir;
45
- const original = knowledgeDir ? path.resolve(knowledgeDir, "summaries", file) : path.resolve(options.sourcesDir, file);
46
- await recordIngest(options.sourcesDir, path.relative(options.sourcesDir, original));
47
- }
48
42
  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 } });
49
43
  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 () => {
50
44
  const hits = await store.search(args.query, args.limit ?? 10);
@@ -71,7 +65,7 @@ export function createKnowledgeServer(store, options) {
71
65
  .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.`, {
72
66
  type: z.enum(typeNames),
73
67
  slug: z.string().describe('Lowercase ASCII words joined by hyphens, e.g. "spring-tides"'),
74
- title: z.string().describe("The page's title, in the person's language"),
68
+ title: z.string().describe("The page's title, in the language you reply in"),
75
69
  content: z.string().optional().describe("The page's markdown body (under its title), following the type's template"),
76
70
  fields,
77
71
  also: z
@@ -87,34 +81,30 @@ export function createKnowledgeServer(store, options) {
87
81
  }, async (args) => attempt(async () => {
88
82
  const type = store.types().find((t) => t.type === args.type);
89
83
  if (!args.content)
90
- 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
+ return `Template for a ${type.type} page (${type.description}):\n\n${type.template}\n\nFill it in the language you reply in and call knowledge_create again with content.`;
91
85
  const ids = await store.createMany([
92
- { type: args.type, slug: args.slug, title: args.title, content: args.content, fields: changes(args.fields) },
93
- ...(args.also ?? []).map((page) => ({ ...page, fields: changes(page.fields) })),
86
+ { type: args.type, slug: args.slug, title: args.title, content: unescapedText(args.content), fields: changes(args.fields) },
87
+ ...(args.also ?? []).map((page) => ({ ...page, content: unescapedText(page.content), fields: changes(page.fields) })),
94
88
  ]);
95
- for (const id of ids)
96
- await ingested(id);
97
89
  return `Created ${ids.join(", ")}.`;
98
90
  }));
99
91
  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 () => {
100
- await store.edit(args.page, args.oldText, args.newText, changes(args.fields));
101
- await ingested(args.page);
92
+ await store.edit(args.page, unescapedText(args.oldText), unescapedText(args.newText), changes(args.fields));
102
93
  return `Edited ${args.page}.`;
103
94
  }));
104
95
  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 () => {
105
96
  if (args.page === OVERVIEW) {
106
- await store.writeOverview(args.content);
97
+ await store.writeOverview(unescapedText(args.content));
107
98
  return "Rewrote the overview.";
108
99
  }
109
- await store.rewrite(args.page, args.content, changes(args.fields));
110
- await ingested(args.page);
100
+ await store.rewrite(args.page, unescapedText(args.content), changes(args.fields));
111
101
  return `Rewrote ${args.page}.`;
112
102
  }));
113
103
  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 () => {
114
104
  await store.supersede(args.page, args.by, args.reason);
115
105
  return `${args.page} is now superseded by ${args.by}. Repoint the links that should go to the new page.`;
116
106
  }));
117
- 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 () => {
107
+ const retire = tool("knowledge_retire", "Retire a page whose knowledge was wrong (e.g. learned from a wrong original). It asks the person itself, with a panel: don't ask them first. Retired, 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 language you reply in") }, async (args) => attempt(async () => {
118
108
  const page = await store.read(args.page);
119
109
  if (!page)
120
110
  throw new Error(`There's no page "${args.page}".`);
@@ -134,32 +124,7 @@ export function createKnowledgeServer(store, options) {
134
124
  await store.log(args.operation, args.what, args.pages ?? []);
135
125
  return "Logged.";
136
126
  }));
137
- 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 () => {
138
- const report = { ...(await store.check()) };
139
- if (options.sourcesDir) {
140
- const sources = await listSources(options.sourcesDir, options.knowledgeDir, options.projectDir ?? path.dirname(options.sourcesDir));
141
- report.newOriginals = sources.filter((s) => s.status === "new").map((s) => s.path);
142
- report.changedOriginals = sources.filter((s) => s.status === "changed").map((s) => ({ path: s.path, summaries: s.summaries }));
143
- report.missingFromSources = sources.filter((s) => s.status === "missing").map((s) => s.path);
144
- const retired = (await loadManifest(options.sourcesDir)).retired;
145
- if (retired.length) {
146
- const citations = [];
147
- for (const info of await store.list()) {
148
- const page = await store.read(info.id);
149
- for (const original of retired) {
150
- const name = path.basename(original.path);
151
- for (const line of (page?.content ?? "").split(/\r?\n/))
152
- if (line.includes(name))
153
- citations.push({ original: original.path, page: info.id, passage: line.trim() });
154
- if (page?.fields.file?.endsWith(name) && info.status === "active")
155
- citations.push({ original: original.path, page: info.id, passage: `file: ${page.fields.file}` });
156
- }
157
- }
158
- report.citesRetiredOriginals = citations;
159
- }
160
- }
161
- return JSON.stringify(report, null, 2);
162
- }), { annotations: { readOnlyHint: true } });
127
+ const check = tool("knowledge_check", "The knowledge base's mechanical problems in one call: broken links, orphan pages and links to retired pages. Fix what's mechanical; report what needs the person. Whether its summaries are up to date with their originals is a comparison you make: list_sources' changedAt against each summary's ingested (knowledge_index shows them): both ISO 8601 in UTC, the later one sorts after as text.", {}, async () => attempt(async () => JSON.stringify(await store.check(), null, 2)), { annotations: { readOnlyHint: true } });
163
128
  return createSdkMcpServer({
164
129
  name: "knowledge",
165
130
  version: "1.0.0",
@@ -8,10 +8,14 @@ export declare const MAX_SOURCE_BYTES: number;
8
8
  export declare function htmlToMarkdown(html: string, url: string): Promise<string | null>;
9
9
  /** Downloads `url` into `file`, refusing other schemes and anything over `maxBytes`; returns its content type. */
10
10
  export declare function downloadToFile(url: string, file: string, maxBytes: number): Promise<string>;
11
+ /**
12
+ * The "Sources" section appended to the system prompt whenever there's a sources folder, with or
13
+ * without a knowledge base: what the originals are and how they come and go. `projectDir` and
14
+ * `sourcesDir` give the folder's name as the model reads it.
15
+ */
16
+ export declare function sourcesPromptSection(projectDir: string, sourcesDir: string): string;
11
17
  /** Options of `createSaveToSourcesServer()`. */
12
18
  export interface SourceToolsOptions {
13
- /** The knowledge folder, to tell which originals have a summary page (`list_sources`). */
14
- knowledgeDir?: string;
15
19
  /** The project root: paths in the tools' answers are relative to it. Defaults to the parent of `sourcesDir`. */
16
20
  projectDir?: string;
17
21
  /** A person can be asked (not autonomous): adds `request_file` and `retire_source`, which ask first. */
@@ -104,6 +104,16 @@ export async function downloadToFile(url, file, maxBytes) {
104
104
  await writeFile(file, Buffer.concat(chunks));
105
105
  return response.headers.get("content-type") ?? "";
106
106
  }
107
+ /**
108
+ * The "Sources" section appended to the system prompt whenever there's a sources folder, with or
109
+ * without a knowledge base: what the originals are and how they come and go. `projectDir` and
110
+ * `sourcesDir` give the folder's name as the model reads it.
111
+ */
112
+ export function sourcesPromptSection(projectDir, sourcesDir) {
113
+ const folder = `${path.relative(projectDir, sourcesDir).split(path.sep).join("/") || "."}/`;
114
+ return `## Sources
115
+ - **Originals (read-only for you)**: \`${folder}\`. Never rewrite one; build on them. \`list_sources\` lists them with when each one's content last changed (\`changedAt\`), and the missing and retired ones; add a file only with \`save_to_sources\`, \`download_to_sources\` or by asking the person (\`request_file\`), and take out a wrong or superseded one only with \`retire_source\`. \`Read\` reads PDFs and images; \`extract_text\` reads DOCX, PPTX (with speaker notes) and XLSX.`;
116
+ }
107
117
  /**
108
118
  * The tools that keep `sourcesDir`, the originals the agent reads but never edits (Write/Edit
109
119
  * are scoped to the notes folder, see hooks/fileScopeGate.ts): `save_to_sources`,
@@ -149,9 +159,9 @@ export function createSaveToSourcesServer(runDir, sourcesDir, description = DEFA
149
159
  return fail(message(error));
150
160
  }
151
161
  });
152
- 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 () => {
162
+ const listSourcesTool = tool("list_sources", "List the originals in sources/: present, missing (deleted by hand) or retired (when, why, what replaced it); with changedAt (when its content last changed, ISO 8601 in UTC: a later date sorts after as text, to compare with when something was built from it), type, size, pages of a PDF or slides of a PPTX (read long ones in parts), provenance and the version it replaces. Use it instead of listing the folder.", {}, async () => {
153
163
  try {
154
- return ok(JSON.stringify(await listSources(sourcesDir, options.knowledgeDir, projectDir), null, 2));
164
+ return ok(JSON.stringify(await listSources(sourcesDir), null, 2));
155
165
  }
156
166
  catch (error) {
157
167
  return fail(message(error));
@@ -215,7 +225,7 @@ export function createSaveToSourcesServer(runDir, sourcesDir, description = DEFA
215
225
  }
216
226
  });
217
227
  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.", {
218
- description: z.string().describe("What file you need and why, in one or two sentences, in the person's language"),
228
+ description: z.string().describe("What file you need and why, in one or two sentences, in the language you reply in"),
219
229
  destination: z.string().optional().describe("Path to save it under, relative to sources/; the file's own name if omitted"),
220
230
  replaces,
221
231
  }, async (args) => {
@@ -235,23 +245,18 @@ export function createSaveToSourcesServer(runDir, sourcesDir, description = DEFA
235
245
  return fail(message(error));
236
246
  }
237
247
  });
238
- 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.', {
248
+ const retire = tool("retire_source", 'Retire an original that was wrong ("wrong") or replaced by a better one ("replaced"). It asks the person itself, with a panel: don\'t ask them first. Approved, it\'s moved to sources/.agent-kit/retired/ (never deleted), and list_sources shows it as retired. What you built from it is yours to review afterwards.', {
239
249
  source: z.string().describe("The original, relative to sources/"),
240
250
  why: z.enum(["wrong", "replaced"]),
241
- reason: z.string().describe("Why, in one sentence, in the person's language"),
251
+ reason: z.string().describe("Why, in one sentence, in the language you reply in"),
242
252
  replacedBy: z.string().optional().describe('For "replaced": the original that replaces it, relative to sources/'),
243
253
  }, async (args) => {
244
254
  const answer = await askForDecision(runDir, { title: t().retireTitle, lines: t().retireLines(inFolder(args.source), args.why, args.reason, args.replacedBy && inFolder(args.replacedBy), folder) });
245
255
  if (answer !== "" && answer !== "y" && answer !== "yes")
246
256
  return ok(answer === "q" ? "The person stopped: don't retire it, and stop what you were doing." : "The person said no: the original stays.");
247
257
  try {
248
- const result = await retireSource(sourcesDir, options.knowledgeDir, projectDir, inFolder(args.source), args.why, args.reason, args.replacedBy && inFolder(args.replacedBy));
249
- const pages = result.summaries.map((s) => `${s.page} marked ${s.marked}${s.supersededBy ? ` (see ${s.supersededBy})` : ""}`);
250
- return ok([
251
- `Retired: moved to sources/${result.movedTo}.`,
252
- ...(pages.length ? pages : ["It had no summary page."]),
253
- "Now update index.md, and correct or re-ground the pages that cite it (search the knowledge base for its file name).",
254
- ].join("\n"));
258
+ const result = await retireSource(sourcesDir, inFolder(args.source), args.why, args.reason, args.replacedBy && inFolder(args.replacedBy));
259
+ return ok(`Retired: moved to sources/${result.movedTo}. If anything was built from it (a summary, notes that cite it), review it now: ${args.why === "wrong" ? "what it taught was wrong" : `it was replaced${args.replacedBy ? ` by sources/${inFolder(args.replacedBy)}` : ""}`}.`);
255
260
  }
256
261
  catch (error) {
257
262
  return fail(message(error));
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { createPromptLoader } from "./core/promptTemplate.js";
6
6
  export { resolveClaudeAuth, type ClaudeAuthConfig } from "./core/claudeAuth.js";
7
7
  export { ensureClaudeAuth } from "./tui/claudeAuth.js";
8
8
  export { createFriendlyToolLabel, truncate, truncatePath, type ToolDescriber, } from "./core/toolLabels.js";
9
- export type { Mode, BaseSessionConfig, AgentSpec, PlanModeSpec } from "./core/agentSpec.js";
9
+ export type { Mode, BaseSessionConfig, AgentSpec, AgentIdentity, PlanModeSpec } from "./core/agentSpec.js";
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";
@@ -31,7 +31,7 @@ export { setInteractionPort, getInteractionPort, type InteractionPort, type Appr
31
31
  export { terminalInteractionPort, setSharedReadline, getSharedReadline } from "./tui/terminalInteraction.js";
32
32
  export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS, type HumanApprovalTexts } from "./core/tools/humanApproval.js";
33
33
  export { createManualLoginServer, type ManualInterventionTexts } from "./core/tools/manualLogin.js";
34
- export { createSaveToSourcesServer, type SourceToolsOptions } from "./core/tools/saveToSources.js";
34
+ export { createSaveToSourcesServer, sourcesPromptSection, type SourceToolsOptions } from "./core/tools/saveToSources.js";
35
35
  export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
36
36
  export type { SessionOpener } from "./tui/runs.js";
37
37
  export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ export { terminalInteractionPort, setSharedReadline, getSharedReadline } from ".
36
36
  setInteractionPort(terminalInteractionPort);
37
37
  export { createHumanApprovalServer, DEFAULT_HUMAN_APPROVAL_TEXTS } from "./core/tools/humanApproval.js";
38
38
  export { createManualLoginServer } from "./core/tools/manualLogin.js";
39
- export { createSaveToSourcesServer } from "./core/tools/saveToSources.js";
39
+ export { createSaveToSourcesServer, sourcesPromptSection } from "./core/tools/saveToSources.js";
40
40
  export { runChatTui } from "./tui/chatTui.js";
41
41
  export { createConsoleRenderer } from "./tui/consoleRenderer.js";
42
42
  export { runChatInk } from "./tui/ink/runChatInk.js";
@@ -19,6 +19,7 @@ export declare function previewLines(lines: readonly string[], terminalRows: num
19
19
  export declare function statusText(mode: Mode | undefined, turns: number, usage: SessionUsage | null, extra?: {
20
20
  contextPercent?: number | null;
21
21
  modeSwitchable?: boolean;
22
+ width?: number;
22
23
  }): string;
23
24
  export interface SessionViewProps {
24
25
  model: SessionModel;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import wrapAnsi from "wrap-ansi";
3
+ import stringWidth from "string-width";
3
4
  import { useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react";
4
5
  import { Box, measureElement, Static, Text, useInput, useStdout } from "ink";
5
6
  import { MultiSelect, Select, TextInput } from "@inkjs/ui";
@@ -86,13 +87,24 @@ function formatTokens(count) {
86
87
  return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
87
88
  }
88
89
  export function statusText(mode, turns, usage, extra = {}) {
89
- const parts = [
90
+ const text = (withCached) => statusParts(mode, turns, usage, extra, withCached).join(" · ");
91
+ const full = text(true);
92
+ // The cached part goes first when the terminal is too narrow for it.
93
+ return extra.width !== undefined && stringWidth(full) > extra.width ? text(false) : full;
94
+ }
95
+ function statusParts(mode, turns, usage, extra, withCached) {
96
+ return [
90
97
  ...(mode ? [`⏵⏵ ${t().mode(mode)}${extra.modeSwitchable ? ` (${t().switchModeKey})` : ""}`] : []),
91
98
  t().turns(turns),
92
- ...(usage ? [t().tokens(formatTokens(usage.inputTokens), formatTokens(usage.outputTokens))] : []),
99
+ // The new input apart from what was read again from the cache: the total grows with every
100
+ // call (each one sends the whole context again), which looked like the context growing.
101
+ ...(usage
102
+ ? [
103
+ t().tokens(formatTokens(usage.inputTokens - (usage.cacheReadTokens ?? 0)), formatTokens(usage.outputTokens), withCached && usage.cacheReadTokens ? formatTokens(usage.cacheReadTokens) : undefined),
104
+ ]
105
+ : []),
93
106
  ...(extra.contextPercent != null ? [t().context(Math.round(extra.contextPercent))] : []),
94
107
  ];
95
- return parts.join(" · ");
96
108
  }
97
109
  // No emoji-capable characters: Windows Terminal draws ✳ (U+2733) as a green emoji.
98
110
  const WORKING_GLYPHS = ["·", "✢", "✱", "✶", "✻", "✽", "✻", "✶", "✱", "✢"];
@@ -110,7 +122,8 @@ function Working({ label, startedAt, width }) {
110
122
  /** Line in progress, checkpoint panel or spinner, the input, and the status bar. */
111
123
  function LiveArea({ session, checkpoint, renderApproval, mode, width, statusExtra, modeSwitchable, reservedRows, children }) {
112
124
  // The mode in the spinner's color; the rest dim.
113
- const plain = statusText(mode, session.turns, session.usage, { contextPercent: session.contextPercent, modeSwitchable }) + (statusExtra ? ` · ${statusExtra}` : "");
125
+ const extraText = statusExtra ? ` · ${statusExtra}` : "";
126
+ const plain = statusText(mode, session.turns, session.usage, { contextPercent: session.contextPercent, modeSwitchable, width: width - stringWidth(extraText) }) + extraText;
114
127
  const cut = mode ? plain.indexOf(" · ") : -1;
115
128
  const status = cut > 0 ? ui.working(plain.slice(0, cut)) + ui.dim(plain.slice(cut)) : ui.dim(plain);
116
129
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, marginTop: session.liveGap ? 1 : 0, children: [stripAnsi(session.live) ? _jsx(Text, { children: session.live }) : null, checkpoint ? (_jsx(CheckpointPanel, { checkpoint: checkpoint, renderApproval: renderApproval, reservedRows: reservedRows, width: width })) : session.busy ? (
@@ -74,6 +74,13 @@ export interface InkChatOptions extends ChatTuiOptions {
74
74
  */
75
75
  firstPromptSuggestion?: string;
76
76
  }
77
+ /** The chat's own commands, never sent to the model (the agent-help skill lists them; a test keeps both in step). */
78
+ export declare const DEFAULT_EXIT_COMMANDS: readonly string[];
79
+ export declare const LOCAL_COMMANDS: {
80
+ readonly copy: "/copy";
81
+ readonly resume: "/resume";
82
+ readonly plan: "/plan";
83
+ };
77
84
  /** A choice asked in place of the prompt (the /resume list). */
78
85
  export interface ChatPicker {
79
86
  title: string;
@@ -27,7 +27,9 @@ import { createTerminalStatus, focusFromReport } from "./terminalStatus.js";
27
27
  import { listProjectFiles } from "./fileMentions.js";
28
28
  import { headerLines } from "./header.js";
29
29
  const DEFAULT_PROMPT_LABEL = "\n> ";
30
- const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
30
+ /** The chat's own commands, never sent to the model (the agent-help skill lists them; a test keeps both in step). */
31
+ export const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
32
+ export const LOCAL_COMMANDS = { copy: "/copy", resume: "/resume", plan: "/plan" };
31
33
  const DEFAULT_HISTORY_LIMIT = 100;
32
34
  // The prompt's frame: a border and one column of padding on each side.
33
35
  const PROMPT_FRAME_COLUMNS = 4;
@@ -382,19 +384,19 @@ export async function runChatInk(options, tuiOptions = {}) {
382
384
  if (exitCommands.has(line.toLowerCase()))
383
385
  break;
384
386
  // A local command, never sent to the model: the last reply to the clipboard (OSC 52).
385
- if (status && line.toLowerCase() === "/copy") {
387
+ if (status && line.toLowerCase() === LOCAL_COMMANDS.copy) {
386
388
  const reply = model.lastReply();
387
389
  if (reply)
388
390
  stdout.write(clipboardSequence(reply));
389
391
  model.writeLine(ui.dim(reply ? t().copiedReply(reply.length) : t().nothingToCopy));
390
392
  continue;
391
393
  }
392
- if (runsDir && line.toLowerCase() === "/resume") {
394
+ if (runsDir && line.toLowerCase() === LOCAL_COMMANDS.resume) {
393
395
  await resume();
394
396
  continue;
395
397
  }
396
398
  // A local command too: into plan mode, or back to the mode it was entered from.
397
- if (line.toLowerCase() === "/plan") {
399
+ if (line.toLowerCase() === LOCAL_COMMANDS.plan) {
398
400
  const control = modeControl();
399
401
  const next = control ? togglePlanMode(control) : null;
400
402
  if (next)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",
@@ -1,4 +0,0 @@
1
- {
2
- "name": "knowledge",
3
- "description": "Built-in knowledge base workflows (an LLM wiki): page templates, ingesting sources, querying the knowledge base and health-checking it."
4
- }
@@ -1,7 +0,0 @@
1
- ---
2
- description: Ingest material into the knowledge base right now - the files or topic given, or everything in sources/ not ingested yet
3
- ---
4
-
5
- Apply the `knowledge-ingest` skill right now to: $ARGUMENTS
6
-
7
- If nothing is given above, ingest every original `list_sources` reports as new, and update the summaries of the ones it reports as changed. When you're done, summarize which pages you created and updated, and anything you couldn't read.
@@ -1,5 +0,0 @@
1
- ---
2
- description: Health-check the knowledge base right now - broken links, orphans, duplicates, contradictions, gaps
3
- ---
4
-
5
- Apply the `knowledge-lint` skill right now over the whole knowledge base. Fix what's mechanical (links, index, missing back-links) and report the rest: what needs the human's decision, and the gaps worth filling next.
@@ -1,5 +0,0 @@
1
- ---
2
- description: Answer a question from the knowledge base, and file the answer back if it is worth keeping
3
- ---
4
-
5
- Apply the `knowledge-query` skill right now to answer: $ARGUMENTS
@@ -1,46 +0,0 @@
1
- ---
2
- name: knowledge-ingest
3
- description: Ingest one source into the knowledge base - a file in sources/, a document, a page - creating its summary page, creating or updating the concept and entity pages it touches, linking everything both ways, and updating index.md and log.md.
4
- ---
5
-
6
- # Ingesting a source into the knowledge base
7
-
8
- A source is ingested once, and from then on the knowledge base holds what it taught: later sessions read the pages, not the original again. Ingesting is not summarizing a file in isolation - its value is in how it connects to what the knowledge base already knows. Load the `knowledge-pages` skill first for the page templates.
9
-
10
- ## 1. Check it isn't already ingested
11
-
12
- `list_sources` gives each original's status and summary pages: *new* needs ingesting; *ingested* has a page; *changed* changed after its ingest. For a document from elsewhere, look for it in `index.md` (Summaries) or `Grep` its URL inside the knowledge base. If it already has a page:
13
- - unchanged source: nothing to do;
14
- - the source changed or the page is thin: update that page instead of creating another.
15
-
16
- ## 2. Read it for real
17
-
18
- Read the whole source, not its first page (a long PDF in parts: `list_sources` gives its pages). If it exists only outside the sources folder, keep the original first: `download_to_sources` for a URL (a web page is kept as markdown too: read that one), `save_to_sources` for a file in the run's folder, `request_file` to ask the person for one. `Read` reads PDFs and images; read DOCX, PPTX (with its speaker notes) and XLSX with `extract_text`.
19
-
20
- ## 3. Write the summary page
21
-
22
- Create `summaries/<slug>.md` with the template: summary, key points (literal wording where exactness matters), frontmatter `file`/`url` pointing to the original.
23
-
24
- ## 4. Update concepts and entities
25
-
26
- For every concept or entity the source explains or relies on:
27
- - no page yet: create it;
28
- - page exists: add what is new (a better definition, an example, a nuance, a fact) with `Edit`, and link the summary under "Sources".
29
-
30
- Check first whether it exists under another name (`Grep` the knowledge base for the term and look at `aliases`): a duplicate page is worse than none.
31
-
32
- ## 5. Link both ways and look for conflicts
33
-
34
- Every link you added from page A to page B needs its counterpart in B. If the source contradicts an existing page, record both versions with attribution in both pages - don't overwrite.
35
-
36
- ## 6. Close
37
-
38
- - `index.md`: new pages, and one-line descriptions that changed.
39
- - `log.md`: `## [date] ingest | <source title>` with the pages created and updated.
40
- - `overview.md`: only if the source changes the big picture.
41
-
42
- When ingesting several sources in a row, do steps 1-5 for each and close once at the end.
43
-
44
- ## An original that was wrong or replaced
45
-
46
- `retire_source` (the person approves it) moves it aside and marks its summary retired (wrong) or superseded (replaced by a better one). Then update `index.md`, and fix the pages that cite it (`Grep` its file name): after a wrong one, correct or remove what it taught; after a replaced one, ingest the new original and re-ground those passages on it.
@@ -1,26 +0,0 @@
1
- ---
2
- name: knowledge-lint
3
- description: Health-check the knowledge base - broken links, pages missing from index.md, orphan pages, one-way links, duplicated concepts, mentioned-but-missing concepts, contradictions, gaps - fix what's mechanical and report the rest.
4
- ---
5
-
6
- # Linting the knowledge base
7
-
8
- A knowledge base maintained across sessions drifts: links break, pages go unlisted, the same concept appears under two names. Lint it when asked and after a large ingest. Load `knowledge-pages` first for the templates.
9
-
10
- ## Checks
11
-
12
- Use `Glob` to list pages and `Grep` (always with `path` inside the knowledge base) to find links, instead of reading every page in full.
13
-
14
- 1. **Broken links**: a relative link whose target file doesn't exist. Fix it if the right target is obvious (a typo, a page under a close name); otherwise report it.
15
- 2. **Index**: every page is listed in `index.md` and every entry there points to an existing page. Fix it.
16
- 3. **Orphans**: pages nothing links to (`Grep` for the file name). Link them from where they belong, or report why they don't belong anywhere.
17
- 4. **One-way links**: a summary that feeds a concept which doesn't list it back... Add the missing side.
18
- 5. **Duplicates**: two pages for the same idea under different names. Merge the content into one, mark the other as superseded (never delete it) and repoint links.
19
- 6. **Missing pages**: terms that several pages treat as concepts or entities but have no page. Create them if the material is there; otherwise list them.
20
- 7. **Contradictions**: claims that disagree across pages without being recorded as such. Record both sides, attributed.
21
- 8. **Provenance**: claims with no traceable source. Add the link, or mark them as unsourced.
22
- 9. **Gaps**: originals `list_sources` reports as new (no summary yet), changed (their summary may be stale) or missing (deleted by hand: their summaries point nowhere), and concepts mentioned but never explained by any source. Report them - they are what to ingest, update or find next.
23
-
24
- ## Close
25
-
26
- Append `## [date] lint | <summary>` to `log.md` listing what was fixed. Then report to the human: what you fixed, and what needs their decision or more material. Don't make judgement calls silently - merging pages that are only similar, or choosing between contradictory sources, goes in the report.