@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.
- package/assets/agent-help/SKILL.md +1 -1
- package/assets/knowledge-plugin/commands/ingest.md +1 -1
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +8 -3
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +12 -5
- package/dist/core/agentSpec.d.ts +16 -16
- package/dist/core/fileKnowledgeStore.d.ts +1 -3
- package/dist/core/fileKnowledgeStore.js +23 -29
- package/dist/core/hooks/fileScopeGate.d.ts +17 -2
- package/dist/core/hooks/fileScopeGate.js +45 -10
- package/dist/core/knowledge.d.ts +20 -17
- package/dist/core/knowledge.js +32 -60
- package/dist/core/knowledgeStore.d.ts +4 -9
- package/dist/core/knowledgeStore.js +16 -2
- package/dist/core/language.js +1 -1
- package/dist/core/messages/de.js +1 -1
- package/dist/core/messages/en.js +1 -1
- package/dist/core/messages/es.js +1 -1
- package/dist/core/messages/fr.js +1 -1
- package/dist/core/session.d.ts +12 -0
- package/dist/core/session.js +88 -33
- package/dist/core/sources.d.ts +24 -31
- package/dist/core/sources.js +45 -108
- package/dist/core/tools/humanApproval.js +3 -3
- package/dist/core/tools/knowledgeTools.d.ts +6 -6
- package/dist/core/tools/knowledgeTools.js +18 -53
- package/dist/core/tools/saveToSources.d.ts +6 -2
- package/dist/core/tools/saveToSources.js +17 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
- package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
- package/assets/knowledge-plugin-files/commands/lint.md +0 -5
- package/assets/knowledge-plugin-files/commands/query.md +0 -5
- package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
- package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
- package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
- package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
package/dist/core/sources.js
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
128
|
-
* the
|
|
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
|
|
132
|
-
return serialized(sourcesDir, () => listSourcesNow(sourcesDir
|
|
110
|
+
export function listSources(sourcesDir) {
|
|
111
|
+
return serialized(sourcesDir, () => listSourcesNow(sourcesDir));
|
|
133
112
|
}
|
|
134
|
-
async function listSourcesNow(sourcesDir
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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,
|
|
251
|
-
return serialized(sourcesDir, () => retireSourceNow(sourcesDir,
|
|
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,
|
|
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
|
-
|
|
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
|
|
38
|
-
options: z.array(z.string()).min(2).max(8).describe("The options, short, in the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
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,
|
|
249
|
-
|
|
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,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,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.
|