@gotcos/glasses-server 6.3.1 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -7
- package/CHANGELOG.md +83 -0
- package/README.md +24 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +57 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -8,26 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process'
|
|
10
10
|
import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
11
|
import { appendFileSync } from 'node:fs'
|
|
13
|
-
import crypto from 'node:crypto'
|
|
14
12
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
13
|
+
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
15
14
|
import { cosBrainDir } from './launch-dir.js'
|
|
16
15
|
import { logTokenAudit } from './token-audit.js'
|
|
17
16
|
import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
|
|
18
|
-
import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
|
|
17
|
+
import { getHistory, addExchange, setExchangeAttachments, removeExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
|
|
19
18
|
import { notifySessionStart, notifyExchange } from './telegram-notify.js'
|
|
20
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isClaudeModel,
|
|
21
|
+
DEFAULT_MODEL,
|
|
22
|
+
resolveClaudeCliModelId,
|
|
23
|
+
resolveCliEffortFlag,
|
|
24
|
+
ULTRACODE_KEYWORD,
|
|
25
|
+
type ClaudeModelPreference,
|
|
26
|
+
type EffortPreference,
|
|
27
|
+
} from '../../shared/model-preference.js'
|
|
21
28
|
import {
|
|
22
29
|
finishClaudeRun,
|
|
23
30
|
getClaudeEffortLevel,
|
|
24
31
|
startClaudeRun,
|
|
25
32
|
updateClaudeRun,
|
|
33
|
+
type ClaudeRunStatus,
|
|
26
34
|
} from './claude-run-ledger.js'
|
|
35
|
+
import {
|
|
36
|
+
claudeToolInputPreview,
|
|
37
|
+
claudeToolResultPreviewLines,
|
|
38
|
+
type ActivityPreviewLine,
|
|
39
|
+
} from './activity-preview.js'
|
|
40
|
+
import {
|
|
41
|
+
createRunOutputImagePublisher,
|
|
42
|
+
isRunOutputImagePublisherCommand,
|
|
43
|
+
type RunOutputImageCollectionStats,
|
|
44
|
+
} from './run-output-images.js'
|
|
45
|
+
import {
|
|
46
|
+
MAX_ATTACHMENTS_PER_PROMPT,
|
|
47
|
+
type MediaAttachmentRef,
|
|
48
|
+
} from '../../shared/media-attachment.js'
|
|
27
49
|
|
|
28
50
|
// Inactivity = no stdout data for this long → kill (catches stalls)
|
|
29
51
|
const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
30
52
|
opus: 180_000, // 3 minutes — Opus can gap during tool use (WebSearch, reasoning)
|
|
53
|
+
fable: 180_000, // 3 minutes — premium thinker, same gap tolerance as Opus
|
|
31
54
|
sonnet: 30_000, // 30 seconds
|
|
32
55
|
haiku: 15_000, // 15 seconds
|
|
33
56
|
}
|
|
@@ -35,10 +58,12 @@ const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
|
35
58
|
// Wall clock max = absolute cap even if actively streaming
|
|
36
59
|
const WALL_MAX_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
37
60
|
opus: 600_000, // 10 minutes
|
|
61
|
+
fable: 900_000, // 15 minutes — deepest model, longest single-turn runs
|
|
38
62
|
sonnet: 120_000, // 2 minutes
|
|
39
63
|
haiku: 60_000, // 1 minute
|
|
40
64
|
}
|
|
41
65
|
const WALL_MAX_EXTENDED_MS = 900_000 // 15 minutes for slash commands / heavy queries
|
|
66
|
+
const WALL_MAX_DEEP_EFFORT_MS = 1_200_000 // 20 minutes for max/ultracode
|
|
42
67
|
|
|
43
68
|
// Heartbeat = emit progress status during silence so client knows we're alive
|
|
44
69
|
const HEARTBEAT_INTERVAL_MS = 6_000 // Every 6 seconds
|
|
@@ -166,7 +191,7 @@ export function logLatency(entry: LatencyEntry): void {
|
|
|
166
191
|
}
|
|
167
192
|
|
|
168
193
|
/**
|
|
169
|
-
* Pre-warm the Claude CLI by running a minimal
|
|
194
|
+
* Pre-warm the Claude CLI by running a minimal default-model query at boot.
|
|
170
195
|
* Captures the CLI session ID so the first real query can use --resume.
|
|
171
196
|
* Called from index.ts alongside prewarmContext().
|
|
172
197
|
*/
|
|
@@ -183,7 +208,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
183
208
|
|
|
184
209
|
const proc = spawn('claude', [
|
|
185
210
|
'-p',
|
|
186
|
-
'--model', DEFAULT_MODEL,
|
|
211
|
+
'--model', resolveClaudeCliModelId(DEFAULT_MODEL),
|
|
187
212
|
'--effort', getClaudeEffortLevel(),
|
|
188
213
|
'--output-format', 'stream-json',
|
|
189
214
|
'--verbose',
|
|
@@ -207,7 +232,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
207
232
|
if (!trimmed) continue
|
|
208
233
|
try {
|
|
209
234
|
const event = JSON.parse(trimmed)
|
|
210
|
-
if (event.type === 'result' && event.session_id) {
|
|
235
|
+
if (event.type === 'result' && event.session_id && !claudeResultErrorMessage(event)) {
|
|
211
236
|
preWarmedCliSessionId = event.session_id
|
|
212
237
|
scheduleCliSessionSave()
|
|
213
238
|
const elapsed = Date.now() - start
|
|
@@ -222,7 +247,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
222
247
|
const elapsed = Date.now() - start
|
|
223
248
|
logTokenAudit({
|
|
224
249
|
source: 'g2-prewarm',
|
|
225
|
-
model:
|
|
250
|
+
model: DEFAULT_MODEL,
|
|
226
251
|
inputChars: 500, // system prompt + "ready"
|
|
227
252
|
outputChars: 50,
|
|
228
253
|
durationMs: elapsed,
|
|
@@ -268,6 +293,8 @@ function isExtendedQuery(query: string): boolean {
|
|
|
268
293
|
export interface ModelRunMetadata {
|
|
269
294
|
codexRunId?: string
|
|
270
295
|
codexThreadId?: string
|
|
296
|
+
outputAttachments?: MediaAttachmentRef[]
|
|
297
|
+
outputImageStats?: RunOutputImageCollectionStats
|
|
271
298
|
}
|
|
272
299
|
|
|
273
300
|
export interface StreamCallbacks {
|
|
@@ -275,9 +302,23 @@ export interface StreamCallbacks {
|
|
|
275
302
|
onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => void
|
|
276
303
|
onError: (error: string) => void
|
|
277
304
|
onToolStatus?: (toolName: string) => void
|
|
305
|
+
onActivityLine?: (line: ActivityPreviewLine) => void
|
|
278
306
|
onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
|
|
279
307
|
}
|
|
280
308
|
|
|
309
|
+
/** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
|
|
310
|
+
* is authoritative and must win before session ids or result text are saved. */
|
|
311
|
+
export function claudeResultErrorMessage(event: any): string | null {
|
|
312
|
+
if (event?.type !== 'result' || (event?.is_error !== true && event?.subtype !== 'error')) return null
|
|
313
|
+
const raw = typeof event?.result === 'string' ? event.result
|
|
314
|
+
: typeof event?.error === 'string' ? event.error
|
|
315
|
+
: typeof event?.error?.message === 'string' ? event.error.message
|
|
316
|
+
: typeof event?.message === 'string' ? event.message
|
|
317
|
+
: ''
|
|
318
|
+
const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
|
|
319
|
+
return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
|
|
320
|
+
}
|
|
321
|
+
|
|
281
322
|
type Phase = 'context' | 'thinking' | 'searching' | 'generating'
|
|
282
323
|
|
|
283
324
|
const PHASE_LABELS: Record<Phase, string> = {
|
|
@@ -294,6 +335,7 @@ const PHASE_LABELS: Record<Phase, string> = {
|
|
|
294
335
|
export interface CallOptions {
|
|
295
336
|
lightweight?: boolean // Skip async context fetch — use cached context instantly (G2 speed path)
|
|
296
337
|
abortSignal?: AbortSignal
|
|
338
|
+
effort?: EffortPreference
|
|
297
339
|
}
|
|
298
340
|
|
|
299
341
|
export async function callClaudeStreaming(
|
|
@@ -301,7 +343,7 @@ export async function callClaudeStreaming(
|
|
|
301
343
|
sessionId: string | undefined,
|
|
302
344
|
callbacks: StreamCallbacks,
|
|
303
345
|
model?: ClaudeModelPreference,
|
|
304
|
-
images?:
|
|
346
|
+
images?: ModelImageInput[],
|
|
305
347
|
reference?: PromptReference,
|
|
306
348
|
globalMsgNum?: number,
|
|
307
349
|
options?: CallOptions,
|
|
@@ -314,9 +356,12 @@ export async function callClaudeStreaming(
|
|
|
314
356
|
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
315
357
|
const contextPrompt = historyPrompt
|
|
316
358
|
|
|
317
|
-
// Resolve model: per-message > session preference >
|
|
359
|
+
// Resolve model: per-message > session preference > public-server default.
|
|
318
360
|
const sessionModel = getSessionModel(sid)
|
|
319
|
-
const resolvedModel: ClaudeModelPreference = model
|
|
361
|
+
const resolvedModel: ClaudeModelPreference = model
|
|
362
|
+
?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL)
|
|
363
|
+
const resolvedEffort = options?.effort
|
|
364
|
+
const cliEffortFlag = resolvedEffort ? resolveCliEffortFlag(resolvedEffort) : getClaudeEffortLevel()
|
|
320
365
|
|
|
321
366
|
// Notify client immediately — model is known before any async work
|
|
322
367
|
// Pass existing CLI session ID if resuming (new sessions get it after first result)
|
|
@@ -327,41 +372,55 @@ export async function callClaudeStreaming(
|
|
|
327
372
|
// Phase: context loading (skipped in lightweight mode)
|
|
328
373
|
let phase: Phase = 'context'
|
|
329
374
|
|
|
375
|
+
const imageInputs: ModelImageInput[] = images ?? []
|
|
376
|
+
const imagePaths = imageInputs.map(input => input.path)
|
|
377
|
+
const outputImageBudget = Math.max(0, MAX_ATTACHMENTS_PER_PROMPT - imageInputs.length)
|
|
378
|
+
let outputImagePublisher: ReturnType<typeof createRunOutputImagePublisher> | null = null
|
|
379
|
+
if (!options?.lightweight && outputImageBudget > 0) {
|
|
380
|
+
try {
|
|
381
|
+
outputImagePublisher = createRunOutputImagePublisher({
|
|
382
|
+
sessionId: sid,
|
|
383
|
+
globalMsgNum,
|
|
384
|
+
maxImages: outputImageBudget,
|
|
385
|
+
})
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error('[claude-bridge] output image publisher unavailable:', err)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
330
391
|
let systemPrompt: string
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
392
|
+
try {
|
|
393
|
+
if (options?.lightweight) {
|
|
394
|
+
// G2 speed path — minimal system prompt, context only when needed
|
|
395
|
+
systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
|
|
396
|
+
} else {
|
|
397
|
+
callbacks.onToolStatus?.('Loading context...')
|
|
398
|
+
// Full COS path — async context with Python subprocess calls
|
|
399
|
+
systemPrompt = await buildSystemPrompt(contextPrompt)
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
outputImagePublisher?.cleanup()
|
|
403
|
+
throw err
|
|
338
404
|
}
|
|
405
|
+
if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
|
|
339
406
|
|
|
340
407
|
// Phase: thinking (waiting for Claude to start)
|
|
341
408
|
phase = 'thinking'
|
|
342
409
|
callbacks.onToolStatus?.('Thinking...')
|
|
343
410
|
|
|
344
|
-
// ── Vision: save temp image files if provided ──
|
|
345
|
-
const imagePaths: string[] = []
|
|
346
|
-
if (images && images.length > 0) {
|
|
347
|
-
for (const img of images) {
|
|
348
|
-
const id = crypto.randomUUID().slice(0, 8)
|
|
349
|
-
const p = join('/tmp', `cos-vision-${id}.jpg`)
|
|
350
|
-
writeFileSync(p, Buffer.from(img, 'base64'))
|
|
351
|
-
imagePaths.push(p)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
411
|
// Check if this is the first query in a new session (before adding exchange)
|
|
356
412
|
const isFirstQuery = isNewSession(sid)
|
|
357
413
|
|
|
358
414
|
// Record user message (with [Photo]/[N Photos] prefix for vision queries)
|
|
359
415
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
360
416
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
361
|
-
addExchange(sid, 'user', historyQuery, globalMsgNum)
|
|
417
|
+
const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
|
|
362
418
|
|
|
363
419
|
// Vision queries need the Read tool to see the image files
|
|
364
|
-
const
|
|
420
|
+
const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
|
|
421
|
+
const tools = outputImagePublisher
|
|
422
|
+
? `${baseTools},${outputImagePublisher.claudeAllowedTool}`
|
|
423
|
+
: baseTools
|
|
365
424
|
|
|
366
425
|
// Prepend image instruction when photos are attached
|
|
367
426
|
let fullQuery: string
|
|
@@ -376,8 +435,8 @@ export async function callClaudeStreaming(
|
|
|
376
435
|
|
|
377
436
|
// Check if we have a prior CLI session for this COS session.
|
|
378
437
|
// If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
|
|
379
|
-
if (!existingCliSession && preWarmedCliSessionId && resolvedModel ===
|
|
380
|
-
// Only
|
|
438
|
+
if (!existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
|
|
439
|
+
// Only the default model consumes the pre-warmed session.
|
|
381
440
|
// Hey Even (Haiku) cold-starts its own session to avoid model contamination.
|
|
382
441
|
existingCliSession = preWarmedCliSessionId
|
|
383
442
|
// Consume the pre-warmed session — next new session will cold start
|
|
@@ -392,8 +451,8 @@ export async function callClaudeStreaming(
|
|
|
392
451
|
|
|
393
452
|
const args = [
|
|
394
453
|
'-p',
|
|
395
|
-
'--model', resolvedModel,
|
|
396
|
-
'--effort',
|
|
454
|
+
'--model', resolveClaudeCliModelId(resolvedModel),
|
|
455
|
+
'--effort', cliEffortFlag,
|
|
397
456
|
'--output-format', 'stream-json',
|
|
398
457
|
'--verbose', // Required: stream-json requires --verbose
|
|
399
458
|
'--dangerously-skip-permissions', // Required: headless CLI mode with no TTY for user prompts
|
|
@@ -420,10 +479,14 @@ export async function callClaudeStreaming(
|
|
|
420
479
|
// Strip CLAUDECODE env var so claude -p doesn't think it's nested
|
|
421
480
|
const env = { ...process.env }
|
|
422
481
|
delete env.CLAUDECODE
|
|
482
|
+
if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
|
|
423
483
|
const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
|
|
424
484
|
const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
|
|
425
485
|
const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
|
|
426
|
-
const
|
|
486
|
+
const effortWallMax = resolvedEffort === 'max' || resolvedEffort === 'ultracode'
|
|
487
|
+
? Math.max(defaultWallMax, WALL_MAX_DEEP_EFFORT_MS)
|
|
488
|
+
: defaultWallMax
|
|
489
|
+
const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
|
|
427
490
|
const startTime = Date.now()
|
|
428
491
|
const run = startClaudeRun({
|
|
429
492
|
cosSessionId: sid,
|
|
@@ -434,6 +497,8 @@ export async function callClaudeStreaming(
|
|
|
434
497
|
timeoutMs: inactivityMs,
|
|
435
498
|
wallMaxMs: wallMax,
|
|
436
499
|
query: fullQuery,
|
|
500
|
+
effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
|
|
501
|
+
cliModelId: resolveClaudeCliModelId(resolvedModel),
|
|
437
502
|
})
|
|
438
503
|
|
|
439
504
|
const proc = spawn('claude', args, {
|
|
@@ -448,19 +513,50 @@ export async function callClaudeStreaming(
|
|
|
448
513
|
let finalized = false // Guard against double onDone/onError
|
|
449
514
|
let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
|
|
450
515
|
let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
|
|
516
|
+
const toolInputs = new Map<number, { name: string; json: string }>()
|
|
451
517
|
|
|
452
518
|
function cleanupImages() {
|
|
453
|
-
|
|
454
|
-
try { unlinkSync(p) } catch { /* ignore */ }
|
|
455
|
-
}
|
|
519
|
+
cleanupModelImageInputs(imageInputs)
|
|
456
520
|
}
|
|
457
521
|
|
|
458
|
-
function finalize(text: string) {
|
|
522
|
+
async function finalize(text: string) {
|
|
459
523
|
if (finalized) return
|
|
460
524
|
finalized = true
|
|
461
525
|
cleanup()
|
|
462
526
|
cleanupImages()
|
|
463
527
|
|
|
528
|
+
// Persist text before output-image normalization. A daemon crash during
|
|
529
|
+
// finalization cannot erase an otherwise successful answer.
|
|
530
|
+
const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
|
|
531
|
+
if (imagePaths.length > 0) {
|
|
532
|
+
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let outputAttachments: MediaAttachmentRef[] = []
|
|
536
|
+
let outputImageStats: RunOutputImageCollectionStats | undefined
|
|
537
|
+
if (outputImagePublisher) {
|
|
538
|
+
callbacks.onToolStatus?.('Preparing images...')
|
|
539
|
+
const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
|
|
540
|
+
preparingHeartbeat.unref?.()
|
|
541
|
+
try {
|
|
542
|
+
outputAttachments = await outputImagePublisher.collect()
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error('[claude-bridge] output image collection failed:', err)
|
|
545
|
+
} finally {
|
|
546
|
+
clearInterval(preparingHeartbeat)
|
|
547
|
+
outputImageStats = outputImagePublisher.stats
|
|
548
|
+
outputImagePublisher.cleanup()
|
|
549
|
+
}
|
|
550
|
+
if (outputAttachments.length > 0) {
|
|
551
|
+
setExchangeAttachments(sid, assistantExchange, outputAttachments)
|
|
552
|
+
}
|
|
553
|
+
if (outputImageStats && outputImageStats.rejected > 0) {
|
|
554
|
+
callbacks.onToolStatus?.(outputImageStats.attached > 0
|
|
555
|
+
? 'Some images could not be attached'
|
|
556
|
+
: 'Image attachment unavailable')
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
464
560
|
// Token audit — log every completed claude -p call
|
|
465
561
|
const totalMs = Date.now() - startTime
|
|
466
562
|
const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
|
|
@@ -473,14 +569,6 @@ export async function callClaudeStreaming(
|
|
|
473
569
|
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
474
570
|
})
|
|
475
571
|
|
|
476
|
-
addExchange(sid, 'assistant', text, globalMsgNum)
|
|
477
|
-
|
|
478
|
-
// Replace photo exchanges with condensed summaries to prevent context rot
|
|
479
|
-
// while preserving enough context for follow-up questions
|
|
480
|
-
if (imagePaths.length > 0) {
|
|
481
|
-
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
572
|
finishClaudeRun(run.runId, {
|
|
485
573
|
status: 'completed',
|
|
486
574
|
startedAtMs: startTime,
|
|
@@ -488,7 +576,10 @@ export async function callClaudeStreaming(
|
|
|
488
576
|
exitCode: 0,
|
|
489
577
|
})
|
|
490
578
|
|
|
491
|
-
callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey)
|
|
579
|
+
callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
|
|
580
|
+
...(outputAttachments.length > 0 ? { outputAttachments } : {}),
|
|
581
|
+
...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
|
|
582
|
+
})
|
|
492
583
|
|
|
493
584
|
// Telegram notifications — fire and forget
|
|
494
585
|
if (isFirstQuery) {
|
|
@@ -498,13 +589,23 @@ export async function callClaudeStreaming(
|
|
|
498
589
|
notifyExchange(sid, query, text)
|
|
499
590
|
}
|
|
500
591
|
|
|
501
|
-
function finalizeError(
|
|
592
|
+
function finalizeError(
|
|
593
|
+
msg: string,
|
|
594
|
+
exitCode?: number | null,
|
|
595
|
+
status: Exclude<ClaudeRunStatus, 'running'> = 'failed',
|
|
596
|
+
) {
|
|
502
597
|
if (finalized) return
|
|
503
598
|
finalized = true
|
|
504
599
|
cleanup()
|
|
505
600
|
cleanupImages()
|
|
601
|
+
outputImagePublisher?.cleanup()
|
|
602
|
+
removeExchange(sid, pendingUserExchange)
|
|
603
|
+
// A failed/cancelled resumed CLI turn may already exist in Claude's own
|
|
604
|
+
// session transcript even though COS history was rolled back. Never resume
|
|
605
|
+
// that potentially contaminated CLI session on retry.
|
|
606
|
+
if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
|
|
506
607
|
finishClaudeRun(run.runId, {
|
|
507
|
-
status
|
|
608
|
+
status,
|
|
508
609
|
startedAtMs: startTime,
|
|
509
610
|
error: msg,
|
|
510
611
|
exitCode,
|
|
@@ -512,6 +613,12 @@ export async function callClaudeStreaming(
|
|
|
512
613
|
callbacks.onError(msg)
|
|
513
614
|
}
|
|
514
615
|
|
|
616
|
+
function handleAbort() {
|
|
617
|
+
if (finalized) return
|
|
618
|
+
proc.kill('SIGTERM')
|
|
619
|
+
finalizeError('claude-bridge: client disconnected before Claude completed.', null, 'client_disconnected')
|
|
620
|
+
}
|
|
621
|
+
|
|
515
622
|
// ─── Heartbeat: emit phase status during silence ───
|
|
516
623
|
|
|
517
624
|
const heartbeat = setInterval(() => {
|
|
@@ -548,7 +655,7 @@ export async function callClaudeStreaming(
|
|
|
548
655
|
proc.kill('SIGTERM')
|
|
549
656
|
if (fullText) {
|
|
550
657
|
// Got partial output — deliver what we have
|
|
551
|
-
finalize(fullText)
|
|
658
|
+
void finalize(fullText)
|
|
552
659
|
} else {
|
|
553
660
|
finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`)
|
|
554
661
|
}
|
|
@@ -558,6 +665,7 @@ export async function callClaudeStreaming(
|
|
|
558
665
|
clearInterval(heartbeat)
|
|
559
666
|
clearTimeout(inactivityTimer)
|
|
560
667
|
clearTimeout(wallTimer)
|
|
668
|
+
options?.abortSignal?.removeEventListener('abort', handleAbort)
|
|
561
669
|
}
|
|
562
670
|
|
|
563
671
|
// ─── Process stdout ───
|
|
@@ -577,7 +685,9 @@ export async function callClaudeStreaming(
|
|
|
577
685
|
try {
|
|
578
686
|
const event = JSON.parse(trimmed)
|
|
579
687
|
|
|
580
|
-
if (event.type === '
|
|
688
|
+
if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
|
|
689
|
+
updateClaudeRun(run.runId, { resolvedModelId: event.model })
|
|
690
|
+
} else if (event.type === 'stream_event') {
|
|
581
691
|
// Real-time token streaming — fires every few tokens during generation
|
|
582
692
|
receivedStreamEvents = true
|
|
583
693
|
const inner = event.event
|
|
@@ -590,6 +700,7 @@ export async function callClaudeStreaming(
|
|
|
590
700
|
callbacks.onToolStatus?.('Reasoning...')
|
|
591
701
|
} else if (inner?.type === 'content_block_start' && inner.content_block?.type === 'tool_use' && inner.content_block.name) {
|
|
592
702
|
const toolName = inner.content_block.name
|
|
703
|
+
if (typeof inner.index === 'number') toolInputs.set(inner.index, { name: toolName, json: '' })
|
|
593
704
|
if (toolName === 'WebSearch' || toolName === 'WebFetch') {
|
|
594
705
|
phase = 'searching'
|
|
595
706
|
}
|
|
@@ -597,6 +708,18 @@ export async function callClaudeStreaming(
|
|
|
597
708
|
if (toolName === 'Read') {
|
|
598
709
|
callbacks.onToolStatus?.('Analyzing photo...')
|
|
599
710
|
}
|
|
711
|
+
} else if (inner?.type === 'content_block_delta' && inner.delta?.type === 'input_json_delta' && typeof inner.index === 'number') {
|
|
712
|
+
const current = toolInputs.get(inner.index)
|
|
713
|
+
if (current && typeof inner.delta.partial_json === 'string') current.json += inner.delta.partial_json
|
|
714
|
+
} else if (inner?.type === 'content_block_stop' && typeof inner.index === 'number') {
|
|
715
|
+
const current = toolInputs.get(inner.index)
|
|
716
|
+
if (current) {
|
|
717
|
+
if (!isRunOutputImagePublisherCommand(current.json)) {
|
|
718
|
+
const preview = claudeToolInputPreview(current.name, current.json)
|
|
719
|
+
if (preview) callbacks.onActivityLine?.(preview)
|
|
720
|
+
}
|
|
721
|
+
toolInputs.delete(inner.index)
|
|
722
|
+
}
|
|
600
723
|
}
|
|
601
724
|
} else if (event.type === 'assistant') {
|
|
602
725
|
// Fallback: only used if CLI doesn't emit stream_events (older CLI compatibility)
|
|
@@ -626,7 +749,14 @@ export async function callClaudeStreaming(
|
|
|
626
749
|
callbacks.onChunk(text)
|
|
627
750
|
}
|
|
628
751
|
}
|
|
752
|
+
} else if (event.type === 'user') {
|
|
753
|
+
for (const preview of claudeToolResultPreviewLines(event)) callbacks.onActivityLine?.(preview)
|
|
629
754
|
} else if (event.type === 'result') {
|
|
755
|
+
const resultError = claudeResultErrorMessage(event)
|
|
756
|
+
if (resultError) {
|
|
757
|
+
finalizeError(resultError)
|
|
758
|
+
continue
|
|
759
|
+
}
|
|
630
760
|
// Capture CLI session ID for future --resume (avoids cold start on next query)
|
|
631
761
|
if (event.session_id) {
|
|
632
762
|
cliSessionMap.set(resolvedCliKey, event.session_id)
|
|
@@ -634,7 +764,7 @@ export async function callClaudeStreaming(
|
|
|
634
764
|
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
635
765
|
}
|
|
636
766
|
// Final result — use accumulated text (more reliable than result.result)
|
|
637
|
-
finalize(fullText || event.result || '')
|
|
767
|
+
void finalize(fullText || event.result || '')
|
|
638
768
|
}
|
|
639
769
|
// tool_use/tool_result/other events still reset inactivity (we got stdout data)
|
|
640
770
|
} catch {
|
|
@@ -655,7 +785,12 @@ export async function callClaudeStreaming(
|
|
|
655
785
|
try {
|
|
656
786
|
const event = JSON.parse(buffer.trim())
|
|
657
787
|
if (event.type === 'result') {
|
|
658
|
-
|
|
788
|
+
const resultError = claudeResultErrorMessage(event)
|
|
789
|
+
if (resultError) {
|
|
790
|
+
finalizeError(resultError, code)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
void finalize(fullText || event.result || '')
|
|
659
794
|
return
|
|
660
795
|
}
|
|
661
796
|
} catch { /* ignore */ }
|
|
@@ -665,19 +800,39 @@ export async function callClaudeStreaming(
|
|
|
665
800
|
finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
|
|
666
801
|
} else if (fullText) {
|
|
667
802
|
// If we got text but no explicit result event, still finalize
|
|
668
|
-
finalize(fullText)
|
|
803
|
+
void finalize(fullText)
|
|
669
804
|
} else {
|
|
670
|
-
|
|
805
|
+
finalizeError('claude-bridge: Claude completed without a response.', code)
|
|
671
806
|
}
|
|
672
807
|
})
|
|
673
808
|
|
|
674
809
|
proc.on('error', (err) => {
|
|
675
810
|
finalizeError(`claude-bridge: ${err.message}`, null)
|
|
676
811
|
})
|
|
812
|
+
proc.stdin.on('error', (err) => {
|
|
813
|
+
finalizeError(`claude-bridge: stdin failed — ${err.message}`, null)
|
|
814
|
+
})
|
|
677
815
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
816
|
+
if (options?.abortSignal) {
|
|
817
|
+
if (options.abortSignal.aborted) {
|
|
818
|
+
handleAbort()
|
|
819
|
+
return sid
|
|
820
|
+
}
|
|
821
|
+
options.abortSignal.addEventListener('abort', handleAbort, { once: true })
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Ultracode is a CLI-only orchestration keyword; history/chat keeps the
|
|
825
|
+
// original user text so the keyword never appears on the lens.
|
|
826
|
+
const cliQuery = resolvedEffort === 'ultracode'
|
|
827
|
+
? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
|
|
828
|
+
: fullQuery
|
|
829
|
+
try {
|
|
830
|
+
proc.stdin.write(cliQuery)
|
|
831
|
+
proc.stdin.end()
|
|
832
|
+
} catch (err) {
|
|
833
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
834
|
+
finalizeError(`claude-bridge: stdin failed — ${message}`, null)
|
|
835
|
+
}
|
|
681
836
|
|
|
682
837
|
return sid
|
|
683
838
|
}
|
|
@@ -33,7 +33,9 @@ export interface ClaudeRunRecord {
|
|
|
33
33
|
updatedAt: string
|
|
34
34
|
model: ClaudeModelPreference
|
|
35
35
|
cliCommand: string
|
|
36
|
-
effortLevel: ClaudeEffortLevel
|
|
36
|
+
effortLevel: ClaudeEffortLevel | 'ultracode'
|
|
37
|
+
cliModelId?: string
|
|
38
|
+
resolvedModelId?: string
|
|
37
39
|
cwd: string
|
|
38
40
|
resumed: boolean
|
|
39
41
|
trustMode: 'full-access'
|
|
@@ -211,6 +213,8 @@ export function startClaudeRun(input: {
|
|
|
211
213
|
timeoutMs: number
|
|
212
214
|
wallMaxMs: number
|
|
213
215
|
query: string
|
|
216
|
+
effortLevel?: ClaudeEffortLevel | 'ultracode'
|
|
217
|
+
cliModelId?: string
|
|
214
218
|
}): ClaudeRunRecord {
|
|
215
219
|
const now = new Date().toISOString()
|
|
216
220
|
const run: ClaudeRunRecord = {
|
|
@@ -222,7 +226,8 @@ export function startClaudeRun(input: {
|
|
|
222
226
|
updatedAt: now,
|
|
223
227
|
model: input.model,
|
|
224
228
|
cliCommand: 'claude -p',
|
|
225
|
-
effortLevel: getClaudeEffortLevel(),
|
|
229
|
+
effortLevel: input.effortLevel ?? getClaudeEffortLevel(),
|
|
230
|
+
cliModelId: input.cliModelId,
|
|
226
231
|
cwd: input.cwd,
|
|
227
232
|
resumed: input.resumed,
|
|
228
233
|
trustMode: 'full-access',
|