@xingular/harness 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -1,206 +0,0 @@
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
- })
@@ -1,64 +0,0 @@
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
- }
@@ -1,64 +0,0 @@
1
- // One entry point for every memory, recall, and Wiki call a session makes, whichever way it
2
- // reaches the harness (a Pi tool, the `xagent-wiki` command, the bundled MCP server).
3
- import type { Bus } from '../core/bus'
4
- import type { HarnessEnv } from '../core/env'
5
- import { HarnessError } from '../core/errors'
6
- import { launchText, load, render } from '../prompt/compile'
7
- import * as api from './api'
8
- import type { CallerContext } from './caller'
9
- import * as recall from './recall'
10
- import * as trajectory from './trajectory'
11
- import { reindexQuietly } from './wikiIndex'
12
-
13
- export type DispatchEnv = HarnessEnv & { bus?: Bus }
14
-
15
- /** Runs one call and returns its reply as text for a model. */
16
- export function dispatch(env: DispatchEnv, context: CallerContext, call: string, params: api.Params, contributions?: readonly string[]): string {
17
- switch (call) {
18
- case 'wiki.entrypoint': {
19
- const recorded = context.runId ? safely(() => load(env.root, context.runId!)) : null
20
- return recorded ? render(env, recorded) : launchText(env, context)
21
- }
22
- case 'recall.search':
23
- return recall.searchTool(env, context, params)
24
- case 'recall.read':
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)
30
- case 'memory.append':
31
- case 'memory.replace':
32
- case 'memory.rethink': {
33
- const text = api.memoryEdit(env, context, call, params)
34
- env.bus?.emit({ type: 'wiki.changed' })
35
- return text
36
- }
37
- case 'wiki.search':
38
- return api.search(env, context, params)
39
- case 'wiki.read':
40
- return api.read(env, context, params)
41
- case 'wiki.list':
42
- return api.list(env, params)
43
- case 'wiki.skill':
44
- return api.skill(env, context, params, contributions)
45
- case 'wiki.memory':
46
- return api.memories(env, context, params)
47
- case 'wiki.propose': {
48
- const text = api.propose(env, context, params)
49
- reindexQuietly(env.root)
50
- env.bus?.emit({ type: 'wiki.changed' })
51
- return text
52
- }
53
- default:
54
- throw new HarnessError('unknown_method', call.startsWith('memory.') ? `Unknown memory call ${call}.` : `Unknown Wiki call ${call}.`)
55
- }
56
- }
57
-
58
- function safely<T>(read: () => T): T | null {
59
- try {
60
- return read()
61
- } catch {
62
- return null
63
- }
64
- }
@@ -1,41 +0,0 @@
1
- import { expect, test } from 'vitest'
2
-
3
- import { dataRoot } from '../../tests/helpers'
4
- import { now } from '../core/fs'
5
- import { appliesTo, isStale, loadMemories, parse, response, saveAsUser, type MemoryFront, type MemoryInput } from './facts'
6
- import { readManifest, wikiDir } from './store'
7
-
8
- test('reads legacy procedures as facts without changing their content', () => {
9
- const parsed = parse('---\nid: legacy\ntitle: Legacy procedure\nkind: procedure\n---\n\nFollow these steps.\n', '/tmp/legacy.md')!
10
- expect(parsed.front.kind).toBe('fact')
11
- expect(parsed.body).toBe('Follow these steps.\n')
12
- const value = response(dataRoot(), parsed)
13
- expect(value.kind).toBe('fact')
14
- expect(value.body).toBe('Follow these steps.\n')
15
- })
16
-
17
- const input = (title: string, kind: string): MemoryInput => ({ title, kind, namespace: '/', body: 'Use pnpm, not npm.', confidence: 'high', status: 'active' })
18
-
19
- test('saves, validates and revisions memories', () => {
20
- const wiki = wikiDir(dataRoot())
21
- const saved = saveAsUser(wiki, input('Package manager', 'preference'))
22
- expect(saved.id).toBe('package-manager')
23
- expect(saved.sourceIds).toEqual(['user'])
24
- saveAsUser(wiki, { ...input('Package manager', 'preference'), id: 'package-manager', body: 'Use pnpm everywhere.' })
25
- expect(readManifest(wiki, 'package-manager')).toHaveLength(3)
26
- expect(() => saveAsUser(wiki, input('Bad', 'opinion'))).toThrow()
27
- expect(() => saveAsUser(wiki, input('Steps', 'procedure'))).toThrow()
28
- expect(() => saveAsUser(wiki, { ...input('Scoped', 'fact'), namespace: '/elsewhere' })).toThrow()
29
- expect(loadMemories(wiki)).toHaveLength(1)
30
- })
31
-
32
- test('applies by namespace and status', () => {
33
- const front: MemoryFront = { id: 'm', title: 'M', kind: 'fact', namespace: '/projects/a', sourceIds: [], confidence: 'high', status: 'active', supersedes: null, links: [], createdAt: '', updatedAt: '', observedAt: '', volatile: false }
34
- expect(isStale(front)).toBe(false)
35
- expect(isStale({ ...front, volatile: true, observedAt: '2020-01-01T00:00:00Z' })).toBe(true)
36
- expect(isStale({ ...front, volatile: true, observedAt: now() })).toBe(false)
37
- expect(appliesTo(front, '/projects/a')).toBe(true)
38
- expect(appliesTo(front, '/projects/b')).toBe(false)
39
- expect(appliesTo({ ...front, namespace: '/' }, null)).toBe(true)
40
- expect(appliesTo({ ...front, namespace: '/', status: 'archived' }, null)).toBe(false)
41
- })
@@ -1,248 +0,0 @@
1
- // Facts: short, typed things to carry between runs (a preference, a decision). Each is a file,
2
- // `wiki/facts/<id>.md`, with frontmatter and a few lines of body, has sources and revisions like
3
- // a page, and changes only through the user or a validated proposal.
4
- import { readFileSync } from 'node:fs'
5
- import { join } from 'node:path'
6
-
7
- import { noteChange } from '../core/changes'
8
- import { HarnessError, invalid, notFound } from '../core/errors'
9
- import * as frontmatter from '../core/frontmatter'
10
- import { atomicWrite, now, safeId } from '../core/fs'
11
- import { comparePaths } from '../core/text'
12
- import { looksSecret } from './secrets'
13
- import * as store from './store'
14
- import { LIBRARY_NAMESPACE, ROOT_NAMESPACE, type WikiLink } from './store'
15
-
16
- /** A procedure is a skill, not a fact. */
17
- export const MEMORY_KINDS = ['preference', 'fact', 'decision', 'feedback'] as const
18
- /** A volatile fact older than this loses rank in search and is reported by Health. */
19
- export const STALE_DAYS = 30
20
- export const CONFIDENCE = ['high', 'medium', 'low'] as const
21
- export const STATUSES = ['active', 'superseded', 'archived'] as const
22
- /** A fact is short, not a document. Measured in bytes. */
23
- export const MAX_BODY = 2000
24
-
25
- export interface MemoryFront {
26
- id: string
27
- title: string
28
- kind: string
29
- /** `/` for everything, or a project namespace such as `/projects/xagent`. */
30
- namespace: string
31
- sourceIds: string[]
32
- confidence: string
33
- status: string
34
- supersedes: string | null
35
- links: WikiLink[]
36
- createdAt: string
37
- updatedAt: string
38
- /** When the fact was observed to be true. */
39
- observedAt: string
40
- /** Current state that will change. Never put in a compiled prompt; loses rank as it ages. */
41
- volatile: boolean
42
- }
43
-
44
- export interface ParsedMemory {
45
- front: MemoryFront
46
- body: string
47
- path: string
48
- }
49
-
50
- /** Days since the fact was observed (or last updated), when that can be read. */
51
- export function ageDays(front: MemoryFront): number | null {
52
- const at = Date.parse(front.observedAt || front.updatedAt)
53
- return Number.isNaN(at) ? null : Math.trunc((Date.now() - at) / 86_400_000)
54
- }
55
-
56
- export function isStale(front: MemoryFront): boolean {
57
- const days = ageDays(front)
58
- return front.volatile && front.status === 'active' && days !== null && days >= STALE_DAYS
59
- }
60
-
61
- export function memoryDir(wiki: string): string {
62
- return join(wiki, 'facts')
63
- }
64
-
65
- /** The reason a fact is not valid, or `null`. */
66
- export function validate(front: MemoryFront, body: string): string | null {
67
- try {
68
- safeId(front.id)
69
- } catch {
70
- return 'The fact id must be letters, numbers, - or _.'
71
- }
72
- if (!front.title.trim() || !body.trim()) return 'A fact needs a title and a body.'
73
- if (Buffer.byteLength(body) > MAX_BODY) return `A memory is a short fact; keep the body under ${MAX_BODY} characters and put longer material in a Wiki page.`
74
- if (!(MEMORY_KINDS as readonly string[]).includes(front.kind)) return `A fact must be one of ${MEMORY_KINDS.join(', ')}.`
75
- if (!(CONFIDENCE as readonly string[]).includes(front.confidence) || !(STATUSES as readonly string[]).includes(front.status)) return 'Unknown confidence or status.'
76
- if (front.namespace !== ROOT_NAMESPACE && store.projectNamespaceOf(front.namespace) !== front.namespace && front.namespace !== LIBRARY_NAMESPACE) {
77
- return "A memory's namespace is / or a project namespace (/projects/<slug>)."
78
- }
79
- return null
80
- }
81
-
82
- const optionalText = (value: unknown, fallback: string): string | null => (value === undefined ? fallback : typeof value === 'string' ? value : null)
83
-
84
- export function parse(text: string, path: string): ParsedMemory | null {
85
- const parsed = frontmatter.parse(text)
86
- if (!parsed) return null
87
- const { id, title, kind, supersedes, volatile } = parsed.front
88
- if (typeof id !== 'string' || typeof title !== 'string' || typeof kind !== 'string') return null
89
- const namespace = optionalText(parsed.front.namespace, ROOT_NAMESPACE)
90
- const confidence = optionalText(parsed.front.confidence, 'medium')
91
- const status = optionalText(parsed.front.status, 'active')
92
- const createdAt = optionalText(parsed.front.createdAt, '')
93
- const updatedAt = optionalText(parsed.front.updatedAt, '')
94
- const observedAt = optionalText(parsed.front.observedAt, '')
95
- const sourceIds = store.stringList(parsed.front.sourceIds)
96
- const links = store.linkList(parsed.front.links, id)
97
- if (namespace === null || confidence === null || status === null || createdAt === null || updatedAt === null || observedAt === null || !sourceIds || !links) return null
98
- if (supersedes !== undefined && supersedes !== null && typeof supersedes !== 'string') return null
99
- if (volatile !== undefined && typeof volatile !== 'boolean') return null
100
- return {
101
- front: {
102
- id,
103
- title,
104
- // Historical procedures read as facts without rewriting files or creating registry skills.
105
- kind: kind === 'procedure' ? 'fact' : kind,
106
- namespace,
107
- sourceIds,
108
- confidence,
109
- status,
110
- supersedes: supersedes ?? null,
111
- links,
112
- createdAt,
113
- updatedAt,
114
- observedAt,
115
- volatile: volatile ?? false
116
- },
117
- body: parsed.body,
118
- path
119
- }
120
- }
121
-
122
- export function loadMemories(wiki: string): ParsedMemory[] {
123
- const files: string[] = []
124
- store.markdownFiles(memoryDir(wiki), files)
125
- const memories: ParsedMemory[] = []
126
- for (const path of files.sort(comparePaths)) {
127
- try {
128
- const memory = parse(readFileSync(path, 'utf8'), path)
129
- if (memory) memories.push(memory)
130
- } catch {
131
- // An unreadable file is not a fact.
132
- }
133
- }
134
- return memories
135
- }
136
-
137
- export function findMemory(wiki: string, id: string): ParsedMemory {
138
- const memory = loadMemories(wiki).find((entry) => entry.front.id === id)
139
- if (!memory) throw notFound('Memory')
140
- return memory
141
- }
142
-
143
- export function serialize(front: MemoryFront, body: string): string {
144
- return frontmatter.serialize(
145
- {
146
- id: front.id,
147
- title: front.title,
148
- kind: front.kind,
149
- namespace: front.namespace,
150
- sourceIds: front.sourceIds,
151
- confidence: front.confidence,
152
- status: front.status,
153
- ...(front.supersedes === null ? {} : { supersedes: front.supersedes }),
154
- links: front.links,
155
- createdAt: front.createdAt,
156
- updatedAt: front.updatedAt,
157
- observedAt: front.observedAt,
158
- ...(front.volatile ? { volatile: true } : {})
159
- },
160
- body
161
- )
162
- }
163
-
164
- export function pathFor(wiki: string, id: string): string {
165
- return join(memoryDir(wiki), `${safeId(id)}.md`)
166
- }
167
-
168
- /**
169
- * Facts that apply in a project namespace (or everywhere): active ones in `/`, `/library`,
170
- * or that namespace. Others stay retrievable by search.
171
- */
172
- export function appliesTo(front: MemoryFront, projectNamespace: string | null): boolean {
173
- return front.status === 'active' && (front.namespace === ROOT_NAMESPACE || front.namespace === LIBRARY_NAMESPACE || front.namespace === projectNamespace)
174
- }
175
-
176
- export type WikiMemory = Omit<MemoryFront, 'supersedes' | 'volatile'> & { supersedes?: string; volatile?: true; body: string; path: string; revisionId: string | null }
177
-
178
- export function response(wiki: string, memory: ParsedMemory): WikiMemory {
179
- const { supersedes, volatile, ...front } = memory.front
180
- return {
181
- ...front,
182
- ...(supersedes === null ? {} : { supersedes }),
183
- ...(volatile ? { volatile: true as const } : {}),
184
- body: memory.body,
185
- path: memory.path,
186
- revisionId: store.currentRevision(wiki, memory.front.id)
187
- }
188
- }
189
-
190
- export interface MemoryInput {
191
- id?: string | null
192
- title: string
193
- kind: string
194
- namespace?: string
195
- body: string
196
- confidence?: string
197
- status?: string
198
- supersedes?: string | null
199
- volatile?: boolean
200
- }
201
-
202
- /** A user's create or edit. Writes a revision before and after, like every other change. */
203
- export function saveAsUser(wiki: string, input: MemoryInput): WikiMemory {
204
- const id = input.id ?? store.slug(input.title)
205
- let existing: ParsedMemory | null = null
206
- try {
207
- existing = findMemory(wiki, id)
208
- } catch {
209
- existing = null
210
- }
211
- const sourceIds = existing ? [...existing.front.sourceIds] : []
212
- if (!sourceIds.length) sourceIds.push('user')
213
- const front: MemoryFront = {
214
- id,
215
- title: input.title.trim(),
216
- kind: input.kind,
217
- namespace: (input.namespace ?? ROOT_NAMESPACE).trim(),
218
- sourceIds,
219
- confidence: input.confidence ?? 'medium',
220
- status: input.status ?? 'active',
221
- supersedes: input.supersedes || null,
222
- links: existing ? existing.front.links : [],
223
- createdAt: existing?.front.createdAt || now(),
224
- updatedAt: now(),
225
- // Saving a fact again says it is still true now.
226
- observedAt: now(),
227
- volatile: input.volatile ?? false
228
- }
229
- const problem = validate(front, input.body)
230
- if (problem) throw invalid(problem)
231
- if (looksSecret(input.body)) throw invalid('The fact contains text that looks like a credential.')
232
- const path = existing?.path ?? pathFor(wiki, id)
233
- if (existing) {
234
- let before: string
235
- try {
236
- before = readFileSync(existing.path, 'utf8')
237
- } catch (error) {
238
- throw new HarnessError('io_error', error instanceof Error ? error.message : String(error))
239
- }
240
- store.snapshot(wiki, id, before, 'user', 'Before edit')
241
- }
242
- const text = serialize(front, input.body)
243
- atomicWrite(path, text)
244
- store.snapshot(wiki, id, text, 'user', existing ? 'Edited' : 'Created')
245
- noteChange({ wiki, id, path, text, actor: 'user', reason: existing ? 'Edited' : 'Created' })
246
- store.log(wiki, `- ${now()} · user · memory · \`${id}\` ${existing ? 'edited' : 'created'}`)
247
- return response(wiki, findMemory(wiki, id))
248
- }