@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,408 +0,0 @@
1
- // Core memory: blocks. A block is a short Markdown file that is always in the context of what
2
- // it is attached to, and that the model edits itself. Blocks live under `wiki/blocks/`:
3
- // `shared/<label>.md` (the `user` block and custom shared blocks), `agents/<agent-id>/` (an
4
- // agent's `persona` and `notes`), and `projects/<slug>/brief.md`. A block's id and scope come
5
- // from where its file lives. Every edit is a revision and an undoable operation.
6
- import { existsSync, readFileSync } from 'node:fs'
7
- import { join, relative } from 'node:path'
8
-
9
- import { noteChange } from '../core/changes'
10
- import { HarnessError, invalid, notFound } from '../core/errors'
11
- import * as frontmatter from '../core/frontmatter'
12
- import { atomicWrite, newId, now, safeId, writeJson } from '../core/fs'
13
- import { listProjects, projectsOrNone } from '../core/projects'
14
- import { charCount, comparePaths, compareText } from '../core/text'
15
- import { listOperations, undo, type Operation } from './operations'
16
- import { looksSecret } from './secrets'
17
- import * as store from './store'
18
-
19
- export const DEFAULT_LIMIT = 2000
20
- export const PROJECT_LIMIT = 3000
21
- export const MAX_LIMIT = 20_000
22
- /** An agent's prompt carries at most this many project briefs; the rest are listed by name. */
23
- export const AGENT_PROJECT_BRIEFS = 2
24
- const AGENT_LABELS = ['persona', 'notes']
25
-
26
- export interface BlockFront {
27
- label: string
28
- /** Tells the model what belongs in the block. */
29
- description: string
30
- limit: number
31
- /** Only the user may edit it. */
32
- readOnly: boolean
33
- /** Custom shared blocks: the agents it is attached to; empty means every agent. */
34
- agents: string[]
35
- updatedAt: string
36
- }
37
-
38
- export type BlockScope = 'shared' | 'agent' | 'project'
39
-
40
- export interface Block {
41
- /** `blocks/shared/user`, `blocks/agents/<agent-id>/persona`, `blocks/projects/<slug>/brief`. */
42
- id: string
43
- scope: BlockScope
44
- /** The agent id or project slug the block belongs to. */
45
- owner: string | null
46
- front: BlockFront
47
- body: string
48
- path: string
49
- }
50
-
51
- /** Who is reading or editing blocks. */
52
- export type Caller =
53
- | { kind: 'user' }
54
- /** An agent with blocks of its own, and the name recorded on its revisions. */
55
- | { kind: 'agent'; id: string; name: string; vaultAccess?: 'self' | 'propose' | 'curator' }
56
- /** A session without blocks of its own, and its project, if any. */
57
- | { kind: 'session'; projectSlug: string | null }
58
-
59
- function actorOf(caller: Caller): string {
60
- return caller.kind === 'user' ? 'user' : caller.kind === 'agent' ? caller.name : 'Harness session'
61
- }
62
-
63
- export function blocksDir(wiki: string): string {
64
- return join(wiki, 'blocks')
65
- }
66
-
67
- function locate(wiki: string, path: string): { id: string; scope: BlockScope; owner: string | null } | null {
68
- const inside = relative(blocksDir(wiki), path)
69
- if (inside.startsWith('..')) return null
70
- const parts = inside.replace(/(?<=[^/])\.[^./]*$/, '').split('/')
71
- const id = `blocks/${parts.join('/')}`
72
- if (parts.length === 2 && parts[0] === 'shared') return { id, scope: 'shared', owner: null }
73
- if (parts.length === 3 && parts[0] === 'agents') return { id, scope: 'agent', owner: parts[1] }
74
- if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'brief') return { id, scope: 'project', owner: parts[1] }
75
- return null
76
- }
77
-
78
- export function parseFront(front: Record<string, unknown>): BlockFront | null {
79
- const { label, description, limit, readOnly, updatedAt } = front
80
- if (typeof label !== 'string') return null
81
- if (description !== undefined && typeof description !== 'string') return null
82
- if (limit !== undefined && !(typeof limit === 'number' && Number.isInteger(limit) && limit >= 0)) return null
83
- if (readOnly !== undefined && typeof readOnly !== 'boolean') return null
84
- if (updatedAt !== undefined && typeof updatedAt !== 'string') return null
85
- const agents = store.stringList(front.agents)
86
- if (!agents) return null
87
- return { label, description: description ?? '', limit: limit ?? DEFAULT_LIMIT, readOnly: readOnly ?? false, agents, updatedAt: updatedAt ?? '' }
88
- }
89
-
90
- export function serializeBlock(front: BlockFront, body: string): string {
91
- return frontmatter.serialize(
92
- { label: front.label, description: front.description, limit: front.limit, readOnly: front.readOnly, ...(front.agents.length ? { agents: front.agents } : {}), updatedAt: front.updatedAt },
93
- body
94
- )
95
- }
96
-
97
- function parse(wiki: string, path: string): Block | null {
98
- const located = locate(wiki, path)
99
- if (!located) return null
100
- let text: string
101
- try {
102
- text = readFileSync(path, 'utf8')
103
- } catch {
104
- return null
105
- }
106
- const parsed = frontmatter.parse(text)
107
- const front = parsed && parseFront(parsed.front)
108
- if (!parsed || !front) return null
109
- return { ...located, front, body: parsed.body.trimEnd(), path }
110
- }
111
-
112
- export function loadAll(wiki: string): Block[] {
113
- const files: string[] = []
114
- store.markdownFiles(blocksDir(wiki), files)
115
- return files.sort(comparePaths).flatMap((path) => parse(wiki, path) ?? [])
116
- }
117
-
118
- /** A block by its id (`blocks/shared/user`). Every segment must be a safe identifier. */
119
- export function find(wiki: string, id: string): Block {
120
- if (!id.startsWith('blocks/')) throw notFound('Memory block')
121
- const segments = id.slice('blocks/'.length).split('/')
122
- try {
123
- segments.forEach(safeId)
124
- } catch {
125
- throw notFound('Memory block')
126
- }
127
- const block = parse(wiki, `${join(blocksDir(wiki), ...segments)}.md`)
128
- if (!block) throw notFound('Memory block')
129
- return block
130
- }
131
-
132
- function create(wiki: string, path: string, label: string, description: string, limit: number): Block {
133
- const file = join(blocksDir(wiki), `${path}.md`)
134
- if (!existsSync(file)) atomicWrite(file, serializeBlock({ label, description, limit, readOnly: false, agents: [], updatedAt: now() }, ''))
135
- const block = parse(wiki, file)
136
- if (!block) throw new HarnessError('invalid_data', `The ${label} memory block could not be read.`)
137
- return block
138
- }
139
-
140
- export function ensureUser(wiki: string): Block {
141
- return create(wiki, 'shared/user', 'user', 'What you know about the user: name, role, preferences, and how they like to work. Shared with every agent and session.', DEFAULT_LIMIT)
142
- }
143
-
144
- export function ensureAgent(wiki: string, agentId: string): Block[] {
145
- const agent = safeId(agentId)
146
- return [
147
- create(wiki, `agents/${agent}/persona`, 'persona', 'How you work: your role for this user, your habits, and what you have learned about doing this job well.', DEFAULT_LIMIT),
148
- create(wiki, `agents/${agent}/notes`, 'notes', 'Working notes to keep across turns: what you are in the middle of and what to follow up. Prune what is done.', DEFAULT_LIMIT)
149
- ]
150
- }
151
-
152
- export function ensureProject(wiki: string, slug: string, name: string): Block {
153
- const safe = safeId(slug)
154
- return create(
155
- wiki,
156
- `projects/${safe}/brief`,
157
- `project/${safe}`,
158
- `What ${name.replaceAll('\n', ' ')} is, its stack and conventions, and its current state. Shared by everyone working in this project.`,
159
- PROJECT_LIMIT
160
- )
161
- }
162
-
163
- /**
164
- * The blocks in a caller's context, in the order they are shown. Missing default blocks are
165
- * created. `projectSlugs` are the projects whose briefs an agent carries (most recent first).
166
- */
167
- export function attached(root: string, caller: Caller, projectSlugs: readonly string[]): Block[] {
168
- const wiki = store.wikiDir(root)
169
- const projectName = (slug: string): string | null => projectsOrNone(root).find((project) => project.slug === slug)?.name ?? null
170
- if (caller.kind === 'user') return loadAll(wiki)
171
- const blocks: Block[] = []
172
- if (caller.kind === 'agent') {
173
- blocks.push(...ensureAgent(wiki, caller.id))
174
- blocks.push(ensureUser(wiki))
175
- blocks.push(...loadAll(wiki).filter((block) => block.scope === 'shared' && block.front.label !== 'user' && (!block.front.agents.length || block.front.agents.includes(caller.id))))
176
- // The most recently active projects are chosen, then listed in a fixed order: later activity
177
- // must not reshuffle the context a prompt cache was built on.
178
- for (const slug of projectSlugs.slice(0, AGENT_PROJECT_BRIEFS).sort(compareText)) {
179
- const name = projectName(slug)
180
- if (name !== null) blocks.push(ensureProject(wiki, slug, name))
181
- }
182
- } else {
183
- blocks.push(ensureUser(wiki))
184
- const name = caller.projectSlug ? projectName(caller.projectSlug) : null
185
- if (caller.projectSlug && name !== null) blocks.push(ensureProject(wiki, caller.projectSlug, name))
186
- }
187
- return blocks
188
- }
189
-
190
- /** Why `caller` may not change `block`, or `null` when it may; the text is what the model sees. */
191
- export function editRefusal(caller: Caller, block: Block): string | null {
192
- const label = block.front.label
193
- if (caller.kind === 'user') return null
194
- if (block.front.readOnly) return `The ${label} block is read-only; only the user can change it.`
195
- if (caller.kind === 'agent' && caller.vaultAccess === 'propose') return 'Memory changes are proposals in this session. Submit the block change with wiki_propose; the next memory merge applies it.'
196
- if (caller.kind === 'agent' && caller.vaultAccess === 'self' && (block.scope !== 'agent' || block.owner !== caller.id || !['persona', 'notes'].includes(label))) return 'This field is outside your writable memory. Submit a block change with wiki_propose for the curator to review.'
197
- if (caller.kind === 'agent') {
198
- if (block.scope === 'agent') return block.owner === caller.id ? null : 'That block belongs to another agent.'
199
- if (block.scope === 'shared') return !block.front.agents.length || block.front.agents.includes(caller.id) ? null : `The ${label} block is not attached to you.`
200
- return null
201
- }
202
- if (block.scope === 'project') return block.owner === caller.projectSlug ? null : "A session may edit only its own project's brief."
203
- return `A Harness session can read the ${label} block but cannot edit it. Report what you learned in your result instead.`
204
- }
205
-
206
- /**
207
- * Finds the block a caller means by `label`: `user`, `persona`, `notes`, `project/<slug>`
208
- * (or `project` for a session's own), a custom shared label, or a full block id.
209
- */
210
- export function resolve(root: string, caller: Caller, rawLabel: string): Block {
211
- const wiki = store.wikiDir(root)
212
- const label = rawLabel.trim().replace(/^\/+/, '')
213
- const unknown = () => invalid(`There is no memory block called ${label}. Use one of the labels shown in your memory blocks.`)
214
- const orUnknown = (id: string): Block => {
215
- try {
216
- return find(wiki, id)
217
- } catch {
218
- throw unknown()
219
- }
220
- }
221
- if (label.startsWith('blocks/')) return orUnknown(label)
222
- if (AGENT_LABELS.includes(label)) {
223
- if (caller.kind !== 'agent') throw invalid(`The ${label} block belongs to an agent, not to a session.`)
224
- return ensureAgent(wiki, caller.id).find((block) => block.front.label === label)!
225
- }
226
- if (label === 'project' || label.startsWith('project/')) {
227
- const slug = label.startsWith('project/') ? label.slice('project/'.length) : caller.kind === 'session' ? caller.projectSlug : null
228
- if (slug === null) throw invalid('Name the project: project/<slug>.')
229
- const project = listProjects(root).find((entry) => entry.slug === slug)
230
- if (!project) throw unknown()
231
- return ensureProject(wiki, project.slug, project.name)
232
- }
233
- if (label === 'user') return ensureUser(wiki)
234
- return orUnknown(`blocks/shared/${label}`)
235
- }
236
-
237
- export type Edit = { kind: 'append'; text: string } | { kind: 'replace'; old: string; new: string } | { kind: 'rethink'; text: string }
238
-
239
- /** The body after an edit. Pure, so the rules are testable. Throws the reason an edit is refused. */
240
- export function applyEdit(body: string, edit: Edit): string {
241
- if (edit.kind === 'append') {
242
- const text = edit.text.trim()
243
- if (!text) throw invalid('Nothing to append.')
244
- return body.trim() ? `${body.trimEnd()}\n${text}` : text
245
- }
246
- if (edit.kind === 'replace') {
247
- if (!edit.old) throw invalid('Give the exact passage to replace.')
248
- const count = body.split(edit.old).length - 1
249
- if (count === 0) throw invalid('That passage is not in the block. Copy it exactly as it appears.')
250
- if (count > 1) throw invalid(`That passage appears ${count} times. Include more of the surrounding text so it is unique.`)
251
- return body.replace(edit.old, () => edit.new).trim()
252
- }
253
- return edit.text.trim()
254
- }
255
-
256
- /** Why `body` cannot be stored in the block, or `null`. */
257
- export function check(front: BlockFront, body: string): string | null {
258
- const length = charCount(body)
259
- if (length > front.limit) {
260
- return `The ${front.label} block would be ${length} of ${front.limit} characters. Condense it with memory_rethink, or move detail to the Wiki with wiki_propose.`
261
- }
262
- if (looksSecret(body) || looksSecret(front.description)) return 'That text looks like it contains a credential, so it was not stored.'
263
- return null
264
- }
265
-
266
- /** Writes a block's new content with a revision before and after and an undo operation. */
267
- function write(wiki: string, block: Block, front: BlockFront, body: string, actor: string, reason: string): Block {
268
- let current = ''
269
- try {
270
- current = readFileSync(block.path, 'utf8')
271
- } catch {
272
- current = ''
273
- }
274
- const next = serializeBlock(front, body)
275
- if (next === current) return block
276
- // The undo target: the latest revision when it already holds the current text, else a new one.
277
- const latest = store.currentRevision(wiki, block.id)
278
- let before: string | null = null
279
- if (latest) {
280
- try {
281
- if (store.revisionText(wiki, block.id, latest) === current) before = latest
282
- } catch {
283
- before = null
284
- }
285
- }
286
- before ??= store.snapshot(wiki, block.id, current, actor, 'Before edit').id
287
- atomicWrite(block.path, next)
288
- const after = store.snapshot(wiki, block.id, next, actor, reason)
289
- noteChange({ wiki, id: block.id, path: block.path, text: next, actor, reason })
290
- const operation: Operation = {
291
- id: newId(),
292
- createdAt: now(),
293
- trigger: `${actor}: ${reason} (${front.label})`,
294
- pages: [{ pageId: block.id, path: block.path, beforeRevisionId: before, afterRevisionId: after.id }],
295
- undone: false
296
- }
297
- writeJson(join(wiki, 'operations', `${operation.id}.json`), operation)
298
- store.log(wiki, `- ${now()} · ${actor} · block · \`${block.id}\` ${reason} · revision \`${after.id}\``)
299
- return find(wiki, block.id)
300
- }
301
-
302
- /** A model's edit through `memory_append`, `memory_replace`, or `memory_rethink`. */
303
- export function edit(root: string, caller: Caller, label: string, change: Edit): Block {
304
- const wiki = store.wikiDir(root)
305
- const block = resolve(root, caller, label)
306
- const refusal = editRefusal(caller, block)
307
- if (refusal) throw invalid(refusal)
308
- const body = applyEdit(block.body, change)
309
- const problem = check(block.front, body)
310
- if (problem) throw invalid(problem)
311
- const reason = change.kind === 'append' ? 'appended' : change.kind === 'replace' ? 'replaced a passage' : 'rewrote'
312
- return write(wiki, block, { ...block.front, updatedAt: now() }, body, actorOf(caller), reason)
313
- }
314
-
315
- export interface BlockInput {
316
- /** An existing block id, or none to create a shared block from `label`. */
317
- id?: string | null
318
- label?: string | null
319
- body: string
320
- description: string
321
- limit: number
322
- readOnly?: boolean
323
- agents?: string[]
324
- }
325
-
326
- /** The user's edit in the Wiki, or a new custom shared block. */
327
- export function saveAsUser(wiki: string, input: BlockInput): Block {
328
- if (!Number.isInteger(input.limit) || input.limit <= 0 || input.limit > MAX_LIMIT) throw invalid(`A block's limit is between 1 and ${MAX_LIMIT} characters.`)
329
- let block: Block
330
- if (input.id) {
331
- block = find(wiki, input.id)
332
- } else {
333
- const label = store.slug(input.label ?? '')
334
- if (!(input.label ?? '').trim() || AGENT_LABELS.includes(label) || label === 'user' || label.startsWith('project')) {
335
- throw invalid('Give the block a label that is not user, persona, notes, or project.')
336
- }
337
- let exists = true
338
- try {
339
- find(wiki, `blocks/shared/${label}`)
340
- } catch {
341
- exists = false
342
- }
343
- if (exists) throw invalid(`A shared block called ${label} already exists.`)
344
- block = create(wiki, `shared/${label}`, label, input.description.trim(), input.limit)
345
- }
346
- const front: BlockFront = { ...block.front, description: input.description.trim(), limit: input.limit, readOnly: input.readOnly ?? false, updatedAt: now() }
347
- if (block.scope === 'shared' && front.label !== 'user') front.agents = input.agents ?? []
348
- const body = input.body.trim()
349
- const problem = check(front, body)
350
- if (problem) throw invalid(problem)
351
- return write(wiki, block, front, body, 'user', input.id ? 'edited' : 'created')
352
- }
353
-
354
- /** The most recent edit of a block that has not been undone yet. */
355
- function lastEdit(wiki: string, blockId: string): Operation | null {
356
- return (
357
- listOperations(wiki)
358
- .filter((operation) => !operation.undone && operation.pages.length === 1 && operation.pages[0].pageId === blockId)
359
- .sort((left, right) => compareText(left.createdAt, right.createdAt))
360
- .at(-1) ?? null
361
- )
362
- }
363
-
364
- export function undoLast(wiki: string, blockId: string): Block {
365
- const latest = lastEdit(wiki, blockId)
366
- if (!latest) throw invalid('There is no edit to undo.')
367
- undo(wiki, latest.id)
368
- return find(wiki, blockId)
369
- }
370
-
371
- /**
372
- * The memory-blocks section of a compiled prompt. `cli` names the `xagent-wiki` commands in
373
- * place of the tools, for sessions that have the command and not the tools.
374
- */
375
- export function render(blocks: readonly Block[], caller: Caller, cli: boolean): string {
376
- if (!blocks.length) return ''
377
- const how = cli
378
- ? '`xagent-wiki block append <label> <text>`, `block replace <label> --old <text> --new <text>`, or `block rethink <label> <text>`'
379
- : 'memory_append, memory_replace, or memory_rethink'
380
- return `## Memory blocks\n\nThese blocks are your memory that is always in view, and they persist across conversations. When you learn something worth keeping, edit a block you may change with ${how}. Keep each block within its limit.\n${renderItems(blocks, caller)}`
381
- }
382
-
383
- /** The blocks alone, each framed with its label, how full it is, and what belongs in it. */
384
- export function renderItems(blocks: readonly Block[], caller: Caller): string {
385
- let text = ''
386
- for (const block of blocks) {
387
- const editable = editRefusal(caller, block) === null
388
- text += `\n<block label="${block.front.label}" chars="${charCount(block.body)}/${block.front.limit}"${editable ? '' : ' read-only="true"'}>\n${block.front.description}\n---\n${block.body.trim() || '(empty)'}\n</block>\n`
389
- }
390
- return text
391
- }
392
-
393
- export type MemoryBlock = Omit<BlockFront, 'agents'> & { agents?: string[]; id: string; scope: BlockScope; owner: string | null; body: string; path: string; revisionId: string | null; canUndo: boolean }
394
-
395
- export function response(wiki: string, block: Block): MemoryBlock {
396
- const { agents, ...front } = block.front
397
- return {
398
- id: block.id,
399
- scope: block.scope,
400
- owner: block.owner,
401
- ...front,
402
- ...(agents.length ? { agents } : {}),
403
- body: block.body,
404
- path: block.path,
405
- revisionId: store.currentRevision(wiki, block.id),
406
- canUndo: lastEdit(wiki, block.id) !== null
407
- }
408
- }
@@ -1,69 +0,0 @@
1
- // Who is calling the memory and Wiki API. The embedding application supplies this when a
2
- // session starts; the harness never looks an agent up by itself.
3
- import { findProject, projectsOrNone, type Project } from '../core/projects'
4
- import { invalid } from '../core/errors'
5
- import type { HarnessEnv } from '../core/env'
6
- import * as blocks from './blocks'
7
-
8
- /**
9
- * The current project only orders results and picks a default namespace for proposals; it
10
- * never limits what can be read.
11
- */
12
- export interface CallerContext {
13
- /** Opaque, host-owned policy data frozen when this session starts. The harness only forwards it to the embedding app's tool policy. */
14
- policyContext?: unknown
15
- /**
16
- * Trusted, frozen vault mutation capability supplied by an embedding host. `self` writes its own
17
- * persona and notes directly and proposes the rest; `propose` writes nothing directly; `curator`
18
- * applies proposals.
19
- */
20
- vaultAccess?: 'self' | 'propose' | 'curator'
21
- maintenanceJobId?: string | null
22
- maintenanceJobIds?: string[]
23
- runId?: string | null
24
- sessionId?: string | null
25
- projectId?: string | null
26
- /** The agent calling; it decides which memory blocks are attached and editable. */
27
- agentId?: string | null
28
- /** Agents whose conversations this caller may not recall (opaque ids from the embedding host). */
29
- recallExcludeAgents?: string[] | null
30
- /** The agent's name, recorded on the revisions it makes. */
31
- agentName?: string | null
32
- /** Projects whose briefs the agent carries, most recently active first. */
33
- briefProjectIds?: string[] | null
34
- /** `Chat` for an agent's own conversation, `Code` for a session in a project. */
35
- actor?: string | null
36
- /** `cli` when the caller reaches the Wiki through the `xagent-wiki` command. */
37
- surface?: string | null
38
- }
39
-
40
- export const isSession = (context: CallerContext): boolean => context.actor === 'Code'
41
- export const actorOf = (context: CallerContext): string => (isSession(context) ? 'Code' : 'Chat')
42
- export const usesCli = (context: CallerContext): boolean => context.surface === 'cli'
43
-
44
- export function projectOf(root: string, context: CallerContext): Project | null {
45
- if (!context.projectId) return null
46
- try {
47
- return findProject(root, context.projectId)
48
- } catch {
49
- return null
50
- }
51
- }
52
-
53
- /** Who is asking, for block attachment and permissions: an agent, or a session. */
54
- export function blockCaller(env: HarnessEnv, context: CallerContext): blocks.Caller {
55
- if (isSession(context)) return { kind: 'session', projectSlug: projectOf(env.root, context)?.slug ?? null }
56
- if (!context.agentId) throw invalid('Memory blocks need an agent or a Harness session.')
57
- return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId, vaultAccess: context.vaultAccess === 'propose' ? 'propose' : context.vaultAccess ? 'self' : undefined }
58
- }
59
-
60
- /** The blocks in the caller's context: an agent also carries the briefs of its active projects. */
61
- export function callerBlocks(env: HarnessEnv, context: CallerContext): { caller: blocks.Caller; attached: blocks.Block[] } {
62
- const caller = blockCaller(env, context)
63
- let slugs: string[] = []
64
- if (caller.kind === 'agent' && context.briefProjectIds?.length) {
65
- const projects = projectsOrNone(env.root)
66
- slugs = context.briefProjectIds.flatMap((id) => projects.find((project) => project.id === id)?.slug ?? [])
67
- }
68
- return { caller, attached: blocks.attached(env.root, caller, slugs) }
69
- }