Files
claw-code-parity/ts-worker/engine/TelegramWorkerEngine.ts
T
Wylabb f7c23fb325
Build Claw Telegram / build (push) Successful in 5m26s
Build Claw Telegram / cleanup (push) Successful in 0s
Replace Rust worker with TS worker in single image
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>
2026-04-07 23:44:57 +02:00

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()
}
}