@xingular/harness 0.1.7 → 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 +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -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 +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -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/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- 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 +6 -0
- 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/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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 +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -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 +0 -130
- 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 +0 -95
- 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 +0 -27
- 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 +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- 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 +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- 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 +0 -57
- 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 +0 -58
- 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 +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -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,7 +21,7 @@ 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'
|
|
25
26
|
import { enqueueCuration, listCuration, curationJob, saveCuration, requireCurator, type CurationJob } from './curation'
|
|
26
27
|
import * as registry from '../skills/registry'
|
|
@@ -46,27 +47,30 @@ export function chatUpdatesPaused(settings: unknown): boolean {
|
|
|
46
47
|
return (settings as { autoMaintenanceChat?: unknown } | null)?.autoMaintenanceChat === false
|
|
47
48
|
}
|
|
48
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
|
+
|
|
49
53
|
/** Where a settled run's knowledge is filed and what the job reads of it; throws the reason it is not eligible. */
|
|
50
54
|
function runEvidence(root: string, runId: string): { namespace: string; transcript: string } {
|
|
51
55
|
const run = findRun(root, runId)
|
|
52
56
|
if (!run) throw new Error('Run was not found.')
|
|
53
57
|
const settings = readJsonOr<{ excludedSourceIds?: unknown } | null>(join(root, 'config/settings.json'), null)
|
|
54
|
-
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')
|
|
55
59
|
const projectId = run.mode === 'chat' || run.mode === 'code' ? run.projectId : null
|
|
56
60
|
let namespace: string
|
|
57
61
|
if (projectId) {
|
|
58
62
|
const project = findProject(root, projectId)
|
|
59
|
-
if (!project.autoMaintenance) throw new
|
|
63
|
+
if (!project.autoMaintenance) throw new NotEligible('automatic updates are paused for this project')
|
|
60
64
|
namespace = project.wikiNamespace
|
|
61
65
|
} else if (run.mode === 'chat') {
|
|
62
|
-
if (chatUpdatesPaused(settings)) throw new
|
|
66
|
+
if (chatUpdatesPaused(settings)) throw new NotEligible('automatic updates are paused for Chat')
|
|
63
67
|
namespace = store.LIBRARY_NAMESPACE
|
|
64
68
|
} else {
|
|
65
|
-
throw new
|
|
69
|
+
throw new NotEligible('run has no project')
|
|
66
70
|
}
|
|
67
71
|
const text = transcript(run.goal ?? '', readEvents(root, runId))
|
|
68
|
-
if (!text.includes('Assistant:')) throw new
|
|
69
|
-
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')
|
|
70
74
|
return { namespace, transcript: text }
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -144,6 +148,12 @@ export function fullBlocks(wiki: string, tried: Set<string>): string[] {
|
|
|
144
148
|
|
|
145
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 }))
|
|
146
150
|
|
|
151
|
+
export interface MaintenanceBatchDecision {
|
|
152
|
+
id: string
|
|
153
|
+
decision: 'apply' | 'reject' | 'needs_user'
|
|
154
|
+
reason: string
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
export interface MaintenanceOptions {
|
|
148
158
|
env: HarnessEnv
|
|
149
159
|
bus: Bus
|
|
@@ -208,7 +218,12 @@ export class Maintenance {
|
|
|
208
218
|
if (this.idle) clearTimeout(this.idle)
|
|
209
219
|
if (this.options.external) {
|
|
210
220
|
if (job.kind === 'run') {
|
|
211
|
-
try {
|
|
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
|
+
}
|
|
212
227
|
const run = findRun(this.options.env.root, job.runId)
|
|
213
228
|
const namespace = run?.projectId ? findProject(this.options.env.root, run.projectId).wikiNamespace : store.LIBRARY_NAMESPACE
|
|
214
229
|
enqueueCuration(this.options.env.root, { ...job, namespace })
|
|
@@ -233,6 +248,150 @@ export class Maintenance {
|
|
|
233
248
|
|
|
234
249
|
jobs(): CurationJob[] { return listCuration(this.options.env.root) }
|
|
235
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
|
+
|
|
236
395
|
claim(context: CallerContext, id: string, prepare = true): CurationJob {
|
|
237
396
|
requireCurator(context)
|
|
238
397
|
const root = this.options.env.root
|
|
@@ -448,6 +607,8 @@ export class Maintenance {
|
|
|
448
607
|
const wiki = store.wikiDir(root)
|
|
449
608
|
let prepared: ReturnType<Maintenance['prepare']>
|
|
450
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
|
|
451
612
|
const message = error instanceof Error ? error.message : String(error)
|
|
452
613
|
if (job.kind === 'run') log(`maintenance skipped run ${job.runId}: ${message}`)
|
|
453
614
|
else if (job.kind === 'ingest') { inbox(wiki, 'unresolved', 'Source was imported but not summarized', message, []); bus.emit({ type: 'wiki.changed' }) }
|
package/src/memory/recall.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and events stay canonical; the recall index is rebuilt from `runs/`.
|
|
4
4
|
import type { DatabaseSync } from 'node:sqlite'
|
|
5
5
|
|
|
6
|
+
import { logLine } from '../core/bus'
|
|
6
7
|
import type { HarnessEnv } from '../core/env'
|
|
7
8
|
import { invalid, notFound } from '../core/errors'
|
|
8
9
|
import { all, run as execute, transaction, withIndex } from '../core/index'
|
|
@@ -67,7 +68,7 @@ export function indexRun(root: string, runId: string): void {
|
|
|
67
68
|
try {
|
|
68
69
|
withIndex(root, (database) => writeRun(database, run, found))
|
|
69
70
|
} catch (error) {
|
|
70
|
-
|
|
71
|
+
logLine(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}`)
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createEnv } from '../core/env'
|
|
7
|
+
import { closeIndex } from '../core/index'
|
|
8
|
+
import { writeJson } from '../core/fs'
|
|
9
|
+
import { runRecordSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
|
|
10
|
+
import { appendEvent, saveRun } from '../sessions/runStore'
|
|
11
|
+
import { WIKI_AGENT_TOOLS } from '../tools/definitions'
|
|
12
|
+
import type { CallerContext } from './caller'
|
|
13
|
+
import { dispatch } from './dispatch'
|
|
14
|
+
import { readTool, searchTool } from './trajectory'
|
|
15
|
+
|
|
16
|
+
const roots: string[] = []
|
|
17
|
+
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
18
|
+
|
|
19
|
+
function setup() {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-trajectory-'))
|
|
21
|
+
roots.push(root)
|
|
22
|
+
writeJson(join(root, 'projects.json'), [
|
|
23
|
+
{ id: 'p1', name: 'App', slug: 'app', rootPath: '/tmp/app', autoMaintenance: true, createdAt: 't' },
|
|
24
|
+
{ id: 'p2', name: 'Site', slug: 'site', rootPath: '/tmp/site', autoMaintenance: true, createdAt: 't' }
|
|
25
|
+
])
|
|
26
|
+
const env = createEnv(root, {
|
|
27
|
+
directory: {
|
|
28
|
+
agentName: (id) => ({ 'agent-a': 'Nora', 'agent-b': 'Kai', 'agent-hidden': 'Hidden' })[id] ?? null,
|
|
29
|
+
sessionAgent: (id) => id === 'hidden-session' ? 'agent-hidden' : null
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
return { root, env }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(id: string, overrides: Partial<RunRecord> = {}): RunRecord {
|
|
36
|
+
return runRecordSchema.parse({
|
|
37
|
+
id,
|
|
38
|
+
projectId: null,
|
|
39
|
+
mode: 'chat',
|
|
40
|
+
harness: 'pi',
|
|
41
|
+
sessionType: 'structured',
|
|
42
|
+
sessionId: `session-${id}`,
|
|
43
|
+
goal: `Goal for ${id}`,
|
|
44
|
+
startedAt: '2026-10-18T10:00:00.000Z',
|
|
45
|
+
endedAt: null,
|
|
46
|
+
status: 'running',
|
|
47
|
+
agentId: 'agent-a',
|
|
48
|
+
...overrides
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function event(runId: string, sequence: number, type: RunEvent['type'], payload: unknown, native?: unknown): RunEvent {
|
|
53
|
+
return {
|
|
54
|
+
id: `${runId}-event-${sequence}`,
|
|
55
|
+
runId,
|
|
56
|
+
sessionId: `session-${runId}`,
|
|
57
|
+
sequence,
|
|
58
|
+
timestamp: `2026-10-18T10:00:${String(sequence).padStart(2, '0')}.000Z`,
|
|
59
|
+
type,
|
|
60
|
+
payload,
|
|
61
|
+
...(native === undefined ? {} : { native })
|
|
62
|
+
} as RunEvent
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function save(root: string, record: RunRecord, events: RunEvent[] = []): void {
|
|
66
|
+
saveRun(root, record)
|
|
67
|
+
for (const entry of events) appendEvent(root, entry)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const agent: CallerContext = { agentId: 'agent-a', actor: 'Chat' }
|
|
71
|
+
|
|
72
|
+
test('trajectory search filters runs and paginates deterministically, including active runs', () => {
|
|
73
|
+
const { root, env } = setup()
|
|
74
|
+
save(root, run('z-run', { projectId: 'p2', agentId: 'agent-b' }))
|
|
75
|
+
save(root, run('b-run', { projectId: 'p2', agentId: 'agent-b' }))
|
|
76
|
+
save(root, run('a-run', { projectId: 'p1', agentId: 'agent-a', startedAt: '2026-10-17T10:00:00.000Z', status: 'completed', endedAt: '2026-10-17T10:01:00.000Z' }))
|
|
77
|
+
|
|
78
|
+
const first = JSON.parse(searchTool(env, agent, { project: 'site', agent: 'Kai', limit: 1 }))
|
|
79
|
+
expect(first.runs.map((item: { id: string }) => item.id)).toEqual(['z-run'])
|
|
80
|
+
expect(first.runs[0]).toMatchObject({ agentId: 'agent-b', agentName: 'Kai', project: { id: 'p2', name: 'Site', slug: 'site' }, status: 'running' })
|
|
81
|
+
expect(first.hasMore).toBe(true)
|
|
82
|
+
|
|
83
|
+
const second = JSON.parse(searchTool(env, agent, { project: 'p2', agent: 'agent-b', limit: 1, cursor: first.nextCursor }))
|
|
84
|
+
expect(second.runs.map((item: { id: string }) => item.id)).toEqual(['b-run'])
|
|
85
|
+
expect(second.hasMore).toBe(false)
|
|
86
|
+
|
|
87
|
+
const bounded = JSON.parse(searchTool(env, agent, { since: '2026-10-18', until: '2026-10-18', status: 'running' }))
|
|
88
|
+
expect(bounded.runs.map((item: { id: string }) => item.id)).toEqual(['z-run', 'b-run'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('trajectory search and read keep host-excluded agents hidden', () => {
|
|
92
|
+
const { root, env } = setup()
|
|
93
|
+
save(root, run('hidden-agent-run', { agentId: 'agent-hidden' }))
|
|
94
|
+
save(root, run('hidden-session-run', { agentId: null, sessionId: 'hidden-session' }))
|
|
95
|
+
save(root, run('visible-run'))
|
|
96
|
+
const caller = { ...agent, recallExcludeAgents: ['agent-hidden'] }
|
|
97
|
+
|
|
98
|
+
const results = JSON.parse(searchTool(env, caller, {}))
|
|
99
|
+
expect(results.runs.map((item: { id: string }) => item.id)).toEqual(['visible-run'])
|
|
100
|
+
expect(() => readTool(env, caller, { run: 'hidden-agent-run' })).toThrow()
|
|
101
|
+
expect(() => readTool(env, caller, { run: 'hidden-session-run' })).toThrow()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('trajectory_read resolves current and returns bounded event pages without native data', () => {
|
|
105
|
+
const { root, env } = setup()
|
|
106
|
+
const longInput = 'x'.repeat(20_000)
|
|
107
|
+
save(root, run('active-run'), [
|
|
108
|
+
event('active-run', 0, 'context.snapshot', { systemPrompt: 'Be useful', tools: [] }, { privateNative: true }),
|
|
109
|
+
event('active-run', 1, 'tool.started', { toolCallId: 't1', name: 'read', input: longInput }),
|
|
110
|
+
event('active-run', 2, 'tool.completed', { toolCallId: 't1', name: 'read', output: 'file contents' })
|
|
111
|
+
])
|
|
112
|
+
const caller = { ...agent, runId: 'active-run' }
|
|
113
|
+
|
|
114
|
+
const initial = JSON.parse(readTool(env, caller, { run: 'current', limit: 1 }))
|
|
115
|
+
expect(initial.events[0]).toMatchObject({ sequence: 0, type: 'context.snapshot', payload: { systemPrompt: 'Be useful', tools: [] } })
|
|
116
|
+
expect(JSON.stringify(initial)).not.toContain('privateNative')
|
|
117
|
+
|
|
118
|
+
const first = JSON.parse(readTool(env, caller, { run: 'current', afterSequence: 0, limit: 1 }))
|
|
119
|
+
expect(first).toMatchObject({ active: true, run: { id: 'active-run', status: 'running' }, hasMore: true, nextAfterSequence: 1 })
|
|
120
|
+
expect(first.events[0]).toMatchObject({ sequence: 1, type: 'tool.started', truncatedFields: ['payload.input'] })
|
|
121
|
+
expect(JSON.stringify(first)).not.toContain('privateNative')
|
|
122
|
+
expect(first.events[0].payload.input.length).toBeLessThan(longInput.length)
|
|
123
|
+
|
|
124
|
+
const second = JSON.parse(readTool(env, caller, { run: 'active-run', afterSequence: first.nextAfterSequence, limit: 1 }))
|
|
125
|
+
expect(second.events[0]).toMatchObject({ sequence: 2, type: 'tool.completed', payload: { output: 'file contents' } })
|
|
126
|
+
expect(second.hasMore).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('trajectory_read follows execution events through the first outcome only', () => {
|
|
130
|
+
const { root, env } = setup()
|
|
131
|
+
save(root, run('settled-run', { status: 'completed', endedAt: '2026-10-18T10:01:00.000Z' }), [
|
|
132
|
+
event('settled-run', 0, 'started', {}),
|
|
133
|
+
event('settled-run', 1, 'completed', {}),
|
|
134
|
+
event('settled-run', 2, 'tool.started', { toolCallId: 'late', name: 'ignored' }, { hostIgnored: 'run_already_ended' })
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
const result = JSON.parse(readTool(env, agent, { run: 'settled-run' }))
|
|
138
|
+
expect(result.active).toBe(false)
|
|
139
|
+
expect(result.events.map((item: { sequence: number }) => item.sequence)).toEqual([0, 1])
|
|
140
|
+
expect(JSON.stringify(result)).not.toContain('ignored')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('trajectory_read keeps account identifiers and credential fields out of event payloads', () => {
|
|
144
|
+
const { root, env } = setup()
|
|
145
|
+
save(root, run('safe-run'), [
|
|
146
|
+
event('safe-run', 0, 'account.status', { accountId: 'private-account-id', category: 'quota', message: 'Limit reached for sk-abcdefghijklmnopqrstuvwxyz012345' }),
|
|
147
|
+
event('safe-run', 1, 'tool.started', { toolCallId: 't1', name: 'login', input: '{"username":"ada","password":"private-password"}' }),
|
|
148
|
+
event('safe-run', 2, 'usage.updated', { inputTokens: 10, request: { index: 1, reasoningSummary: 'private reasoning summary' } })
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
const result = JSON.parse(readTool(env, agent, { run: 'safe-run' }))
|
|
152
|
+
expect(result.events[0].omittedFields).toContain('payload.accountId')
|
|
153
|
+
expect(JSON.stringify(result)).not.toContain('private-account-id')
|
|
154
|
+
expect(JSON.stringify(result)).not.toContain('sk-abcdefghijklmnopqrstuvwxyz012345')
|
|
155
|
+
expect(JSON.stringify(result)).not.toContain('private-password')
|
|
156
|
+
expect(result.events[1].payload.input).not.toContain('password')
|
|
157
|
+
expect(result.events[2].omittedFields).toContain('payload.request.reasoningSummary')
|
|
158
|
+
expect(JSON.stringify(result)).not.toContain('private reasoning summary')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('trajectory calls reject malformed filters and missing current runs', () => {
|
|
162
|
+
const { env } = setup()
|
|
163
|
+
expect(() => searchTool(env, agent, { status: 'unknown' })).toThrow()
|
|
164
|
+
expect(() => searchTool(env, agent, { since: 'not-a-date' })).toThrow()
|
|
165
|
+
expect(() => searchTool(env, agent, { cursor: 'not-a-cursor' })).toThrow()
|
|
166
|
+
expect(() => readTool(env, agent, { run: 'current' })).toThrow()
|
|
167
|
+
expect(() => readTool(env, agent, { run: 'missing' })).toThrow()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('the model tool registry exposes both trajectory calls through Wiki dispatch', () => {
|
|
171
|
+
const { root, env } = setup()
|
|
172
|
+
save(root, run('registered-run'))
|
|
173
|
+
const calls = WIKI_AGENT_TOOLS.map((tool) => tool.call)
|
|
174
|
+
expect(calls).toContain('trajectory.search')
|
|
175
|
+
expect(calls).toContain('trajectory.read')
|
|
176
|
+
expect(JSON.parse(dispatch(env, agent, 'trajectory.search', {})).runs[0].id).toBe('registered-run')
|
|
177
|
+
expect(JSON.parse(dispatch(env, agent, 'trajectory.read', { run: 'registered-run' })).run.id).toBe('registered-run')
|
|
178
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Read-only views over the canonical run records and validated event logs.
|
|
2
|
+
import type { HarnessEnv } from '../core/env'
|
|
3
|
+
import { invalid, notFound } from '../core/errors'
|
|
4
|
+
import { listProjects } from '../core/projects'
|
|
5
|
+
import { compareText } from '../core/text'
|
|
6
|
+
import { scrub } from '../accounts/store'
|
|
7
|
+
import { scrubSensitive } from '../mcp/connections'
|
|
8
|
+
import { isActiveStatus, runStatusSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
|
|
9
|
+
import { readAllRuns } from '../sessions/runIndex'
|
|
10
|
+
import { findRun, snapshot } from '../sessions/runStore'
|
|
11
|
+
import type { CallerContext } from './caller'
|
|
12
|
+
|
|
13
|
+
const SEARCH_DEFAULT = 10
|
|
14
|
+
const SEARCH_MAX = 30
|
|
15
|
+
const READ_DEFAULT = 8
|
|
16
|
+
const READ_MAX = 10
|
|
17
|
+
const EVENT_MAX_CHARS = 18_000
|
|
18
|
+
const PAGE_MAX_CHARS = 48_000
|
|
19
|
+
|
|
20
|
+
export type Params = Record<string, unknown> | null | undefined
|
|
21
|
+
|
|
22
|
+
function textParam(params: Params, key: string): string {
|
|
23
|
+
const value = params?.[key]
|
|
24
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundedInteger(params: Params, key: string, fallback: number, minimum: number, maximum: number): number {
|
|
28
|
+
const value = params?.[key]
|
|
29
|
+
if (value === undefined) return fallback
|
|
30
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum || value > maximum) {
|
|
31
|
+
throw invalid(`${key} must be an integer from ${minimum} to ${maximum}.`)
|
|
32
|
+
}
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dateValue(params: Params, key: string): { text: string; timestamp: number } | null {
|
|
37
|
+
const value = textParam(params, key)
|
|
38
|
+
if (!value) return null
|
|
39
|
+
if (!/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value)) throw invalid(`${key} must be a YYYY-MM-DD date or ISO timestamp.`)
|
|
40
|
+
const timestamp = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}${key === 'until' ? 'T23:59:59.999Z' : 'T00:00:00.000Z'}` : value)
|
|
41
|
+
if (!Number.isFinite(timestamp)) throw invalid(`${key} must be a valid date or ISO timestamp.`)
|
|
42
|
+
return { text: value, timestamp }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Cursor {
|
|
46
|
+
startedAt: string
|
|
47
|
+
id: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decodeCursor(value: string): Cursor | null {
|
|
51
|
+
if (!value) return null
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<Cursor>
|
|
54
|
+
if (typeof parsed.startedAt !== 'string' || !parsed.startedAt || typeof parsed.id !== 'string' || !parsed.id) throw new Error('invalid cursor')
|
|
55
|
+
return { startedAt: parsed.startedAt, id: parsed.id }
|
|
56
|
+
} catch {
|
|
57
|
+
throw invalid('The trajectory cursor is invalid. Start a new search without it.')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encodeCursor(run: RunRecord): string {
|
|
62
|
+
return Buffer.from(JSON.stringify({ startedAt: run.startedAt, id: run.id })).toString('base64url')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hidden(env: HarnessEnv, context: CallerContext, run: RunRecord): boolean {
|
|
66
|
+
const excluded = context.recallExcludeAgents ?? []
|
|
67
|
+
if (!excluded.length) return false
|
|
68
|
+
return excluded.includes(run.agentId ?? '') || excluded.includes(run.sessionId) || excluded.includes(env.directory.sessionAgent(run.sessionId) ?? '')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function afterCursor(run: RunRecord, cursor: Cursor): boolean {
|
|
72
|
+
return run.startedAt < cursor.startedAt || (run.startedAt === cursor.startedAt && run.id < cursor.id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summary(env: HarnessEnv, run: RunRecord, projects: ReturnType<typeof listProjects>) {
|
|
76
|
+
const project = projects.find((entry) => entry.id === run.projectId)
|
|
77
|
+
const goal = run.goal?.trim() ?? ''
|
|
78
|
+
return {
|
|
79
|
+
id: run.id,
|
|
80
|
+
agentId: run.agentId,
|
|
81
|
+
agentName: run.agentId ? env.directory.agentName(run.agentId) : null,
|
|
82
|
+
project: project ? { id: project.id, name: project.name, slug: project.slug } : null,
|
|
83
|
+
mode: run.mode,
|
|
84
|
+
status: run.status,
|
|
85
|
+
goal: goal.length > 500 ? `${goal.slice(0, 497)}…` : goal || null,
|
|
86
|
+
startedAt: run.startedAt,
|
|
87
|
+
endedAt: run.endedAt,
|
|
88
|
+
parentRunId: run.parentRunId,
|
|
89
|
+
relation: run.relation
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `trajectory.search`: structured list/search over the current Harness data root. */
|
|
94
|
+
export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
95
|
+
const agent = textParam(params, 'agent')
|
|
96
|
+
const projectKey = textParam(params, 'project')
|
|
97
|
+
const mode = textParam(params, 'mode')
|
|
98
|
+
const status = textParam(params, 'status')
|
|
99
|
+
const since = dateValue(params, 'since')
|
|
100
|
+
const until = dateValue(params, 'until')
|
|
101
|
+
const limit = boundedInteger(params, 'limit', SEARCH_DEFAULT, 1, SEARCH_MAX)
|
|
102
|
+
const cursor = decodeCursor(textParam(params, 'cursor'))
|
|
103
|
+
|
|
104
|
+
if (status && !runStatusSchema.safeParse(status).success) throw invalid(`Unknown run status ${status}.`)
|
|
105
|
+
const projects = listProjects(env.root)
|
|
106
|
+
const project = projectKey ? projects.find((entry) => entry.id === projectKey || entry.slug === projectKey || entry.name.toLowerCase() === projectKey.toLowerCase()) : null
|
|
107
|
+
if (projectKey && !project) throw invalid(`There is no project called ${projectKey}.`)
|
|
108
|
+
|
|
109
|
+
const matching = readAllRuns(env.root)
|
|
110
|
+
.filter((run) => !hidden(env, context, run))
|
|
111
|
+
.filter((run) => !agent || run.agentId === agent || (run.agentId !== null && env.directory.agentName(run.agentId)?.toLowerCase() === agent.toLowerCase()))
|
|
112
|
+
.filter((run) => !projectKey || run.projectId === project?.id)
|
|
113
|
+
.filter((run) => !mode || run.mode === mode)
|
|
114
|
+
.filter((run) => !status || run.status === status)
|
|
115
|
+
.filter((run) => !since || Date.parse(run.startedAt) >= since.timestamp)
|
|
116
|
+
.filter((run) => !until || Date.parse(run.startedAt) <= until.timestamp)
|
|
117
|
+
.filter((run) => !cursor || afterCursor(run, cursor))
|
|
118
|
+
.sort((left, right) => compareText(right.startedAt, left.startedAt) || compareText(right.id, left.id))
|
|
119
|
+
|
|
120
|
+
const page = matching.slice(0, limit)
|
|
121
|
+
const hasMore = matching.length > page.length
|
|
122
|
+
return JSON.stringify({
|
|
123
|
+
runs: page.map((run) => summary(env, run, projects)),
|
|
124
|
+
hasMore,
|
|
125
|
+
nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function boundedValue(value: unknown, path: string, truncated: string[], stringLimit: number, arrayLimit: number, depth = 0): unknown {
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
const fieldLimit = path.endsWith('.systemPrompt') || path.endsWith('.contextUpdate') ? Math.min(12_000, stringLimit) :
|
|
132
|
+
path.endsWith('.input') || path.endsWith('.output') || path.endsWith('.summary') ? Math.min(10_000, stringLimit) : stringLimit
|
|
133
|
+
if (value.length <= fieldLimit) return value
|
|
134
|
+
truncated.push(path)
|
|
135
|
+
return `${value.slice(0, Math.max(0, fieldLimit - 16))}… [truncated]`
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
const kept = value.slice(0, arrayLimit).map((item, index) => boundedValue(item, `${path}[${index}]`, truncated, stringLimit, arrayLimit, depth + 1))
|
|
139
|
+
if (value.length > arrayLimit) truncated.push(path)
|
|
140
|
+
return kept
|
|
141
|
+
}
|
|
142
|
+
if (!value || typeof value !== 'object' || depth >= 8) return value
|
|
143
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
144
|
+
const kept = entries.slice(0, 40).map(([key, item]) => [key, boundedValue(item, `${path}.${key}`, truncated, stringLimit, arrayLimit, depth + 1)])
|
|
145
|
+
if (entries.length > 40) truncated.push(path)
|
|
146
|
+
return Object.fromEntries(kept)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function publicEvent(event: RunEvent): Record<string, unknown> {
|
|
150
|
+
let stringLimit = 12_000
|
|
151
|
+
let arrayLimit = 20
|
|
152
|
+
while (true) {
|
|
153
|
+
const truncated: string[] = []
|
|
154
|
+
const omitted: string[] = []
|
|
155
|
+
let payload = scrubSensitive(event.payload)
|
|
156
|
+
if (event.type === 'account.status' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
157
|
+
const account = payload as Record<string, unknown>
|
|
158
|
+
if (Object.hasOwn(account, 'accountId')) {
|
|
159
|
+
delete account.accountId
|
|
160
|
+
omitted.push('payload.accountId')
|
|
161
|
+
}
|
|
162
|
+
if (typeof account.message === 'string') account.message = scrub(account.message)
|
|
163
|
+
}
|
|
164
|
+
if (event.type === 'usage.updated' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
165
|
+
const request = (payload as Record<string, unknown>).request
|
|
166
|
+
if (request && typeof request === 'object' && !Array.isArray(request) && Object.hasOwn(request, 'reasoningSummary')) {
|
|
167
|
+
delete (request as Record<string, unknown>).reasoningSummary
|
|
168
|
+
omitted.push('payload.request.reasoningSummary')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const value: Record<string, unknown> = {
|
|
172
|
+
id: event.id,
|
|
173
|
+
sequence: event.sequence,
|
|
174
|
+
timestamp: event.timestamp,
|
|
175
|
+
type: event.type,
|
|
176
|
+
payload: boundedValue(payload, 'payload', truncated, stringLimit, arrayLimit)
|
|
177
|
+
}
|
|
178
|
+
if (event.coalescedFrom !== undefined) value.coalescedFrom = event.coalescedFrom
|
|
179
|
+
if (truncated.length) value.truncatedFields = truncated.slice(0, 40)
|
|
180
|
+
if (omitted.length) value.omittedFields = omitted
|
|
181
|
+
if (JSON.stringify(value).length <= EVENT_MAX_CHARS || (stringLimit <= 500 && arrayLimit <= 1)) return value
|
|
182
|
+
stringLimit = Math.max(500, Math.floor(stringLimit / 2))
|
|
183
|
+
arrayLimit = Math.max(1, Math.floor(arrayLimit / 2))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `trajectory.read`: a bounded page of the events that affected a run. */
|
|
188
|
+
export function readTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
189
|
+
const requested = textParam(params, 'run')
|
|
190
|
+
if (!requested) throw invalid('Give a run id or "current".')
|
|
191
|
+
const runId = requested === 'current' ? context.runId ?? '' : requested
|
|
192
|
+
if (!runId) throw invalid('This caller has no current run.')
|
|
193
|
+
const run = findRun(env.root, runId)
|
|
194
|
+
if (!run || hidden(env, context, run)) throw notFound('Run')
|
|
195
|
+
|
|
196
|
+
const afterSequence = boundedInteger(params, 'afterSequence', -1, -1, Number.MAX_SAFE_INTEGER)
|
|
197
|
+
const limit = boundedInteger(params, 'limit', READ_DEFAULT, 1, READ_MAX)
|
|
198
|
+
const captured = snapshot(env.root, run.id)
|
|
199
|
+
const candidates = captured.events.filter((event) => event.sequence > afterSequence)
|
|
200
|
+
const events: Record<string, unknown>[] = []
|
|
201
|
+
let eventChars = 0
|
|
202
|
+
for (const event of candidates.slice(0, limit)) {
|
|
203
|
+
const bounded = publicEvent(event)
|
|
204
|
+
const size = JSON.stringify(bounded).length
|
|
205
|
+
if (events.length && eventChars + size > PAGE_MAX_CHARS) break
|
|
206
|
+
events.push(bounded)
|
|
207
|
+
eventChars += size
|
|
208
|
+
}
|
|
209
|
+
const lastSequence = events.length ? Number(events[events.length - 1].sequence) : afterSequence
|
|
210
|
+
const hasMore = candidates.some((event) => event.sequence > lastSequence)
|
|
211
|
+
const projects = listProjects(env.root)
|
|
212
|
+
const result = {
|
|
213
|
+
run: summary(env, captured.run, projects),
|
|
214
|
+
active: isActiveStatus(captured.run.status),
|
|
215
|
+
events,
|
|
216
|
+
hasMore,
|
|
217
|
+
nextAfterSequence: lastSequence
|
|
218
|
+
}
|
|
219
|
+
return JSON.stringify(result)
|
|
220
|
+
}
|