@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.
- package/CHANGELOG.md +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- 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/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- 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 +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- 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/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- 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 +3 -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/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- 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/api.ts
CHANGED
|
@@ -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 '../
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
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 =
|
|
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)
|
package/src/plugins/builtin.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
export const
|
|
25
|
-
id: '
|
|
26
|
-
title: '
|
|
27
|
-
description: '
|
|
28
|
-
|
|
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: '
|
|
31
|
-
{ id: '
|
|
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:
|
|
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: '
|
|
55
|
-
required: '
|
|
56
|
-
|
|
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: '
|
|
60
|
-
{ id: 'references', name: 'Selected references', description: '
|
|
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: ['
|
|
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 {
|
|
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', '
|
|
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(
|
|
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
|
|
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('
|
|
143
|
-
|
|
144
|
-
expect(of('
|
|
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: '
|
|
234
|
-
await expect(call('plugins.detach', { id: '
|
|
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: '
|
|
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 () => {
|
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
|