@falkenslab/agent-kit 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +3 -3
  2. package/assets/agent-help/SKILL.md +57 -0
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +8 -3
  5. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +12 -5
  6. package/dist/core/agentHelp.d.ts +30 -0
  7. package/dist/core/agentHelp.js +55 -0
  8. package/dist/core/agentSpec.d.ts +37 -16
  9. package/dist/core/fileKnowledgeStore.d.ts +1 -3
  10. package/dist/core/fileKnowledgeStore.js +23 -29
  11. package/dist/core/hooks/fileScopeGate.d.ts +17 -2
  12. package/dist/core/hooks/fileScopeGate.js +45 -10
  13. package/dist/core/knowledge.d.ts +20 -17
  14. package/dist/core/knowledge.js +32 -60
  15. package/dist/core/knowledgeStore.d.ts +4 -9
  16. package/dist/core/knowledgeStore.js +16 -2
  17. package/dist/core/language.js +1 -1
  18. package/dist/core/messages/de.js +2 -2
  19. package/dist/core/messages/en.d.ts +2 -1
  20. package/dist/core/messages/en.js +2 -2
  21. package/dist/core/messages/es.js +2 -2
  22. package/dist/core/messages/fr.js +2 -2
  23. package/dist/core/runner.d.ts +5 -0
  24. package/dist/core/runner.js +1 -0
  25. package/dist/core/session.d.ts +12 -0
  26. package/dist/core/session.js +121 -40
  27. package/dist/core/sources.d.ts +24 -31
  28. package/dist/core/sources.js +45 -108
  29. package/dist/core/tools/humanApproval.js +3 -3
  30. package/dist/core/tools/knowledgeTools.d.ts +6 -6
  31. package/dist/core/tools/knowledgeTools.js +18 -53
  32. package/dist/core/tools/saveToSources.d.ts +6 -2
  33. package/dist/core/tools/saveToSources.js +17 -12
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js +1 -1
  36. package/dist/tui/ink/SessionView.d.ts +1 -0
  37. package/dist/tui/ink/SessionView.js +17 -4
  38. package/dist/tui/ink/runChatInk.d.ts +7 -0
  39. package/dist/tui/ink/runChatInk.js +6 -4
  40. package/package.json +1 -1
  41. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
  42. package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
  43. package/assets/knowledge-plugin-files/commands/lint.md +0 -5
  44. package/assets/knowledge-plugin-files/commands/query.md +0 -5
  45. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
  46. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
  47. package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
  48. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
@@ -1,3 +1,5 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
4
  import { createTranscriptLogger } from "./hooks/transcriptLogger.js";
3
5
  import { createStepGate } from "./hooks/stepGate.js";
@@ -8,7 +10,7 @@ import { createFileScopeGate } from "./hooks/fileScopeGate.js";
8
10
  import { createPlanGate } from "./hooks/planGate.js";
9
11
  import { createHumanApprovalServer } from "./tools/humanApproval.js";
10
12
  import { createManualLoginServer } from "./tools/manualLogin.js";
11
- import { createSaveToSourcesServer } from "./tools/saveToSources.js";
13
+ import { createSaveToSourcesServer, sourcesPromptSection } from "./tools/saveToSources.js";
12
14
  import { createTimeServer } from "./tools/time.js";
13
15
  import { TODO_TOOL } from "./todos.js";
14
16
  import { createModeControl } from "./modeControl.js";
@@ -16,6 +18,7 @@ import { createFileKnowledgeStore } from "./fileKnowledgeStore.js";
16
18
  import { createKnowledgeServer } from "./tools/knowledgeTools.js";
17
19
  import { allowAnyMcpTool } from "./mcpPermissions.js";
18
20
  import { knowledgePluginRoot, knowledgePromptSection } from "./knowledge.js";
21
+ import { AGENT_HELP_SKILL, identityPromptSection, writeAgentHelpPlugin } from "./agentHelp.js";
19
22
  import { replyLanguageInstruction } from "./language.js";
20
23
  import { chooseLanguage } from "./messages/index.js";
21
24
  import { createRunStore } from "./runs.js";
@@ -61,40 +64,57 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
61
64
  // The built-in knowledge base (rules in the system prompt + its skills/commands as a plugin) needs
62
65
  // somewhere to keep the wiki, so it follows `knowledgeDir`; `spec.knowledgeBase: false` opts out.
63
66
  const includeKnowledgeBase = Boolean(config.knowledgeDir) && spec.knowledgeBase !== false;
64
- // By default the agent reaches it only through the `knowledge_*` tools, over a store, never
65
- // with the file tools (ADR-024); `spec.knowledgeTools: "files"` keeps the file tools on it.
66
- const knowledgeViaStore = includeKnowledgeBase && (spec.knowledgeTools ?? "store") === "store";
67
- const notesDir = knowledgeViaStore ? undefined : config.knowledgeDir;
67
+ // The agent reaches it only through the `knowledge_*` tools, over a store, never with the file
68
+ // tools (ADR-024). Without it (`knowledgeBase: false`), `knowledgeDir` is a folder of the
69
+ // agent's own notes, kept with the file tools under its own rules.
70
+ const notesDir = includeKnowledgeBase ? undefined : config.knowledgeDir;
68
71
  const extraDirs = config.extraWritableDirs ?? [];
69
- // With the store, the file tools are only for the originals (reading) and the extra writable
72
+ const readableExtras = config.extraReadableDirs ?? [];
73
+ // With the knowledge base, the file tools are only for the originals (reading) and the extra
70
74
  // folders; without either, none.
71
75
  const fileTools = !includeFileTools
72
76
  ? []
73
- : !knowledgeViaStore
77
+ : !includeKnowledgeBase
74
78
  ? ["Read", "Write", "Edit", "Glob", "Grep"]
75
- : [...(config.sourcesDir || extraDirs.length ? ["Read", "Glob", "Grep"] : []), ...(extraDirs.length ? ["Write", "Edit"] : [])];
79
+ : [...(config.sourcesDir || extraDirs.length || readableExtras.length ? ["Read", "Glob", "Grep"] : []), ...(extraDirs.length ? ["Write", "Edit"] : [])];
76
80
  // Where Write/Edit may act and Grep may search (see hooks/fileScopeGate.ts): the
77
81
  // project's own folders, never the whole cwd — which would include whatever else the
78
82
  // project directory holds (the user's config...). `sourcesDir` is searchable but
79
83
  // deliberately NOT writable: originals stay as obtained, and the only way to add to it is
80
84
  // the `save_to_sources` tool (which never overwrites).
81
85
  const writableDirs = [notesDir, ...extraDirs].filter((d) => Boolean(d));
82
- const searchableDirs = [notesDir, config.sourcesDir, ...extraDirs].filter((d) => Boolean(d));
86
+ const searchableDirs = [notesDir, config.sourcesDir, ...extraDirs, ...readableExtras].filter((d) => Boolean(d));
83
87
  const readOnlyDirs = config.sourcesDir ? [config.sourcesDir] : [];
84
88
  // Skills/commands/plugins are a separate concern from the file tools: an agent that
85
89
  // wants a plugin-provided skill/command but has no notes or sources folder shouldn't have
86
90
  // to invent one just to get `cwd`/`skills: "all"`/`plugins` wired up. Gated on either
87
91
  // signal, not on `pluginRoots` alone, so an agent with file tools keeps getting the SDK's
88
92
  // own project-level `.claude/skills`/`.claude/commands` discovery.
89
- const pluginRoots = [...spec.pluginRoots(config), ...(includeKnowledgeBase ? [knowledgePluginRoot(knowledgeViaStore ? "tools" : "files")] : [])];
90
- const knowledgeStore = knowledgeViaStore && config.knowledgeDir
91
- ? (spec.knowledgeStore?.(config) ?? createFileKnowledgeStore(config.knowledgeDir, { pageTypes: spec.knowledgePageTypes, sourcesDir: config.sourcesDir }))
93
+ // With an identity, the agent-help skill (agentHelp.ts): written into the run's folder, since
94
+ // it carries this session's facts and the agent's own guide.
95
+ const helpPlugin = spec.identity
96
+ ? await writeAgentHelpPlugin(path.join(runDir, "agent-help"), {
97
+ mode,
98
+ switchable: modeControl.switchable,
99
+ knowledgeBase: includeKnowledgeBase,
100
+ ...(config.sourcesDir ? { sourcesFolder: `${path.relative(config.projectDir, config.sourcesDir).split(path.sep).join("/")}/` } : {}),
101
+ }, spec.helpGuide)
102
+ : undefined;
103
+ const pluginRoots = [
104
+ ...spec.pluginRoots(config),
105
+ ...(includeKnowledgeBase ? [knowledgePluginRoot()] : []),
106
+ ...(helpPlugin ? [helpPlugin] : []),
107
+ ];
108
+ const knowledgeStore = includeKnowledgeBase && config.knowledgeDir
109
+ ? (spec.knowledgeStore?.(config) ?? createFileKnowledgeStore(config.knowledgeDir, { pageTypes: spec.knowledgePageTypes }))
92
110
  : undefined;
93
111
  const includeSkillsAndPlugins = includeFileTools || pluginRoots.length > 0;
112
+ // "plugins": the skills of the plugins loaded, named as the SDK names them.
113
+ const offeredSkills = spec.skills === "plugins" ? await pluginSkills(pluginRoots) : spec.skills;
94
114
  const skillTools = includeSkillsAndPlugins ? ["Skill"] : [];
95
115
  // Whatever opt-in subagents `spec` wants for this config, or undefined if none apply.
96
- // Either opt-in feature needs the Agent tool (to delegate to a subagent) and Bash (used
97
- // only by the subagent itself — see createSubagentBashGate() below, which denies Bash
116
+ // Subagents need the Agent tool (to delegate to them), and Bash when one of them uses it
117
+ // (used only by the subagent itself — see createSubagentBashGate() below, which denies Bash
98
118
  // to the main agent regardless of which one put it in `tools`) — Bash has to be present
99
119
  // in this session's own tools for any subagent to be allowed to use it at all
100
120
  // (confirmed empirically the SDK refuses to spawn a subagent whose own tools list names
@@ -106,6 +126,12 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
106
126
  ? Object.fromEntries(Object.entries(subagents.agents).map(([name, agent]) => [name, { ...agent, prompt: withReplyLine(agent.prompt) }]))
107
127
  : undefined;
108
128
  const includeSubagentTools = subagents !== undefined;
129
+ // Bash only when a subagent can use it: one that lists it, or one without `tools`, which
130
+ // inherits every session tool. Its definition costs about 1.9k input tokens on every call
131
+ // (measured), and the main agent can't use it anyway.
132
+ const subagentTools = includeSubagentTools
133
+ ? ["Agent", ...(Object.values(subagents.agents).some((agent) => !agent.tools || agent.tools.includes("Bash")) ? ["Bash"] : [])]
134
+ : [];
109
135
  // The exact set of subagent_type values the Agent tool may spawn in this session — see
110
136
  // createSubagentTypeGate() below for why this can't just be "whatever's in `agents`
111
137
  // below": the SDK's own built-in "general-purpose" type is spawnable regardless of that
@@ -114,10 +140,21 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
114
140
  const allowedSubagentTypes = subagents?.allowedSubagentTypes ?? [];
115
141
  const transcriptPath = path.join(runDir, "transcript.jsonl");
116
142
  const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
143
+ // The person's preferences, by title, for the knowledge base's section (#33).
144
+ const preferences = knowledgeStore
145
+ ? (await knowledgeStore.list()).filter((page) => page.type === "preference" && page.status === "active").map(({ id, title }) => ({ id, title }))
146
+ : [];
147
+ const promptSections = [
148
+ spec.buildSystemPrompt(config),
149
+ ...(spec.identity ? [identityPromptSection(spec.identity)] : []),
150
+ // The sources' own section, with or without a knowledge base (#30).
151
+ ...(config.sourcesDir ? [sourcesPromptSection(config.projectDir, config.sourcesDir)] : []),
152
+ ...(includeKnowledgeBase && config.knowledgeDir
153
+ ? [knowledgePromptSection({ withSources: Boolean(config.sourcesDir), pageTypes: knowledgeStore?.types(), preferences })]
154
+ : []),
155
+ ];
117
156
  const sdkOptions = {
118
- systemPrompt: withReplyLine(includeKnowledgeBase && config.knowledgeDir
119
- ? `${spec.buildSystemPrompt(config)}\n\n${knowledgePromptSection(config.projectDir, config.knowledgeDir, config.sourcesDir, { tools: knowledgeViaStore, pageTypes: knowledgeStore?.types() })}`
120
- : spec.buildSystemPrompt(config)),
157
+ systemPrompt: withReplyLine(promptSections.join("\n\n")),
121
158
  // Not the SDK's default (every source): "user" hands the agent the runner's own Claude
122
159
  // Code configuration, and its `language` setting outranked the agent's prompt.
123
160
  settingSources: spec.settingSources ?? ["project"],
@@ -127,8 +164,16 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
127
164
  // whose own memory is the knowledge base. includeGitInstructions: false leaves out the
128
165
  // CLI's commit workflow and git context (the runner's git user name among it), which no
129
166
  // agent here needs and which pulled replies towards the runner's language (confirmed
130
- // empirically, see ADR-018).
131
- settings: { autoCompactEnabled: options.autoCompactEnabled ?? true, autoMemoryEnabled: false, includeGitInstructions: false },
167
+ // empirically, see ADR-018). disableClaudeAiConnectors: true keeps out the claude.ai
168
+ // connectors of the account the session runs with (Drive, Gmail…), which the CLI fetches
169
+ // whatever settingSources says (confirmed empirically): the runner's integrations, not the
170
+ // agent's. An MCP server the agent passes itself is unaffected.
171
+ settings: {
172
+ autoCompactEnabled: options.autoCompactEnabled ?? true,
173
+ autoMemoryEnabled: false,
174
+ includeGitInstructions: false,
175
+ disableClaudeAiConnectors: true,
176
+ },
132
177
  // No built-in tools except, if applicable, Read/Write/Edit/Glob/Grep scoped to the
133
178
  // project's own folders (fileScopeGate below), and WebFetch/WebSearch
134
179
  // with no domain restriction (not conditioned on includeFileTools: it's read-only,
@@ -139,8 +184,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
139
184
  // so the model ends up looking for SKILL.md files by hand (confirmed empirically).
140
185
  // TodoWrite: the SDK's task list for long jobs, in every session (the chats show the list
141
186
  // instead of the calls; see todos.ts). Accepted in an explicit list (confirmed empirically).
142
- tools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
143
- allowedTools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
187
+ tools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...subagentTools],
188
+ allowedTools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...subagentTools],
144
189
  // Every mcp__* tool (whatever spec.buildMcpServers() registers, approvals/manualLogin
145
190
  // when enabled below, sourceFiles, and any server a project's own .mcp.json
146
191
  // declares) is approved generically here rather than listed one by one — see
@@ -170,8 +215,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
170
215
  // "skills" acts as a name whitelist, not an addition: "all" enables both the
171
216
  // SDK's own official skills (pdf/docx), the project's own custom ones, and the
172
217
  // plugin-provided built-ins below. A spec's own list gets the knowledge base's
173
- // skills added, so it only names its own.
174
- skills: skillList(spec.skills, includeKnowledgeBase ? (knowledgeViaStore ? "tools" : "files") : null),
218
+ // skills and agent-help added, so it only names its own.
219
+ skills: skillList(offeredSkills, includeKnowledgeBase, Boolean(helpPlugin)),
175
220
  plugins: pluginRoots.map((pluginPath) => ({ type: "local", path: pluginPath, skipMcpDiscovery: true })),
176
221
  }
177
222
  : {}),
@@ -187,7 +232,6 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
187
232
  ...(config.sourcesDir
188
233
  ? {
189
234
  sourceFiles: createSaveToSourcesServer(runDir, config.sourcesDir, spec.saveToSourcesDescription, {
190
- knowledgeDir: config.knowledgeDir,
191
235
  projectDir: config.projectDir,
192
236
  interactive: mode !== "autonomous",
193
237
  }),
@@ -196,13 +240,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
196
240
  // The knowledge base's own tools, over its store (tools/knowledgeTools.ts, ADR-024).
197
241
  ...(knowledgeStore
198
242
  ? {
199
- knowledge: createKnowledgeServer(knowledgeStore, {
200
- runDir,
201
- interactive: mode !== "autonomous",
202
- sourcesDir: config.sourcesDir,
203
- projectDir: config.projectDir,
204
- knowledgeDir: config.knowledgeDir,
205
- }),
243
+ knowledge: createKnowledgeServer(knowledgeStore, { runDir, interactive: mode !== "autonomous" }),
206
244
  }
207
245
  : {}),
208
246
  // The date and date arithmetic, in every session and mode: only reads (tools/time.ts).
@@ -217,8 +255,16 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
217
255
  writableDirs,
218
256
  searchableDirs,
219
257
  readOnlyDirs,
220
- deniedPaths: config.deniedPaths ?? [],
221
- ...(knowledgeViaStore && config.knowledgeDir ? { toolOnlyDirs: [{ dir: config.knowledgeDir, instead: "the knowledge_* tools" }] } : {}),
258
+ // The SDK's own credentials, whatever the agent denies.
259
+ deniedPaths: [...(config.deniedPaths ?? []), path.join(claudeConfigDir(), ".credentials.json")],
260
+ // Read and Glob only in the agent's folders, plus what only the kit knows it
261
+ // needs: the run folder, the plugins, the project's .claude/ (when loaded) and
262
+ // the SDK's large tool results. Never the rest of the disk (~/.ssh, other
263
+ // projects' .env...), which an agent reading untrusted content could be talked into.
264
+ readableDirs: searchableDirs,
265
+ alsoReadable: [runDir, ...pluginRoots, ...((spec.settingSources ?? ["project"]).includes("project") ? [path.join(config.projectDir, ".claude")] : [])],
266
+ toolResultsRoot: claudeProjectDir(config.projectDir),
267
+ ...(includeKnowledgeBase && config.knowledgeDir ? { toolOnlyDirs: [{ dir: config.knowledgeDir, instead: "the knowledge_* tools" }] } : {}),
222
268
  }),
223
269
  ] }]
224
270
  : []),
@@ -254,15 +300,50 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
254
300
  return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language, ...(knowledgeStore ? { knowledgeStore } : {}) };
255
301
  }
256
302
  export { createModeControl, togglePlanMode } from "./modeControl.js";
257
- /** The knowledge plugin's skills, as the SDK names a plugin's skills ("plugin:skill"): the tools variant has no page templates skill (knowledge_create gives them). */
258
- const KNOWLEDGE_SKILLS = {
259
- tools: ["knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`),
260
- files: ["knowledge-pages", "knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`),
261
- };
262
- function skillList(skills, knowledgeVariant) {
303
+ /** The Claude Code configuration folder the SDK's CLI uses: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
304
+ function claudeConfigDir() {
305
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
306
+ }
307
+ /**
308
+ * The CLI's folder for a project, where it keeps each session's transcript and large tool
309
+ * results (`<session>/tool-results/`): the project's path with every character but letters
310
+ * and digits as "-" (confirmed empirically).
311
+ */
312
+ export function claudeProjectDir(projectDir) {
313
+ return path.join(claudeConfigDir(), "projects", path.resolve(projectDir).replace(/[^a-zA-Z0-9]/g, "-"));
314
+ }
315
+ /** The knowledge plugin's skills, as the SDK names a plugin's skills ("plugin:skill"). */
316
+ const KNOWLEDGE_SKILLS = ["knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`);
317
+ /**
318
+ * The skills of the plugins in `roots`, as the SDK names them: `<plugin>:<folder>`, the
319
+ * plugin's name from its `.claude-plugin/plugin.json` and the skill's folder under `skills/`,
320
+ * whatever its frontmatter's `name` says (confirmed empirically).
321
+ */
322
+ export async function pluginSkills(roots) {
323
+ const names = [];
324
+ for (const root of roots) {
325
+ let plugin;
326
+ try {
327
+ plugin = JSON.parse(await readFile(path.join(root, ".claude-plugin", "plugin.json"), "utf8")).name;
328
+ }
329
+ catch {
330
+ continue; // not a plugin the SDK would load
331
+ }
332
+ if (!plugin)
333
+ continue;
334
+ const folders = await readdir(path.join(root, "skills"), { withFileTypes: true }).catch(() => []);
335
+ for (const folder of folders.filter((entry) => entry.isDirectory())) {
336
+ const hasSkill = await readFile(path.join(root, "skills", folder.name, "SKILL.md")).then(() => true, () => false);
337
+ if (hasSkill)
338
+ names.push(`${plugin}:${folder.name}`);
339
+ }
340
+ }
341
+ return names;
342
+ }
343
+ function skillList(skills, knowledgeBase, agentHelp) {
263
344
  if (skills === undefined || skills === "all")
264
345
  return "all";
265
- return knowledgeVariant ? [...new Set([...skills, ...KNOWLEDGE_SKILLS[knowledgeVariant]])] : skills;
346
+ return [...new Set([...skills, ...(knowledgeBase ? KNOWLEDGE_SKILLS : []), ...(agentHelp ? [AGENT_HELP_SKILL] : [])])];
266
347
  }
267
348
  /**
268
349
  * User message queue backed by a single long-lived generator, for any multi-turn
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
3
3
  * manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
4
- * file tools can't write) with each original's hash, size and provenance, the version it
5
- * replaces, and the hash it had when a summary page first pointed to it. From that it tells
6
- * new, ingested, changed and missing originals apart, refuses duplicates, and retires an
7
- * original into `.agent-kit/retired/` instead of deleting it.
4
+ * file tools can't write) with each original's hash, size, provenance, the version it replaces
5
+ * and when its content last changed. From that it refuses duplicates, tells present, missing
6
+ * and retired originals apart, and retires an original into `.agent-kit/retired/` instead of
7
+ * deleting it. It knows nothing of the knowledge base: each owns its data, and the model
8
+ * connects them through their tools (#30). Hashes never leave it.
8
9
  */
9
10
  export declare const KIT_DIR = ".agent-kit";
10
11
  /** Where an original came from. */
@@ -33,8 +34,8 @@ export interface SourceEntry {
33
34
  replaces?: string;
34
35
  /** The page this one was generated from (a web page's markdown, from its HTML). */
35
36
  derivedFrom?: string;
36
- /** The hash when a summary page first pointed to it: a different hash now means it changed after its ingest. */
37
- ingestedHash?: string;
37
+ /** When its content last changed (ISO 8601): when it was added, or when its hash last differed. Not when it was only touched. */
38
+ changedAt: string;
38
39
  }
39
40
  export interface RetiredSource {
40
41
  path: string;
@@ -50,18 +51,23 @@ export interface SourceManifest {
50
51
  files: Record<string, SourceEntry>;
51
52
  retired: RetiredSource[];
52
53
  }
53
- export type SourceStatus = "new" | "ingested" | "changed" | "missing" | "present";
54
+ /** Present in the folder; missing (in the manifest, deleted by hand); or retired with `retire_source`. */
55
+ export type SourceStatus = "present" | "missing" | "retired";
54
56
  export interface SourceListing {
55
57
  path: string;
56
58
  status: SourceStatus;
57
59
  type: string;
60
+ /** When its content last changed (ISO 8601), to compare with when something was built from it. */
61
+ changedAt?: string;
58
62
  size?: number;
59
63
  pages?: number;
60
64
  slides?: number;
61
65
  origin?: SourceOrigin;
62
66
  replaces?: string;
63
- /** The summary pages that point to it (paths relative to the project). */
64
- summaries: string[];
67
+ /** For a retired one: when, why, and what replaced it. */
68
+ retiredAt?: string;
69
+ why?: "wrong" | "replaced";
70
+ replacedBy?: string;
65
71
  }
66
72
  export declare function hashFile(file: string): Promise<string>;
67
73
  /** Resolves `candidate` against `base`; the absolute path only if it stays inside it. */
@@ -70,18 +76,15 @@ export declare function loadManifest(sourcesDir: string): Promise<SourceManifest
70
76
  export declare function saveManifest(sourcesDir: string, manifest: SourceManifest): Promise<void>;
71
77
  /** Every original under `sourcesDir` (paths relative to it), leaving out hidden files and folders (the kit's own among them). */
72
78
  export declare function scanSources(sourcesDir: string): Promise<string[]>;
73
- /** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
74
- export declare function summariesByOriginal(knowledgeDir: string): Promise<Map<string, string[]>>;
75
79
  /** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
76
80
  export declare function pdfPageCount(bytes: Buffer): number | undefined;
77
81
  /** Slides of a PPTX, from the file names in the zip (stored uncompressed). */
78
82
  export declare function pptxSlideCount(bytes: Buffer): number | undefined;
79
83
  /**
80
- * Every original with its status, recording in the manifest the ones copied in by hand and,
81
- * the first time a summary page points to one, its hash at ingest. Without a knowledge
82
- * folder there's no ingest to compare with: originals are "present".
84
+ * Every original with its status and when its content last changed, recording in the manifest
85
+ * the ones copied in by hand; then the missing ones and the retired ones.
83
86
  */
84
- export declare function listSources(sourcesDir: string, knowledgeDir: string | undefined, projectDir: string): Promise<SourceListing[]>;
87
+ export declare function listSources(sourcesDir: string): Promise<SourceListing[]>;
85
88
  export interface AddedSource {
86
89
  /** Where it is, relative to `sourcesDir`. */
87
90
  path: string;
@@ -99,24 +102,14 @@ export declare function addSource(sourcesDir: string, file: string, destination:
99
102
  derivedFrom?: string;
100
103
  name?: string;
101
104
  }): Promise<AddedSource>;
102
- /** What retiring an original did to its summary pages (paths relative to the project). */
105
+ /** Where a retired original went. */
103
106
  export interface RetireResult {
107
+ /** Relative to `sourcesDir`. */
104
108
  movedTo: string;
105
- summaries: {
106
- page: string;
107
- marked: "retired" | "superseded";
108
- supersededBy?: string;
109
- }[];
110
109
  }
111
110
  /**
112
- * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
113
- * and records why. Its summary pages are marked: retired if the original was wrong (what it
114
- * taught was wrong), superseded if it was replaced, pointing to the replacement's summary
115
- * when there is one.
116
- */
117
- export declare function retireSource(sourcesDir: string, knowledgeDir: string | undefined, projectDir: string, source: string, why: "wrong" | "replaced", reason: string, replacedBy?: string): Promise<RetireResult>;
118
- /**
119
- * Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
120
- * summary page was just written from it, so "changed" means changed after this.
111
+ * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder) and
112
+ * records why. What was built from it (summary pages) is the knowledge base's to update: the
113
+ * model does it with its tools.
121
114
  */
122
- export declare function recordIngest(sourcesDir: string, source: string): Promise<void>;
115
+ export declare function retireSource(sourcesDir: string, source: string, why: "wrong" | "replaced", reason: string, replacedBy?: string): Promise<RetireResult>;
@@ -5,10 +5,11 @@ import path from "node:path";
5
5
  /**
6
6
  * The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
7
7
  * manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
8
- * file tools can't write) with each original's hash, size and provenance, the version it
9
- * replaces, and the hash it had when a summary page first pointed to it. From that it tells
10
- * new, ingested, changed and missing originals apart, refuses duplicates, and retires an
11
- * original into `.agent-kit/retired/` instead of deleting it.
8
+ * file tools can't write) with each original's hash, size, provenance, the version it replaces
9
+ * and when its content last changed. From that it refuses duplicates, tells present, missing
10
+ * and retired originals apart, and retires an original into `.agent-kit/retired/` instead of
11
+ * deleting it. It knows nothing of the knowledge base: each owns its data, and the model
12
+ * connects them through their tools (#30). Hashes never leave it.
12
13
  */
13
14
  export const KIT_DIR = ".agent-kit";
14
15
  const MANIFEST = "sources.json";
@@ -70,41 +71,20 @@ export async function scanSources(sourcesDir) {
70
71
  await walk(sourcesDir);
71
72
  return found.sort();
72
73
  }
73
- /** An entry for `file`, reusing the recorded hash when its size and modification time haven't changed. */
74
+ /**
75
+ * An entry for `file`, reusing the recorded hash when its size and modification time haven't
76
+ * changed; `changedAt` moves only when the content (its hash) did. Entries from before
77
+ * `changedAt` existed take their `addedAt`.
78
+ */
74
79
  async function freshEntry(file, known, origin) {
75
80
  const info = await stat(file);
76
- if (known && known.size === info.size && known.mtimeMs === info.mtimeMs)
77
- return known;
78
- return { ...(known ?? { addedAt: now(), origin }), hash: await hashFile(file), size: info.size, mtimeMs: info.mtimeMs };
79
- }
80
- /** The original a frontmatter `file:` value points to (a relative path or a markdown link), resolved from the page's folder. */
81
- function linkedFile(value, pageDir) {
82
- const link = /\]\(([^)]+)\)/.exec(value)?.[1] ?? value.trim().replace(/^["']|["']$/g, "");
83
- if (!link || /^[a-z]+:\/\//i.test(link))
84
- return undefined;
85
- return path.resolve(pageDir, decodeURI(link));
86
- }
87
- /** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
88
- export async function summariesByOriginal(knowledgeDir) {
89
- const byOriginal = new Map();
90
- const dir = path.join(knowledgeDir, "summaries");
91
- let names;
92
- try {
93
- names = await readdir(dir);
94
- }
95
- catch {
96
- return byOriginal;
97
- }
98
- for (const name of names.filter((n) => n.endsWith(".md"))) {
99
- const page = path.join(dir, name);
100
- const text = await readFile(page, "utf8");
101
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
102
- const value = /^file:\s*(.+)$/m.exec(frontmatter)?.[1];
103
- const original = value ? linkedFile(value, dir) : undefined;
104
- if (original)
105
- byOriginal.set(path.normalize(original), [...(byOriginal.get(path.normalize(original)) ?? []), page]);
106
- }
107
- return byOriginal;
81
+ const previous = known ? { ...known, changedAt: known.changedAt ?? known.addedAt } : undefined;
82
+ if (previous && previous.size === info.size && previous.mtimeMs === info.mtimeMs)
83
+ return previous;
84
+ const hash = await hashFile(file);
85
+ const stamp = now();
86
+ const base = previous ?? { addedAt: stamp, origin, changedAt: stamp };
87
+ return { ...base, hash, size: info.size, mtimeMs: info.mtimeMs, changedAt: previous && previous.hash === hash ? previous.changedAt : stamp };
108
88
  }
109
89
  /** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
110
90
  export function pdfPageCount(bytes) {
@@ -124,31 +104,19 @@ export function pptxSlideCount(bytes) {
124
104
  }
125
105
  const typeOf = (file) => path.extname(file).slice(1).toLowerCase() || "file";
126
106
  /**
127
- * Every original with its status, recording in the manifest the ones copied in by hand and,
128
- * the first time a summary page points to one, its hash at ingest. Without a knowledge
129
- * folder there's no ingest to compare with: originals are "present".
107
+ * Every original with its status and when its content last changed, recording in the manifest
108
+ * the ones copied in by hand; then the missing ones and the retired ones.
130
109
  */
131
- export function listSources(sourcesDir, knowledgeDir, projectDir) {
132
- return serialized(sourcesDir, () => listSourcesNow(sourcesDir, knowledgeDir, projectDir));
110
+ export function listSources(sourcesDir) {
111
+ return serialized(sourcesDir, () => listSourcesNow(sourcesDir));
133
112
  }
134
- async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
113
+ async function listSourcesNow(sourcesDir) {
135
114
  const manifest = await loadManifest(sourcesDir);
136
- const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
137
115
  const files = await scanSources(sourcesDir);
138
116
  const listing = [];
139
117
  for (const rel of files) {
140
118
  const full = path.join(sourcesDir, rel);
141
119
  const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
142
- const pagesOf = summaries.get(path.normalize(full)) ?? [];
143
- let status = "present";
144
- if (knowledgeDir) {
145
- if (pagesOf.length === 0)
146
- status = "new";
147
- else {
148
- entry.ingestedHash ??= entry.hash;
149
- status = entry.ingestedHash === entry.hash ? "ingested" : "changed";
150
- }
151
- }
152
120
  manifest.files[rel] = entry;
153
121
  const type = typeOf(rel);
154
122
  const counts = type === "pdf" || type === "pptx"
@@ -156,18 +124,28 @@ async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
156
124
  : {};
157
125
  listing.push({
158
126
  path: rel,
159
- status,
127
+ status: "present",
160
128
  type,
129
+ changedAt: entry.changedAt,
161
130
  size: entry.size,
162
131
  ...counts,
163
132
  origin: entry.origin,
164
133
  ...(entry.replaces ? { replaces: entry.replaces } : {}),
165
- summaries: pagesOf.map((page) => posix(path.relative(projectDir, page))),
166
134
  });
167
135
  }
168
136
  for (const [rel, entry] of Object.entries(manifest.files)) {
169
137
  if (!files.includes(rel))
170
- listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin, summaries: [] });
138
+ listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin });
139
+ }
140
+ for (const retired of manifest.retired) {
141
+ listing.push({
142
+ path: retired.path,
143
+ status: "retired",
144
+ type: typeOf(retired.path),
145
+ retiredAt: retired.retiredAt,
146
+ why: retired.why,
147
+ ...(retired.replacedBy ? { replacedBy: retired.replacedBy } : {}),
148
+ });
171
149
  }
172
150
  await saveManifest(sourcesDir, manifest);
173
151
  return listing;
@@ -219,11 +197,13 @@ async function addSourceNow(sourcesDir, file, destination, origin, extra) {
219
197
  }
220
198
  const rel = posix(path.relative(sourcesDir, target));
221
199
  const info = await stat(target);
200
+ const stamp = now();
222
201
  manifest.files[rel] = {
223
202
  hash,
224
203
  size: info.size,
225
204
  mtimeMs: info.mtimeMs,
226
- addedAt: now(),
205
+ addedAt: stamp,
206
+ changedAt: stamp,
227
207
  origin,
228
208
  ...(extra.replaces ? { replaces: posix(path.relative(sourcesDir, resolveWithin(sourcesDir, extra.replaces))) } : {}),
229
209
  ...(extra.derivedFrom ? { derivedFrom: extra.derivedFrom } : {}),
@@ -231,26 +211,15 @@ async function addSourceNow(sourcesDir, file, destination, origin, extra) {
231
211
  await saveManifest(sourcesDir, manifest);
232
212
  return { path: rel };
233
213
  }
234
- /** Adds frontmatter fields and a notice under the frontmatter of a markdown page. */
235
- async function markPage(page, fields, notice) {
236
- const text = await readFile(page, "utf8");
237
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
238
- const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
239
- const updated = match
240
- ? `---\n${match[1].split(/\r?\n/).filter((line) => !Object.keys(fields).some((key) => line.startsWith(`${key}:`))).concat(lines).join("\n")}\n---\n\n> ${notice}\n\n${text.slice(match[0].length).replace(/^\s+/, "")}`
241
- : `---\n${lines.join("\n")}\n---\n\n> ${notice}\n\n${text}`;
242
- await writeFile(page, updated);
243
- }
244
214
  /**
245
- * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
246
- * and records why. Its summary pages are marked: retired if the original was wrong (what it
247
- * taught was wrong), superseded if it was replaced, pointing to the replacement's summary
248
- * when there is one.
215
+ * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder) and
216
+ * records why. What was built from it (summary pages) is the knowledge base's to update: the
217
+ * model does it with its tools.
249
218
  */
250
- export function retireSource(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
251
- return serialized(sourcesDir, () => retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy));
219
+ export function retireSource(sourcesDir, source, why, reason, replacedBy) {
220
+ return serialized(sourcesDir, () => retireSourceNow(sourcesDir, source, why, reason, replacedBy));
252
221
  }
253
- async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
222
+ async function retireSourceNow(sourcesDir, source, why, reason, replacedBy) {
254
223
  const full = resolveWithin(sourcesDir, source);
255
224
  if (!full || !(await stat(full).catch(() => null))?.isFile())
256
225
  throw new Error(`"${source}" isn't an original in sources/.`);
@@ -260,7 +229,6 @@ async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why
260
229
  const replacement = replacedBy ? resolveWithin(sourcesDir, replacedBy) : undefined;
261
230
  if (replacedBy && (!replacement || !(await stat(replacement).catch(() => null))))
262
231
  throw new Error(`"${replacedBy}" isn't an original in sources/.`);
263
- const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
264
232
  const manifest = await loadManifest(sourcesDir);
265
233
  const stamp = now();
266
234
  const movedTo = posix(path.join(KIT_DIR, RETIRED_DIR, `${stamp.replace(/[:.]/g, "-")}-${path.basename(rel)}`));
@@ -277,36 +245,5 @@ async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why
277
245
  });
278
246
  delete manifest.files[rel];
279
247
  await saveManifest(sourcesDir, manifest);
280
- const date = stamp.slice(0, 10);
281
- const replacementSummary = replacement ? summaries.get(path.normalize(replacement))?.[0] : undefined;
282
- const result = { movedTo, summaries: [] };
283
- for (const page of summaries.get(path.normalize(full)) ?? []) {
284
- if (why === "wrong") {
285
- await markPage(page, { status: "retired", retired: date }, `Retired on ${date}: its original was wrong (${reason}). Don't rely on what it says.`);
286
- result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "retired" });
287
- }
288
- else {
289
- const link = replacementSummary ? posix(path.relative(path.dirname(page), replacementSummary)) : undefined;
290
- await markPage(page, { status: "superseded", superseded: date, ...(link ? { superseded_by: link } : {}) }, `Superseded on ${date}: its original was replaced (${reason})${link ? `; see [the new summary](${link})` : ""}.`);
291
- result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "superseded", ...(replacementSummary ? { supersededBy: posix(path.relative(projectDir, replacementSummary)) } : {}) });
292
- }
293
- }
294
- return result;
295
- }
296
- /**
297
- * Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
298
- * summary page was just written from it, so "changed" means changed after this.
299
- */
300
- export function recordIngest(sourcesDir, source) {
301
- return serialized(sourcesDir, async () => {
302
- const full = resolveWithin(sourcesDir, source);
303
- if (!full || !(await stat(full).catch(() => null))?.isFile())
304
- return;
305
- const rel = posix(path.relative(sourcesDir, full));
306
- const manifest = await loadManifest(sourcesDir);
307
- const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
308
- entry.ingestedHash = entry.hash;
309
- manifest.files[rel] = entry;
310
- await saveManifest(sourcesDir, manifest);
311
- });
248
+ return { movedTo };
312
249
  }