@xingular/harness 0.1.6 → 2.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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createHarness, type Harness } from '../harness'
|
|
7
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
8
|
+
import type { PiSessionLike } from '../sessions/runner'
|
|
9
|
+
import { registryTools } from '../tools/registryWrite'
|
|
10
|
+
import type { ToolSession } from '../tools/types'
|
|
11
|
+
import * as blocks from './blocks'
|
|
12
|
+
import { memoryEdit, propose } from './api'
|
|
13
|
+
import { enqueueCuration } from './curation'
|
|
14
|
+
import { findMemory, saveAsUser } from './facts'
|
|
15
|
+
import * as registry from '../skills/registry'
|
|
16
|
+
import * as attachments from '../plugins/attachments'
|
|
17
|
+
import { listInbox } from './operations'
|
|
18
|
+
import { wikiDir } from './store'
|
|
19
|
+
|
|
20
|
+
const opened: Harness[] = []
|
|
21
|
+
afterEach(async () => { for (const harness of opened.splice(0)) await harness.close() })
|
|
22
|
+
const self = { agentId: 'nora', agentName: 'Nora', actor: 'Chat', vaultAccess: 'self' as const, runId: 'origin' }
|
|
23
|
+
const curator = { agentId: 'curator', agentName: 'Curator', actor: 'Chat', vaultAccess: 'curator' as const, runId: 'review' }
|
|
24
|
+
|
|
25
|
+
function setup(): Harness {
|
|
26
|
+
let emit: (event: unknown) => void = () => undefined
|
|
27
|
+
const session: PiSessionLike = { sessionId: 'test-session', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
28
|
+
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, secrets: memorySecretStore(), sessionFactory: async () => session, maintainerFactory: async () => { throw new Error('Hidden maintenance must not run') } })
|
|
29
|
+
opened.push(harness)
|
|
30
|
+
harness.accounts.create('Main', 'apiKey')
|
|
31
|
+
blocks.ensureAgent(wikiDir(harness.env.root), 'nora')
|
|
32
|
+
return harness
|
|
33
|
+
}
|
|
34
|
+
async function origin(harness: Harness): Promise<void> {
|
|
35
|
+
await harness.sessions.run({ run: { id: 'origin', sessionId: 'nora' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Prefer pnpm for all builds.', caller: self, tools: { allow: [] } })
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('normal callers directly write only their own persona and notes; standalone callers retain their prior scope', () => {
|
|
40
|
+
const h = setup()
|
|
41
|
+
expect(memoryEdit(h.env, self, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toContain('Updated')
|
|
42
|
+
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'user', text: 'A shared preference.' })).toThrow('outside your writable memory')
|
|
43
|
+
blocks.ensureAgent(wikiDir(h.env.root), 'other')
|
|
44
|
+
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'blocks/agents/other/notes', text: 'Not mine.' })).toThrow()
|
|
45
|
+
expect(memoryEdit(h.env, { ...self, vaultAccess: undefined }, 'memory.append', { block: 'user', text: 'Standalone scope.' })).toContain('Updated')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a shared fact is proposed without writing, then validated against its original run and applied once', async () => {
|
|
49
|
+
const h = setup(); await origin(h)
|
|
50
|
+
const submitted = propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
51
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
52
|
+
expect(submitted).toContain(job.id)
|
|
53
|
+
expect(() => findMemory(wikiDir(h.env.root), 'build-tool')).toThrow()
|
|
54
|
+
expect(() => h.maintenance.readJob(self, job.id)).toThrow('curator capability')
|
|
55
|
+
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Preserve the stated build preference.')
|
|
56
|
+
expect(receipt.status).toBe('completed')
|
|
57
|
+
expect(findMemory(wikiDir(h.env.root), 'build-tool').front.sourceIds).toContain('run:origin')
|
|
58
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
|
|
62
|
+
const h = setup(); await origin(h)
|
|
63
|
+
propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
64
|
+
propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
65
|
+
const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
|
|
66
|
+
const batchCurator = { ...curator, maintenanceJobIds: ids }
|
|
67
|
+
expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
|
|
68
|
+
|
|
69
|
+
const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
|
|
70
|
+
pages: [],
|
|
71
|
+
memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
72
|
+
blocks: [],
|
|
73
|
+
flags: []
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(applied.operationId).toMatch(/^batch-/)
|
|
77
|
+
expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
|
|
78
|
+
expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
|
|
79
|
+
expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
|
|
80
|
+
expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a batch parks updates whose captured Wiki target changed before review', async () => {
|
|
84
|
+
const h = setup(); await origin(h)
|
|
85
|
+
const wiki = wikiDir(h.env.root)
|
|
86
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
|
|
87
|
+
propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
88
|
+
const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
|
|
89
|
+
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
|
|
90
|
+
|
|
91
|
+
const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
|
|
92
|
+
pages: [],
|
|
93
|
+
memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
94
|
+
blocks: [],
|
|
95
|
+
flags: []
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(result.jobs[0].status).toBe('needs_user')
|
|
99
|
+
expect(result.operationId).toBeNull()
|
|
100
|
+
expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
|
|
104
|
+
const h = setup(); await origin(h)
|
|
105
|
+
propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
|
|
106
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
107
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review the claimed evidence').status).toBe('needs_user')
|
|
108
|
+
expect(() => findMemory(wikiDir(h.env.root), 'invented')).toThrow()
|
|
109
|
+
const block = blocks.find(wikiDir(h.env.root), 'blocks/agents/nora/notes')
|
|
110
|
+
blocks.saveAsUser(wikiDir(h.env.root), { id: block.id, body: '', description: block.front.description, limit: 2000, readOnly: true })
|
|
111
|
+
expect(() => propose(h.env, self, { kind: 'block', id: block.id, title: 'Notes', body: 'Changed.' })).toThrow('user-only')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('text resources remain inactive until curated; undo restores attachments and preserves subsequent user edits', async () => {
|
|
115
|
+
const h = setup()
|
|
116
|
+
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'registry_write')!
|
|
117
|
+
await tool.execute({ kind: 'skill', name: 'release-notes', description: 'Write release notes', body: 'Summarize verified changes.', reason: 'Repeated release work.' })
|
|
118
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'registry')!
|
|
119
|
+
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
120
|
+
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Reuse the repeated release procedure.')
|
|
121
|
+
expect(attachments.attached(attachments.load(h.env.root), ['skills/release-notes'], { agentId: 'nora' }, false)).toBe(true)
|
|
122
|
+
h.maintenance.undoOperation(receipt.receipt!.operationId!)
|
|
123
|
+
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
124
|
+
const next = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'other-notes', description: 'Notes', body: 'Write verified notes.', reason: 'Workflow improvement.' } })
|
|
125
|
+
const applied = h.maintenance.applyJob(curator, next.id, undefined, 'Apply text-only improvement.')
|
|
126
|
+
registry.writeItem(h.env.root, 'skill', 'other-notes', 'Notes', 'The user changed this.', 'User edit')
|
|
127
|
+
expect(() => h.maintenance.undoOperation(applied.receipt!.operationId!)).toThrow('changed after')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('changed target versions are escalated instead of overwritten', () => {
|
|
131
|
+
const h = setup()
|
|
132
|
+
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'Original notes.', 'Initial')
|
|
133
|
+
const job = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'notes', description: 'Notes', body: 'Proposed notes.', reason: 'Improve' } })
|
|
134
|
+
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'User edits win.', 'User edit')
|
|
135
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review update').status).toBe('needs_user')
|
|
136
|
+
expect(registry.list(h.env.root).find(item => item.id === 'skills/notes')).toBeDefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('executable plugins are submitted without saving, activation or trust', async () => {
|
|
140
|
+
const h = setup()
|
|
141
|
+
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'plugin_write')!
|
|
142
|
+
await tool.execute({ name: 'code-change', description: 'A code plugin', files: [{ path: 'extensions/index.ts', content: 'export default () => {}' }] })
|
|
143
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'plugin')!
|
|
144
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Code needs trust').status).toBe('needs_user')
|
|
145
|
+
expect(existsSync(join(h.env.root, 'plugins/code-change'))).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('external jobs persist and repeated schedule occurrences are distinct; no hidden provider runs', async () => {
|
|
149
|
+
const h = setup()
|
|
150
|
+
const source = h.maintenance.ingest('source.txt', Buffer.from('Prefer pnpm for all builds.'))
|
|
151
|
+
expect(h.maintenance.jobs().some(job => job.input.kind === 'ingest' && job.input.sourceId === source.id)).toBe(true)
|
|
152
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
153
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
154
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-two' })
|
|
155
|
+
expect(h.maintenance.jobs().filter(job => job.input.kind === 'review')).toHaveLength(2)
|
|
156
|
+
await h.maintenance.settled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a curator run never enqueues another completed-run maintenance job', async () => {
|
|
160
|
+
const h = setup()
|
|
161
|
+
await h.sessions.run({ run: { id: 'curator-run', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Review maintenance.', caller: curator, tools: { allow: [] } })
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
163
|
+
expect(h.maintenance.jobs().some(job => job.input.kind === 'run' && job.input.runId === 'curator-run')).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('curator prose cannot become its own evidence; original source runs can support a review', async () => {
|
|
167
|
+
const h = setup(); await origin(h)
|
|
168
|
+
await h.sessions.run({ run: { id: 'review', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Prefer pnpm for all builds.', caller: curator, tools: { allow: [] } })
|
|
169
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
170
|
+
propose(h.env, curator, { kind: 'fact', title: 'Curator claim', body: 'A claim from the review.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
171
|
+
const own = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
172
|
+
expect(h.maintenance.applyJob(curator, own.id, undefined, 'Check the original evidence.').status).toBe('needs_user')
|
|
173
|
+
expect(() => findMemory(wikiDir(h.env.root), 'curator-claim')).toThrow()
|
|
174
|
+
const review = enqueueCuration(h.env.root, { kind: 'review', requestId: 'grounded-review' })
|
|
175
|
+
const applied = h.maintenance.applyJob(curator, review.id, { memories: [{ title: 'Original preference', kind: 'preference', body: 'Prefer pnpm.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }] }] }, 'Retain the original recorded preference.')
|
|
176
|
+
expect(applied.status).toBe('completed')
|
|
177
|
+
expect(findMemory(wikiDir(h.env.root), 'original-preference').front.sourceIds).toContain('run:origin')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
test('a failed evidence preparation remains attributed and can be rejected without rereading invalid input', () => {
|
|
182
|
+
const h = setup()
|
|
183
|
+
const job = enqueueCuration(h.env.root, { kind: 'consolidate', blockIds: ['missing-block'] })
|
|
184
|
+
expect(() => h.maintenance.readJob(curator, job.id)).toThrow('No writable blocks')
|
|
185
|
+
expect(h.maintenance.jobs().find(item => item.id === job.id)).toMatchObject({ status: 'failed', runId: 'review' })
|
|
186
|
+
expect(h.maintenance.decide(curator, job.id, 'reject', 'The target no longer exists.').status).toBe('rejected')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('a propose-only caller writes no memory directly, not even its own persona and notes', () => {
|
|
190
|
+
const h = setup()
|
|
191
|
+
const proposer = { ...self, vaultAccess: 'propose' as const }
|
|
192
|
+
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toThrow('Memory changes are proposals')
|
|
193
|
+
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'persona', text: 'I am terse.' })).toThrow('Memory changes are proposals')
|
|
194
|
+
expect(propose(h.env, proposer, { kind: 'block', id: 'blocks/agents/nora/notes', title: 'Notes', body: 'Working on release notes.' })).toContain('Submitted proposal')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('with the run trigger off, a settled run queues no consolidation of its own', async () => {
|
|
198
|
+
let emit: (event: unknown) => void = () => undefined
|
|
199
|
+
const session: PiSessionLike = { sessionId: 'quiet', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
200
|
+
const h = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, maintenanceRunTrigger: false, secrets: memorySecretStore(), sessionFactory: async () => session })
|
|
201
|
+
opened.push(h)
|
|
202
|
+
h.accounts.create('Main', 'apiKey')
|
|
203
|
+
await h.sessions.run({ run: { id: 'quiet-run', sessionId: 'nora' }, cwd: h.env.root, sessionDir: join(h.env.root, 'history'), text: 'Prefer pnpm.', caller: self, tools: { allow: [] } })
|
|
204
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
205
|
+
expect(h.maintenance.jobs().filter(job => job.input.kind === 'run')).toEqual([])
|
|
206
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { invalid, notFound } from '../core/errors'
|
|
6
|
+
import { newId, now, readJson, safeId, writeJson } from '../core/fs'
|
|
7
|
+
import type { CallerContext } from './caller'
|
|
8
|
+
import type { Job } from './maintenance'
|
|
9
|
+
import type { MaintenanceRequest } from './maintainer'
|
|
10
|
+
import { captureHashes, parseProposalSet, type ProposalSet, type RegistryProposal, type Evidence, type Trigger } from './proposals'
|
|
11
|
+
import { looksSecret } from './secrets'
|
|
12
|
+
import * as store from './store'
|
|
13
|
+
|
|
14
|
+
export type CurationInput = Job
|
|
15
|
+
| { kind: 'proposal'; namespace: string; proposals: ProposalSet; caller: CallerContext }
|
|
16
|
+
| { kind: 'registry'; proposal: RegistryProposal; caller: CallerContext }
|
|
17
|
+
| { kind: 'plugin'; proposal: unknown; caller: CallerContext }
|
|
18
|
+
export interface CurationJob {
|
|
19
|
+
id: string
|
|
20
|
+
key: string
|
|
21
|
+
input: CurationInput
|
|
22
|
+
status: 'queued' | 'running' | 'completed' | 'failed' | 'rejected' | 'needs_user'
|
|
23
|
+
createdAt: string
|
|
24
|
+
updatedAt: string
|
|
25
|
+
runId: string | null
|
|
26
|
+
reason: string
|
|
27
|
+
hashes: [string, string][]
|
|
28
|
+
prepared?: { request: MaintenanceRequest; evidence: Evidence; namespace: string; trigger: Trigger }
|
|
29
|
+
receipt: { operationId: string | null; notes: string[]; parked: number; flagged: number } | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const directory = (root: string): string => join(store.wikiDir(root), 'maintenance-jobs')
|
|
33
|
+
export function listCuration(root: string): CurationJob[] {
|
|
34
|
+
let names: string[] = []
|
|
35
|
+
try { names = readdirSync(directory(root)) } catch { return [] }
|
|
36
|
+
return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
|
|
37
|
+
const job = readJson<CurationJob>(join(directory(root), name))
|
|
38
|
+
return job && typeof job.id === 'string' && job.input && typeof job.input.kind === 'string' ? [job] : []
|
|
39
|
+
}).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
40
|
+
}
|
|
41
|
+
export function curationJob(root: string, id: string): CurationJob {
|
|
42
|
+
const job = readJson<CurationJob>(join(directory(root), `${safeId(id)}.json`))
|
|
43
|
+
if (!job) throw notFound('Maintenance job')
|
|
44
|
+
return job
|
|
45
|
+
}
|
|
46
|
+
export function saveCuration(root: string, job: CurationJob): CurationJob {
|
|
47
|
+
writeJson(join(directory(root), `${safeId(job.id)}.json`), { ...job, updatedAt: now() })
|
|
48
|
+
return job
|
|
49
|
+
}
|
|
50
|
+
export function enqueueCuration(root: string, input: CurationInput): CurationJob {
|
|
51
|
+
if (input.kind === 'proposal') input = { ...input, proposals: parseProposalSet(input.proposals) }
|
|
52
|
+
if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw invalid('The proposal is larger than 256 KiB.')
|
|
53
|
+
if (looksSecret(JSON.stringify(input))) throw invalid('The proposal contains text that looks like a credential.')
|
|
54
|
+
const captured = captureHashes(store.wikiDir(root))
|
|
55
|
+
const revisions = input.kind === 'consolidate' ? input.blockIds.map(id => [id, captured.get(id)]) : undefined
|
|
56
|
+
const key = createHash('sha256').update(JSON.stringify({ input, revisions })).digest('hex')
|
|
57
|
+
const existing = listCuration(root).find((job) => job.key === key)
|
|
58
|
+
if (existing) return existing
|
|
59
|
+
const timestamp = now()
|
|
60
|
+
return saveCuration(root, { id: newId(), key, input, status: 'queued', createdAt: timestamp, updatedAt: timestamp, runId: null, reason: '', hashes: [...captured], receipt: null })
|
|
61
|
+
}
|
|
62
|
+
export function requireCurator(context: CallerContext): void {
|
|
63
|
+
if (context.vaultAccess !== 'curator' || !context.runId) throw invalid('This operation needs a curator capability issued by the host for this run.')
|
|
64
|
+
}
|
package/src/memory/dispatch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { launchText, load, render } from '../prompt/compile'
|
|
|
7
7
|
import * as api from './api'
|
|
8
8
|
import type { CallerContext } from './caller'
|
|
9
9
|
import * as recall from './recall'
|
|
10
|
+
import * as trajectory from './trajectory'
|
|
10
11
|
import { reindexQuietly } from './wikiIndex'
|
|
11
12
|
|
|
12
13
|
export type DispatchEnv = HarnessEnv & { bus?: Bus }
|
|
@@ -22,6 +23,10 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
|
|
|
22
23
|
return recall.searchTool(env, context, params)
|
|
23
24
|
case 'recall.read':
|
|
24
25
|
return recall.readTool(env, context, params)
|
|
26
|
+
case 'trajectory.search':
|
|
27
|
+
return trajectory.searchTool(env, context, params)
|
|
28
|
+
case 'trajectory.read':
|
|
29
|
+
return trajectory.readTool(env, context, params)
|
|
25
30
|
case 'memory.append':
|
|
26
31
|
case 'memory.replace':
|
|
27
32
|
case 'memory.rethink': {
|
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
5
5
|
|
|
6
6
|
import { memorySecretStore } from '../accounts/secrets'
|
|
7
7
|
import { writeJson } from '../core/fs'
|
|
8
|
-
import type
|
|
8
|
+
import { setLogSink, type Notice } from '../core/bus'
|
|
9
9
|
import { createHarness, type Harness } from '../harness'
|
|
10
10
|
import type { PiSessionLike } from '../sessions/runner'
|
|
11
11
|
import * as blocks from './blocks'
|
|
@@ -132,6 +132,22 @@ describe('the memory lifecycle', () => {
|
|
|
132
132
|
expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('a code run outside any project is not maintained, and it leaves no log line', async () => {
|
|
136
|
+
const lines: string[] = []
|
|
137
|
+
setLogSink((text) => void lines.push(text))
|
|
138
|
+
try {
|
|
139
|
+
let jobs = 0
|
|
140
|
+
const { harness } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
|
|
141
|
+
await harness.sessions.run({ run: { id: 'code-1', sessionId: 'nora', mode: 'code' },cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Code' }, tools: { allow: [] } })
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
143
|
+
await harness.maintenance.settled()
|
|
144
|
+
expect(jobs).toBe(0)
|
|
145
|
+
expect(lines.filter((line) => line.includes('maintenance'))).toEqual([])
|
|
146
|
+
} finally {
|
|
147
|
+
setLogSink(null)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
135
151
|
it('an imported source is read once and its proposals are checked against its text', async () => {
|
|
136
152
|
const { harness, wiki } = setup(async ({ kind, collect }) => ({
|
|
137
153
|
prompt: async () => {
|