@xingular/harness 0.1.5 → 0.1.7
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 +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/src/memory/caller.ts
CHANGED
|
@@ -10,11 +10,23 @@ import * as blocks from './blocks'
|
|
|
10
10
|
* never limits what can be read.
|
|
11
11
|
*/
|
|
12
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[]
|
|
13
23
|
runId?: string | null
|
|
14
24
|
sessionId?: string | null
|
|
15
25
|
projectId?: string | null
|
|
16
26
|
/** The agent calling; it decides which memory blocks are attached and editable. */
|
|
17
27
|
agentId?: string | null
|
|
28
|
+
/** Agents whose conversations this caller may not recall (opaque ids from the embedding host). */
|
|
29
|
+
recallExcludeAgents?: string[] | null
|
|
18
30
|
/** The agent's name, recorded on the revisions it makes. */
|
|
19
31
|
agentName?: string | null
|
|
20
32
|
/** Projects whose briefs the agent carries, most recently active first. */
|
|
@@ -42,7 +54,7 @@ export function projectOf(root: string, context: CallerContext): Project | null
|
|
|
42
54
|
export function blockCaller(env: HarnessEnv, context: CallerContext): blocks.Caller {
|
|
43
55
|
if (isSession(context)) return { kind: 'session', projectSlug: projectOf(env.root, context)?.slug ?? null }
|
|
44
56
|
if (!context.agentId) throw invalid('Memory blocks need an agent or a Harness session.')
|
|
45
|
-
return { kind: 'agent', id: context.agentId, name: context.agentName ?? env.directory.agentName(context.agentId) ?? context.agentId }
|
|
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 }
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/** The blocks in the caller's context: an agent also carries the briefs of its active projects. */
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createHarness, type Harness } from '../harness'
|
|
7
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
8
|
+
import type { PiSessionLike } from '../sessions/runner'
|
|
9
|
+
import { registryTools } from '../tools/registryWrite'
|
|
10
|
+
import type { ToolSession } from '../tools/types'
|
|
11
|
+
import * as blocks from './blocks'
|
|
12
|
+
import { memoryEdit, propose } from './api'
|
|
13
|
+
import { enqueueCuration } from './curation'
|
|
14
|
+
import { findMemory } from './facts'
|
|
15
|
+
import * as registry from '../skills/registry'
|
|
16
|
+
import * as attachments from '../plugins/attachments'
|
|
17
|
+
import { wikiDir } from './store'
|
|
18
|
+
|
|
19
|
+
const opened: Harness[] = []
|
|
20
|
+
afterEach(async () => { for (const harness of opened.splice(0)) await harness.close() })
|
|
21
|
+
const self = { agentId: 'nora', agentName: 'Nora', actor: 'Chat', vaultAccess: 'self' as const, runId: 'origin' }
|
|
22
|
+
const curator = { agentId: 'curator', agentName: 'Curator', actor: 'Chat', vaultAccess: 'curator' as const, runId: 'review' }
|
|
23
|
+
|
|
24
|
+
function setup(): Harness {
|
|
25
|
+
let emit: (event: unknown) => void = () => undefined
|
|
26
|
+
const session: PiSessionLike = { sessionId: 'test-session', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
27
|
+
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, secrets: memorySecretStore(), sessionFactory: async () => session, maintainerFactory: async () => { throw new Error('Hidden maintenance must not run') } })
|
|
28
|
+
opened.push(harness)
|
|
29
|
+
harness.accounts.create('Main', 'apiKey')
|
|
30
|
+
blocks.ensureAgent(wikiDir(harness.env.root), 'nora')
|
|
31
|
+
return harness
|
|
32
|
+
}
|
|
33
|
+
async function origin(harness: Harness): Promise<void> {
|
|
34
|
+
await harness.sessions.run({ run: { id: 'origin', sessionId: 'nora' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Prefer pnpm for all builds.', caller: self, tools: { allow: [] } })
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('normal callers directly write only their own persona and notes; standalone callers retain their prior scope', () => {
|
|
39
|
+
const h = setup()
|
|
40
|
+
expect(memoryEdit(h.env, self, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toContain('Updated')
|
|
41
|
+
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'user', text: 'A shared preference.' })).toThrow('outside your writable memory')
|
|
42
|
+
blocks.ensureAgent(wikiDir(h.env.root), 'other')
|
|
43
|
+
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'blocks/agents/other/notes', text: 'Not mine.' })).toThrow()
|
|
44
|
+
expect(memoryEdit(h.env, { ...self, vaultAccess: undefined }, 'memory.append', { block: 'user', text: 'Standalone scope.' })).toContain('Updated')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a shared fact is proposed without writing, then validated against its original run and applied once', async () => {
|
|
48
|
+
const h = setup(); await origin(h)
|
|
49
|
+
const submitted = propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
50
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
51
|
+
expect(submitted).toContain(job.id)
|
|
52
|
+
expect(() => findMemory(wikiDir(h.env.root), 'build-tool')).toThrow()
|
|
53
|
+
expect(() => h.maintenance.readJob(self, job.id)).toThrow('curator capability')
|
|
54
|
+
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Preserve the stated build preference.')
|
|
55
|
+
expect(receipt.status).toBe('completed')
|
|
56
|
+
expect(findMemory(wikiDir(h.env.root), 'build-tool').front.sourceIds).toContain('run:origin')
|
|
57
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
|
|
61
|
+
const h = setup(); await origin(h)
|
|
62
|
+
propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
|
|
63
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
64
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review the claimed evidence').status).toBe('needs_user')
|
|
65
|
+
expect(() => findMemory(wikiDir(h.env.root), 'invented')).toThrow()
|
|
66
|
+
const block = blocks.find(wikiDir(h.env.root), 'blocks/agents/nora/notes')
|
|
67
|
+
blocks.saveAsUser(wikiDir(h.env.root), { id: block.id, body: '', description: block.front.description, limit: 2000, readOnly: true })
|
|
68
|
+
expect(() => propose(h.env, self, { kind: 'block', id: block.id, title: 'Notes', body: 'Changed.' })).toThrow('user-only')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('text resources remain inactive until curated; undo restores attachments and preserves subsequent user edits', async () => {
|
|
72
|
+
const h = setup()
|
|
73
|
+
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'registry_write')!
|
|
74
|
+
await tool.execute({ kind: 'skill', name: 'release-notes', description: 'Write release notes', body: 'Summarize verified changes.', reason: 'Repeated release work.' })
|
|
75
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'registry')!
|
|
76
|
+
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
77
|
+
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Reuse the repeated release procedure.')
|
|
78
|
+
expect(attachments.attached(attachments.load(h.env.root), ['skills/release-notes'], { agentId: 'nora' }, false)).toBe(true)
|
|
79
|
+
h.maintenance.undoOperation(receipt.receipt!.operationId!)
|
|
80
|
+
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
81
|
+
const next = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'other-notes', description: 'Notes', body: 'Write verified notes.', reason: 'Workflow improvement.' } })
|
|
82
|
+
const applied = h.maintenance.applyJob(curator, next.id, undefined, 'Apply text-only improvement.')
|
|
83
|
+
registry.writeItem(h.env.root, 'skill', 'other-notes', 'Notes', 'The user changed this.', 'User edit')
|
|
84
|
+
expect(() => h.maintenance.undoOperation(applied.receipt!.operationId!)).toThrow('changed after')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('changed target versions are escalated instead of overwritten', () => {
|
|
88
|
+
const h = setup()
|
|
89
|
+
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'Original notes.', 'Initial')
|
|
90
|
+
const job = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'notes', description: 'Notes', body: 'Proposed notes.', reason: 'Improve' } })
|
|
91
|
+
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'User edits win.', 'User edit')
|
|
92
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review update').status).toBe('needs_user')
|
|
93
|
+
expect(registry.list(h.env.root).find(item => item.id === 'skills/notes')).toBeDefined()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('executable plugins are submitted without saving, activation or trust', async () => {
|
|
97
|
+
const h = setup()
|
|
98
|
+
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'plugin_write')!
|
|
99
|
+
await tool.execute({ name: 'code-change', description: 'A code plugin', files: [{ path: 'extensions/index.ts', content: 'export default () => {}' }] })
|
|
100
|
+
const job = h.maintenance.jobs().find(job => job.input.kind === 'plugin')!
|
|
101
|
+
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Code needs trust').status).toBe('needs_user')
|
|
102
|
+
expect(existsSync(join(h.env.root, 'plugins/code-change'))).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('external jobs persist and repeated schedule occurrences are distinct; no hidden provider runs', async () => {
|
|
106
|
+
const h = setup()
|
|
107
|
+
const source = h.maintenance.ingest('source.txt', Buffer.from('Prefer pnpm for all builds.'))
|
|
108
|
+
expect(h.maintenance.jobs().some(job => job.input.kind === 'ingest' && job.input.sourceId === source.id)).toBe(true)
|
|
109
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
110
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
111
|
+
h.maintenance.enqueue({ kind: 'review', requestId: 'day-two' })
|
|
112
|
+
expect(h.maintenance.jobs().filter(job => job.input.kind === 'review')).toHaveLength(2)
|
|
113
|
+
await h.maintenance.settled()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('a curator run never enqueues another completed-run maintenance job', async () => {
|
|
117
|
+
const h = setup()
|
|
118
|
+
await h.sessions.run({ run: { id: 'curator-run', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Review maintenance.', caller: curator, tools: { allow: [] } })
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
120
|
+
expect(h.maintenance.jobs().some(job => job.input.kind === 'run' && job.input.runId === 'curator-run')).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('curator prose cannot become its own evidence; original source runs can support a review', async () => {
|
|
124
|
+
const h = setup(); await origin(h)
|
|
125
|
+
await h.sessions.run({ run: { id: 'review', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Prefer pnpm for all builds.', caller: curator, tools: { allow: [] } })
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
127
|
+
propose(h.env, curator, { kind: 'fact', title: 'Curator claim', body: 'A claim from the review.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
128
|
+
const own = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
129
|
+
expect(h.maintenance.applyJob(curator, own.id, undefined, 'Check the original evidence.').status).toBe('needs_user')
|
|
130
|
+
expect(() => findMemory(wikiDir(h.env.root), 'curator-claim')).toThrow()
|
|
131
|
+
const review = enqueueCuration(h.env.root, { kind: 'review', requestId: 'grounded-review' })
|
|
132
|
+
const applied = h.maintenance.applyJob(curator, review.id, { memories: [{ title: 'Original preference', kind: 'preference', body: 'Prefer pnpm.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }] }] }, 'Retain the original recorded preference.')
|
|
133
|
+
expect(applied.status).toBe('completed')
|
|
134
|
+
expect(findMemory(wikiDir(h.env.root), 'original-preference').front.sourceIds).toContain('run:origin')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
test('a failed evidence preparation remains attributed and can be rejected without rereading invalid input', () => {
|
|
139
|
+
const h = setup()
|
|
140
|
+
const job = enqueueCuration(h.env.root, { kind: 'consolidate', blockIds: ['missing-block'] })
|
|
141
|
+
expect(() => h.maintenance.readJob(curator, job.id)).toThrow('No writable blocks')
|
|
142
|
+
expect(h.maintenance.jobs().find(item => item.id === job.id)).toMatchObject({ status: 'failed', runId: 'review' })
|
|
143
|
+
expect(h.maintenance.decide(curator, job.id, 'reject', 'The target no longer exists.').status).toBe('rejected')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a propose-only caller writes no memory directly, not even its own persona and notes', () => {
|
|
147
|
+
const h = setup()
|
|
148
|
+
const proposer = { ...self, vaultAccess: 'propose' as const }
|
|
149
|
+
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toThrow('Memory changes are proposals')
|
|
150
|
+
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'persona', text: 'I am terse.' })).toThrow('Memory changes are proposals')
|
|
151
|
+
expect(propose(h.env, proposer, { kind: 'block', id: 'blocks/agents/nora/notes', title: 'Notes', body: 'Working on release notes.' })).toContain('Submitted proposal')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('with the run trigger off, a settled run queues no consolidation of its own', async () => {
|
|
155
|
+
let emit: (event: unknown) => void = () => undefined
|
|
156
|
+
const session: PiSessionLike = { sessionId: 'quiet', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
157
|
+
const h = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, maintenanceRunTrigger: false, secrets: memorySecretStore(), sessionFactory: async () => session })
|
|
158
|
+
opened.push(h)
|
|
159
|
+
h.accounts.create('Main', 'apiKey')
|
|
160
|
+
await h.sessions.run({ run: { id: 'quiet-run', sessionId: 'nora' }, cwd: h.env.root, sessionDir: join(h.env.root, 'history'), text: 'Prefer pnpm.', caller: self, tools: { allow: [] } })
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
162
|
+
expect(h.maintenance.jobs().filter(job => job.input.kind === 'run')).toEqual([])
|
|
163
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { invalid, notFound } from '../core/errors'
|
|
6
|
+
import { newId, now, readJson, safeId, writeJson } from '../core/fs'
|
|
7
|
+
import type { CallerContext } from './caller'
|
|
8
|
+
import type { Job } from './maintenance'
|
|
9
|
+
import type { MaintenanceRequest } from './maintainer'
|
|
10
|
+
import { captureHashes, parseProposalSet, type ProposalSet, type RegistryProposal, type Evidence, type Trigger } from './proposals'
|
|
11
|
+
import { looksSecret } from './secrets'
|
|
12
|
+
import * as store from './store'
|
|
13
|
+
|
|
14
|
+
export type CurationInput = Job
|
|
15
|
+
| { kind: 'proposal'; namespace: string; proposals: ProposalSet; caller: CallerContext }
|
|
16
|
+
| { kind: 'registry'; proposal: RegistryProposal; caller: CallerContext }
|
|
17
|
+
| { kind: 'plugin'; proposal: unknown; caller: CallerContext }
|
|
18
|
+
export interface CurationJob {
|
|
19
|
+
id: string
|
|
20
|
+
key: string
|
|
21
|
+
input: CurationInput
|
|
22
|
+
status: 'queued' | 'running' | 'completed' | 'failed' | 'rejected' | 'needs_user'
|
|
23
|
+
createdAt: string
|
|
24
|
+
updatedAt: string
|
|
25
|
+
runId: string | null
|
|
26
|
+
reason: string
|
|
27
|
+
hashes: [string, string][]
|
|
28
|
+
prepared?: { request: MaintenanceRequest; evidence: Evidence; namespace: string; trigger: Trigger }
|
|
29
|
+
receipt: { operationId: string | null; notes: string[]; parked: number; flagged: number } | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const directory = (root: string): string => join(store.wikiDir(root), 'maintenance-jobs')
|
|
33
|
+
export function listCuration(root: string): CurationJob[] {
|
|
34
|
+
let names: string[] = []
|
|
35
|
+
try { names = readdirSync(directory(root)) } catch { return [] }
|
|
36
|
+
return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
|
|
37
|
+
const job = readJson<CurationJob>(join(directory(root), name))
|
|
38
|
+
return job && typeof job.id === 'string' && job.input && typeof job.input.kind === 'string' ? [job] : []
|
|
39
|
+
}).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
40
|
+
}
|
|
41
|
+
export function curationJob(root: string, id: string): CurationJob {
|
|
42
|
+
const job = readJson<CurationJob>(join(directory(root), `${safeId(id)}.json`))
|
|
43
|
+
if (!job) throw notFound('Maintenance job')
|
|
44
|
+
return job
|
|
45
|
+
}
|
|
46
|
+
export function saveCuration(root: string, job: CurationJob): CurationJob {
|
|
47
|
+
writeJson(join(directory(root), `${safeId(job.id)}.json`), { ...job, updatedAt: now() })
|
|
48
|
+
return job
|
|
49
|
+
}
|
|
50
|
+
export function enqueueCuration(root: string, input: CurationInput): CurationJob {
|
|
51
|
+
if (input.kind === 'proposal') input = { ...input, proposals: parseProposalSet(input.proposals) }
|
|
52
|
+
if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw invalid('The proposal is larger than 256 KiB.')
|
|
53
|
+
if (looksSecret(JSON.stringify(input))) throw invalid('The proposal contains text that looks like a credential.')
|
|
54
|
+
const captured = captureHashes(store.wikiDir(root))
|
|
55
|
+
const revisions = input.kind === 'consolidate' ? input.blockIds.map(id => [id, captured.get(id)]) : undefined
|
|
56
|
+
const key = createHash('sha256').update(JSON.stringify({ input, revisions })).digest('hex')
|
|
57
|
+
const existing = listCuration(root).find((job) => job.key === key)
|
|
58
|
+
if (existing) return existing
|
|
59
|
+
const timestamp = now()
|
|
60
|
+
return saveCuration(root, { id: newId(), key, input, status: 'queued', createdAt: timestamp, updatedAt: timestamp, runId: null, reason: '', hashes: [...captured], receipt: null })
|
|
61
|
+
}
|
|
62
|
+
export function requireCurator(context: CallerContext): void {
|
|
63
|
+
if (context.vaultAccess !== 'curator' || !context.runId) throw invalid('This operation needs a curator capability issued by the host for this run.')
|
|
64
|
+
}
|
|
@@ -22,11 +22,18 @@ import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingM
|
|
|
22
22
|
import { inbox, undo } from './operations'
|
|
23
23
|
import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
|
|
24
24
|
import { looksSecret } from './secrets'
|
|
25
|
+
import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
|
|
26
|
+
import * as registry from '../skills/registry'
|
|
27
|
+
import * as attachments from '../plugins/attachments'
|
|
28
|
+
import { parkPluginProposal, pluginProposalSchema } from '../plugins/proposal'
|
|
29
|
+
import { registryProposalSchema } from './proposals'
|
|
30
|
+
import { writeJson } from '../core/fs'
|
|
31
|
+
import { invalid } from '../core/errors'
|
|
25
32
|
import * as store from './store'
|
|
26
33
|
import { namespaces, searchEntries } from './wiki'
|
|
27
34
|
import { reindexQuietly } from './wikiIndex'
|
|
28
35
|
|
|
29
|
-
export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string } | { kind: 'consolidate'; blockIds: string[] }
|
|
36
|
+
export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string; namespace?: string } | { kind: 'consolidate'; blockIds: string[] } | { kind: 'review'; requestId?: string; manual?: boolean }
|
|
30
37
|
|
|
31
38
|
const FULL_PAGES = 5
|
|
32
39
|
const FULL_PAGE_LIMIT = 6000
|
|
@@ -144,6 +151,10 @@ export interface MaintenanceOptions {
|
|
|
144
151
|
factory?: MaintainerFactory
|
|
145
152
|
/** How long the queue stays empty before full blocks are condensed. */
|
|
146
153
|
idleMs?: number
|
|
154
|
+
external?: boolean
|
|
155
|
+
excludedTriggers?: string[]
|
|
156
|
+
/** False: a settled run queues no consolidation of its own. */
|
|
157
|
+
runTrigger?: boolean
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
export class Maintenance {
|
|
@@ -157,6 +168,13 @@ export class Maintenance {
|
|
|
157
168
|
/** Starts the idle check. Jobs can be queued whether or not it runs. */
|
|
158
169
|
start(): void {
|
|
159
170
|
this.stopped = false
|
|
171
|
+
if (this.options.external) {
|
|
172
|
+
for (const job of this.jobs().filter((job) => job.status === 'running')) {
|
|
173
|
+
job.status = 'failed'
|
|
174
|
+
job.reason = 'The previous process stopped before this request settled. Retry it explicitly.'
|
|
175
|
+
saveCuration(this.options.env.root, job)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
160
178
|
this.rest()
|
|
161
179
|
}
|
|
162
180
|
|
|
@@ -188,6 +206,17 @@ export class Maintenance {
|
|
|
188
206
|
|
|
189
207
|
enqueue(job: Job): void {
|
|
190
208
|
if (this.idle) clearTimeout(this.idle)
|
|
209
|
+
if (this.options.external) {
|
|
210
|
+
if (job.kind === 'run') {
|
|
211
|
+
try { runEvidence(this.options.env.root, job.runId) } catch (error) { log('maintenance source skipped', error instanceof Error ? error.message : String(error)); return }
|
|
212
|
+
const run = findRun(this.options.env.root, job.runId)
|
|
213
|
+
const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
|
|
214
|
+
enqueueCuration(this.options.env.root, { ...job, namespace })
|
|
215
|
+
} else enqueueCuration(this.options.env.root, job)
|
|
216
|
+
this.options.bus.emit({ type: 'maintenance.queued' })
|
|
217
|
+
this.rest()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
191
220
|
this.chain = this.chain
|
|
192
221
|
.then(() => this.process(job))
|
|
193
222
|
.catch((error) => log('maintenance', error instanceof Error ? error.message : String(error)))
|
|
@@ -196,11 +225,158 @@ export class Maintenance {
|
|
|
196
225
|
|
|
197
226
|
/** A settled run is evidence; whether it is eligible is decided when its job runs. */
|
|
198
227
|
onRunEvent(event: RunEvent): void {
|
|
228
|
+
if (this.options.runTrigger === false) return
|
|
229
|
+
const caller = readJsonOr<CallerContext | null>(join(this.options.env.root, 'runs', event.runId, 'caller.json'), null)
|
|
230
|
+
if (caller?.vaultAccess === 'curator' || this.options.excludedTriggers?.includes(findRun(this.options.env.root, event.runId)?.trigger ?? '')) return
|
|
199
231
|
if (event.type === 'completed') this.enqueue({ kind: 'run', runId: event.runId })
|
|
200
232
|
}
|
|
201
233
|
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
jobs(): CurationJob[] { return listCuration(this.options.env.root) }
|
|
235
|
+
|
|
236
|
+
claim(context: CallerContext, id: string, prepare = true): CurationJob {
|
|
237
|
+
requireCurator(context)
|
|
238
|
+
const root = this.options.env.root
|
|
239
|
+
const job = curationJob(root, id)
|
|
240
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
241
|
+
if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
|
|
242
|
+
if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
|
|
243
|
+
job.runId = context.runId!
|
|
244
|
+
if (prepare && job.input.kind !== 'proposal' && job.input.kind !== 'registry' && job.input.kind !== 'plugin' && !job.prepared) {
|
|
245
|
+
let prepared: ReturnType<Maintenance['prepare']>
|
|
246
|
+
try { prepared = this.prepare(job.input) } catch (error) { job.status = 'failed'; job.reason = error instanceof Error ? error.message : String(error); saveCuration(root, job); throw error }
|
|
247
|
+
job.prepared = { request: prepared.request, evidence: prepared.evidence, namespace: prepared.namespace, trigger: prepared.trigger }
|
|
248
|
+
job.hashes = [...prepared.captured]
|
|
249
|
+
if (job.prepared.evidence.sourceText) job.prepared.evidence.sourceText = takeChars(job.prepared.evidence.sourceText, 60_000)
|
|
250
|
+
}
|
|
251
|
+
if (prepare && job.input.kind === 'proposal' && !job.prepared) {
|
|
252
|
+
const input = job.input
|
|
253
|
+
const origin = input.caller.runId
|
|
254
|
+
const attached = input.proposals.blocks.map((proposal) => blocks.find(store.wikiDir(root), proposal.block))
|
|
255
|
+
const evidence = origin && input.caller.vaultAccess !== 'curator' ? evidenceText(root, origin) : ''
|
|
256
|
+
const context = maintainerContext(root, input.namespace, evidence)
|
|
257
|
+
job.prepared = { request: { jobId: job.id, namespace: input.namespace, trigger: { kind: 'run', runId: origin ?? '', transcript: takeChars(evidence, 24_000) }, pages: context.pages, memories: context.memories, blocks: blockValues(attached), index: '' }, evidence: { runText: evidence, blocks: attached.map((block) => block.id) }, namespace: input.namespace, trigger: { kind: 'agent', runId: origin ?? null, actor: 'Curator' } }
|
|
258
|
+
}
|
|
259
|
+
job.status = 'running'
|
|
260
|
+
job.runId = context.runId!
|
|
261
|
+
return saveCuration(root, job)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
readJob(context: CallerContext, id: string): unknown {
|
|
265
|
+
const job = this.claim(context, id)
|
|
266
|
+
return { id: job.id, status: job.status, input: job.input, context: job.prepared?.request ?? null, receipt: job.receipt }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
decide(context: CallerContext, id: string, decision: 'reject' | 'needs_user', reason: string): CurationJob {
|
|
270
|
+
if (!reason.trim()) throw invalid('Explain the decision.')
|
|
271
|
+
const job = this.claim(context, id, false)
|
|
272
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
273
|
+
job.status = decision === 'reject' ? 'rejected' : 'needs_user'
|
|
274
|
+
job.reason = reason.trim()
|
|
275
|
+
job.receipt = { operationId: null, notes: [job.reason], parked: decision === 'needs_user' ? 1 : 0, flagged: 0 }
|
|
276
|
+
if (decision === 'needs_user') {
|
|
277
|
+
const input = job.input
|
|
278
|
+
if (input.kind === 'registry') {
|
|
279
|
+
const proposal = input.proposal
|
|
280
|
+
inbox(store.wikiDir(this.options.env.root), 'proposed', `Review ${proposal.kind}: ${proposal.name}`, reason, [], null, { kind: proposal.kind, proposal, curationJobId: id })
|
|
281
|
+
} else if (input.kind === 'plugin') parkPluginProposal(store.wikiDir(this.options.env.root), `Curated request ${id}`, pluginProposalSchema.parse(input.proposal))
|
|
282
|
+
else inbox(store.wikiDir(this.options.env.root), 'unresolved', 'Maintenance needs your decision', reason, [])
|
|
283
|
+
}
|
|
284
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
285
|
+
return saveCuration(this.options.env.root, job)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
applyJob(context: CallerContext, id: string, replacement?: unknown, reason = '', scope?: attachments.AttachScope): CurationJob {
|
|
289
|
+
if (!reason.trim()) throw invalid('Explain why this change should be applied.')
|
|
290
|
+
const root = this.options.env.root
|
|
291
|
+
const wiki = store.wikiDir(root)
|
|
292
|
+
const job = this.claim(context, id)
|
|
293
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
294
|
+
const committed = readJsonOr<{ id: string } | null>(join(wiki, 'operations', `${job.id}.json`), null)
|
|
295
|
+
if (committed) {
|
|
296
|
+
job.status = 'completed'
|
|
297
|
+
job.receipt = { operationId: committed.id, notes: ['This request was already committed.'], parked: 0, flagged: 0 }
|
|
298
|
+
return saveCuration(root, job)
|
|
299
|
+
}
|
|
300
|
+
const input = job.input
|
|
301
|
+
if (input.kind === 'plugin') return this.decide(context, id, 'needs_user', reason || 'Plugin files require user review before activation; executable code also needs explicit trust.')
|
|
302
|
+
if (input.kind === 'registry') {
|
|
303
|
+
const proposal = registryProposalSchema.parse(input.proposal)
|
|
304
|
+
const itemId = `${proposal.kind}s/${store.slug(proposal.name)}`
|
|
305
|
+
if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim() || looksSecret(proposal.body)) throw invalid('Invalid text resource proposal.')
|
|
306
|
+
const existing = registry.list(root).find((item) => item.id === itemId)
|
|
307
|
+
const current = captureHashes(wiki).get(itemId)
|
|
308
|
+
if (existing?.builtin || current !== new Map(job.hashes).get(itemId)) return this.decide(context, id, 'needs_user', 'The resource is built-in or changed since it was proposed. Review the current version.')
|
|
309
|
+
const target = scope ?? { agentId: input.caller.agentId ?? context.agentId }
|
|
310
|
+
if (target.agentId && target.projectId) throw invalid('Choose one attachment scope.')
|
|
311
|
+
if (target.agentId && target.agentId !== context.agentId && target.agentId !== input.caller.agentId && !this.options.env.directory.agentName(target.agentId)) throw invalid('Unknown attachment target.')
|
|
312
|
+
if (target.projectId) findProject(root, target.projectId)
|
|
313
|
+
const before = existing ? store.snapshot(wiki, itemId, readFileSync(existing.path, 'utf8'), 'Curator', `Before ${id}`).id : null
|
|
314
|
+
const beforeChoices = attachments.load(root)
|
|
315
|
+
registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Curated request ${id}: ${reason || proposal.reason}`, context.agentName ?? 'Curator')
|
|
316
|
+
const saved = registry.list(root).find((item) => item.id === itemId)!
|
|
317
|
+
const after = store.currentRevision(wiki, itemId)!
|
|
318
|
+
const choices = attachments.load(root)
|
|
319
|
+
attachments.set(choices, [itemId], target, true, false)
|
|
320
|
+
attachments.save(root, choices)
|
|
321
|
+
writeJson(join(wiki, 'operations', `${id}.json`), { id, createdAt: now(), trigger: `Curated request ${id}`, pages: [{ pageId: itemId, path: saved.path, beforeRevisionId: before, afterRevisionId: after }], undone: false, protected: true, attachmentsBefore: beforeChoices, attachmentsAfter: choices })
|
|
322
|
+
job.receipt = { operationId: id, notes: [`Saved ${itemId}; enabled for ${target.agentId ?? target.projectId ?? 'everyone'}. ${reason || proposal.reason}`], parked: 0, flagged: 0 }
|
|
323
|
+
this.options.bus.emit({ type: 'plugins.changed' })
|
|
324
|
+
} else {
|
|
325
|
+
const prepared = job.prepared
|
|
326
|
+
const proposalInput = input.kind === 'proposal' ? input : null
|
|
327
|
+
const proposals = parseProposalSet(replacement ?? proposalInput?.proposals)
|
|
328
|
+
const origin = proposalInput?.caller.runId ?? null
|
|
329
|
+
const trigger: Trigger = prepared?.trigger ?? { kind: 'agent', runId: origin, actor: context.agentName ?? 'Curator' }
|
|
330
|
+
const evidence: Evidence = input.kind === 'proposal' ? (prepared?.evidence ?? {}) : this.prepare(input).evidence
|
|
331
|
+
const originEvidence: Evidence = prepared?.evidence ?? { runText: origin ? evidenceText(root, origin) : null, blocks: proposals.blocks.map((block) => block.block) }
|
|
332
|
+
{
|
|
333
|
+
if (input.kind === 'proposal') Object.assign(evidence, originEvidence)
|
|
334
|
+
evidence.runs = {}
|
|
335
|
+
evidence.sources = {}
|
|
336
|
+
const refs = [...proposals.pages, ...proposals.memories, ...proposals.blocks].flatMap(proposal => proposal.sourceRefs).slice(0, 20)
|
|
337
|
+
for (const ref of refs) {
|
|
338
|
+
if (ref.kind === 'run' && findRun(root, ref.ref) && readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)?.vaultAccess !== 'curator') {
|
|
339
|
+
const excluded = readJsonOr<{ excludedSourceIds?: string[] }>(join(root, 'config/settings.json'), {}).excludedSourceIds ?? []
|
|
340
|
+
const text = evidenceText(root, ref.ref)
|
|
341
|
+
if (!excluded.includes(ref.ref) && !looksSecret(text)) evidence.runs[ref.ref] = takeChars(text, 60_000)
|
|
342
|
+
}
|
|
343
|
+
if (ref.kind === 'source') {
|
|
344
|
+
const source = store.listSources(wiki).find(source => source.id === ref.ref)
|
|
345
|
+
if (source) {
|
|
346
|
+
const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
|
|
347
|
+
if (!looksSecret(text)) evidence.sources[ref.ref] = takeChars(text, 60_000)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const outcome = apply(wiki, prepared?.namespace ?? proposalInput?.namespace ?? store.LIBRARY_NAMESPACE, trigger, proposals, new Map(job.hashes), evidence, id, context.agentName ?? 'Curator')
|
|
353
|
+
if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
|
|
354
|
+
job.receipt = { operationId: outcome.operation?.id ?? null, notes: outcome.notes, parked: outcome.parked, flagged: outcome.flagged }
|
|
355
|
+
}
|
|
356
|
+
job.status = job.receipt.parked ? 'needs_user' : 'completed'
|
|
357
|
+
job.reason = reason
|
|
358
|
+
reindexQuietly(root)
|
|
359
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
360
|
+
return saveCuration(root, job)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
finishRun(runId: string, failed: boolean): void {
|
|
364
|
+
for (const job of this.jobs().filter((job) => job.runId === runId && job.status === 'running')) {
|
|
365
|
+
job.status = failed ? 'failed' : 'completed'
|
|
366
|
+
job.reason = failed ? 'The run stopped before completing this request. Retry it explicitly.' : 'The review completed without applying a change.'
|
|
367
|
+
job.receipt ??= { operationId: null, notes: [job.reason], parked: 0, flagged: 0 }
|
|
368
|
+
saveCuration(this.options.env.root, job)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
retry(id: string): CurationJob {
|
|
373
|
+
const job = curationJob(this.options.env.root, id)
|
|
374
|
+
if (job.status !== 'failed') throw invalid('Only a failed request can be retried.')
|
|
375
|
+
return saveCuration(this.options.env.root, { ...job, status: 'queued', runId: null, reason: '', receipt: null })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
prepare(job: Job): { request: MaintenanceRequest; evidence: Evidence; captured: Map<string, string>; namespace: string; trigger: Trigger } {
|
|
379
|
+
const { env } = this.options
|
|
204
380
|
const root = env.root
|
|
205
381
|
const wiki = store.wikiDir(root)
|
|
206
382
|
const evidence: Evidence = {}
|
|
@@ -220,9 +396,7 @@ export class Maintenance {
|
|
|
220
396
|
text = null
|
|
221
397
|
}
|
|
222
398
|
if (text === null) {
|
|
223
|
-
|
|
224
|
-
bus.emit({ type: 'wiki.changed' })
|
|
225
|
-
return
|
|
399
|
+
throw invalid('Only text sources can be synthesized automatically right now.')
|
|
226
400
|
}
|
|
227
401
|
shown = takeChars(text, 60_000)
|
|
228
402
|
evidence.sourceText = text
|
|
@@ -234,8 +408,7 @@ export class Maintenance {
|
|
|
234
408
|
try {
|
|
235
409
|
found = runEvidence(root, job.runId)
|
|
236
410
|
} catch (error) {
|
|
237
|
-
|
|
238
|
-
return
|
|
411
|
+
throw error
|
|
239
412
|
}
|
|
240
413
|
evidence.runText = evidenceText(root, job.runId)
|
|
241
414
|
attached = runBlocks(env, job.runId)
|
|
@@ -244,7 +417,7 @@ export class Maintenance {
|
|
|
244
417
|
trigger = { kind: 'run', runId: job.runId }
|
|
245
418
|
request = { kind: 'run', runId: job.runId, transcript: shown }
|
|
246
419
|
} else {
|
|
247
|
-
attached = job.blockIds.flatMap((id) => {
|
|
420
|
+
attached = (job.kind === 'review' ? blocks.loadAll(wiki).filter((block) => !block.front.readOnly).sort((a, b) => charCount(b.body) / b.front.limit - charCount(a.body) / a.front.limit).slice(0, 30).map((block) => block.id) : job.blockIds).flatMap((id) => {
|
|
248
421
|
try {
|
|
249
422
|
const block = blocks.find(wiki, id)
|
|
250
423
|
return block.front.readOnly ? [] : [block]
|
|
@@ -252,7 +425,7 @@ export class Maintenance {
|
|
|
252
425
|
return []
|
|
253
426
|
}
|
|
254
427
|
})
|
|
255
|
-
if (!attached.length)
|
|
428
|
+
if (!attached.length && job.kind !== 'review') throw invalid('No writable blocks remain in this job.')
|
|
256
429
|
namespace = store.LIBRARY_NAMESPACE
|
|
257
430
|
trigger = { kind: 'consolidate' }
|
|
258
431
|
request = { kind: 'consolidate' }
|
|
@@ -266,12 +439,27 @@ export class Maintenance {
|
|
|
266
439
|
} catch {
|
|
267
440
|
index = ''
|
|
268
441
|
}
|
|
442
|
+
return { request: { jobId: '', namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index }, evidence, captured, namespace, trigger }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async process(job: Job): Promise<void> {
|
|
446
|
+
const { env, bus } = this.options
|
|
447
|
+
const root = env.root
|
|
448
|
+
const wiki = store.wikiDir(root)
|
|
449
|
+
let prepared: ReturnType<Maintenance['prepare']>
|
|
450
|
+
try { prepared = this.prepare(job) } catch (error) {
|
|
451
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
452
|
+
if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
|
|
453
|
+
else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
const { namespace, trigger, evidence, captured } = prepared
|
|
269
457
|
const described = describeTrigger(trigger)
|
|
270
458
|
try {
|
|
271
459
|
// The job runs on the default account.
|
|
272
460
|
const account = this.options.accounts.context(this.options.accounts.resolve())
|
|
273
461
|
const proposed = await runMaintenance(
|
|
274
|
-
{
|
|
462
|
+
{ ...prepared.request, jobId: newId() },
|
|
275
463
|
{ cwd: wiki, product: env.productName, account, runtime: () => this.options.accounts.runtime(account) },
|
|
276
464
|
this.options.factory ?? piMaintainerFactory
|
|
277
465
|
)
|
package/src/memory/operations.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { invalid, notFound } from '../core/errors'
|
|
|
8
8
|
import { atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
|
|
9
9
|
import { compareText } from '../core/text'
|
|
10
10
|
import * as store from './store'
|
|
11
|
+
import * as attachments from '../plugins/attachments'
|
|
12
|
+
import { dirname } from 'node:path'
|
|
11
13
|
|
|
12
14
|
export interface OperationPage {
|
|
13
15
|
pageId: string
|
|
@@ -23,6 +25,9 @@ export interface Operation {
|
|
|
23
25
|
trigger: string
|
|
24
26
|
pages: OperationPage[]
|
|
25
27
|
undone: boolean
|
|
28
|
+
protected?: boolean
|
|
29
|
+
attachmentsBefore?: attachments.Attachments
|
|
30
|
+
attachmentsAfter?: attachments.Attachments
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function operationsDir(wiki: string): string {
|
|
@@ -107,7 +112,14 @@ export function undo(wiki: string, operationId: string): Operation {
|
|
|
107
112
|
const operation = readJson<Operation>(path)
|
|
108
113
|
if (!operation) throw notFound('Maintenance operation')
|
|
109
114
|
if (operation.undone) throw invalid('This operation was already undone.')
|
|
115
|
+
if (operation.protected) {
|
|
116
|
+
for (const page of operation.pages) {
|
|
117
|
+
if (!existsSync(page.path) || readFileSync(page.path, 'utf8') !== store.revisionText(wiki, page.pageId, page.afterRevisionId)) throw invalid('An entry changed after this operation; review it before undoing.')
|
|
118
|
+
}
|
|
119
|
+
if (operation.attachmentsAfter && JSON.stringify(attachments.load(dirname(wiki))) !== JSON.stringify(operation.attachmentsAfter)) throw invalid('Attachment settings changed after this operation; review them before undoing.')
|
|
120
|
+
}
|
|
110
121
|
const short = operation.id.slice(0, 8)
|
|
122
|
+
if (operation.attachmentsBefore) attachments.save(dirname(wiki), operation.attachmentsBefore)
|
|
111
123
|
for (const page of operation.pages) {
|
|
112
124
|
let current = ''
|
|
113
125
|
try {
|