@falkenslab/agent-kit 0.15.0 → 0.16.1
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.
- package/README.md +3 -1
- package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
- package/assets/knowledge-plugin/commands/ingest.md +1 -1
- package/assets/knowledge-plugin/commands/lint.md +1 -1
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
- package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
- package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
- package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
- package/assets/knowledge-plugin-files/commands/lint.md +5 -0
- package/assets/knowledge-plugin-files/commands/query.md +5 -0
- package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
- package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
- package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
- package/dist/core/agentSpec.d.ts +27 -2
- package/dist/core/extractText.d.ts +13 -0
- package/dist/core/extractText.js +147 -0
- package/dist/core/fileKnowledgeStore.d.ts +10 -0
- package/dist/core/fileKnowledgeStore.js +395 -0
- package/dist/core/hooks/fileScopeGate.d.ts +8 -0
- package/dist/core/hooks/fileScopeGate.js +20 -0
- package/dist/core/hooks/humanInput.d.ts +18 -0
- package/dist/core/hooks/humanInput.js +30 -2
- package/dist/core/hooks/planGate.js +14 -5
- package/dist/core/hooks/stepGate.js +6 -0
- package/dist/core/interaction.d.ts +20 -0
- package/dist/core/knowledge.d.ts +14 -4
- package/dist/core/knowledge.js +36 -6
- package/dist/core/knowledgeStore.d.ts +139 -0
- package/dist/core/knowledgeStore.js +84 -0
- package/dist/core/messages/de.js +65 -1
- package/dist/core/messages/en.d.ts +39 -0
- package/dist/core/messages/en.js +65 -1
- package/dist/core/messages/es.js +65 -1
- package/dist/core/messages/fr.js +65 -1
- package/dist/core/modeControl.d.ts +36 -0
- package/dist/core/modeControl.js +61 -0
- package/dist/core/session.d.ts +6 -31
- package/dist/core/session.js +81 -71
- package/dist/core/sources.d.ts +122 -0
- package/dist/core/sources.js +312 -0
- package/dist/core/todos.d.ts +23 -0
- package/dist/core/todos.js +33 -0
- package/dist/core/toolLabels.d.ts +1 -1
- package/dist/core/toolLabels.js +42 -2
- package/dist/core/tools/humanApproval.d.ts +9 -5
- package/dist/core/tools/humanApproval.js +39 -7
- package/dist/core/tools/knowledgeTools.d.ts +16 -0
- package/dist/core/tools/knowledgeTools.js +168 -0
- package/dist/core/tools/saveToSources.d.ts +27 -8
- package/dist/core/tools/saveToSources.js +217 -55
- package/dist/core/tools/time.d.ts +29 -0
- package/dist/core/tools/time.js +129 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +12 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/tui/chatTui.js +14 -1
- package/dist/tui/consoleRenderer.js +16 -2
- package/dist/tui/ink/SessionView.js +67 -17
- package/dist/tui/ink/inkInteraction.d.ts +4 -2
- package/dist/tui/ink/inkInteraction.js +20 -2
- package/dist/tui/ink/runChatInk.js +5 -0
- package/dist/tui/ink/sessionModel.d.ts +3 -0
- package/dist/tui/ink/sessionModel.js +14 -1
- package/dist/tui/terminalInteraction.js +2 -0
- package/package.json +31 -1
- /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
// Relative to the sources folder; a leading "sources/" or the folder's own name is dropped.
|
|
167
|
+
let relative = value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
168
|
+
for (const prefix of ["sources/", `${path.basename(options.sourcesDir)}/`])
|
|
169
|
+
if (relative.startsWith(prefix))
|
|
170
|
+
relative = relative.slice(prefix.length);
|
|
171
|
+
const original = path.resolve(options.sourcesDir, relative);
|
|
172
|
+
if (!(await stat(original).catch(() => null))?.isFile())
|
|
173
|
+
throw new Error(`"${value}" isn't an original in sources/ (list_sources lists them).`);
|
|
174
|
+
return posix(path.relative(path.dirname(file), original));
|
|
175
|
+
}
|
|
176
|
+
async function applyFields(page, changes = {}) {
|
|
177
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
178
|
+
if (key === "type")
|
|
179
|
+
continue;
|
|
180
|
+
if (value === null)
|
|
181
|
+
page.fields.delete(key);
|
|
182
|
+
else
|
|
183
|
+
page.fields.set(key, await storedField(key, value, page.file));
|
|
184
|
+
}
|
|
185
|
+
page.fields.set("updated", today());
|
|
186
|
+
}
|
|
187
|
+
async function save(page) {
|
|
188
|
+
await mkdir(path.dirname(page.file), { recursive: true });
|
|
189
|
+
await writeFile(page.file, serialize(page.fields, page.body));
|
|
190
|
+
await writeIndex();
|
|
191
|
+
}
|
|
192
|
+
/** The notice under the frontmatter of a superseded or retired page. */
|
|
193
|
+
const withNotice = (body, notice) => `> ${notice}\n\n${body.replace(/^> (Superseded|Retired) on [^\n]*\n\n/, "")}`;
|
|
194
|
+
async function indexText() {
|
|
195
|
+
const pages = (await allPages()).filter((p) => statusOf(p) !== "retired");
|
|
196
|
+
const lines = ["# Index", ""];
|
|
197
|
+
for (const type of types) {
|
|
198
|
+
const own = pages.filter((p) => p.type === type).sort((a, b) => titleOf(a).localeCompare(titleOf(b)));
|
|
199
|
+
if (!own.length)
|
|
200
|
+
continue;
|
|
201
|
+
lines.push(`## ${type.indexSection}`, "");
|
|
202
|
+
for (const page of own) {
|
|
203
|
+
const extras = (type.indexFields ?? []).filter((f) => page.fields.get(f)).map((f) => `${f}: ${page.fields.get(f)}`);
|
|
204
|
+
const status = statusOf(page) === "superseded" ? " (superseded)" : "";
|
|
205
|
+
lines.push(`- [${titleOf(page)}](${posix(path.relative(knowledgeDir, page.file))}) — ${descriptionOf(page)}${extras.length ? ` (${extras.join(", ")})` : ""}${status}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
208
|
+
}
|
|
209
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
210
|
+
}
|
|
211
|
+
async function writeIndex() {
|
|
212
|
+
await mkdir(knowledgeDir, { recursive: true });
|
|
213
|
+
await writeFile(path.join(knowledgeDir, "index.md"), await indexText());
|
|
214
|
+
}
|
|
215
|
+
async function pagesByFile() {
|
|
216
|
+
const pages = await allPages();
|
|
217
|
+
return { pages, byFile: new Map(pages.map((p) => [path.normalize(p.file), p.id])) };
|
|
218
|
+
}
|
|
219
|
+
const normalize = (text) => text
|
|
220
|
+
.normalize("NFD")
|
|
221
|
+
.replace(/[̀-ͯ]/g, "")
|
|
222
|
+
.toLowerCase();
|
|
223
|
+
return {
|
|
224
|
+
types: () => types,
|
|
225
|
+
async list() {
|
|
226
|
+
return (await allPages())
|
|
227
|
+
.filter((p) => statusOf(p) !== "retired")
|
|
228
|
+
.map((p) => ({ id: p.id, type: p.type.type, title: titleOf(p), description: descriptionOf(p), status: statusOf(p), fields: Object.fromEntries(p.fields) }));
|
|
229
|
+
},
|
|
230
|
+
async read(id) {
|
|
231
|
+
let page;
|
|
232
|
+
try {
|
|
233
|
+
page = await find(id);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const { pages, byFile } = await pagesByFile();
|
|
239
|
+
const linkedFrom = pages
|
|
240
|
+
.filter((p) => p.id !== page.id && linkedIds(fromFile(p.body, p.file, byFile)).includes(page.id))
|
|
241
|
+
.map((p) => p.id);
|
|
242
|
+
const fields = Object.fromEntries([...page.fields].filter(([key]) => key !== "type" && key !== "title"));
|
|
243
|
+
return { id: page.id, type: page.type.type, slug: page.slug, title: titleOf(page), fields, content: fromFile(page.body, page.file, byFile), linkedFrom };
|
|
244
|
+
},
|
|
245
|
+
async create(typeName, slug, title, content, fields = {}) {
|
|
246
|
+
return (await createPages([{ type: typeName, slug, title, content, fields }]))[0];
|
|
247
|
+
},
|
|
248
|
+
createMany: createPages,
|
|
249
|
+
async edit(id, oldText, newText, fields) {
|
|
250
|
+
const page = await find(id);
|
|
251
|
+
const { byFile } = await pagesByFile();
|
|
252
|
+
const current = fromFile(page.body, page.file, byFile);
|
|
253
|
+
const count = oldText ? current.split(oldText).length - 1 : 0;
|
|
254
|
+
if (count === 0)
|
|
255
|
+
throw new Error(`The text to replace isn't in "${id}" (as returned by knowledge_read, links as ids). Read it again.`);
|
|
256
|
+
if (count > 1)
|
|
257
|
+
throw new Error(`The text to replace appears ${count} times in "${id}": include more around it so it's unique.`);
|
|
258
|
+
await linksResolve(newText, id);
|
|
259
|
+
page.body = toFile(current.replace(oldText, () => newText), page.file);
|
|
260
|
+
await applyFields(page, fields);
|
|
261
|
+
await save(page);
|
|
262
|
+
},
|
|
263
|
+
async rewrite(id, content, fields) {
|
|
264
|
+
const page = await find(id);
|
|
265
|
+
await linksResolve(content, id);
|
|
266
|
+
page.body = toFile(/^#\s/.test(content.trimStart()) ? content : `# ${titleOf(page)}\n\n${content}`, page.file);
|
|
267
|
+
await applyFields(page, fields);
|
|
268
|
+
await save(page);
|
|
269
|
+
},
|
|
270
|
+
async supersede(id, by, reason) {
|
|
271
|
+
const page = await find(id);
|
|
272
|
+
const replacement = await find(by);
|
|
273
|
+
if (replacement.id === page.id)
|
|
274
|
+
throw new Error("A page can't supersede itself.");
|
|
275
|
+
const link = posix(path.relative(path.dirname(page.file), replacement.file));
|
|
276
|
+
page.fields.set("status", "superseded");
|
|
277
|
+
page.fields.set("superseded_by", link);
|
|
278
|
+
page.body = withNotice(page.body, `Superseded on ${today()}${reason ? ` (${reason})` : ""}: see [${titleOf(replacement)}](${link}).`);
|
|
279
|
+
await applyFields(page);
|
|
280
|
+
await save(page);
|
|
281
|
+
},
|
|
282
|
+
async retire(id, reason) {
|
|
283
|
+
const page = await find(id);
|
|
284
|
+
page.fields.set("status", "retired");
|
|
285
|
+
page.fields.set("retired", today());
|
|
286
|
+
page.body = withNotice(page.body, `Retired on ${today()}: ${reason}. Don't rely on what it says.`);
|
|
287
|
+
await applyFields(page);
|
|
288
|
+
await save(page);
|
|
289
|
+
},
|
|
290
|
+
async search(query, limit = 10) {
|
|
291
|
+
const terms = normalize(query)
|
|
292
|
+
.split(/[^a-z0-9]+/)
|
|
293
|
+
.filter((term) => term.length > 1);
|
|
294
|
+
if (!terms.length)
|
|
295
|
+
return [];
|
|
296
|
+
const hits = [];
|
|
297
|
+
const { pages, byFile } = await pagesByFile();
|
|
298
|
+
for (const page of pages) {
|
|
299
|
+
if (statusOf(page) === "retired")
|
|
300
|
+
continue;
|
|
301
|
+
const title = normalize(titleOf(page));
|
|
302
|
+
const aliases = normalize(page.fields.get("aliases") ?? "");
|
|
303
|
+
const lines = fromFile(page.body, page.file, byFile)
|
|
304
|
+
.split(/\r?\n/)
|
|
305
|
+
.filter((line) => !/^#\s/.test(line));
|
|
306
|
+
let score = 0;
|
|
307
|
+
let snippet = "";
|
|
308
|
+
for (const term of terms) {
|
|
309
|
+
if (title.includes(term))
|
|
310
|
+
score += 5;
|
|
311
|
+
if (aliases.includes(term))
|
|
312
|
+
score += 4;
|
|
313
|
+
for (const line of lines) {
|
|
314
|
+
if (normalize(line).includes(term)) {
|
|
315
|
+
score += 1;
|
|
316
|
+
snippet ||= line.trim();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (score > 0)
|
|
321
|
+
hits.push({ id: page.id, title: titleOf(page), snippet: (snippet || descriptionOf(page)).slice(0, 200), score });
|
|
322
|
+
}
|
|
323
|
+
return hits
|
|
324
|
+
.sort((a, b) => b.score - a.score)
|
|
325
|
+
.slice(0, limit)
|
|
326
|
+
.map(({ id, title, snippet }) => ({ id, title, snippet }));
|
|
327
|
+
},
|
|
328
|
+
index: indexText,
|
|
329
|
+
async recentLog(limit = 10) {
|
|
330
|
+
const text = await readFile(path.join(knowledgeDir, "log.md"), "utf8").catch(() => "");
|
|
331
|
+
// Entries start with "## [date] operation | what"; the newest are at the bottom.
|
|
332
|
+
const entries = text.split(/\r?\n(?=## )/).filter((entry) => entry.startsWith("## ")).map((entry) => entry.trim());
|
|
333
|
+
return entries.slice(-limit).reverse();
|
|
334
|
+
},
|
|
335
|
+
async log(operation, what, pages = []) {
|
|
336
|
+
await mkdir(knowledgeDir, { recursive: true });
|
|
337
|
+
const file = path.join(knowledgeDir, "log.md");
|
|
338
|
+
const exists = await stat(file).catch(() => null);
|
|
339
|
+
const entry = `## [${today()}] ${operation} | ${what}\n${pages.length ? `- pages: ${pages.join(", ")}\n` : ""}`;
|
|
340
|
+
await appendFile(file, `${exists ? "\n" : "# Log\n\n"}${entry}`);
|
|
341
|
+
},
|
|
342
|
+
async check() {
|
|
343
|
+
await writeIndex(); // the one mechanical fix there is: an index.md out of date
|
|
344
|
+
const { pages, byFile } = await pagesByFile();
|
|
345
|
+
const reservedFiles = new Set([...RESERVED].map((name) => path.normalize(path.join(knowledgeDir, `${name}.md`))));
|
|
346
|
+
const known = new Map(pages.map((p) => [p.id, p]));
|
|
347
|
+
const inbound = new Map();
|
|
348
|
+
const report = { brokenLinks: [], orphans: [], linksToRetired: [], missingOriginals: [] };
|
|
349
|
+
for (const page of pages) {
|
|
350
|
+
if (statusOf(page) === "retired")
|
|
351
|
+
continue;
|
|
352
|
+
const body = fromFile(page.body, page.file, byFile);
|
|
353
|
+
// Relative links to .md files that resolve to nothing are broken too. The overview, the
|
|
354
|
+
// index and the log aren't pages but are files: a link to one that exists is fine (a
|
|
355
|
+
// knowledge base written with the file tools links the overview, #22).
|
|
356
|
+
for (const m of page.body.matchAll(/\]\(([^)\s:]+\.md)(?:#[^)]*)?\)/g)) {
|
|
357
|
+
const target = path.normalize(path.resolve(path.dirname(page.file), decodeURI(m[1])));
|
|
358
|
+
if (byFile.has(target) || !target.startsWith(path.normalize(knowledgeDir)))
|
|
359
|
+
continue;
|
|
360
|
+
if (reservedFiles.has(target) && (await stat(target).catch(() => null)))
|
|
361
|
+
continue;
|
|
362
|
+
report.brokenLinks.push({ page: page.id, target: posix(path.relative(knowledgeDir, target)) });
|
|
363
|
+
}
|
|
364
|
+
for (const id of new Set(linkedIds(body))) {
|
|
365
|
+
const target = known.get(id);
|
|
366
|
+
if (!target)
|
|
367
|
+
report.brokenLinks.push({ page: page.id, target: id });
|
|
368
|
+
else if (statusOf(target) === "retired")
|
|
369
|
+
report.linksToRetired.push({ page: page.id, target: id });
|
|
370
|
+
if (id !== page.id)
|
|
371
|
+
inbound.set(id, (inbound.get(id) ?? 0) + 1);
|
|
372
|
+
}
|
|
373
|
+
const file = page.fields.get("file");
|
|
374
|
+
if (file && !/^[a-z]+:\/\//i.test(file) && !(await stat(path.resolve(path.dirname(page.file), file)).catch(() => null))) {
|
|
375
|
+
report.missingOriginals.push({ page: page.id, file });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
report.orphans = pages
|
|
379
|
+
.filter((p) => statusOf(p) !== "retired" && p.type.type !== "summary" && p.type.type !== "synthesis" && !inbound.get(p.id))
|
|
380
|
+
.map((p) => p.id);
|
|
381
|
+
return report;
|
|
382
|
+
},
|
|
383
|
+
async overview() {
|
|
384
|
+
const file = path.join(knowledgeDir, "overview.md");
|
|
385
|
+
const text = await readFile(file, "utf8").catch(() => "");
|
|
386
|
+
return text ? fromFile(text, file, (await pagesByFile()).byFile) : "";
|
|
387
|
+
},
|
|
388
|
+
async writeOverview(content) {
|
|
389
|
+
await linksResolve(content);
|
|
390
|
+
const file = path.join(knowledgeDir, "overview.md");
|
|
391
|
+
await mkdir(knowledgeDir, { recursive: true });
|
|
392
|
+
await writeFile(file, `${toFile(content.trim(), file)}\n`);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
/** Built-in tools that only read, search, load a skill or
|
|
3
|
-
const READING_TOOLS = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Skill", "Agent"]);
|
|
4
|
-
/** The kit's own MCP tools
|
|
5
|
-
const ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__"];
|
|
2
|
+
/** Built-in tools that only read, search, load a skill, delegate or keep the task list (a subagent's own calls come through this gate too). */
|
|
3
|
+
const READING_TOOLS = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Skill", "Agent", "TodoWrite"]);
|
|
4
|
+
/** The kit's own MCP servers whose tools only ask a human (approvals, manual intervention) or only read (the date and time). */
|
|
5
|
+
const ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__", "mcp__time__"];
|
|
6
|
+
/** The kit's own MCP tools that only read, on servers that also have tools that write. */
|
|
7
|
+
const READING_KIT_TOOLS = new Set([
|
|
8
|
+
"mcp__sourceFiles__list_sources",
|
|
9
|
+
"mcp__sourceFiles__extract_text",
|
|
10
|
+
"mcp__knowledge__knowledge_index",
|
|
11
|
+
"mcp__knowledge__knowledge_search",
|
|
12
|
+
"mcp__knowledge__knowledge_read",
|
|
13
|
+
"mcp__knowledge__knowledge_check",
|
|
14
|
+
]);
|
|
6
15
|
/** Why a call is denied in plan mode, and what the model should do instead. */
|
|
7
16
|
const denial = "Plan mode: nothing can be changed until the user leaves plan mode. Keep reading and researching, then present the plan; the user switches out of plan mode when they want it carried out.";
|
|
8
17
|
/**
|
|
@@ -13,7 +22,7 @@ const denial = "Plan mode: nothing can be changed until the user leaves plan mod
|
|
|
13
22
|
export function checkPlanScope(scope, toolName, input) {
|
|
14
23
|
if (READING_TOOLS.has(toolName))
|
|
15
24
|
return undefined;
|
|
16
|
-
if (ASKING_SERVERS.some((prefix) => toolName.startsWith(prefix)))
|
|
25
|
+
if (ASKING_SERVERS.some((prefix) => toolName.startsWith(prefix)) || READING_KIT_TOOLS.has(toolName))
|
|
17
26
|
return undefined;
|
|
18
27
|
if (toolName === "Write" || toolName === "Edit") {
|
|
19
28
|
const target = typeof input.file_path === "string" && input.file_path !== "" ? path.resolve(scope.projectDir, input.file_path) : undefined;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { askForDecision } from "./humanInput.js";
|
|
2
2
|
import { t } from "../messages/index.js";
|
|
3
|
+
const SELF_ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__"];
|
|
4
|
+
const SELF_ASKING_TOOLS = new Set(["mcp__sourceFiles__request_file", "mcp__sourceFiles__retire_source", "mcp__knowledge__knowledge_retire", "TodoWrite"]);
|
|
3
5
|
/**
|
|
4
6
|
* PreToolUse hook for "interactive" mode: pauses before every action and asks for
|
|
5
7
|
* confirmation (by keyboard or by file, see humanInput.ts), like reviewing a plan step
|
|
@@ -14,6 +16,10 @@ export function createStepGate(runDir, isActive = () => true) {
|
|
|
14
16
|
if (!isActive())
|
|
15
17
|
return {};
|
|
16
18
|
const pre = input;
|
|
19
|
+
// A tool that asks the person itself (an approval, a question, the plan, a file, retiring
|
|
20
|
+
// a source) or only keeps the task list isn't asked about first: that would be asking to ask.
|
|
21
|
+
if (SELF_ASKING_SERVERS.some((prefix) => pre.tool_name.startsWith(prefix)) || SELF_ASKING_TOOLS.has(pre.tool_name))
|
|
22
|
+
return {};
|
|
17
23
|
const answer = await askForDecision(runDir, {
|
|
18
24
|
title: t().proposedAction,
|
|
19
25
|
lines: [t().toolLine(pre.tool_name), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
|
|
@@ -4,6 +4,13 @@ export interface ApprovalPrompt {
|
|
|
4
4
|
lines: string[];
|
|
5
5
|
/** Terminal question text; defaults to the approve/reject one. */
|
|
6
6
|
question?: string;
|
|
7
|
+
/** The lines are markdown (a plan): a UI that can render it should. */
|
|
8
|
+
markdown?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** How `askChoice()` asks: the options to pick from, and whether several can be picked. */
|
|
11
|
+
export interface ChoiceSettings {
|
|
12
|
+
options: readonly string[];
|
|
13
|
+
multiple: boolean;
|
|
7
14
|
}
|
|
8
15
|
/**
|
|
9
16
|
* The UI side of a human-in-the-loop checkpoint: how a host shows the question and reads a
|
|
@@ -23,6 +30,19 @@ export interface InteractionPort {
|
|
|
23
30
|
askDecision(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
|
|
24
31
|
/** Waits until the person confirms they intervened by hand (e.g. logged in). */
|
|
25
32
|
askManualIntervention(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* A free-text answer (e.g. the path of a file the agent asked for), with case and spaces
|
|
35
|
+
* kept; "" for none. Optional: without it the kit asks through `askDecision()`, whose
|
|
36
|
+
* answer a plain terminal reads as text too.
|
|
37
|
+
*/
|
|
38
|
+
askText?(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* A choice between options. Resolves to the raw answer: the chosen options' numbers
|
|
41
|
+
* (1-based, comma-separated, "1" or "1,3") on the first line, and the person's own answer
|
|
42
|
+
* ("Other") on the lines after it, or alone. Optional: without it the kit asks through
|
|
43
|
+
* `askDecision()`, with the options numbered in the prompt.
|
|
44
|
+
*/
|
|
45
|
+
askChoice?(prompt: ApprovalPrompt, choice: ChoiceSettings, signal: AbortSignal): Promise<string>;
|
|
26
46
|
/** A one-way message for the person. */
|
|
27
47
|
notify(message: string): void;
|
|
28
48
|
}
|
package/dist/core/knowledge.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PageType } from "./knowledgeStore.js";
|
|
1
2
|
/**
|
|
2
3
|
* The built-in knowledge base (an "LLM wiki"): `sourcesDir` holds the originals, `knowledgeDir`
|
|
3
4
|
* is the wiki the agent writes and maintains, and the rules below plus the plugin's skills
|
|
@@ -6,10 +7,19 @@
|
|
|
6
7
|
* with its own page types (topics, activities...) opts out with `AgentSpec.knowledgeBase: false` and
|
|
7
8
|
* writes its own rules, or extends these.
|
|
8
9
|
*/
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Absolute path of the plugin shipped with the kit, next to `dist/` (or `src/` under tsx):
|
|
12
|
+
* `assets/knowledge-plugin`, whose skills work through the `knowledge_*` tools, or with
|
|
13
|
+
* `"files"` `assets/knowledge-plugin-files`, whose skills work on the files (ADR-024).
|
|
14
|
+
*/
|
|
15
|
+
export declare function knowledgePluginRoot(variant?: "tools" | "files"): string;
|
|
11
16
|
/**
|
|
12
17
|
* The "Knowledge base" section appended to the system prompt: the layout and the working
|
|
13
|
-
* rules.
|
|
18
|
+
* rules. With `tools` (the kit's default since ADR-024), for a knowledge base reached through
|
|
19
|
+
* the `knowledge_*` tools, listing `pageTypes`; otherwise for one kept with the file tools,
|
|
20
|
+
* whose page templates live in the `knowledge-pages` skill.
|
|
14
21
|
*/
|
|
15
|
-
export declare function knowledgePromptSection(projectDir: string, knowledgeDir: string, sourcesDir?: string
|
|
22
|
+
export declare function knowledgePromptSection(projectDir: string, knowledgeDir: string, sourcesDir?: string, options?: {
|
|
23
|
+
tools?: boolean;
|
|
24
|
+
pageTypes?: readonly PageType[];
|
|
25
|
+
}): string;
|