@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.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  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/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /package/src/{memory → core}/secrets.ts +0 -0
@@ -1,584 +0,0 @@
1
- // Validated Wiki updates.
2
- //
3
- // Agents and the sleep-time maintainer only *propose* pages, facts, and block rewrites. This
4
- // module validates every proposal (type, links, evidence, confidence, secrets, concurrent
5
- // edits), applies the valid ones as one atomic operation with revisions, and sends the rest to
6
- // the Inbox, where the user can accept them. Proposals to change skills, prompts, or rules
7
- // always wait in the Inbox.
8
- import { createHash } from 'node:crypto'
9
- import { mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
10
- import { dirname, join, relative } from 'node:path'
11
- import { z } from 'zod'
12
-
13
- import { noteChange } from '../core/changes'
14
- import { HarnessError, invalid, notFound } from '../core/errors'
15
- import { atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
16
- import { charCount, compareText, takeChars } from '../core/text'
17
- import { acceptPluginProposal, pluginProposalSchema } from '../plugins/proposal'
18
- import * as registry from '../skills/registry'
19
- import * as blocks from './blocks'
20
- import * as facts from './facts'
21
- import { inbox, operationsDir, type InboxItem, type Operation, type OperationPage } from './operations'
22
- import { looksSecret } from './secrets'
23
- import * as store from './store'
24
- import { LINK_TYPES, PAGE_TYPES, type WikiLink } from './store'
25
-
26
- // ---- Proposals ------------------------------------------------------------------------------
27
-
28
- const optionalText = z.string().nullish().transform((value) => value ?? null)
29
-
30
- const sourceRefSchema = z.object({
31
- kind: z.string(),
32
- ref: z.string(),
33
- /** A passage quoted from the evidence. A run or source reference counts only when this passage is found in it. */
34
- excerpt: z.string().nullish().transform((value) => value ?? undefined)
35
- })
36
- export type SourceRef = z.infer<typeof sourceRefSchema>
37
-
38
- const linkProposalSchema = z.object({ toId: z.string(), type: z.string() })
39
- export type LinkProposal = z.infer<typeof linkProposalSchema>
40
-
41
- export const pageProposalSchema = z.object({
42
- id: optionalText,
43
- title: z.string(),
44
- type: z.string(),
45
- body: z.string(),
46
- /** Where a new page is filed (`/library`, `/projects/<slug>`); the job's namespace by default. */
47
- namespace: optionalText,
48
- confidence: optionalText,
49
- sourceRefs: z.array(sourceRefSchema).default([]),
50
- links: z.array(linkProposalSchema).default([])
51
- })
52
- export type PageProposal = z.infer<typeof pageProposalSchema>
53
-
54
- export const memoryProposalSchema = z.object({
55
- id: optionalText,
56
- title: z.string(),
57
- kind: z.string(),
58
- body: z.string(),
59
- /** `/` for everything, or a project namespace. The job's project namespace by default. */
60
- namespace: optionalText,
61
- confidence: optionalText,
62
- supersedes: optionalText,
63
- volatile: z.boolean().nullish().transform((value) => value ?? null),
64
- sourceRefs: z.array(sourceRefSchema).default([]),
65
- links: z.array(linkProposalSchema).default([])
66
- })
67
- export type MemoryProposal = z.infer<typeof memoryProposalSchema>
68
-
69
- /** A rewritten memory block from sleep-time consolidation. */
70
- const blockProposalSchema = z.object({ block: z.string(), body: z.string(), sourceRefs: z.array(sourceRefSchema).default([]) })
71
- export type BlockProposal = z.infer<typeof blockProposalSchema>
72
-
73
- const claimFlagSchema = z.object({ claim: z.string(), kind: z.string(), reason: z.string() })
74
- export type ClaimFlag = z.infer<typeof claimFlagSchema>
75
-
76
- export const proposalSetSchema = z.object({
77
- pages: z.array(pageProposalSchema).default([]),
78
- memories: z.array(memoryProposalSchema).default([]),
79
- blocks: z.array(blockProposalSchema).default([]),
80
- flags: z.array(claimFlagSchema).default([])
81
- })
82
- export type ProposalSet = z.infer<typeof proposalSetSchema>
83
-
84
- export function parseProposalSet(value: unknown): ProposalSet {
85
- const parsed = proposalSetSchema.safeParse(value ?? {})
86
- if (!parsed.success) throw new HarnessError('invalid_data', `Stored data could not be read: ${parsed.error.issues[0]?.message ?? 'invalid proposals'}`)
87
- return parsed.data
88
- }
89
-
90
- /** The text proposals are checked against, and the blocks a job may rewrite. */
91
- export interface Evidence {
92
- /** The triggering run's request, assistant messages, and tool summaries. */
93
- runText?: string | null
94
- /** The imported source's text. */
95
- sourceText?: string | null
96
- /** Ids of the blocks attached to the run. */
97
- blocks?: string[]
98
- runs?: Record<string, string>
99
- sources?: Record<string, string>
100
- }
101
-
102
- const MIN_EXCERPT = 12
103
-
104
- function squash(text: string): string {
105
- return text.split(/\s+/).filter(Boolean).join(' ').toLowerCase()
106
- }
107
-
108
- /**
109
- * Whether `excerpt` occurs in `text`, ignoring case and runs of whitespace. Short excerpts
110
- * prove nothing, so they never count.
111
- */
112
- export function quotedIn(text: string | null | undefined, excerpt: string | null | undefined): boolean {
113
- if (typeof text !== 'string' || typeof excerpt !== 'string') return false
114
- const wanted = squash(excerpt)
115
- return charCount(wanted) >= MIN_EXCERPT && squash(text).includes(wanted)
116
- }
117
-
118
- /** What caused the change; decides which evidence references are valid and who the actor is. */
119
- export type Trigger =
120
- | { kind: 'ingest'; sourceId: string; sourceName: string }
121
- | { kind: 'run'; runId: string }
122
- /** An agent's `wiki_propose` during a run. */
123
- | { kind: 'agent'; runId: string | null; actor: string }
124
- /** The user accepted an Inbox item. */
125
- | { kind: 'user'; itemId: string }
126
- /** Idle consolidation of memory blocks that are close to their limit. */
127
- | { kind: 'consolidate' }
128
-
129
- export function describeTrigger(trigger: Trigger): string {
130
- switch (trigger.kind) {
131
- case 'ingest':
132
- return `ingest of ${trigger.sourceName} (${trigger.sourceId})`
133
- case 'run':
134
- return `run ${trigger.runId}`
135
- case 'agent':
136
- return trigger.runId ? `${trigger.actor} agent in run ${trigger.runId}` : `${trigger.actor} agent`
137
- case 'user':
138
- return `Inbox item ${trigger.itemId.slice(0, 8)}`
139
- case 'consolidate':
140
- return 'consolidation of full memory blocks'
141
- }
142
- }
143
-
144
- function triggerActor(trigger: Trigger): string {
145
- return trigger.kind === 'agent' ? trigger.actor : trigger.kind === 'user' ? 'user' : 'Sleep-time'
146
- }
147
-
148
- function triggerRunId(trigger: Trigger): string | null {
149
- return trigger.kind === 'run' ? trigger.runId : trigger.kind === 'agent' ? trigger.runId : null
150
- }
151
-
152
- // ---- Applying ---------------------------------------------------------------------------------
153
-
154
- function fileHash(path: string): string | null {
155
- try {
156
- return createHash('sha256').update(readFileSync(path)).digest('hex')
157
- } catch {
158
- return null
159
- }
160
- }
161
-
162
- function readOrNull(path: string): string | null {
163
- try {
164
- return readFileSync(path, 'utf8')
165
- } catch {
166
- return null
167
- }
168
- }
169
-
170
- /** Captures page, fact, and block file hashes when a job starts, to detect edits made while it ran. */
171
- export function captureHashes(wiki: string): Map<string, string> {
172
- const hashes = new Map<string, string>()
173
- const add = (id: string, path: string) => {
174
- const hash = fileHash(path)
175
- if (hash) hashes.set(id, hash)
176
- }
177
- for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
178
- for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
179
- for (const block of blocks.loadAll(wiki)) add(block.id, block.path)
180
- for (const item of registry.list(dirname(wiki))) add(item.id, item.path)
181
- return hashes
182
- }
183
-
184
- /**
185
- * Rewrites the generated indexes: the root `index.md` lists the namespaces, and each namespace
186
- * has its own `index.md` listing its pages with one line each. Any page is two steps away.
187
- */
188
- export function writeIndex(wiki: string): void {
189
- const pages = store.loadPages(wiki)
190
- const memories = facts.loadMemories(wiki)
191
- const namespaces = [...new Set(pages.map((page) => page.location.namespace).filter((namespace) => namespace !== store.ROOT_NAMESPACE))].sort(compareText)
192
- let root = '# Wiki index\n\nEach namespace has its own index of pages.\n\n'
193
- if (!namespaces.length) root += 'No pages yet.\n'
194
- for (const namespace of namespaces) {
195
- const own = pages.filter((page) => page.location.namespace === namespace).sort((left, right) => compareText(left.location.path, right.location.path))
196
- const folder = namespace.replace(/^\/+/, '')
197
- root += `- [${namespace}](${folder}/index.md) — ${own.length} page${own.length === 1 ? '' : 's'}\n`
198
- let text = `# Index of ${namespace}\n\n`
199
- for (const page of own) {
200
- const inside = relative(join(wiki, folder), page.path)
201
- text += `- [${page.frontmatter.title}](${inside.startsWith('..') ? page.path : inside}) (${page.frontmatter.type}, \`${page.frontmatter.id}\`) — ${store.firstParagraph(page.body)}\n`
202
- }
203
- atomicWrite(join(wiki, folder, 'index.md'), text)
204
- }
205
- const active = memories.filter((memory) => memory.front.status === 'active')
206
- if (active.length) {
207
- root += '\n## Facts\n\n'
208
- for (const memory of active) root += `- [${memory.front.title}](facts/${memory.front.id}.md) (${memory.front.kind}, ${memory.front.namespace}${memory.front.volatile ? ', volatile' : ''})\n`
209
- }
210
- atomicWrite(join(wiki, 'index.md'), root)
211
- }
212
-
213
- export interface ApplyOutcome {
214
- operation: Operation | null
215
- parked: number
216
- flagged: number
217
- /** One line per proposal: what happened to it. */
218
- notes: string[]
219
- }
220
-
221
- interface Write {
222
- entryId: string
223
- path: string
224
- oldText: string | null
225
- newText: string
226
- }
227
-
228
- /** Validates proposals and applies the valid ones as one operation. Pure file logic. */
229
- export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence, operationKey?: string, appliedBy?: string): ApplyOutcome {
230
- if (operationKey) {
231
- const committed = readJson<Operation>(join(operationsDir(wiki), `${safeId(operationKey)}.json`))
232
- if (committed) return { operation: committed, parked: 0, flagged: 0, notes: ['This request was already applied.'] }
233
- }
234
- const existing = store.loadPages(wiki)
235
- const existingMemories = facts.loadMemories(wiki)
236
- const root = dirname(wiki)
237
- const sourceIds = new Set(store.listSources(wiki).map((source) => source.id))
238
- const knownIds = new Set<string>([
239
- ...existing.map((page) => page.frontmatter.id),
240
- ...existingMemories.map((memory) => memory.front.id),
241
- ...registry.list(root).map((item) => item.id),
242
- ...sourceIds
243
- ])
244
- const proposedIds = new Set<string>([
245
- ...proposals.pages.map((page) => store.slug(page.id ?? page.title)),
246
- ...proposals.memories.map((memory) => store.slug(memory.id ?? memory.title))
247
- ])
248
- const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
249
- const writes: Write[] = []
250
- const actor = appliedBy ?? triggerActor(trigger)
251
- const described = describeTrigger(trigger)
252
- const fromUser = trigger.kind === 'user'
253
-
254
- // Sends one proposal to the Inbox with its payload, so the user can accept it later.
255
- const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
256
- outcome.parked += 1
257
- outcome.notes.push(`${label} went to the Inbox: ${reason}`)
258
- inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, operationKey && payload && typeof payload === 'object' ? { ...payload, curationJobId: operationKey } : payload)
259
- }
260
-
261
- // Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
262
- // or an imported source counts only with an excerpt found in its text; a bare reference to
263
- // the caller's own run proves nothing.
264
- const evidenceFor = (refs: readonly SourceRef[]): string[] => {
265
- const found: string[] = []
266
- for (const reference of refs) {
267
- let id: string | null = null
268
- if (reference.kind === 'source') {
269
- const source = evidence.sources?.[reference.ref] ?? (trigger.kind === 'ingest' && trigger.sourceId === reference.ref ? evidence.sourceText : null)
270
- if (sourceIds.has(reference.ref) && (operationKey ? quotedIn(source, reference.excerpt) : (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt)))) id = reference.ref
271
- } else if (reference.kind === 'run') {
272
- if (quotedIn(evidence.runs?.[reference.ref] ?? (triggerRunId(trigger) === reference.ref ? evidence.runText : null), reference.excerpt)) id = `run:${reference.ref}`
273
- } else if (reference.kind === 'url') {
274
- if (!operationKey && (reference.ref.startsWith('https://') || reference.ref.startsWith('http://'))) id = `url:${reference.ref}`
275
- } else if (reference.kind === 'user') {
276
- if (fromUser) id = 'user:accepted'
277
- }
278
- if (id && !found.includes(id)) found.push(id)
279
- }
280
- return found
281
- }
282
- const filterLinks = (links: readonly LinkProposal[], ownId: string): WikiLink[] =>
283
- links
284
- .filter((link) => (LINK_TYPES as readonly string[]).includes(link.type))
285
- .filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
286
- .filter((link) => link.toId !== ownId)
287
- .map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
288
- const changedSince = (id: string, path: string): boolean => (captured.has(id) || Boolean(operationKey)) && captured.get(id) !== fileHash(path)
289
-
290
- for (const proposal of proposals.pages) {
291
- const pageId = store.slug(proposal.id ?? proposal.title)
292
- const current = existing.find((page) => page.frontmatter.id === pageId)
293
- const namespace = current?.location.namespace ?? proposal.namespace ?? defaultNamespace
294
- const payload = { kind: 'page', namespace, proposal, trigger: described }
295
- const label = `Page "${proposal.title}"`
296
- const blocked = `Proposed page "${proposal.title}" was not applied`
297
-
298
- if (!(PAGE_TYPES as readonly string[]).includes(proposal.type) || !proposal.title.trim() || !proposal.body.trim()) {
299
- park('blocked', blocked, label, 'The proposal was incomplete (type, title, or body).', proposal.body, pageId, payload)
300
- continue
301
- }
302
- if (looksSecret(proposal.body) || looksSecret(proposal.title)) {
303
- // Never stored, not even in the Inbox payload.
304
- outcome.parked += 1
305
- outcome.notes.push(`Page "${proposal.title}" was rejected: it contains text that looks like a credential.`)
306
- inbox(wiki, 'blocked', blocked, 'The proposal contained text that looks like a credential, so it was not written.', [pageId])
307
- continue
308
- }
309
- let targetDir: string | null = null
310
- if (!current) {
311
- try {
312
- targetDir = store.namespaceDir(wiki, namespace)
313
- } catch (error) {
314
- park('blocked', blocked, label, error instanceof Error ? error.message : String(error), proposal.body, pageId, payload)
315
- continue
316
- }
317
- }
318
- // Consolidating a run never creates a page: one run is an episode, not an entity.
319
- if (!current && (trigger.kind === 'run' || trigger.kind === 'consolidate')) {
320
- park('unresolved', `"${proposal.title}" would be a new page`, label, 'Sleep-time consolidation updates existing pages only. Accept this to create the page.', proposal.body, pageId, payload)
321
- continue
322
- }
323
- const found = evidenceFor(proposal.sourceRefs)
324
- if (!found.length) {
325
- park('unresolved', `"${proposal.title}" has no verifiable evidence`, label, 'The proposal quotes nothing the host could find in its source or run, so it was not written.', proposal.body, pageId, payload)
326
- continue
327
- }
328
- if (proposal.confidence === 'low' && !fromUser) {
329
- park('unresolved', `"${proposal.title}" is low confidence`, label, 'Low-confidence proposals wait for review.', proposal.body, pageId, payload)
330
- continue
331
- }
332
- // An entry edited while the job ran is never overwritten.
333
- if (current && changedSince(pageId, current.path)) {
334
- park('blocked', blocked, label, 'The page changed while the update was running, so the proposal was parked instead of overwriting the edit.', proposal.body, pageId, payload)
335
- continue
336
- }
337
-
338
- const links = filterLinks(proposal.links, pageId)
339
- const contradicted = links.filter((link) => link.type === 'contradicts')
340
- if (contradicted.length) {
341
- inbox(wiki, 'unresolved', `"${proposal.title}" contradicts another entry`, 'The page was written with an explicit contradicts link. Review both claims and their sources.', [pageId, ...contradicted.map((link) => link.toId)])
342
- outcome.flagged += 1
343
- }
344
- const sourceList = current ? [...current.frontmatter.sourceIds] : []
345
- for (const id of found) if (!sourceList.includes(id)) sourceList.push(id)
346
- const front = { id: pageId, title: proposal.title.trim(), type: proposal.type, updatedAt: now(), sourceIds: sourceList, links }
347
- const path = current?.path ?? join(targetDir!, `${pageId}.md`)
348
- outcome.notes.push(`Page "${proposal.title}" (${pageId}) ${current ? 'updated' : 'created'} in ${namespace}.`)
349
- writes.push({ entryId: pageId, oldText: current ? readOrNull(current.path) : null, newText: store.serializePage(front, proposal.body), path })
350
- }
351
-
352
- const projectDefault = store.projectNamespaceOf(defaultNamespace) ?? store.ROOT_NAMESPACE
353
- for (const proposal of proposals.memories) {
354
- const memoryId = store.slug(proposal.id ?? proposal.title)
355
- const current = existingMemories.find((memory) => memory.front.id === memoryId)
356
- const payload = { kind: 'memory', proposal, trigger: described }
357
- const label = `Fact "${proposal.title}"`
358
- const title = `Fact "${proposal.title}" needs review`
359
- if (looksSecret(proposal.body) || looksSecret(proposal.title)) {
360
- outcome.parked += 1
361
- outcome.notes.push(`Fact "${proposal.title}" was rejected: it contains text that looks like a credential.`)
362
- inbox(wiki, 'blocked', `Fact "${proposal.title}" was not saved`, 'The proposal contained text that looks like a credential, so it was not written.', [])
363
- continue
364
- }
365
- const front: facts.MemoryFront = {
366
- id: memoryId,
367
- title: proposal.title.trim(),
368
- kind: proposal.kind,
369
- namespace: current?.front.namespace ?? proposal.namespace ?? projectDefault,
370
- sourceIds: current ? [...current.front.sourceIds] : [],
371
- confidence: proposal.confidence ?? 'medium',
372
- status: 'active',
373
- supersedes: proposal.supersedes && existingMemories.some((memory) => memory.front.id === proposal.supersedes) ? proposal.supersedes : null,
374
- links: filterLinks(proposal.links, memoryId),
375
- createdAt: current?.front.createdAt || now(),
376
- updatedAt: now(),
377
- observedAt: now(),
378
- volatile: proposal.volatile ?? current?.front.volatile ?? false
379
- }
380
- const problem = facts.validate(front, proposal.body)
381
- if (problem) {
382
- park('blocked', title, label, problem, proposal.body, memoryId, payload)
383
- continue
384
- }
385
- const found = evidenceFor(proposal.sourceRefs)
386
- if (!found.length) {
387
- park('unresolved', title, label, 'The fact quotes nothing the host could find in its source or run, so it was not saved.', proposal.body, memoryId, payload)
388
- continue
389
- }
390
- if (front.confidence === 'low' && !fromUser) {
391
- park('unresolved', title, label, 'Low-confidence facts wait for review.', proposal.body, memoryId, payload)
392
- continue
393
- }
394
- if (current && changedSince(memoryId, current.path)) {
395
- park('blocked', title, label, 'The fact changed while the update was running, so the proposal was parked.', proposal.body, memoryId, payload)
396
- continue
397
- }
398
- for (const id of found) if (!front.sourceIds.includes(id)) front.sourceIds.push(id)
399
- // A superseding fact retires the one it replaces in the same operation.
400
- const old = front.supersedes ? existingMemories.find((memory) => memory.front.id === front.supersedes) : undefined
401
- if (old) {
402
- writes.push({ entryId: old.front.id, path: old.path, oldText: readOrNull(old.path), newText: facts.serialize({ ...old.front, status: 'superseded', updatedAt: now() }, old.body) })
403
- }
404
- const path = current?.path ?? facts.pathFor(wiki, memoryId)
405
- outcome.notes.push(`Fact "${proposal.title}" (${memoryId}) ${current ? 'updated' : 'saved'}.`)
406
- writes.push({ entryId: memoryId, oldText: current ? readOrNull(current.path) : null, newText: facts.serialize(front, proposal.body), path })
407
- }
408
-
409
- const attachedBlocks = evidence.blocks ?? []
410
- for (const proposal of proposals.blocks) {
411
- const refuse = (reason: string): void => {
412
- outcome.parked += 1
413
- outcome.notes.push(`Block ${proposal.block} was not rewritten: ${reason}`)
414
- inbox(wiki, 'unresolved', `A rewrite of ${proposal.block} was not applied`, `${reason}\n\n${takeChars(proposal.body, 600)}`, [proposal.block])
415
- }
416
- let block: blocks.Block
417
- try {
418
- block = blocks.find(wiki, proposal.block)
419
- } catch {
420
- refuse('there is no such memory block.')
421
- continue
422
- }
423
- const body = proposal.body.trim()
424
- if (!attachedBlocks.includes(block.id) || block.front.readOnly) {
425
- refuse('the block is not attached to this run, or it is read-only.')
426
- continue
427
- }
428
- const problem = blocks.check(block.front, body)
429
- if (problem) {
430
- refuse(problem)
431
- continue
432
- }
433
- // A full block is only condensed: the rewrite must be shorter and add no evidence.
434
- const grounded = trigger.kind === 'consolidate' ? body !== '' && charCount(body) < charCount(block.body) : evidenceFor(proposal.sourceRefs).length > 0
435
- if (!grounded) {
436
- refuse(trigger.kind === 'consolidate' ? 'a consolidated block must be shorter than it was.' : 'it quotes nothing the host could find in the run.')
437
- continue
438
- }
439
- if (changedSince(block.id, block.path)) {
440
- refuse('the block changed while the update was running.')
441
- continue
442
- }
443
- if (body === block.body.trim()) continue
444
- outcome.notes.push(`Block ${block.front.label} rewritten.`)
445
- writes.push({ entryId: block.id, oldText: readOrNull(block.path), newText: blocks.serializeBlock({ ...block.front, updatedAt: now() }, body), path: block.path })
446
- }
447
-
448
- for (const flag of proposals.flags) {
449
- inbox(wiki, flag.kind === 'contradiction' ? 'blocked' : 'unresolved', `Flagged claim: ${takeChars(flag.claim, 120)}`, `${flag.reason} (${flag.kind})`, [])
450
- outcome.flagged += 1
451
- }
452
-
453
- if (!writes.length) {
454
- store.log(wiki, `- ${now()} · ${actor} · ${described} · nothing changed · ${outcome.parked} parked · ${outcome.flagged} flagged`)
455
- return outcome
456
- }
457
-
458
- // Apply everything or nothing: on any failure, restore what was already written.
459
- writes.forEach((write, index) => {
460
- try {
461
- atomicWrite(write.path, write.newText)
462
- } catch (error) {
463
- for (const done of writes.slice(0, index)) {
464
- try {
465
- if (done.oldText === null) rmSync(done.path, { force: true })
466
- else atomicWrite(done.path, done.oldText)
467
- } catch {
468
- // Best effort: the original failure is what the caller needs to see.
469
- }
470
- }
471
- throw error
472
- }
473
- })
474
-
475
- const operationId = operationKey ?? newId()
476
- const short = operationId.slice(0, 8)
477
- const pages: OperationPage[] = writes.map((write) => ({
478
- pageId: write.entryId,
479
- path: write.path,
480
- beforeRevisionId: write.oldText === null ? null : store.snapshot(wiki, write.entryId, write.oldText, actor, `Before update ${short}`).id,
481
- afterRevisionId: store.snapshot(wiki, write.entryId, write.newText, actor, `Update ${short} (${described})`).id
482
- }))
483
- const operation: Operation = { id: operationId, createdAt: now(), trigger: described, pages, undone: false }
484
- writeJson(join(operationsDir(wiki), `${operationId}.json`), operation)
485
- for (const write of writes) noteChange({ wiki, id: write.entryId, path: write.path, text: write.newText, actor, reason: described })
486
- writeIndex(wiki)
487
- store.log(
488
- wiki,
489
- `- ${now()} · ${actor} · ${described} · entries ${pages.map((page) => `\`${page.pageId}\``).join(', ')} · revisions ${pages.map((page) => `\`${page.afterRevisionId.slice(0, 8)}\``).join(', ')} · op \`${operationId}\` · ${outcome.parked} parked · ${outcome.flagged} flagged`
490
- )
491
- inbox(
492
- wiki,
493
- 'applied',
494
- `Updated ${pages.length} entr${pages.length === 1 ? 'y' : 'ies'}`,
495
- `From ${described}. Undo restores the previous content as new revisions.`,
496
- pages.map((page) => page.pageId),
497
- operationId
498
- )
499
- outcome.operation = operation
500
- return outcome
501
- }
502
-
503
- // ---- Registry proposals (skills, prompts, rules) -------------------------------------------
504
-
505
- export const registryProposalSchema = z.object({
506
- /** `skill`, `prompt`, or `rule`. */
507
- kind: z.string(),
508
- name: z.string(),
509
- description: z.string().default(''),
510
- body: z.string(),
511
- reason: z.string().default('')
512
- })
513
- export type RegistryProposal = z.infer<typeof registryProposalSchema>
514
-
515
- /**
516
- * Skills, prompts, and rules change how every agent behaves, so proposals never apply on
517
- * their own: they wait in the Inbox until the user accepts them.
518
- */
519
- export function parkRegistryProposal(wiki: string, trigger: Trigger, proposal: RegistryProposal): string {
520
- if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim()) {
521
- throw invalid('A skill, prompt, or rule proposal needs a kind, name, and body.')
522
- }
523
- if (looksSecret(proposal.body)) throw invalid('The proposal contains text that looks like a credential.')
524
- const id = `${proposal.kind}s/${store.slug(proposal.name)}`
525
- inbox(
526
- wiki,
527
- 'proposed',
528
- `Proposed ${proposal.kind} "${proposal.name.trim()}"`,
529
- `${proposal.reason.trim() || 'Review the change before it applies to every agent.'}\n\nFrom ${describeTrigger(trigger)}.`,
530
- [id],
531
- null,
532
- { kind: proposal.kind, proposal, trigger: describeTrigger(trigger) }
533
- )
534
- store.log(wiki, `- ${now()} · ${triggerActor(trigger)} · proposed \`${id}\` · waiting in Inbox`)
535
- return id
536
- }
537
-
538
- function parsed<T>(schema: z.ZodType<T>, value: unknown): T {
539
- const result = schema.safeParse(value)
540
- if (!result.success) throw new HarnessError('invalid_data', `Stored data could not be read: ${result.error.issues[0]?.message ?? 'invalid proposal'}`)
541
- return result.data
542
- }
543
-
544
- /**
545
- * Accepts a parked proposal: pages and facts go through `apply` with the user as the
546
- * authority; registry items are written with a revision before and after.
547
- */
548
- export function accept(root: string, itemId: string): string {
549
- const wiki = store.wikiDir(root)
550
- const path = join(wiki, 'inbox', `${safeId(itemId)}.json`)
551
- const item = readJson<InboxItem>(path)
552
- if (!item) throw notFound('Inbox item')
553
- const payload = item.payload as { kind?: unknown; proposal?: unknown; namespace?: unknown } | undefined | null
554
- if (!payload) throw invalid('This Inbox item has nothing to accept.')
555
- const kind = typeof payload.kind === 'string' ? payload.kind : ''
556
- const trigger: Trigger = { kind: 'user', itemId: item.id }
557
- const userRef: SourceRef = { kind: 'user', ref: 'accepted', excerpt: undefined }
558
- let message: string
559
- if (kind === 'page' || kind === 'memory') {
560
- const set: ProposalSet = { pages: [], memories: [], blocks: [], flags: [] }
561
- if (kind === 'page') {
562
- const proposal = parsed(pageProposalSchema, payload.proposal)
563
- set.pages.push({ ...proposal, sourceRefs: [...proposal.sourceRefs, userRef] })
564
- } else {
565
- const proposal = parsed(memoryProposalSchema, payload.proposal)
566
- set.memories.push({ ...proposal, sourceRefs: [...proposal.sourceRefs, userRef] })
567
- }
568
- const namespace = typeof payload.namespace === 'string' ? payload.namespace : store.LIBRARY_NAMESPACE
569
- const outcome = apply(wiki, namespace, trigger, set, new Map(), {})
570
- if (!outcome.operation) throw invalid(outcome.notes.join(' '))
571
- message = outcome.notes.join(' ')
572
- } else if (kind === 'skill' || kind === 'prompt' || kind === 'rule') {
573
- const proposal = parsed(registryProposalSchema, payload.proposal)
574
- message = `Saved ${registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Accepted ${describeTrigger(trigger)}`)}.`
575
- } else if (kind === 'plugin') {
576
- message = acceptPluginProposal(root, parsed(pluginProposalSchema, payload.proposal))
577
- } else {
578
- throw invalid('Unknown proposal kind.')
579
- }
580
- const target = join(wiki, 'inbox', 'dismissed')
581
- mkdirSync(target, { recursive: true })
582
- renameSync(path, join(target, `${item.id}.json`))
583
- return message
584
- }
@@ -1,77 +0,0 @@
1
- // Selected references: Wiki pages and skills a person attached to one request. Each is pinned to
2
- // a revision when it is selected, so what the model reads cannot change under it, and the rest
3
- // of a long reference can be retrieved only at that revision.
4
- import { readFileSync } from 'node:fs'
5
- import { join } from 'node:path'
6
-
7
- import { invalid, notFound } from '../core/errors'
8
- import * as frontmatter from '../core/frontmatter'
9
- import { readJson, safeId } from '../core/fs'
10
- import { charCount } from '../core/text'
11
- import type { Source } from './runContext'
12
- import * as store from './store'
13
- import { entryFile, entryId, resolveEntry } from './wiki'
14
-
15
- export interface PinnedReference {
16
- kind: 'page' | 'skill'
17
- id: string
18
- label: string
19
- revision: string
20
- }
21
-
22
- /**
23
- * Pins the entry's current content. The latest revision is reused when it already holds that
24
- * content; otherwise a new one is written. The entry must be a page or a saved skill.
25
- */
26
- export function pin(root: string, kind: 'page' | 'skill', id: string): string {
27
- return pinAll(root, [{ kind, id }])[0]
28
- }
29
-
30
- /** Pins several entries, resolving every one before any snapshot is written. */
31
- export function pinAll(root: string, items: readonly { kind: 'page' | 'skill'; id: string }[]): string[] {
32
- const wiki = store.wikiDir(root)
33
- const contents = items.map(({ kind, id }) => {
34
- const entry = resolveEntry(root, id)
35
- const valid = kind === 'page' ? entry.kind === 'page' : entry.kind === 'registry' && entry.item.kind === 'skills'
36
- if (!valid || entryId(entry) !== id) throw invalid('The selected reference is not a Wiki page or saved skill.')
37
- return readFileSync(entryFile(entry), 'utf8')
38
- })
39
- return items.map(({ id }, index) => {
40
- const current = store.currentRevision(wiki, id)
41
- if (current) {
42
- try {
43
- if (store.revisionText(wiki, id, current) === contents[index]) return current
44
- } catch {
45
- // The manifest names a revision whose text is gone; pin a new one.
46
- }
47
- }
48
- return store.snapshot(wiki, id, contents[index], 'user', 'Pinned chat reference').id
49
- })
50
- }
51
-
52
- /** The pinned revision's body without its frontmatter. */
53
- export function body(root: string, id: string, revision: string | null): string {
54
- if (!revision) throw invalid('This reference has no pinned revision.')
55
- const text = store.revisionText(store.wikiDir(root), id, revision)
56
- return frontmatter.bodyOf(text)
57
- }
58
-
59
- const PAGE_CHARS = 12_000
60
-
61
- /** A window of a reference that was selected for the caller's current run. */
62
- export function readPinned(root: string, runId: string | null | undefined, id: string, revision: string, offset: number): string {
63
- if (!runId) throw invalid('Pinned references require a current run.')
64
- const pack = readJson<{ sections?: { id: string; sources?: Source[] }[] }>(join(root, 'runs', safeId(runId), 'context.json'))
65
- if (!pack) throw notFound('Run context')
66
- const selected = (pack.sections ?? [])
67
- .filter((section) => section.id === 'references')
68
- .flatMap((section) => section.sources ?? [])
69
- .find((source) => source.id === id && source.revision === revision && (source.kind === 'page' || source.kind === 'skill'))
70
- if (!selected) throw invalid('This revision was not selected for the current reply.')
71
- const characters = Array.from(body(root, id, revision))
72
- const count = characters.length
73
- if (offset > count) throw invalid('The reference offset is past the end.')
74
- const excerpt = characters.slice(offset, offset + PAGE_CHARS).join('')
75
- const end = offset + charCount(excerpt)
76
- return `[${id}@${revision}] · characters ${offset}–${end} of ${count} · nextOffset: ${end < count ? end : 'none'}\n\n${excerpt}`
77
- }