@xingular/harness 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +1 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
package/src/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
|
-
})
|