@xingular/harness 0.1.6 → 2.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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- 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 +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- 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 +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- 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 +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- 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 +134 -7
- 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 +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -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/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -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 +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// their limit are condensed.
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
8
9
|
|
|
9
10
|
import type { Accounts } from '../accounts/accounts'
|
|
10
11
|
import { log, type Bus } from '../core/bus'
|
|
@@ -20,13 +21,20 @@ import { callerBlocks, type CallerContext } from './caller'
|
|
|
20
21
|
import * as facts from './facts'
|
|
21
22
|
import { piMaintainerFactory, runMaintenance, type ExistingBlock, type ExistingMemory, type ExistingPage, type MaintainerFactory, type MaintenanceRequest } from './maintainer'
|
|
22
23
|
import { inbox, undo } from './operations'
|
|
23
|
-
import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type Trigger } from './proposals'
|
|
24
|
+
import { accept, apply, captureHashes, describeTrigger, parseProposalSet, writeIndex, type Evidence, type ProposalSet, type SourceRef, type Trigger } from './proposals'
|
|
24
25
|
import { looksSecret } from './secrets'
|
|
26
|
+
import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
|
|
27
|
+
import * as registry from '../skills/registry'
|
|
28
|
+
import * as attachments from '../plugins/attachments'
|
|
29
|
+
import { parkPluginProposal, pluginProposalSchema } from '../plugins/proposal'
|
|
30
|
+
import { registryProposalSchema } from './proposals'
|
|
31
|
+
import { writeJson } from '../core/fs'
|
|
32
|
+
import { invalid } from '../core/errors'
|
|
25
33
|
import * as store from './store'
|
|
26
34
|
import { namespaces, searchEntries } from './wiki'
|
|
27
35
|
import { reindexQuietly } from './wikiIndex'
|
|
28
36
|
|
|
29
|
-
export type Job = { kind: 'ingest'; namespace: string; sourceId: string } | { kind: 'run'; runId: string } | { kind: 'consolidate'; blockIds: string[] }
|
|
37
|
+
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
38
|
|
|
31
39
|
const FULL_PAGES = 5
|
|
32
40
|
const FULL_PAGE_LIMIT = 6000
|
|
@@ -39,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
|
|
|
39
47
|
return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** A run that automatic maintenance leaves alone for a settled reason: a setting, a pause, or no project. Not an error. */
|
|
51
|
+
class NotEligible extends Error {}
|
|
52
|
+
|
|
42
53
|
/** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
|
|
43
54
|
function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
|
|
44
55
|
const run = findRun(root, runId)
|
|
45
56
|
if (!run) throw new Error('Run was not found.')
|
|
46
57
|
const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
|
|
47
|
-
if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new
|
|
58
|
+
if (Array.isArray(settings?.excludedSourceIds) && settings.excludedSourceIds.includes(runId)) throw new NotEligible('run excluded in settings')
|
|
48
59
|
const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
|
|
49
60
|
let namespace: string
|
|
50
61
|
if (projectId) {
|
|
51
62
|
const project = findProject(root, projectId)
|
|
52
|
-
if (!project.autoMaintenance) throw new
|
|
63
|
+
if (!project.autoMaintenance) throw new NotEligible('automatic updates are paused for this project')
|
|
53
64
|
namespace = project.wikiNamespace
|
|
54
65
|
} else if (run.mode === 'chat') {
|
|
55
|
-
if (chatUpdatesPaused(settings)) throw new
|
|
66
|
+
if (chatUpdatesPaused(settings)) throw new NotEligible('automatic updates are paused for Chat')
|
|
56
67
|
namespace = store.LIBRARY_NAMESPACE
|
|
57
68
|
} else {
|
|
58
|
-
throw new
|
|
69
|
+
throw new NotEligible('run has no project')
|
|
59
70
|
}
|
|
60
71
|
const text = transcript(run.goal ?? '', readEvents(root, runId))
|
|
61
|
-
if (!text.includes('Assistant:')) throw new
|
|
62
|
-
if (looksSecret(text)) throw new
|
|
72
|
+
if (!text.includes('Assistant:')) throw new NotEligible('no recorded assistant output to use as evidence')
|
|
73
|
+
if (looksSecret(text)) throw new NotEligible('the run contains text that looks like a credential')
|
|
63
74
|
return { namespace, transcript: text }
|
|
64
75
|
}
|
|
65
76
|
|
|
@@ -137,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
|
|
|
137
148
|
|
|
138
149
|
const blockValues = (found: readonly blocks.Block[]): ExistingBlock[] => found.map((block) => ({ id: block.id, label: block.front.label, description: block.front.description, limit: block.front.limit, body: block.body }))
|
|
139
150
|
|
|
151
|
+
export interface MaintenanceBatchDecision {
|
|
152
|
+
id: string
|
|
153
|
+
decision: 'apply' | 'reject' | 'needs_user'
|
|
154
|
+
reason: string
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
export interface MaintenanceOptions {
|
|
141
158
|
env: HarnessEnv
|
|
142
159
|
bus: Bus
|
|
@@ -144,6 +161,10 @@ export interface MaintenanceOptions {
|
|
|
144
161
|
factory?: MaintainerFactory
|
|
145
162
|
/** How long the queue stays empty before full blocks are condensed. */
|
|
146
163
|
idleMs?: number
|
|
164
|
+
external?: boolean
|
|
165
|
+
excludedTriggers?: string[]
|
|
166
|
+
/** False: a settled run queues no consolidation of its own. */
|
|
167
|
+
runTrigger?: boolean
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
export class Maintenance {
|
|
@@ -157,6 +178,13 @@ export class Maintenance {
|
|
|
157
178
|
/** Starts the idle check. Jobs can be queued whether or not it runs. */
|
|
158
179
|
start(): void {
|
|
159
180
|
this.stopped = false
|
|
181
|
+
if (this.options.external) {
|
|
182
|
+
for (const job of this.jobs().filter((job) => job.status === 'running')) {
|
|
183
|
+
job.status = 'failed'
|
|
184
|
+
job.reason = 'The previous process stopped before this request settled. Retry it explicitly.'
|
|
185
|
+
saveCuration(this.options.env.root, job)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
160
188
|
this.rest()
|
|
161
189
|
}
|
|
162
190
|
|
|
@@ -188,6 +216,22 @@ export class Maintenance {
|
|
|
188
216
|
|
|
189
217
|
enqueue(job: Job): void {
|
|
190
218
|
if (this.idle) clearTimeout(this.idle)
|
|
219
|
+
if (this.options.external) {
|
|
220
|
+
if (job.kind === 'run') {
|
|
221
|
+
try {
|
|
222
|
+
runEvidence(this.options.env.root, job.runId)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!(error instanceof NotEligible)) log('maintenance source skipped', error instanceof Error ? error.message : String(error))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const run = findRun(this.options.env.root, job.runId)
|
|
228
|
+
const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
|
|
229
|
+
enqueueCuration(this.options.env.root, { ...job, namespace })
|
|
230
|
+
} else enqueueCuration(this.options.env.root, job)
|
|
231
|
+
this.options.bus.emit({ type: 'maintenance.queued' })
|
|
232
|
+
this.rest()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
191
235
|
this.chain = this.chain
|
|
192
236
|
.then(() => this.process(job))
|
|
193
237
|
.catch((error) => log('maintenance', error instanceof Error ? error.message : String(error)))
|
|
@@ -196,11 +240,302 @@ export class Maintenance {
|
|
|
196
240
|
|
|
197
241
|
/** A settled run is evidence; whether it is eligible is decided when its job runs. */
|
|
198
242
|
onRunEvent(event: RunEvent): void {
|
|
243
|
+
if (this.options.runTrigger === false) return
|
|
244
|
+
const caller = readJsonOr<CallerContext | null>(join(this.options.env.root, 'runs', event.runId, 'caller.json'), null)
|
|
245
|
+
if (caller?.vaultAccess === 'curator' || this.options.excludedTriggers?.includes(findRun(this.options.env.root, event.runId)?.trigger ?? '')) return
|
|
199
246
|
if (event.type === 'completed') this.enqueue({ kind: 'run', runId: event.runId })
|
|
200
247
|
}
|
|
201
248
|
|
|
202
|
-
|
|
203
|
-
|
|
249
|
+
jobs(): CurationJob[] { return listCuration(this.options.env.root) }
|
|
250
|
+
|
|
251
|
+
/** Reads every request frozen into the current host-assigned review batch. */
|
|
252
|
+
readBatch(context: CallerContext): { jobs: unknown[] } {
|
|
253
|
+
requireCurator(context)
|
|
254
|
+
const ids = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
|
|
255
|
+
return {
|
|
256
|
+
jobs: ids.map((id) => {
|
|
257
|
+
try {
|
|
258
|
+
return this.readJob(context, id)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return { id, status: 'failed', error: error instanceof Error ? error.message : String(error) }
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Applies one evidence-checked Wiki change set for all accepted Wiki requests in a batch. */
|
|
267
|
+
applyBatch(context: CallerContext, decisions: readonly MaintenanceBatchDecision[], replacement: unknown): { operationId: string | null; jobs: CurationJob[] } {
|
|
268
|
+
requireCurator(context)
|
|
269
|
+
if (!decisions.length) throw invalid('Decide every assigned request in the batch.')
|
|
270
|
+
const ids = decisions.map((decision) => decision.id)
|
|
271
|
+
if (new Set(ids).size !== ids.length) throw invalid('Each request can appear only once in a batch decision.')
|
|
272
|
+
const assigned = context.maintenanceJobIds ?? (context.maintenanceJobId ? [context.maintenanceJobId] : [])
|
|
273
|
+
const pending = assigned.map((id) => curationJob(this.options.env.root, id)).filter((job) => !['completed', 'rejected', 'needs_user'].includes(job.status))
|
|
274
|
+
if (pending.some((job) => !ids.includes(job.id))) throw invalid('Decide every assigned request in one batch.')
|
|
275
|
+
|
|
276
|
+
const entries = decisions.map((decision) => {
|
|
277
|
+
if (!decision.reason.trim()) throw invalid('Explain every batch decision.')
|
|
278
|
+
if (!['apply', 'reject', 'needs_user'].includes(decision.decision)) throw invalid('Choose apply, reject, or needs_user for every request.')
|
|
279
|
+
const job = curationJob(this.options.env.root, decision.id)
|
|
280
|
+
if (context.maintenanceJobIds !== undefined && !context.maintenanceJobIds.includes(decision.id) && !('caller' in job.input && job.input.caller.runId === context.runId)) throw invalid('This request is not assigned to this run.')
|
|
281
|
+
if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
|
|
282
|
+
return { decision, job }
|
|
283
|
+
})
|
|
284
|
+
const wikiEntries = entries.filter(({ decision, job }) => decision.decision === 'apply' && !['registry', 'plugin'].includes(job.input.kind) && !['completed', 'rejected', 'needs_user'].includes(job.status))
|
|
285
|
+
if (wikiEntries.length && replacement === undefined) throw invalid('Provide one combined, evidence-backed Wiki change set for the approved requests.')
|
|
286
|
+
const proposals: ProposalSet | null = wikiEntries.length ? parseProposalSet(replacement) : null
|
|
287
|
+
const operationId = wikiEntries.length ? `batch-${createHash('sha256').update(wikiEntries.map(({ job }) => job.id).sort().join('\0')).digest('hex').slice(0, 32)}` : null
|
|
288
|
+
const results = new Map<string, CurationJob>()
|
|
289
|
+
|
|
290
|
+
if (wikiEntries.length) {
|
|
291
|
+
const prepared = wikiEntries.map(({ job }) => this.claim(context, job.id))
|
|
292
|
+
const root = this.options.env.root
|
|
293
|
+
const wiki = store.wikiDir(root)
|
|
294
|
+
const evidence: Evidence = { runs: {}, sources: {}, blocks: [] }
|
|
295
|
+
const blocksSeen = new Set<string>()
|
|
296
|
+
const captured = new Map<string, string>()
|
|
297
|
+
const conflicts = new Set<string>()
|
|
298
|
+
for (const job of prepared) {
|
|
299
|
+
const input = job.input
|
|
300
|
+
for (const id of job.prepared?.evidence.blocks ?? (input.kind === 'proposal' ? input.proposals.blocks.map((proposal) => proposal.block) : [])) blocksSeen.add(id)
|
|
301
|
+
for (const [id, hash] of job.hashes) {
|
|
302
|
+
const previous = captured.get(id)
|
|
303
|
+
if (previous !== undefined && previous !== hash) conflicts.add(id)
|
|
304
|
+
else captured.set(id, hash)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const id of conflicts) captured.set(id, `conflicting-batch-snapshot:${id}`)
|
|
308
|
+
evidence.blocks = [...blocksSeen]
|
|
309
|
+
|
|
310
|
+
const refs: SourceRef[] = [...proposals!.pages, ...proposals!.memories, ...proposals!.blocks].flatMap((proposal) => proposal.sourceRefs)
|
|
311
|
+
const excluded = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)?.excludedSourceIds
|
|
312
|
+
const excludedIds = Array.isArray(excluded) ? excluded.filter((id): id is string => typeof id === 'string') : []
|
|
313
|
+
for (const ref of refs) {
|
|
314
|
+
if (ref.kind === 'run' && findRun(root, ref.ref)) {
|
|
315
|
+
const caller = readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)
|
|
316
|
+
if (!excludedIds.includes(ref.ref) && caller?.vaultAccess !== 'curator') {
|
|
317
|
+
const text = evidenceText(root, ref.ref)
|
|
318
|
+
if (!looksSecret(text)) evidence.runs![ref.ref] = takeChars(text, 60_000)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (ref.kind === 'source') {
|
|
322
|
+
const source = store.listSources(wiki).find((item) => item.id === ref.ref)
|
|
323
|
+
if (source) {
|
|
324
|
+
try {
|
|
325
|
+
const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
|
|
326
|
+
if (!looksSecret(text)) evidence.sources![ref.ref] = takeChars(text, 60_000)
|
|
327
|
+
} catch {
|
|
328
|
+
// Missing or unreadable evidence is rejected by the proposal validator.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const namespaceFor = (sourceRefs: readonly SourceRef[]): string => {
|
|
335
|
+
for (const ref of sourceRefs) {
|
|
336
|
+
const sourceJob = prepared.find((job) => {
|
|
337
|
+
if (job.input.kind === 'proposal') return job.input.caller.runId === ref.ref
|
|
338
|
+
if (job.input.kind === 'run') return job.input.runId === ref.ref
|
|
339
|
+
if (job.input.kind === 'ingest') return job.input.sourceId === ref.ref
|
|
340
|
+
return false
|
|
341
|
+
})
|
|
342
|
+
const namespace = sourceJob?.prepared?.namespace ?? (sourceJob?.input.kind === 'proposal' ? sourceJob.input.namespace : undefined)
|
|
343
|
+
if (namespace) return namespace
|
|
344
|
+
}
|
|
345
|
+
return prepared[0]?.prepared?.namespace ?? (prepared[0]?.input.kind === 'proposal' ? prepared[0].input.namespace : undefined) ?? store.LIBRARY_NAMESPACE
|
|
346
|
+
}
|
|
347
|
+
const batchProposals: ProposalSet = {
|
|
348
|
+
...proposals!,
|
|
349
|
+
pages: proposals!.pages.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) })),
|
|
350
|
+
memories: proposals!.memories.map((proposal) => ({ ...proposal, namespace: proposal.namespace ?? namespaceFor(proposal.sourceRefs) }))
|
|
351
|
+
}
|
|
352
|
+
const trigger: Trigger = prepared.some((job) => job.input.kind === 'consolidate')
|
|
353
|
+
? { kind: 'consolidate' }
|
|
354
|
+
: prepared.some((job) => job.input.kind === 'run')
|
|
355
|
+
? { kind: 'run', runId: (prepared.find((job) => job.input.kind === 'run')!.input as Extract<Job, { kind: 'run' }>).runId }
|
|
356
|
+
: prepared.some((job) => job.input.kind === 'ingest')
|
|
357
|
+
? (prepared.find((job) => job.input.kind === 'ingest')!.prepared?.trigger ?? { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' })
|
|
358
|
+
: { kind: 'agent', runId: context.runId ?? null, actor: context.agentName ?? 'Curator' }
|
|
359
|
+
const outcome = apply(wiki, namespaceFor(refs), trigger, batchProposals, captured, evidence, operationId!, context.agentName ?? 'Curator')
|
|
360
|
+
if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
|
|
361
|
+
reindexQuietly(root)
|
|
362
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
363
|
+
let operationAssigned = false
|
|
364
|
+
for (const { decision, job: original } of wikiEntries) {
|
|
365
|
+
const job = curationJob(root, original.id)
|
|
366
|
+
const jobReason = decision.reason.trim()
|
|
367
|
+
job.status = outcome.parked ? 'needs_user' : 'completed'
|
|
368
|
+
job.reason = jobReason
|
|
369
|
+
job.receipt = {
|
|
370
|
+
operationId: outcome.operation && !operationAssigned ? outcome.operation.id : null,
|
|
371
|
+
notes: [...outcome.notes, ...(outcome.operation ? [`Combined batch operation ${outcome.operation.id}.`] : ['The combined review produced no Wiki changes.'])],
|
|
372
|
+
parked: outcome.parked,
|
|
373
|
+
flagged: outcome.flagged
|
|
374
|
+
}
|
|
375
|
+
if (outcome.operation && !operationAssigned) operationAssigned = true
|
|
376
|
+
results.set(job.id, saveCuration(root, job))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const { decision, job } of entries) {
|
|
381
|
+
if (results.has(job.id) || ['completed', 'rejected', 'needs_user'].includes(job.status)) {
|
|
382
|
+
results.set(job.id, curationJob(this.options.env.root, job.id))
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
if (decision.decision === 'apply') {
|
|
386
|
+
results.set(job.id, this.applyJob(context, job.id, undefined, decision.reason))
|
|
387
|
+
} else {
|
|
388
|
+
results.set(job.id, this.decide(context, job.id, decision.decision, decision.reason))
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const receiptOperationId = [...results.values()].find((job) => job.receipt?.operationId)?.receipt?.operationId ?? null
|
|
392
|
+
return { operationId: receiptOperationId, jobs: decisions.map(({ id }) => results.get(id) ?? curationJob(this.options.env.root, id)) }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
claim(context: CallerContext, id: string, prepare = true): CurationJob {
|
|
396
|
+
requireCurator(context)
|
|
397
|
+
const root = this.options.env.root
|
|
398
|
+
const job = curationJob(root, id)
|
|
399
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
400
|
+
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.')
|
|
401
|
+
if (job.runId && job.runId !== context.runId && job.status === 'running') throw invalid('Another run owns this request.')
|
|
402
|
+
job.runId = context.runId!
|
|
403
|
+
if (prepare && job.input.kind !== 'proposal' && job.input.kind !== 'registry' && job.input.kind !== 'plugin' && !job.prepared) {
|
|
404
|
+
let prepared: ReturnType<Maintenance['prepare']>
|
|
405
|
+
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 }
|
|
406
|
+
job.prepared = { request: prepared.request, evidence: prepared.evidence, namespace: prepared.namespace, trigger: prepared.trigger }
|
|
407
|
+
job.hashes = [...prepared.captured]
|
|
408
|
+
if (job.prepared.evidence.sourceText) job.prepared.evidence.sourceText = takeChars(job.prepared.evidence.sourceText, 60_000)
|
|
409
|
+
}
|
|
410
|
+
if (prepare && job.input.kind === 'proposal' && !job.prepared) {
|
|
411
|
+
const input = job.input
|
|
412
|
+
const origin = input.caller.runId
|
|
413
|
+
const attached = input.proposals.blocks.map((proposal) => blocks.find(store.wikiDir(root), proposal.block))
|
|
414
|
+
const evidence = origin && input.caller.vaultAccess !== 'curator' ? evidenceText(root, origin) : ''
|
|
415
|
+
const context = maintainerContext(root, input.namespace, evidence)
|
|
416
|
+
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' } }
|
|
417
|
+
}
|
|
418
|
+
job.status = 'running'
|
|
419
|
+
job.runId = context.runId!
|
|
420
|
+
return saveCuration(root, job)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
readJob(context: CallerContext, id: string): unknown {
|
|
424
|
+
const job = this.claim(context, id)
|
|
425
|
+
return { id: job.id, status: job.status, input: job.input, context: job.prepared?.request ?? null, receipt: job.receipt }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
decide(context: CallerContext, id: string, decision: 'reject' | 'needs_user', reason: string): CurationJob {
|
|
429
|
+
if (!reason.trim()) throw invalid('Explain the decision.')
|
|
430
|
+
const job = this.claim(context, id, false)
|
|
431
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
432
|
+
job.status = decision === 'reject' ? 'rejected' : 'needs_user'
|
|
433
|
+
job.reason = reason.trim()
|
|
434
|
+
job.receipt = { operationId: null, notes: [job.reason], parked: decision === 'needs_user' ? 1 : 0, flagged: 0 }
|
|
435
|
+
if (decision === 'needs_user') {
|
|
436
|
+
const input = job.input
|
|
437
|
+
if (input.kind === 'registry') {
|
|
438
|
+
const proposal = input.proposal
|
|
439
|
+
inbox(store.wikiDir(this.options.env.root), 'proposed', `Review ${proposal.kind}: ${proposal.name}`, reason, [], null, { kind: proposal.kind, proposal, curationJobId: id })
|
|
440
|
+
} else if (input.kind === 'plugin') parkPluginProposal(store.wikiDir(this.options.env.root), `Curated request ${id}`, pluginProposalSchema.parse(input.proposal))
|
|
441
|
+
else inbox(store.wikiDir(this.options.env.root), 'unresolved', 'Maintenance needs your decision', reason, [])
|
|
442
|
+
}
|
|
443
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
444
|
+
return saveCuration(this.options.env.root, job)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
applyJob(context: CallerContext, id: string, replacement?: unknown, reason = '', scope?: attachments.AttachScope): CurationJob {
|
|
448
|
+
if (!reason.trim()) throw invalid('Explain why this change should be applied.')
|
|
449
|
+
const root = this.options.env.root
|
|
450
|
+
const wiki = store.wikiDir(root)
|
|
451
|
+
const job = this.claim(context, id)
|
|
452
|
+
if (job.status === 'completed' || job.status === 'rejected' || job.status === 'needs_user') return job
|
|
453
|
+
const committed = readJsonOr<{ id: string } | null>(join(wiki, 'operations', `${job.id}.json`), null)
|
|
454
|
+
if (committed) {
|
|
455
|
+
job.status = 'completed'
|
|
456
|
+
job.receipt = { operationId: committed.id, notes: ['This request was already committed.'], parked: 0, flagged: 0 }
|
|
457
|
+
return saveCuration(root, job)
|
|
458
|
+
}
|
|
459
|
+
const input = job.input
|
|
460
|
+
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.')
|
|
461
|
+
if (input.kind === 'registry') {
|
|
462
|
+
const proposal = registryProposalSchema.parse(input.proposal)
|
|
463
|
+
const itemId = `${proposal.kind}s/${store.slug(proposal.name)}`
|
|
464
|
+
if (!['skill', 'prompt', 'rule'].includes(proposal.kind) || !proposal.name.trim() || !proposal.body.trim() || looksSecret(proposal.body)) throw invalid('Invalid text resource proposal.')
|
|
465
|
+
const existing = registry.list(root).find((item) => item.id === itemId)
|
|
466
|
+
const current = captureHashes(wiki).get(itemId)
|
|
467
|
+
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.')
|
|
468
|
+
const target = scope ?? { agentId: input.caller.agentId ?? context.agentId }
|
|
469
|
+
if (target.agentId && target.projectId) throw invalid('Choose one attachment scope.')
|
|
470
|
+
if (target.agentId && target.agentId !== context.agentId && target.agentId !== input.caller.agentId && !this.options.env.directory.agentName(target.agentId)) throw invalid('Unknown attachment target.')
|
|
471
|
+
if (target.projectId) findProject(root, target.projectId)
|
|
472
|
+
const before = existing ? store.snapshot(wiki, itemId, readFileSync(existing.path, 'utf8'), 'Curator', `Before ${id}`).id : null
|
|
473
|
+
const beforeChoices = attachments.load(root)
|
|
474
|
+
registry.writeItem(root, proposal.kind, proposal.name, proposal.description, proposal.body, `Curated request ${id}: ${reason || proposal.reason}`, context.agentName ?? 'Curator')
|
|
475
|
+
const saved = registry.list(root).find((item) => item.id === itemId)!
|
|
476
|
+
const after = store.currentRevision(wiki, itemId)!
|
|
477
|
+
const choices = attachments.load(root)
|
|
478
|
+
attachments.set(choices, [itemId], target, true, false)
|
|
479
|
+
attachments.save(root, choices)
|
|
480
|
+
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 })
|
|
481
|
+
job.receipt = { operationId: id, notes: [`Saved ${itemId}; enabled for ${target.agentId ?? target.projectId ?? 'everyone'}. ${reason || proposal.reason}`], parked: 0, flagged: 0 }
|
|
482
|
+
this.options.bus.emit({ type: 'plugins.changed' })
|
|
483
|
+
} else {
|
|
484
|
+
const prepared = job.prepared
|
|
485
|
+
const proposalInput = input.kind === 'proposal' ? input : null
|
|
486
|
+
const proposals = parseProposalSet(replacement ?? proposalInput?.proposals)
|
|
487
|
+
const origin = proposalInput?.caller.runId ?? null
|
|
488
|
+
const trigger: Trigger = prepared?.trigger ?? { kind: 'agent', runId: origin, actor: context.agentName ?? 'Curator' }
|
|
489
|
+
const evidence: Evidence = input.kind === 'proposal' ? (prepared?.evidence ?? {}) : this.prepare(input).evidence
|
|
490
|
+
const originEvidence: Evidence = prepared?.evidence ?? { runText: origin ? evidenceText(root, origin) : null, blocks: proposals.blocks.map((block) => block.block) }
|
|
491
|
+
{
|
|
492
|
+
if (input.kind === 'proposal') Object.assign(evidence, originEvidence)
|
|
493
|
+
evidence.runs = {}
|
|
494
|
+
evidence.sources = {}
|
|
495
|
+
const refs = [...proposals.pages, ...proposals.memories, ...proposals.blocks].flatMap(proposal => proposal.sourceRefs).slice(0, 20)
|
|
496
|
+
for (const ref of refs) {
|
|
497
|
+
if (ref.kind === 'run' && findRun(root, ref.ref) && readJsonOr<CallerContext | null>(join(root, 'runs', ref.ref, 'caller.json'), null)?.vaultAccess !== 'curator') {
|
|
498
|
+
const excluded = readJsonOr<{ excludedSourceIds?: string[] }>(join(root, 'config/settings.json'), {}).excludedSourceIds ?? []
|
|
499
|
+
const text = evidenceText(root, ref.ref)
|
|
500
|
+
if (!excluded.includes(ref.ref) && !looksSecret(text)) evidence.runs[ref.ref] = takeChars(text, 60_000)
|
|
501
|
+
}
|
|
502
|
+
if (ref.kind === 'source') {
|
|
503
|
+
const source = store.listSources(wiki).find(source => source.id === ref.ref)
|
|
504
|
+
if (source) {
|
|
505
|
+
const text = readFileSync(join(wiki, 'raw', source.id, source.name), 'utf8')
|
|
506
|
+
if (!looksSecret(text)) evidence.sources[ref.ref] = takeChars(text, 60_000)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const outcome = apply(wiki, prepared?.namespace ?? proposalInput?.namespace ?? store.LIBRARY_NAMESPACE, trigger, proposals, new Map(job.hashes), evidence, id, context.agentName ?? 'Curator')
|
|
512
|
+
if (outcome.operation) writeJson(join(wiki, 'operations', `${outcome.operation.id}.json`), { ...outcome.operation, protected: true })
|
|
513
|
+
job.receipt = { operationId: outcome.operation?.id ?? null, notes: outcome.notes, parked: outcome.parked, flagged: outcome.flagged }
|
|
514
|
+
}
|
|
515
|
+
job.status = job.receipt.parked ? 'needs_user' : 'completed'
|
|
516
|
+
job.reason = reason
|
|
517
|
+
reindexQuietly(root)
|
|
518
|
+
this.options.bus.emit({ type: 'wiki.changed' })
|
|
519
|
+
return saveCuration(root, job)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
finishRun(runId: string, failed: boolean): void {
|
|
523
|
+
for (const job of this.jobs().filter((job) => job.runId === runId && job.status === 'running')) {
|
|
524
|
+
job.status = failed ? 'failed' : 'completed'
|
|
525
|
+
job.reason = failed ? 'The run stopped before completing this request. Retry it explicitly.' : 'The review completed without applying a change.'
|
|
526
|
+
job.receipt ??= { operationId: null, notes: [job.reason], parked: 0, flagged: 0 }
|
|
527
|
+
saveCuration(this.options.env.root, job)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
retry(id: string): CurationJob {
|
|
532
|
+
const job = curationJob(this.options.env.root, id)
|
|
533
|
+
if (job.status !== 'failed') throw invalid('Only a failed request can be retried.')
|
|
534
|
+
return saveCuration(this.options.env.root, { ...job, status: 'queued', runId: null, reason: '', receipt: null })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
prepare(job: Job): { request: MaintenanceRequest; evidence: Evidence; captured: Map<string, string>; namespace: string; trigger: Trigger } {
|
|
538
|
+
const { env } = this.options
|
|
204
539
|
const root = env.root
|
|
205
540
|
const wiki = store.wikiDir(root)
|
|
206
541
|
const evidence: Evidence = {}
|
|
@@ -220,9 +555,7 @@ export class Maintenance {
|
|
|
220
555
|
text = null
|
|
221
556
|
}
|
|
222
557
|
if (text === null) {
|
|
223
|
-
|
|
224
|
-
bus.emit({ type: 'wiki.changed' })
|
|
225
|
-
return
|
|
558
|
+
throw invalid('Only text sources can be synthesized automatically right now.')
|
|
226
559
|
}
|
|
227
560
|
shown = takeChars(text, 60_000)
|
|
228
561
|
evidence.sourceText = text
|
|
@@ -234,8 +567,7 @@ export class Maintenance {
|
|
|
234
567
|
try {
|
|
235
568
|
found = runEvidence(root, job.runId)
|
|
236
569
|
} catch (error) {
|
|
237
|
-
|
|
238
|
-
return
|
|
570
|
+
throw error
|
|
239
571
|
}
|
|
240
572
|
evidence.runText = evidenceText(root, job.runId)
|
|
241
573
|
attached = runBlocks(env, job.runId)
|
|
@@ -244,7 +576,7 @@ export class Maintenance {
|
|
|
244
576
|
trigger = { kind: 'run', runId: job.runId }
|
|
245
577
|
request = { kind: 'run', runId: job.runId, transcript: shown }
|
|
246
578
|
} else {
|
|
247
|
-
attached = job.blockIds.flatMap((id) => {
|
|
579
|
+
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
580
|
try {
|
|
249
581
|
const block = blocks.find(wiki, id)
|
|
250
582
|
return block.front.readOnly ? [] : [block]
|
|
@@ -252,7 +584,7 @@ export class Maintenance {
|
|
|
252
584
|
return []
|
|
253
585
|
}
|
|
254
586
|
})
|
|
255
|
-
if (!attached.length)
|
|
587
|
+
if (!attached.length && job.kind !== 'review') throw invalid('No writable blocks remain in this job.')
|
|
256
588
|
namespace = store.LIBRARY_NAMESPACE
|
|
257
589
|
trigger = { kind: 'consolidate' }
|
|
258
590
|
request = { kind: 'consolidate' }
|
|
@@ -266,12 +598,29 @@ export class Maintenance {
|
|
|
266
598
|
} catch {
|
|
267
599
|
index = ''
|
|
268
600
|
}
|
|
601
|
+
return { request: { jobId: '', namespace, trigger: request, pages: context.pages, memories: context.memories, blocks: blockValues(attached), namespaces: namespaces(root), index }, evidence, captured, namespace, trigger }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async process(job: Job): Promise<void> {
|
|
605
|
+
const { env, bus } = this.options
|
|
606
|
+
const root = env.root
|
|
607
|
+
const wiki = store.wikiDir(root)
|
|
608
|
+
let prepared: ReturnType<Maintenance['prepare']>
|
|
609
|
+
try { prepared = this.prepare(job) } catch (error) {
|
|
610
|
+
// A run that is not eligible is an expected outcome, not a problem to report.
|
|
611
|
+
if (error instanceof NotEligible) return
|
|
612
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
613
|
+
if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
|
|
614
|
+
else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
const { namespace, trigger, evidence, captured } = prepared
|
|
269
618
|
const described = describeTrigger(trigger)
|
|
270
619
|
try {
|
|
271
620
|
// The job runs on the default account.
|
|
272
621
|
const account = this.options.accounts.context(this.options.accounts.resolve())
|
|
273
622
|
const proposed = await runMaintenance(
|
|
274
|
-
{
|
|
623
|
+
{ ...prepared.request, jobId: newId() },
|
|
275
624
|
{ cwd: wiki, product: env.productName, account, runtime: () => this.options.accounts.runtime(account) },
|
|
276
625
|
this.options.factory ?? piMaintainerFactory
|
|
277
626
|
)
|
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 {
|
package/src/memory/proposals.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface Evidence {
|
|
|
95
95
|
sourceText?: string | null
|
|
96
96
|
/** Ids of the blocks attached to the run. */
|
|
97
97
|
blocks?: string[]
|
|
98
|
+
runs?: Record<string, string>
|
|
99
|
+
sources?: Record<string, string>
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
const MIN_EXCERPT = 12
|
|
@@ -175,6 +177,7 @@ export function captureHashes(wiki: string): Map<string, string> {
|
|
|
175
177
|
for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
|
|
176
178
|
for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
|
|
177
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)
|
|
178
181
|
return hashes
|
|
179
182
|
}
|
|
180
183
|
|
|
@@ -223,7 +226,11 @@ interface Write {
|
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
/** Validates proposals and applies the valid ones as one operation. Pure file logic. */
|
|
226
|
-
export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence): ApplyOutcome {
|
|
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
|
+
}
|
|
227
234
|
const existing = store.loadPages(wiki)
|
|
228
235
|
const existingMemories = facts.loadMemories(wiki)
|
|
229
236
|
const root = dirname(wiki)
|
|
@@ -240,7 +247,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
240
247
|
])
|
|
241
248
|
const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
|
|
242
249
|
const writes: Write[] = []
|
|
243
|
-
const actor = triggerActor(trigger)
|
|
250
|
+
const actor = appliedBy ?? triggerActor(trigger)
|
|
244
251
|
const described = describeTrigger(trigger)
|
|
245
252
|
const fromUser = trigger.kind === 'user'
|
|
246
253
|
|
|
@@ -248,7 +255,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
248
255
|
const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
|
|
249
256
|
outcome.parked += 1
|
|
250
257
|
outcome.notes.push(`${label} went to the Inbox: ${reason}`)
|
|
251
|
-
inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, payload)
|
|
258
|
+
inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, operationKey && payload && typeof payload === 'object' ? { ...payload, curationJobId: operationKey } : payload)
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
// Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
|
|
@@ -259,11 +266,12 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
259
266
|
for (const reference of refs) {
|
|
260
267
|
let id: string | null = null
|
|
261
268
|
if (reference.kind === 'source') {
|
|
262
|
-
|
|
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
|
|
263
271
|
} else if (reference.kind === 'run') {
|
|
264
|
-
if (triggerRunId(trigger) === reference.ref
|
|
272
|
+
if (quotedIn(evidence.runs?.[reference.ref] ?? (triggerRunId(trigger) === reference.ref ? evidence.runText : null), reference.excerpt)) id = `run:${reference.ref}`
|
|
265
273
|
} else if (reference.kind === 'url') {
|
|
266
|
-
if (reference.ref.startsWith('https://') || reference.ref.startsWith('http://')) id = `url:${reference.ref}`
|
|
274
|
+
if (!operationKey && (reference.ref.startsWith('https://') || reference.ref.startsWith('http://'))) id = `url:${reference.ref}`
|
|
267
275
|
} else if (reference.kind === 'user') {
|
|
268
276
|
if (fromUser) id = 'user:accepted'
|
|
269
277
|
}
|
|
@@ -277,7 +285,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
277
285
|
.filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
|
|
278
286
|
.filter((link) => link.toId !== ownId)
|
|
279
287
|
.map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
|
|
280
|
-
const changedSince = (id: string, path: string): boolean => captured.has(id) && captured.get(id) !== fileHash(path)
|
|
288
|
+
const changedSince = (id: string, path: string): boolean => (captured.has(id) || Boolean(operationKey)) && captured.get(id) !== fileHash(path)
|
|
281
289
|
|
|
282
290
|
for (const proposal of proposals.pages) {
|
|
283
291
|
const pageId = store.slug(proposal.id ?? proposal.title)
|
|
@@ -464,7 +472,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
464
472
|
}
|
|
465
473
|
})
|
|
466
474
|
|
|
467
|
-
const operationId = newId()
|
|
475
|
+
const operationId = operationKey ?? newId()
|
|
468
476
|
const short = operationId.slice(0, 8)
|
|
469
477
|
const pages: OperationPage[] = writes.map((write) => ({
|
|
470
478
|
pageId: write.entryId,
|
|
@@ -87,6 +87,17 @@ test('an agent recalls its own chats by default and everything on request', () =
|
|
|
87
87
|
expect(searchTool(env, { ...agent('agent-a'), runId: 'chat-1' }, { query: 'pnpm' })).not.toContain('run chat-1')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test('a caller cannot recall the conversations of agents its host excludes', () => {
|
|
91
|
+
const env = setup()
|
|
92
|
+
sync(env.root)
|
|
93
|
+
const caller = { ...agent('agent-a'), recallExcludeAgents: ['agent-b'] }
|
|
94
|
+
const all = searchTool(env, caller, { query: 'pnpm', scope: 'all' })
|
|
95
|
+
expect(all).toContain('chat-1')
|
|
96
|
+
expect(all).not.toContain('chat-2')
|
|
97
|
+
expect(() => readTool(env, caller, { run: 'chat-2' })).toThrow()
|
|
98
|
+
expect(readTool(env, agent('agent-a'), { run: 'chat-2' })).toContain('Plan the launch')
|
|
99
|
+
})
|
|
100
|
+
|
|
90
101
|
test('a session recalls only session runs of its project', () => {
|
|
91
102
|
const env = setup()
|
|
92
103
|
sync(env.root)
|