@swarmclawai/swarmclaw 0.4.0 → 0.4.5

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.
Files changed (144) hide show
  1. package/README.md +13 -2
  2. package/next.config.ts +8 -0
  3. package/package.json +2 -1
  4. package/src/app/api/agents/[id]/route.ts +20 -21
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  6. package/src/app/api/agents/route.ts +3 -2
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/connectors/[id]/route.ts +10 -3
  9. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  10. package/src/app/api/connectors/route.ts +6 -3
  11. package/src/app/api/credentials/[id]/route.ts +2 -1
  12. package/src/app/api/credentials/route.ts +2 -2
  13. package/src/app/api/documents/route.ts +2 -2
  14. package/src/app/api/files/serve/route.ts +8 -0
  15. package/src/app/api/knowledge/[id]/route.ts +5 -4
  16. package/src/app/api/knowledge/upload/route.ts +2 -2
  17. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  18. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  19. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  20. package/src/app/api/mcp-servers/route.ts +2 -2
  21. package/src/app/api/memory/[id]/route.ts +9 -8
  22. package/src/app/api/memory/route.ts +2 -2
  23. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  24. package/src/app/api/openclaw/directory/route.ts +26 -0
  25. package/src/app/api/openclaw/discover/route.ts +61 -0
  26. package/src/app/api/openclaw/sync/route.ts +30 -0
  27. package/src/app/api/orchestrator/run/route.ts +2 -2
  28. package/src/app/api/projects/[id]/route.ts +55 -0
  29. package/src/app/api/projects/route.ts +27 -0
  30. package/src/app/api/providers/[id]/models/route.ts +2 -1
  31. package/src/app/api/providers/[id]/route.ts +13 -15
  32. package/src/app/api/providers/route.ts +2 -2
  33. package/src/app/api/schedules/[id]/route.ts +16 -18
  34. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  35. package/src/app/api/schedules/route.ts +2 -2
  36. package/src/app/api/secrets/[id]/route.ts +16 -17
  37. package/src/app/api/secrets/route.ts +2 -2
  38. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  39. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  40. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  41. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  42. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  43. package/src/app/api/sessions/[id]/route.ts +2 -1
  44. package/src/app/api/sessions/route.ts +2 -2
  45. package/src/app/api/skills/[id]/route.ts +23 -21
  46. package/src/app/api/skills/import/route.ts +2 -2
  47. package/src/app/api/skills/route.ts +2 -2
  48. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  49. package/src/app/api/tasks/[id]/route.ts +6 -5
  50. package/src/app/api/tasks/route.ts +2 -2
  51. package/src/app/api/tts/stream/route.ts +48 -0
  52. package/src/app/api/upload/route.ts +2 -2
  53. package/src/app/api/uploads/[filename]/route.ts +4 -1
  54. package/src/app/api/webhooks/[id]/route.ts +29 -31
  55. package/src/app/api/webhooks/route.ts +2 -2
  56. package/src/app/page.tsx +3 -24
  57. package/src/cli/index.js +28 -0
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/spec.js +2 -0
  60. package/src/components/agents/agent-list.tsx +3 -1
  61. package/src/components/agents/agent-sheet.tsx +116 -14
  62. package/src/components/chat/chat-area.tsx +27 -4
  63. package/src/components/chat/chat-header.tsx +141 -29
  64. package/src/components/chat/tool-call-bubble.tsx +9 -3
  65. package/src/components/chat/voice-overlay.tsx +80 -0
  66. package/src/components/connectors/connector-list.tsx +6 -2
  67. package/src/components/connectors/connector-sheet.tsx +31 -7
  68. package/src/components/layout/app-layout.tsx +47 -25
  69. package/src/components/projects/project-list.tsx +122 -0
  70. package/src/components/projects/project-sheet.tsx +135 -0
  71. package/src/components/schedules/schedule-list.tsx +3 -1
  72. package/src/components/sessions/new-session-sheet.tsx +6 -6
  73. package/src/components/sessions/session-card.tsx +1 -1
  74. package/src/components/sessions/session-list.tsx +7 -7
  75. package/src/components/shared/connector-platform-icon.tsx +4 -0
  76. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  77. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  78. package/src/components/shared/settings/section-web-search.tsx +56 -0
  79. package/src/components/shared/settings/settings-page.tsx +73 -0
  80. package/src/components/skills/skill-list.tsx +2 -1
  81. package/src/components/tasks/task-list.tsx +5 -2
  82. package/src/hooks/use-continuous-speech.ts +144 -0
  83. package/src/hooks/use-view-router.ts +52 -0
  84. package/src/hooks/use-voice-conversation.ts +80 -0
  85. package/src/lib/id.ts +6 -0
  86. package/src/lib/projects.ts +13 -0
  87. package/src/lib/provider-sets.ts +5 -0
  88. package/src/lib/providers/anthropic.ts +14 -1
  89. package/src/lib/providers/index.ts +6 -0
  90. package/src/lib/providers/ollama.ts +9 -1
  91. package/src/lib/providers/openai.ts +9 -1
  92. package/src/lib/providers/openclaw.ts +11 -0
  93. package/src/lib/server/api-routes.test.ts +5 -6
  94. package/src/lib/server/build-llm.ts +17 -4
  95. package/src/lib/server/chat-execution.ts +38 -4
  96. package/src/lib/server/collection-helpers.ts +54 -0
  97. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  98. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  99. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  100. package/src/lib/server/connectors/googlechat.ts +46 -7
  101. package/src/lib/server/connectors/manager.ts +392 -3
  102. package/src/lib/server/connectors/media.ts +2 -2
  103. package/src/lib/server/connectors/openclaw.ts +64 -0
  104. package/src/lib/server/connectors/pairing.test.ts +99 -0
  105. package/src/lib/server/connectors/pairing.ts +256 -0
  106. package/src/lib/server/connectors/signal.ts +1 -0
  107. package/src/lib/server/connectors/teams.ts +5 -5
  108. package/src/lib/server/connectors/types.ts +10 -0
  109. package/src/lib/server/execution-log.ts +3 -3
  110. package/src/lib/server/heartbeat-service.ts +1 -1
  111. package/src/lib/server/knowledge-db.test.ts +2 -33
  112. package/src/lib/server/main-agent-loop.ts +6 -6
  113. package/src/lib/server/memory-db.ts +6 -6
  114. package/src/lib/server/openclaw-approvals.ts +105 -0
  115. package/src/lib/server/openclaw-sync.ts +496 -0
  116. package/src/lib/server/orchestrator-lg.ts +30 -9
  117. package/src/lib/server/orchestrator.ts +4 -4
  118. package/src/lib/server/process-manager.ts +2 -2
  119. package/src/lib/server/queue.ts +22 -10
  120. package/src/lib/server/scheduler.ts +2 -2
  121. package/src/lib/server/session-mailbox.ts +2 -2
  122. package/src/lib/server/session-run-manager.ts +2 -2
  123. package/src/lib/server/session-tools/connector.ts +51 -4
  124. package/src/lib/server/session-tools/crud.ts +3 -3
  125. package/src/lib/server/session-tools/delegate.ts +3 -3
  126. package/src/lib/server/session-tools/file.ts +176 -3
  127. package/src/lib/server/session-tools/index.ts +2 -0
  128. package/src/lib/server/session-tools/memory.ts +2 -2
  129. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  130. package/src/lib/server/session-tools/sandbox.ts +33 -0
  131. package/src/lib/server/session-tools/search-providers.ts +270 -0
  132. package/src/lib/server/session-tools/session-info.ts +2 -2
  133. package/src/lib/server/session-tools/web.ts +47 -66
  134. package/src/lib/server/storage.ts +12 -0
  135. package/src/lib/server/stream-agent-chat.ts +29 -0
  136. package/src/lib/server/task-result.test.ts +44 -0
  137. package/src/lib/server/task-result.ts +14 -0
  138. package/src/lib/tool-definitions.ts +5 -3
  139. package/src/lib/tts-stream.ts +130 -0
  140. package/src/lib/view-routes.ts +28 -0
  141. package/src/proxy.ts +3 -0
  142. package/src/stores/use-app-store.ts +28 -1
  143. package/src/stores/use-chat-store.ts +9 -1
  144. package/src/types/index.ts +27 -2
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+ import { VIEW_TO_PATH, PATH_TO_VIEW, DEFAULT_VIEW } from '@/lib/view-routes'
6
+
7
+ export function useViewRouter() {
8
+ const fromPopstate = useRef(false)
9
+
10
+ // Mount: read pathname → set active view
11
+ useEffect(() => {
12
+ const view = PATH_TO_VIEW[window.location.pathname]
13
+ if (view) {
14
+ useAppStore.getState().setActiveView(view)
15
+ } else {
16
+ useAppStore.getState().setActiveView(DEFAULT_VIEW)
17
+ window.history.replaceState(null, '', VIEW_TO_PATH[DEFAULT_VIEW])
18
+ }
19
+ }, [])
20
+
21
+ // State→URL: push new path when activeView changes
22
+ useEffect(() => {
23
+ let prev = useAppStore.getState().activeView
24
+ const unsub = useAppStore.subscribe((state) => {
25
+ const next = state.activeView
26
+ if (next === prev) return
27
+ prev = next
28
+ if (fromPopstate.current) {
29
+ fromPopstate.current = false
30
+ return
31
+ }
32
+ const targetPath = VIEW_TO_PATH[next]
33
+ if (targetPath && window.location.pathname !== targetPath) {
34
+ window.history.pushState(null, '', targetPath)
35
+ }
36
+ })
37
+ return unsub
38
+ }, [])
39
+
40
+ // Popstate: browser back/forward → update view
41
+ useEffect(() => {
42
+ const onPopstate = () => {
43
+ const view = PATH_TO_VIEW[window.location.pathname]
44
+ if (view) {
45
+ fromPopstate.current = true
46
+ useAppStore.getState().setActiveView(view)
47
+ }
48
+ }
49
+ window.addEventListener('popstate', onPopstate)
50
+ return () => window.removeEventListener('popstate', onPopstate)
51
+ }, [])
52
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react'
4
+ import { useContinuousSpeech } from './use-continuous-speech'
5
+ import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
6
+ import { useChatStore } from '@/stores/use-chat-store'
7
+
8
+ export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
9
+
10
+ export function useVoiceConversation() {
11
+ const [active, setActive] = useState(false)
12
+ const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
13
+ const accumulatorRef = useRef<SentenceAccumulator | null>(null)
14
+ const queueRef = useRef<AudioChunkQueue | null>(null)
15
+ const sendMessage = useChatStore((s) => s.sendMessage)
16
+
17
+ const speech = useContinuousSpeech({
18
+ onUtterance: useCallback((text: string) => {
19
+ setVoiceState('processing')
20
+ // Send the transcribed text as a chat message
21
+ sendMessage(text)
22
+ }, [sendMessage]),
23
+ })
24
+
25
+ // Called by the chat store's onStreamEvent callback
26
+ const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
27
+ if (!active) return
28
+
29
+ if (event.t === 'd' && event.text) {
30
+ setVoiceState('speaking')
31
+ if (!accumulatorRef.current) {
32
+ const queue = new AudioChunkQueue()
33
+ queueRef.current = queue
34
+ queue.onComplete = () => {
35
+ // Resume listening after TTS playback finishes
36
+ setVoiceState('listening')
37
+ speech.resume()
38
+ }
39
+ accumulatorRef.current = new SentenceAccumulator((sentence) => {
40
+ queue.enqueue(fetchStreamTts(sentence))
41
+ })
42
+ }
43
+ accumulatorRef.current.push(event.text)
44
+ } else if (event.t === 'done') {
45
+ // Flush remaining text to TTS
46
+ if (accumulatorRef.current) {
47
+ accumulatorRef.current.flush()
48
+ accumulatorRef.current = null
49
+ }
50
+ }
51
+ }, [active, speech])
52
+
53
+ const start = useCallback(() => {
54
+ setActive(true)
55
+ setVoiceState('listening')
56
+ // Register the stream event handler on the chat store
57
+ useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
58
+ speech.start()
59
+ }, [speech, handleStreamEvent])
60
+
61
+ const stop = useCallback(() => {
62
+ setActive(false)
63
+ setVoiceState('idle')
64
+ speech.stop()
65
+ queueRef.current?.stop()
66
+ queueRef.current = null
67
+ accumulatorRef.current = null
68
+ useChatStore.setState({ onStreamEvent: null, voiceConversationActive: false })
69
+ }, [speech])
70
+
71
+ return {
72
+ active,
73
+ state: voiceState,
74
+ interimText: speech.interimText,
75
+ transcript: speech.transcript,
76
+ supported: speech.supported,
77
+ start,
78
+ stop,
79
+ }
80
+ }
package/src/lib/id.ts ADDED
@@ -0,0 +1,6 @@
1
+ import crypto from 'crypto'
2
+
3
+ /** Generate a random hex ID. Default 4 bytes = 8 hex chars. */
4
+ export function genId(bytes = 4): string {
5
+ return crypto.randomBytes(bytes).toString('hex')
6
+ }
@@ -0,0 +1,13 @@
1
+ import { api } from './api-client'
2
+ import type { Project } from '../types'
3
+
4
+ export const fetchProjects = () => api<Record<string, Project>>('GET', '/projects')
5
+
6
+ export const createProject = (data: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) =>
7
+ api<Project>('POST', '/projects', data)
8
+
9
+ export const updateProject = (id: string, data: Partial<Project>) =>
10
+ api<Project>('PUT', `/projects/${id}`, data)
11
+
12
+ export const deleteProject = (id: string) =>
13
+ api<string>('DELETE', `/projects/${id}`)
@@ -0,0 +1,5 @@
1
+ /** CLI providers that use their own tool execution — incompatible with LangGraph orchestration. */
2
+ export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
3
+
4
+ /** Providers with native tool/capability support (CLI providers + OpenClaw). */
5
+ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'openclaw'])
@@ -23,10 +23,12 @@ function fileToContentBlocks(filePath: string): any[] {
23
23
  return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
24
24
  }
25
25
 
26
- export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
26
+ export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
27
  return new Promise((resolve) => {
28
28
  const messages = buildMessages(session, message, imagePath, loadHistory)
29
29
  const model = session.model || 'claude-sonnet-4-6'
30
+ let usageInput = 0
31
+ let usageOutput = 0
30
32
 
31
33
  const body: Record<string, unknown> = {
32
34
  model,
@@ -86,11 +88,22 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
86
88
  fullResponse += parsed.delta.text
87
89
  write(`data: ${JSON.stringify({ t: 'd', text: parsed.delta.text })}\n\n`)
88
90
  }
91
+ // message_start carries input token count
92
+ if (parsed.type === 'message_start' && parsed.message?.usage) {
93
+ usageInput = parsed.message.usage.input_tokens || 0
94
+ }
95
+ // message_delta carries output token count
96
+ if (parsed.type === 'message_delta' && parsed.usage) {
97
+ usageOutput = parsed.usage.output_tokens || 0
98
+ }
89
99
  } catch {}
90
100
  }
91
101
  })
92
102
 
93
103
  apiRes.on('end', () => {
104
+ if (onUsage && (usageInput > 0 || usageOutput > 0)) {
105
+ onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
106
+ }
94
107
  active.delete(session.id)
95
108
  resolve(fullResponse)
96
109
  })
@@ -13,6 +13,11 @@ export interface ProviderHandler {
13
13
  streamChat: (opts: StreamChatOptions) => Promise<string>
14
14
  }
15
15
 
16
+ export interface StreamChatUsage {
17
+ inputTokens: number
18
+ outputTokens: number
19
+ }
20
+
16
21
  export interface StreamChatOptions {
17
22
  session: any
18
23
  message: string
@@ -22,6 +27,7 @@ export interface StreamChatOptions {
22
27
  write: (data: string) => void
23
28
  active: Map<string, any>
24
29
  loadHistory: (sessionId: string) => any[]
30
+ onUsage?: (usage: StreamChatUsage) => void
25
31
  }
26
32
 
27
33
  interface BuiltinProviderConfig extends ProviderInfo {
@@ -6,7 +6,7 @@ import type { StreamChatOptions } from './index'
6
6
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
7
7
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
8
8
 
9
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory }: StreamChatOptions): Promise<string> {
9
+ export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
10
10
  return new Promise((resolve) => {
11
11
  const messages = buildMessages(session, message, imagePath, loadHistory)
12
12
  const model = session.model || 'llama3'
@@ -69,6 +69,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
69
69
  fullResponse += content
70
70
  write(`data: ${JSON.stringify({ t: 'd', text: content })}\n\n`)
71
71
  }
72
+ // Final chunk (done: true) carries token counts
73
+ if (parsed.done && onUsage) {
74
+ const input = parsed.prompt_eval_count || 0
75
+ const output = parsed.eval_count || 0
76
+ if (input > 0 || output > 0) {
77
+ onUsage({ inputTokens: input, outputTokens: output })
78
+ }
79
+ }
72
80
  } catch {}
73
81
  }
74
82
  })
@@ -43,7 +43,7 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
43
43
  return [{ type: 'text', text: `[Attached file: ${name}]` }]
44
44
  }
45
45
 
46
- export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
46
+ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
47
47
  return new Promise(async (resolve) => {
48
48
  const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
49
49
  const model = session.model || 'gpt-4o'
@@ -52,6 +52,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
52
52
  model,
53
53
  messages,
54
54
  stream: true,
55
+ stream_options: { include_usage: true },
55
56
  })
56
57
 
57
58
  let fullResponse = ''
@@ -136,6 +137,13 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
136
137
  fullResponse += delta
137
138
  write(`data: ${JSON.stringify({ t: 'd', text: delta })}\n\n`)
138
139
  }
140
+ // Extract usage from the final chunk (stream_options: include_usage)
141
+ if (parsed.usage && onUsage) {
142
+ onUsage({
143
+ inputTokens: parsed.usage.prompt_tokens || 0,
144
+ outputTokens: parsed.usage.completion_tokens || 0,
145
+ })
146
+ }
139
147
  } catch {}
140
148
  }
141
149
  }
@@ -63,6 +63,17 @@ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
63
63
  }
64
64
 
65
65
  function loadOrCreateDeviceIdentity(): DeviceIdentity {
66
+ // 0. Check shared device token for cross-synced identity
67
+ try {
68
+ const { getSharedDeviceToken } = require('../server/openclaw-sync')
69
+ const sharedToken = getSharedDeviceToken()
70
+ if (sharedToken) {
71
+ // Shared token exists — the connector has already paired.
72
+ // Still need the keypair, so continue to identity resolution below.
73
+ // The token will be used during WS connect.
74
+ }
75
+ } catch { /* openclaw-sync not available */ }
76
+
66
77
  // 1. Prefer the openclaw CLI's identity — it's likely already paired with the gateway
67
78
  const cliIdentityPath = path.join(resolveCliStateDir(), 'identity', 'device.json')
68
79
  const cliIdentity = tryLoadIdentityFile(cliIdentityPath)
@@ -346,17 +346,16 @@ describe('MCP Server API contract', () => {
346
346
  assert.match(src, /export\s+async\s+function\s+DELETE/)
347
347
  })
348
348
 
349
- it('MCP POST route assigns an id via crypto.randomBytes', () => {
349
+ it('MCP POST route assigns an id via genId helper', () => {
350
350
  const src = readRoute('mcp-servers', 'route.ts')
351
- assert.match(src, /crypto\.randomBytes/)
351
+ assert.match(src, /const\s+id\s*=\s*genId\(/)
352
352
  })
353
353
 
354
- it('MCP PUT route preserves id and sets updatedAt', () => {
354
+ it('MCP PUT route preserves id and sets updatedAt via mutateItem', () => {
355
355
  const src = readRoute('mcp-servers', '[id]', 'route.ts')
356
+ assert.match(src, /mutateItem\(/)
356
357
  assert.match(src, /updatedAt:\s*Date\.now\(\)/)
357
- // Verify id is pinned (spread then override)
358
- assert.match(src, /\.\.\.servers\[id\]/)
359
- assert.match(src, /\bid\b,/)
358
+ assert.match(src, /\.\.\.server,\s*\.\.\.body,\s*id,/)
360
359
  })
361
360
  })
362
361
  })
@@ -3,10 +3,10 @@ import { ChatOpenAI } from '@langchain/openai'
3
3
  import { loadCredentials, decryptKey, loadAgents, loadSettings } from './storage'
4
4
  import { getProviderList } from '../providers'
5
5
  import { normalizeOpenClawEndpoint } from '../openclaw-endpoint'
6
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
6
7
 
7
8
  const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
8
9
  const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
9
- const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
10
10
 
11
11
  /**
12
12
  * Build a LangChain chat model from provider config.
@@ -18,8 +18,9 @@ export function buildChatModel(opts: {
18
18
  model: string
19
19
  apiKey: string | null
20
20
  apiEndpoint?: string | null
21
+ thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'
21
22
  }) {
22
- const { provider, model, apiKey, apiEndpoint } = opts
23
+ const { provider, model, apiKey, apiEndpoint, thinkingLevel } = opts
23
24
  const providers = getProviderList()
24
25
  const providerInfo = providers.find((p) => p.id === provider)
25
26
  const endpointRaw = apiEndpoint || providerInfo?.defaultEndpoint || null
@@ -28,11 +29,18 @@ export function buildChatModel(opts: {
28
29
  : endpointRaw
29
30
 
30
31
  if (provider === 'anthropic') {
31
- return new ChatAnthropic({
32
+ const anthropicOpts: Record<string, unknown> = {
32
33
  model: model || 'claude-sonnet-4-6',
33
34
  anthropicApiKey: apiKey || undefined,
34
35
  maxTokens: 8192,
35
- })
36
+ }
37
+ if (thinkingLevel) {
38
+ const budgetMap = { minimal: 1024, low: 4096, medium: 8192, high: 16384 }
39
+ anthropicOpts.thinking = { type: 'enabled', budget_tokens: budgetMap[thinkingLevel] }
40
+ // Extended thinking requires higher maxTokens (budget + output)
41
+ anthropicOpts.maxTokens = budgetMap[thinkingLevel] + 8192
42
+ }
43
+ return new ChatAnthropic(anthropicOpts as ConstructorParameters<typeof ChatAnthropic>[0])
36
44
  }
37
45
 
38
46
  if (provider === 'ollama') {
@@ -48,6 +56,11 @@ export function buildChatModel(opts: {
48
56
 
49
57
  // All other providers — OpenAI-compatible with their registered endpoint
50
58
  const config: any = { model: model || 'gpt-4o', apiKey: apiKey || undefined }
59
+ // Map thinking level to reasoning_effort for OpenAI o-series models
60
+ if (thinkingLevel && provider === 'openai' && /^o\d/.test(model || '')) {
61
+ const effortMap = { minimal: 'low', low: 'low', medium: 'medium', high: 'high' }
62
+ config.modelKwargs = { reasoning_effort: effortMap[thinkingLevel] }
63
+ }
51
64
  if (endpoint) {
52
65
  config.configuration = { baseURL: endpoint }
53
66
  // OpenClaw endpoints behind Hostinger's proxy use express.json() middleware
@@ -9,9 +9,11 @@ import {
9
9
  loadSkills,
10
10
  loadSettings,
11
11
  loadUsage,
12
+ appendUsage,
12
13
  active,
13
14
  } from './storage'
14
15
  import { getProvider } from '@/lib/providers'
16
+ import { estimateCost } from './cost'
15
17
  import { log } from './logger'
16
18
  import { logExecution } from './execution-log'
17
19
  import { streamAgentChat } from './stream-agent-chat'
@@ -22,10 +24,9 @@ import { getMemoryDb } from './memory-db'
22
24
  import { routeTaskIntent } from './capability-router'
23
25
  import { notify } from './ws-hub'
24
26
  import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
25
- import type { MessageToolEvent, SSEEvent } from '@/types'
27
+ import type { MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
26
28
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
27
-
28
- const CLI_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli'])
29
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
29
30
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
30
31
 
31
32
  interface SessionWithTools {
@@ -572,8 +573,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
572
573
  kill: () => abortController.abort(),
573
574
  })
574
575
 
576
+ // Capture provider-reported usage for the direct (non-tools) path.
577
+ // Uses a mutable object because TS can't track callback mutations on plain variables.
578
+ const directUsage = { inputTokens: 0, outputTokens: 0, received: false }
579
+ const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
580
+
575
581
  try {
576
- const hasTools = !!sessionForRun.tools?.length && !CLI_PROVIDER_IDS.has(providerType)
577
582
  // Heartbeat runs are self-contained — skip conversation history to avoid
578
583
  // blowing past the context window on long-lived sessions.
579
584
  const heartbeatHistory = isAutoRunNoHistory ? [] : undefined
@@ -601,6 +606,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
601
606
  write: (raw: string) => parseAndEmit(raw),
602
607
  active,
603
608
  loadHistory: isAutoRunNoHistory ? () => [] : getSessionMessages,
609
+ onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
604
610
  })
605
611
  } catch (err: any) {
606
612
  errorMessage = err?.message || String(err)
@@ -622,6 +628,34 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
622
628
  markProviderSuccess(providerType)
623
629
  }
624
630
 
631
+ // Record usage for the direct (non-tools) streamChat path.
632
+ // streamAgentChat already calls appendUsage internally for the tools path.
633
+ if (!hasTools && fullResponse && !errorMessage) {
634
+ const inputTokens = directUsage.received ? directUsage.inputTokens : Math.ceil(message.length / 4)
635
+ const outputTokens = directUsage.received ? directUsage.outputTokens : Math.ceil(fullResponse.length / 4)
636
+ const totalTokens = inputTokens + outputTokens
637
+ if (totalTokens > 0) {
638
+ const cost = estimateCost(sessionForRun.model, inputTokens, outputTokens)
639
+ const history = getSessionMessages(sessionId)
640
+ const usageRecord: UsageRecord = {
641
+ sessionId,
642
+ messageIndex: history.length,
643
+ model: sessionForRun.model,
644
+ provider: providerType,
645
+ inputTokens,
646
+ outputTokens,
647
+ totalTokens,
648
+ estimatedCost: cost,
649
+ timestamp: Date.now(),
650
+ }
651
+ appendUsage(sessionId, usageRecord)
652
+ emit({
653
+ t: 'md',
654
+ text: JSON.stringify({ usage: { inputTokens, outputTokens, totalTokens, estimatedCost: cost } }),
655
+ })
656
+ }
657
+ }
658
+
625
659
  const requestedToolNames = (!internal && source === 'chat')
626
660
  ? requestedToolNamesFromMessage(message)
627
661
  : []
@@ -0,0 +1,54 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notify } from './ws-hub'
3
+
4
+ export interface CollectionOps<T> {
5
+ load: () => Record<string, T>
6
+ save: (data: Record<string, T>) => void
7
+ deleteFn?: (id: string) => void
8
+ topic?: string
9
+ }
10
+
11
+ /**
12
+ * Load → 404 check → mutate → save → notify.
13
+ * `fn` receives the item and the full collection, returns the updated item.
14
+ */
15
+ export function mutateItem<T>(
16
+ ops: CollectionOps<T>,
17
+ id: string,
18
+ fn: (item: T, all: Record<string, T>) => T,
19
+ ): T | null {
20
+ const all = ops.load()
21
+ if (!all[id]) return null
22
+ all[id] = fn(all[id], all)
23
+ ops.save(all)
24
+ if (ops.topic) notify(ops.topic)
25
+ return all[id]
26
+ }
27
+
28
+ /**
29
+ * Load → 404 check → delete → notify.
30
+ * Uses `ops.deleteFn` if provided, otherwise inline `delete` + `save`.
31
+ */
32
+ export function deleteItem<T>(
33
+ ops: CollectionOps<T>,
34
+ id: string,
35
+ ): boolean {
36
+ const all = ops.load()
37
+ if (!all[id]) return false
38
+ if (ops.deleteFn) {
39
+ ops.deleteFn(id)
40
+ } else {
41
+ delete all[id]
42
+ ops.save(all)
43
+ }
44
+ if (ops.topic) notify(ops.topic)
45
+ return true
46
+ }
47
+
48
+ export function notFound(msg = 'Not found') {
49
+ return NextResponse.json({ error: msg }, { status: 404 })
50
+ }
51
+
52
+ export function badRequest(msg: string) {
53
+ return NextResponse.json({ error: msg }, { status: 400 })
54
+ }