openvisio-agent 0.19.11 → 0.20.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.
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { resolve } from 'node:path'
3
+ import { OV_DIR } from './lib.mjs'
4
+
5
+ export function studioOptions({ flags = {}, positional = [] } = {}) {
6
+ const supported = new Set(['port', 'no-open', 'demo', 'state-dir'])
7
+ if (positional.length) throw new Error('studio accepts options only. Use openvisio-agent studio --help.')
8
+ for (const key of Object.keys(flags)) if (!supported.has(key)) throw new Error(`Unknown studio option --${key}.`)
9
+ for (const key of ['demo', 'no-open']) if (flags[key] != null && flags[key] !== true) throw new Error(`--${key} does not accept a value.`)
10
+ const rawPort = flags.port ?? 4317
11
+ if (!['string', 'number'].includes(typeof rawPort) || !/^\d+$/.test(String(rawPort)) || !Number.isSafeInteger(Number(rawPort)) || Number(rawPort) > 65535) throw new Error('--port must be a number between 0 and 65535 (0 selects an available port).')
12
+ if (flags['state-dir'] != null && (typeof flags['state-dir'] !== 'string' || !flags['state-dir'].trim())) throw new Error('--state-dir requires a directory path.')
13
+ return { host: '127.0.0.1', port: Number(rawPort), stateDir: flags['state-dir'] ? resolve(flags['state-dir']) : OV_DIR, demo: !!flags.demo, openBrowser: !flags['no-open'] }
14
+ }
15
+
16
+ export function openStudioBrowser(url, { platform = process.platform, spawnProcess = spawn } = {}) {
17
+ const parsed = new URL(url)
18
+ if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1') return Promise.resolve(false)
19
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open'
20
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
21
+ return new Promise((resolveResult) => {
22
+ let child
23
+ try { child = spawnProcess(command, args, { stdio: 'ignore', detached: false, shell: false, windowsHide: true }) }
24
+ catch { resolveResult(false); return }
25
+ let settled = false
26
+ const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolveResult(value) } }
27
+ const timer = setTimeout(() => { try { child.kill() } catch {} done(false) }, 5000)
28
+ timer.unref?.()
29
+ child.once('error', () => done(false))
30
+ child.once('exit', (code) => done(code === 0))
31
+ })
32
+ }
33
+
34
+ export async function runStudio(input, { startServer, openBrowser = openStudioBrowser, log = console.log, signals = process } = {}) {
35
+ const options = studioOptions(input)
36
+ const start = startServer || (await import('./studio-server.mjs')).startStudioServer
37
+ let server
38
+ try { server = await start(options) }
39
+ catch (error) {
40
+ if (error?.code === 'EADDRINUSE') throw new Error(`Port ${options.port} is already in use. Open the existing viewer or run openvisio-agent studio --port 0.`)
41
+ throw error
42
+ }
43
+ log(`Agent studio: ${server.url}`)
44
+ if (options.demo) log('Demo mode shows simulated activity. It does not start agents or model calls.')
45
+ else log('Showing local watcher activity. Updated watchers record new cycles automatically.')
46
+ log('Press Ctrl+C to close the viewer. Agents keep running.')
47
+ let closing
48
+ const close = () => {
49
+ if (closing) return closing
50
+ signals.removeListener('SIGINT', close)
51
+ signals.removeListener('SIGTERM', close)
52
+ closing = Promise.resolve().then(() => server.close())
53
+ closing.catch((error) => { log(`Viewer shutdown failed: ${error?.message || error}`); process.exitCode = 1 })
54
+ return closing
55
+ }
56
+ signals.once('SIGINT', close)
57
+ signals.once('SIGTERM', close)
58
+ if (options.openBrowser && !(await openBrowser(server.url))) log(`Open ${server.url} in your browser.`)
59
+ return { ...server, close }
60
+ }
@@ -0,0 +1,224 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open, readdir, readFile, realpath } from 'node:fs/promises'
4
+ import { createServer } from 'node:http'
5
+ import { dirname, join, relative, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { isPrivateThoughtEvent, sanitizeJournalData } from './agent-journal.mjs'
8
+
9
+ const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
10
+ const FRESH_MS = 45_000
11
+ const DEFAULT_ASSETS = fileURLToPath(new URL('../studio/', import.meta.url))
12
+ const pidAlive = (pid) => {
13
+ if (!Number.isSafeInteger(pid) || pid < 1) return false
14
+ try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
15
+ }
16
+
17
+ function normalizeEvent(value) {
18
+ if (!value || value.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.runId !== 'string' || typeof value.agent?.identifier !== 'string' || !Number.isFinite(Date.parse(value.timestamp)) || typeof value.type !== 'string' || isPrivateThoughtEvent(value.type)) return null
19
+ return sanitizeJournalData(value)
20
+ }
21
+
22
+ async function tailEvents(path, maxBytes) {
23
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
24
+ try {
25
+ const entry = await handle.stat()
26
+ if (!entry.isFile()) return { events: [], bytes: 0 }
27
+ const count = Math.min(entry.size, maxBytes), offset = entry.size - count
28
+ const bytes = Buffer.alloc(count)
29
+ const { bytesRead } = await handle.read(bytes, 0, count, offset)
30
+ let text = bytes.subarray(0, bytesRead).toString('utf8')
31
+ if (offset) text = text.slice(text.indexOf('\n') + 1)
32
+ const lines = text.split('\n')
33
+ lines.pop() // A partial final line is retried on the next snapshot.
34
+ const events = []
35
+ for (const line of lines) {
36
+ if (Buffer.byteLength(line) > 32 * 1024) continue
37
+ try { const event = normalizeEvent(JSON.parse(line)); if (event) events.push(event) } catch { /* partial or corrupt record */ }
38
+ }
39
+ return { events, bytes: bytesRead }
40
+ } finally { await handle.close() }
41
+ }
42
+
43
+ async function readSmallLocalFile(path, maxBytes = 64 * 1024) {
44
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
45
+ try {
46
+ const entry = await handle.stat()
47
+ if (!entry.isFile() || entry.size > maxBytes) return null
48
+ return await handle.readFile('utf8')
49
+ } finally { await handle.close() }
50
+ }
51
+
52
+ async function configuredAgents(stateDir) {
53
+ const entries = await readdir(stateDir, { withFileTypes: true }).catch(() => [])
54
+ const agents = []
55
+ // Only direct, conventionally named setup files are considered; never recurse
56
+ // into credential/config folders or expose their complete contents.
57
+ for (const entry of entries.filter((entry) => entry.isFile() && /^[a-z0-9][a-z0-9_-]{0,79}\.json$/i.test(entry.name)).slice(0, 64)) {
58
+ try {
59
+ const config = JSON.parse(await readSmallLocalFile(join(stateDir, entry.name)))
60
+ if (!config?.slug || entry.name !== `${config.slug}.json`) continue
61
+ const backend = config.mode === 'backend' && typeof config.identifier === 'string' && config.identifier && typeof config.apiKey === 'string'
62
+ const relay = typeof config.host === 'string' && typeof config.key === 'string'
63
+ if (!backend && !relay) continue
64
+ let pid = null, watcherAlive = null
65
+ const lock = await readSmallLocalFile(join(stateDir, `watch-${config.slug}.lock`), 64).catch(() => null)
66
+ if (/^\d+\s*$/.test(lock || '')) { pid = Number(lock.trim()); watcherAlive = pidAlive(pid) }
67
+ const identifier = backend ? config.identifier : config.slug
68
+ agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
69
+ } catch { /* unrelated, malformed or concurrently replaced setup file */ }
70
+ }
71
+ return agents
72
+ }
73
+
74
+ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
75
+ const warnings = [], directory = join(stateDir, 'observability')
76
+ let files = []
77
+ try {
78
+ const entry = await lstat(directory)
79
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error('Observability path is not a real directory')
80
+ const entries = await readdir(directory, { withFileTypes: true })
81
+ files = (await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')).slice(0, 256).map(async (entry) => {
82
+ const path = join(directory, entry.name)
83
+ const metadata = await lstat(path).catch(() => null)
84
+ return metadata?.isFile() && !metadata.isSymbolicLink() ? { path, modified: metadata.mtimeMs } : null
85
+ }))).filter(Boolean).sort((a, b) => b.modified - a.modified).slice(0, MAX_FILES)
86
+ if (entries.length > MAX_FILES) warnings.push('The viewer is showing a bounded tail of the most recently updated agent journals.')
87
+ } catch (error) { if (error.code !== 'ENOENT') warnings.push('Agent journals are currently unavailable.') }
88
+ const collected = []
89
+ let remaining = MAX_TOTAL_BYTES
90
+ for (const file of files) {
91
+ if (remaining <= 0) break
92
+ try {
93
+ const result = await tailEvents(file.path, Math.min(MAX_FILE_BYTES, remaining))
94
+ remaining -= result.bytes
95
+ collected.push(...result.events)
96
+ } catch { /* rotation or removal raced the read; retry next snapshot */ }
97
+ }
98
+ const events = [...new Map(collected.map((event) => [event.id, event])).values()].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp) || (a.runId === b.runId ? Number(a.sequence || 0) - Number(b.sequence || 0) : 0))
99
+ const latest = new Map()
100
+ for (const event of events) latest.set(event.agent.identifier, event)
101
+ const configured = await configuredAgents(stateDir)
102
+ const agentsById = new Map(configured.map((agent) => [agent.identifier, agent]))
103
+ for (const event of latest.values()) {
104
+ const age = now() - Date.parse(event.timestamp)
105
+ const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
106
+ const existing = agentsById.get(event.agent.identifier)
107
+ agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
108
+ }
109
+ const agents = [...agentsById.values()].sort((a, b) => a.name.localeCompare(b.name))
110
+ return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
111
+ }
112
+
113
+ function demoSnapshot(stamp) {
114
+ const events = []
115
+ const agents = ['codex', 'opencode', 'claude'].map((provider, index) => ({ id: `demo-${provider}`, identifier: `demo-${provider}`, slug: ['Alex', 'Robin', 'Sam'][index], name: ['Alex', 'Robin', 'Sam'][index], provider, runId: `demo-run-${provider}`, pid: null, status: 'demo', demo: true, lastSeen: new Date(stamp).toISOString(), lastEventType: 'output.final' }))
116
+ for (const [index, agent] of agents.entries()) {
117
+ const cycleId = `${agent.runId}:cycle-1`, common = { cycleId, kind: 'full', lane: 'work', model: ['gpt-5.6-sol', 'openai/gpt-5.6-sol', 'claude-sonnet'][index], workdir: '/demo/workspace/ticket-77', ticket: { projectId: 5, ticketId: 77, slug: 'DEMO-77' }, watcherPid: null }
118
+ const rows = [
119
+ ['runtime.started', { status: 'simulated' }],
120
+ ['cycle.queued', { ...common, status: 'queued' }],
121
+ ['cycle.started', { ...common, status: 'running', queueMs: 24 }],
122
+ ['process.started', { ...common, runtime: 'ACP', runtimePid: null, status: 'connecting' }],
123
+ ['plan.updated', { ...common, entries: [{ content: 'Inspect the failing build', status: 'completed', priority: 'high' }, { content: 'Fix the module import and run tests', status: 'completed', priority: 'high' }, { content: 'Publish the verified result', status: 'completed', priority: 'normal' }] }],
124
+ ['tool.started', { ...common, toolCallId: 'demo-read', name: 'read_file', title: 'Read package.json', status: 'running', kind: 'read', locations: [{ path: 'package.json' }] }],
125
+ ['tool.finished', { ...common, toolCallId: 'demo-read', name: 'read_file', title: 'Read package.json', status: 'completed', kind: 'read', locations: [{ path: 'package.json' }] }],
126
+ ['output.progress', { ...common, text: 'I found the import that prevented the build from completing.' }],
127
+ ['tool.started', { ...common, toolCallId: 'demo-test', name: 'execute', title: 'Run the build and tests', status: 'running', kind: 'execute' }],
128
+ ['tool.finished', { ...common, toolCallId: 'demo-test', name: 'execute', title: 'Run the build and tests', status: 'completed', kind: 'execute' }],
129
+ ['output.final', { ...common, text: 'The module import is fixed. The build and 18 tests pass. This is simulated demonstration data.' }],
130
+ ['cycle.finished', { ...common, status: 'completed', durationMs: 24_000 }],
131
+ ['process.ready', { ...common, runtime: 'ACP', runtimePid: null, status: 'idle' }],
132
+ ]
133
+ for (const [offset, [type, data]] of rows.entries()) events.push({ schemaVersion: 1, id: `${agent.runId}:${offset + 1}`, sequence: offset + 1, runId: agent.runId, timestamp: new Date(stamp - 30_000 + index * 1000 + offset * 1500).toISOString(), type, agent: { identifier: agent.identifier, slug: agent.slug, provider: agent.provider }, pid: null, demo: true, data })
134
+ }
135
+ return { schemaVersion: 1, demo: true, generatedAt: new Date(stamp).toISOString(), agents, events: events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)), limits: { maxEvents: MAX_EVENTS }, warnings: ['Demo mode contains simulated agent activity. No model or task is running.'] }
136
+ }
137
+
138
+ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false }) {
139
+ if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
140
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
141
+ if (!stateDir) throw new Error('Agent Studio requires a local state directory')
142
+ const bindHost = host === 'localhost' ? '127.0.0.1' : host
143
+ const root = await realpath(assetsDir)
144
+ const clients = new Set()
145
+ const demoState = demo ? demoSnapshot(Date.now()) : null
146
+ let current, pending, stopped = false, interval
147
+ const delivered = new WeakMap()
148
+ const fingerprint = (value) => createHash('sha256').update(JSON.stringify({ agents: value.agents, events: value.events, warnings: value.warnings })).digest('hex')
149
+ const snapshot = async () => {
150
+ if (pending) return pending
151
+ pending = (async () => {
152
+ current = demoState || await readStudioSnapshot({ stateDir })
153
+ return current
154
+ })().finally(() => { pending = null })
155
+ return pending
156
+ }
157
+ const headers = {
158
+ 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
159
+ 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
160
+ 'x-frame-options': 'DENY', 'cross-origin-resource-policy': 'same-origin',
161
+ }
162
+ const sendSnapshot = (res, value) => {
163
+ if (res.writableNeedDrain) return
164
+ res.write(`event: snapshot\ndata: ${JSON.stringify(value)}\n\n`)
165
+ delivered.set(res, fingerprint(value))
166
+ if (res.writableLength > 8 * 1024 * 1024) { clients.delete(res); res.end() }
167
+ }
168
+ const server = createServer(async (req, res) => {
169
+ for (const [key, value] of Object.entries(headers)) res.setHeader(key, value)
170
+ try {
171
+ const boundPort = server.address().port
172
+ const allowedAuthorities = new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`])
173
+ if (!allowedAuthorities.has(String(req.headers.host || '').toLowerCase())) { res.writeHead(403); res.end('Invalid local host'); return }
174
+ if (req.headers.origin && ![...allowedAuthorities].some((authority) => req.headers.origin === `http://${authority}`)) { res.writeHead(403); res.end('Cross-origin access denied'); return }
175
+ if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Cross-site access denied'); return }
176
+ if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { allow: 'GET, HEAD' }); res.end('Read-only viewer'); return }
177
+ const path = new URL(req.url, `http://${req.headers.host}`).pathname
178
+ if (path === '/api/snapshot') { res.setHeader('content-type', 'application/json; charset=utf-8'); res.end(req.method === 'HEAD' ? undefined : JSON.stringify(await snapshot())); return }
179
+ if (path === '/api/events' && req.method === 'GET') {
180
+ if (clients.size >= 32) { res.writeHead(503); res.end('Too many local viewer connections'); return }
181
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', connection: 'keep-alive' })
182
+ clients.add(res); req.on('close', () => clients.delete(res))
183
+ sendSnapshot(res, await snapshot())
184
+ return
185
+ }
186
+ const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs' })[path]
187
+ if (!file) { res.writeHead(404); res.end('Not found'); return }
188
+ const target = await realpath(resolve(root, file))
189
+ const rel = relative(root, target)
190
+ if (rel.startsWith('..') || resolve(dirname(target)) !== root) { res.writeHead(403); res.end('Invalid asset'); return }
191
+ const body = await readFile(target)
192
+ res.setHeader('content-type', file.endsWith('.css') ? 'text/css; charset=utf-8' : file.endsWith('.mjs') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8')
193
+ res.end(req.method === 'HEAD' ? undefined : body)
194
+ } catch (error) {
195
+ if (!res.headersSent) res.writeHead(error.code === 'ENOENT' ? 404 : 500)
196
+ res.end('Local viewer request unavailable')
197
+ }
198
+ })
199
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, bindHost, resolve) }).catch((error) => {
200
+ if (error.code === 'EADDRINUSE') throw Object.assign(new Error(`Agent Studio port ${port} is already in use. Choose another port with --port.`), { code: error.code })
201
+ throw error
202
+ })
203
+ let ticks = 0
204
+ interval = setInterval(async () => {
205
+ if (stopped || !clients.size) return
206
+ try {
207
+ const value = await snapshot()
208
+ const next = fingerprint(value)
209
+ for (const res of clients) if (delivered.get(res) !== next) sendSnapshot(res, value)
210
+ if (++ticks % 30 === 0) for (const res of clients) if (!res.writableNeedDrain) res.write(': heartbeat\n\n')
211
+ } catch { /* keep serving the next readable snapshot */ }
212
+ }, 500)
213
+ interval.unref?.()
214
+ let closing
215
+ const close = () => {
216
+ if (closing) return closing
217
+ stopped = true; clearInterval(interval)
218
+ for (const res of clients) res.end()
219
+ clients.clear(); server.closeAllConnections()
220
+ closing = new Promise((resolve) => server.close(resolve))
221
+ return closing
222
+ }
223
+ return { url: `http://${bindHost === '::1' ? '[::1]' : bindHost}:${server.address().port}`, close }
224
+ }
@@ -1,4 +1,5 @@
1
1
  export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
2
+ if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error('Invalid task column cache lifetime')
2
3
  const cache = new Map(), pending = new Map()
3
4
  return async (projectId) => {
4
5
  const key = String(projectId)
@@ -6,9 +7,9 @@ export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
6
7
  if (hit && hit.expiresAt > now()) return hit.value
7
8
  if (pending.has(key)) return pending.get(key)
8
9
  const request = Promise.resolve().then(() => load(projectId)).then((data) => {
9
- const types = data.types ?? data.task_types ?? data.taskTypes
10
+ const types = data?.types ?? data?.task_types ?? data?.taskTypes
10
11
  if (!Array.isArray(types)) throw new Error('Cannot verify task columns')
11
- const matching = (pattern) => new Set(types.filter((type) => pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
12
+ const matching = (pattern) => new Set(types.filter((type) => type && ['number', 'string'].includes(typeof type.id) && Number.isSafeInteger(Number(type.id)) && Number(type.id) > 0 && pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
12
13
  const value = {
13
14
  done: matching(/\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i),
14
15
  review: matching(/\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i),
@@ -0,0 +1,62 @@
1
+ import { messageParentId, messageSenderIsAgent, renderedAgentMessages } from './events.mjs'
2
+
3
+ // An agent's own root post establishes a conversation, even if nobody tagged
4
+ // the agent before replying. Verify the exact root, never mere participation.
5
+ export function ownThreadRoot(thread, rootId, identity) {
6
+ if (rootId == null || String(rootId).trim() === '') return null
7
+ return renderedAgentMessages(thread, identity).find((row) => String(row.id) === String(rootId) && row.parentId == null) || null
8
+ }
9
+
10
+ function verifiedOtherAuthor(row, identity) {
11
+ const sender = [row.sender, row.user, row.author, row.member].find((value) => value && typeof value === 'object') || {}
12
+ if (messageSenderIsAgent(row)) {
13
+ const agent = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((value) => value && typeof value === 'object') || sender
14
+ const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
15
+ const knownId = (id) => messageParentId({ parent_id: id }) != null
16
+ if (knownId(identity?.id) && knownId(agentId)) return Number(agentId) !== Number(identity.id)
17
+ const identifier = agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier
18
+ return !!identifier && !!identity?.identifier && String(identifier).toLowerCase() !== String(identity.identifier).toLowerCase()
19
+ }
20
+ const kind = String(sender.type ?? sender.kind ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
21
+ return /^(?:human|user|member|person)$/.test(kind) || typeof sender.email === 'string' || messageParentId({ parent_id: row.sender_id ?? row.user_id ?? row.author_id }) != null
22
+ }
23
+
24
+ function containsRoot(value, rootId, identity, depth = 0) {
25
+ if (!value || depth > 6) return false
26
+ if (Array.isArray(value)) return value.some((row) => containsRoot(row, rootId, identity, depth + 1))
27
+ if (typeof value !== 'object') return false
28
+ if (String(value.message_id ?? value.messageId ?? value.id) === String(rootId) && messageParentId(value) == null && typeof (value.content ?? value.body ?? value.text) === 'string' && verifiedOtherAuthor(value, identity)) return true
29
+ return ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
30
+ }
31
+
32
+ export function createThreadContextResolver({ read, save, cancelled, load, identity, now = Date.now }) {
33
+ const pending = new Map()
34
+ const otherRoots = new Map()
35
+ return async (channelId, rootId) => {
36
+ if (channelId == null || rootId == null || cancelled(channelId, rootId)) return ''
37
+ const cached = read(channelId, rootId)
38
+ if (cached) return cached
39
+ const key = `${channelId}:${rootId}`
40
+ const currentIdentity = identity()
41
+ const identityKey = JSON.stringify([currentIdentity?.id, currentIdentity?.identifier, currentIdentity?.slug])
42
+ const previous = otherRoots.get(key)
43
+ if (previous?.identityKey === identityKey && previous.until > now()) return ''
44
+ if (!pending.has(key)) {
45
+ const request = Promise.resolve().then(() => load(channelId, rootId)).then((thread) => {
46
+ const root = ownThreadRoot(thread, rootId, identity())
47
+ // Organization fanout repeats unrelated human threads. Cache only an
48
+ // observed non-owned root, never an unavailable/incomplete API result.
49
+ if (!root && containsRoot(thread, rootId, identity())) {
50
+ otherRoots.delete(key)
51
+ otherRoots.set(key, { identityKey, until: now() + 60_000 })
52
+ while (otherRoots.size > 256) otherRoots.delete(otherRoots.keys().next().value)
53
+ }
54
+ if (!root || cancelled(channelId, rootId)) return ''
55
+ save(channelId, rootId, root.content)
56
+ return root.content
57
+ }).finally(() => pending.delete(key))
58
+ pending.set(key, request)
59
+ }
60
+ return pending.get(key)
61
+ }
62
+ }