@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
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
|
|
|
@@ -221,6 +222,13 @@ export function sourceLabel(env: HarnessEnv, mode: string, sessionId: string, pr
|
|
|
221
222
|
}
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
/** Whether a conversation belongs to an agent the caller may not recall. */
|
|
226
|
+
function excluded(env: HarnessEnv, context: CallerContext, sessionId: string): boolean {
|
|
227
|
+
const hidden = context.recallExcludeAgents ?? []
|
|
228
|
+
if (!hidden.length) return false
|
|
229
|
+
return hidden.includes(sessionId) || hidden.includes(env.directory.sessionAgent(sessionId) ?? '')
|
|
230
|
+
}
|
|
231
|
+
|
|
224
232
|
/** `recall.search` as text for a model. */
|
|
225
233
|
export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
226
234
|
const query = textParam(params, 'query')
|
|
@@ -229,7 +237,7 @@ export function searchTool(env: HarnessEnv, context: CallerContext, params: Para
|
|
|
229
237
|
const requested = params?.limit
|
|
230
238
|
const limit = typeof requested === 'number' && Number.isInteger(requested) && requested >= 0 ? requested : SEARCH_DEFAULT
|
|
231
239
|
// The caller's current run is what it is in the middle of, not something to recall.
|
|
232
|
-
const hits = search(env.root, query, scope, limit + 4).filter((hit) => hit.runId !== context.runId).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
|
|
240
|
+
const hits = search(env.root, query, scope, limit + 4 + (context.recallExcludeAgents?.length ? SEARCH_MAX : 0)).filter((hit) => hit.runId !== context.runId && !excluded(env, context, hit.sessionId)).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
|
|
233
241
|
if (!hits.length) {
|
|
234
242
|
return scope.sessionId && !isSession(context) ? 'Nothing in your own conversations matches. Pass scope "all" to search every conversation and Harness run.' : 'Nothing in the recorded history matches.'
|
|
235
243
|
}
|
|
@@ -242,7 +250,7 @@ export function readTool(env: HarnessEnv, context: CallerContext, params: Params
|
|
|
242
250
|
const run = findRun(env.root, runId)
|
|
243
251
|
if (!run) throw notFound('Run')
|
|
244
252
|
// Reading is as wide as an "all" search for an agent, and as narrow as a search for a session.
|
|
245
|
-
if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run)) throw notFound('Run')
|
|
253
|
+
if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run) || excluded(env, context, run.sessionId)) throw notFound('Run')
|
|
246
254
|
const messages = read(env.root, run.id)
|
|
247
255
|
if (!messages.length) return 'That run has no recorded messages.'
|
|
248
256
|
let text = `Run ${run.id} · ${run.startedAt.slice(0, 16)} · ${sourceLabel(env, run.mode, run.sessionId, run.projectId)} · ${run.status}\n`
|
|
@@ -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
|
+
}
|
package/src/memory/wiki.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
|
|
8
8
|
import { noteChange } from '../core/changes'
|
|
9
|
+
import { logLine } from '../core/bus'
|
|
9
10
|
import { HarnessError, notFound } from '../core/errors'
|
|
10
11
|
import * as frontmatter from '../core/frontmatter'
|
|
11
12
|
import { atomicWrite, now } from '../core/fs'
|
|
@@ -216,7 +217,7 @@ export function searchEntries(root: string, query: string, options: SearchOption
|
|
|
216
217
|
try {
|
|
217
218
|
ranked = wikiIndex.search(root, query, limit * 4, boost)
|
|
218
219
|
} catch (error) {
|
|
219
|
-
|
|
220
|
+
logLine(`[index] search fell back to files: ${error instanceof Error ? error.message : String(error)}`)
|
|
220
221
|
const needle = query.trim().toLowerCase()
|
|
221
222
|
if (!needle) return []
|
|
222
223
|
const rank = (entry: IndexedEntry): number => (entry.namespace === boost ? 0 : 2) + (entry.title.toLowerCase().includes(needle) ? 0 : 1)
|
package/src/memory/wikiIndex.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import type { DatabaseSync } from 'node:sqlite'
|
|
5
5
|
|
|
6
|
+
import { logLine } from '../core/bus'
|
|
6
7
|
import { all, dbPath, deleteIndex, ftsQuery, run, transaction, withIndex } from '../core/index'
|
|
7
8
|
import { readAllRuns, syncRuns, upsertRun } from '../sessions/runIndex'
|
|
8
9
|
import { scan } from './wiki'
|
|
@@ -55,7 +56,7 @@ export function reindexQuietly(root: string): void {
|
|
|
55
56
|
try {
|
|
56
57
|
reindexWiki(root)
|
|
57
58
|
} catch (error) {
|
|
58
|
-
|
|
59
|
+
logLine(`[index] reindex failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -81,7 +82,7 @@ export function ensure(root: string, after?: () => void): number {
|
|
|
81
82
|
try {
|
|
82
83
|
syncRuns(root)
|
|
83
84
|
} catch (error) {
|
|
84
|
-
|
|
85
|
+
logLine(`[index] ${error instanceof Error ? error.message : String(error)}`)
|
|
85
86
|
}
|
|
86
87
|
} else {
|
|
87
88
|
pages = rebuild(root)
|
package/src/plugins/builtin.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// exists; none of it is loaded through Pi's extension system.
|
|
3
3
|
import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
|
|
4
4
|
import { ASK_USER } from '../tools/askUser'
|
|
5
|
+
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
|
|
5
6
|
import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
|
|
6
|
-
import { MEMORY_TOOLS, RECALL_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
|
|
7
|
+
import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
|
|
7
8
|
import { FIRST_PARTY_PLUGINS } from './firstParty'
|
|
8
9
|
import type { PluginDescriptor } from './types'
|
|
9
10
|
|
|
@@ -36,6 +37,7 @@ export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Inclu
|
|
|
36
37
|
|
|
37
38
|
export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
38
39
|
PI_PLUGIN,
|
|
40
|
+
{ id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
|
|
39
41
|
{
|
|
40
42
|
id: 'memory',
|
|
41
43
|
title: 'Memory',
|
|
@@ -59,7 +61,7 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
|
59
61
|
],
|
|
60
62
|
toolGroups: ['Knowledge']
|
|
61
63
|
},
|
|
62
|
-
{ id: 'history', title: 'History', description: 'Search
|
|
64
|
+
{ id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
|
|
63
65
|
{
|
|
64
66
|
id: 'plugin-proposals',
|
|
65
67
|
title: 'Plugin proposals',
|
|
@@ -73,7 +73,7 @@ describe('the plugin list', () => {
|
|
|
73
73
|
['app-tasks', 'application', ['tool:start_task:on']],
|
|
74
74
|
['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
|
|
75
75
|
])
|
|
76
|
-
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'wiki'])
|
|
76
|
+
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'subagents', 'wiki'])
|
|
77
77
|
|
|
78
78
|
const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
|
|
79
79
|
// It was there when trust was first asked per package, so it was taken over as trusted.
|
package/src/server/methods.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { capabilitySet } from '../capabilities/runSnapshot'
|
|
|
14
14
|
import type { Harness } from '../harness'
|
|
15
15
|
import * as blocks from '../memory/blocks'
|
|
16
16
|
import { validateScopeIds } from '../mcp/connections'
|
|
17
|
-
import type
|
|
17
|
+
import { callerBlocks, type CallerContext } from '../memory/caller'
|
|
18
18
|
import { dispatch } from '../memory/dispatch'
|
|
19
19
|
import * as facts from '../memory/facts'
|
|
20
20
|
import * as graph from '../memory/graph'
|
|
@@ -33,6 +33,7 @@ import { comparison, compile, CONTEXT_PROFILES, load, profiles, render, setProfi
|
|
|
33
33
|
import { selectHistory } from '../sessions/history'
|
|
34
34
|
import { runsSince } from '../sessions/runIndex'
|
|
35
35
|
import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
|
|
36
|
+
import type { SubagentInput, SubagentSelection } from '../sessions/subagents'
|
|
36
37
|
import type { SessionRunInput } from '../sessions/sessions'
|
|
37
38
|
import * as registry from '../skills/registry'
|
|
38
39
|
import type { TerminalPrepareInput } from '../terminal/terminals'
|
|
@@ -46,6 +47,16 @@ const text = (params: Params, name: string): string => {
|
|
|
46
47
|
return value
|
|
47
48
|
}
|
|
48
49
|
const optional = (params: Params, name: string): string | null => (typeof params[name] === 'string' && params[name] ? (params[name] as string) : null)
|
|
50
|
+
const questionAnswer = (value: unknown): { selected: string[]; text?: string; notes?: Record<string, string> } => {
|
|
51
|
+
const answer = value && typeof value === 'object' && !Array.isArray(value) ? value as Params : {}
|
|
52
|
+
return {
|
|
53
|
+
selected: Array.isArray(answer.selected) ? answer.selected.filter((label: unknown): label is string => typeof label === 'string') : [],
|
|
54
|
+
...(typeof answer.text === 'string' && answer.text ? { text: answer.text } : {}),
|
|
55
|
+
...(answer.notes && typeof answer.notes === 'object' && !Array.isArray(answer.notes)
|
|
56
|
+
? { notes: Object.fromEntries(Object.entries(answer.notes).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) }
|
|
57
|
+
: {})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
49
60
|
const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
|
|
50
61
|
|
|
51
62
|
/** Sources larger than this are not imported. */
|
|
@@ -65,6 +76,14 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
65
76
|
'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
|
|
66
77
|
'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
|
|
67
78
|
'harness.rebuildIndex': () => harness.rebuildIndex(),
|
|
79
|
+
'maintenance.list': (params: Params) => maintenance.jobs().filter((job) => params.runId ? job.runId === params.runId || (Array.isArray(params.ids) && params.ids.includes(job.id)) : params.includeCompleted === true || !['completed', 'rejected'].includes(job.status)).map(({ prepared: _prepared, hashes: _hashes, ...job }) => job),
|
|
80
|
+
'maintenance.review': (params: Params) => { maintenance.enqueue({ kind: 'review', requestId: optional(params, 'requestId') ?? String(Date.now()), manual: params.manual === true }); return { queued: true } },
|
|
81
|
+
'maintenance.failRun': (params: Params) => { maintenance.finishRun(text(params, 'runId'), true); return null },
|
|
82
|
+
'maintenance.retry': (params: Params) => maintenance.retry(text(params, 'id')),
|
|
83
|
+
'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
|
|
84
|
+
'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
|
|
85
|
+
'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
|
|
86
|
+
'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
|
|
68
87
|
|
|
69
88
|
// ---- Accounts --------------------------------------------------------------------------------
|
|
70
89
|
'accounts.list': () => accounts.overview(),
|
|
@@ -84,6 +103,7 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
84
103
|
'accounts.sharedDir': () => ensureSharedDir(accounts.dir),
|
|
85
104
|
'accounts.refresh': (params: Params) => accounts.refresh(optional(params, 'accountId')),
|
|
86
105
|
'accounts.loginStart': (params: Params) => accounts.loginStart(text(params, 'accountId'), text(params, 'provider'), optional(params, 'method')),
|
|
106
|
+
'accounts.loginStatus': (params: Params) => accounts.loginStatus(text(params, 'accountId')),
|
|
87
107
|
'accounts.loginRespond': (params: Params) => accounts.loginRespond(text(params, 'loginId'), text(params, 'promptId'), typeof params.value === 'string' ? params.value : null),
|
|
88
108
|
'accounts.loginCancel': (params: Params) => accounts.loginCancel(text(params, 'accountId'), optional(params, 'loginId')),
|
|
89
109
|
'accounts.setApiKey': (params: Params) => accounts.setApiKey(text(params, 'accountId'), text(params, 'key'), text(params, 'provider')),
|
|
@@ -115,6 +135,7 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
115
135
|
'connections.setAgentScope': (params: Params) => apps.setAgentScope(params.creator ?? {}, text(params, 'agentId'), Array.isArray(params.ids) ? params.ids : []),
|
|
116
136
|
'connections.removeAgentScope': (params: Params) => apps.removeAgentScope(text(params, 'agentId')),
|
|
117
137
|
'connections.revision': () => apps.connections.revision(),
|
|
138
|
+
'connections.appClients': () => apps.appClients(),
|
|
118
139
|
'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
|
|
119
140
|
|
|
120
141
|
// ---- Wiki: what a person browses and edits ---------------------------------------------------
|
|
@@ -196,6 +217,11 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
196
217
|
},
|
|
197
218
|
|
|
198
219
|
// ---- What a session calls, on behalf of a caller the host names -------------------------------
|
|
220
|
+
'memory.permissions': (params: Params) => {
|
|
221
|
+
const context = (params.context ?? {}) as CallerContext
|
|
222
|
+
const { caller, attached } = callerBlocks(env, context)
|
|
223
|
+
return attached.map((block) => ({ id: block.id, writable: blocks.editRefusal(caller, block) === null, reason: blocks.editRefusal(caller, block) }))
|
|
224
|
+
},
|
|
199
225
|
'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
|
|
200
226
|
|
|
201
227
|
// ---- Recall ------------------------------------------------------------------------------------
|
|
@@ -278,6 +304,13 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
278
304
|
await runs.interrupt(runId)
|
|
279
305
|
return loadRun(root(), runId)
|
|
280
306
|
},
|
|
307
|
+
// A message the person sends into a live reply. It reaches the model after the current step;
|
|
308
|
+
// `message.steered` marks the moment, and a message the run never takes is reported by its end.
|
|
309
|
+
'runs.steer': async (params: Params) => {
|
|
310
|
+
const runId = text(params, 'runId')
|
|
311
|
+
await runs.steer(runId, text(params, 'text'))
|
|
312
|
+
return { runId }
|
|
313
|
+
},
|
|
281
314
|
// What the application reports into a live run, and an approval it asks a person for.
|
|
282
315
|
'runs.emit': (params: Params) => {
|
|
283
316
|
const run = runs.active(text(params, 'runId'))
|
|
@@ -298,9 +331,17 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
298
331
|
return { decision }
|
|
299
332
|
},
|
|
300
333
|
'runs.resolveApproval': (params: Params) => runs.resolveApproval(text(params, 'runId'), text(params, 'approvalId'), params.decision),
|
|
301
|
-
'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'),
|
|
334
|
+
'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'), questionAnswer(params.answer)),
|
|
335
|
+
'runs.answerQuestions': (params: Params) => {
|
|
336
|
+
if (!Array.isArray(params.answers)) throw invalid('answers must be an array.')
|
|
337
|
+
runs.answerQuestions(text(params, 'runId'), text(params, 'batchId'), params.answers.map((item: Params) => ({ questionId: text(item, 'questionId'), answer: questionAnswer(item.answer) })))
|
|
338
|
+
},
|
|
302
339
|
|
|
303
340
|
// ---- Sessions ----------------------------------------------------------------------------------
|
|
341
|
+
'subagents.spawn': (params: Params) => harness.subagents.spawn(params as SubagentInput),
|
|
342
|
+
'subagents.wait': (params: Params) => harness.subagents.wait(params as SubagentSelection),
|
|
343
|
+
'subagents.results': (params: Params) => harness.subagents.results(params as SubagentSelection),
|
|
344
|
+
'sessions.spawnSubagent': (params: Params) => sessions.spawnSubagent(params as SubagentInput),
|
|
304
345
|
'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
|
|
305
346
|
'sessions.history': (params: Params) => ({ sessionFile: selectHistory({ sessionDir: text(params, 'sessionDir'), resume: params.resume ?? null }) }),
|
|
306
347
|
|