@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
package/README.md CHANGED
@@ -38,11 +38,11 @@ A foundation for building AI agents on top of the [Claude Agent SDK](https://www
38
38
 
39
39
  See [everything he can do](https://falkenslab.github.io/agent-kit/docs/next/examples/captain-whiskers#what-he-can-do), with screenshots.
40
40
 
41
- ### teacher-agent
41
+ ### miyagi
42
42
 
43
- [teacher-agent](https://github.com/falkenslab/teacher-agent) is a real assistant for Moodle teachers. It signs in with your account in a Chrome window and works as you would: it grades submissions, answers in the forum, creates or reviews content and sums up how the class is going. It asks for your permission before publishing anything students will see, and takes notes about the course to remember everything in the next session.
43
+ [miyagi](https://github.com/falkenslab/miyagi) is a real assistant for Moodle teachers. It signs in with your account in a Chrome window and works as you would: it grades submissions, answers in the forum, creates or reviews content and sums up how the class is going. It asks for your permission before publishing anything students will see, and takes notes about the course to remember everything in the next session.
44
44
 
45
- Oversight, memory, safety and the chat come from the kit; teacher-agent brings what it knows about Moodle and driving the browser.
45
+ Oversight, memory, safety and the chat come from the kit; miyagi brings what it knows about Moodle and driving the browser.
46
46
 
47
47
  ## Installation
48
48
 
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: agent-help
3
+ description: How to use this agent - its chat's modes, keys and slash commands, resuming an earlier conversation, approvals and questions, the command line, and the agent's own commands and configuration. Load it whenever the person asks how to do something with you, what a key or command does, or what you can do.
4
+ ---
5
+
6
+ # Helping the person use you
7
+
8
+ Answer from this page only: the chat's part below is the same for every agent built on agent-kit, and the "This session" and "This agent" sections at the end are about you. If something isn't covered here, say you don't know rather than inventing a key, command or option. Answer in the language you reply in, briefly, with the exact key or command; keys and commands are typed as written here, whatever the language.
9
+
10
+ ## Modes
11
+
12
+ How much you ask before acting. The current one is in the status bar, below the prompt.
13
+
14
+ - **interactive**: asks before every single action (each tool call), with the tool and its parameters.
15
+ - **guided**: asks only before something hard to undo or visible to others (publishing, sending); everything else runs on its own.
16
+ - **autonomous**: never asks; there's nobody to ask. A session that starts autonomous stays autonomous.
17
+ - **plan**: only reads and plans, changing nothing, until the plan is approved. The plan comes as a panel: **Run it** (leaves plan mode and carries it out), **Keep planning** (with a comment on what should change) or **Cancel**.
18
+
19
+ `Shift+Tab` switches to the next mode the session allows (guided, interactive, plan, in a loop). `/plan` turns plan mode on, and off again back to the mode it came from.
20
+
21
+ ## Approvals and questions
22
+
23
+ - **An approval** shows what is about to happen: **Yes** to go on, **No** to refuse that one action (the agent tries something else), **Stop** to stop it altogether.
24
+ - **A question** with options: `↑`/`↓` and `Enter` to choose, `Space` to mark when several can be chosen, or **Other** to type your own answer. A free question: type the answer and `Enter`, or `Esc` for no answer.
25
+ - **A request for a file**: drag the file onto the terminal (or type its path) and `Enter`, or just `Enter` if you don't have it.
26
+ - **A manual step** (signing in by hand, for an agent that drives a window): do it, then **Done, continue**.
27
+ - In interactive mode every action asks; switching to guided with `Shift+Tab` stops that.
28
+
29
+ ## Keys
30
+
31
+ - `Enter` sends the message; `\` then `Enter`, or `Ctrl+J`, starts a new line.
32
+ - `Tab` completes a `/command`, or takes the grey suggestion.
33
+ - `↑`/`↓` go through earlier messages; `Ctrl+R` searches them. `↑` on an empty prompt edits the last message still waiting to be sent.
34
+ - `Ctrl+W` deletes a word, `Ctrl+K` to the end of the line, `Ctrl+U` the whole line; `Ctrl+←`/`Ctrl+→` move by word.
35
+ - `@` mentions a file, with completion.
36
+ - `?` on an empty prompt shows the shortcuts.
37
+ - `Esc` interrupts the agent's turn; `Ctrl+C` interrupts it, or leaves at an empty prompt.
38
+ - `Ctrl+O` unfolds (and folds again) the tool calls; the task list of a long job stays on screen while it runs.
39
+ - `PgUp`/`PgDn` or the mouse wheel scroll; `Ctrl+End` goes back to the bottom. In full screen, drag to select and right-click to copy.
40
+
41
+ Messages typed while the agent works are queued and sent when it finishes.
42
+
43
+ ## Commands
44
+
45
+ - `/exit` or `/quit`: leave the chat (an agent can name its own exit commands instead).
46
+ - `/copy`: copy the last reply to the clipboard.
47
+ - `/resume`: pick an earlier conversation to go on with (`↑`/`↓`, `Enter`, `Esc` to cancel), where the agent keeps its conversations.
48
+ - `/plan`: plan mode on or off (see Modes).
49
+
50
+ Typing `/` and `Tab` lists every command, the agent's own included (named `/plugin:command`). An unknown `/command` isn't sent.
51
+
52
+ ## Command line
53
+
54
+ - `--continue`: start with the latest conversation instead of a new one.
55
+ - `--language=<code>`: the language of the chat and of the replies: `en`, `es`, `fr` or `de`. Without it, the agent's configured one, or the system's.
56
+
57
+ Some agents use a simpler line-by-line chat that has only part of this (no full screen, no `/copy`).
@@ -4,4 +4,4 @@ description: Ingest material into the knowledge base right now - the files or to
4
4
 
5
5
  Apply the `knowledge-ingest` skill right now to: $ARGUMENTS
6
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.
7
+ If nothing is given above, ingest every original in `list_sources` that no summary is about, and update the summaries whose original's `changedAt` is after their `ingested` (both ISO 8601 in UTC: the later one sorts after as text). When you're done, summarize which pages you created and updated, and anything you couldn't read.
@@ -9,7 +9,12 @@ A source is ingested once, and from then on the knowledge base holds what it tau
9
9
 
10
10
  ## 1. Check it isn't already ingested
11
11
 
12
- `list_sources` gives each original's status and summary pages: *new* needs ingesting; *ingested* has a page; *changed* changed after its ingest (update its summary). For a document from elsewhere, `knowledge_search` its title or URL.
12
+ The sources and the knowledge base each keep their own data; you match them:
13
+
14
+ - `list_sources` gives each original with `changedAt`, when its content last changed.
15
+ - `knowledge_index` gives each summary with `file`, the original it's about, and `ingested`, when it was written.
16
+
17
+ An original with no summary needs ingesting. One whose `changedAt` is after its summary's `ingested` changed since: update its summary (`knowledge_rewrite`, which sets `ingested` again). Both dates are ISO 8601 in UTC, so the later one sorts after as text; use `date_math` if in doubt. For a document from elsewhere, `knowledge_search` its title or URL.
13
18
 
14
19
  ## 2. Read it for real
15
20
 
@@ -17,7 +22,7 @@ Read the whole source, not its first page (a long PDF in parts: `list_sources` g
17
22
 
18
23
  ## 3. Write the summary page
19
24
 
20
- `knowledge_create` with type `summary` (call it once without content to get the template), and the field `file` (the original, relative to sources/) or `url`. Key points quote literally where exactness matters. Create the new concept and entity pages it feeds in the same call (`also`): they can link to each other.
25
+ `knowledge_create` with type `summary` (call it once without content to get the template), and the field `file` (the original, exactly as `list_sources` names it) or `url`; `ingested` is set for you. Key points quote literally where exactness matters. Create the new concept and entity pages it feeds in the same call (`also`): they can link to each other.
21
26
 
22
27
  ## 4. Update concepts and entities
23
28
 
@@ -33,4 +38,4 @@ If the source contradicts a page, record both versions with attribution in both
33
38
 
34
39
  ## An original that was wrong or replaced
35
40
 
36
- `retire_source` (the person approves it) moves it aside and marks its summary retired (wrong) or superseded (replaced). Then `knowledge_check` lists the passages that cite it: after a wrong one, correct or remove what it taught (`knowledge_edit`, or `knowledge_retire` for a page that was only about it); after a replaced one, ingest the new original and re-ground those passages on it.
41
+ `retire_source` (it asks the person itself) moves it aside; `list_sources` shows it as retired, with why and what replaced it. The knowledge base is then yours to update: find its summaries (`knowledge_index`, by `file`) and the passages that cite it (`knowledge_search` its file name). After a wrong one, `knowledge_retire` its summary (it asks the person itself: don't ask them first) and correct or remove what it taught (`knowledge_edit`, or `knowledge_retire` for a page that was only about it). After a replaced one, ingest the new original, `knowledge_supersede` the old summary by the new one, and re-ground those passages on it.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: knowledge-lint
3
- description: Health-check the knowledge base - broken links, orphan pages, links to retired pages, missing or changed originals, duplicated concepts, mentioned-but-missing concepts, contradictions, gaps - fix what's mechanical and report the rest.
3
+ description: Health-check the knowledge base - broken links, orphan pages, links to retired pages, summaries behind their originals, duplicated concepts, mentioned-but-missing concepts, contradictions, gaps - fix what's mechanical and report the rest.
4
4
  ---
5
5
 
6
6
  # Linting the knowledge base
@@ -9,14 +9,21 @@ A knowledge base maintained across sessions drifts. Lint it when asked and after
9
9
 
10
10
  ## 1. The mechanical checks, in one call
11
11
 
12
- `knowledge_check` returns broken links, orphan pages, links to retired pages, summaries whose original is gone, originals that are new, changed or missing, and the passages that cite a retired original. The index and backlinks are kept by the store: there's nothing to fix there.
12
+ `knowledge_check` returns broken links, orphan pages and links to retired pages. The index and backlinks are kept by the store: there's nothing to fix there.
13
13
 
14
14
  - **Broken links**: fix them with `knowledge_edit` when the right target is obvious (a typo, a page under a close name: `knowledge_search`); otherwise report them.
15
15
  - **Orphans**: link them from the pages where they belong, or report why they don't belong anywhere.
16
- - **Links to retired pages, passages citing a retired original**: correct or re-ground them.
17
- - **New or changed originals**: report them as what to ingest or update next (or do it, if asked).
16
+ - **Links to retired pages**: correct or re-ground them.
18
17
 
19
- ## 2. The checks that need judgement
18
+ ## 2. The knowledge base against its originals
19
+
20
+ When there's a sources folder, match `list_sources` with `knowledge_index`:
21
+
22
+ - **Not ingested**: present originals no summary is about. Report them as what to ingest next (or do it, if asked).
23
+ - **Behind**: summaries whose original's `changedAt` is after their `ingested`, (ISO 8601 in UTC: the later one sorts after as text). Report them as what to update.
24
+ - **Retired or missing originals**: summaries still active whose original is retired (`knowledge_retire` them if it was wrong, which asks the person itself, or `knowledge_supersede` them if it was replaced) or missing (report them); and the passages that cite them (`knowledge_search` the file name).
25
+
26
+ ## 3. The checks that need judgement
20
27
 
21
28
  Use `knowledge_index` and `knowledge_search`; read only the pages a check needs.
22
29
 
@@ -0,0 +1,30 @@
1
+ import type { AgentIdentity, Mode } from "./agentSpec.js";
2
+ /**
3
+ * The agent's self-knowledge: who it is (a section of the system prompt) and how it is used
4
+ * (the `agent-help` skill). The chat's part of the skill is the kit's, the same for every
5
+ * agent and kept next to the chat's code (`assets/agent-help/SKILL.md`, checked against the
6
+ * chat's commands and keys by a test); the agent's part is its own guide (`AgentSpec.helpGuide`).
7
+ */
8
+ /** The plugin the skill is loaded from, as the SDK names its skills ("agent-kit:agent-help"). */
9
+ export declare const AGENT_HELP_SKILL = "agent-kit:agent-help";
10
+ /** Absolute path of the kit's part of the skill, next to `dist/` (or `src/` under tsx). */
11
+ export declare function agentHelpSkillPath(): string;
12
+ /** The "Who you are" section appended to the system prompt: name, version, what it is, the kit's version. */
13
+ export declare function identityPromptSection(identity: AgentIdentity): string;
14
+ /** What the generated skill says about this session. */
15
+ export interface AgentHelpSession {
16
+ mode: Mode;
17
+ /** The modes Shift+Tab goes through (`ModeControl.switchable`). */
18
+ switchable: readonly Mode[];
19
+ /** The built-in knowledge base is on: its slash commands exist. */
20
+ knowledgeBase: boolean;
21
+ /** The sources folder, as shown to the model (e.g. "sources/"). */
22
+ sourcesFolder?: string;
23
+ }
24
+ /**
25
+ * Writes the `agent-help` plugin into `dir` (a folder of this run's), with the kit's part of
26
+ * the skill, this session's facts and the agent's own guide, and returns its root. Generated
27
+ * rather than shipped as it is: the guide goes inside the skill, so the agent needs no file
28
+ * tools to read it, and nothing about it reaches the context until the skill is loaded.
29
+ */
30
+ export declare function writeAgentHelpPlugin(dir: string, session: AgentHelpSession, helpGuide?: string): Promise<string>;
@@ -0,0 +1,55 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { agentKitVersion } from "./version.js";
5
+ /**
6
+ * The agent's self-knowledge: who it is (a section of the system prompt) and how it is used
7
+ * (the `agent-help` skill). The chat's part of the skill is the kit's, the same for every
8
+ * agent and kept next to the chat's code (`assets/agent-help/SKILL.md`, checked against the
9
+ * chat's commands and keys by a test); the agent's part is its own guide (`AgentSpec.helpGuide`).
10
+ */
11
+ /** The plugin the skill is loaded from, as the SDK names its skills ("agent-kit:agent-help"). */
12
+ export const AGENT_HELP_SKILL = "agent-kit:agent-help";
13
+ /** Absolute path of the kit's part of the skill, next to `dist/` (or `src/` under tsx). */
14
+ export function agentHelpSkillPath() {
15
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "agent-help", "SKILL.md");
16
+ }
17
+ /** The "Who you are" section appended to the system prompt: name, version, what it is, the kit's version. */
18
+ export function identityPromptSection(identity) {
19
+ const who = `${identity.name}${identity.version ? ` ${identity.version}` : ""}${identity.description ? `: ${identity.description}` : ""}`;
20
+ return `## Who you are
21
+ You are ${who}. You are built on agent-kit ${agentKitVersion()}, which provides your chat (its modes, keys and slash commands) and your built-in tools.
22
+ When the person asks how to use you (your modes, keys or commands, resuming an earlier conversation, your configuration, what you can do), load the \`agent-help\` skill and answer from it; don't invent options it doesn't list.`;
23
+ }
24
+ /** The skill's "This session" section. */
25
+ function sessionSection(session) {
26
+ const lines = [
27
+ `- This session started in **${session.mode}** mode.`,
28
+ session.switchable.length > 1
29
+ ? `- \`Shift+Tab\` goes through: ${session.switchable.join(", ")}.`
30
+ : "- The mode can't be switched during this session.",
31
+ ];
32
+ if (session.knowledgeBase)
33
+ lines.push("- You keep a knowledge base (your memory across sessions). Its commands: `/knowledge:ingest` (learn the new or changed files of the sources folder), `/knowledge:query <question>` (answer from it), `/knowledge:lint` (check it and fix what's mechanical).");
34
+ if (session.sourcesFolder)
35
+ lines.push(`- Originals go in \`${session.sourcesFolder}\`: the person drops files there (or you ask for one, or download it), and you never change them.`);
36
+ return `## This session\n\n${lines.join("\n")}`;
37
+ }
38
+ /**
39
+ * Writes the `agent-help` plugin into `dir` (a folder of this run's), with the kit's part of
40
+ * the skill, this session's facts and the agent's own guide, and returns its root. Generated
41
+ * rather than shipped as it is: the guide goes inside the skill, so the agent needs no file
42
+ * tools to read it, and nothing about it reaches the context until the skill is loaded.
43
+ */
44
+ export async function writeAgentHelpPlugin(dir, session, helpGuide) {
45
+ const kitPart = (await readFile(agentHelpSkillPath(), "utf8")).trimEnd();
46
+ const guide = helpGuide ? (await readFile(helpGuide, "utf8")).trim() : "";
47
+ const agentPart = guide
48
+ ? `## This agent\n\nThe agent's own guide, for everything about its domain (its commands, configuration, folders):\n\n${guide}`
49
+ : "## This agent\n\nThis agent has no guide of its own: for its domain, say only what your instructions tell you, and that the rest isn't documented.";
50
+ await mkdir(path.join(dir, ".claude-plugin"), { recursive: true });
51
+ await mkdir(path.join(dir, "skills", "agent-help"), { recursive: true });
52
+ await writeFile(path.join(dir, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: "agent-kit", description: "How to use this agent: the kit's chat and the agent's own guide." }, null, 2)}\n`);
53
+ await writeFile(path.join(dir, "skills", "agent-help", "SKILL.md"), `${kitPart}\n\n${sessionSection(session)}\n\n${agentPart}\n`);
54
+ return dir;
55
+ }
@@ -40,6 +40,12 @@ export interface BaseSessionConfig {
40
40
  sourcesDir?: string;
41
41
  /** Directories besides `knowledgeDir`/`sourcesDir` where Write/Edit are allowed (see hooks/fileScopeGate.ts). */
42
42
  extraWritableDirs?: string[];
43
+ /**
44
+ * Directories besides `sourcesDir`/`extraWritableDirs` the agent may read and search (Read,
45
+ * Glob, Grep), never write. Read and Glob reach nothing else on the disk but what the kit
46
+ * knows the agent needs (see hooks/fileScopeGate.ts).
47
+ */
48
+ extraReadableDirs?: string[];
43
49
  /** Files or directories the agent must never read, search or write, e.g. a config file holding a password (see hooks/fileScopeGate.ts). */
44
50
  deniedPaths?: string[];
45
51
  /** Extra values (e.g. a password) to scrub out of the transcript log — see hooks/transcriptLogger.ts. */
@@ -70,6 +76,18 @@ export interface BaseSessionConfig {
70
76
  export interface AgentSpec<TConfig extends BaseSessionConfig> {
71
77
  /** The system prompt for this run — everything role/mode/domain-specific lives behind this call. */
72
78
  buildSystemPrompt(config: TConfig): string;
79
+ /**
80
+ * Who the agent is. With it, the kit tells the model its name, version and what it is, plus
81
+ * agent-kit's version, and offers the `agent-help` skill, which answers how to use the
82
+ * agent: the kit's chat (modes, keys, slash commands, resuming) and `helpGuide`. Without
83
+ * it, neither.
84
+ */
85
+ identity?: AgentIdentity;
86
+ /**
87
+ * Absolute path of a markdown guide to the agent's own domain (its commands, configuration,
88
+ * folders), which the `agent-help` skill includes. Only with `identity`.
89
+ */
90
+ helpGuide?: string;
73
91
  /**
74
92
  * MCP servers this agent always registers (e.g. Playwright for a browser-driving
75
93
  * agent), on top of the generic ones `buildSessionOptions()` wires up itself when
@@ -109,23 +127,15 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
109
127
  saveToSourcesDescription?: string;
110
128
  /**
111
129
  * The built-in knowledge base (see knowledge.ts): when `config.knowledgeDir` is set, the kit
112
- * appends its "Knowledge base" rules to the system prompt and loads its plugin (skills
130
+ * appends its "Knowledge base" rules to the system prompt, loads its plugin (skills
113
131
  * knowledge-ingest/knowledge-query/knowledge-lint, commands /knowledge:ingest, /knowledge:query,
114
- * /knowledge:lint), so the agent maintains its notes as an interlinked wiki, through the
115
- * `knowledge_*` tools by default (see `knowledgeTools`). On by default;
116
- * set `false` for an agent that writes its own rules for `knowledgeDir` (or wants plain
117
- * notes) — `knowledgePromptSection()`/`knowledgePluginRoot()` are exported to reuse the pieces.
132
+ * /knowledge:lint) and its `knowledge_*` tools, the only way into the knowledge base (ADR-024).
133
+ * On by default; `false` makes `knowledgeDir` a folder of the agent's own notes, kept with the
134
+ * file tools under rules it writes itself.
118
135
  */
119
136
  knowledgeBase?: boolean;
120
137
  /**
121
- * How the agent reaches the built-in knowledge base: `"store"` (the default), only through
122
- * the kit's `knowledge_*` tools over a `KnowledgeStore`, never with the file tools, so the
123
- * storage can change and the wiki's rules are kept by code (ADR-024); `"files"`, with the
124
- * file tools on `knowledgeDir` and the rules in the prompt, as before.
125
- */
126
- knowledgeTools?: "store" | "files";
127
- /**
128
- * The agent's own page types for the built-in knowledge base, besides the kit's four
138
+ * The agent's own page types for the built-in knowledge base, besides the kit's
129
139
  * (summary, concept, entity, synthesis): each with its folder (`""` for the root), index
130
140
  * section, description (told to the model) and template.
131
141
  */
@@ -154,11 +164,13 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
154
164
  settingSources?: SettingSource[];
155
165
  /**
156
166
  * The skills the agent offers (the SDK's `skills`): names, or `plugin:skill` for a
157
- * plugin's. Default `"all"`, every skill found (the SDK's own, the project's, the
158
- * plugins'). A list keeps the rest out of each turn's context; the knowledge base's own
167
+ * plugin's. Default `"all"`, every skill found: the SDK's own (about twenty, whatever
168
+ * `settingSources` says), the project's, the plugins'. `"plugins"` offers only those of the
169
+ * plugins the session loads (`pluginRoots`, the knowledge base's, agent-help's), without
170
+ * naming them. A list keeps the rest out of each turn's context; the knowledge base's own
159
171
  * skills are added to it when the knowledge base is on. A context filter, not a sandbox.
160
172
  */
161
- skills?: string[] | "all";
173
+ skills?: string[] | "all" | "plugins";
162
174
  /**
163
175
  * Text for the generic human-approval checkpoint (see tools/humanApproval.ts) — what
164
176
  * counts as "publishing something visible to others" is entirely domain-specific.
@@ -192,6 +204,15 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
192
204
  */
193
205
  planMode?: PlanModeSpec<TConfig>;
194
206
  }
207
+ /** Who an agent is, as told to its model (`AgentSpec.identity`). */
208
+ export interface AgentIdentity {
209
+ /** The agent's name, e.g. "padawan". */
210
+ name: string;
211
+ /** Its version, e.g. its package's. */
212
+ version?: string;
213
+ /** What it is, in one line, e.g. "an agent that takes a Moodle course as a student". */
214
+ description?: string;
215
+ }
195
216
  /** The domain's part of "plan" mode: which files hold the plan and which of its own tools only read. */
196
217
  export interface PlanModeSpec<TConfig extends BaseSessionConfig = BaseSessionConfig> {
197
218
  /**
@@ -1,10 +1,8 @@
1
1
  import { type KnowledgeStore, type PageType } from "./knowledgeStore.js";
2
2
  /** Options of `createFileKnowledgeStore()`. */
3
3
  export interface FileKnowledgeStoreOptions {
4
- /** The agent's own page types, besides the kit's four. */
4
+ /** The agent's own page types, besides the kit's. */
5
5
  pageTypes?: readonly PageType[];
6
- /** The sources folder: a summary's `file` field is given relative to it. */
7
- sourcesDir?: string;
8
6
  }
9
7
  /** A `KnowledgeStore` over the markdown files of `knowledgeDir`. */
10
8
  export declare function createFileKnowledgeStore(knowledgeDir: string, options?: FileKnowledgeStoreOptions): KnowledgeStore;
@@ -27,6 +27,8 @@ function serialize(fields, body) {
27
27
  const lines = [...fields].map(([key, value]) => `${key}: ${/[:#]/.test(value) && !/^\[.*\]$/.test(value) ? JSON.stringify(value) : value}`);
28
28
  return `---\n${lines.join("\n")}\n---\n\n${body.replace(/^\s+/, "").replace(/\s*$/, "")}\n`;
29
29
  }
30
+ /** When a summary was written from its original (ISO 8601, as `list_sources`' `changedAt`). */
31
+ const stampNow = () => new Date().toISOString();
30
32
  /** A `KnowledgeStore` over the markdown files of `knowledgeDir`. */
31
33
  export function createFileKnowledgeStore(knowledgeDir, options = {}) {
32
34
  const declared = options.pageTypes ?? [];
@@ -134,9 +136,9 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
134
136
  for (const [i, page] of ready.entries()) {
135
137
  const fields = newPages[i].fields ?? {};
136
138
  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
139
+ await applyFields(page, fields);
140
+ if (page.type.type === "summary")
141
+ page.fields.set("ingested", stampNow());
140
142
  }
141
143
  for (const page of ready) {
142
144
  await mkdir(path.dirname(page.file), { recursive: true });
@@ -159,28 +161,20 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
159
161
  if (missing.length)
160
162
  throw new Error(`These links point to pages that don't exist: ${missing.join(", ")}. Create them first, or link existing ones (knowledge_search).`);
161
163
  }
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
- }
164
+ /**
165
+ * Sets or removes the fields given. `type` is the page's, and a summary's `ingested` is the
166
+ * store's (when it was written from its original), so neither is taken from the caller.
167
+ * `file` is kept as given: the original's path relative to the sources folder, which the
168
+ * store doesn't check (the sources are another extension's data, #30).
169
+ */
176
170
  async function applyFields(page, changes = {}) {
177
171
  for (const [key, value] of Object.entries(changes)) {
178
- if (key === "type")
172
+ if (key === "type" || key === "ingested")
179
173
  continue;
180
174
  if (value === null)
181
175
  page.fields.delete(key);
182
176
  else
183
- page.fields.set(key, await storedField(key, value, page.file));
177
+ page.fields.set(key, value);
184
178
  }
185
179
  page.fields.set("updated", today());
186
180
  }
@@ -191,7 +185,8 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
191
185
  }
192
186
  /** The notice under the frontmatter of a superseded or retired page. */
193
187
  const withNotice = (body, notice) => `> ${notice}\n\n${body.replace(/^> (Superseded|Retired) on [^\n]*\n\n/, "")}`;
194
- async function indexText() {
188
+ /** The catalog: with links as ids (`summary/kitchen`) for the tools, or as files for `index.md`. */
189
+ async function indexText(links = "ids") {
195
190
  const pages = (await allPages()).filter((p) => statusOf(p) !== "retired");
196
191
  const lines = ["# Index", ""];
197
192
  for (const type of types) {
@@ -202,7 +197,7 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
202
197
  for (const page of own) {
203
198
  const extras = (type.indexFields ?? []).filter((f) => page.fields.get(f)).map((f) => `${f}: ${page.fields.get(f)}`);
204
199
  const status = statusOf(page) === "superseded" ? " (superseded)" : "";
205
- lines.push(`- [${titleOf(page)}](${posix(path.relative(knowledgeDir, page.file))}) — ${descriptionOf(page)}${extras.length ? ` (${extras.join(", ")})` : ""}${status}`);
200
+ lines.push(`- [${titleOf(page)}](${links === "ids" ? page.id : posix(path.relative(knowledgeDir, page.file))}) — ${descriptionOf(page)}${extras.length ? ` (${extras.join(", ")})` : ""}${status}`);
206
201
  }
207
202
  lines.push("");
208
203
  }
@@ -210,7 +205,7 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
210
205
  }
211
206
  async function writeIndex() {
212
207
  await mkdir(knowledgeDir, { recursive: true });
213
- await writeFile(path.join(knowledgeDir, "index.md"), await indexText());
208
+ await writeFile(path.join(knowledgeDir, "index.md"), await indexText("files"));
214
209
  }
215
210
  async function pagesByFile() {
216
211
  const pages = await allPages();
@@ -265,6 +260,9 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
265
260
  await linksResolve(content, id);
266
261
  page.body = toFile(/^#\s/.test(content.trimStart()) ? content : `# ${titleOf(page)}\n\n${content}`, page.file);
267
262
  await applyFields(page, fields);
263
+ // A summary redone whole is redone from its original: written now.
264
+ if (page.type.type === "summary")
265
+ page.fields.set("ingested", stampNow());
268
266
  await save(page);
269
267
  },
270
268
  async supersede(id, by, reason) {
@@ -325,7 +323,7 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
325
323
  .slice(0, limit)
326
324
  .map(({ id, title, snippet }) => ({ id, title, snippet }));
327
325
  },
328
- index: indexText,
326
+ index: () => indexText("ids"),
329
327
  async recentLog(limit = 10) {
330
328
  const text = await readFile(path.join(knowledgeDir, "log.md"), "utf8").catch(() => "");
331
329
  // Entries start with "## [date] operation | what"; the newest are at the bottom.
@@ -345,7 +343,7 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
345
343
  const reservedFiles = new Set([...RESERVED].map((name) => path.normalize(path.join(knowledgeDir, `${name}.md`))));
346
344
  const known = new Map(pages.map((p) => [p.id, p]));
347
345
  const inbound = new Map();
348
- const report = { brokenLinks: [], orphans: [], linksToRetired: [], missingOriginals: [] };
346
+ const report = { brokenLinks: [], orphans: [], linksToRetired: [] };
349
347
  for (const page of pages) {
350
348
  if (statusOf(page) === "retired")
351
349
  continue;
@@ -370,13 +368,9 @@ export function createFileKnowledgeStore(knowledgeDir, options = {}) {
370
368
  if (id !== page.id)
371
369
  inbound.set(id, (inbound.get(id) ?? 0) + 1);
372
370
  }
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
371
  }
378
372
  report.orphans = pages
379
- .filter((p) => statusOf(p) !== "retired" && p.type.type !== "summary" && p.type.type !== "synthesis" && !inbound.get(p.id))
373
+ .filter((p) => statusOf(p) !== "retired" && !["summary", "synthesis", "preference"].includes(p.type.type) && !inbound.get(p.id))
380
374
  .map((p) => p.id);
381
375
  return report;
382
376
  },
@@ -24,17 +24,32 @@ export interface FileScope {
24
24
  dir: string;
25
25
  instead: string;
26
26
  }[];
27
+ /**
28
+ * With it, `Read` and `Glob` are only allowed inside these (an allow-list, like
29
+ * `writableDirs` and `searchableDirs`), plus `alsoReadable` and the tool results under
30
+ * `toolResultsRoot`; `deniedPaths` still wins. Without it, they reach any path but the
31
+ * denied ones: any file the user can read, `~/.ssh` included.
32
+ */
33
+ readableDirs?: string[];
34
+ /** Readable too with `readableDirs`, but not named in a denial: the run folder, the plugin roots. */
35
+ alsoReadable?: string[];
36
+ /**
37
+ * The CLI's folder for this project (`~/.claude/projects/<project>`): with `readableDirs`,
38
+ * its sessions' `tool-results/` folders, where the SDK leaves large tool outputs for the
39
+ * agent to `Read`, are readable; nothing else in it.
40
+ */
41
+ toolResultsRoot?: string;
27
42
  }
28
43
  /** Whether `candidate` is `base` itself or somewhere inside it (case-insensitive on Windows, like path.relative). */
29
44
  export declare function isWithin(base: string, candidate: string): boolean;
30
45
  /**
31
46
  * The reason to deny `toolName` with `input` under `scope`, or `undefined` to let it
32
- * through. Tools other than Read/Write/Edit/Grep are never judged here, and a call
47
+ * through. Tools other than Read/Write/Edit/Grep/Glob are never judged here, and a call
33
48
  * without a path is left for the tool itself to reject.
34
49
  */
35
50
  export declare function checkFileScope(scope: FileScope, toolName: string, input: Record<string, unknown>): string | undefined;
36
51
  /**
37
- * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep, for the main agent and its
52
+ * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep/Glob, for the main agent and its
38
53
  * subagents alike. Bash, when a subagent has it, is not covered: it can reach any path,
39
54
  * which is why Bash-granting features stay opt-in.
40
55
  */
@@ -5,11 +5,33 @@ export function isWithin(base, candidate) {
5
5
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
6
6
  }
7
7
  function describeDirs(scope, dirs) {
8
- return dirs.map((dir) => `${path.relative(scope.projectDir, dir) || "."}/`).join(", ");
8
+ return dirs.map((dir) => (isWithin(scope.projectDir, dir) ? `${path.relative(scope.projectDir, dir) || "."}/` : `${dir}/`)).join(", ");
9
+ }
10
+ /** Where a `Glob` really starts: its path plus the pattern's fixed folders ("sources/**" starts in sources/). */
11
+ function globStart(scope, input) {
12
+ const target = typeof input.path === "string" && input.path !== "" ? path.resolve(scope.projectDir, input.path) : scope.projectDir;
13
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
14
+ return path.resolve(target, pattern.replace(/[^/\\]*[*?[{].*$/, ""));
15
+ }
16
+ /** Whether `target` is readable under a scope with `readableDirs`. */
17
+ function isReadable(scope, target) {
18
+ const dirs = [...(scope.readableDirs ?? []), ...(scope.alsoReadable ?? [])].map((dir) => path.resolve(scope.projectDir, dir));
19
+ if (dirs.some((dir) => isWithin(dir, target)))
20
+ return true;
21
+ if (!scope.toolResultsRoot || !isWithin(scope.toolResultsRoot, target))
22
+ return false;
23
+ // <root>/<session>/tool-results/…, and nothing else of the CLI's (transcripts, other files).
24
+ return path.relative(scope.toolResultsRoot, target).split(path.sep)[1] === "tool-results";
25
+ }
26
+ /** The denial for reading or listing outside `readableDirs`. */
27
+ function notReadable(scope, shown, tool) {
28
+ const dirs = scope.readableDirs ?? [];
29
+ const what = tool === "Glob" ? `Glob can't list "${String(shown)}": listing` : `"${String(shown)}" can't be read: reading`;
30
+ return `${what} is only allowed inside ${dirs.length ? `${describeDirs(scope, dirs)} and ` : ""}your own plugins and run folder.`;
9
31
  }
10
32
  /**
11
33
  * The reason to deny `toolName` with `input` under `scope`, or `undefined` to let it
12
- * through. Tools other than Read/Write/Edit/Grep are never judged here, and a call
34
+ * through. Tools other than Read/Write/Edit/Grep/Glob are never judged here, and a call
13
35
  * without a path is left for the tool itself to reject.
14
36
  */
15
37
  export function checkFileScope(scope, toolName, input) {
@@ -26,19 +48,32 @@ export function checkFileScope(scope, toolName, input) {
26
48
  return viaTools(input.file_path, only);
27
49
  }
28
50
  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;
51
+ // Where a search really starts: Grep's path, Glob's path plus the pattern's fixed folders.
52
+ // From above the folder it would reach into it.
53
+ const start = toolName === "Glob" ? globStart(scope, input) : (resolve(input.path) ?? scope.projectDir);
34
54
  const only = toolOnly(start) ?? toolOnlyDirs.find(({ dir }) => isWithin(start, dir));
35
55
  if (only)
36
- return viaTools(input.path ?? pattern, only);
56
+ return viaTools(input.path ?? input.pattern ?? "", only);
37
57
  }
38
58
  switch (toolName) {
39
59
  case "Read": {
40
60
  const target = resolve(input.file_path);
41
- return target && isDenied(target) ? `"${input.file_path}" is off limits: it can't be read.` : undefined;
61
+ if (!target)
62
+ return undefined;
63
+ if (isDenied(target))
64
+ return `"${input.file_path}" is off limits: it can't be read.`;
65
+ return scope.readableDirs && !isReadable(scope, target) ? notReadable(scope, input.file_path, "Read") : undefined;
66
+ }
67
+ case "Glob": {
68
+ const start = globStart(scope, input);
69
+ const shown = input.path ?? input.pattern;
70
+ if (isDenied(start))
71
+ return `"${String(shown)}" is off limits: it can't be listed.`;
72
+ if (!scope.readableDirs)
73
+ return undefined;
74
+ // ".." in the pattern ("**/../../x") would climb out of where the search starts.
75
+ const climbs = typeof input.pattern === "string" && input.pattern.split(/[/\\]/).includes("..");
76
+ return climbs || !isReadable(scope, start) ? notReadable(scope, shown, "Glob") : undefined;
42
77
  }
43
78
  case "Write":
44
79
  case "Edit": {
@@ -66,7 +101,7 @@ export function checkFileScope(scope, toolName, input) {
66
101
  }
67
102
  }
68
103
  /**
69
- * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep, for the main agent and its
104
+ * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep/Glob, for the main agent and its
70
105
  * subagents alike. Bash, when a subagent has it, is not covered: it can reach any path,
71
106
  * which is why Bash-granting features stay opt-in.
72
107
  */