@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.
- package/CHANGELOG.md +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +1 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
package/src/plugins/catalogue.ts
CHANGED
|
@@ -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
|
|
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' :
|
|
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 ?? []) {
|
package/src/plugins/discover.ts
CHANGED
|
@@ -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
|
|
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()
|
|
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
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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'), '
|
|
101
|
-
kit(join(base, 'sheet'), '
|
|
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:
|
|
105
|
-
const sheet = listed.find((plugin) => plugin.id === 'package:
|
|
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: ['
|
|
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
|
|
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
|
|
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: '
|
|
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:
|
|
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('
|
|
148
|
-
writeFile(join(base, 'slow.mjs'), `export default function (harness) { harness.on('
|
|
149
|
-
writeFile(join(base, 'good.mjs'), recorder(out, ['
|
|
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: '
|
|
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: ['
|
|
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
|
-
})
|
package/src/plugins/hooks.ts
CHANGED
|
@@ -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
|
|
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 = ['
|
|
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
|
|
22
|
+
/** A Wiki note, as it is after the change. */
|
|
23
23
|
export interface EntryEvent {
|
|
24
|
-
type: '
|
|
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('
|
|
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
|
|
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
|
package/src/plugins/identity.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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: '
|
|
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
|
|
package/src/plugins/install.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/plugins/inventory.ts
CHANGED
|
@@ -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 '../
|
|
10
|
+
import type { CallerContext } from '../core/caller'
|
|
11
11
|
import { BUILT_IN_DYNAMIC, compile, toolGroups, type Launch } from '../prompt/compile'
|
|
12
|
-
import
|
|
12
|
+
import type { Materialised } from '../tree/materialise'
|
|
13
13
|
import { ASK_USER } from '../tools/askUser'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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))
|
package/src/plugins/plugins.ts
CHANGED
|
@@ -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
|
+
}
|