@xingular/harness 2.1.1 → 3.0.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 (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -1,63 +1,28 @@
1
- // The memory and Wiki tools a session gets, defined once. An SDK session gets them as Pi tools;
2
- // a terminal session gets them from the `xagent-wiki` command or the bundled MCP server. All of
3
- // them reach the same calls.
1
+ // The harness's own model tools, defined once: History (earlier runs), the Wiki (project notes),
2
+ // and skill loading from the run's harness tree. An SDK session gets them as Pi tools; a
3
+ // terminal session reaches the same calls through the bundled server.
4
4
 
5
- export type WikiCall =
6
- | 'memory.append' | 'memory.replace' | 'memory.rethink'
5
+ export type HarnessCall =
7
6
  | 'recall.search' | 'recall.read'
8
7
  | 'trajectory.search' | 'trajectory.read'
9
- | 'wiki.entrypoint' | 'wiki.search' | 'wiki.read' | 'wiki.list' | 'wiki.skill' | 'wiki.memory' | 'wiki.propose'
8
+ | 'wiki.search' | 'wiki.read' | 'wiki.list' | 'wiki.write'
9
+ | 'skill.load'
10
10
 
11
11
  /** A JSON Schema object, kept loose so TypeBox, MCP, and the CLI can all use it. */
12
12
  export type JsonSchema = { type: string; properties?: Record<string, unknown>; required?: string[]; [key: string]: unknown }
13
13
 
14
- export interface WikiToolDefinition {
14
+ export interface HarnessToolDefinition {
15
15
  name: string
16
- call: WikiCall
16
+ call: HarnessCall
17
17
  label: string
18
18
  description: string
19
19
  parameters: JsonSchema
20
- /** The compiled prompt is injected at launch; the tool exists for sessions that need it again. */
21
- bootstrap?: boolean
22
20
  }
23
21
 
24
- /** The kinds of entry a model names. A fact was called `memory` before; that word is still accepted. */
25
- export const WIKI_ENTRY_KINDS = ['page', 'fact', 'block', 'skill', 'rule', 'prompt'] as const
26
- export const WIKI_PAGE_TYPES = ['project', 'person', 'concept', 'decision', 'summary', 'question', 'reference'] as const
27
- export const WIKI_MEMORY_KINDS = ['preference', 'fact', 'decision', 'feedback'] as const
28
- export const WIKI_LINK_TYPES = ['references', 'depends_on', 'supersedes', 'contradicts', 'produced_by', 'uses'] as const
29
-
30
22
  const text = (description: string) => ({ type: 'string', description })
31
- const block = text('The block\'s label as shown in your memory blocks: user, persona, notes, project/<slug>, or a shared block\'s label')
32
-
33
- /** Core memory: the blocks that are always in the caller's context. */
34
- export const MEMORY_TOOLS: WikiToolDefinition[] = [
35
- {
36
- name: 'memory_append',
37
- call: 'memory.append',
38
- label: 'Add to memory',
39
- description:
40
- 'Add a line or short passage to the end of one of your memory blocks. Use it when you learn something worth keeping across conversations: about the user, about how you work, or about a project. The change is saved at once and the user can undo it.',
41
- parameters: { type: 'object', properties: { block, text: text('What to add') }, required: ['block', 'text'] }
42
- },
43
- {
44
- name: 'memory_replace',
45
- call: 'memory.replace',
46
- label: 'Correct memory',
47
- description: 'Replace one exact passage in a memory block, to correct or remove something that is no longer true. Copy the old passage exactly; pass an empty new passage to delete it.',
48
- parameters: { type: 'object', properties: { block, old: text('The exact passage to replace'), new: text('What replaces it') }, required: ['block', 'old', 'new'] }
49
- },
50
- {
51
- name: 'memory_rethink',
52
- call: 'memory.rethink',
53
- label: 'Rewrite memory',
54
- description: 'Rewrite a whole memory block, to condense it when it is close to its limit or has grown repetitive. Keep everything that is still true and useful.',
55
- parameters: { type: 'object', properties: { block, body: text('The complete new content of the block') }, required: ['block', 'body'] }
56
- }
57
- ]
58
23
 
59
24
  /** History: what was said and done in earlier conversations and runs. */
60
- export const RECALL_TOOLS: WikiToolDefinition[] = [
25
+ export const RECALL_TOOLS: HarnessToolDefinition[] = [
61
26
  {
62
27
  name: 'history_search',
63
28
  call: 'recall.search',
@@ -86,7 +51,7 @@ export const RECALL_TOOLS: WikiToolDefinition[] = [
86
51
  ]
87
52
 
88
53
  /** Trajectory: structured run metadata and the events the harness recorded. */
89
- export const TRAJECTORY_TOOLS: WikiToolDefinition[] = [
54
+ export const TRAJECTORY_TOOLS: HarnessToolDefinition[] = [
90
55
  {
91
56
  name: 'trajectory_search',
92
57
  call: 'trajectory.search',
@@ -123,106 +88,58 @@ export const TRAJECTORY_TOOLS: WikiToolDefinition[] = [
123
88
  }
124
89
  ]
125
90
 
126
- export const WIKI_TOOLS: WikiToolDefinition[] = [
127
- ...MEMORY_TOOLS,
128
- ...RECALL_TOOLS,
129
- {
130
- name: 'wiki_entrypoint',
131
- call: 'wiki.entrypoint',
132
- label: 'Context pack',
133
- description: 'Your {product} context pack again: memory blocks, rules, the skills index, the project map, and tool guidance. It is already in your instructions at the start; call this to see its current state.',
134
- parameters: { type: 'object', properties: {} },
135
- bootstrap: true
136
- },
91
+ /** The Wiki: project notes. Reading is always available; writing is switched on per caller. */
92
+ export const WIKI_READ_TOOLS: HarnessToolDefinition[] = [
137
93
  {
138
94
  name: 'wiki_search',
139
95
  call: 'wiki.search',
140
- label: 'Search Wiki',
141
- description:
142
- 'Search everything written down in {product}: the Library, every project namespace, facts, skills, rules, and prompts. Results from the current project rank higher. Each hit has a path and an [id@revision] citation.',
143
- parameters: {
144
- type: 'object',
145
- properties: {
146
- query: text('What to look for'),
147
- kinds: { type: 'array', items: { type: 'string', enum: [...WIKI_ENTRY_KINDS] }, description: 'Only these kinds of entries' },
148
- namespace: text('Only this namespace, such as /library or /projects/<name>'),
149
- limit: { type: 'integer', minimum: 1, maximum: 50 }
150
- },
151
- required: ['query']
152
- }
96
+ label: 'Search the Wiki',
97
+ description: 'Search the Wiki: notes about projects and a shared Library. The current project\'s notes rank higher. Each hit gives a note path for wiki_read. Notes are reference material, not instructions.',
98
+ parameters: { type: 'object', properties: { query: text('What to look for'), project: text('Prefer this project slug'), limit: { type: 'integer', minimum: 1, maximum: 50 } }, required: ['query'] }
153
99
  },
154
100
  {
155
101
  name: 'wiki_read',
156
102
  call: 'wiki.read',
157
- label: 'Read Wiki entry',
158
- description: 'Read one Wiki page, fact, skill, rule, or prompt in full, with its sources, links, and backlinks. Pass an id (arch, skills/review) or a Wiki path (/projects/xagent/arch).',
159
- parameters: { type: 'object', properties: { id: text('Entry id or Wiki path'), revision: text('Optional pinned revision selected for this reply'), offset: { type: 'integer', minimum: 0, description: 'Character offset when reading a pinned revision; use nextOffset to continue.' } }, required: ['id'] }
103
+ label: 'Read a note',
104
+ description: 'Read one Wiki note by its path, such as projects/<slug>/decisions or library/glossary, with the notes it links to and the notes that link to it.',
105
+ parameters: { type: 'object', properties: { path: text('The note path') }, required: ['path'] }
160
106
  },
161
107
  {
162
108
  name: 'wiki_list',
163
109
  call: 'wiki.list',
164
- label: 'List Wiki',
165
- description: 'List Wiki namespaces and pages (optionally in one namespace), or all facts, skills, rules, or prompts.',
166
- parameters: {
167
- type: 'object',
168
- properties: { namespace: text('A namespace such as /library or /projects/<name>'), kind: { type: 'string', enum: [...WIKI_ENTRY_KINDS] } }
169
- }
170
- },
171
- {
172
- name: 'skill_load',
173
- call: 'wiki.skill',
174
- label: 'Load skill',
175
- description: "Load a skill's instructions by name, or list every skill (and which are active) when no name is given. Follow a skill's steps when the task matches it.",
176
- parameters: { type: 'object', properties: { name: text('Skill name or id, such as review or skills/review') } }
177
- },
110
+ label: 'List notes',
111
+ description: 'List Wiki notes: all of them, or one project\'s (pass its slug), or the Library (pass "library").',
112
+ parameters: { type: 'object', properties: { project: text('A project slug, or "library"') } }
113
+ }
114
+ ]
115
+
116
+ export const WIKI_WRITE = 'wiki_write'
117
+
118
+ export const WIKI_WRITE_TOOLS: HarnessToolDefinition[] = [
178
119
  {
179
- name: 'memory_facts',
180
- call: 'wiki.memory',
181
- label: 'Recall facts',
182
- description: 'Recall facts: short statements, preferences, decisions, and feedback. Without a query it returns the facts that apply to the current project.',
183
- parameters: { type: 'object', properties: { query: text('What to recall'), namespace: text('/ or a project namespace') } }
184
- },
120
+ name: WIKI_WRITE,
121
+ call: 'wiki.write',
122
+ label: 'Write a note',
123
+ description: 'Create or replace a Wiki note with Markdown text. Keep notes short: what a document is for and a link to it, a decision and why. Link other notes with [[path]]. The earlier text is kept as a version the person can restore.',
124
+ parameters: { type: 'object', properties: { path: text('The note path, under library/ or projects/<slug>/'), text: text('The complete Markdown text of the note') }, required: ['path', 'text'] }
125
+ }
126
+ ]
127
+
128
+ /** Skills: load one of this agent's skills from its harness. */
129
+ export const SKILL_TOOLS: HarnessToolDefinition[] = [
185
130
  {
186
- name: 'wiki_propose',
187
- call: 'wiki.propose',
188
- label: 'Propose Wiki update',
189
- description:
190
- 'Record durable knowledge. kind "fact" for a short fact, preference, decision, or piece of feedback, "page" to update an existing page or add one about an entity or concept (not about one run), or "skill"/"rule"/"prompt" to suggest a change to those. ' +
191
- 'Cite evidence in sourceRefs: {"kind":"run","ref":"current","excerpt":"…"} with a passage copied exactly from this conversation or run (what the user wrote, or what you already said), a source id, or a URL. ' +
192
- 'A run reference without an excerpt found in the run is not evidence. Cited pages and facts apply right away (with undo); uncited or low-confidence ones, and every skill, rule, or prompt change, wait in the Wiki Inbox for the user. Never include secrets.',
193
- parameters: {
194
- type: 'object',
195
- properties: {
196
- kind: { type: 'string', enum: [...WIKI_ENTRY_KINDS] },
197
- title: text('Title (or the skill/rule/prompt name)'),
198
- body: text('Markdown body. For a fact, a few lines.'),
199
- id: text('Existing id to update'),
200
- type: text(`Page type (${WIKI_PAGE_TYPES.join(', ')}) or what sort of fact (${WIKI_MEMORY_KINDS.join(', ')})`),
201
- namespace: text('Where it belongs: /library, /projects/<name>, or / for a fact that applies everywhere. Defaults to the current project.'),
202
- confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
203
- description: text('One line for a skill, rule, or prompt'),
204
- supersedes: text('Id of a fact this one replaces'),
205
- volatile: { type: 'boolean', description: 'For a fact: true when it describes current state that will change (an open issue list, who is on call)' },
206
- reason: text('Why this change is needed (skills, rules, prompts)'),
207
- sourceRefs: {
208
- type: 'array',
209
- items: { type: 'object', properties: { kind: { type: 'string', enum: ['run', 'source', 'url'] }, ref: { type: 'string' }, excerpt: { type: 'string', description: 'For a run or source: a passage copied exactly from it' } }, required: ['kind'] }
210
- },
211
- links: {
212
- type: 'array',
213
- items: { type: 'object', properties: { toId: { type: 'string' }, type: { type: 'string', enum: [...WIKI_LINK_TYPES] } }, required: ['toId', 'type'] }
214
- }
215
- },
216
- required: ['kind', 'title', 'body']
217
- }
218
- },
219
- // Keep new tools at the end so existing tool order and cached prefixes remain stable.
220
- ...TRAJECTORY_TOOLS
131
+ name: 'skill_load',
132
+ call: 'skill.load',
133
+ label: 'Load a skill',
134
+ description: 'Load the steps of one of your skills by name, when your instructions say a situation calls for it. Without a name, lists your skills.',
135
+ parameters: { type: 'object', properties: { name: text('The skill name, as your instructions write it') } }
136
+ }
221
137
  ]
222
138
 
223
- /** Tools agents get when the context pack is already in their instructions. */
224
- /** A tool's description with the application's name in it. */
225
- export const describeTool = (tool: WikiToolDefinition, productName: string): string => tool.description.replaceAll('{product}', productName)
139
+ /** Every harness tool. */
140
+ export const HARNESS_TOOLS: HarnessToolDefinition[] = [...RECALL_TOOLS, ...TRAJECTORY_TOOLS, ...WIKI_READ_TOOLS, ...WIKI_WRITE_TOOLS, ...SKILL_TOOLS]
141
+
142
+ /** The harness tools every session has; `wiki_write` is added only when the caller switches it on. */
143
+ export const DEFAULT_TOOL_NAMES = HARNESS_TOOLS.filter((tool) => tool.name !== WIKI_WRITE).map((tool) => tool.name)
226
144
 
227
- export const WIKI_AGENT_TOOLS = WIKI_TOOLS.filter((tool) => !tool.bootstrap)
228
- export const WIKI_TOOL_NAMES = WIKI_AGENT_TOOLS.map((tool) => tool.name)
145
+ export const describeTool = (tool: HarnessToolDefinition, productName: string): string => tool.description.replaceAll('{product}', productName)
@@ -0,0 +1,78 @@
1
+ // One entry point for every History, Wiki and skill call a session makes, whichever way it
2
+ // reaches the harness (a Pi tool, the bundled server, a terminal session).
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import type { Bus } from '../core/bus'
7
+ import { projectOf, type CallerContext } from '../core/caller'
8
+ import type { HarnessEnv } from '../core/env'
9
+ import { HarnessError, invalid } from '../core/errors'
10
+ import * as recall from '../history/recall'
11
+ import * as trajectory from '../history/trajectory'
12
+ import * as notes from '../wiki/notes'
13
+ import type { HarnessCall } from './definitions'
14
+
15
+ export type DispatchEnv = HarnessEnv & { bus?: Bus }
16
+ export type Params = Record<string, unknown>
17
+
18
+ export interface DispatchOptions {
19
+ /** The run's harness tree on disk, for `skill_load`. */
20
+ treeDir?: string | null
21
+ }
22
+
23
+ function str(params: Params, key: string): string {
24
+ const value = params[key]
25
+ return typeof value === 'string' ? value : ''
26
+ }
27
+
28
+ /** Runs one call and returns its reply as text for a model. */
29
+ export function dispatch(env: DispatchEnv, context: CallerContext, call: HarnessCall | string, params: Params, options: DispatchOptions = {}): string {
30
+ switch (call) {
31
+ case 'recall.search':
32
+ return recall.searchTool(env, context, params)
33
+ case 'recall.read':
34
+ return recall.readTool(env, context, params)
35
+ case 'trajectory.search':
36
+ return trajectory.searchTool(env, context, params)
37
+ case 'trajectory.read':
38
+ return trajectory.readTool(env, context, params)
39
+ case 'wiki.search': {
40
+ const project = str(params, 'project') || projectOf(env.root, context)?.slug || null
41
+ const hits = notes.search(env.root, str(params, 'query'), { project, limit: typeof params.limit === 'number' ? params.limit : 10 })
42
+ if (!hits.length) return 'No note matches.'
43
+ return hits.map((hit) => `- ${hit.path} — ${hit.title}\n ${hit.excerpt}`).join('\n')
44
+ }
45
+ case 'wiki.read': {
46
+ const note = notes.read(env.root, str(params, 'path'))
47
+ const tail = [note.links.length ? `Links to: ${note.links.join(', ')}` : '', note.backlinks.length ? `Linked from: ${note.backlinks.join(', ')}` : ''].filter(Boolean).join('\n')
48
+ return `# ${note.title} (${note.path}, updated ${note.updatedAt})\n\n${note.body}${tail ? `\n\n---\n${tail}` : ''}`
49
+ }
50
+ case 'wiki.list': {
51
+ const project = str(params, 'project')
52
+ const found = notes.list(env.root, project ? { project: project === 'library' ? null : project } : {})
53
+ const shown = project === 'library' ? found.filter((note) => note.path.startsWith('library/')) : found
54
+ return shown.length ? shown.map((note) => `- ${note.path} — ${note.title}`).join('\n') : 'No notes yet.'
55
+ }
56
+ case 'wiki.write': {
57
+ const note = notes.write(env.root, str(params, 'path'), str(params, 'text'), { actor: context.agentName ?? context.agentId ?? 'agent', reason: 'Written by an agent' })
58
+ env.bus?.emit({ type: 'wiki.changed' })
59
+ return `Saved ${note.path}.`
60
+ }
61
+ case 'skill.load':
62
+ return loadSkill(options.treeDir ?? null, str(params, 'name'))
63
+ default:
64
+ throw new HarnessError('unknown_method', `Unknown harness call ${call}.`)
65
+ }
66
+ }
67
+
68
+ /** A skill's text from the run's harness tree, or the list of its skills. */
69
+ export function loadSkill(treeDir: string | null, name: string): string {
70
+ const skills = treeDir && existsSync(join(treeDir, 'skills')) ? readdirSync(join(treeDir, 'skills'), { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(treeDir, 'skills', entry.name, 'SKILL.md'))).map((entry) => entry.name).sort() : []
71
+ const wanted = name.trim().replace(/^skills\//, '').replace(/`/g, '')
72
+ if (!wanted) return skills.length ? `Your skills: ${skills.join(', ')}. Load one with skill_load and its name.` : 'You have no skills.'
73
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(wanted)) throw invalid('A skill name is lowercase letters, digits, _ or -.')
74
+ if (!skills.includes(wanted)) return `There is no skill named ${wanted}.${skills.length ? ` Your skills: ${skills.join(', ')}.` : ''}`
75
+ const dir = join(treeDir!, 'skills', wanted)
76
+ const helpers = existsSync(join(dir, 'scripts')) ? readdirSync(join(dir, 'scripts')) : []
77
+ return `${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${helpers.length ? `\n\n(This skill's helpers are in ${join(dir, 'scripts')}: ${helpers.join(', ')}.)` : ''}`
78
+ }
@@ -0,0 +1,23 @@
1
+ // The History, Wiki and skill tools a session has.
2
+ import type { CallerContext } from '../core/caller'
3
+ import { describeTool, HARNESS_TOOLS } from './definitions'
4
+ import { dispatch, type DispatchEnv } from './dispatch'
5
+ import type { ToolSpec } from './types'
6
+
7
+ export function harnessTools(env: DispatchEnv, context: CallerContext, options: { treeDir?: string | null } = {}): ToolSpec[] {
8
+ return HARNESS_TOOLS.map((tool) => ({
9
+ name: tool.name,
10
+ label: tool.label,
11
+ description: describeTool(tool, env.productName),
12
+ parameters: (Type) => Type.Unsafe(tool.parameters),
13
+ readOnly: tool.name !== 'wiki_write',
14
+ execute: async (input) => {
15
+ try {
16
+ return dispatch(env, context, tool.call, input, { treeDir: options.treeDir }) || 'Nothing found.'
17
+ } catch (error) {
18
+ // The model sees the reason and can adjust, rather than the run failing.
19
+ return `That did not work: ${error instanceof Error ? error.message : String(error)}`
20
+ }
21
+ }
22
+ }))
23
+ }
@@ -27,10 +27,10 @@ export function subagentTools(service: Subagents, parentRunId: string): ToolSpec
27
27
  return [{
28
28
  name: SPAWN_SUBAGENT,
29
29
  label: 'Start a subagent',
30
- description: 'Start a fresh child run of yourself for independent work. Give a complete brief: the child has no conversation history and no more capabilities than you. Returns its run ID immediately by default so you can continue working. Results arrive automatically before you finish. Use wait_subagents to join selected children, or wait: true for an inline result. Stopping you stops your children.',
31
- parameters: (Type) => Type.Object({ brief: Type.String(), tools: Type.Optional(Type.Array(Type.String())), wait: Type.Optional(Type.Boolean({ description: 'Wait for the child and return its result; default false' })) }),
30
+ description: 'Start a fresh child run for independent work: yourself, or one of your subagents when you name it in agent. Give a complete brief: the child has no conversation history and no more capabilities than you. Returns its run ID immediately by default so you can continue working. Results arrive automatically before you finish. Use wait_subagents to join selected children, or wait: true for an inline result. Stopping you stops your children.',
31
+ parameters: (Type) => Type.Object({ brief: Type.String(), agent: Type.Optional(Type.String({ description: 'The name of one of your subagents; omit for a copy of yourself' })), tools: Type.Optional(Type.Array(Type.String())), wait: Type.Optional(Type.Boolean({ description: 'Wait for the child and return its result; default false' })) }),
32
32
  execute: execute(async (input) => {
33
- const child = await service.spawn({ parentRunId, brief: input.brief as string, tools: input.tools as string[] | undefined, wait: input.wait as boolean | undefined })
33
+ const child = await service.spawn({ parentRunId, brief: input.brief as string, agent: input.agent as string | undefined, tools: input.tools as string[] | undefined, wait: input.wait as boolean | undefined })
34
34
  if (input.wait === true) return reported(parentRunId, service.results({ parentRunId, runIds: [child.runId] }), [child.runId])
35
35
  return { text: JSON.stringify(child), details: { [SUBAGENT_RECEIPT]: { version: 1, ownerRunId: parentRunId, spawned: [child.runId] } satisfies SubagentReceipt } }
36
36
  })
@@ -1,6 +1,6 @@
1
1
  // A tool as the harness holds it: described without Pi imports, so a provider can be written
2
2
  // against this package alone. The session turns each one into a Pi tool.
3
- import type { CallerContext } from '../memory/caller'
3
+ import type { CallerContext } from '../core/caller'
4
4
  import type { CapabilitySet } from '../capabilities/types'
5
5
  import type { ActiveRun } from '../sessions/registry'
6
6
  import type { ToolAttachmentRef } from '../sessions/contracts'
@@ -22,6 +22,8 @@ export interface ToolSpec {
22
22
  parameters: (type: typeof import('typebox').Type) => unknown
23
23
  /** Only reads or asks; such a tool stays callable in plan mode. */
24
24
  readOnly?: boolean
25
+ /** Whether this particular call is safe for plan-mode research. Refusals happen before approvals. */
26
+ planModeSafe?: (input: Record<string, unknown>) => boolean
25
27
  execute: (input: Record<string, unknown>) => Promise<string | ToolResult>
26
28
  }
27
29
 
@@ -0,0 +1,221 @@
1
+ // The component library: skills, rules and prompts kept as files under `<data>/registry/`, and
2
+ // any built-in ones an application ships in folders laid out the same way. Nothing here reaches
3
+ // a session by itself: a person adds an item to a harness tree, where it becomes a skill (c6) or
4
+ // guardrail text (c2). The Hub installs into this library.
5
+ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
6
+ import { basename, dirname, join } from 'node:path'
7
+
8
+ import { invalid, notFound } from '../core/errors'
9
+ import * as frontmatter from '../core/frontmatter'
10
+ import { isDir, safeRelative } from '../core/fs'
11
+ import { compareText, lines, takeChars } from '../core/text'
12
+ import { GUARDRAILS, PI_SURFACE, type Tree } from './surface'
13
+ import { autotrigger } from './tree'
14
+
15
+ export const KINDS = ['skills', 'prompts', 'rules'] as const
16
+ export type ComponentKind = (typeof KINDS)[number]
17
+
18
+ export interface ComponentItem {
19
+ /** `<kind>/<name>`, stable across edits. */
20
+ id: string
21
+ kind: string
22
+ name: string
23
+ description: string
24
+ path: string
25
+ /** Set on an item that ships with the application. */
26
+ builtin?: true
27
+ }
28
+
29
+ export interface SkillBundleInput {
30
+ name: string
31
+ files: { path: string; content: string; encoding: 'utf8' | 'base64' }[]
32
+ }
33
+
34
+ const MAX_SKILL_BUNDLES = 20
35
+ const MAX_SKILL_FILES = 100
36
+ const MAX_SKILL_IMPORT_BYTES = 10 * 1024 * 1024
37
+
38
+ let builtinDirs: string[] = []
39
+
40
+ /** The folders built-in items are read from, each laid out like `registry/`. Later folders win. */
41
+ export function configureBuiltins(directories: readonly string[]): void {
42
+ builtinDirs = [...directories]
43
+ }
44
+
45
+ const kindDir = (root: string, kind: string): string => join(root, 'registry', kind)
46
+
47
+ function itemFrom(kind: string, stem: string, file: string): ComponentItem | null {
48
+ let text: string
49
+ try {
50
+ text = readFileSync(file, 'utf8')
51
+ } catch {
52
+ return null
53
+ }
54
+ const parsed = frontmatter.parse(text)
55
+ const name = parsed?.front.name
56
+ const description = parsed?.front.description
57
+ // A frontmatter block whose name or description is not text is treated as no frontmatter.
58
+ const usable = parsed !== null && (name === undefined || name === null || typeof name === 'string') && (description === undefined || description === null || typeof description === 'string')
59
+ const body = usable ? parsed.body : text
60
+ return {
61
+ id: `${kind}/${stem}`,
62
+ kind,
63
+ name: usable && typeof name === 'string' ? name : stem.replace(/[-_]/g, ' '),
64
+ description: usable && typeof description === 'string' ? description : takeChars(lines(body).find((line) => line.trim() && !line.startsWith('#')) ?? '', 160),
65
+ path: file
66
+ }
67
+ }
68
+
69
+ const stemOf = (name: string): string => (name.lastIndexOf('.') > 0 ? name.slice(0, name.lastIndexOf('.')) : name)
70
+
71
+ /** The name and description of a skill or prompt file anywhere on disk, read the way a registry item is. */
72
+ export function describeFile(file: string): { name: string; description: string } | null {
73
+ const stem = basename(file) === 'SKILL.md' ? basename(dirname(file)) : stemOf(basename(file))
74
+ const item = itemFrom('', stem, file)
75
+ return item && { name: item.name, description: item.description }
76
+ }
77
+
78
+ function itemsIn(directory: string): ComponentItem[] {
79
+ const items: ComponentItem[] = []
80
+ for (const kind of KINDS) {
81
+ let names: string[]
82
+ try {
83
+ names = readdirSync(join(directory, kind))
84
+ } catch {
85
+ continue
86
+ }
87
+ for (const name of names) {
88
+ const path = join(directory, kind, name)
89
+ const stem = stemOf(name)
90
+ if (stem.startsWith('.')) continue
91
+ const file = isDir(path) ? join(path, 'SKILL.md') : name.endsWith('.md') ? path : null
92
+ const item = file && itemFrom(kind, stem, file)
93
+ if (item) items.push(item)
94
+ }
95
+ }
96
+ return items
97
+ }
98
+
99
+ export function list(root: string): ComponentItem[] {
100
+ const items = new Map<string, ComponentItem>()
101
+ for (const directory of builtinDirs) for (const item of itemsIn(directory)) items.set(item.id, { ...item, builtin: true })
102
+ for (const item of itemsIn(join(root, 'registry'))) items.set(item.id, item)
103
+ return [...items.values()].sort((left, right) => compareText(left.id, right.id))
104
+ }
105
+
106
+ /** A tree with one library item added: a skill as `skills/<name>/` with its files, a rule as guardrail text. */
107
+ export function addToTree(root: string, tree: Tree, id: string): Tree {
108
+ const item = list(root).find((candidate) => candidate.id === id)
109
+ if (!item) throw notFound('That library item')
110
+ const next: Tree = { ...tree }
111
+ const text = readFileSync(item.path, 'utf8')
112
+ const body = (frontmatter.parse(text)?.body ?? text).trim()
113
+ if (item.kind === 'rules') {
114
+ const current = (next[GUARDRAILS] ?? '').trimEnd()
115
+ next[GUARDRAILS] = `${current ? `${current}\n\n` : ''}${body}\n`
116
+ return next
117
+ }
118
+ if (item.kind !== 'skills') throw invalid('Only skills and rules can be added to a harness; prompts stay in the library.')
119
+ const slug = basename(item.path) === 'SKILL.md' ? basename(dirname(item.path)) : basename(item.path).replace(/\.md$/, '')
120
+ const name = slug.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|-+$/g, '') || 'skill'
121
+ next[`skills/${name}/SKILL.md`] = `---\nname: ${name}\ndescription: ${(item.description || `the ${name} workflow applies`).replace(/\n/g, ' ')}\n---\n${body}\n`
122
+ // A folder skill's helpers travel with it.
123
+ const folder = dirname(item.path)
124
+ if (basename(item.path) === 'SKILL.md' && isDir(join(folder, 'scripts'))) {
125
+ for (const file of readdirSync(join(folder, 'scripts'))) next[`skills/${name}/scripts/${file}`] = readFileSync(join(folder, 'scripts', file), 'utf8')
126
+ }
127
+ autotrigger(PI_SURFACE, next)
128
+ return next
129
+ }
130
+
131
+ /** Imports inert registry resources. Prompts and rules are single UTF-8 Markdown documents. */
132
+ export function importItems(root: string, kind: ComponentKind, bundles: SkillBundleInput[]): ComponentItem[] {
133
+ if (!Array.isArray(bundles) || bundles.length === 0 || bundles.length > MAX_SKILL_BUNDLES) {
134
+ throw invalid(`Import between 1 and ${MAX_SKILL_BUNDLES} skills at a time.`)
135
+ }
136
+
137
+ const directory = kindDir(root, kind)
138
+ mkdirSync(directory, { recursive: true })
139
+ const existing = new Set(list(root).map((item) => item.id))
140
+ const seen = new Set<string>()
141
+ let totalBytes = 0
142
+ const prepared = bundles.map((bundle) => {
143
+ if (!bundle || typeof bundle.name !== 'string' || !Array.isArray(bundle.files) || bundle.files.length === 0 || bundle.files.length > MAX_SKILL_FILES) {
144
+ throw invalid('Each skill needs a name and a bounded list of files.')
145
+ }
146
+ const name = bundle.name.trim()
147
+ if (name.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
148
+ const slug = Array.from(name.toLowerCase(), (character) => (/^[a-z0-9]$/.test(character) ? character : '-')).join('').split('-').filter(Boolean).join('-')
149
+ if (!slug || slug.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
150
+ const id = `${kind}/${slug}`
151
+ if (existing.has(id) || seen.has(id)) throw invalid(`The skill “${name}” already exists.`)
152
+ try {
153
+ lstatSync(join(directory, kind === 'skills' ? slug : `${slug}.md`))
154
+ throw invalid(`The skill “${name}” already exists.`)
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
157
+ }
158
+ seen.add(id)
159
+
160
+ const files = new Map<string, Uint8Array>()
161
+ for (const file of bundle.files) {
162
+ if (!file || typeof file.path !== 'string' || file.path.length > 240 || typeof file.content !== 'string' || !['utf8', 'base64'].includes(file.encoding)) {
163
+ throw invalid(`The skill “${name}” contains an invalid file.`)
164
+ }
165
+ let relative: string
166
+ try {
167
+ relative = safeRelative(file.path)
168
+ } catch {
169
+ throw invalid(`The skill “${name}” contains a path outside its folder.`)
170
+ }
171
+ if (relative.includes('\0') || relative.split('/').some((part) => part.startsWith('.') || part.includes(':'))) {
172
+ throw invalid(`The skill “${name}” contains an unsupported file path.`)
173
+ }
174
+ const portablePath = relative.normalize('NFC').toLowerCase()
175
+ if ([...files.keys()].some((path) => path.normalize('NFC').toLowerCase() === portablePath)) throw invalid(`The skill “${name}” contains a duplicate file path.`)
176
+ let byteLength: number
177
+ if (file.encoding === 'base64') {
178
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(file.content)) {
179
+ throw invalid(`The file “${relative}” is not valid base64.`)
180
+ }
181
+ if (file.content.length > Math.ceil(MAX_SKILL_IMPORT_BYTES / 3) * 4) throw invalid('The skill import is larger than 10 MiB.')
182
+ byteLength = Math.floor(file.content.length * 3 / 4) - (file.content.endsWith('==') ? 2 : file.content.endsWith('=') ? 1 : 0)
183
+ } else {
184
+ byteLength = Buffer.byteLength(file.content, 'utf8')
185
+ }
186
+ totalBytes += byteLength
187
+ if (totalBytes > MAX_SKILL_IMPORT_BYTES) throw invalid('The skill import is larger than 10 MiB.')
188
+ const bytes = file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : Buffer.from(file.content, 'utf8')
189
+ if (file.encoding === 'base64' && bytes.toString('base64') !== file.content) throw invalid(`The file “${relative}” is not valid base64.`)
190
+ files.set(relative, bytes)
191
+ }
192
+ if (kind === 'skills' && !files.has('SKILL.md')) throw invalid(`The skill “${name}” must include SKILL.md at its root.`)
193
+ if (kind !== 'skills' && (bundle.files.length !== 1 || bundle.files[0].encoding !== 'utf8' || bundle.files[0].path !== 'CONTENT.md')) throw invalid('A prompt or rule must contain one UTF-8 CONTENT.md file.')
194
+ return { name, slug, files }
195
+ })
196
+
197
+ const staging = mkdtempSync(join(directory, '.agent-template-import-'))
198
+ const installed: string[] = []
199
+ try {
200
+ for (const bundle of prepared) {
201
+ const target = join(staging, bundle.slug)
202
+ for (const [relative, bytes] of bundle.files) {
203
+ const path = join(target, ...relative.split('/'))
204
+ mkdirSync(dirname(path), { recursive: true })
205
+ writeFileSync(path, bytes, { flag: 'wx' })
206
+ }
207
+ }
208
+ for (const bundle of prepared) {
209
+ const target = join(directory, kind === 'skills' ? bundle.slug : `${bundle.slug}.md`)
210
+ renameSync(kind === 'skills' ? join(staging, bundle.slug) : join(staging, bundle.slug, 'CONTENT.md'), target)
211
+ installed.push(target)
212
+ }
213
+ } catch (error) {
214
+ for (const target of installed) rmSync(target, { recursive: true, force: true })
215
+ throw error
216
+ } finally {
217
+ rmSync(staging, { recursive: true, force: true })
218
+ }
219
+ return list(root)
220
+ }
221
+