@xingular/harness 2.1.2 → 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 (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  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/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -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
@@ -1,8 +1,9 @@
1
1
  // Which plugin a found file belongs to, and what it is called there. The list a person sees and
2
2
  // the choice of what loads both use this, so a switch on the page names exactly what stops loading.
3
+ import { readFileSync } from 'node:fs'
3
4
  import { basename, dirname } from 'node:path'
4
5
 
5
- import * as registry from '../skills/registry'
6
+ import * as frontmatter from '../core/frontmatter'
6
7
  import { BUILTIN_PREFIX, type Found } from './discover'
7
8
  import { packageManifest } from './manifest'
8
9
  import type { ContributionKind } from './types'
@@ -24,7 +25,7 @@ export function describeFound(item: Found): { name: string; description: string
24
25
  const file = basename(item.path)
25
26
  if (item.kind === 'extension') return { name: file.startsWith('index.') ? basename(dirname(item.path)) : file, description: null }
26
27
  if (item.kind === 'context' || item.kind === 'theme' || item.kind === 'hook') return { name: file, description: null }
27
- const described = registry.describeFile(item.path)
28
+ const described = describeFile(item.path)
28
29
  return { name: described?.name ?? file, description: described?.description ?? '' }
29
30
  }
30
31
 
@@ -35,3 +36,16 @@ export function foundIds(item: Found): [string, string] {
35
36
  const pluginId = pluginIdOf(item)
36
37
  return [contributionId(pluginId, FOUND_KINDS[item.kind], describeFound(item).name), pluginId]
37
38
  }
39
+
40
+ /** A Markdown file's `name` and `description` frontmatter; a `SKILL.md` is named after its folder. */
41
+ export function describeFile(path: string): { name: string; description: string } | null {
42
+ let text: string
43
+ try {
44
+ text = readFileSync(path, 'utf8')
45
+ } catch {
46
+ return null
47
+ }
48
+ const front = frontmatter.parse(text)?.front ?? {}
49
+ const fallback = basename(path) === 'SKILL.md' ? basename(dirname(path)) : basename(path).replace(/\.md$/, '')
50
+ return { name: typeof front.name === 'string' && front.name.trim() ? front.name.trim() : fallback, description: typeof front.description === 'string' ? front.description.trim() : '' }
51
+ }
@@ -83,7 +83,7 @@ describe('installing a package', () => {
83
83
  it('refuses a folder that is not there, and a plugin that was not installed', async () => {
84
84
  const { base, call } = setup()
85
85
  await expect(call('plugins.install', { source: join(base, 'missing') })).rejects.toThrow('does not exist')
86
- await expect(call('plugins.remove', { pluginId: 'memory' })).rejects.toThrow('was not installed as a package')
86
+ await expect(call('plugins.remove', { pluginId: 'wiki' })).rejects.toThrow('was not installed as a package')
87
87
  await expect(call('plugins.update', { pluginId: 'nope' })).rejects.toThrow()
88
88
  })
89
89
 
@@ -10,6 +10,7 @@ import { ensureSharedDir } from '../accounts/env'
10
10
  import { invalid, notFound } from '../core/errors'
11
11
  import { now } from '../core/fs'
12
12
  import type { Harness } from '../harness'
13
+ import { loadPi } from '../pi/runtime'
13
14
  import { ensureAdopted, list, type PluginScope } from './api'
14
15
  import * as attachments from './attachments'
15
16
  import { withoutInstallScripts } from './discover'
@@ -26,7 +27,7 @@ export function normalizeSource(input: string): string {
26
27
  }
27
28
 
28
29
  async function manager(harness: Harness) {
29
- const pi = await import('@earendil-works/pi-coding-agent')
30
+ const pi = await loadPi()
30
31
  // The shared folder is where `settings.json` really is; an account's own is a link to it.
31
32
  const agentDir = ensureSharedDir(harness.accounts.dir)
32
33
  const settings = pi.SettingsManager.create(harness.stateDir, agentDir)
@@ -7,13 +7,12 @@ import type { HarnessEnv } from '../core/env'
7
7
  import { charCount } from '../core/text'
8
8
  import { allowed } from '../mcp/connections'
9
9
  import type { Connection } from '../mcp/types'
10
- import type { CallerContext } from '../memory/caller'
10
+ import type { CallerContext } from '../core/caller'
11
11
  import { BUILT_IN_DYNAMIC, compile, toolGroups, type Launch } from '../prompt/compile'
12
- import * as registry from '../skills/registry'
12
+ import type { Materialised } from '../tree/materialise'
13
13
  import { ASK_USER } from '../tools/askUser'
14
- import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
15
- import { WIKI_TOOL_NAMES } from '../tools/definitions'
16
- import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
14
+ import { DEFAULT_TOOL_NAMES } from '../tools/definitions'
15
+ import { HARNESS_TREE_PLUGIN } from './builtin'
17
16
  import type { Contribution, Inventory, InventoryItem, Plugin, PluginDescriptor } from './types'
18
17
 
19
18
  export interface InventoryInput {
@@ -28,6 +27,8 @@ export interface InventoryInput {
28
27
  plugins: readonly Plugin[]
29
28
  descriptors: readonly PluginDescriptor[]
30
29
  saved: readonly Connection[]
30
+ /** The harness tree the caller runs, when it has one. */
31
+ tree?: Materialised | null
31
32
  }
32
33
 
33
34
  const size = (path: string | null): number | null => {
@@ -47,10 +48,8 @@ export function inventory(input: InventoryInput): Inventory {
47
48
  items.push({ pluginId, contributionId: null, kind, name, delivery, chars, cut: false, ...rest })
48
49
  }
49
50
 
50
- const pack = compile(env, context, launch)
51
+ const pack = compile(env, context, launch, input.tree ?? null)
51
52
  const dynamic = new Set([...BUILT_IN_DYNAMIC.map((entry) => entry.id), ...(launch.sections ?? []).filter((section) => section.dynamic).map((section) => section.id)])
52
- const active = registry.enabledFor(env.root, context.projectId ?? null, context.agentId ?? null)
53
- const owner = (entry: registry.RegistryItem): string => (entry.builtin ? INCLUDED_PLUGIN.id : YOURS_PLUGIN.id)
54
53
  let systemChars = 0
55
54
  let contextChars = 0
56
55
 
@@ -68,26 +67,28 @@ export function inventory(input: InventoryInput): Inventory {
68
67
  }
69
68
  continue
70
69
  }
71
- if (section.id === 'rules') {
72
- for (const rule of active.filter((entry) => entry.kind === 'rules')) {
73
- item(owner(rule), 'instruction', rule.name, delivery, charCount(`### ${rule.name} (${rule.id})\n\n${registry.itemBody(rule).trim()}`), { contributionId: `${owner(rule)}/${rule.id}`, cut: section.cut })
74
- }
75
- continue
76
- }
77
70
  const plugin = descriptors.find((descriptor) => descriptor.sections?.some((declared) => declared.id === section.id))
78
71
  if (!plugin) unattributed.push({ kind: 'section', id: section.id })
79
72
  const name = plugin?.sections?.find((declared) => declared.id === section.id)?.name ?? section.title
80
73
  item(plugin?.id ?? '', 'instruction', name, delivery, section.size, { contributionId: plugin ? `${plugin.id}/instruction/${name}` : null, cut: section.cut })
81
74
  }
82
75
 
83
- // A skill's or a prompt's own text is loaded only when it is used.
84
- for (const entry of active.filter((candidate) => candidate.kind === 'skills' || candidate.kind === 'prompts')) {
85
- item(owner(entry), entry.kind === 'skills' ? 'skill' : 'command', entry.name, 'on-demand', size(entry.path), { contributionId: `${owner(entry)}/${entry.id}` })
76
+ // The harness tree's skills are loaded only when used; its plugins run in the tool loop.
77
+ const tree = input.tree ?? null
78
+ if (tree) {
79
+ for (const rel of Object.keys(tree.tree).filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)).sort()) {
80
+ const name = rel.split('/')[1]!
81
+ item(HARNESS_TREE_PLUGIN.id, 'skill', name, 'on-demand', charCount(tree.tree[rel]!), { contributionId: `${HARNESS_TREE_PLUGIN.id}/skill/${name}` })
82
+ }
83
+ for (const path of tree.extensionPaths) {
84
+ const name = path.split('/').pop()!
85
+ item(HARNESS_TREE_PLUGIN.id, 'code', name, 'code', size(path), { contributionId: `${HARNESS_TREE_PLUGIN.id}/code/${name}` })
86
+ }
86
87
  }
87
88
 
88
89
  const names = [...input.tools]
89
90
  const extensionToolNames = new Set(input.plugins.filter((plugin) => ['project', 'package', 'shared', 'home'].includes(plugin.source)).flatMap((plugin) => plugin.contributions.filter((entry) => entry.kind === 'tool' && entry.state === 'on').map((entry) => entry.name)))
90
- for (const name of [ASK_USER, PLUGIN_PROPOSE, ...WIKI_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
91
+ for (const name of [ASK_USER, ...DEFAULT_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
91
92
  for (const name of names) {
92
93
  if (extensionToolNames.has(name)) continue
93
94
  const plugin = descriptors.find((descriptor) => descriptor.tools?.some((tool) => tool.name === name))
@@ -1,11 +1,10 @@
1
1
  // The harness decides what a Pi session loads. Pi's own discovery is switched off for sessions
2
2
  // and terminals; they receive the paths chosen here, so nothing reaches a model unlisted.
3
- import { chmodSync, readdirSync } from 'node:fs'
3
+ import { chmodSync, lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
 
6
6
  import type { Bus } from '../core/bus'
7
7
  import { now, readJsonOr, writeJson } from '../core/fs'
8
- import { removeLinkedSkills } from '../skills/registry'
9
8
  import { attached, type Attachments, type AttachScope } from './attachments'
10
9
  import { piResolver, type Found, type ResolveInput, type Resolver } from './discover'
11
10
  import { foundIds, pluginIdOf } from './identity'
@@ -187,3 +186,40 @@ export class Plugins {
187
186
  this.options.bus.emit({ type: 'plugins.changed' })
188
187
  }
189
188
  }
189
+
190
+ // Prefix of the entries an earlier version of the harness linked into a Pi agent folder's `skills/`.
191
+ const LINKED_SKILL_PREFIX = 'agentx-'
192
+
193
+ function isLinkedSkill(path: string): boolean {
194
+ let meta
195
+ try {
196
+ meta = lstatSync(path)
197
+ } catch {
198
+ return false
199
+ }
200
+ if (meta.isSymbolicLink()) return true
201
+ if (!meta.isDirectory()) return false
202
+ // A single-file skill was linked as `agentx-<name>/SKILL.md` inside a folder the harness made.
203
+ try {
204
+ const entries = readdirSync(path)
205
+ return entries.length === 1 && entries[0] === 'SKILL.md' && lstatSync(join(path, 'SKILL.md')).isSymbolicLink()
206
+ } catch {
207
+ return false
208
+ }
209
+ }
210
+
211
+ /** Removes the skill links an earlier version of the harness kept in a Pi agent folder's `skills/`. */
212
+ export function removeLinkedSkills(skillsDir: string): void {
213
+ let names: string[]
214
+ try {
215
+ names = readdirSync(skillsDir)
216
+ } catch {
217
+ return
218
+ }
219
+ for (const name of names) {
220
+ const path = join(skillsDir, name)
221
+ if (!name.startsWith(LINKED_SKILL_PREFIX) || !isLinkedSkill(path)) continue
222
+ if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
223
+ else rmSync(path, { recursive: true, force: true })
224
+ }
225
+ }