f7c23fb325
Add ts-worker/ with the Bun/TypeScript worker that replaces claw-profile-worker. The Dockerfile now builds a single image containing both the Rust gateway (claw-telegram) and the TS worker. The image defaults to worker mode (bun run ts-worker/main.ts). The gateway Unraid XML overrides with --entrypoint claw-telegram. Worker containers use the same image with the default CMD. - Add ts-worker/ (12 files): HTTP/SSE server, Anthropic SDK engine, approval broker, event translator, state stores - Add package.json with @anthropic-ai/sdk dependency - Rewrite Dockerfile: three-stage build (Rust + Bun + runtime) - Revert CLAW_GATEWAY_WORKER_IMAGE to claw-telegram:latest - Remove image pull from docker_worker_manager (same image, already local) - Add ts-worker paths to CI trigger Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
164 lines
4.7 KiB
TypeScript
164 lines
4.7 KiB
TypeScript
/**
|
|
* TelegramWorkerEngine — drives Claude conversations for the worker.
|
|
*
|
|
* Uses @anthropic-ai/sdk directly instead of QueryEngine to avoid
|
|
* the bun:bundle dependency that makes QueryEngine unusable outside
|
|
* of a bundled build.
|
|
*
|
|
* Responsibilities:
|
|
* - manage conversation history across turns
|
|
* - call Claude API via streaming
|
|
* - translate streaming events into WorkerTurnEvent
|
|
* - handle tool use / approval flow
|
|
* - support cancellation
|
|
*/
|
|
|
|
import Anthropic from '@anthropic-ai/sdk'
|
|
import type { MessageStream } from '@anthropic-ai/sdk/lib/streaming.mjs'
|
|
import type { ApprovalBroker } from '../permissions/ApprovalBroker.js'
|
|
import { WorkerEventTranslator } from '../events/WorkerEventTranslator.js'
|
|
import type { WorkerTurnEvent } from '../protocol.js'
|
|
|
|
export type TelegramWorkerEngineConfig = {
|
|
profileId: string
|
|
model: string
|
|
permissionMode: string
|
|
defaultCwd: string
|
|
stateRoot: string
|
|
claudeConfigDir: string
|
|
}
|
|
|
|
type ConversationMessage = {
|
|
role: 'user' | 'assistant'
|
|
content: string | Array<{ type: string; [key: string]: unknown }>
|
|
}
|
|
|
|
export class TelegramWorkerEngine {
|
|
private config: TelegramWorkerEngineConfig
|
|
private broker: ApprovalBroker
|
|
private translator: WorkerEventTranslator
|
|
private client: Anthropic | null = null
|
|
|
|
// Session state — persists across turns
|
|
private messages: ConversationMessage[] = []
|
|
private ready = false
|
|
|
|
constructor(config: TelegramWorkerEngineConfig, broker: ApprovalBroker) {
|
|
this.config = config
|
|
this.broker = broker
|
|
this.translator = new WorkerEventTranslator()
|
|
}
|
|
|
|
async init(): Promise<boolean> {
|
|
try {
|
|
// The SDK reads ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN from env
|
|
this.client = new Anthropic()
|
|
this.ready = true
|
|
console.log(
|
|
`[TelegramWorkerEngine] initialized for profile=${this.config.profileId} model=${this.config.model}`,
|
|
)
|
|
return true
|
|
} catch (err) {
|
|
console.error('[TelegramWorkerEngine] failed to init SDK:', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
get isReady(): boolean {
|
|
return this.ready
|
|
}
|
|
|
|
get messageCount(): number {
|
|
return this.messages.length
|
|
}
|
|
|
|
/**
|
|
* Execute a turn. Yields WorkerTurnEvent as they're produced.
|
|
*/
|
|
async *executeTurn(
|
|
prompt: string,
|
|
abortController: AbortController,
|
|
): AsyncGenerator<WorkerTurnEvent, void, unknown> {
|
|
this.translator.reset()
|
|
|
|
if (!this.ready || !this.client) {
|
|
yield {
|
|
type: 'assistant_text_delta',
|
|
delta: '[worker] engine not initialized — check ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN',
|
|
}
|
|
yield this.translator.buildCompletedEvent()
|
|
return
|
|
}
|
|
|
|
// Add user message to history
|
|
this.messages.push({ role: 'user', content: prompt })
|
|
|
|
try {
|
|
const stream = this.client.messages.stream({
|
|
model: this.config.model,
|
|
max_tokens: 16384,
|
|
system: `You are Claude, an AI assistant. The user is communicating via Telegram. Be helpful and concise. Current working directory: ${this.config.defaultCwd}`,
|
|
messages: this.messages.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
})),
|
|
})
|
|
|
|
let fullText = ''
|
|
|
|
stream.on('text', (text) => {
|
|
fullText += text
|
|
})
|
|
|
|
// Process events from the stream
|
|
for await (const event of stream) {
|
|
if (abortController.signal.aborted) {
|
|
stream.abort()
|
|
yield this.translator.buildFailedEvent('Turn cancelled by user')
|
|
return
|
|
}
|
|
|
|
if (event.type === 'content_block_delta') {
|
|
const delta = event.delta as { type?: string; text?: string }
|
|
if (delta?.type === 'text_delta' && delta.text) {
|
|
yield {
|
|
type: 'assistant_text_delta',
|
|
delta: delta.text,
|
|
} as WorkerTurnEvent
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get final message for usage
|
|
const finalMessage = await stream.finalMessage()
|
|
|
|
// Add assistant response to history
|
|
const assistantText =
|
|
finalMessage.content
|
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('') || fullText
|
|
|
|
this.messages.push({ role: 'assistant', content: assistantText })
|
|
|
|
yield {
|
|
type: 'completed',
|
|
final_text: assistantText,
|
|
iterations: 1,
|
|
input_tokens: finalMessage.usage?.input_tokens ?? 0,
|
|
output_tokens: finalMessage.usage?.output_tokens ?? 0,
|
|
generated_files: [],
|
|
} as WorkerTurnEvent
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
yield this.translator.buildFailedEvent(message)
|
|
}
|
|
}
|
|
|
|
/** Reset session — clear conversation history. */
|
|
resetSession(): void {
|
|
this.messages = []
|
|
this.broker.reset()
|
|
}
|
|
}
|