@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,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Word, PowerPoint and Excel originals as markdown: the SDK's `Read` refuses DOCX and PPTX
5
+ * (confirmed empirically), and course material is often both. The libraries are optional
6
+ * peer dependencies of the kit (`mammoth` for DOCX, `fflate` to open PPTX and XLSX, which
7
+ * are zips of XML); without them the caller is told what to install.
8
+ */
9
+ export const EXTRACTABLE = new Set(["docx", "pptx", "xlsx"]);
10
+ /** The most rows of a sheet written out; the rest are counted. */
11
+ const MAX_SHEET_ROWS = 200;
12
+ export class MissingLibraryError extends Error {
13
+ library;
14
+ constructor(library, options) {
15
+ super(`Reading this format needs the optional "${library}" library: npm install ${library}`, options);
16
+ this.library = library;
17
+ }
18
+ }
19
+ async function load(library) {
20
+ try {
21
+ return (await import(library));
22
+ }
23
+ catch (error) {
24
+ throw new MissingLibraryError(library, { cause: error });
25
+ }
26
+ }
27
+ const decode = (xml) => xml
28
+ .replace(/&lt;/g, "<")
29
+ .replace(/&gt;/g, ">")
30
+ .replace(/&quot;/g, '"')
31
+ .replace(/&apos;/g, "'")
32
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
33
+ .replace(/&amp;/g, "&");
34
+ /** The paragraphs of a DrawingML text body (slides and notes): each `<a:p>` its runs' `<a:t>` text. */
35
+ function paragraphs(xml) {
36
+ return [...xml.matchAll(/<a:p\b[\s\S]*?<\/a:p>/g)]
37
+ .map((p) => [...p[0].matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((t) => decode(t[1])).join(""))
38
+ .map((text) => text.trim())
39
+ .filter(Boolean);
40
+ }
41
+ const text = (files, name) => (files[name] ? new TextDecoder().decode(files[name]) : undefined);
42
+ async function unzip(file) {
43
+ const { unzipSync } = await load("fflate");
44
+ return unzipSync(new Uint8Array(await readFile(file)));
45
+ }
46
+ /** A slide deck: a section per slide (its title, text and speaker notes), from slide `from` to `to` (1-based). */
47
+ async function pptx(file, from, to) {
48
+ const files = await unzip(file);
49
+ const slides = Object.keys(files)
50
+ .map((name) => /^ppt\/slides\/slide(\d+)\.xml$/.exec(name))
51
+ .filter((m) => Boolean(m))
52
+ .map((m) => Number(m[1]))
53
+ .sort((a, b) => a - b);
54
+ const first = Math.max(1, from ?? 1);
55
+ const last = Math.min(slides.length, to ?? slides.length);
56
+ const parts = [`# ${path.basename(file)}`, `${slides.length} slides${first > 1 || last < slides.length ? `; slides ${first} to ${last}` : ""}.`];
57
+ let images = 0;
58
+ for (const n of slides.slice(first - 1, last)) {
59
+ const xml = text(files, `ppt/slides/slide${n}.xml`) ?? "";
60
+ images += (xml.match(/<p:pic\b/g) ?? []).length;
61
+ const [title, ...body] = paragraphs(xml);
62
+ parts.push(`## Slide ${n}${title ? `: ${title}` : ""}`);
63
+ if (body.length)
64
+ parts.push(body.map((line) => `- ${line}`).join("\n"));
65
+ // A slide's notes are linked from its relationships; their slide-number placeholder is left out.
66
+ const rels = text(files, `ppt/slides/_rels/slide${n}.xml.rels`) ?? "";
67
+ const notesTarget = /Target="\.\.\/notesSlides\/(notesSlide\d+\.xml)"/.exec(rels)?.[1];
68
+ const notes = notesTarget ? paragraphs(text(files, `ppt/notesSlides/${notesTarget}`) ?? "").filter((line) => !/^\d+$/.test(line)) : [];
69
+ if (notes.length)
70
+ parts.push(`Speaker notes:\n${notes.map((line) => `> ${line}`).join("\n")}`);
71
+ }
72
+ if (images)
73
+ parts.push(`(${images} images left out.)`);
74
+ return `${parts.join("\n\n")}\n`;
75
+ }
76
+ /** The column index of a cell reference ("C7" → 2). */
77
+ function columnOf(ref) {
78
+ let index = 0;
79
+ for (const letter of /^[A-Z]+/.exec(ref)?.[0] ?? "A")
80
+ index = index * 26 + letter.charCodeAt(0) - 64;
81
+ return index - 1;
82
+ }
83
+ const cell = (value) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
84
+ /** A workbook: a markdown table per sheet, capped in rows. */
85
+ async function xlsx(file) {
86
+ const files = await unzip(file);
87
+ const shared = [...(text(files, "xl/sharedStrings.xml") ?? "").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((si) => [...si[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => decode(t[1])).join(""));
88
+ const workbook = text(files, "xl/workbook.xml") ?? "";
89
+ const names = [...workbook.matchAll(/<sheet\b[^>]*name="([^"]*)"/g)].map((m) => decode(m[1]));
90
+ const sheets = Object.keys(files)
91
+ .map((name) => /^xl\/worksheets\/sheet(\d+)\.xml$/.exec(name))
92
+ .filter((m) => Boolean(m))
93
+ .map((m) => Number(m[1]))
94
+ .sort((a, b) => a - b);
95
+ const parts = [`# ${path.basename(file)}`];
96
+ for (const n of sheets) {
97
+ const xml = text(files, `xl/worksheets/sheet${n}.xml`) ?? "";
98
+ const rows = [...xml.matchAll(/<row\b[\s\S]*?<\/row>/g)].map((row) => {
99
+ const values = [];
100
+ for (const c of row[0].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
101
+ const attrs = c[1];
102
+ const ref = /r="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? "";
103
+ const type = /t="(\w+)"/.exec(attrs)?.[1];
104
+ const raw = /<v>([\s\S]*?)<\/v>/.exec(c[2] ?? "")?.[1] ?? /<t[^>]*>([\s\S]*?)<\/t>/.exec(c[2] ?? "")?.[1] ?? "";
105
+ const value = type === "s" ? (shared[Number(raw)] ?? "") : decode(raw);
106
+ values[ref ? columnOf(ref) : values.length] = value;
107
+ }
108
+ return Array.from(values, (v) => cell(v ?? ""));
109
+ });
110
+ const filled = rows.filter((row) => row.some(Boolean));
111
+ parts.push(`## ${names[n - 1] ?? `Sheet ${n}`}`);
112
+ if (!filled.length) {
113
+ parts.push("(empty)");
114
+ continue;
115
+ }
116
+ const width = Math.max(...filled.map((row) => row.length));
117
+ const pad = (row) => `| ${Array.from({ length: width }, (_, i) => row[i] ?? "").join(" | ")} |`;
118
+ const shown = filled.slice(0, MAX_SHEET_ROWS);
119
+ parts.push([pad(shown[0]), `|${" --- |".repeat(width)}`, ...shown.slice(1).map(pad)].join("\n"));
120
+ if (filled.length > MAX_SHEET_ROWS)
121
+ parts.push(`(${filled.length - MAX_SHEET_ROWS} more rows.)`);
122
+ }
123
+ return `${parts.join("\n\n")}\n`;
124
+ }
125
+ /** A Word document: headings, paragraphs, lists and tables. */
126
+ async function docx(file) {
127
+ const mammoth = await load("mammoth");
128
+ const { value: html } = await mammoth.convertToHtml({ path: file });
129
+ const images = (html.match(/<img\b/g) ?? []).length;
130
+ // Markdown with turndown (also optional); plain text without it.
131
+ const turndown = await load("turndown").catch(() => null);
132
+ const body = turndown
133
+ ? new turndown.default({ headingStyle: "atx" }).turndown(html.replace(/<img\b[^>]*>/g, ""))
134
+ : decode(html.replace(/<\/(p|h\d|li|tr)>/g, "\n").replace(/<[^>]+>/g, "")).trim();
135
+ return `# ${path.basename(file)}\n\n${body}\n${images ? `\n(${images} images left out.)\n` : ""}`;
136
+ }
137
+ /** An original's text as markdown; `from`/`to` pick slides of a deck. */
138
+ export async function extractText(file, from, to) {
139
+ const type = path.extname(file).slice(1).toLowerCase();
140
+ if (type === "docx")
141
+ return docx(file);
142
+ if (type === "pptx")
143
+ return pptx(file, from, to);
144
+ if (type === "xlsx")
145
+ return xlsx(file);
146
+ throw new Error(`extract_text reads DOCX, PPTX and XLSX; for "${path.basename(file)}" use Read (PDFs, images, text).`);
147
+ }
@@ -0,0 +1,10 @@
1
+ import { type KnowledgeStore, type PageType } from "./knowledgeStore.js";
2
+ /** Options of `createFileKnowledgeStore()`. */
3
+ export interface FileKnowledgeStoreOptions {
4
+ /** The agent's own page types, besides the kit's four. */
5
+ pageTypes?: readonly PageType[];
6
+ /** The sources folder: a summary's `file` field is given relative to it. */
7
+ sourcesDir?: string;
8
+ }
9
+ /** A `KnowledgeStore` over the markdown files of `knowledgeDir`. */
10
+ export declare function createFileKnowledgeStore(knowledgeDir: string, options?: FileKnowledgeStoreOptions): KnowledgeStore;
@@ -0,0 +1,378 @@
1
+ import { appendFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BUILT_IN_PAGE_TYPES, isSlug, linkedIds, parseId, } from "./knowledgeStore.js";
4
+ /**
5
+ * The kit's knowledge store over markdown files, in the layout the knowledge base always had
6
+ * (ADR-008): `index.md`, `log.md`, `overview.md` and a folder per page type (`summaries/`,
7
+ * `concepts/`...), or the root for a declared type with `dir: ""`. An existing knowledge base
8
+ * works as is. Links are stored as relative paths, so the files read well on their own, and
9
+ * returned as page ids; the index is rewritten after every change; backlinks are computed.
10
+ */
11
+ const RESERVED = new Set(["index", "log", "overview"]);
12
+ const posix = (p) => p.split(path.sep).join("/");
13
+ const today = () => new Date().toISOString().slice(0, 10);
14
+ function parse(text) {
15
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
16
+ const fields = new Map();
17
+ if (!match)
18
+ return { fields, body: text.replace(/^\s+/, "") };
19
+ for (const line of match[1].split(/\r?\n/)) {
20
+ const kv = /^([A-Za-z_][\w-]*):\s?(.*)$/.exec(line);
21
+ if (kv)
22
+ fields.set(kv[1], kv[2].trim().replace(/^"(.*)"$/, "$1"));
23
+ }
24
+ return { fields, body: text.slice(match[0].length).replace(/^\s+/, "") };
25
+ }
26
+ function serialize(fields, body) {
27
+ const lines = [...fields].map(([key, value]) => `${key}: ${/[:#]/.test(value) && !/^\[.*\]$/.test(value) ? JSON.stringify(value) : value}`);
28
+ return `---\n${lines.join("\n")}\n---\n\n${body.replace(/^\s+/, "").replace(/\s*$/, "")}\n`;
29
+ }
30
+ /** A `KnowledgeStore` over the markdown files of `knowledgeDir`. */
31
+ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
32
+ const declared = options.pageTypes ?? [];
33
+ const types = [...BUILT_IN_PAGE_TYPES.filter((t) => !declared.some((d) => d.type === t.type)), ...declared];
34
+ const byType = new Map(types.map((t) => [t.type, t]));
35
+ const fileOf = (type, slug) => path.join(knowledgeDir, type.dir, `${slug}.md`);
36
+ async function readPage(type, slug) {
37
+ const file = fileOf(type, slug);
38
+ let text;
39
+ try {
40
+ text = await readFile(file, "utf8");
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ const { fields, body } = parse(text);
46
+ // At the root, a page belongs to the type its frontmatter says.
47
+ if (type.dir === "" && fields.get("type") !== type.type)
48
+ return null;
49
+ return { id: `${type.type}/${slug}`, type, slug, file, fields, body };
50
+ }
51
+ async function allPages() {
52
+ const pages = [];
53
+ for (const type of types) {
54
+ let names;
55
+ try {
56
+ names = await readdir(path.join(knowledgeDir, type.dir));
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ for (const name of names.filter((n) => n.endsWith(".md")).sort()) {
62
+ const slug = name.slice(0, -3);
63
+ if (type.dir === "" && RESERVED.has(slug))
64
+ continue;
65
+ const page = await readPage(type, slug);
66
+ if (page)
67
+ pages.push(page);
68
+ }
69
+ }
70
+ return pages;
71
+ }
72
+ async function find(id) {
73
+ const parsed = parseId(id);
74
+ const type = parsed && byType.get(parsed.type);
75
+ if (!parsed || !type)
76
+ throw new Error(`"${id}" isn't a page id: it's <type>/<slug>, with a type among ${types.map((t) => t.type).join(", ")}.`);
77
+ const page = await readPage(type, parsed.slug);
78
+ if (!page)
79
+ throw new Error(`There's no page "${id}". knowledge_search or knowledge_index finds the right one.`);
80
+ return page;
81
+ }
82
+ /** Links as ids → relative paths, from `file`. */
83
+ function toFile(markdown, file) {
84
+ return markdown.replace(/\]\(([a-z][a-z0-9-]*\/[a-z0-9]+(?:-[a-z0-9]+)*)(#[^)]*)?\)/g, (whole, id, anchor = "") => {
85
+ const parsed = parseId(id);
86
+ const type = parsed && byType.get(parsed.type);
87
+ if (!parsed || !type)
88
+ return whole;
89
+ return `](${posix(path.relative(path.dirname(file), fileOf(type, parsed.slug)))}${anchor})`;
90
+ });
91
+ }
92
+ /** Relative links to pages → ids. */
93
+ function fromFile(markdown, file, pagesByFile) {
94
+ return markdown.replace(/\]\(([^)\s:]+\.md)(#[^)]*)?\)/g, (whole, link, anchor = "") => {
95
+ const id = pagesByFile.get(path.normalize(path.resolve(path.dirname(file), decodeURI(link))));
96
+ return id ? `](${id}${anchor})` : whole;
97
+ });
98
+ }
99
+ const titleOf = (page) => page.fields.get("title") || /^#\s+(.+)$/m.exec(page.body)?.[1].trim() || page.slug;
100
+ function descriptionOf(page) {
101
+ const given = page.fields.get("description");
102
+ if (given)
103
+ return given;
104
+ const line = page.body
105
+ .split(/\r?\n/)
106
+ .map((l) => l.trim())
107
+ .find((l) => l && !l.startsWith("#") && !l.startsWith(">") && !l.startsWith("-") && !l.startsWith("<"));
108
+ const sentence = (line ?? "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").split(/(?<=[.!?])\s/)[0];
109
+ return sentence.length > 140 ? `${sentence.slice(0, 139)}…` : sentence;
110
+ }
111
+ const statusOf = (page) => page.fields.get("status") || "active";
112
+ /** Checks every page first (types, slugs, ids, links among them or to existing pages), then writes them all. */
113
+ async function createPages(newPages) {
114
+ const ids = new Set();
115
+ const ready = [];
116
+ for (const { type: typeName, slug, title, content } of newPages) {
117
+ const type = byType.get(typeName);
118
+ if (!type)
119
+ throw new Error(`"${typeName}" isn't a page type: ${types.map((t) => t.type).join(", ")}.`);
120
+ if (!isSlug(slug))
121
+ throw new Error(`"${slug}" isn't a valid slug: lowercase ASCII words joined by hyphens, e.g. "spring-tides".`);
122
+ if (type.dir === "" && RESERVED.has(slug))
123
+ throw new Error(`"${slug}" is reserved.`);
124
+ const id = `${type.type}/${slug}`;
125
+ if (ids.has(id) || (await stat(fileOf(type, slug)).catch(() => null)))
126
+ throw new Error(`"${id}" already exists: edit it, or pick another slug.`);
127
+ ids.add(id);
128
+ const file = fileOf(type, slug);
129
+ const body = /^#\s/.test(content.trimStart()) ? content : `# ${title}\n\n${content}`;
130
+ ready.push({ id, type, slug, file, fields: new Map([["type", type.type], ["title", title]]), body });
131
+ }
132
+ for (const page of ready)
133
+ await linksResolve(page.body, page.id, ids);
134
+ for (const [i, page] of ready.entries()) {
135
+ const fields = newPages[i].fields ?? {};
136
+ page.body = toFile(page.body, page.file);
137
+ if (page.type.type === "summary" && !("ingested" in fields))
138
+ page.fields.set("ingested", today());
139
+ await applyFields(page, fields); // may refuse a summary's missing original, before anything is written
140
+ }
141
+ for (const page of ready) {
142
+ await mkdir(path.dirname(page.file), { recursive: true });
143
+ await writeFile(page.file, serialize(page.fields, page.body));
144
+ }
145
+ await writeIndex();
146
+ return [...ids];
147
+ }
148
+ /** Throws when a link points to a page that doesn't exist (nor is in `alsoCreating`). */
149
+ async function linksResolve(markdown, self, alsoCreating = new Set()) {
150
+ const missing = [];
151
+ for (const id of new Set(linkedIds(markdown))) {
152
+ if (id === self || alsoCreating.has(id))
153
+ continue;
154
+ const parsed = parseId(id);
155
+ const type = parsed && byType.get(parsed.type);
156
+ if (!type || !parsed || !(await readPage(type, parsed.slug)))
157
+ missing.push(id);
158
+ }
159
+ if (missing.length)
160
+ throw new Error(`These links point to pages that don't exist: ${missing.join(", ")}. Create them first, or link existing ones (knowledge_search).`);
161
+ }
162
+ /** A field given by the model, made ready to store: `file` (relative to sources/) becomes a link from the page. */
163
+ async function storedField(key, value, file) {
164
+ if (key !== "file" || !options.sourcesDir || /^[a-z]+:\/\//i.test(value))
165
+ return value;
166
+ const original = path.resolve(options.sourcesDir, value.replace(/^sources\//, ""));
167
+ if (!(await stat(original).catch(() => null))?.isFile())
168
+ throw new Error(`"${value}" isn't an original in sources/ (list_sources lists them).`);
169
+ return posix(path.relative(path.dirname(file), original));
170
+ }
171
+ async function applyFields(page, changes = {}) {
172
+ for (const [key, value] of Object.entries(changes)) {
173
+ if (key === "type")
174
+ continue;
175
+ if (value === null)
176
+ page.fields.delete(key);
177
+ else
178
+ page.fields.set(key, await storedField(key, value, page.file));
179
+ }
180
+ page.fields.set("updated", today());
181
+ }
182
+ async function save(page) {
183
+ await mkdir(path.dirname(page.file), { recursive: true });
184
+ await writeFile(page.file, serialize(page.fields, page.body));
185
+ await writeIndex();
186
+ }
187
+ /** The notice under the frontmatter of a superseded or retired page. */
188
+ const withNotice = (body, notice) => `> ${notice}\n\n${body.replace(/^> (Superseded|Retired) on [^\n]*\n\n/, "")}`;
189
+ async function indexText() {
190
+ const pages = (await allPages()).filter((p) => statusOf(p) !== "retired");
191
+ const lines = ["# Index", ""];
192
+ for (const type of types) {
193
+ const own = pages.filter((p) => p.type === type).sort((a, b) => titleOf(a).localeCompare(titleOf(b)));
194
+ if (!own.length)
195
+ continue;
196
+ lines.push(`## ${type.indexSection}`, "");
197
+ for (const page of own) {
198
+ const extras = (type.indexFields ?? []).filter((f) => page.fields.get(f)).map((f) => `${f}: ${page.fields.get(f)}`);
199
+ const status = statusOf(page) === "superseded" ? " (superseded)" : "";
200
+ lines.push(`- [${titleOf(page)}](${posix(path.relative(knowledgeDir, page.file))}) — ${descriptionOf(page)}${extras.length ? ` (${extras.join(", ")})` : ""}${status}`);
201
+ }
202
+ lines.push("");
203
+ }
204
+ return `${lines.join("\n").trimEnd()}\n`;
205
+ }
206
+ async function writeIndex() {
207
+ await mkdir(knowledgeDir, { recursive: true });
208
+ await writeFile(path.join(knowledgeDir, "index.md"), await indexText());
209
+ }
210
+ async function pagesByFile() {
211
+ const pages = await allPages();
212
+ return { pages, byFile: new Map(pages.map((p) => [path.normalize(p.file), p.id])) };
213
+ }
214
+ const normalize = (text) => text
215
+ .normalize("NFD")
216
+ .replace(/[̀-ͯ]/g, "")
217
+ .toLowerCase();
218
+ return {
219
+ types: () => types,
220
+ async list() {
221
+ return (await allPages())
222
+ .filter((p) => statusOf(p) !== "retired")
223
+ .map((p) => ({ id: p.id, type: p.type.type, title: titleOf(p), description: descriptionOf(p), status: statusOf(p), fields: Object.fromEntries(p.fields) }));
224
+ },
225
+ async read(id) {
226
+ let page;
227
+ try {
228
+ page = await find(id);
229
+ }
230
+ catch {
231
+ return null;
232
+ }
233
+ const { pages, byFile } = await pagesByFile();
234
+ const linkedFrom = pages
235
+ .filter((p) => p.id !== page.id && linkedIds(fromFile(p.body, p.file, byFile)).includes(page.id))
236
+ .map((p) => p.id);
237
+ const fields = Object.fromEntries([...page.fields].filter(([key]) => key !== "type" && key !== "title"));
238
+ return { id: page.id, type: page.type.type, slug: page.slug, title: titleOf(page), fields, content: fromFile(page.body, page.file, byFile), linkedFrom };
239
+ },
240
+ async create(typeName, slug, title, content, fields = {}) {
241
+ return (await createPages([{ type: typeName, slug, title, content, fields }]))[0];
242
+ },
243
+ createMany: createPages,
244
+ async edit(id, oldText, newText, fields) {
245
+ const page = await find(id);
246
+ const { byFile } = await pagesByFile();
247
+ const current = fromFile(page.body, page.file, byFile);
248
+ const count = oldText ? current.split(oldText).length - 1 : 0;
249
+ if (count === 0)
250
+ throw new Error(`The text to replace isn't in "${id}" (as returned by knowledge_read, links as ids). Read it again.`);
251
+ if (count > 1)
252
+ throw new Error(`The text to replace appears ${count} times in "${id}": include more around it so it's unique.`);
253
+ await linksResolve(newText, id);
254
+ page.body = toFile(current.replace(oldText, () => newText), page.file);
255
+ await applyFields(page, fields);
256
+ await save(page);
257
+ },
258
+ async rewrite(id, content, fields) {
259
+ const page = await find(id);
260
+ await linksResolve(content, id);
261
+ page.body = toFile(/^#\s/.test(content.trimStart()) ? content : `# ${titleOf(page)}\n\n${content}`, page.file);
262
+ await applyFields(page, fields);
263
+ await save(page);
264
+ },
265
+ async supersede(id, by, reason) {
266
+ const page = await find(id);
267
+ const replacement = await find(by);
268
+ if (replacement.id === page.id)
269
+ throw new Error("A page can't supersede itself.");
270
+ const link = posix(path.relative(path.dirname(page.file), replacement.file));
271
+ page.fields.set("status", "superseded");
272
+ page.fields.set("superseded_by", link);
273
+ page.body = withNotice(page.body, `Superseded on ${today()}${reason ? ` (${reason})` : ""}: see [${titleOf(replacement)}](${link}).`);
274
+ await applyFields(page);
275
+ await save(page);
276
+ },
277
+ async retire(id, reason) {
278
+ const page = await find(id);
279
+ page.fields.set("status", "retired");
280
+ page.fields.set("retired", today());
281
+ page.body = withNotice(page.body, `Retired on ${today()}: ${reason}. Don't rely on what it says.`);
282
+ await applyFields(page);
283
+ await save(page);
284
+ },
285
+ async search(query, limit = 10) {
286
+ const terms = normalize(query)
287
+ .split(/[^a-z0-9]+/)
288
+ .filter((term) => term.length > 1);
289
+ if (!terms.length)
290
+ return [];
291
+ const hits = [];
292
+ const { pages, byFile } = await pagesByFile();
293
+ for (const page of pages) {
294
+ if (statusOf(page) === "retired")
295
+ continue;
296
+ const title = normalize(titleOf(page));
297
+ const aliases = normalize(page.fields.get("aliases") ?? "");
298
+ const lines = fromFile(page.body, page.file, byFile)
299
+ .split(/\r?\n/)
300
+ .filter((line) => !/^#\s/.test(line));
301
+ let score = 0;
302
+ let snippet = "";
303
+ for (const term of terms) {
304
+ if (title.includes(term))
305
+ score += 5;
306
+ if (aliases.includes(term))
307
+ score += 4;
308
+ for (const line of lines) {
309
+ if (normalize(line).includes(term)) {
310
+ score += 1;
311
+ snippet ||= line.trim();
312
+ }
313
+ }
314
+ }
315
+ if (score > 0)
316
+ hits.push({ id: page.id, title: titleOf(page), snippet: (snippet || descriptionOf(page)).slice(0, 200), score });
317
+ }
318
+ return hits
319
+ .sort((a, b) => b.score - a.score)
320
+ .slice(0, limit)
321
+ .map(({ id, title, snippet }) => ({ id, title, snippet }));
322
+ },
323
+ index: indexText,
324
+ async log(operation, what, pages = []) {
325
+ await mkdir(knowledgeDir, { recursive: true });
326
+ const file = path.join(knowledgeDir, "log.md");
327
+ const exists = await stat(file).catch(() => null);
328
+ const entry = `## [${today()}] ${operation} | ${what}\n${pages.length ? `- pages: ${pages.join(", ")}\n` : ""}`;
329
+ await appendFile(file, `${exists ? "\n" : "# Log\n\n"}${entry}`);
330
+ },
331
+ async check() {
332
+ await writeIndex(); // the one mechanical fix there is: an index.md out of date
333
+ const { pages, byFile } = await pagesByFile();
334
+ const known = new Map(pages.map((p) => [p.id, p]));
335
+ const inbound = new Map();
336
+ const report = { brokenLinks: [], orphans: [], linksToRetired: [], missingOriginals: [] };
337
+ for (const page of pages) {
338
+ if (statusOf(page) === "retired")
339
+ continue;
340
+ const body = fromFile(page.body, page.file, byFile);
341
+ // Relative links to .md files that resolve to nothing are broken too.
342
+ for (const m of page.body.matchAll(/\]\(([^)\s:]+\.md)(?:#[^)]*)?\)/g)) {
343
+ const target = path.normalize(path.resolve(path.dirname(page.file), decodeURI(m[1])));
344
+ if (!byFile.has(target) && target.startsWith(path.normalize(knowledgeDir)))
345
+ report.brokenLinks.push({ page: page.id, target: posix(path.relative(knowledgeDir, target)) });
346
+ }
347
+ for (const id of new Set(linkedIds(body))) {
348
+ const target = known.get(id);
349
+ if (!target)
350
+ report.brokenLinks.push({ page: page.id, target: id });
351
+ else if (statusOf(target) === "retired")
352
+ report.linksToRetired.push({ page: page.id, target: id });
353
+ if (id !== page.id)
354
+ inbound.set(id, (inbound.get(id) ?? 0) + 1);
355
+ }
356
+ const file = page.fields.get("file");
357
+ if (file && !/^[a-z]+:\/\//i.test(file) && !(await stat(path.resolve(path.dirname(page.file), file)).catch(() => null))) {
358
+ report.missingOriginals.push({ page: page.id, file });
359
+ }
360
+ }
361
+ report.orphans = pages
362
+ .filter((p) => statusOf(p) !== "retired" && p.type.type !== "summary" && p.type.type !== "synthesis" && !inbound.get(p.id))
363
+ .map((p) => p.id);
364
+ return report;
365
+ },
366
+ async overview() {
367
+ const file = path.join(knowledgeDir, "overview.md");
368
+ const text = await readFile(file, "utf8").catch(() => "");
369
+ return text ? fromFile(text, file, (await pagesByFile()).byFile) : "";
370
+ },
371
+ async writeOverview(content) {
372
+ await linksResolve(content);
373
+ const file = path.join(knowledgeDir, "overview.md");
374
+ await mkdir(knowledgeDir, { recursive: true });
375
+ await writeFile(file, `${toFile(content.trim(), file)}\n`);
376
+ },
377
+ };
378
+ }
@@ -16,6 +16,14 @@ export interface FileScope {
16
16
  searchableDirs: string[];
17
17
  /** Never readable, searchable or writable. */
18
18
  deniedPaths: string[];
19
+ /**
20
+ * Reached only through the kit's own tools, never the file tools: the knowledge folder
21
+ * when the agent has the `knowledge_*` tools (ADR-024). The denial says what to use instead.
22
+ */
23
+ toolOnlyDirs?: {
24
+ dir: string;
25
+ instead: string;
26
+ }[];
19
27
  }
20
28
  /** Whether `candidate` is `base` itself or somewhere inside it (case-insensitive on Windows, like path.relative). */
21
29
  export declare function isWithin(base: string, candidate: string): boolean;
@@ -15,6 +15,26 @@ function describeDirs(scope, dirs) {
15
15
  export function checkFileScope(scope, toolName, input) {
16
16
  const resolve = (value) => typeof value === "string" && value !== "" ? path.resolve(scope.projectDir, value) : undefined;
17
17
  const isDenied = (target) => scope.deniedPaths.some((denied) => isWithin(denied, target));
18
+ // The folder of the kit's own tools that `target` is in, if any.
19
+ const toolOnlyDirs = (scope.toolOnlyDirs ?? []).map((only) => ({ ...only, dir: path.resolve(scope.projectDir, only.dir) }));
20
+ const toolOnly = (target) => toolOnlyDirs.find(({ dir }) => isWithin(dir, target));
21
+ const viaTools = (shown, only) => `"${String(shown)}" is in ${path.relative(scope.projectDir, only.dir) || "."}/, which is reached only through ${only.instead}, not the file tools.`;
22
+ if (toolName === "Read" || toolName === "Write" || toolName === "Edit") {
23
+ const target = resolve(input.file_path);
24
+ const only = target && toolOnly(target);
25
+ if (only)
26
+ return viaTools(input.file_path, only);
27
+ }
28
+ if (toolName === "Grep" || toolName === "Glob") {
29
+ const target = resolve(input.path) ?? scope.projectDir;
30
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
31
+ // Where a search really starts: Grep's path, Glob's path plus the pattern's fixed folders
32
+ // ("sources/**" starts in sources/). From above the folder it would reach into it.
33
+ const start = toolName === "Glob" ? path.resolve(target, pattern.replace(/[^/\\]*[*?[{].*$/, "")) : target;
34
+ const only = toolOnly(start) ?? toolOnlyDirs.find(({ dir }) => isWithin(start, dir));
35
+ if (only)
36
+ return viaTools(input.path ?? pattern, only);
37
+ }
18
38
  switch (toolName) {
19
39
  case "Read": {
20
40
  const target = resolve(input.file_path);
@@ -11,5 +11,23 @@ export type { ApprovalPrompt } from "../interaction.js";
11
11
  * Returns the answer lowercased and trimmed ("", "y", "n", "q"...).
12
12
  */
13
13
  export declare function askForDecision(runDir: string, prompt: ApprovalPrompt): Promise<string>;
14
+ /**
15
+ * A free-text answer, through the port's `askText()` (or `askDecision()` when it has none)
16
+ * and the response file: trimmed, with its case kept.
17
+ */
18
+ export declare function askForText(runDir: string, prompt: ApprovalPrompt): Promise<string>;
19
+ /** What the person chose: the options they picked, and their own answer if they gave one. */
20
+ export interface ChoiceAnswer {
21
+ chosen: string[];
22
+ other?: string;
23
+ }
24
+ /** Reads a raw choice answer (see `InteractionPort.askChoice()`) against `options`. */
25
+ export declare function parseChoice(raw: string, options: readonly string[], multiple: boolean): ChoiceAnswer;
26
+ /**
27
+ * A choice between `options`, through the port's `askChoice()` (or `askDecision()` with the
28
+ * options numbered in the prompt) and the response file, which takes the same answer: the
29
+ * numbers, or the person's own words.
30
+ */
31
+ export declare function askForChoice(runDir: string, prompt: ApprovalPrompt, options: readonly string[], multiple?: boolean): Promise<ChoiceAnswer>;
14
32
  /** Like `askForDecision()`, for a manual-intervention checkpoint (see tools/manualLogin.ts). */
15
33
  export declare function askForManualIntervention(runDir: string, prompt: ApprovalPrompt): Promise<string>;
@@ -1,6 +1,7 @@
1
1
  import { readFile, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getInteractionPort } from "../interaction.js";
4
+ import { t } from "../messages/index.js";
4
5
  /**
5
6
  * Asks the human for a decision through two channels in parallel, whichever answers
6
7
  * first wins: (1) the installed `InteractionPort` (the keyboard, for a person running the
@@ -14,6 +15,33 @@ import { getInteractionPort } from "../interaction.js";
14
15
  export async function askForDecision(runDir, prompt) {
15
16
  return await raceWithResponseFile(runDir, (port, signal) => port.askDecision(prompt, signal));
16
17
  }
18
+ /**
19
+ * A free-text answer, through the port's `askText()` (or `askDecision()` when it has none)
20
+ * and the response file: trimmed, with its case kept.
21
+ */
22
+ export async function askForText(runDir, prompt) {
23
+ return await raceWithResponseFile(runDir, (port, signal) => (port.askText ? port.askText(prompt, signal) : port.askDecision(prompt, signal)), false);
24
+ }
25
+ /** Reads a raw choice answer (see `InteractionPort.askChoice()`) against `options`. */
26
+ export function parseChoice(raw, options, multiple) {
27
+ const [first = "", ...rest] = raw.trim().split(/\r?\n/);
28
+ const isNumbers = /^\s*\d+(\s*,\s*\d+)*\s*$/.test(first);
29
+ const numbers = isNumbers ? first.split(",").map((n) => Number(n.trim())).filter((n) => n >= 1 && n <= options.length) : [];
30
+ const chosen = [...new Set(numbers)].slice(0, multiple ? undefined : 1).map((n) => options[n - 1]);
31
+ const other = (isNumbers ? rest : [first, ...rest]).join("\n").trim();
32
+ return other ? { chosen, other } : { chosen };
33
+ }
34
+ /**
35
+ * A choice between `options`, through the port's `askChoice()` (or `askDecision()` with the
36
+ * options numbered in the prompt) and the response file, which takes the same answer: the
37
+ * numbers, or the person's own words.
38
+ */
39
+ export async function askForChoice(runDir, prompt, options, multiple = false) {
40
+ const raw = await raceWithResponseFile(runDir, (port, signal) => port.askChoice
41
+ ? port.askChoice(prompt, { options, multiple }, signal)
42
+ : port.askDecision({ ...prompt, lines: [...prompt.lines, ...options.map((option, i) => `${i + 1}. ${option}`)], question: t().choiceQuestion(multiple) }, signal), false);
43
+ return parseChoice(raw, options, multiple);
44
+ }
17
45
  /** Like `askForDecision()`, for a manual-intervention checkpoint (see tools/manualLogin.ts). */
18
46
  export async function askForManualIntervention(runDir, prompt) {
19
47
  return await raceWithResponseFile(runDir, (port, signal) => port.askManualIntervention(prompt, signal));
@@ -21,7 +49,7 @@ export async function askForManualIntervention(runDir, prompt) {
21
49
  function never() {
22
50
  return new Promise(() => { });
23
51
  }
24
- async function raceWithResponseFile(runDir, ask) {
52
+ async function raceWithResponseFile(runDir, ask, lowercase = true) {
25
53
  const responseFile = path.join(runDir, "approval-response.txt");
26
54
  await rm(responseFile, { force: true });
27
55
  let settled = false;
@@ -48,5 +76,5 @@ async function raceWithResponseFile(runDir, ask) {
48
76
  settled = true;
49
77
  controller.abort();
50
78
  await rm(responseFile, { force: true });
51
- return raw.trim().toLowerCase();
79
+ return lowercase ? raw.trim().toLowerCase() : raw.trim();
52
80
  }