@falkenslab/agent-kit 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/assets/agent-help/SKILL.md +1 -1
  2. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  3. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +8 -3
  4. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +12 -5
  5. package/dist/core/agentSpec.d.ts +16 -16
  6. package/dist/core/fileKnowledgeStore.d.ts +1 -3
  7. package/dist/core/fileKnowledgeStore.js +23 -29
  8. package/dist/core/hooks/fileScopeGate.d.ts +17 -2
  9. package/dist/core/hooks/fileScopeGate.js +45 -10
  10. package/dist/core/knowledge.d.ts +20 -17
  11. package/dist/core/knowledge.js +32 -60
  12. package/dist/core/knowledgeStore.d.ts +4 -9
  13. package/dist/core/knowledgeStore.js +16 -2
  14. package/dist/core/language.js +1 -1
  15. package/dist/core/messages/de.js +1 -1
  16. package/dist/core/messages/en.js +1 -1
  17. package/dist/core/messages/es.js +1 -1
  18. package/dist/core/messages/fr.js +1 -1
  19. package/dist/core/session.d.ts +12 -0
  20. package/dist/core/session.js +88 -33
  21. package/dist/core/sources.d.ts +24 -31
  22. package/dist/core/sources.js +45 -108
  23. package/dist/core/tools/humanApproval.js +3 -3
  24. package/dist/core/tools/knowledgeTools.d.ts +6 -6
  25. package/dist/core/tools/knowledgeTools.js +18 -53
  26. package/dist/core/tools/saveToSources.d.ts +6 -2
  27. package/dist/core/tools/saveToSources.js +17 -12
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/package.json +1 -1
  31. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
  32. package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
  33. package/assets/knowledge-plugin-files/commands/lint.md +0 -5
  34. package/assets/knowledge-plugin-files/commands/query.md +0 -5
  35. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
  36. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
  37. package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
  38. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
@@ -5,10 +5,11 @@ import path from "node:path";
5
5
  /**
6
6
  * The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
7
7
  * manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
8
- * file tools can't write) with each original's hash, size and provenance, the version it
9
- * replaces, and the hash it had when a summary page first pointed to it. From that it tells
10
- * new, ingested, changed and missing originals apart, refuses duplicates, and retires an
11
- * original into `.agent-kit/retired/` instead of deleting it.
8
+ * file tools can't write) with each original's hash, size, provenance, the version it replaces
9
+ * and when its content last changed. From that it refuses duplicates, tells present, missing
10
+ * and retired originals apart, and retires an original into `.agent-kit/retired/` instead of
11
+ * deleting it. It knows nothing of the knowledge base: each owns its data, and the model
12
+ * connects them through their tools (#30). Hashes never leave it.
12
13
  */
13
14
  export const KIT_DIR = ".agent-kit";
14
15
  const MANIFEST = "sources.json";
@@ -70,41 +71,20 @@ export async function scanSources(sourcesDir) {
70
71
  await walk(sourcesDir);
71
72
  return found.sort();
72
73
  }
73
- /** An entry for `file`, reusing the recorded hash when its size and modification time haven't changed. */
74
+ /**
75
+ * An entry for `file`, reusing the recorded hash when its size and modification time haven't
76
+ * changed; `changedAt` moves only when the content (its hash) did. Entries from before
77
+ * `changedAt` existed take their `addedAt`.
78
+ */
74
79
  async function freshEntry(file, known, origin) {
75
80
  const info = await stat(file);
76
- if (known && known.size === info.size && known.mtimeMs === info.mtimeMs)
77
- return known;
78
- return { ...(known ?? { addedAt: now(), origin }), hash: await hashFile(file), size: info.size, mtimeMs: info.mtimeMs };
79
- }
80
- /** The original a frontmatter `file:` value points to (a relative path or a markdown link), resolved from the page's folder. */
81
- function linkedFile(value, pageDir) {
82
- const link = /\]\(([^)]+)\)/.exec(value)?.[1] ?? value.trim().replace(/^["']|["']$/g, "");
83
- if (!link || /^[a-z]+:\/\//i.test(link))
84
- return undefined;
85
- return path.resolve(pageDir, decodeURI(link));
86
- }
87
- /** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
88
- export async function summariesByOriginal(knowledgeDir) {
89
- const byOriginal = new Map();
90
- const dir = path.join(knowledgeDir, "summaries");
91
- let names;
92
- try {
93
- names = await readdir(dir);
94
- }
95
- catch {
96
- return byOriginal;
97
- }
98
- for (const name of names.filter((n) => n.endsWith(".md"))) {
99
- const page = path.join(dir, name);
100
- const text = await readFile(page, "utf8");
101
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
102
- const value = /^file:\s*(.+)$/m.exec(frontmatter)?.[1];
103
- const original = value ? linkedFile(value, dir) : undefined;
104
- if (original)
105
- byOriginal.set(path.normalize(original), [...(byOriginal.get(path.normalize(original)) ?? []), page]);
106
- }
107
- return byOriginal;
81
+ const previous = known ? { ...known, changedAt: known.changedAt ?? known.addedAt } : undefined;
82
+ if (previous && previous.size === info.size && previous.mtimeMs === info.mtimeMs)
83
+ return previous;
84
+ const hash = await hashFile(file);
85
+ const stamp = now();
86
+ const base = previous ?? { addedAt: stamp, origin, changedAt: stamp };
87
+ return { ...base, hash, size: info.size, mtimeMs: info.mtimeMs, changedAt: previous && previous.hash === hash ? previous.changedAt : stamp };
108
88
  }
109
89
  /** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
110
90
  export function pdfPageCount(bytes) {
@@ -124,31 +104,19 @@ export function pptxSlideCount(bytes) {
124
104
  }
125
105
  const typeOf = (file) => path.extname(file).slice(1).toLowerCase() || "file";
126
106
  /**
127
- * Every original with its status, recording in the manifest the ones copied in by hand and,
128
- * the first time a summary page points to one, its hash at ingest. Without a knowledge
129
- * folder there's no ingest to compare with: originals are "present".
107
+ * Every original with its status and when its content last changed, recording in the manifest
108
+ * the ones copied in by hand; then the missing ones and the retired ones.
130
109
  */
131
- export function listSources(sourcesDir, knowledgeDir, projectDir) {
132
- return serialized(sourcesDir, () => listSourcesNow(sourcesDir, knowledgeDir, projectDir));
110
+ export function listSources(sourcesDir) {
111
+ return serialized(sourcesDir, () => listSourcesNow(sourcesDir));
133
112
  }
134
- async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
113
+ async function listSourcesNow(sourcesDir) {
135
114
  const manifest = await loadManifest(sourcesDir);
136
- const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
137
115
  const files = await scanSources(sourcesDir);
138
116
  const listing = [];
139
117
  for (const rel of files) {
140
118
  const full = path.join(sourcesDir, rel);
141
119
  const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
142
- const pagesOf = summaries.get(path.normalize(full)) ?? [];
143
- let status = "present";
144
- if (knowledgeDir) {
145
- if (pagesOf.length === 0)
146
- status = "new";
147
- else {
148
- entry.ingestedHash ??= entry.hash;
149
- status = entry.ingestedHash === entry.hash ? "ingested" : "changed";
150
- }
151
- }
152
120
  manifest.files[rel] = entry;
153
121
  const type = typeOf(rel);
154
122
  const counts = type === "pdf" || type === "pptx"
@@ -156,18 +124,28 @@ async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
156
124
  : {};
157
125
  listing.push({
158
126
  path: rel,
159
- status,
127
+ status: "present",
160
128
  type,
129
+ changedAt: entry.changedAt,
161
130
  size: entry.size,
162
131
  ...counts,
163
132
  origin: entry.origin,
164
133
  ...(entry.replaces ? { replaces: entry.replaces } : {}),
165
- summaries: pagesOf.map((page) => posix(path.relative(projectDir, page))),
166
134
  });
167
135
  }
168
136
  for (const [rel, entry] of Object.entries(manifest.files)) {
169
137
  if (!files.includes(rel))
170
- listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin, summaries: [] });
138
+ listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin });
139
+ }
140
+ for (const retired of manifest.retired) {
141
+ listing.push({
142
+ path: retired.path,
143
+ status: "retired",
144
+ type: typeOf(retired.path),
145
+ retiredAt: retired.retiredAt,
146
+ why: retired.why,
147
+ ...(retired.replacedBy ? { replacedBy: retired.replacedBy } : {}),
148
+ });
171
149
  }
172
150
  await saveManifest(sourcesDir, manifest);
173
151
  return listing;
@@ -219,11 +197,13 @@ async function addSourceNow(sourcesDir, file, destination, origin, extra) {
219
197
  }
220
198
  const rel = posix(path.relative(sourcesDir, target));
221
199
  const info = await stat(target);
200
+ const stamp = now();
222
201
  manifest.files[rel] = {
223
202
  hash,
224
203
  size: info.size,
225
204
  mtimeMs: info.mtimeMs,
226
- addedAt: now(),
205
+ addedAt: stamp,
206
+ changedAt: stamp,
227
207
  origin,
228
208
  ...(extra.replaces ? { replaces: posix(path.relative(sourcesDir, resolveWithin(sourcesDir, extra.replaces))) } : {}),
229
209
  ...(extra.derivedFrom ? { derivedFrom: extra.derivedFrom } : {}),
@@ -231,26 +211,15 @@ async function addSourceNow(sourcesDir, file, destination, origin, extra) {
231
211
  await saveManifest(sourcesDir, manifest);
232
212
  return { path: rel };
233
213
  }
234
- /** Adds frontmatter fields and a notice under the frontmatter of a markdown page. */
235
- async function markPage(page, fields, notice) {
236
- const text = await readFile(page, "utf8");
237
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
238
- const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
239
- const updated = match
240
- ? `---\n${match[1].split(/\r?\n/).filter((line) => !Object.keys(fields).some((key) => line.startsWith(`${key}:`))).concat(lines).join("\n")}\n---\n\n> ${notice}\n\n${text.slice(match[0].length).replace(/^\s+/, "")}`
241
- : `---\n${lines.join("\n")}\n---\n\n> ${notice}\n\n${text}`;
242
- await writeFile(page, updated);
243
- }
244
214
  /**
245
- * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
246
- * and records why. Its summary pages are marked: retired if the original was wrong (what it
247
- * taught was wrong), superseded if it was replaced, pointing to the replacement's summary
248
- * when there is one.
215
+ * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder) and
216
+ * records why. What was built from it (summary pages) is the knowledge base's to update: the
217
+ * model does it with its tools.
249
218
  */
250
- export function retireSource(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
251
- return serialized(sourcesDir, () => retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy));
219
+ export function retireSource(sourcesDir, source, why, reason, replacedBy) {
220
+ return serialized(sourcesDir, () => retireSourceNow(sourcesDir, source, why, reason, replacedBy));
252
221
  }
253
- async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
222
+ async function retireSourceNow(sourcesDir, source, why, reason, replacedBy) {
254
223
  const full = resolveWithin(sourcesDir, source);
255
224
  if (!full || !(await stat(full).catch(() => null))?.isFile())
256
225
  throw new Error(`"${source}" isn't an original in sources/.`);
@@ -260,7 +229,6 @@ async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why
260
229
  const replacement = replacedBy ? resolveWithin(sourcesDir, replacedBy) : undefined;
261
230
  if (replacedBy && (!replacement || !(await stat(replacement).catch(() => null))))
262
231
  throw new Error(`"${replacedBy}" isn't an original in sources/.`);
263
- const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
264
232
  const manifest = await loadManifest(sourcesDir);
265
233
  const stamp = now();
266
234
  const movedTo = posix(path.join(KIT_DIR, RETIRED_DIR, `${stamp.replace(/[:.]/g, "-")}-${path.basename(rel)}`));
@@ -277,36 +245,5 @@ async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why
277
245
  });
278
246
  delete manifest.files[rel];
279
247
  await saveManifest(sourcesDir, manifest);
280
- const date = stamp.slice(0, 10);
281
- const replacementSummary = replacement ? summaries.get(path.normalize(replacement))?.[0] : undefined;
282
- const result = { movedTo, summaries: [] };
283
- for (const page of summaries.get(path.normalize(full)) ?? []) {
284
- if (why === "wrong") {
285
- await markPage(page, { status: "retired", retired: date }, `Retired on ${date}: its original was wrong (${reason}). Don't rely on what it says.`);
286
- result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "retired" });
287
- }
288
- else {
289
- const link = replacementSummary ? posix(path.relative(path.dirname(page), replacementSummary)) : undefined;
290
- await markPage(page, { status: "superseded", superseded: date, ...(link ? { superseded_by: link } : {}) }, `Superseded on ${date}: its original was replaced (${reason})${link ? `; see [the new summary](${link})` : ""}.`);
291
- result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "superseded", ...(replacementSummary ? { supersededBy: posix(path.relative(projectDir, replacementSummary)) } : {}) });
292
- }
293
- }
294
- return result;
295
- }
296
- /**
297
- * Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
298
- * summary page was just written from it, so "changed" means changed after this.
299
- */
300
- export function recordIngest(sourcesDir, source) {
301
- return serialized(sourcesDir, async () => {
302
- const full = resolveWithin(sourcesDir, source);
303
- if (!full || !(await stat(full).catch(() => null))?.isFile())
304
- return;
305
- const rel = posix(path.relative(sourcesDir, full));
306
- const manifest = await loadManifest(sourcesDir);
307
- const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
308
- entry.ingestedHash = entry.hash;
309
- manifest.files[rel] = entry;
310
- await saveManifest(sourcesDir, manifest);
311
- });
248
+ return { movedTo };
312
249
  }
@@ -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
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.17.0",
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.