@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
package/src/memory/api.ts DELETED
@@ -1,303 +0,0 @@
1
- // The Wiki as a model sees it: search, read, list, skills, facts, proposals, and memory-block
2
- // edits. Results are plain text with `[id@revision]` citations, and every entry a run reads is
3
- // recorded in `runs/<id>/wiki-context.json`.
4
- import { dirname } from 'node:path'
5
-
6
- import type { HarnessEnv } from '../core/env'
7
- import { HarnessError, invalid, notFound } from '../core/errors'
8
- import { charCount } from '../core/text'
9
- import { applicableMemories, clip } from '../prompt/compile'
10
- import { findRun, readEvents } from '../sessions/runStore'
11
- import type { RunEvent } from '../sessions/contracts'
12
- import { takeChars } from '../core/text'
13
- import * as registry from '../skills/registry'
14
- import { enqueueCuration } from './curation'
15
- import * as blocks from './blocks'
16
- import { actorOf, blockCaller, projectOf, type CallerContext } from './caller'
17
- import * as facts from './facts'
18
- import { apply, captureHashes, parkRegistryProposal, parseProposalSet, type Trigger } from './proposals'
19
- import * as references from './references'
20
- import { cite, record, recordSources } from './runContext'
21
- import * as store from './store'
22
- import { backlinks, entryId, namespaces, registryEntryKind, resolveEntry, searchEntries } from './wiki'
23
-
24
- const READ_LIMIT = 20_000
25
-
26
- export type Params = Record<string, unknown> | null | undefined
27
-
28
- function textParam(params: Params, key: string): string {
29
- const value = params?.[key]
30
- return typeof value === 'string' ? value.trim() : ''
31
- }
32
-
33
- const rawParam = (params: Params, key: string): string => (typeof params?.[key] === 'string' ? (params[key] as string) : '')
34
- const countParam = (params: Params, key: string): number | null => {
35
- const value = params?.[key]
36
- return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
37
- }
38
-
39
- // ---- Run evidence -------------------------------------------------------------------------------
40
-
41
- function transcriptText(goal: string, events: readonly RunEvent[]): string {
42
- let text = `User: ${goal}\n`
43
- for (const event of events) {
44
- const payload = event.payload as { role?: unknown; text?: unknown; name?: unknown; summary?: unknown }
45
- if (event.type === 'message.completed' && payload.role === 'assistant') text += `Assistant: ${typeof payload.text === 'string' ? payload.text : ''}\n`
46
- else if (event.type === 'tool.started') text += `Tool ${typeof payload.name === 'string' ? payload.name : 'tool'}: ${typeof payload.summary === 'string' ? payload.summary : ''}\n`
47
- }
48
- return text
49
- }
50
-
51
- /** What a consolidation job reads of a run, capped. */
52
- export function transcript(goal: string, events: readonly RunEvent[]): string {
53
- return takeChars(transcriptText(goal, events), 24_000)
54
- }
55
-
56
- /** Everything recorded of a run, uncapped: what an excerpt is checked against. */
57
- export function evidenceText(root: string, runId: string): string {
58
- let events: RunEvent[] = []
59
- try {
60
- events = readEvents(root, runId)
61
- } catch {
62
- events = []
63
- }
64
- return transcriptText(findRun(root, runId)?.goal ?? '', events)
65
- }
66
-
67
- // ---- Memory blocks -----------------------------------------------------------------------
68
-
69
- /** `memory.append`, `memory.replace`, and `memory.rethink`. */
70
- export function memoryEdit(env: HarnessEnv, context: CallerContext, call: string, params: Params): string {
71
- const caller = blockCaller(env, context)
72
- const label = textParam(params, 'block')
73
- if (!label) throw invalid('Name the block to edit, such as user, persona, or notes.')
74
- let edit: blocks.Edit
75
- if (call === 'memory.append') edit = { kind: 'append', text: rawParam(params, 'text') }
76
- else if (call === 'memory.replace') edit = { kind: 'replace', old: rawParam(params, 'old'), new: rawParam(params, 'new') }
77
- else if (call === 'memory.rethink') edit = { kind: 'rethink', text: rawParam(params, 'body') }
78
- else throw new HarnessError('unknown_method', `Unknown memory call ${call}.`)
79
- const block = blocks.edit(env.root, caller, label, edit)
80
- record(env.root, context, 'edit', [[block.id, 'block']])
81
- return `Updated the ${block.front.label} block (${charCount(block.body)} of ${block.front.limit} characters). It is in your context from the next turn.`
82
- }
83
-
84
- // ---- Retrieval ------------------------------------------------------------------------------
85
-
86
- function ownBlocksPrefix(env: HarnessEnv, context: CallerContext): string | null {
87
- try {
88
- const caller = blockCaller(env, context)
89
- return caller.kind === 'agent' ? `blocks/agents/${caller.id}/` : null
90
- } catch {
91
- return null
92
- }
93
- }
94
-
95
- /** A fact is stored and indexed under the kind `memory`. A model says `fact`; `memory` is still understood. */
96
- const storedKind = (kind: string): string => (kind === 'fact' ? 'memory' : kind)
97
- const shownKind = (kind: string): string => (kind === 'memory' ? 'fact' : kind)
98
-
99
- export function search(env: HarnessEnv, context: CallerContext, params: Params): string {
100
- const { root } = env
101
- const query = textParam(params, 'query')
102
- if (!query) return 'Give a query.'
103
- const kinds = (Array.isArray(params?.kinds) ? params.kinds.filter((kind): kind is string => typeof kind === 'string') : []).map(storedKind)
104
- const limit = countParam(params, 'limit') ?? 8
105
- // Another agent's blocks never appear; a block of the caller's own does.
106
- const own = ownBlocksPrefix(env, context)
107
- const hits = searchEntries(root, query, { kinds, namespace: textParam(params, 'namespace') || null, projectId: context.projectId, limit: limit + 6 })
108
- .filter((hit) => context.vaultAccess === 'curator' || !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
109
- .slice(0, limit)
110
- if (!hits.length) return 'No matching Wiki entries.'
111
- const wiki = store.wikiDir(root)
112
- record(root, context, 'search', hits.map((hit) => [hit.id, hit.kind]))
113
- return `${hits.map((hit, index) => `${index + 1}. [${shownKind(hit.kind)}] ${hit.title} — ${hit.path} ${cite(wiki, hit.id)}\n ${hit.snippet}`).join('\n')}\n\nRead an entry with wiki_read and its id or path.`
114
- }
115
-
116
- export function read(env: HarnessEnv, context: CallerContext, params: Params): string {
117
- const { root } = env
118
- const key = textParam(params, 'id') || textParam(params, 'path') || textParam(params, 'idOrPath')
119
- if (!key) throw invalid('Give an id or Wiki path.')
120
- if (typeof params?.revision === 'string') {
121
- const text = references.readPinned(root, context.runId, key, params.revision, countParam(params, 'offset') ?? 0)
122
- recordSources(root, context, 'read', [{ id: key, kind: key.startsWith('skills/') ? 'skill' : 'page', revision: params.revision }])
123
- return text
124
- }
125
- const wiki = store.wikiDir(root)
126
- const entry = resolveEntry(root, key)
127
- // An agent's own blocks are not readable by other agents or by sessions.
128
- if (entry.kind === 'block' && entry.block.scope === 'agent') {
129
- const own = ownBlocksPrefix(env, context)
130
- if (context.vaultAccess !== 'curator' && (own === null || `blocks/agents/${entry.block.owner}/` !== own)) throw notFound('Wiki entry')
131
- }
132
- const linking = backlinks(root, entryId(entry))
133
- const linked = linking.length ? linking.map((link) => `${link.fromId} (${link.type})`).join(', ') : 'none'
134
- let kind: string
135
- let text: string
136
- if (entry.kind === 'page') {
137
- const { frontmatter: front, location, body } = entry.page
138
- kind = 'page'
139
- text = `# ${front.title} ${cite(wiki, front.id)}\nPath: ${location.path} · namespace ${location.namespace} · type ${front.type} · updated ${front.updatedAt}\nSources: ${front.sourceIds.length ? front.sourceIds.join(', ') : 'none (unresolved)'}\nLinks: ${front.links.length ? front.links.map((link) => `${link.type} ${link.toId}`).join(', ') : 'none'}\nBacklinks: ${linked}\n\n${clip(body, READ_LIMIT)}`
140
- } else if (entry.kind === 'memory') {
141
- const { front, body } = entry.memory
142
- kind = 'memory'
143
- text = `# ${front.title} ${cite(wiki, front.id)}\nFact · ${front.kind} · namespace ${front.namespace} · ${front.confidence} confidence · ${front.status}${front.supersedes ? ` · supersedes ${front.supersedes}` : ''}\nSources: ${front.sourceIds.length ? front.sourceIds.join(', ') : 'none'}\nBacklinks: ${linked}\n\n${body}`
144
- } else if (entry.kind === 'block') {
145
- const { block } = entry
146
- kind = 'block'
147
- text = `# ${block.front.label} ${cite(wiki, block.id)}\nMemory block · ${block.scope} · ${charCount(block.body)} of ${block.front.limit} characters\n${block.front.description}\n\n${block.body}`
148
- } else {
149
- const { item } = entry
150
- kind = registryEntryKind(item.kind) ?? 'skill'
151
- text = `# ${item.name} ${cite(wiki, item.id)}\nRegistry ${item.id} · file ${item.path}\nBacklinks: ${linked}\n\n${clip(registry.itemBody(item), READ_LIMIT)}`
152
- }
153
- record(root, context, 'read', [[entryId(entry), kind]])
154
- return text
155
- }
156
-
157
- export function list(env: HarnessEnv, params: Params): string {
158
- const { root } = env
159
- const namespace = textParam(params, 'namespace')
160
- const kind = storedKind(textParam(params, 'kind'))
161
- const wiki = store.wikiDir(root)
162
- if (kind === 'memory') {
163
- const memories = facts.loadMemories(wiki)
164
- if (!memories.length) return 'No facts yet.'
165
- return memories
166
- .filter((memory) => !namespace || memory.front.namespace === namespace)
167
- .map((memory) => `- ${memory.front.title} (${memory.front.id}) [${memory.front.kind}] ${memory.front.namespace} · ${memory.front.status}`)
168
- .join('\n')
169
- }
170
- if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
171
- const plural = `${kind}s`
172
- const items = registry.list(root).filter((item) => item.kind === plural).map((item) => `- ${item.name} (${item.id}) — ${item.description}`)
173
- return items.length ? items.join('\n') : `No ${plural} yet.`
174
- }
175
- let text = ''
176
- if (!namespace) text += `Namespaces: / (Home), /library${namespaces(root).map((entry) => `, ${entry.namespace}`).join('')}\n\n`
177
- const prefix = `${namespace.replace(/\/+$/, '')}/`
178
- const pages = store
179
- .loadPages(wiki)
180
- .filter((page) => !namespace || page.location.namespace === namespace || page.location.path.startsWith(prefix))
181
- .slice(0, 200)
182
- .map((page) => `- ${page.location.path} — ${page.frontmatter.title} (${page.frontmatter.type}) [${page.frontmatter.id}]`)
183
- return text + (pages.length ? pages.join('\n') : 'No pages here.')
184
- }
185
-
186
- /** One skill's instructions by name or id, or the skills index when no name is given. */
187
- export function skill(env: HarnessEnv, context: CallerContext, params: Params, contributions?: readonly string[]): string {
188
- const { root } = env
189
- const name = textParam(params, 'name').toLowerCase()
190
- const items = registry.limitToContributions(registry.list(root).filter((item) => item.kind === 'skills'), contributions)
191
- if (!name) {
192
- if (!items.length) return 'No skills yet.'
193
- const active = (contributions === undefined
194
- ? registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null)
195
- : items).map((item) => item.id)
196
- return items.map((item) => `- ${item.name} (${item.id})${active.includes(item.id) ? ' · active' : ''} — ${item.description}`).join('\n')
197
- }
198
- const found = items.find((item) => item.id.toLowerCase() === name || item.id.replace(/^skills\//, '').toLowerCase() === name || item.name.toLowerCase() === name)
199
- if (!found) return `There is no skill called "${name}". Call skill_load without a name to list them.`
200
- record(root, context, 'skill', [[found.id, 'skill']])
201
- const folder = found.path.endsWith('SKILL.md') ? `\nSupporting files are in ${dirname(found.path)}.` : ''
202
- return `# ${found.name} ${cite(store.wikiDir(root), found.id)}\n${found.description}${folder}\n\n${clip(registry.itemBody(found), READ_LIMIT)}`
203
- }
204
-
205
- /** ", volatile, observed 12 days ago" for a fact that describes current state. */
206
- export function volatileNote(front: facts.MemoryFront): string {
207
- if (!front.volatile) return ''
208
- const days = facts.ageDays(front)
209
- return days === null ? ', volatile' : `, volatile, observed ${days} day${days === 1 ? '' : 's'} ago`
210
- }
211
-
212
- export function memories(env: HarnessEnv, context: CallerContext, params: Params): string {
213
- const { root } = env
214
- const wiki = store.wikiDir(root)
215
- const query = textParam(params, 'query')
216
- const namespace = textParam(params, 'namespace')
217
- let selected: facts.ParsedMemory[]
218
- if (!query) {
219
- selected = applicableMemories(wiki, namespace || (projectOf(root, context)?.wikiNamespace ?? null))
220
- } else {
221
- selected = searchEntries(root, query, { kinds: ['memory'], namespace: namespace || null, projectId: context.projectId, limit: 20 }).flatMap((hit) => {
222
- try {
223
- return [facts.findMemory(wiki, hit.id)]
224
- } catch {
225
- return []
226
- }
227
- })
228
- }
229
- if (!selected.length) return 'No facts match.'
230
- const shown = selected.slice(0, 30)
231
- record(root, context, 'memory', shown.map((memory) => [memory.front.id, 'memory']))
232
- return shown
233
- .map((memory) => `- [${memory.front.kind}] ${memory.front.title} ${cite(wiki, memory.front.id)} (${memory.front.namespace}, ${memory.front.confidence} confidence${memory.front.status === 'active' ? '' : `, ${memory.front.status}`}${volatileNote(memory.front)})\n ${memory.body.trim().replaceAll('\n', '\n ')}`)
234
- .join('\n')
235
- }
236
-
237
- // ---- Proposals -------------------------------------------------------------------------------
238
-
239
- function sourceRefs(params: Params, context: CallerContext): { kind: string; ref: string; excerpt?: string }[] {
240
- if (!Array.isArray(params?.sourceRefs)) return []
241
- return params.sourceRefs.flatMap((reference: { kind?: unknown; ref?: unknown; excerpt?: unknown } | null) => {
242
- if (!reference || typeof reference.kind !== 'string') return []
243
- const given = typeof reference.ref === 'string' ? reference.ref : ''
244
- // "current" (or nothing) names the caller's own run.
245
- const own = reference.kind === 'run' && (!given || given === 'current')
246
- if (own && !context.runId) return []
247
- return [{ kind: reference.kind, ref: own ? context.runId! : given, ...(typeof reference.excerpt === 'string' ? { excerpt: reference.excerpt } : {}) }]
248
- })
249
- }
250
-
251
- /** A `links` argument: usable only when every entry names a target and a type. */
252
- function links(params: Params): { toId: string; type: string }[] {
253
- const given = params?.links
254
- if (!Array.isArray(given) || !given.every((link) => link && typeof link.toId === 'string' && typeof link.type === 'string')) return []
255
- return given.map((link: { toId: string; type: string }) => ({ toId: link.toId, type: link.type }))
256
- }
257
-
258
- const optional = (params: Params, key: string): string | null => textParam(params, key) || null
259
-
260
- /** Validates and applies (or parks) one proposal. */
261
- export function propose(env: HarnessEnv, context: CallerContext, params: Params): string {
262
- const { root } = env
263
- const wiki = store.wikiDir(root)
264
- const kind = storedKind(textParam(params, 'kind'))
265
- const trigger: Trigger = { kind: 'agent', runId: context.runId ?? null, actor: actorOf(context) }
266
- const defaultNamespace = projectOf(root, context)?.wikiNamespace ?? store.LIBRARY_NAMESPACE
267
- const title = textParam(params, 'title')
268
- const body = rawParam(params, 'body')
269
- let proposals: unknown
270
- if (kind === 'page') {
271
- proposals = { pages: [{ id: optional(params, 'id'), title, type: optional(params, 'type') ?? 'concept', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), sourceRefs: sourceRefs(params, context), links: links(params) }] }
272
- } else if (kind === 'memory') {
273
- proposals = {
274
- memories: [{ id: optional(params, 'id'), title, kind: optional(params, 'type') ?? 'fact', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), supersedes: optional(params, 'supersedes'), volatile: typeof params?.volatile === 'boolean' ? params.volatile : null, sourceRefs: sourceRefs(params, context), links: links(params) }]
275
- }
276
- } else if (kind === 'block') {
277
- const id = textParam(params, 'id')
278
- const block = blocks.find(wiki, id)
279
- if (block.front.readOnly) throw invalid('This block is user-only; only the user can change it.')
280
- proposals = { blocks: [{ block: id, body, sourceRefs: sourceRefs(params, context) }] }
281
- } else if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
282
- if (context.vaultAccess) {
283
- const job = enqueueCuration(root, { kind: 'registry', proposal: { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') }, caller: context })
284
- return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
285
- }
286
- const id = parkRegistryProposal(wiki, trigger, { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') })
287
- return `The ${kind} change (${id}) is waiting in the Wiki Inbox. Skills, prompts, and rules change only after the user accepts them.`
288
- } else {
289
- throw invalid('kind must be page, fact, skill, rule, or prompt.')
290
- }
291
- // The caller's own run is evidence only for what it can quote from it.
292
- if (context.vaultAccess) {
293
- const job = enqueueCuration(root, { kind: 'proposal', namespace: defaultNamespace, proposals: parseProposalSet(proposals), caller: context })
294
- return `Submitted proposal ${job.id} for curator review. Nothing has been changed.`
295
- }
296
- const outcome = apply(wiki, defaultNamespace, trigger, parseProposalSet(proposals), captureHashes(wiki), { runText: context.runId ? evidenceText(root, context.runId) : null })
297
- let text = outcome.notes.join('\n')
298
- if (outcome.operation) {
299
- text += `\nApplied as operation ${outcome.operation.id.slice(0, 8)}; the user can undo it from the Wiki Inbox.`
300
- record(root, context, 'edit', outcome.operation.pages.map((page) => [page.pageId, kind]))
301
- }
302
- return text
303
- }
@@ -1,78 +0,0 @@
1
- import { expect, test } from 'vitest'
2
-
3
- import { rootWithProject } from '../../tests/helpers'
4
- import { applyEdit, attached, DEFAULT_LIMIT, edit, PROJECT_LIMIT, render, saveAsUser, undoLast, type BlockInput, type Caller } from './blocks'
5
- import { readManifest, wikiDir } from './store'
6
-
7
- const agent = (id: string): Caller => ({ kind: 'agent', id, name: 'Nora' })
8
- const append = (text: string) => ({ kind: 'append' as const, text })
9
-
10
- test('edits apply with limits and exact replacement', () => {
11
- expect(applyEdit('', append('Likes pnpm.'))).toBe('Likes pnpm.')
12
- expect(applyEdit('A', append(' B '))).toBe('A\nB')
13
- expect(applyEdit('Use npm.', { kind: 'replace', old: 'npm', new: 'pnpm' })).toBe('Use pnpm.')
14
- expect(() => applyEdit('a a', { kind: 'replace', old: 'a', new: 'b' })).toThrow('2 times')
15
- expect(() => applyEdit('a', { kind: 'replace', old: 'z', new: 'b' })).toThrow()
16
- expect(() => applyEdit('a', append(' '))).toThrow()
17
- expect(applyEdit('old', { kind: 'rethink', text: ' new ' })).toBe('new')
18
- })
19
-
20
- test('an agent gets its blocks, the user block and work project briefs', () => {
21
- const root = rootWithProject()
22
- const blocks = attached(root, agent('a1'), ['app', 'missing'])
23
- expect(blocks.map((block) => block.front.label)).toEqual(['persona', 'notes', 'user', 'project/app'])
24
- expect(blocks[0].id).toBe('blocks/agents/a1/persona')
25
- expect(blocks[3].front.limit).toBe(PROJECT_LIMIT)
26
- expect(attached(root, { kind: 'session', projectSlug: 'app' }, []).map((block) => block.front.label)).toEqual(['user', 'project/app'])
27
- })
28
-
29
- test('edits are revisioned, limited and undoable', () => {
30
- const root = rootWithProject()
31
- const wiki = wikiDir(root)
32
- const nora = agent('a1')
33
- expect(edit(root, nora, 'user', append('Prefers short answers.')).body).toBe('Prefers short answers.')
34
- const revisions = readManifest(wiki, 'blocks/shared/user')
35
- expect(revisions).toHaveLength(2)
36
- expect(revisions[1].actor).toBe('Nora')
37
- edit(root, nora, 'user', append('Works in Bangkok time.'))
38
- expect(readManifest(wiki, 'blocks/shared/user')).toHaveLength(3)
39
- expect(undoLast(wiki, 'blocks/shared/user').body).toBe('Prefers short answers.')
40
- expect(undoLast(wiki, 'blocks/shared/user').body).toBe('')
41
- expect(() => undoLast(wiki, 'blocks/shared/user')).toThrow()
42
-
43
- expect(() => edit(root, nora, 'notes', { kind: 'rethink', text: 'x'.repeat(DEFAULT_LIMIT + 1) })).toThrow('memory_rethink')
44
- expect(() => edit(root, nora, 'notes', append('key sk-ant-abc'))).toThrow('credential')
45
- expect(() => edit(root, nora, 'nope', append('x'))).toThrow()
46
- })
47
-
48
- test('sessions edit only their project brief', () => {
49
- const root = rootWithProject()
50
- const session: Caller = { kind: 'session', projectSlug: 'app' }
51
- expect(edit(root, session, 'project', append('Tauri 2 app.')).id).toBe('blocks/projects/app/brief')
52
- expect(() => edit(root, session, 'user', append('x'))).toThrow('cannot edit')
53
- expect(() => edit(root, session, 'persona', append('x'))).toThrow()
54
- attached(root, agent('a1'), [])
55
- expect(() => edit(root, session, 'blocks/agents/a1/persona', append('x'))).toThrow()
56
- expect(() => edit(root, { kind: 'session', projectSlug: null }, 'project/app', append('x'))).toThrow()
57
- // An agent may edit any project's brief, and its own blocks, but not another agent's.
58
- expect(edit(root, agent('a1'), 'project/app', append('Uses pnpm.')).body).toContain('Uses pnpm.')
59
- expect(() => edit(root, agent('a2'), 'blocks/agents/a1/persona', append('x'))).toThrow()
60
- })
61
-
62
- test('the user creates and restricts shared blocks', () => {
63
- const root = rootWithProject()
64
- const wiki = wikiDir(root)
65
- const input = (id: string | null, label: string | null, agents: string[], readOnly: boolean): BlockInput => ({ id, label, body: 'We ship on Fridays.', description: 'Team habits.', limit: 500, readOnly, agents })
66
- const created = saveAsUser(wiki, input(null, 'Team habits', ['a1'], false))
67
- expect(created.id).toBe('blocks/shared/team-habits')
68
- expect(() => saveAsUser(wiki, input(null, 'Team habits', [], false))).toThrow()
69
- expect(() => saveAsUser(wiki, input(null, 'user', [], false))).toThrow()
70
- expect(attached(root, agent('a1'), []).some((block) => block.id === created.id)).toBe(true)
71
- expect(attached(root, agent('a2'), []).some((block) => block.id === created.id)).toBe(false)
72
- expect(() => edit(root, agent('a2'), 'team-habits', append('x'))).toThrow()
73
- saveAsUser(wiki, input(created.id, null, [], true))
74
- expect(() => edit(root, agent('a1'), 'team-habits', append('x'))).toThrow('read-only')
75
- const rendered = render(attached(root, agent('a1'), []), agent('a1'), false)
76
- expect(rendered).toContain('<block label="team-habits" chars="19/500" read-only="true">')
77
- expect(rendered).toContain('<block label="persona" chars="0/2000">')
78
- })