@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
@@ -6,10 +6,10 @@ import { ensureSharedDir, sharedDir } from '../accounts/env'
6
6
  import { invalid, notFound } from '../core/errors'
7
7
  import { findProject } from '../core/projects'
8
8
  import type { Harness } from '../harness'
9
- import type { CallerContext } from '../memory/caller'
9
+ import type { CallerContext } from '../core/caller'
10
10
  import type { Launch } from '../prompt/compile'
11
+ import { materialise, type HarnessChoice } from '../tree/materialise'
11
12
  import * as attachments from './attachments'
12
- import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
13
13
  import { catalogue } from './catalogue'
14
14
  import { inventory } from './inventory'
15
15
  import type { Loading } from './plugins'
@@ -67,6 +67,8 @@ export interface InventoryParams extends PluginScope {
67
67
  launch?: Launch
68
68
  tools?: string[]
69
69
  connections?: boolean
70
+ /** The harness tree the caller runs, so what it adds is listed too. */
71
+ harness?: HarnessChoice | null
70
72
  }
71
73
 
72
74
  export async function inventoryFor(harness: Harness, params: InventoryParams): Promise<Inventory> {
@@ -79,7 +81,8 @@ export async function inventoryFor(harness: Harness, params: InventoryParams): P
79
81
  // What is switched off for the caller is not in what reaches it.
80
82
  plugins: await list(harness, { ...params, agentId: params.agentId ?? params.context?.agentId ?? null, projectId: params.projectId ?? params.context?.projectId ?? null }),
81
83
  descriptors: harness.descriptors,
82
- saved: harness.apps.connections.list()
84
+ saved: harness.apps.connections.list(),
85
+ tree: params.harness ? materialise(harness.env.root, params.harness, { trust: harness.trust }) : null
83
86
  })
84
87
  }
85
88
 
@@ -129,13 +132,12 @@ export async function attach(harness: Harness, params: AttachParams, on: boolean
129
132
  if (contribution?.kind === 'connection') {
130
133
  harness.apps.attach(contribution.id.slice(`${plugin.id}/connection/`.length), scope, on)
131
134
  } else {
132
- const registryItem = plugin.id === YOURS_PLUGIN.id || plugin.id === INCLUDED_PLUGIN.id
133
- // A registry item's choice is stored under its own id; anything found, under its id and then its plugin's.
134
- const ids = registryItem ? [contribution!.id.slice(plugin.id.length + 1)] : contribution ? [contribution.id, plugin.id] : [plugin.id]
135
- // What it is until a person says otherwise: a built-in registry item is on, a person's own is
136
- // off, a part that ships switched off is off, and anything found on disk is on.
135
+ // A choice is stored under the contribution's id and then its plugin's.
136
+ const ids = contribution ? [contribution.id, plugin.id] : [plugin.id]
137
+ // What it is until a person says otherwise: a part that ships switched off is off, and
138
+ // anything found on disk is on.
137
139
  const shipped = harness.descriptors.find((descriptor) => descriptor.id === plugin.id)?.switchable
138
- const fallback = registryItem ? plugin.id === INCLUDED_PLUGIN.id : shipped ? shipped.on : true
140
+ const fallback = shipped ? shipped.on : true
139
141
  const choices = attachments.load(root)
140
142
  attachments.set(choices, ids, scope, on, fallback)
141
143
  attachments.save(root, choices)
@@ -3,12 +3,11 @@
3
3
  import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
4
4
  import { ASK_USER } from '../tools/askUser'
5
5
  import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
6
- import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
7
- import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
6
+ import { RECALL_TOOLS, SKILL_TOOLS, TRAJECTORY_TOOLS, WIKI_READ_TOOLS, WIKI_WRITE_TOOLS, type HarnessToolDefinition } from '../tools/definitions'
8
7
  import { FIRST_PARTY_PLUGINS } from './firstParty'
9
8
  import type { PluginDescriptor } from './types'
10
9
 
11
- const tools = (definitions: readonly WikiToolDefinition[]): PluginDescriptor['tools'] => definitions.map((tool) => ({ name: tool.name, description: tool.label }))
10
+ const tools = (definitions: readonly HarnessToolDefinition[]): PluginDescriptor['tools'] => definitions.map((tool) => ({ name: tool.name, description: tool.label }))
12
11
 
13
12
  /** Pi itself. Its command's built-in extensions are added to it as they are found. */
14
13
  export const PI_PLUGIN: PluginDescriptor = {
@@ -20,55 +19,39 @@ export const PI_PLUGIN: PluginDescriptor = {
20
19
  toolGroups: ['Native']
21
20
  }
22
21
 
23
- /** A person's own registry. It owns the rules and skills sections even while it is empty. */
24
- export const YOURS_PLUGIN: PluginDescriptor = {
25
- id: 'yours',
26
- title: 'Yours',
27
- description: 'Rules, skills and prompts you wrote, kept as files in the registry folder.',
28
- manage: 'registry',
22
+ /** The harness tree: the eight components that shape what an agent is told and can do. */
23
+ export const HARNESS_TREE_PLUGIN: PluginDescriptor = {
24
+ id: 'harness',
25
+ title: 'Harness',
26
+ description: 'The agent\'s own harness: system prompt, guardrails, memory and tool guidance in every prompt; skills, scripts and subagents on demand; plugins in the tool loop. Versioned, and refined from evidence.',
27
+ required: 'Every agent runs on its harness.',
29
28
  sections: [
30
- { id: 'rules', name: 'Rules', description: 'The full text of every rule that is on.' },
31
- { id: 'skills', name: 'Skills list', description: 'One line for each skill that is on.' }
29
+ { id: 'system', name: 'System prompt', description: 'c1: role and procedure.' },
30
+ { id: 'guardrails', name: 'Guardrails', description: 'c2: standing rules.' },
31
+ { id: 'memory', name: 'Memory', description: 'c3: durable facts about this kind of work.' },
32
+ { id: 'subagents', name: 'Subagents', description: 'c5: the list of this harness\'s subagents.' },
33
+ { id: 'scripts', name: 'Scripts', description: 'c7: where this harness\'s scripts are.' }
32
34
  ],
33
- tools: WIKI_TOOLS.filter((tool) => tool.name === 'skill_load').map((tool) => ({ name: tool.name, description: tool.label }))
35
+ tools: tools(SKILL_TOOLS)
34
36
  }
35
37
 
36
- export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Included', description: 'Rules, skills and prompts that ship with the app.', manage: 'registry' }
37
-
38
38
  export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
39
39
  PI_PLUGIN,
40
40
  { id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
41
- {
42
- id: 'memory',
43
- title: 'Memory',
44
- description: 'Blocks of text that are always in context and that the agent edits as it works.',
45
- required: 'Every agent keeps its own memory, and the background upkeep depends on it.',
46
- // Facts are memory too: what the agent wrote down, recalled on request.
47
- tools: tools([...MEMORY_TOOLS, ...WIKI_TOOLS.filter((tool) => tool.name === 'memory_facts')]),
48
- sections: [{ id: 'blocks', name: 'Memory blocks', description: 'Every block attached to the caller, in full.' }],
49
- toolGroups: ['Memory']
50
- },
41
+ HARNESS_TREE_PLUGIN,
51
42
  {
52
43
  id: 'wiki',
53
44
  title: 'Wiki',
54
- description: 'Pages and facts the agent searches, reads, and proposes changes to.',
55
- required: 'What was learned is recorded and checked here.',
56
- // `WIKI_TOOLS` is every memory tool; the Wiki's own are the ones that are not Memory's or History's.
57
- tools: tools(WIKI_TOOLS.filter((tool) => tool.name.startsWith('wiki_'))),
45
+ description: 'Notes about projects, linked to each other, that agents search and read.',
46
+ required: 'Project notes are reached through it.',
47
+ tools: tools([...WIKI_READ_TOOLS, ...WIKI_WRITE_TOOLS]),
58
48
  sections: [
59
- { id: 'knowledge', name: 'Knowledge map', description: 'Project pages, the Library count, and facts that match the request.' },
60
- { id: 'references', name: 'Selected references', description: 'Pages and skills pinned to one request.' }
49
+ { id: 'wiki', name: 'Wiki', description: 'The current project\'s notes.' },
50
+ { id: 'references', name: 'Selected references', description: 'Notes pinned to one request.' }
61
51
  ],
62
- toolGroups: ['Knowledge']
63
- },
64
- { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
65
- {
66
- id: 'plugin-proposals',
67
- title: 'Plugin proposals',
68
- description: 'Lets an agent propose a plugin. You accept it in the Inbox, switch it on, and trust its code; an agent can do none of those.',
69
- required: 'It only proposes. Nothing is saved or run until you decide.',
70
- tools: [{ name: PLUGIN_PROPOSE, description: 'Propose a plugin' }]
52
+ toolGroups: ['Wiki']
71
53
  },
54
+ { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]), toolGroups: ['History'] },
72
55
  { id: 'ask-user', title: 'Ask the user', description: 'Lets the agent stop and ask a question with choices.', required: 'An agent must be able to stop and ask you.', tools: [{ name: ASK_USER }] },
73
56
  {
74
57
  id: 'connected-apps',
@@ -79,6 +62,5 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
79
62
  tools: CONNECTION_TOOLS.map((name) => ({ name })),
80
63
  toolGroups: ['Connections']
81
64
  },
82
- YOURS_PLUGIN,
83
65
  ...FIRST_PARTY_PLUGINS
84
66
  ]
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
6
6
  import { memorySecretStore } from '../accounts/secrets'
7
7
  import { createHarness, type Harness } from '../harness'
8
8
  import { methods } from '../server/methods'
9
- import { WIKI_TOOLS } from '../tools/definitions'
9
+ import { HARNESS_TOOLS } from '../tools/definitions'
10
10
  import { WEB_PLUGIN } from '../web/tools'
11
11
  import { rootWithProject, writeFile } from '../../tests/helpers'
12
12
  import { HARNESS_PLUGINS } from './builtin'
@@ -68,12 +68,11 @@ describe('the plugin list', () => {
68
68
  ['project', 'project', ['code:local.ts:untrusted', 'instruction:AGENTS.md:on']],
69
69
  ['package:review-kit', 'package', ['code:guard.ts:on', 'command:guard:on', 'skill:audit:on']],
70
70
  ['home', 'home', ['skill:deploy:on']],
71
- ['yours', 'yours', ['instruction:Rules:on', 'instruction:Skills list:on', 'tool:skill_load:on', 'command:review:on', 'instruction:Run tests:on', 'skill:Release notes:on', 'skill:unused:off']],
72
71
  ['app-identity', 'application', ['instruction:Identity line:on']],
73
72
  ['app-tasks', 'application', ['tool:start_task:on']],
74
73
  ['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
75
74
  ])
76
- expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'subagents', 'wiki'])
75
+ expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'harness', 'history', 'output-trim', 'subagents', 'wiki'])
77
76
 
78
77
  const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
79
78
  // It was there when trust was first asked per package, so it was taken over as trusted.
@@ -81,8 +80,6 @@ describe('the plugin list', () => {
81
80
  expect(kit.contributions[0].description).toBe('Adds commands /guard; hooks tool_call.')
82
81
  const project = plugins.find((plugin) => plugin.id === 'project')!
83
82
  expect(project).toMatchObject({ runsCode: true, trust: 'untrusted', code: { files: ['.pi/extensions/local.ts'] } })
84
- const yours = plugins.find((plugin) => plugin.id === 'yours')!
85
- expect(yours.contributions.map((entry) => entry.scope)).toEqual([null, null, null, 'App', 'Everywhere', 'App', null])
86
83
  const pi = plugins.find((plugin) => plugin.id === 'pi')!
87
84
  expect(pi.required).toBe('Pi runs every session.')
88
85
  expect(pi.contributions.filter((entry) => entry.kind === 'code').map((entry) => [entry.name, entry.state])).toEqual([['mcp', 'on'], ['llama.cpp', 'off']])
@@ -94,7 +91,7 @@ describe('the plugin list', () => {
94
91
  expect(names.filter((name, index) => names.indexOf(name) !== index)).toEqual([])
95
92
  }
96
93
  const declared = HARNESS_PLUGINS.flatMap((plugin) => (plugin.tools ?? []).map((tool) => tool.name))
97
- expect(WIKI_TOOLS.map((tool) => tool.name).filter((name) => !declared.includes(name))).toEqual([])
94
+ expect(HARNESS_TOOLS.map((tool) => tool.name).filter((name) => !declared.includes(name))).toEqual([])
98
95
  })
99
96
 
100
97
  it('lists nothing a folder brings when no folder is named, and what every account shares even before the first account exists', async () => {
@@ -133,20 +130,21 @@ describe('what reaches one caller', () => {
133
130
  }
134
131
 
135
132
  it('attributes every prompt section, tool group and tool to a plugin', async () => {
136
- const { call, cwd } = setup()
137
- const result = await call<Inventory>('plugins.inventory', { cwd, context: { agentId: 'a1', projectId: 'p1' }, launch, tools: ['read', 'bash', 'start_task', 'web_search'] })
133
+ const { call, cwd, harness } = setup()
134
+ const { commit } = await import('../tree/store')
135
+ commit(harness.env.root, 'a1', { 'systemprompt.md': 'You review changes.\n', 'guardrails.md': '- When writing release notes, load the `release` skill.\n', 'memory.md': '', 'tool_guidance.md': 'Prefer grep.\n', 'skills/release/SKILL.md': '---\nname: release\ndescription: writing release notes\n---\nSteps.\n' }, { kind: 'seed' })
136
+ const result = await call<Inventory>('plugins.inventory', { cwd, context: { agentId: 'a1', projectId: 'p1' }, launch, tools: ['read', 'bash', 'start_task', 'web_search'], harness: { scope: 'a1' } })
138
137
  expect(result.unattributed).toEqual([])
139
138
  const of = (plugin: string) => result.items.filter((item) => item.pluginId === plugin).map((item) => `${item.kind}:${item.name}:${item.delivery}`)
140
139
  expect(of('app-identity')).toEqual(['instruction:Identity line:system'])
141
140
  expect(of('app-tasks')).toEqual(['instruction:Tool guidance: Tasks:system', 'tool:start_task:tool'])
142
- expect(of('yours')).toEqual(['instruction:Run tests:system', 'instruction:Skills list:system', 'command:review:on-demand', 'skill:Release notes:on-demand', 'tool:skill_load:tool'])
143
- // Facts are memory: recalling them is a memory tool, though it is defined beside the Wiki's.
144
- expect(of('memory')).toEqual(['instruction:Memory blocks:context', 'instruction:Tool guidance: Memory:system', 'tool:memory_append:tool', 'tool:memory_replace:tool', 'tool:memory_rethink:tool', 'tool:memory_facts:tool'])
141
+ expect(of('harness')).toEqual(['instruction:System prompt:system', 'instruction:Guardrails:system', 'skill:release:on-demand', 'tool:skill_load:tool'])
142
+ expect(of('wiki')).toEqual(['instruction:Wiki:context', 'instruction:Tool guidance: Wiki:system', 'tool:wiki_search:tool', 'tool:wiki_read:tool', 'tool:wiki_list:tool'])
143
+ expect(of('history')).toEqual(['instruction:Tool guidance: History:system', 'tool:history_search:tool', 'tool:history_read:tool', 'tool:trajectory_search:tool', 'tool:trajectory_read:tool'])
145
144
  expect(of('pi')).toEqual(['instruction:Tool guidance: Native:system', 'tool:read:tool', 'tool:bash:tool', 'instruction:Base prompt:system'])
146
145
  // The working folder's code is not trusted, so only its instruction file reaches the caller.
147
146
  expect(of('project')).toEqual(['instruction:AGENTS.md:system'])
148
147
  expect(of('package:review-kit')).toEqual(['code:guard.ts:code', 'skill:audit:on-demand'])
149
- expect(result.items.find((item) => item.name === 'Run tests')).toMatchObject({ contributionId: 'yours/rules/tests', cut: false })
150
148
  expect(result.items.find((item) => item.name === 'AGENTS.md')?.chars).toBe(10)
151
149
  expect(result.systemChars).toBeGreaterThan(0)
152
150
  expect(result.contextChars).toBeGreaterThan(0)
@@ -177,31 +175,6 @@ describe('what reaches one caller', () => {
177
175
  })
178
176
 
179
177
  describe('switching a plugin on and off', () => {
180
- const state = (plugins: Plugin[], id: string) => plugins.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === id)?.state
181
-
182
- it('switches a registry item for one agent, and the agent then gets it in its prompt', async () => {
183
- const { call } = setup()
184
- const compiled = async (agentId: string) => (await call<{ text: string }>('context.compile', { context: { agentId, actor: 'Chat' }, render: true })).text
185
- expect(await compiled('a1')).not.toContain('Release notes')
186
- const after = await call<Plugin[]>('plugins.attach', { id: 'yours/skills/release', scope: { agentId: 'a1' } })
187
- const release = after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/skills/release')!
188
- expect(release.attachment).toEqual({ global: false, on: { agentIds: ['a1'], projectIds: ['p1'] }, off: { agentIds: [], projectIds: [] } })
189
- expect(release.scope).toBe('App, 1 agent')
190
- expect(await compiled('a1')).toContain('Release notes')
191
- expect(await compiled('a2')).not.toContain('Release notes')
192
- // The earlier calls read the same choices.
193
- expect((await call<any>('skills.list')).enabled).toEqual({ global: ['rules/tests'], p1: ['skills/release', 'prompts/review'] })
194
-
195
- // A rule that is on everywhere is switched off for one agent only.
196
- await call('plugins.detach', { id: 'yours/rules/tests', scope: { agentId: 'a1' } })
197
- expect(await compiled('a1')).not.toContain('Always run the test suite')
198
- expect(await compiled('a2')).toContain('Always run the test suite')
199
- expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a1' }), 'yours/rules/tests')).toBe('off')
200
- expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a2' }), 'yours/rules/tests')).toBe('on')
201
-
202
- await call('plugins.forgetAgent', { agentId: 'a1' })
203
- expect(await compiled('a1')).toContain('Always run the test suite')
204
- })
205
178
 
206
179
  it('stops a found plugin, or one item of it, from loading for the caller it was switched off for', async () => {
207
180
  const { call, harness, cwd, base } = setup()
@@ -230,13 +203,12 @@ describe('switching a plugin on and off', () => {
230
203
 
231
204
  it('refuses a required part, an application part, and a file Pi reads itself, and says why', async () => {
232
205
  const { call, cwd } = setup()
233
- await expect(call('plugins.detach', { id: 'memory' })).rejects.toThrow('Memory cannot be switched off. Every agent keeps its own memory')
234
- await expect(call('plugins.detach', { id: 'memory/tool/memory_append' })).rejects.toThrow('cannot be switched off')
206
+ await expect(call('plugins.detach', { id: 'harness' })).rejects.toThrow('Harness cannot be switched off. Every agent runs on its harness')
207
+ await expect(call('plugins.detach', { id: 'wiki/tool/wiki_read' })).rejects.toThrow('cannot be switched off')
235
208
  await expect(call('plugins.detach', { id: 'app-tasks' })).rejects.toThrow('switched by the application')
236
- await expect(call('plugins.detach', { id: 'yours' })).rejects.toThrow('one item at a time')
237
209
  await expect(call('plugins.detach', { cwd, id: 'project/instruction/AGENTS.md' })).rejects.toThrow('cannot be switched on its own')
238
210
  await expect(call('plugins.detach', { id: 'no-such-plugin' })).rejects.toThrow()
239
- await expect(call('plugins.attach', { id: 'yours/skills/release', scope: { projectId: 'nope' } })).rejects.toThrow()
211
+ await expect(call('plugins.attach', { id: 'package:review-kit', scope: { projectId: 'nope' } })).rejects.toThrow()
240
212
  })
241
213
 
242
214
  it('switches a connected app for one agent through its own scope', async () => {
@@ -1,13 +1,11 @@
1
1
  // Every plugin there is, from every source, as one list. Building it reads files and settings;
2
2
  // it runs no plugin's code and changes nothing.
3
- import { basename, dirname, join } from 'node:path'
3
+ import { basename, dirname } from 'node:path'
4
4
 
5
5
  import { projectsOrNone } from '../core/projects'
6
6
  import { compareText } from '../core/text'
7
7
  import type { Connection } from '../mcp/types'
8
- import * as registry from '../skills/registry'
9
8
  import { attached, describe, type Attachment, type Attachments, type AttachScope } from './attachments'
10
- import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
11
9
  import { settingValue } from './firstParty'
12
10
  import { BUILTIN_PREFIX, type Found } from './discover'
13
11
  import { registrationHash } from './runtime'
@@ -40,8 +38,6 @@ export interface CatalogueInput {
40
38
  caller: AttachScope
41
39
  }
42
40
 
43
- const REGISTRY_KINDS: Record<string, ContributionKind> = { rules: 'instruction', skills: 'skill', prompts: 'command' }
44
-
45
41
  const contribution = (plugin: string, kind: ContributionKind, name: string, rest: Partial<Contribution> = {}): Contribution => ({
46
42
  id: contributionId(plugin, kind, name),
47
43
  kind,
@@ -98,24 +94,6 @@ function scopeWords(attachment: Attachment, projects: Map<string, string>): stri
98
94
  return parts.length ? parts.join(', ') : null
99
95
  }
100
96
 
101
- /** A person's registry items, and the built-in ones, as contributions with who each is on for. */
102
- function registryContributions(input: CatalogueInput, projects: Map<string, string>): { yours: Contribution[]; included: Contribution[] } {
103
- const yours: Contribution[] = []
104
- const included: Contribution[] = []
105
- const scoped = !!(input.caller.agentId || input.caller.projectId)
106
- for (const item of registry.list(input.root)) {
107
- const kind = REGISTRY_KINDS[item.kind]
108
- if (!kind) continue
109
- // A registry item's choices are stored under its own id, whichever plugin lists it.
110
- const attachment = describe(input.attachments, item.id, !!item.builtin)
111
- const scope = scopeWords(attachment, projects)
112
- const on = scoped ? attached(input.attachments, [item.id], input.caller, !!item.builtin) : scope !== null
113
- const plugin = item.builtin ? INCLUDED_PLUGIN.id : YOURS_PLUGIN.id
114
- ;(item.builtin ? included : yours).push({ ...contribution(plugin, kind, item.name, { description: item.description, path: item.path, state: on ? 'on' : 'off', scope, attachment }), id: `${plugin}/${item.id}` })
115
- }
116
- return { yours, included }
117
- }
118
-
119
97
  /** A connection keeps its own scope; this is the same shape for a screen. */
120
98
  function connectionAttachment(connection: Connection): Attachment | null {
121
99
  if (!connection.enabled) return null
@@ -177,16 +155,8 @@ const empty = (id: string, title: string, description: string, source: PluginSou
177
155
 
178
156
  export function catalogue(input: CatalogueInput): Plugin[] {
179
157
  const plugins = new Map<string, Plugin>()
180
- for (const descriptor of input.descriptors) plugins.set(descriptor.id, fromDescriptor(descriptor, descriptor.id === 'pi' ? 'pi' : descriptor.id === YOURS_PLUGIN.id ? 'yours' : input.application.has(descriptor.id) ? 'application' : 'builtin', input.attachments, input.caller))
158
+ for (const descriptor of input.descriptors) plugins.set(descriptor.id, fromDescriptor(descriptor, descriptor.id === 'pi' ? 'pi' : input.application.has(descriptor.id) ? 'application' : 'builtin', input.attachments, input.caller))
181
159
 
182
- const projects = new Map(projectsOrNone(input.root).map((project) => [project.id, project.name]))
183
- const items = registryContributions(input, projects)
184
- const yours = plugins.get(YOURS_PLUGIN.id)
185
- if (yours) {
186
- yours.path = join(input.root, 'registry')
187
- yours.contributions.push(...items.yours)
188
- }
189
- if (items.included.length) plugins.set(INCLUDED_PLUGIN.id, { ...fromDescriptor(INCLUDED_PLUGIN, 'builtin'), contributions: items.included })
190
160
 
191
161
  const apps = plugins.get('connected-apps')
192
162
  if (apps) {
@@ -196,6 +166,7 @@ export function catalogue(input: CatalogueInput): Plugin[] {
196
166
  }
197
167
  }
198
168
 
169
+ const projects = new Map(projectsOrNone(input.root).map((project) => [project.id, project.name]))
199
170
  const loading = input.loading
200
171
  const trusted = trustedBy(loading)
201
172
  for (const item of loading?.found ?? []) {
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
5
5
  import { join, sep } from 'node:path'
6
6
 
7
7
  import { log } from '../core/bus'
8
+ import { loadPi } from '../pi/runtime'
8
9
  import { packageManifest } from './manifest'
9
10
 
10
11
  /**
@@ -82,7 +83,7 @@ export async function withoutInstallScripts<T>(action: () => Promise<T>): Promis
82
83
  }
83
84
 
84
85
  export const piResolver: Resolver = async ({ cwd, agentDir, install, packages }) => {
85
- const pi = await import('@earendil-works/pi-coding-agent')
86
+ const pi = await loadPi()
86
87
  const settings = pi.SettingsManager.create(cwd, agentDir)
87
88
  const shared = new Set((settings.getGlobalSettings().packages ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.source)))
88
89
  const manager = (): InstanceType<typeof pi.DefaultPackageManager> => new pi.DefaultPackageManager({ cwd, agentDir, settingsManager: settings, builtinExtensions: [...PI_BUILTINS] })
@@ -23,7 +23,7 @@ async function setup() {
23
23
  const root = rootWithProject()
24
24
  const base = mkdtempSync(join(tmpdir(), 'first-party-'))
25
25
  // The background upkeep would ask the scripted model too; it is not what is tested here.
26
- const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), maintainerFactory: async () => ({ prompt: async () => undefined, dispose: () => undefined, error: () => null }) })
26
+ const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore() })
27
27
  open.push(harness)
28
28
  harness.accounts.create('Main', 'apiKey')
29
29
  const model = await fauxModel(harness)
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
1
+ import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, describe, expect, it } from 'vitest'
@@ -6,13 +6,9 @@ import { afterEach, describe, expect, it } from 'vitest'
6
6
  import { memorySecretStore } from '../accounts/secrets'
7
7
  import { Bus } from '../core/bus'
8
8
  import { createHarness, type Harness } from '../harness'
9
- import { listInbox } from '../memory/operations'
10
- import { wikiDir } from '../memory/store'
11
9
  import { methods } from '../server/methods'
12
- import { pluginProposeTool } from '../tools/pluginPropose'
13
10
  import { rootWithProject, writeFile } from '../../tests/helpers'
14
11
  import { entryEvent, Hooks, type HookEvent } from './hooks'
15
- import { checkPluginProposal } from './proposal'
16
12
  import type { Plugin } from './types'
17
13
 
18
14
  const open: Harness[] = []
@@ -31,7 +27,7 @@ const recorder = (out: string, events: string[], extra = ''): string =>
31
27
  const told = (out: string): Record<string, unknown>[] => (existsSync(out) ? readFileSync(out, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)) : [])
32
28
 
33
29
  function kit(folder: string, name: string, hooks: string): void {
34
- writeFile(join(folder, 'package.json'), JSON.stringify({ name, version: '1.0.0', description: `${name} mirrors memory.`, harness: { extends: ['memory', 'nonsense'], hooks: './hooks.mjs' } }))
30
+ writeFile(join(folder, 'package.json'), JSON.stringify({ name, version: '1.0.0', description: `${name} mirrors notes.`, harness: { extends: ['wiki', 'nonsense'], hooks: './hooks.mjs' } }))
35
31
  writeFile(join(folder, 'hooks.mjs'), hooks)
36
32
  }
37
33
 
@@ -42,30 +38,28 @@ function setup() {
42
38
  open.push(harness)
43
39
  const table = methods(harness)
44
40
  const call = <T>(method: string, params: Record<string, unknown> = {}) => table[method](params) as Promise<T>
45
- const saveFact = (title: string, body: string) => call('facts.save', { input: { title, kind: 'fact', body, namespace: '/' } })
41
+ const saveFact = (title: string, body: string) => call('wiki.write', { path: `library/${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, text: `---\ntitle: ${title}\n---\n\n${body}\n` })
46
42
  return { harness, root, base, call, saveFact }
47
43
  }
48
44
 
49
45
  describe('a written file as an event', () => {
50
- it('is a fact, a memory block, or a page by where it is in the Wiki', () => {
46
+ it('is a saved note, titled from its front matter', () => {
51
47
  const wiki = '/data/wiki'
52
48
  const change = (path: string, text: string) => entryEvent({ wiki, id: 'x', path: join(wiki, path), text, actor: 'user', reason: 'Edited' })
53
- expect(change('facts/pnpm.md', '---\ntitle: Use pnpm\nkind: preference\n---\n\nAlways pnpm.\n')).toMatchObject({ type: 'memory.saved', title: 'Use pnpm', body: 'Always pnpm.', actor: 'user' })
54
- expect(change('blocks/agents/a1/notes.md', '---\nlabel: notes\n---\n\nLikes tabs.\n')).toMatchObject({ type: 'memory.block.changed', title: 'notes', body: 'Likes tabs.' })
55
- expect(change('library/arch.md', '---\ntitle: Architecture\n---\n\nLayers.\n')).toMatchObject({ type: 'wiki.page.saved', title: 'Architecture' })
49
+ expect(change('library/arch.md', '---\ntitle: Architecture\n---\n\nLayers.\n')).toMatchObject({ type: 'wiki.page.saved', title: 'Architecture', body: 'Layers.', actor: 'user' })
56
50
  expect(change('home.md', 'No front matter.')).toMatchObject({ type: 'wiki.page.saved', title: 'x', body: 'No front matter.' })
57
51
  })
58
52
  })
59
53
 
60
54
  describe('hooks a plugin gives the harness', () => {
61
- it('mirrors saved facts through a real disposable MCP subprocess and stops on detach, changed code or revoked permission', async () => {
55
+ it('mirrors saved notes through a real disposable MCP subprocess and stops on detach, changed code or revoked permission', async () => {
62
56
  const { harness, base, call, saveFact } = setup()
63
57
  const output = join(base, 'mirrored.jsonl')
64
58
  await harness.apps.save({ id: 'mirror', name: 'Disposable mirror', kind: 'local', command: process.execPath, args: [join(process.cwd(), 'tests/mcp-hook-fixture.mjs'), output], enabled: true, global: true, readTools: ['mirror_fact'] })
65
59
  const paths = ['one', 'two'].map((name) => join(base, name))
66
60
  for (const [index, path] of paths.entries()) {
67
61
  const name = ['mirror-one', 'mirror-two'][index]
68
- kit(path, name, `export default function(harness) { harness.on('memory.saved', async (event, ctx) => { await ctx.call('Disposable mirror', 'mirror_fact', { plugin: '${name}', id: event.id, title: event.title, body: event.body }) }) }\n`)
62
+ kit(path, name, `export default function(harness) { harness.on('wiki.page.saved', async (event, ctx) => { await ctx.call('Disposable mirror', 'mirror_fact', { plugin: '${name}', id: event.id, title: event.title, body: event.body }) }) }\n`)
69
63
  const plugins = await call<Plugin[]>('plugins.install', { source: path })
70
64
  const plugin = plugins.find((entry) => entry.id === `package:${name}`)!
71
65
  await call('plugins.attach', { id: plugin.id })
@@ -97,17 +91,17 @@ describe('hooks a plugin gives the harness', () => {
97
91
  const { harness, base, call, saveFact } = setup()
98
92
  const one = join(base, 'one.log')
99
93
  const two = join(base, 'two.log')
100
- kit(join(base, 'docs'), 'memory-to-docs', recorder(one, ['memory.saved']))
101
- kit(join(base, 'sheet'), 'memory-to-sheet', recorder(two, ['memory.saved', 'history.run.ended']))
94
+ kit(join(base, 'docs'), 'notes-to-docs', recorder(one, ['wiki.page.saved']))
95
+ kit(join(base, 'sheet'), 'notes-to-sheet', recorder(two, ['wiki.page.saved', 'history.run.ended']))
102
96
  await call('plugins.install', { source: join(base, 'docs') })
103
97
  const listed = await call<Plugin[]>('plugins.install', { source: join(base, 'sheet') })
104
- const docs = listed.find((plugin) => plugin.id === 'package:memory-to-docs')!
105
- const sheet = listed.find((plugin) => plugin.id === 'package:memory-to-sheet')!
98
+ const docs = listed.find((plugin) => plugin.id === 'package:notes-to-docs')!
99
+ const sheet = listed.find((plugin) => plugin.id === 'package:notes-to-sheet')!
106
100
  // What it says it extends is kept only for parts that exist.
107
- expect(docs).toMatchObject({ extends: ['memory'], runsCode: true, trust: 'untrusted', attachment: { global: false } })
101
+ expect(docs).toMatchObject({ extends: ['wiki'], runsCode: true, trust: 'untrusted', attachment: { global: false } })
108
102
  expect(docs.contributions.map((entry) => [entry.kind, entry.name, entry.state])).toEqual([['code', 'hooks.mjs', 'off']])
109
103
 
110
- // Installed, switched off, untrusted: a saved fact reaches nobody.
104
+ // Installed, switched off, untrusted: a saved note reaches nobody.
111
105
  await saveFact('Use pnpm', 'Always pnpm.')
112
106
  await harness.hooks.settled()
113
107
  expect([told(one), told(two)]).toEqual([[], []])
@@ -120,17 +114,17 @@ describe('hooks a plugin gives the harness', () => {
120
114
  await harness.hooks.settled()
121
115
  expect([told(one), told(two)]).toEqual([[], []])
122
116
 
123
- // Trusted: both hear the next fact, each in its own way.
117
+ // Trusted: both hear the next note, each in its own way.
124
118
  await call('plugins.trust', { hash: docs.code!.hash })
125
119
  const trusted = await call<Plugin[]>('plugins.trust', { hash: sheet.code!.hash })
126
120
  await harness.hooks.reload()
127
121
  await saveFact('Line length', 'Wrap at 100.')
128
122
  await harness.hooks.settled()
129
- expect(told(one)).toEqual([{ type: 'memory.saved', id: expect.any(String), title: 'Line length', body: 'Wrap at 100.', actor: 'user' }])
123
+ expect(told(one)).toEqual([{ type: 'wiki.page.saved', id: expect.any(String), title: 'Line length', body: 'Wrap at 100.', actor: 'user' }])
130
124
  expect(told(two)).toEqual(told(one))
131
125
  expect(trusted.find((plugin) => plugin.id === docs.id)!.contributions[0]).toMatchObject({ state: 'on', problem: null })
132
126
  const described = (await call<Plugin[]>('plugins.list')).find((plugin) => plugin.id === sheet.id)!.contributions[0]
133
- expect(described.description).toBe('Runs in the background after: memory.saved, history.run.ended.')
127
+ expect(described.description).toBe('Runs in the background after: wiki.page.saved, history.run.ended.')
134
128
 
135
129
  // Switched off for everyone again, it stops hearing; the other keeps going.
136
130
  await call('plugins.detach', { id: docs.id })
@@ -144,20 +138,20 @@ describe('hooks a plugin gives the harness', () => {
144
138
  const bus = new Bus()
145
139
  const base = temp()
146
140
  const out = join(base, 'ok.log')
147
- writeFile(join(base, 'bad.mjs'), `export default function (harness) { harness.on('memory.saved', async () => { throw new Error('the document is gone') }) }\n`)
148
- writeFile(join(base, 'slow.mjs'), `export default function (harness) { harness.on('memory.saved', () => new Promise(() => {})) }\n`)
149
- writeFile(join(base, 'good.mjs'), recorder(out, ['memory.saved']))
141
+ writeFile(join(base, 'bad.mjs'), `export default function (harness) { harness.on('wiki.page.saved', async () => { throw new Error('the document is gone') }) }\n`)
142
+ writeFile(join(base, 'slow.mjs'), `export default function (harness) { harness.on('wiki.page.saved', () => new Promise(() => {})) }\n`)
143
+ writeFile(join(base, 'good.mjs'), recorder(out, ['wiki.page.saved']))
150
144
  writeFile(join(base, 'broken.mjs'), `export const nothing = 1\n`)
151
145
  writeFile(join(base, 'unknown.mjs'), `export default function (harness) { harness.on('memory.deleted', () => {}) }\n`)
152
146
  const hooks = new Hooks({ stateDir: base, bus, timeoutMs: 40, importFile, call: async () => undefined, wanted: async () => ['bad', 'slow', 'good', 'broken', 'unknown'].map((name) => ({ path: join(base, `${name}.mjs`), hash: 'h1' })) })
153
147
  await hooks.reload()
154
- const event: HookEvent = { type: 'memory.saved', id: 'f1', path: '/w/facts/f1.md', title: 'Fact', body: 'Body', text: 'Body', actor: 'user', reason: 'Created', at: '2026-10-13T00:00:00.000Z' }
148
+ const event: HookEvent = { type: 'wiki.page.saved', id: 'f1', path: '/w/library/f1.md', title: 'Fact', body: 'Body', text: 'Body', actor: 'user', reason: 'Created', at: '2026-10-13T00:00:00.000Z' }
155
149
  hooks.emit(event)
156
150
  await hooks.settled()
157
151
  // The good hook ran although two before it failed.
158
152
  expect(told(out)).toHaveLength(1)
159
153
  const status = hooks.status()
160
- expect(status[join(base, 'bad.mjs')]).toMatchObject({ events: ['memory.saved'], lastError: 'memory.saved: the document is gone' })
154
+ expect(status[join(base, 'bad.mjs')]).toMatchObject({ events: ['wiki.page.saved'], lastError: 'wiki.page.saved: the document is gone' })
161
155
  expect(status[join(base, 'slow.mjs')].lastError).toMatch(/took longer than/)
162
156
  expect(status[join(base, 'good.mjs')]).toMatchObject({ lastError: null, lastRunAt: expect.any(String) })
163
157
  expect(status[join(base, 'broken.mjs')].lastError).toBe('It could not be loaded: It must export a default function.')
@@ -204,47 +198,3 @@ describe('hooks a plugin gives the harness', () => {
204
198
  await expect(harness.apps.callUnattended('Nope', 'read_record', {})).rejects.toThrow('no connected app called "Nope"')
205
199
  })
206
200
  })
207
-
208
- describe('a plugin an agent proposes', () => {
209
- const proposal = { name: 'Memory to Docs', description: 'Adds every saved memory to the team document.', extends: ['memory'], reason: 'You asked for a copy in Docs.', files: [{ path: 'hooks.mjs', content: "export default function (harness) { harness.on('memory.saved', async () => {}) }\n" }, { path: 'skills/docs/SKILL.md', content: '---\nname: docs\ndescription: How the copy works.\n---\nSteps.\n' }] }
210
-
211
- it('waits in the Inbox, and is saved switched off and untrusted only when the person accepts it', async () => {
212
- const { harness, root, call } = setup()
213
- const tool = pluginProposeTool(harness.env, { agentId: 'a1', agentName: 'Helper', actor: 'Chat' })
214
- expect(await tool.execute(proposal)).toBe('The plugin "memory-to-docs" is waiting in the Inbox. It is saved only if the user accepts it, and it does nothing until they switch it on and trust its code.')
215
- expect(existsSync(join(root, 'plugins'))).toBe(false)
216
- const [item] = listInbox(wikiDir(root))
217
- expect(item).toMatchObject({ kind: 'proposed', title: 'Proposed plugin "Memory to Docs"' })
218
- expect(item.detail).toContain('- hooks.mjs (')
219
- expect(item.detail).toContain('It brings code.')
220
- expect(item.detail).toContain('From Helper (Chat).')
221
-
222
- expect(await call<string>('wiki.inboxAccept', { itemId: item.id })).toBe('Saved the plugin "memory-to-docs", switched off. Its code does not run until you switch it on and trust it.')
223
- expect(readdirSync(join(root, 'plugins'))).toEqual(['memory-to-docs'])
224
- expect(JSON.parse(readFileSync(join(root, 'plugins/memory-to-docs/package.json'), 'utf8'))).toMatchObject({ name: 'memory-to-docs', description: proposal.description, harness: { extends: ['memory'], hooks: './hooks.mjs' } })
225
- const plugin = (await call<Plugin[]>('plugins.list')).find((candidate) => candidate.id === 'package:memory-to-docs')!
226
- expect(plugin).toMatchObject({ extends: ['memory'], trust: 'untrusted', install: { kind: 'folder' }, attachment: { global: false } })
227
- expect(plugin.contributions.map((entry) => [entry.kind, entry.name, entry.state])).toEqual([['skill', 'docs', 'off'], ['code', 'hooks.mjs', 'off']])
228
- await harness.hooks.reload()
229
- expect(Object.keys(harness.hooks.status())).toEqual([])
230
- // Accepting the same proposal again would overwrite a folder, so it is refused.
231
- await tool.execute(proposal)
232
- await expect(call('wiki.inboxAccept', { itemId: listInbox(wikiDir(root))[0].id })).rejects.toThrow('already a plugin folder')
233
- })
234
-
235
- it('refuses what could not be a safe plugin, and says why', () => {
236
- const bad = (change: Partial<typeof proposal>) => () => checkPluginProposal({ ...proposal, ...change })
237
- expect(bad({ name: ' ' })).toThrow('short name')
238
- expect(bad({ description: '' })).toThrow('one line')
239
- expect(bad({ extends: ['approvals'] })).toThrow('"approvals" is none of them')
240
- expect(bad({ files: [] })).toThrow('between 1 and 40 files')
241
- expect(bad({ files: [{ path: '../outside.mjs', content: 'x' }] })).toThrow('not a path inside the plugin')
242
- expect(bad({ files: [{ path: '/etc/hosts', content: 'x' }] })).toThrow('not a path inside the plugin')
243
- expect(bad({ files: [{ path: '.env', content: 'x' }] })).toThrow('cannot be part of a plugin')
244
- expect(bad({ files: [{ path: 'node_modules/x/index.js', content: 'x' }] })).toThrow('cannot be part of a plugin')
245
- expect(bad({ files: [{ path: 'package.json', content: '{}' }] })).toThrow('Leave package.json out')
246
- expect(bad({ files: [{ path: 'a.md', content: 'x' }, { path: 'A.md', content: 'y' }] })).toThrow('listed twice')
247
- expect(bad({ files: [{ path: 'big.md', content: 'x'.repeat(300 * 1024) }] })).toThrow('larger than 256 KiB')
248
- expect(checkPluginProposal({ ...proposal, files: [{ path: './skills/a/SKILL.md', content: 'x' }] })).toMatchObject({ slug: 'memory-to-docs', hooks: null, runsCode: false })
249
- })
250
- })
@@ -7,7 +7,7 @@
7
7
  // and what it throws is recorded and shown on its plugin. It reaches outside only through
8
8
  // `ctx.call`, which the harness limits to connected-app tools a person marked to run without asking.
9
9
  import { chmodSync } from 'node:fs'
10
- import { join, sep } from 'node:path'
10
+ import { join } from 'node:path'
11
11
  import { pathToFileURL } from 'node:url'
12
12
 
13
13
  import type { Bus } from '../core/bus'
@@ -16,12 +16,12 @@ import { onChange, type Change } from '../core/changes'
16
16
  import * as frontmatter from '../core/frontmatter'
17
17
  import { now, readJsonOr, writeJson } from '../core/fs'
18
18
 
19
- export const HOOK_EVENTS = ['memory.saved', 'memory.block.changed', 'wiki.page.saved', 'history.run.ended'] as const
19
+ export const HOOK_EVENTS = ['wiki.page.saved', 'history.run.ended'] as const
20
20
  export type HookEventType = (typeof HOOK_EVENTS)[number]
21
21
 
22
- /** A fact, a memory block, or a page, as it is after the change. */
22
+ /** A Wiki note, as it is after the change. */
23
23
  export interface EntryEvent {
24
- type: 'memory.saved' | 'memory.block.changed' | 'wiki.page.saved'
24
+ type: 'wiki.page.saved'
25
25
  id: string
26
26
  path: string
27
27
  title: string
@@ -58,7 +58,7 @@ export interface HookContext {
58
58
 
59
59
  type Handler = (event: HookEvent, context: HookContext) => unknown
60
60
 
61
- /** What a hooks file is given: `export default function (harness) { harness.on('memory.saved', …) }`. */
61
+ /** What a hooks file is given: `export default function (harness) { harness.on('wiki.page.saved', …) }`. */
62
62
  export interface HookApi {
63
63
  on(event: HookEventType, handler: Handler): void
64
64
  }
@@ -100,8 +100,7 @@ interface Loaded extends HookTarget {
100
100
 
101
101
  /** Which event a written file is, from where it is in the Wiki. */
102
102
  export function entryEvent(change: Change): EntryEvent {
103
- const within = change.path.startsWith(`${change.wiki}${sep}`) ? change.path.slice(change.wiki.length + 1).split(sep) : []
104
- const type = within[0] === 'facts' ? 'memory.saved' : within[0] === 'blocks' ? 'memory.block.changed' : 'wiki.page.saved'
103
+ const type = 'wiki.page.saved' as const
105
104
  const parsed = frontmatter.parse(change.text)
106
105
  const front = (parsed?.front ?? {}) as Record<string, unknown>
107
106
  const title = typeof front.title === 'string' ? front.title : typeof front.label === 'string' ? front.label : change.id