@gotcos/glasses-server 6.99.33 → 7.0.18
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 +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Recent activity first" for the Today summary (Miles, 2026-11-09): what was said INTO a session lately and where its
|
|
3
|
+
* latest reply left off, with when, so the summary describes what a session is doing now instead of its first prompt.
|
|
4
|
+
*
|
|
5
|
+
* Why: each session's list `line` (composeDiscussionSummary) is its FIRST prompt plus the latest reply, cut at 180
|
|
6
|
+
* characters. On 11/09 the Codex orchestrator's line led with an ask from 10/11, 29 days old, and the model wrote
|
|
7
|
+
* "following up a transcription bakeoff" about a session that had just confirmed a release was live.
|
|
8
|
+
*
|
|
9
|
+
* readRecentActivity(provider, file, now) answers the last few asks (each with when and how it arrived), the latest
|
|
10
|
+
* reply and when the session last moved. Read-only display evidence: never imported by an attach, queue or write gate.
|
|
11
|
+
*
|
|
12
|
+
* - Claude: the last 8 MB of the transcript, memoized on mtime and size like readClaudeSubagents. User turns follow
|
|
13
|
+
* parseAgentSession's rules: sidechains, `isMeta` and `isCompactSummary` rows are not asks (a peer row is, unwrapped
|
|
14
|
+
* by unwrapPeerMessage), the verify footer comes off (withoutTurnFooter), wrapper tags go (STRIPPABLE_TAG_RE) and a
|
|
15
|
+
* pasted JSON block becomes "[pasted data]". A peer row, or a row that carried a COS verify footer, arrived from
|
|
16
|
+
* another screen or session: `via: 'peer'`, never attributed to the person. A peer row that is agent-to-agent
|
|
17
|
+
* protocol (a cross-session or agent-message envelope) is not an ask at all.
|
|
18
|
+
* - Codex: rollouts reach 32 GB with single lines of 44 MB (measured 11/09). An incremental forward scan keeps its byte
|
|
19
|
+
* offset in memory; the first pass reads at most the last 256 MB; a line over 1 MB is skipped without being decoded
|
|
20
|
+
* or parsed; reads are async, 4 MB at a time, and the scan yields between chunks. One scan per file at a time: a
|
|
21
|
+
* second caller waits on the first, and a scan the Today route stopped waiting for still finishes and fills the memo.
|
|
22
|
+
* Codex rows carry no sender marker, so only a COS verify footer marks one as sent in from another screen.
|
|
23
|
+
* - Cursor: the existing tail peek (the last user query and reply); its transcript has no timestamps, so every time is
|
|
24
|
+
* the file's mtime.
|
|
25
|
+
*
|
|
26
|
+
* `startedAt` and `firstAsk` are set only for a session whose file was created within the last day, for the
|
|
27
|
+
* "started today as" note. Opens refuse symlinks and non-regular files. Never throws: null when unreadable.
|
|
28
|
+
*/
|
|
29
|
+
import { constants } from 'node:fs'
|
|
30
|
+
import { open, stat } from 'node:fs/promises'
|
|
31
|
+
import {
|
|
32
|
+
STRIPPABLE_TAG_RE, createdFromCodexFilename, firstClaudeUserTitle, firstCursorUserTitle, isWrapperPrompt, parseJsonLine,
|
|
33
|
+
payloadText, codexUserText, peekCodexMeta, peekCursorDiscussion, proseSnippet, type AgentProvider,
|
|
34
|
+
} from './agent-session-store.js'
|
|
35
|
+
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
36
|
+
import { stripG2TurnFooter } from './turn-footer.js'
|
|
37
|
+
|
|
38
|
+
export type RecentVia = 'typed' | 'peer'
|
|
39
|
+
export interface RecentAsk { at: number; text: string; via: RecentVia }
|
|
40
|
+
export interface RecentReply { at: number; text: string }
|
|
41
|
+
export interface RecentActivity {
|
|
42
|
+
/** The newest asks into the session, oldest first, at most RECENT_ASKS_KEPT. */
|
|
43
|
+
asks: RecentAsk[]
|
|
44
|
+
/** The newest assistant reply (Codex: its newest final answer, not commentary). */
|
|
45
|
+
reply: RecentReply | null
|
|
46
|
+
/** The newest record's time (ms), 0 when none was seen. */
|
|
47
|
+
movedAt: number
|
|
48
|
+
/** Only for a session created within the last day: when, and its first ask. */
|
|
49
|
+
startedAt?: number
|
|
50
|
+
firstAsk?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const RECENT_ASKS_KEPT = 3
|
|
54
|
+
export const CLAUDE_RECENT_TAIL_BYTES = 8 * 1024 * 1024
|
|
55
|
+
export const CODEX_FIRST_PASS_BYTES = 256 * 1024 * 1024
|
|
56
|
+
/** A line longer than this is skipped unparsed (a 44 MB Codex line holds tool output, never an ask). */
|
|
57
|
+
export const RECENT_LINE_MAX_BYTES = 1024 * 1024
|
|
58
|
+
export const RECENT_CHUNK_BYTES = 4 * 1024 * 1024
|
|
59
|
+
const TEXT_KEEP = 1000
|
|
60
|
+
const MEMO_MAX = 64
|
|
61
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
62
|
+
|
|
63
|
+
// ── text ───────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const PASTED = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content\b[^>]*>/gi
|
|
66
|
+
const WRAPPER_BLOCKS = /<(task-notification|system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args|user_query)\b[^>]*>[\s\S]*?<\/\1>/gi
|
|
67
|
+
/** A peer row that is agent-to-agent protocol, not words for the session. */
|
|
68
|
+
const AGENT_ENVELOPE = /^\s*(?:<(?:cross-session-message|agent-message)\b|Message Type:|Subagent hand-?back\b)/i
|
|
69
|
+
/** A paragraph that is pasted data: it opens with { or [ and holds a "key": pair. */
|
|
70
|
+
const JSON_PARAGRAPH = /^\s*[[{][\s\S]*"\s*:/
|
|
71
|
+
|
|
72
|
+
/** One ask's words: pasted data replaced, wrapper blocks and tags out, one line, bounded. '' when nothing is left. */
|
|
73
|
+
export function cleanAskText(raw: string): string {
|
|
74
|
+
// Whole wrapper blocks go first (a background task's notification, a reminder, a slash command's parts). What is left
|
|
75
|
+
// is then judged as parseAgentSession's collectTurn judges a turn: still a wrapper (it opens with a tag the block list
|
|
76
|
+
// does not know), it is not an ask, and no tag is taken off so that its insides could read as words.
|
|
77
|
+
let s = raw.replace(WRAPPER_BLOCKS, ' ').replace(PASTED, (_m, inner: string) => {
|
|
78
|
+
const t = String(inner).trim()
|
|
79
|
+
return t.startsWith('{') || t.startsWith('[') ? '\n\n[pasted data]\n\n' : ` ${t} `
|
|
80
|
+
})
|
|
81
|
+
if (!s.trim() || isWrapperPrompt(s)) return ''
|
|
82
|
+
s = s.split(/\n\s*\n/).map(p => (JSON_PARAGRAPH.test(p) ? '[pasted data]' : p)).join('\n\n')
|
|
83
|
+
s = s.replace(STRIPPABLE_TAG_RE, ' ').replace(/\s+/g, ' ').trim()
|
|
84
|
+
if (!s || isWrapperPrompt(s) || s.startsWith('[Request interrupted') || s.startsWith('[image: source')) return ''
|
|
85
|
+
return s.slice(0, TEXT_KEEP)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** An ask's words and how it arrived: a footer means another screen (glasses, phone or web) sent it. */
|
|
89
|
+
function footerVia(text: string, peer: boolean): { text: string; via: RecentVia } {
|
|
90
|
+
const f = stripG2TurnFooter(text)
|
|
91
|
+
return { text: f.text, via: peer || f.turnId ? 'peer' : 'typed' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tsOf = (v: unknown): number => (typeof v === 'string' ? Date.parse(v) || 0 : 0)
|
|
95
|
+
|
|
96
|
+
interface Collector { asks: RecentAsk[]; reply: RecentReply | null; movedAt: number }
|
|
97
|
+
const emptyCollector = (): Collector => ({ asks: [], reply: null, movedAt: 0 })
|
|
98
|
+
function addAsk(c: Collector, ask: RecentAsk) {
|
|
99
|
+
c.asks.push(ask)
|
|
100
|
+
if (c.asks.length > RECENT_ASKS_KEPT) c.asks.shift()
|
|
101
|
+
}
|
|
102
|
+
const moved = (c: Collector, at: number) => { if (at > c.movedAt) c.movedAt = at }
|
|
103
|
+
|
|
104
|
+
/** One Claude record into the collector (parseAgentSession's user-turn rules). */
|
|
105
|
+
export function collectClaudeRecord(c: Collector, obj: Record<string, unknown>): void {
|
|
106
|
+
if (obj.isSidechain === true) return
|
|
107
|
+
const at = tsOf(obj.timestamp)
|
|
108
|
+
if (at && (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'system')) moved(c, at)
|
|
109
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') return
|
|
110
|
+
if (obj.type === 'user' && obj.toolUseResult) return
|
|
111
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
112
|
+
const text = message ? payloadText(message) : null
|
|
113
|
+
if (!text) return
|
|
114
|
+
if (obj.type === 'assistant') {
|
|
115
|
+
const prose = proseSnippet(text, TEXT_KEEP)
|
|
116
|
+
if (prose) c.reply = { at, text: prose }
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const isPeer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer'
|
|
120
|
+
const peerWords = isPeer ? unwrapPeerMessage(text) : null
|
|
121
|
+
// Injected rows (a slash-command body, a compaction preamble) are not asks; a peer row is, once unwrapped.
|
|
122
|
+
if (peerWords === null && (obj.isMeta === true || obj.isCompactSummary === true)) return
|
|
123
|
+
if (peerWords !== null && AGENT_ENVELOPE.test(peerWords)) return
|
|
124
|
+
const { text: words, via } = footerVia(peerWords ?? text, peerWords !== null)
|
|
125
|
+
const clean = cleanAskText(words)
|
|
126
|
+
if (clean) addAsk(c, { at, text: clean, via })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One Codex rollout line into the collector. The caller has already checked its size. */
|
|
130
|
+
export function collectCodexLine(c: Collector, line: Buffer): void {
|
|
131
|
+
const head = line.subarray(0, 512).toString('utf8')
|
|
132
|
+
const stamp = /"timestamp":"([^"]{10,40})"/.exec(head)
|
|
133
|
+
const at = stamp ? Date.parse(stamp[1]!) || 0 : 0
|
|
134
|
+
if (at) moved(c, at)
|
|
135
|
+
// Role and type sit before the content in a rollout line, so the head decides without decoding the rest.
|
|
136
|
+
if (!head.includes('"type":"message"') || !(head.includes('"role":"user"') || head.includes('"role":"assistant"'))) return
|
|
137
|
+
const obj = parseJsonLine(line.toString('utf8'))
|
|
138
|
+
if (!obj || obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return
|
|
139
|
+
const p = obj.payload as Record<string, unknown>
|
|
140
|
+
if (p.type !== 'message') return
|
|
141
|
+
if (p.role === 'assistant') {
|
|
142
|
+
if (p.phase === 'commentary') return
|
|
143
|
+
const text = payloadText(p)
|
|
144
|
+
const prose = text ? proseSnippet(text, TEXT_KEEP) : ''
|
|
145
|
+
if (prose) c.reply = { at, text: prose }
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (p.role !== 'user') return
|
|
149
|
+
const text = codexUserText(p)
|
|
150
|
+
if (!text || isWrapperPrompt(text)) return
|
|
151
|
+
const { text: words, via } = footerVia(text, false)
|
|
152
|
+
const clean = cleanAskText(words)
|
|
153
|
+
if (clean) addAsk(c, { at, text: clean, via })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── reads ──────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
type Handle = Awaited<ReturnType<typeof open>>
|
|
159
|
+
async function openRegular(file: string): Promise<{ handle: Handle; size: number; mtimeMs: number; birthtimeMs: number; ino: number } | null> {
|
|
160
|
+
let handle: Handle | null = null
|
|
161
|
+
try {
|
|
162
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
|
|
163
|
+
const st = await handle.stat()
|
|
164
|
+
if (!st.isFile()) { await handle.close().catch(() => {}); return null }
|
|
165
|
+
return { handle, size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, ino: st.ino }
|
|
166
|
+
} catch {
|
|
167
|
+
await handle?.close().catch(() => {})
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const yieldLoop = () => new Promise<void>(resolve => setImmediate(resolve))
|
|
172
|
+
|
|
173
|
+
const claudeMemo = new Map<string, { mtimeMs: number; size: number; value: Collector }>()
|
|
174
|
+
const remember = <V>(memo: Map<string, V>, key: string, value: V) => {
|
|
175
|
+
if (!memo.has(key) && memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
|
|
176
|
+
memo.set(key, value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A Claude transcript's tail (8 MB), memoized on mtime and size. */
|
|
180
|
+
async function readClaude(file: string, tailBytes = CLAUDE_RECENT_TAIL_BYTES): Promise<{ c: Collector; birthtimeMs: number } | null> {
|
|
181
|
+
const opened = await openRegular(file)
|
|
182
|
+
if (!opened) return null
|
|
183
|
+
const { handle, size, mtimeMs, birthtimeMs } = opened
|
|
184
|
+
try {
|
|
185
|
+
const hit = claudeMemo.get(file)
|
|
186
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return { c: hit.value, birthtimeMs }
|
|
187
|
+
const length = Math.min(size, tailBytes), start = size - length
|
|
188
|
+
const buffer = Buffer.alloc(length)
|
|
189
|
+
const read = await handle.read(buffer, 0, length, start)
|
|
190
|
+
const data = buffer.subarray(0, read.bytesRead)
|
|
191
|
+
const c = emptyCollector()
|
|
192
|
+
let pos = 0, n = 0
|
|
193
|
+
// A tail that starts mid-file has a cut first line: it fails to parse and is skipped like any bad line.
|
|
194
|
+
while (pos < data.length) {
|
|
195
|
+
let end = data.indexOf(0x0a, pos)
|
|
196
|
+
if (end < 0) end = data.length
|
|
197
|
+
if (end - pos > 0 && end - pos <= RECENT_LINE_MAX_BYTES) {
|
|
198
|
+
const obj = parseJsonLine(data.subarray(pos, end).toString('utf8'))
|
|
199
|
+
if (obj) collectClaudeRecord(c, obj)
|
|
200
|
+
}
|
|
201
|
+
pos = end + 1
|
|
202
|
+
if (++n % 2000 === 0) await yieldLoop()
|
|
203
|
+
}
|
|
204
|
+
remember(claudeMemo, file, { mtimeMs, size, value: c })
|
|
205
|
+
return { c, birthtimeMs }
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
} finally {
|
|
209
|
+
await handle.close().catch(() => {})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface CodexScan { offset: number; size: number; partial: Buffer | null; skipping: boolean; c: Collector; running: Promise<void> | null; ino: number }
|
|
214
|
+
const codexMemo = new Map<string, CodexScan>()
|
|
215
|
+
|
|
216
|
+
/** Test seam: forget every memo (a new server process starts with none). */
|
|
217
|
+
export function resetRecentActivityMemo(): void { claudeMemo.clear(); codexMemo.clear() }
|
|
218
|
+
/** Test seam: the bytes of an unfinished line a Codex scan holds for its next read (never more than the line bound). */
|
|
219
|
+
export function codexScanHeldBytes(file: string): number { return codexMemo.get(file)?.partial?.length ?? 0 }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Scan a Codex rollout forward from where the last scan stopped. A first pass, or a gap past `firstPassBytes`, starts
|
|
223
|
+
* that far from the end and drops the line it lands inside.
|
|
224
|
+
*/
|
|
225
|
+
async function scanCodex(file: string, firstPassBytes: number, chunkBytes: number): Promise<CodexScan | null> {
|
|
226
|
+
const opened = await openRegular(file)
|
|
227
|
+
if (!opened) return null
|
|
228
|
+
const { handle, size, ino } = opened
|
|
229
|
+
try {
|
|
230
|
+
let scan = codexMemo.get(file)
|
|
231
|
+
if (scan && (scan.ino !== ino || size < scan.offset)) scan = undefined // replaced or truncated: start over
|
|
232
|
+
if (!scan) {
|
|
233
|
+
scan = { offset: 0, size: 0, partial: null, skipping: false, c: emptyCollector(), running: null, ino }
|
|
234
|
+
remember(codexMemo, file, scan)
|
|
235
|
+
}
|
|
236
|
+
if (scan.running) { await scan.running; return scan }
|
|
237
|
+
const s = scan
|
|
238
|
+
const run = (async () => {
|
|
239
|
+
if (size - s.offset > firstPassBytes) {
|
|
240
|
+
s.offset = size - firstPassBytes
|
|
241
|
+
s.partial = null
|
|
242
|
+
s.skipping = s.offset > 0 // landed mid-line: that line is not whole
|
|
243
|
+
}
|
|
244
|
+
while (s.offset < size) {
|
|
245
|
+
const length = Math.min(chunkBytes, size - s.offset)
|
|
246
|
+
const chunk = Buffer.alloc(length)
|
|
247
|
+
const { bytesRead } = await handle.read(chunk, 0, length, s.offset)
|
|
248
|
+
if (bytesRead <= 0) break
|
|
249
|
+
s.offset += bytesRead
|
|
250
|
+
let buf = chunk.subarray(0, bytesRead)
|
|
251
|
+
if (s.partial) { buf = Buffer.concat([s.partial, buf]); s.partial = null }
|
|
252
|
+
let pos = 0
|
|
253
|
+
for (let nl = buf.indexOf(0x0a, pos); nl >= 0; nl = buf.indexOf(0x0a, pos)) {
|
|
254
|
+
if (s.skipping) s.skipping = false
|
|
255
|
+
else if (nl - pos > 0 && nl - pos <= RECENT_LINE_MAX_BYTES) collectCodexLine(s.c, buf.subarray(pos, nl))
|
|
256
|
+
pos = nl + 1
|
|
257
|
+
}
|
|
258
|
+
const rest = buf.subarray(pos)
|
|
259
|
+
// An unfinished line past the bound is never kept: the rest of it is skipped up to its newline. While skipping
|
|
260
|
+
// (no newline in this chunk at all), nothing is kept either.
|
|
261
|
+
if (!s.skipping) {
|
|
262
|
+
if (rest.length > RECENT_LINE_MAX_BYTES) s.skipping = true
|
|
263
|
+
else if (rest.length) s.partial = Buffer.from(rest)
|
|
264
|
+
}
|
|
265
|
+
await yieldLoop()
|
|
266
|
+
}
|
|
267
|
+
s.size = size
|
|
268
|
+
})()
|
|
269
|
+
s.running = run
|
|
270
|
+
try { await run } finally { s.running = null }
|
|
271
|
+
return s
|
|
272
|
+
} catch {
|
|
273
|
+
return null
|
|
274
|
+
} finally {
|
|
275
|
+
await handle.close().catch(() => {})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface RecentReadOptions { claudeTailBytes?: number; codexFirstPassBytes?: number; chunkBytes?: number }
|
|
280
|
+
|
|
281
|
+
/** What was said into one session lately, its latest reply and when it last moved, or null when it cannot be read. */
|
|
282
|
+
export async function readRecentActivity(provider: AgentProvider, file: string, now = Date.now(), opts: RecentReadOptions = {}): Promise<RecentActivity | null> {
|
|
283
|
+
try {
|
|
284
|
+
let c: Collector, createdAt = 0, firstAsk = ''
|
|
285
|
+
if (provider === 'claude') {
|
|
286
|
+
const r = await readClaude(file, opts.claudeTailBytes)
|
|
287
|
+
if (!r) return null
|
|
288
|
+
c = r.c
|
|
289
|
+
createdAt = r.birthtimeMs
|
|
290
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstClaudeUserTitle(file)) ?? ''
|
|
291
|
+
} else if (provider === 'codex') {
|
|
292
|
+
const s = await scanCodex(file, opts.codexFirstPassBytes ?? CODEX_FIRST_PASS_BYTES, opts.chunkBytes ?? RECENT_CHUNK_BYTES)
|
|
293
|
+
if (!s) return null
|
|
294
|
+
c = s.c
|
|
295
|
+
createdAt = Date.parse(createdFromCodexFilename(file.split('/').pop() ?? '') ?? '') || 0
|
|
296
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await peekCodexMeta(file))?.title ?? ''
|
|
297
|
+
} else {
|
|
298
|
+
const st = await stat(file).catch(() => null)
|
|
299
|
+
if (!st?.isFile()) return null
|
|
300
|
+
const peek = await peekCursorDiscussion(file)
|
|
301
|
+
const at = st.mtimeMs
|
|
302
|
+
c = { asks: [], reply: null, movedAt: at }
|
|
303
|
+
const ask = peek.lastUser ? cleanAskText(peek.lastUser) : ''
|
|
304
|
+
if (ask) c.asks.push({ at, text: ask, via: 'typed' })
|
|
305
|
+
const reply = peek.latestAssistant ? proseSnippet(peek.latestAssistant, TEXT_KEEP) : ''
|
|
306
|
+
if (reply) c.reply = { at, text: reply }
|
|
307
|
+
createdAt = st.birthtimeMs
|
|
308
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstCursorUserTitle(file)) ?? ''
|
|
309
|
+
}
|
|
310
|
+
// Copies, so a caller can never edit the memo.
|
|
311
|
+
const out: RecentActivity = {
|
|
312
|
+
asks: c.asks.filter(a => a.at <= now).map(a => ({ ...a })),
|
|
313
|
+
reply: c.reply && c.reply.at <= now ? { ...c.reply } : null,
|
|
314
|
+
movedAt: c.movedAt,
|
|
315
|
+
}
|
|
316
|
+
if (firstAsk && createdAt) { out.startedAt = createdAt; out.firstAsk = firstAsk }
|
|
317
|
+
return out
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -250,7 +250,7 @@ const STRIPPABLE_TAGS = [
|
|
|
250
250
|
'local-command-stdout', 'local-command-stderr', 'thinking',
|
|
251
251
|
]
|
|
252
252
|
|
|
253
|
-
const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
253
|
+
export const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
|
|
@@ -995,7 +995,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
|
|
|
995
995
|
return { id, cwd, title, subagent, created }
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
-
async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
998
|
+
export async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
999
999
|
const text = await readWindow(path, true)
|
|
1000
1000
|
let lastUser: string | null = null
|
|
1001
1001
|
for (const line of text.split('\n')) {
|
|
@@ -1010,7 +1010,7 @@ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string |
|
|
|
1010
1010
|
return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
|
|
1011
1011
|
}
|
|
1012
1012
|
|
|
1013
|
-
async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
1013
|
+
export async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
1014
1014
|
const text = await readWindow(path, false)
|
|
1015
1015
|
let fallback: string | null = null
|
|
1016
1016
|
for (const line of text.split('\n')) {
|
|
@@ -1455,12 +1455,31 @@ export async function listAgentSessions(
|
|
|
1455
1455
|
return rows.slice(0, limit)
|
|
1456
1456
|
}
|
|
1457
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Every Claude transcript by its lowercase id (names only, no stat). `shouldStop` lets a bounded caller end the walk
|
|
1460
|
+
* between folders; a stopped walk answers what it has.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function loadClaudeTranscriptIndex(roots: AgentSessionRoots, shouldStop: () => boolean = () => false): Promise<Map<string, string[]>> {
|
|
1463
|
+
const files = new Map<string, string[]>()
|
|
1464
|
+
for (const folder of await dirents(roots.claudeProjects)) {
|
|
1465
|
+
if (shouldStop()) break
|
|
1466
|
+
const dir = join(roots.claudeProjects, folder)
|
|
1467
|
+
for (const name of await dirents(dir)) {
|
|
1468
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
1469
|
+
const id = name.slice(0, -6).toLowerCase()
|
|
1470
|
+
files.set(id, [...(files.get(id) || []), join(dir, name)])
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return files
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1458
1476
|
export async function findAgentSessionFile(
|
|
1459
1477
|
provider: AgentProvider,
|
|
1460
1478
|
sessionId: string,
|
|
1461
1479
|
roots: AgentSessionRoots,
|
|
1462
1480
|
now = new Date(),
|
|
1463
1481
|
preloadedAliases?: ClaudeAliasMap,
|
|
1482
|
+
preloaded?: { claudeFiles?: ReadonlyMap<string, string[]>; codexFiles?: readonly string[] },
|
|
1464
1483
|
): Promise<string | null> {
|
|
1465
1484
|
if (!isSafeSessionId(sessionId)) return null
|
|
1466
1485
|
const needle = sessionId.trim().toLowerCase()
|
|
@@ -1473,15 +1492,8 @@ export async function findAgentSessionFile(
|
|
|
1473
1492
|
// Names only. This used to lstat every transcript on this Mac (1,697 of
|
|
1474
1493
|
// them) to keep directories out of the candidate set, on every call. The
|
|
1475
1494
|
// isFile check now runs on the one id that matched, below.
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
const dir = join(roots.claudeProjects, folder)
|
|
1479
|
-
for (const name of await dirents(dir)) {
|
|
1480
|
-
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
1481
|
-
const id = name.slice(0, -6).toLowerCase()
|
|
1482
|
-
files.set(id, [...(files.get(id) || []), join(dir, name)])
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1495
|
+
// 7.0.1: a caller resolving several ids at once (work-session-activity.ts) builds this index once and hands it in.
|
|
1496
|
+
const files = preloaded?.claudeFiles ?? await loadClaudeTranscriptIndex(roots)
|
|
1485
1497
|
const exact = aliases.has(needle) || files.has(needle)
|
|
1486
1498
|
const ids = new Set<string>()
|
|
1487
1499
|
for (const [id, head] of aliases) {
|
|
@@ -1498,8 +1510,9 @@ export async function findAgentSessionFile(
|
|
|
1498
1510
|
return matches.length === 1 ? matches[0] : null
|
|
1499
1511
|
}
|
|
1500
1512
|
if (provider === 'codex') {
|
|
1501
|
-
|
|
1502
|
-
|
|
1513
|
+
const lists = preloaded?.codexFiles ? [preloaded.codexFiles] : await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))
|
|
1514
|
+
for (const list of lists) {
|
|
1515
|
+
for (const file of list) {
|
|
1503
1516
|
const name = file.split('/').pop()?.toLowerCase() ?? ''
|
|
1504
1517
|
if (name.includes(needle)) return file
|
|
1505
1518
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Images a session LOOKED AT: the pictures that came back inside tool results (a Read of a PNG,
|
|
2
|
+
// a browser or computer-use screenshot) and the pictures a person attached to a prompt.
|
|
3
|
+
//
|
|
4
|
+
// WHY. Miles, 11/08: the Claude app shows a thumbnail under a step like "Ran 2 commands, read 2
|
|
5
|
+
// files", labelled "Read" and "1 of 2". Control Web showed only words for that turn. 6.99.28
|
|
6
|
+
// covered images written into reply text and 6.99.35 covered SendUserFile; neither covered tool
|
|
7
|
+
// results or prompt attachments.
|
|
8
|
+
//
|
|
9
|
+
// RECORD SHAPES (read from real transcripts on this Mac, 11/08, shapes only):
|
|
10
|
+
// - Claude, a Read of an image: a `user` row with `toolUseResult: { type: 'image', file: { base64,
|
|
11
|
+
// type, originalSize, dimensions } }` and `message.content: [{ type: 'tool_result', tool_use_id,
|
|
12
|
+
// content: [{ type: 'image', source: { type: 'base64', media_type, data } }] }]`.
|
|
13
|
+
// - Claude, a screenshot (Claude_Browser / claude-in-chrome / computer-use / Claude_Preview): the
|
|
14
|
+
// same tool_result block, its `content` a mix of text blocks and image blocks of base64 source.
|
|
15
|
+
// - Claude, a prompt attachment: a plain `user` row (no toolUseResult) whose `message.content`
|
|
16
|
+
// holds `{ type: 'image', source: { type: 'base64', ... } }` beside the text block. A few rows
|
|
17
|
+
// (sent through the SDK) carry the image and no words at all.
|
|
18
|
+
// - Codex: `response_item` rows. A tool output is `function_call_output` or
|
|
19
|
+
// `custom_tool_call_output` with `output: [{ type: 'input_text' }, { type: 'input_image',
|
|
20
|
+
// image_url: 'data:image/png;base64,...', detail }]`; a prompt attachment is a user `message`
|
|
21
|
+
// whose `content` holds `input_image` blocks. The calls were `exec`, `wait` and `js`.
|
|
22
|
+
// - Every image seen was embedded base64 (png, jpeg, webp). None referenced a path only. The
|
|
23
|
+
// path-only Read is still handled, under the workspace rules output images use.
|
|
24
|
+
//
|
|
25
|
+
// This module is pure: it finds the images and names them. Ids, the workspace check and the
|
|
26
|
+
// bytes live in agent-session-output-images.ts, beside the other previews.
|
|
27
|
+
import type { AgentProvider } from './agent-session-store.js'
|
|
28
|
+
|
|
29
|
+
export type ToolImageLabel = 'Read' | 'Screenshot' | 'Image' | 'Attached'
|
|
30
|
+
export interface SessionToolImage {
|
|
31
|
+
/** Opaque, process-scoped: `ti_` and 64 hex characters. */
|
|
32
|
+
id: string
|
|
33
|
+
/** What the step was, as the Claude app labels it: "Read", "Screenshot", or "Attached" for a prompt image. */
|
|
34
|
+
tool: ToolImageLabel
|
|
35
|
+
/** Its place in the step, 1-based: "1 of 2". */
|
|
36
|
+
index: number
|
|
37
|
+
total: number
|
|
38
|
+
state: 'available' | 'unavailable'
|
|
39
|
+
/**
|
|
40
|
+
* `too_large`: the embedded image is over MAX_TOOL_IMAGE_BYTES. `outside_workspace` and
|
|
41
|
+
* `unavailable`: a path-only Read, under the output-image rules. `missing`: no bytes and no path.
|
|
42
|
+
* `secret_file` (7.0.4): a Read (path-only, or with its bytes embedded) of a file whose name says it may hold secrets
|
|
43
|
+
* (lib/secret-path-guard.ts). Embedded bytes with no path (a screenshot, a prompt attachment) have no name to judge.
|
|
44
|
+
*/
|
|
45
|
+
reason?: 'outside_workspace' | 'unavailable' | 'too_large' | 'missing' | 'secret_file'
|
|
46
|
+
/** 7.0.4: the words the card shows instead, set only with `secret_file`. */
|
|
47
|
+
notice?: string
|
|
48
|
+
/** The step came before the turn's words (a reply that opened with tool calls). */
|
|
49
|
+
before?: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const SESSION_TOOL_IMAGE_ID = /^ti_[a-f0-9]{64}$/
|
|
53
|
+
/** Thumbnails one turn may show. The rest are counted ("+N more") and never get an id. */
|
|
54
|
+
export const TOOL_IMAGES_PER_TURN = 6
|
|
55
|
+
/** The largest decoded image served. The biggest seen on this Mac was about 510 KB. */
|
|
56
|
+
export const MAX_TOOL_IMAGE_BYTES = 8 * 1024 * 1024
|
|
57
|
+
export const TOOL_IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
|
|
58
|
+
const IMAGE_PATH = /\.(png|jpe?g|webp|gif)$/i
|
|
59
|
+
|
|
60
|
+
export type ToolImageRef =
|
|
61
|
+
/** `path`: the file a Read named, when its bytes are embedded (7.0.4 QA: the secret guard judges that name). */
|
|
62
|
+
| { kind: 'embedded'; mediaType: string; data: string; path?: string }
|
|
63
|
+
| { kind: 'path'; path: string }
|
|
64
|
+
| { kind: 'missing' }
|
|
65
|
+
export interface ToolUseMeta { name: string; path?: string }
|
|
66
|
+
export interface FoundToolImage { tool: ToolImageLabel; ref: ToolImageRef }
|
|
67
|
+
|
|
68
|
+
/** The Claude app's label for a tool. Pure. */
|
|
69
|
+
export function toolLabel(name: string | undefined): ToolImageLabel {
|
|
70
|
+
if (name === 'Read') return 'Read'
|
|
71
|
+
if (name && /screenshot|computer|zoom|browser_batch|app_batch|snapshot/i.test(name)) return 'Screenshot'
|
|
72
|
+
return 'Image'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Decoded size of a base64 string, without decoding it. */
|
|
76
|
+
export function estimateBytes(b64: string): number {
|
|
77
|
+
let pad = 0
|
|
78
|
+
if (b64.endsWith('==')) pad = 2; else if (b64.endsWith('=')) pad = 1
|
|
79
|
+
return Math.max(0, Math.floor(b64.length * 3 / 4) - pad)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The image type the bytes ARE, from their magic numbers, or null. Never the declared type. */
|
|
83
|
+
export function sniffImageType(bytes: Buffer): string | null {
|
|
84
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
|
85
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
86
|
+
if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('latin1'))) return 'image/gif'
|
|
87
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Decode an embedded image: refused over the cap (checked before decoding) or when the bytes are not a safe image type. */
|
|
92
|
+
export function decodeToolImage(ref: { mediaType: string; data: string }): { status: 'ok'; bytes: Buffer; type: string } | { status: 'too_large' | 'unavailable' } {
|
|
93
|
+
if (estimateBytes(ref.data) > MAX_TOOL_IMAGE_BYTES) return { status: 'too_large' }
|
|
94
|
+
const bytes = Buffer.from(ref.data, 'base64')
|
|
95
|
+
if (!bytes.length || bytes.length > MAX_TOOL_IMAGE_BYTES) return { status: bytes.length ? 'too_large' : 'unavailable' }
|
|
96
|
+
const type = sniffImageType(bytes)
|
|
97
|
+
return type ? { status: 'ok', bytes, type } : { status: 'unavailable' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function obj(v: unknown): Record<string, unknown> | null { return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : null }
|
|
101
|
+
|
|
102
|
+
/** An Anthropic image block of base64 source with a safe declared type, or null. */
|
|
103
|
+
function base64Block(block: unknown): ToolImageRef | null {
|
|
104
|
+
const b = obj(block); if (!b || b.type !== 'image') return null
|
|
105
|
+
const s = obj(b.source)
|
|
106
|
+
if (!s || s.type !== 'base64' || typeof s.data !== 'string' || !s.data || typeof s.media_type !== 'string') return null
|
|
107
|
+
if (!TOOL_IMAGE_TYPES.includes(s.media_type)) return null
|
|
108
|
+
return { kind: 'embedded', mediaType: s.media_type, data: s.data }
|
|
109
|
+
}
|
|
110
|
+
/** A Codex/OpenAI `input_image` block carrying a data URL of a safe type, or null. Remote URLs stay inert. */
|
|
111
|
+
function dataUrlBlock(block: unknown): ToolImageRef | null {
|
|
112
|
+
const b = obj(block); if (!b || b.type !== 'input_image' || typeof b.image_url !== 'string') return null
|
|
113
|
+
const m = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(b.image_url.slice(0, 64))
|
|
114
|
+
if (!m) return null
|
|
115
|
+
return { kind: 'embedded', mediaType: m[1], data: b.image_url.slice(m[0].length) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Keep the source of a Codex view_image so embedded bytes pass the same filename guard as Read. */
|
|
119
|
+
function codexImagePath(args: unknown): string | undefined {
|
|
120
|
+
if (typeof args === 'string') {
|
|
121
|
+
if (args.length > 16_384) return undefined
|
|
122
|
+
try { args = JSON.parse(args) } catch { return undefined }
|
|
123
|
+
}
|
|
124
|
+
const path = obj(args)?.path
|
|
125
|
+
return typeof path === 'string' && path.trim().length > 0 && path.length <= 4096 && !path.includes('\0') ? path : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The tool calls a row makes, by call id: their names, and a Read's file path. Pure. */
|
|
129
|
+
export function toolUsesOf(provider: AgentProvider, row: Record<string, unknown>): Array<[string, ToolUseMeta]> {
|
|
130
|
+
if (row.isSidechain === true) return []
|
|
131
|
+
if (provider === 'claude') {
|
|
132
|
+
if (row.type !== 'assistant') return []
|
|
133
|
+
const content = obj(row.message)?.content
|
|
134
|
+
if (!Array.isArray(content)) return []
|
|
135
|
+
const out: Array<[string, ToolUseMeta]> = []
|
|
136
|
+
for (const block of content) {
|
|
137
|
+
const b = obj(block)
|
|
138
|
+
if (!b || b.type !== 'tool_use' || typeof b.id !== 'string' || typeof b.name !== 'string') continue
|
|
139
|
+
const input = obj(b.input)
|
|
140
|
+
const path = typeof input?.file_path === 'string' ? input.file_path : undefined
|
|
141
|
+
out.push([b.id, path ? { name: b.name, path } : { name: b.name }])
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
if (provider === 'codex') {
|
|
146
|
+
if (row.type !== 'response_item') return []
|
|
147
|
+
const p = obj(row.payload)
|
|
148
|
+
if (!p || (p.type !== 'function_call' && p.type !== 'custom_tool_call') || typeof p.call_id !== 'string' || typeof p.name !== 'string') return []
|
|
149
|
+
const path = p.name === 'view_image' ? codexImagePath(p.type === 'function_call' ? p.arguments : p.input) : undefined
|
|
150
|
+
return [[p.call_id, path ? { name: p.name, path } : { name: p.name }]]
|
|
151
|
+
}
|
|
152
|
+
return []
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The images inside a row's tool results, in order, labelled by the tool that produced them.
|
|
157
|
+
* A sub-agent's results went to its parent, not to the person, and stay out. Pure.
|
|
158
|
+
*/
|
|
159
|
+
export function toolResultImages(provider: AgentProvider, row: Record<string, unknown>, uses: ReadonlyMap<string, ToolUseMeta>): FoundToolImage[] {
|
|
160
|
+
if (row.isSidechain === true) return []
|
|
161
|
+
const out: FoundToolImage[] = []
|
|
162
|
+
if (provider === 'claude') {
|
|
163
|
+
if (row.type !== 'user') return []
|
|
164
|
+
const content = obj(row.message)?.content
|
|
165
|
+
if (!Array.isArray(content)) return []
|
|
166
|
+
const results = content.filter(b => obj(b)?.type === 'tool_result')
|
|
167
|
+
for (const block of results) {
|
|
168
|
+
const b = obj(block)!
|
|
169
|
+
const meta = typeof b.tool_use_id === 'string' ? uses.get(b.tool_use_id) : undefined
|
|
170
|
+
const tool = toolLabel(meta?.name)
|
|
171
|
+
// 7.0.4 QA: a Read's embedded bytes keep the path it read, so `~/Desktop/credentials.png` is refused by name.
|
|
172
|
+
const readPath = meta?.name === 'Read' && meta.path ? meta.path : undefined
|
|
173
|
+
const found = Array.isArray(b.content) ? b.content.map(base64Block).filter((x): x is ToolImageRef => x !== null) : []
|
|
174
|
+
if (found.length) { for (const ref of found) out.push({ tool, ref: readPath && ref.kind === 'embedded' ? { ...ref, path: readPath } : ref }); continue }
|
|
175
|
+
// A Read of an image whose message block was not written: the row's own record of the
|
|
176
|
+
// result may still carry the bytes; else only the path the Read named; else nothing to show.
|
|
177
|
+
const result = obj(row.toolUseResult)
|
|
178
|
+
if (results.length !== 1 || b.is_error === true || result?.type !== 'image') continue
|
|
179
|
+
const file = obj(result.file)
|
|
180
|
+
if (file && typeof file.base64 === 'string' && file.base64 && typeof file.type === 'string' && TOOL_IMAGE_TYPES.includes(file.type)) {
|
|
181
|
+
out.push({ tool, ref: { kind: 'embedded', mediaType: file.type, data: file.base64, ...(readPath ? { path: readPath } : {}) } }); continue
|
|
182
|
+
}
|
|
183
|
+
if (meta?.name === 'Read' && meta.path && IMAGE_PATH.test(meta.path)) out.push({ tool, ref: { kind: 'path', path: meta.path } })
|
|
184
|
+
else out.push({ tool, ref: { kind: 'missing' } })
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
if (provider === 'codex') {
|
|
189
|
+
if (row.type !== 'response_item') return []
|
|
190
|
+
const p = obj(row.payload)
|
|
191
|
+
if (!p || (p.type !== 'function_call_output' && p.type !== 'custom_tool_call_output') || !Array.isArray(p.output)) return []
|
|
192
|
+
const meta = typeof p.call_id === 'string' ? uses.get(p.call_id) : undefined
|
|
193
|
+
const tool = meta?.name === 'view_image' ? 'Read' : toolLabel(meta?.name)
|
|
194
|
+
for (const block of p.output) {
|
|
195
|
+
const ref = dataUrlBlock(block)
|
|
196
|
+
if (!ref || ref.kind !== 'embedded') continue
|
|
197
|
+
// A known file-read without a trustworthy source cannot bypass secret filename checks.
|
|
198
|
+
// Other image tools (for example exec screenshots) do not name a source file.
|
|
199
|
+
out.push({ tool, ref: meta?.name === 'view_image' ? meta.path ? { ...ref, path: meta.path } : { kind: 'missing' } : ref })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The images a person attached to a prompt row, or [] (a tool result, a sub-agent, an injected
|
|
207
|
+
* row, an assistant row). Whether the row is a turn at all is the turn reader's call. Pure.
|
|
208
|
+
*/
|
|
209
|
+
export function promptImages(provider: AgentProvider, row: Record<string, unknown>): ToolImageRef[] {
|
|
210
|
+
if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return []
|
|
211
|
+
if (provider === 'claude') {
|
|
212
|
+
if (row.type !== 'user' || row.toolUseResult) return []
|
|
213
|
+
const content = obj(row.message)?.content
|
|
214
|
+
if (!Array.isArray(content) || content.some(b => obj(b)?.type === 'tool_result')) return []
|
|
215
|
+
return content.map(base64Block).filter((x): x is ToolImageRef => x !== null)
|
|
216
|
+
}
|
|
217
|
+
if (provider === 'codex') {
|
|
218
|
+
if (row.type !== 'response_item') return []
|
|
219
|
+
const p = obj(row.payload)
|
|
220
|
+
if (!p || p.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return []
|
|
221
|
+
return p.content.map(dataUrlBlock).filter((x): x is ToolImageRef => x !== null)
|
|
222
|
+
}
|
|
223
|
+
return []
|
|
224
|
+
}
|