@gotcos/glasses-server 6.51.0 → 6.52.3
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/.env.example +16 -0
- package/CHANGELOG.md +86 -0
- package/README.md +56 -3
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +58 -9
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-instance-claim.ts +22 -1
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +629 -18
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +14 -7
- package/server/routes/health.ts +24 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
// The Messages trail (6.52.0): a readable account of a running Messages job.
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT IS. A Messages run used to show raw tool lines (`Bash: <command>`) and a stream
|
|
4
|
+
// of text in which the agent's narration and its final answer ran together. Sessions
|
|
5
|
+
// already solved "what is it doing": one grammar (session-stream-events.ts) of steps with
|
|
6
|
+
// a verb and a short target, the agent's own words between them, and a status. This module
|
|
7
|
+
// is the second, ISOLATED reader of each Messages bridge's stdout that feeds that grammar,
|
|
8
|
+
// and a few small mappers where an engine writes a shape the grammar does not read.
|
|
9
|
+
//
|
|
10
|
+
// ISOLATION IS THE CONTRACT. The bridges' own stdout handlers decide the final answer,
|
|
11
|
+
// the chunks, the activity lines and every terminal path, and they are untouched. The tee
|
|
12
|
+
// is a SEPARATE `data` listener registered AFTER the bridge's (the session-stream producer
|
|
13
|
+
// pattern), holds no state the bridge can see, and swallows its own failures. When a caller
|
|
14
|
+
// passes no `onTrail`, no listener is registered at all, which is the path the legacy
|
|
15
|
+
// `/api/query` route, openai-compat and a server with `COS_MESSAGES_TRAIL=0` all take.
|
|
16
|
+
//
|
|
17
|
+
// WHAT NEVER ENTERS THE TRAIL:
|
|
18
|
+
// - a prompt. The job's own prompt echo, a replayed steer, an injected user row: none of
|
|
19
|
+
// them is ever a trail entry (`fromGrammar`). The only thing a user row contributes is
|
|
20
|
+
// the OUTCOME of a tool call.
|
|
21
|
+
// - lifecycle status. `init`/`result`, Codex `turn.*`: the job status is authoritative,
|
|
22
|
+
// and a trail that said "done" before the journal did would contradict it.
|
|
23
|
+
// - model reasoning. Thinking blocks, Codex `reasoning` items and Cursor `thinking`
|
|
24
|
+
// records are dropped, as the shipped copy promises.
|
|
25
|
+
//
|
|
26
|
+
// Redaction and the user's tool-activity opt-out are applied at the journal boundary and
|
|
27
|
+
// in the coordinator respectively (`sanitizeJobTrailDraft`, `applyTrailToolMode`), so a
|
|
28
|
+
// draft cannot reach the journal without both.
|
|
29
|
+
//
|
|
30
|
+
// REDACT BEFORE CUT, EVERYWHERE (QA round 1). A secret straddling a cut is a prefix no
|
|
31
|
+
// pattern recognises any more, so the readers redact every visible field in full BEFORE the
|
|
32
|
+
// grammar or a mapper shortens it (`preRedactClaudeRecord`, `trailLine`, `secretFree`), and
|
|
33
|
+
// the journal boundary redacts again. Every redaction reads at most
|
|
34
|
+
// REDACTION_SCAN_MAX_CHARS, cut at a token boundary, so no input can make a regex run long.
|
|
35
|
+
|
|
36
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
37
|
+
import { boundForRedaction, redactSecretText } from './activity-preview.js'
|
|
38
|
+
import { redactQueryJobText } from './query-job-types.js'
|
|
39
|
+
import {
|
|
40
|
+
DETAIL_MAX_CHARS,
|
|
41
|
+
TARGET_MAX_CHARS,
|
|
42
|
+
TRUNCATION_MARK,
|
|
43
|
+
basename,
|
|
44
|
+
codexExecCommandOutcome,
|
|
45
|
+
codexExecCommandText,
|
|
46
|
+
commandSummary,
|
|
47
|
+
draftsFromRecord,
|
|
48
|
+
oneLine,
|
|
49
|
+
targetForTool,
|
|
50
|
+
verbForToolName,
|
|
51
|
+
type SessionStreamDraft,
|
|
52
|
+
type SessionStreamVerb,
|
|
53
|
+
type ToolOutcome,
|
|
54
|
+
} from './session-stream-events.js'
|
|
55
|
+
import { createLineAssembler } from './session-stream-producer.js'
|
|
56
|
+
|
|
57
|
+
export type JobTrailProvider = 'claude' | 'codex' | 'cursor'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One trail entry, before transport stamps.
|
|
61
|
+
*
|
|
62
|
+
* The SAME kinds and fields as the Sessions grammar, so the app renders both with one
|
|
63
|
+
* reducer. Two differences, both narrowing: there is no `prompt` kind (see the header), and
|
|
64
|
+
* `status` is always `working` because lifecycle belongs to the job. `status` carries either
|
|
65
|
+
* a `tool_outcome` (the result of the step before it) or a `detail`: Claude Code's own
|
|
66
|
+
* readable step line (`task_summary.detail`, `post_turn_summary.status_detail`), which is an
|
|
67
|
+
* extra field on an existing kind rather than a new kind, the grammar's rule for additions.
|
|
68
|
+
*/
|
|
69
|
+
export type JobTrailDraft =
|
|
70
|
+
| { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string; call?: string }
|
|
71
|
+
| { kind: 'prose'; text: string }
|
|
72
|
+
| { kind: 'status'; state: 'working'; tool_outcome?: ToolOutcome; detail?: string; needs_action?: string }
|
|
73
|
+
|
|
74
|
+
export const JOB_TRAIL_VERBS: readonly SessionStreamVerb[] = ['read', 'edit', 'write', 'bash', 'search', 'task', 'other']
|
|
75
|
+
|
|
76
|
+
/** Per prose entry. The app keeps 2,000 of a Sessions prose (its own cap), so the trail
|
|
77
|
+
* journals no more than a client will ever show. */
|
|
78
|
+
export const TRAIL_PROSE_MAX_CHARS = 2_000
|
|
79
|
+
|
|
80
|
+
/** Claude Code's step and summary lines are one sentence; this is room for two. */
|
|
81
|
+
export const TRAIL_STATUS_MAX_CHARS = 160
|
|
82
|
+
|
|
83
|
+
const CALL_ID_RE = /^[A-Za-z0-9_-]{4,96}$/
|
|
84
|
+
const CONTROL_EXCEPT_NEWLINE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
|
|
85
|
+
|
|
86
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
87
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function str(value: unknown): string {
|
|
91
|
+
return typeof value === 'string' ? value : ''
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function callId(value: unknown): string | undefined {
|
|
95
|
+
return typeof value === 'string' && CALL_ID_RE.test(value) ? value : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function lineCount(value: unknown): number {
|
|
99
|
+
if (typeof value !== 'string') return 0
|
|
100
|
+
const text = value.replace(/\n+$/, '')
|
|
101
|
+
return text.length === 0 ? 0 : text.split('\n').length
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function linesDetail(value: unknown): string {
|
|
105
|
+
const lines = lineCount(value)
|
|
106
|
+
return lines === 0 ? 'no output' : `${lines} line${lines === 1 ? '' : 's'}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseRecord(line: string): Record<string, unknown> | null {
|
|
110
|
+
const trimmed = typeof line === 'string' ? line.trim() : ''
|
|
111
|
+
if (!trimmed || trimmed[0] !== '{') return null
|
|
112
|
+
try {
|
|
113
|
+
return asRecord(JSON.parse(trimmed))
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── The user's opt-out ───
|
|
120
|
+
|
|
121
|
+
export type JobTrailToolMode = 'off' | 'status' | 'preview'
|
|
122
|
+
|
|
123
|
+
/** A failed step's detail that is DERIVED (a status or a count), never output text. */
|
|
124
|
+
const DERIVED_FAILURE_DETAIL_RE = /^(?:|exit -?\d+|denied|interrupted|timeout|not found|failed|error)$/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The job's `activityToolMode`, applied to one draft. Null means drop it.
|
|
128
|
+
*
|
|
129
|
+
* off prose only. Every `tool` draft goes, every step outcome, and Claude Code's
|
|
130
|
+
* own step line and `needs_action` too, since each of them names a step. The
|
|
131
|
+
* user turned tool activity off; the agent's own words stay.
|
|
132
|
+
* status steps with their verb and short target (a basename, a command summary, a
|
|
133
|
+
* pattern: the grammar never carries raw input), and outcomes as derived
|
|
134
|
+
* tokens only. A failure whose detail is text the tool wrote is shown as
|
|
135
|
+
* `error`, because that text is raw output.
|
|
136
|
+
* preview the full grammar, including the first words of a failure.
|
|
137
|
+
*
|
|
138
|
+
* Every mode still passes through `sanitizeJobTrailDraft` before the journal.
|
|
139
|
+
*/
|
|
140
|
+
export function applyTrailToolMode(draft: JobTrailDraft, mode: JobTrailToolMode): JobTrailDraft | null {
|
|
141
|
+
if (draft.kind === 'prose') return draft
|
|
142
|
+
// QA round 1: Claude Code's step line (`detail`) and `needs_action` name the step too
|
|
143
|
+
// ("Running npm test"), so `off` keeps the agent's own words and nothing else.
|
|
144
|
+
if (mode === 'off') return null
|
|
145
|
+
if (mode === 'preview') return draft
|
|
146
|
+
if (draft.kind === 'status' && draft.tool_outcome && !draft.tool_outcome.ok
|
|
147
|
+
&& !DERIVED_FAILURE_DETAIL_RE.test(draft.tool_outcome.detail)) {
|
|
148
|
+
return { ...draft, tool_outcome: { ...draft.tool_outcome, detail: 'error' } }
|
|
149
|
+
}
|
|
150
|
+
return draft
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── The journal boundary ───
|
|
154
|
+
|
|
155
|
+
/** The bounded head a redaction reads, carriage returns folded and control bytes out. */
|
|
156
|
+
function scannable(value: unknown): string {
|
|
157
|
+
if (typeof value !== 'string' || value.length === 0) return ''
|
|
158
|
+
const { head } = boundForRedaction(value)
|
|
159
|
+
return head.replace(/\r\n?/g, '\n').replace(CONTROL_EXCEPT_NEWLINE_RE, ' ')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Both redaction layers, in order, on raw text: private-material blocks and provider
|
|
163
|
+
* secrets (activity-preview), then the store's credential and local-path patterns. Runs
|
|
164
|
+
* BEFORE any flattening or cutting, so a multi-line key block is caught whole and a secret
|
|
165
|
+
* is never half-cut into a visible prefix. */
|
|
166
|
+
function redacted(value: unknown): string {
|
|
167
|
+
const plain = scannable(value)
|
|
168
|
+
return plain ? redactQueryJobText(redactSecretText(plain)) : ''
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Secrets out and nothing else: for a path the caller will shorten to its basename (the
|
|
172
|
+
* local-path layer would turn the whole path into `[path]` first). */
|
|
173
|
+
function secretFree(value: unknown): string {
|
|
174
|
+
const plain = scannable(value)
|
|
175
|
+
return plain ? redactSecretText(plain) : ''
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function trailLine(value: unknown, max: number): string {
|
|
179
|
+
return oneLine(redacted(value), max)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function trailBlock(value: unknown, max: number): string {
|
|
183
|
+
const text = redacted(value).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
184
|
+
if (text.length <= max) return text
|
|
185
|
+
return text.slice(0, Math.max(0, max - TRUNCATION_MARK.length)).trimEnd() + TRUNCATION_MARK
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sanitizeOutcome(raw: unknown): ToolOutcome | undefined {
|
|
189
|
+
const r = asRecord(raw)
|
|
190
|
+
if (!r || typeof r.ok !== 'boolean') return undefined
|
|
191
|
+
const call = callId(r.call)
|
|
192
|
+
return { ok: r.ok, detail: trailLine(r.detail, DETAIL_MAX_CHARS), ...(call ? { call } : {}) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The only shape a trail row may have in the journal, from anything.
|
|
197
|
+
*
|
|
198
|
+
* Every text field is redacted (`redacted`) and then capped; `verb` is held to the closed
|
|
199
|
+
* set; a call id must look like one. Idempotent, so hydration runs it again on every row a
|
|
200
|
+
* journal holds and a rule added later applies to rows written earlier. Null means the row
|
|
201
|
+
* says nothing worth keeping (empty prose, a lifecycle status, an unnamed step).
|
|
202
|
+
*/
|
|
203
|
+
export function sanitizeJobTrailDraft(raw: unknown): JobTrailDraft | null {
|
|
204
|
+
const r = asRecord(raw)
|
|
205
|
+
if (!r) return null
|
|
206
|
+
if (r.kind === 'prose') {
|
|
207
|
+
const text = trailBlock(r.text, TRAIL_PROSE_MAX_CHARS)
|
|
208
|
+
return text ? { kind: 'prose', text } : null
|
|
209
|
+
}
|
|
210
|
+
if (r.kind === 'tool') {
|
|
211
|
+
const verb = JOB_TRAIL_VERBS.includes(r.verb as SessionStreamVerb) ? r.verb as SessionStreamVerb : 'other'
|
|
212
|
+
const target = trailLine(r.target, TARGET_MAX_CHARS)
|
|
213
|
+
if (!target) return null
|
|
214
|
+
const call = callId(r.call)
|
|
215
|
+
return { kind: 'tool', verb, target, detail: trailLine(r.detail, DETAIL_MAX_CHARS), ...(call ? { call } : {}) }
|
|
216
|
+
}
|
|
217
|
+
if (r.kind === 'status') {
|
|
218
|
+
if (r.state !== 'working') return null
|
|
219
|
+
const outcome = sanitizeOutcome(r.tool_outcome)
|
|
220
|
+
const detail = trailLine(r.detail, TRAIL_STATUS_MAX_CHARS)
|
|
221
|
+
const needs = trailLine(r.needs_action, TRAIL_STATUS_MAX_CHARS)
|
|
222
|
+
if (!outcome && !detail && !needs) return null
|
|
223
|
+
return {
|
|
224
|
+
kind: 'status',
|
|
225
|
+
state: 'working',
|
|
226
|
+
...(outcome ? { tool_outcome: outcome } : {}),
|
|
227
|
+
...(detail ? { detail } : {}),
|
|
228
|
+
...(needs ? { needs_action: needs } : {}),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A journaled trail event's data back into its parts, or null (a malformed row). */
|
|
235
|
+
export function parseJobTrailEventData(raw: unknown): { trailSeq: number; draft: JobTrailDraft } | null {
|
|
236
|
+
const r = asRecord(raw)
|
|
237
|
+
if (!r) return null
|
|
238
|
+
const trailSeq = r.trailSeq
|
|
239
|
+
if (typeof trailSeq !== 'number' || !Number.isSafeInteger(trailSeq) || trailSeq < 1) return null
|
|
240
|
+
const { trailSeq: _trailSeq, ...rest } = r
|
|
241
|
+
const draft = sanitizeJobTrailDraft(rest)
|
|
242
|
+
return draft ? { trailSeq, draft } : null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Characters of text a trail entry holds, for the snapshot's size bound. */
|
|
246
|
+
export function jobTrailTextChars(draft: JobTrailDraft): number {
|
|
247
|
+
if (draft.kind === 'prose') return draft.text.length
|
|
248
|
+
if (draft.kind === 'tool') return draft.target.length + draft.detail.length
|
|
249
|
+
return (draft.detail?.length ?? 0) + (draft.needs_action?.length ?? 0) + (draft.tool_outcome?.detail.length ?? 0)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Readers: one stdout line to zero or more drafts ───
|
|
253
|
+
|
|
254
|
+
export interface JobTrailReader {
|
|
255
|
+
/** One complete stdout line. Never throws. */
|
|
256
|
+
line(line: string): void
|
|
257
|
+
/** End of stream: flush anything held for coalescing. */
|
|
258
|
+
end(): void
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The Sessions grammar's output, narrowed to what a trail may carry.
|
|
263
|
+
*
|
|
264
|
+
* `prompt` is the ONE place a person's words could enter the trail, and it never does:
|
|
265
|
+
* the job's own prompt echo (and, from Phase 4, a replayed steer with its attachment block,
|
|
266
|
+
* image paths or the ULTRACODE keyword) is dropped here, whatever uuid it carries.
|
|
267
|
+
*/
|
|
268
|
+
function fromGrammar(draft: SessionStreamDraft): JobTrailDraft | null {
|
|
269
|
+
switch (draft.kind) {
|
|
270
|
+
case 'tool':
|
|
271
|
+
return { kind: 'tool', verb: draft.verb, target: draft.target, detail: draft.detail, ...(draft.call ? { call: draft.call } : {}) }
|
|
272
|
+
case 'prose':
|
|
273
|
+
return { kind: 'prose', text: draft.text }
|
|
274
|
+
case 'status':
|
|
275
|
+
// Lifecycle (`init` working, `result` done) is the job's; only an outcome rides.
|
|
276
|
+
return draft.tool_outcome ? { kind: 'status', state: 'working', tool_outcome: draft.tool_outcome } : null
|
|
277
|
+
case 'prompt':
|
|
278
|
+
return null
|
|
279
|
+
default:
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Tool-input keys a step's TARGET is read from (see `targetForTool`), and the path keys
|
|
285
|
+
* that are shortened to a basename. Every other key (file contents, edit strings) only
|
|
286
|
+
* ever yields a count, so it is left as is. */
|
|
287
|
+
const TARGET_TEXT_KEYS = ['command', 'cmd', 'pattern', 'query', 'skill', 'description', 'subject', 'prompt', 'url'] as const
|
|
288
|
+
const TARGET_PATH_KEYS = ['file_path', 'path', 'notebook_path'] as const
|
|
289
|
+
|
|
290
|
+
function preRedactToolInput(input: unknown): unknown {
|
|
291
|
+
const args = asRecord(input)
|
|
292
|
+
if (!args) return input
|
|
293
|
+
const out: Record<string, unknown> = { ...args }
|
|
294
|
+
for (const key of TARGET_PATH_KEYS) if (typeof args[key] === 'string') out[key] = secretFree(args[key])
|
|
295
|
+
for (const key of TARGET_TEXT_KEYS) if (typeof args[key] === 'string') out[key] = redacted(args[key])
|
|
296
|
+
return out
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A Claude record with every field the grammar turns into VISIBLE text redacted in full,
|
|
301
|
+
* before the grammar cuts it: text blocks (prose), a tool_use's target fields, and an
|
|
302
|
+
* error result's text (the first words of a failure). A successful result is left whole:
|
|
303
|
+
* the grammar reads only counts from it, and bounding it would change those counts.
|
|
304
|
+
*/
|
|
305
|
+
function preRedactClaudeRecord(record: Record<string, unknown>): Record<string, unknown> {
|
|
306
|
+
const message = asRecord(record.message)
|
|
307
|
+
if (!message || !Array.isArray(message.content)) return record
|
|
308
|
+
const content = message.content.map(raw => {
|
|
309
|
+
const block = asRecord(raw)
|
|
310
|
+
if (!block) return raw
|
|
311
|
+
if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: redacted(block.text) }
|
|
312
|
+
if (block.type === 'tool_use') return { ...block, input: preRedactToolInput(block.input) }
|
|
313
|
+
if (block.type === 'tool_result' && block.is_error === true) {
|
|
314
|
+
if (typeof block.content === 'string') return { ...block, content: redacted(block.content) }
|
|
315
|
+
if (Array.isArray(block.content)) {
|
|
316
|
+
return { ...block, content: block.content.map(part => {
|
|
317
|
+
const p = asRecord(part)
|
|
318
|
+
return p && typeof p.text === 'string' ? { ...p, text: redacted(p.text) } : part
|
|
319
|
+
}) }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return raw
|
|
323
|
+
})
|
|
324
|
+
return { ...record, message: { ...message, content } }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Claude (`claude -p --output-format stream-json --verbose`).
|
|
329
|
+
*
|
|
330
|
+
* Measured on Claude Code 2.1.278 (canary capture, 2026-09-19): every content block is its
|
|
331
|
+
* OWN `assistant` record, and the blocks of one model message share `message.id`. So prose
|
|
332
|
+
* is held per message id and flushed at the first line that is not more text of the same
|
|
333
|
+
* message: one prose entry per assistant message, never one per streamed delta (deltas are
|
|
334
|
+
* `stream_event` rows, which only ever flush). `post_turn_summary` arrives before `result`.
|
|
335
|
+
*
|
|
336
|
+
* A subagent's records carry `parent_tool_use_id`; its steps are shown, its prose is not
|
|
337
|
+
* (those are not the agent's words to the user).
|
|
338
|
+
*/
|
|
339
|
+
function createClaudeReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
|
|
340
|
+
let pending: { key: string; text: string } | null = null
|
|
341
|
+
let lastStatus = ''
|
|
342
|
+
const flush = () => {
|
|
343
|
+
if (!pending) return
|
|
344
|
+
const text = pending.text
|
|
345
|
+
pending = null
|
|
346
|
+
emit({ kind: 'prose', text })
|
|
347
|
+
}
|
|
348
|
+
const status = (detail: string, needs: string) => {
|
|
349
|
+
if (!detail && !needs) return
|
|
350
|
+
const key = `${detail}\n${needs}`
|
|
351
|
+
// Claude Code repeats a task summary while one step runs; the trail says it once.
|
|
352
|
+
if (key === lastStatus) return
|
|
353
|
+
lastStatus = key
|
|
354
|
+
emit({ kind: 'status', state: 'working', ...(detail ? { detail } : {}), ...(needs ? { needs_action: needs } : {}) })
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
line(raw) {
|
|
358
|
+
const record = parseRecord(raw)
|
|
359
|
+
if (!record) return
|
|
360
|
+
const type = str(record.type)
|
|
361
|
+
if (type === 'stream_event') {
|
|
362
|
+
const inner = asRecord(record.event)
|
|
363
|
+
const innerType = str(inner?.type)
|
|
364
|
+
const block = asRecord(inner?.content_block)
|
|
365
|
+
if (innerType === 'message_start' || innerType === 'message_stop'
|
|
366
|
+
|| (innerType === 'content_block_start' && block?.type !== 'text')) flush()
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
if (type === 'assistant') {
|
|
370
|
+
const message = asRecord(record.message)
|
|
371
|
+
const key = str(message?.id)
|
|
372
|
+
const subagent = str(record.parent_tool_use_id).length > 0
|
|
373
|
+
for (const draft of draftsFromRecord('claude', preRedactClaudeRecord(record))) {
|
|
374
|
+
const mapped = fromGrammar(draft)
|
|
375
|
+
if (!mapped) continue
|
|
376
|
+
if (mapped.kind === 'prose') {
|
|
377
|
+
if (subagent) continue
|
|
378
|
+
if (pending && (!key || pending.key !== key)) flush()
|
|
379
|
+
pending = pending ? { key, text: `${pending.text}\n\n${mapped.text}` } : { key, text: mapped.text }
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
flush()
|
|
383
|
+
emit(mapped)
|
|
384
|
+
}
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
flush()
|
|
388
|
+
if (type === 'user') {
|
|
389
|
+
for (const draft of draftsFromRecord('claude', preRedactClaudeRecord(record))) {
|
|
390
|
+
const mapped = fromGrammar(draft)
|
|
391
|
+
if (mapped) emit(mapped)
|
|
392
|
+
}
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
if (type === 'system') {
|
|
396
|
+
if (record.subtype === 'task_summary') {
|
|
397
|
+
status(trailLine(record.detail, TRAIL_STATUS_MAX_CHARS), '')
|
|
398
|
+
} else if (record.subtype === 'post_turn_summary') {
|
|
399
|
+
status(trailLine(record.status_detail, TRAIL_STATUS_MAX_CHARS), trailLine(record.needs_action, TRAIL_STATUS_MAX_CHARS))
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// `result`, `init`, `rate_limit_event`, `thinking_tokens`: the job status is authoritative.
|
|
403
|
+
},
|
|
404
|
+
end: flush,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The shell-wrapper and outcome rules are the Sessions grammar's (it reads the same live
|
|
409
|
+
// `codex exec --json` stream for a Continue turn), shared rather than copied.
|
|
410
|
+
const codexCommandText = codexExecCommandText
|
|
411
|
+
const codexCommandOutcome = codexExecCommandOutcome
|
|
412
|
+
|
|
413
|
+
function outcomeDraft(outcome: Omit<ToolOutcome, 'call'>, call?: string): JobTrailDraft {
|
|
414
|
+
return { kind: 'status', state: 'working', tool_outcome: { ...outcome, ...(call ? { call } : {}) } }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Codex (`codex exec --json`).
|
|
419
|
+
*
|
|
420
|
+
* Field names measured from the shipped binary's serde strings (ChatGPT.app's `codex`,
|
|
421
|
+
* 2026-09-19), and for the lifecycle events, `agent_message` and `command_execution`
|
|
422
|
+
* confirmed by a real 0.155.0 run (`__fixtures__/codex-exec-json-0.155.0.jsonl`; the other
|
|
423
|
+
* item kinds are still the serde-string reading): events `item.started|updated|completed`, items `agent_message`, `reasoning`,
|
|
424
|
+
* `command_execution` (`command`, `aggregated_output`, `exit_code`, `status`), `file_change`
|
|
425
|
+
* (`changes[{path,kind: add|delete|update}]`, `status`), `mcp_tool_call` (`server`, `tool`,
|
|
426
|
+
* `status`, `error`), `web_search` (`query`), `todo_list`. The `{type, item:{...}}` nesting is
|
|
427
|
+
* the one the bridge and its tests already read. An agent message is complete when it
|
|
428
|
+
* arrives, so it is one prose entry as is. Each step is announced once per item id, at
|
|
429
|
+
* start when there is one, and its outcome follows at completion. Anything else is dropped,
|
|
430
|
+
* `reasoning` by contract and newer item kinds conservatively.
|
|
431
|
+
*
|
|
432
|
+
* A row that is not an exec item event (an older `{msg:{...}}` wrapper, a rollout row) goes
|
|
433
|
+
* through the Sessions grammar, which reads those shapes.
|
|
434
|
+
*/
|
|
435
|
+
function createCodexReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
|
|
436
|
+
const announced = new Set<string>()
|
|
437
|
+
const announce = (id: string, steps: JobTrailDraft[]) => {
|
|
438
|
+
if (id) {
|
|
439
|
+
if (announced.has(id)) return
|
|
440
|
+
announced.add(id)
|
|
441
|
+
}
|
|
442
|
+
for (const step of steps) emit(step)
|
|
443
|
+
}
|
|
444
|
+
const item = (eventType: string, raw: Record<string, unknown>) => {
|
|
445
|
+
const id = str(raw.id)
|
|
446
|
+
const call = callId(id)
|
|
447
|
+
const done = eventType === 'item.completed'
|
|
448
|
+
switch (str(raw.type)) {
|
|
449
|
+
case 'agent_message': {
|
|
450
|
+
// Bounded before anything else reads it: the journal boundary redacts this text, and
|
|
451
|
+
// no regex is ever handed an unbounded message.
|
|
452
|
+
const text = boundForRedaction(str(raw.text)).head
|
|
453
|
+
if (done && text.trim()) emit({ kind: 'prose', text })
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
case 'command_execution': {
|
|
457
|
+
const command = oneLine(commandSummary(redacted(codexCommandText(raw.command))), TARGET_MAX_CHARS)
|
|
458
|
+
announce(id, [{ kind: 'tool', verb: 'bash', target: command || 'shell', detail: '', ...(call ? { call } : {}) }])
|
|
459
|
+
if (done) emit(outcomeDraft(codexCommandOutcome(raw), call))
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
case 'file_change': {
|
|
463
|
+
const changes = (Array.isArray(raw.changes) ? raw.changes : [])
|
|
464
|
+
.map(asRecord)
|
|
465
|
+
.filter((change): change is Record<string, unknown> => change !== null)
|
|
466
|
+
.slice(0, 5)
|
|
467
|
+
announce(id, changes.map((change): JobTrailDraft => {
|
|
468
|
+
const name = oneLine(basename(secretFree(change.path)), TARGET_MAX_CHARS) || 'file'
|
|
469
|
+
if (change.kind === 'add') return { kind: 'tool', verb: 'write', target: name, detail: '' }
|
|
470
|
+
if (change.kind === 'delete') return { kind: 'tool', verb: 'other', target: oneLine(`delete ${name}`, TARGET_MAX_CHARS), detail: '' }
|
|
471
|
+
return { kind: 'tool', verb: 'edit', target: name, detail: '' }
|
|
472
|
+
}))
|
|
473
|
+
if (done) {
|
|
474
|
+
const failed = raw.status === 'failed'
|
|
475
|
+
for (const change of changes) {
|
|
476
|
+
const detail = failed ? 'failed' : change.kind === 'add' ? 'created' : change.kind === 'delete' ? 'deleted' : 'edited'
|
|
477
|
+
emit(outcomeDraft({ ok: !failed, detail }))
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
case 'mcp_tool_call': {
|
|
483
|
+
const name = [str(raw.server), str(raw.tool)].filter(Boolean).join('.')
|
|
484
|
+
announce(id, [{ kind: 'tool', verb: 'other', target: trailLine(name, TARGET_MAX_CHARS) || 'mcp tool', detail: '', ...(call ? { call } : {}) }])
|
|
485
|
+
if (done) {
|
|
486
|
+
const failed = raw.status === 'failed' || (raw.error != null && raw.error !== '')
|
|
487
|
+
emit(outcomeDraft({ ok: !failed, detail: failed ? 'failed' : '' }, call))
|
|
488
|
+
}
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
case 'web_search': {
|
|
492
|
+
const query = trailLine(raw.query, TARGET_MAX_CHARS)
|
|
493
|
+
// A search can start before its query is known; announce it once the query is.
|
|
494
|
+
if (!query && !done) return
|
|
495
|
+
announce(id, [{ kind: 'tool', verb: 'search', target: query || 'web search', detail: '', ...(call ? { call } : {}) }])
|
|
496
|
+
if (done) emit(outcomeDraft({ ok: true, detail: '' }, call))
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
default:
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
line(raw) {
|
|
505
|
+
const record = parseRecord(raw)
|
|
506
|
+
if (!record) return
|
|
507
|
+
const type = str(record.type)
|
|
508
|
+
if (type === 'item.started' || type === 'item.updated' || type === 'item.completed') {
|
|
509
|
+
const body = asRecord(record.item)
|
|
510
|
+
if (body) item(type, body)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
// `thread.started`, `turn.started|completed|failed`: lifecycle, the job's.
|
|
514
|
+
if (type.includes('.')) return
|
|
515
|
+
for (const draft of draftsFromRecord('codex', record)) {
|
|
516
|
+
const mapped = fromGrammar(draft)
|
|
517
|
+
if (mapped) emit(mapped)
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
end() {},
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Cursor tool-call keys (canonical protobuf JSON of `ToolCall`, measured from the
|
|
525
|
+
* 2026.09.15 agent bundle's descriptors) to the grammar's tool names. Anything absent is an
|
|
526
|
+
* `other` step named by its key. */
|
|
527
|
+
const CURSOR_TOOL_NAMES: Readonly<Record<string, string>> = {
|
|
528
|
+
shellToolCall: 'Bash',
|
|
529
|
+
piBashToolCall: 'Bash',
|
|
530
|
+
editToolCall: 'Edit',
|
|
531
|
+
piEditToolCall: 'Edit',
|
|
532
|
+
applyAgentDiffToolCall: 'Edit',
|
|
533
|
+
piWriteToolCall: 'Write',
|
|
534
|
+
readToolCall: 'Read',
|
|
535
|
+
piReadToolCall: 'Read',
|
|
536
|
+
grepToolCall: 'Grep',
|
|
537
|
+
piGrepToolCall: 'Grep',
|
|
538
|
+
globToolCall: 'Glob',
|
|
539
|
+
piFindToolCall: 'Glob',
|
|
540
|
+
semSearchToolCall: 'Search',
|
|
541
|
+
webSearchToolCall: 'WebSearch',
|
|
542
|
+
webFetchToolCall: 'WebFetch',
|
|
543
|
+
taskToolCall: 'Task',
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const CURSOR_FILE_TOOLS = new Set(['Read', 'Edit', 'Write'])
|
|
547
|
+
|
|
548
|
+
function cursorStep(key: string, args: Record<string, unknown>, call?: string): JobTrailDraft {
|
|
549
|
+
const canonical = CURSOR_TOOL_NAMES[key]
|
|
550
|
+
const readable = key.replace(/ToolCall$/, '')
|
|
551
|
+
if (!canonical) {
|
|
552
|
+
const named = key === 'mcpToolCall' ? str(args.toolName) || str(args.name) : ''
|
|
553
|
+
return { kind: 'tool', verb: 'other', target: trailLine(named || readable, TARGET_MAX_CHARS) || 'tool', detail: '', ...(call ? { call } : {}) }
|
|
554
|
+
}
|
|
555
|
+
// Redacted in full before `targetForTool` shortens them.
|
|
556
|
+
const input: Record<string, unknown> = {
|
|
557
|
+
...(CURSOR_FILE_TOOLS.has(canonical) ? { file_path: secretFree(str(args.path) || str(args.filePath) || str(args.targetFile)) } : {}),
|
|
558
|
+
command: redacted(str(args.command)),
|
|
559
|
+
pattern: redacted(str(args.pattern) || str(args.globPattern) || str(args.query) || str(args.searchTerm) || str(args.url)),
|
|
560
|
+
description: redacted(str(args.description)),
|
|
561
|
+
}
|
|
562
|
+
const target = targetForTool(canonical, input) || readable
|
|
563
|
+
return { kind: 'tool', verb: verbForToolName(canonical), target, detail: '', ...(call ? { call } : {}) }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** A Cursor result oneof (`success`, `failure` with `exitCode`, `timeout`, `rejected`,
|
|
567
|
+
* `permissionDenied`, `fileNotFound`, `error`, ...) to a derived token. Never output text. */
|
|
568
|
+
function cursorOutcome(key: string, result: unknown): Omit<ToolOutcome, 'call'> {
|
|
569
|
+
const r = asRecord(result)
|
|
570
|
+
if (!r) return { ok: true, detail: '' }
|
|
571
|
+
const success = asRecord(r.success)
|
|
572
|
+
if (success) return { ok: true, detail: CURSOR_TOOL_NAMES[key] === 'Bash' ? linesDetail(success.stdout) : '' }
|
|
573
|
+
const failure = asRecord(r.failure)
|
|
574
|
+
if (failure) return { ok: false, detail: typeof failure.exitCode === 'number' ? `exit ${failure.exitCode}` : 'failed' }
|
|
575
|
+
if ('timeout' in r) return { ok: false, detail: 'timeout' }
|
|
576
|
+
if ('rejected' in r || 'permissionDenied' in r || 'readPermissionDenied' in r || 'writePermissionDenied' in r) {
|
|
577
|
+
return { ok: false, detail: 'denied' }
|
|
578
|
+
}
|
|
579
|
+
if ('fileNotFound' in r) return { ok: false, detail: 'not found' }
|
|
580
|
+
if ('error' in r || 'spawnError' in r || 'invalidFile' in r) return { ok: false, detail: 'error' }
|
|
581
|
+
return { ok: true, detail: '' }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Cursor (`agent -p --output-format stream-json --stream-partial-output`).
|
|
586
|
+
*
|
|
587
|
+
* Measured from the 2026.09.15 agent bundle: with partial output on, every text delta is
|
|
588
|
+
* an `assistant` record WITH `timestamp_ms` and no `model_call_id`; right before each
|
|
589
|
+
* `tool_call started` the CLI flushes the whole segment as one `assistant` record carrying
|
|
590
|
+
* `model_call_id`, and before `result` it flushes the last segment with NO `timestamp_ms`.
|
|
591
|
+
* Those two flush records are the coalesced prose: exactly one entry per segment, and the
|
|
592
|
+
* deltas are never read. (A flush before a `retry` record carries a timestamp and no model
|
|
593
|
+
* call id, the same as a delta, so the trail skips that one segment; the answer is not
|
|
594
|
+
* affected.)
|
|
595
|
+
*
|
|
596
|
+
* The prompt echo (a `user` record), `thinking` records and lifecycle rows are dropped.
|
|
597
|
+
*/
|
|
598
|
+
function createCursorReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
|
|
599
|
+
const announced = new Set<string>()
|
|
600
|
+
return {
|
|
601
|
+
line(raw) {
|
|
602
|
+
const record = parseRecord(raw)
|
|
603
|
+
if (!record) return
|
|
604
|
+
const type = str(record.type)
|
|
605
|
+
if (type === 'assistant') {
|
|
606
|
+
const segment = (record.model_call_id != null && record.model_call_id !== '') || typeof record.timestamp_ms !== 'number'
|
|
607
|
+
if (!segment) return
|
|
608
|
+
for (const draft of draftsFromRecord('cursor', preRedactClaudeRecord(record))) {
|
|
609
|
+
if (draft.kind === 'prose') emit({ kind: 'prose', text: draft.text })
|
|
610
|
+
}
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
if (type !== 'tool_call') return
|
|
614
|
+
const toolCall = asRecord(record.tool_call)
|
|
615
|
+
if (!toolCall) return
|
|
616
|
+
const key = Object.keys(toolCall).find(name => /ToolCall$/.test(name))
|
|
617
|
+
if (!key) return
|
|
618
|
+
const body = asRecord(toolCall[key]) ?? {}
|
|
619
|
+
const call = callId(record.call_id)
|
|
620
|
+
const seen = call ? announced.has(call) : false
|
|
621
|
+
if (record.subtype === 'started') {
|
|
622
|
+
if (seen) return
|
|
623
|
+
if (call) announced.add(call)
|
|
624
|
+
emit(cursorStep(key, asRecord(body.args) ?? {}, call))
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
if (record.subtype === 'completed') {
|
|
628
|
+
if (call && !seen) {
|
|
629
|
+
announced.add(call)
|
|
630
|
+
emit(cursorStep(key, asRecord(body.args) ?? {}, call))
|
|
631
|
+
}
|
|
632
|
+
emit(outcomeDraft(cursorOutcome(key, body.result), call))
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
end() {},
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function createJobTrailReader(provider: JobTrailProvider, emit: (draft: JobTrailDraft) => void): JobTrailReader {
|
|
640
|
+
if (provider === 'codex') return createCodexReader(emit)
|
|
641
|
+
if (provider === 'cursor') return createCursorReader(emit)
|
|
642
|
+
return createClaudeReader(emit)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ─── The tee ───
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Process-wide counters for `/api/health` (`messages_trail`): stdout lines the readers were
|
|
649
|
+
* handed, entries they emitted, and failures they swallowed (a reader that throws, or a
|
|
650
|
+
* consumer that does). Counts and a timestamp only, never text.
|
|
651
|
+
*/
|
|
652
|
+
const trailStats = { linesRead: 0, entriesEmitted: 0, readerErrors: 0, lastErrorAt: null as number | null }
|
|
653
|
+
|
|
654
|
+
function noteTrailError(): void {
|
|
655
|
+
trailStats.readerErrors++
|
|
656
|
+
trailStats.lastErrorAt = Date.now()
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export interface JobTrailStats {
|
|
660
|
+
linesRead: number
|
|
661
|
+
entriesEmitted: number
|
|
662
|
+
readerErrors: number
|
|
663
|
+
lastErrorAt: string | null
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function jobTrailStats(): JobTrailStats {
|
|
667
|
+
return {
|
|
668
|
+
linesRead: trailStats.linesRead,
|
|
669
|
+
entriesEmitted: trailStats.entriesEmitted,
|
|
670
|
+
readerErrors: trailStats.readerErrors,
|
|
671
|
+
lastErrorAt: trailStats.lastErrorAt === null ? null : new Date(trailStats.lastErrorAt).toISOString(),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function __resetJobTrailStatsForTests(): void {
|
|
676
|
+
trailStats.linesRead = 0
|
|
677
|
+
trailStats.entriesEmitted = 0
|
|
678
|
+
trailStats.readerErrors = 0
|
|
679
|
+
trailStats.lastErrorAt = null
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** The part of a child's stdout the tee uses. A Node Readable fits, and so does the plain
|
|
683
|
+
* EventEmitter the bridge tests fake a child with. */
|
|
684
|
+
export interface JobTrailStream {
|
|
685
|
+
on(event: string, listener: (...args: any[]) => void): unknown
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Attach the trail as a SECOND reader of a bridge's stdout.
|
|
690
|
+
*
|
|
691
|
+
* Call it AFTER the bridge registers its own `data` listener, so the bridge sees every chunk
|
|
692
|
+
* first and its behaviour cannot depend on this one. With no `onTrail` it registers nothing,
|
|
693
|
+
* which is how every caller without a trail consumer keeps exactly the 6.51.0 stream. The
|
|
694
|
+
* listener never throws into the stream: a bad line costs its own drafts and nothing else.
|
|
695
|
+
*/
|
|
696
|
+
export function teeJobTrail(
|
|
697
|
+
stream: JobTrailStream | null | undefined,
|
|
698
|
+
provider: JobTrailProvider,
|
|
699
|
+
onTrail?: (draft: JobTrailDraft) => void,
|
|
700
|
+
): void {
|
|
701
|
+
if (!onTrail || !stream) return
|
|
702
|
+
const emit = (draft: JobTrailDraft) => {
|
|
703
|
+
try {
|
|
704
|
+
onTrail(draft)
|
|
705
|
+
trailStats.entriesEmitted++
|
|
706
|
+
} catch {
|
|
707
|
+
/* the trail is observation; it never affects the run that produced it */
|
|
708
|
+
noteTrailError()
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const reader = createJobTrailReader(provider, emit)
|
|
712
|
+
const decoder = new StringDecoder('utf8')
|
|
713
|
+
const assembler = createLineAssembler()
|
|
714
|
+
let ended = false
|
|
715
|
+
const readLine = (line: string) => {
|
|
716
|
+
trailStats.linesRead++
|
|
717
|
+
try { reader.line(line) } catch { noteTrailError() /* one line */ }
|
|
718
|
+
}
|
|
719
|
+
const feed = (text: string) => {
|
|
720
|
+
for (const line of assembler.push(text)) readLine(line)
|
|
721
|
+
}
|
|
722
|
+
stream.on('data', (chunk: unknown) => {
|
|
723
|
+
if (ended) return
|
|
724
|
+
try {
|
|
725
|
+
feed(typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? decoder.write(chunk) : '')
|
|
726
|
+
} catch { noteTrailError() /* one chunk */ }
|
|
727
|
+
})
|
|
728
|
+
const finish = () => {
|
|
729
|
+
if (ended) return
|
|
730
|
+
ended = true
|
|
731
|
+
try {
|
|
732
|
+
feed(decoder.end())
|
|
733
|
+
for (const line of assembler.flush()) readLine(line)
|
|
734
|
+
reader.end()
|
|
735
|
+
} catch { noteTrailError() /* end of an observation */ }
|
|
736
|
+
}
|
|
737
|
+
stream.on('end', finish)
|
|
738
|
+
stream.on('close', finish)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ─── Ollama: the server owns the loop, so the bridge calls these directly ───
|
|
742
|
+
|
|
743
|
+
/** One Ollama tool call as a step. The COS tools are searches and a meeting read. */
|
|
744
|
+
export function ollamaTrailStep(name: string, args: Record<string, unknown>): JobTrailDraft {
|
|
745
|
+
if (name === 'search_meetings' || name === 'search_memories') {
|
|
746
|
+
return { kind: 'tool', verb: 'search', target: trailLine(args.query, TARGET_MAX_CHARS) || name, detail: '' }
|
|
747
|
+
}
|
|
748
|
+
if (name === 'read_meeting') {
|
|
749
|
+
return { kind: 'tool', verb: 'read', target: oneLine(basename(secretFree(args.filename)), TARGET_MAX_CHARS) || name, detail: '' }
|
|
750
|
+
}
|
|
751
|
+
return { kind: 'tool', verb: 'other', target: trailLine(name, TARGET_MAX_CHARS) || 'tool', detail: '' }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Its outcome: the tools answer an error as `{"error": ...}` or `unknown tool ...`. */
|
|
755
|
+
export function ollamaTrailOutcome(result: string): JobTrailDraft {
|
|
756
|
+
const failed = /^\s*\{\s*"error"\s*:/.test(result) || /^unknown tool\b/.test(result)
|
|
757
|
+
return outcomeDraft({ ok: !failed, detail: failed ? 'error' : '' })
|
|
758
|
+
}
|