@hybridaione/hybridclaw 0.1.22 → 0.2.1

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 (137) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +48 -2
  3. package/config.example.json +10 -2
  4. package/container/package-lock.json +2 -2
  5. package/container/package.json +1 -1
  6. package/container/src/hybridai-client.ts +270 -8
  7. package/container/src/index.ts +179 -7
  8. package/container/src/token-usage.ts +89 -0
  9. package/container/src/tools.ts +192 -0
  10. package/container/src/types.ts +21 -0
  11. package/dist/agent.d.ts +1 -1
  12. package/dist/agent.d.ts.map +1 -1
  13. package/dist/agent.js +2 -2
  14. package/dist/agent.js.map +1 -1
  15. package/dist/chunk.d.ts +6 -0
  16. package/dist/chunk.d.ts.map +1 -0
  17. package/dist/chunk.js +129 -0
  18. package/dist/chunk.js.map +1 -0
  19. package/dist/cli.js +15 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/config.d.ts +6 -0
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +21 -2
  24. package/dist/config.js.map +1 -1
  25. package/dist/container-runner.d.ts +2 -1
  26. package/dist/container-runner.d.ts.map +1 -1
  27. package/dist/container-runner.js +42 -3
  28. package/dist/container-runner.js.map +1 -1
  29. package/dist/conversation.d.ts +5 -0
  30. package/dist/conversation.d.ts.map +1 -1
  31. package/dist/conversation.js +15 -4
  32. package/dist/conversation.js.map +1 -1
  33. package/dist/discord-stream.d.ts +32 -0
  34. package/dist/discord-stream.d.ts.map +1 -0
  35. package/dist/discord-stream.js +196 -0
  36. package/dist/discord-stream.js.map +1 -0
  37. package/dist/discord.d.ts +30 -2
  38. package/dist/discord.d.ts.map +1 -1
  39. package/dist/discord.js +1395 -41
  40. package/dist/discord.js.map +1 -1
  41. package/dist/gateway-client.d.ts.map +1 -1
  42. package/dist/gateway-client.js +5 -0
  43. package/dist/gateway-client.js.map +1 -1
  44. package/dist/gateway-service.d.ts +1 -0
  45. package/dist/gateway-service.d.ts.map +1 -1
  46. package/dist/gateway-service.js +444 -62
  47. package/dist/gateway-service.js.map +1 -1
  48. package/dist/gateway-types.d.ts +7 -1
  49. package/dist/gateway-types.d.ts.map +1 -1
  50. package/dist/gateway-types.js.map +1 -1
  51. package/dist/gateway.js +57 -5
  52. package/dist/gateway.js.map +1 -1
  53. package/dist/git-commit.d.ts +2 -0
  54. package/dist/git-commit.d.ts.map +1 -0
  55. package/dist/git-commit.js +63 -0
  56. package/dist/git-commit.js.map +1 -0
  57. package/dist/health.d.ts.map +1 -1
  58. package/dist/health.js +44 -4
  59. package/dist/health.js.map +1 -1
  60. package/dist/heartbeat.d.ts.map +1 -1
  61. package/dist/heartbeat.js +21 -0
  62. package/dist/heartbeat.js.map +1 -1
  63. package/dist/observability-ingest.d.ts.map +1 -1
  64. package/dist/observability-ingest.js +26 -0
  65. package/dist/observability-ingest.js.map +1 -1
  66. package/dist/onboarding.d.ts.map +1 -1
  67. package/dist/onboarding.js +1 -2
  68. package/dist/onboarding.js.map +1 -1
  69. package/dist/prompt-hooks.d.ts +11 -1
  70. package/dist/prompt-hooks.d.ts.map +1 -1
  71. package/dist/prompt-hooks.js +51 -0
  72. package/dist/prompt-hooks.js.map +1 -1
  73. package/dist/runtime-config.d.ts +9 -1
  74. package/dist/runtime-config.d.ts.map +1 -1
  75. package/dist/runtime-config.js +20 -214
  76. package/dist/runtime-config.js.map +1 -1
  77. package/dist/scheduled-task-runner.d.ts.map +1 -1
  78. package/dist/scheduled-task-runner.js +20 -0
  79. package/dist/scheduled-task-runner.js.map +1 -1
  80. package/dist/session-maintenance.d.ts.map +1 -1
  81. package/dist/session-maintenance.js +1 -0
  82. package/dist/session-maintenance.js.map +1 -1
  83. package/dist/token-efficiency.d.ts +41 -0
  84. package/dist/token-efficiency.d.ts.map +1 -0
  85. package/dist/token-efficiency.js +164 -0
  86. package/dist/token-efficiency.js.map +1 -0
  87. package/dist/tui.js +9 -0
  88. package/dist/tui.js.map +1 -1
  89. package/dist/types.d.ts +13 -0
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/update.d.ts +3 -0
  92. package/dist/update.d.ts.map +1 -0
  93. package/dist/update.js +331 -0
  94. package/dist/update.js.map +1 -0
  95. package/dist/workspace.d.ts.map +1 -1
  96. package/dist/workspace.js +2 -1
  97. package/dist/workspace.js.map +1 -1
  98. package/docs/index.html +51 -5
  99. package/package.json +1 -1
  100. package/skills/personality/SKILL.md +108 -0
  101. package/skills/skill-creator/SKILL.md +232 -0
  102. package/skills/skill-creator/agents/openai.yaml +4 -0
  103. package/skills/skill-creator/license.txt +202 -0
  104. package/skills/skill-creator/references/openai_yaml.md +40 -0
  105. package/skills/skill-creator/references/output-patterns.md +119 -0
  106. package/skills/skill-creator/references/workflows.md +99 -0
  107. package/skills/skill-creator/scripts/generate_openai_yaml.py +271 -0
  108. package/skills/skill-creator/scripts/init_skill.py +238 -0
  109. package/skills/skill-creator/scripts/package_skill.py +161 -0
  110. package/skills/skill-creator/scripts/quick_validate.py +291 -0
  111. package/skills/skill-creator/scripts/test_package_skill.py +80 -0
  112. package/src/agent.ts +15 -1
  113. package/src/chunk.ts +153 -0
  114. package/src/cli.ts +15 -1
  115. package/src/config.ts +22 -2
  116. package/src/container-runner.ts +44 -1
  117. package/src/conversation.ts +35 -4
  118. package/src/discord-stream.ts +240 -0
  119. package/src/discord.ts +1609 -39
  120. package/src/gateway-client.ts +7 -0
  121. package/src/gateway-service.ts +490 -61
  122. package/src/gateway-types.ts +12 -1
  123. package/src/gateway.ts +71 -4
  124. package/src/health.ts +49 -4
  125. package/src/heartbeat.ts +21 -0
  126. package/src/observability-ingest.ts +24 -0
  127. package/src/onboarding.ts +1 -2
  128. package/src/prompt-hooks.ts +64 -1
  129. package/src/runtime-config.ts +44 -194
  130. package/src/scheduled-task-runner.ts +20 -0
  131. package/src/session-maintenance.ts +1 -0
  132. package/src/token-efficiency.ts +228 -0
  133. package/src/tui.ts +8 -0
  134. package/src/types.ts +14 -0
  135. package/src/update.ts +389 -0
  136. package/src/workspace.ts +2 -2
  137. package/templates/AGENTS.md +19 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,85 @@
8
8
 
9
9
  ### Fixed
10
10
 
11
+ ## [0.2.1](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.1)
12
+
13
+ ### Added
14
+
15
+ - **Discord `message` tool actions**: Added OpenClaw-style `message` tool support in the container with `read`, `member-info`, and `channel-info` actions, routed via the gateway API.
16
+ - **Gateway Discord action endpoint**: Added `POST /api/discord/action` to execute Discord context actions for tools and automated runs.
17
+
18
+ ### Changed
19
+
20
+ - **Discord presence handling**: Switched from prompt-injected presence snapshots to cache-backed presence data returned by `member-info` (`status` + `activities`) when available.
21
+ - **Discord context guidance**: Updated safety prompt policy to explicitly route recap/member lookup questions through `message` tool actions instead of guessing.
22
+ - **Tool allowlists**: Enabled `message` in heartbeat and base subagent allowed tool sets for delegated and automated workflows.
23
+ - **Container gateway auth context**: Container input now carries gateway base URL/token and maps loopback hosts to `host.docker.internal` for in-container API reachability.
24
+ - **Gateway token fallback**: Runtime now generates an internal gateway API token when no explicit token is configured, while preserving env/config overrides.
25
+
26
+ ### Fixed
27
+
28
+
29
+ ## [0.2.0](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.0)
30
+
31
+ ### Added
32
+
33
+ - **Personality switcher skill**: Added `skills/personality/SKILL.md` with `/personality` command workflow (`list`, `set`, `reset`) and a 25-profile persona set (including expert, style, and role personas like `pirate`, `noir`, `german`, `coach`, `doctor`, `soldier`, and `lawyer`).
34
+ - **Ralph loop runtime mode**: Added configurable autonomous iteration (`proactive.ralph.maxIterations`) in the container tool loop. When enabled, turns continue automatically until the model emits `<choice>STOP</choice>` (or the configured loop budget is reached).
35
+ - **Ralph command controls**: Added gateway/TUI command support for `ralph on|off|set <n>|info`, with immediate current-session container restart to apply loop settings without waiting for idle recycle.
36
+ - **Skill creator authoring toolkit**: Added bundled `skills/skill-creator/` (invocable skill, references, and helper scripts) for initializing, validating, packaging, and generating `agents/openai.yaml` metadata for new skills.
37
+ - **Discord context enrichment pipeline**: Added pending guild-history context, participant alias memory, `@name` mention-to-ID rewrite support, and optional per-channel presence snapshots for better grounded Discord replies.
38
+
39
+ ### Changed
40
+
41
+ - **Personality persistence contract**: Standardized the managed `SOUL.md` personality block to `Name`, `Definition`, and `Rules`, so active persona behavior is fully file-driven.
42
+ - **Personality style policy**: Updated persona rules so style signals are explicitly visible for the active personality (instead of only a subset).
43
+ - **Personality skill prompt mode**: Set personality switching to command-only behavior (`always: false`, `disable-model-invocation: true`) to avoid per-turn prompt overhead while keeping `/personality` invocations available.
44
+ - **Workspace AGENTS template behavior**: Updated `templates/AGENTS.md` group-chat guidance with explicit "Quality > quantity" speaking rules and emoji-reaction social-signal policy (`React Like a Human`, one reaction per message).
45
+ - **Runtime self-awareness hook**: Prompt assembly now always injects runtime metadata (`version`, UTC date, model/default model, chatbot/channel/guild IDs, node/OS/host/workspace) and keeps it active in `minimal` mode.
46
+ - **Discord runtime controls**: Added and hot-wired `discord.{guildMembersIntent,presenceIntent,respondToAllMessages,commandsOnly,commandUserId}` config behavior for intent selection, trigger policy, and command-user authorization.
47
+ - **Gateway status reporting**: `status` command output now includes the running HybridClaw version line.
48
+
49
+ ### Fixed
50
+
51
+ ## [0.1.24](https://github.com/HybridAIOne/hybridclaw/tree/v0.1.24)
52
+
53
+ ### Added
54
+
55
+ - **Discord edit-in-place streaming pipeline**: Added end-to-end assistant text delta streaming from container runtime to Discord delivery, including NDJSON `text` events and incremental Discord message edits.
56
+ - **Discord stream/chunk primitives**: Added `src/discord-stream.ts` (stream lifecycle manager with throttled edits and rollover) and `src/chunk.ts` (boundary-aware chunking with code-fence preservation and line limits).
57
+ - **Discord conversational event handling**: Added message debounce batching, in-flight run tracking, message edit/delete interruption handling, and thumbs-down reaction feedback capture for subsequent context.
58
+
59
+ ### Changed
60
+
61
+ - **Discord reply delivery semantics**: Replaced fixed 2000-char truncation with complete multi-message delivery and chunk-safe send/edit behavior.
62
+ - **Discord responsiveness model**: Message handling now keeps typing indicators alive during long turns, updates presence while processing, and acknowledges queued work with processing reactions.
63
+ - **Discord context assembly**: Conversation turns now prepend reply-chain/thread context and include parsed attachment context (inline text/code where readable, metadata fallback for unsupported types).
64
+
65
+ ### Fixed
66
+
67
+ - **Long response truncation**: Removed `.slice(0, 2000)` response truncation paths that dropped tail content and broke code blocks.
68
+ - **Perceived Discord stalls**: Fixed single-shot typing behavior by introducing a periodic typing loop for long-running turns.
69
+ - **Mid-turn user correction handling**: Edited/deleted source messages now cancel in-flight processing and clean up partial streamed output to prevent orphaned replies.
70
+ - **Screenshot reply verbosity in Discord**: Image-attachment responses now suppress workspace-path narration and default to concise delivery text (`Here it is.`/`Here they are.`).
71
+
72
+ ## [0.1.23](https://github.com/HybridAIOne/hybridclaw/tree/v0.1.23)
73
+
74
+ ### Added
75
+
76
+ - **Token usage observability fields**: `model.usage` audit events now include prompt/completion/total token counts (API-reported when available, deterministic estimates as fallback), model-call counts, and char-level prompt/completion sizing.
77
+ - **Context optimization telemetry**: Added `context.optimization` audit events with history compression statistics (per-message truncation count, dropped chars/messages, and applied history budgets).
78
+
79
+ ### Changed
80
+
81
+ - **Runtime-config migration logging clarity**: Startup schema normalization now logs a dedicated `normalized config schema vN` message when version is unchanged, instead of reporting a misleading `migrated ... from vN to vN`.
82
+ - **History prompt assembly**: Conversation history now applies per-message truncation plus head/tail-aware budget compression to reduce token load while preserving recent context.
83
+ - **Bootstrap file truncation strategy**: Oversized workspace context files now use head/tail truncation (70/20 split) instead of head-only clipping.
84
+ - **Prompt mode tiers**: Prompt hooks now support `full`/`minimal`/`none` modes; pre-compaction memory flush uses `minimal` mode to reduce static prompt overhead.
85
+
86
+ ### Fixed
87
+
88
+ - **Local runtime-state git noise**: Added `.hybridclaw/` to `.gitignore` so container image fingerprint state files are no longer reported as untracked changes.
89
+
11
90
  ## [0.1.22](https://github.com/HybridAIOne/hybridclaw/tree/v0.1.22)
12
91
 
13
92
  ### Added
package/README.md CHANGED
@@ -11,6 +11,16 @@ npm install -g @hybridaione/hybridclaw
11
11
  hybridclaw onboarding
12
12
  ```
13
13
 
14
+ Latest release: [v0.2.1](https://github.com/HybridAIOne/hybridclaw/releases/tag/v0.2.1)
15
+
16
+ ## What's new in v0.2.1
17
+
18
+ - Added OpenClaw-style Discord `message` tool actions (`read`, `member-info`, `channel-info`) to the container runtime
19
+ - Added gateway endpoint `POST /api/discord/action` for Discord context lookups from tools
20
+ - Replaced prompt-time Discord presence snapshots with cache-backed `member-info` presence fields (`status`, `activities`)
21
+ - Routed Discord context lookups through gateway API from container with host remapping and token propagation
22
+ - Enabled `message` tool in heartbeat and base subagent allowlists
23
+
14
24
  ## HybridAI Advantage
15
25
 
16
26
  - Security-focused foundation
@@ -75,9 +85,13 @@ HybridClaw best-in-class capabilities:
75
85
  - explicit trust-model acceptance during onboarding (recorded in `config.json`)
76
86
  - typed `config.json` runtime settings with defaults, validation, and hot reload
77
87
  - formal prompt hook orchestration (`bootstrap`, `memory`, `safety`)
88
+ - Discord conversational UX: edit-in-place streaming responses, fence-safe chunking beyond Discord's 2000-char limit, typing keepalive, debounce batching, reply-chain-aware context, and concise attachment-first screenshot replies
89
+ - token-efficient context assembly: per-message history truncation, hard history budgets with head/tail preservation, and head/tail truncation for oversized bootstrap files
90
+ - runtime self-awareness in prompts: exact HybridClaw version/date, model, and runtime host metadata injected each turn for reliable "what version/model are you?" answers
78
91
  - proactive runtime layer with active-hours gating, push delegation (`single`/`parallel`/`chain`), depth-aware tool policy, and retry controls
79
92
  - structured audit trail: append-only hash-chained wire logs (`data/audit/<session>/wire.jsonl`) with tamper-evident immutability, normalized SQLite audit tables, and verification/search CLI commands
80
93
  - observability export: incremental `events:batch` forwarding with durable cursor tracking and bot-scoped ingest token lifecycle via `ingest-token:ensure`
94
+ - model token telemetry in audit/observability events (`model.usage`) with API usage + deterministic fallback estimates
81
95
  - gateway lifecycle controls: managed + unmanaged restart/stop flows with graceful shutdown fallback paths
82
96
  - instruction-integrity approval flow: core instruction docs (`AGENTS.md`, `SECURITY.md`, `TRUST_MODEL.md`) are hash-verified against a local approved baseline before TUI start
83
97
 
@@ -87,11 +101,18 @@ HybridClaw uses typed runtime config in `config.json` (auto-created on first run
87
101
 
88
102
  - Start from `config.example.json` (reference)
89
103
  - Runtime watches `config.json` and hot-reloads most settings (model defaults, heartbeat, prompt hooks, limits, etc.)
104
+ - `discord.guildMembersIntent` enables richer guild member context and better `@name` mention resolution in replies (requires enabling **Server Members Intent** in Discord Developer Portal)
105
+ - `discord.presenceIntent` enables Discord presence events (requires enabling **Presence Intent** in Discord Developer Portal)
106
+ - `discord.respondToAllMessages` changes guild trigger behavior: `false` (default) replies only on mention/`!claw`; `true` replies to every user message in the channel
107
+ - `discord.commandUserId` restricts `!claw <command>` admin commands to a single Discord user ID (all other messages still use normal chat handling)
108
+ - `discord.commandsOnly` optional hard mode: if `true`, the bot ignores non-`!claw` messages and only accepts prefixed commands (optionally limited by `discord.commandUserId`)
90
109
  - `skills.extraDirs` adds additional enterprise/shared skill roots (lowest precedence tier)
91
- - `proactive.*` controls autonomous behavior (`activeHours`, `delegation`, `autoRetry`)
110
+ - `proactive.*` controls autonomous behavior (`activeHours`, `delegation`, `autoRetry`, `ralph`)
111
+ - `proactive.ralph.maxIterations` enables Ralph loop (`0` off, `-1` unlimited, `>0` extra autonomous iterations before forcing completion)
112
+ - TUI/Gateway command: `ralph on|off|set <n>|info` (`0` off, `-1` unlimited, `1-64` extra iterations)
92
113
  - `observability.*` controls push ingest into HybridAI (`events:batch` endpoint, batching, identity metadata)
93
114
  - Some settings require restart to fully apply (for example HTTP bind host/port)
94
- - Default bot is configured via `hybridai.defaultChatbotId` in `config.json` (legacy `HYBRIDAI_CHATBOT_ID` values are auto-migrated on startup)
115
+ - Default bot is configured via `hybridai.defaultChatbotId` in `config.json`
95
116
 
96
117
  Secrets remain in `.env`:
97
118
 
@@ -139,6 +160,7 @@ HybridClaw can forward structured audit records to HybridAI's ingest API:
139
160
  - transport: bearer ingest token auto-fetched via `POST /api/v1/agent-observability/ingest-token:ensure` using `HYBRIDAI_API_KEY`
140
161
  - delivery: incremental batches with persisted cursor (`observability_offsets` table), max 1000 events and max 2,000,000-byte payload per request
141
162
  - token handling: token cache is stored locally in SQLite (`observability_ingest_tokens`) and automatically refreshed on ingest auth failures
163
+ - token visibility: `model.usage` payloads include `promptTokens`, `completionTokens`, `totalTokens`, plus estimated and API-native counters for accuracy/coverage
142
164
 
143
165
  Config keys (in `config.json`):
144
166
 
@@ -252,6 +274,29 @@ Explicit invocation is supported via:
252
274
  Example skill in this repo:
253
275
 
254
276
  - `skills/repo-orientation/SKILL.md`
277
+ - `skills/current-time/SKILL.md`
278
+ - `skills/personality/SKILL.md`
279
+ - `skills/skill-creator/SKILL.md`
280
+
281
+ ### Personality switching skill
282
+
283
+ HybridClaw includes a command-only personality skill that updates the active persona contract in `SOUL.md`.
284
+
285
+ - List current/available persona: `/personality` (or `/personality list`)
286
+ - Activate persona: `/personality <name>`
287
+ - Reset to default persona: `/personality reset`
288
+
289
+ The skill writes/updates a managed block in `SOUL.md`:
290
+
291
+ - `## Active personality`
292
+ - `Name: ...`
293
+ - `Definition: ...` (copied from the selected profile in `skills/personality/SKILL.md`)
294
+ - `Rules: ...` (runtime style/behavior constraints)
295
+
296
+ Notes:
297
+
298
+ - The personality skill is intentionally command-only (`always: false`, `disable-model-invocation: true`) to avoid adding per-turn prompt overhead.
299
+ - Profiles are defined in `skills/personality/SKILL.md` and currently include 25 switchable personas (expert, style, and role personas).
255
300
 
256
301
  ## Agent tools
257
302
 
@@ -315,6 +360,7 @@ CLI runtime commands:
315
360
  - `hybridclaw gateway <command...>` — Send a command to a running gateway (for example `sessions`, `bot info`)
316
361
  - `hybridclaw tui` — Start terminal client connected to gateway
317
362
  - `hybridclaw onboarding` — Run HybridAI account/API key onboarding
363
+ - `hybridclaw update [status|--check] [--yes]` — Check for updates and upgrade global npm installs (source checkouts get git-based update instructions)
318
364
  - `hybridclaw audit ...` — Verify and inspect structured audit trail (`recent`, `search`, `approvals`, `verify`, `instructions`)
319
365
 
320
366
  In Discord, use `!claw help` to see all commands. Key ones:
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 2,
2
+ "version": 3,
3
3
  "security": {
4
4
  "trustModelAccepted": false,
5
5
  "trustModelAcceptedAt": "",
@@ -10,7 +10,12 @@
10
10
  "extraDirs": []
11
11
  },
12
12
  "discord": {
13
- "prefix": "!claw"
13
+ "prefix": "!claw",
14
+ "guildMembersIntent": false,
15
+ "presenceIntent": false,
16
+ "respondToAllMessages": false,
17
+ "commandsOnly": false,
18
+ "commandUserId": ""
14
19
  },
15
20
  "hybridai": {
16
21
  "baseUrl": "https://hybridai.one",
@@ -94,6 +99,9 @@
94
99
  "maxAttempts": 3,
95
100
  "baseDelayMs": 2000,
96
101
  "maxDelayMs": 8000
102
+ },
103
+ "ralph": {
104
+ "maxIterations": 0
97
105
  }
98
106
  }
99
107
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.1.22",
3
+ "version": "0.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "hybridclaw-agent",
9
- "version": "0.1.22",
9
+ "version": "0.2.1",
10
10
  "dependencies": {
11
11
  "@mozilla/readability": "^0.6.0",
12
12
  "agent-browser": "^0.15.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.1.22",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -1,4 +1,4 @@
1
- import type { ChatCompletionResponse, ChatMessage, ToolDefinition } from './types.js';
1
+ import type { ChatCompletionResponse, ChatMessage, ToolCall, ToolDefinition } from './types.js';
2
2
 
3
3
  export class HybridAIRequestError extends Error {
4
4
  status: number;
@@ -12,18 +12,45 @@ export class HybridAIRequestError extends Error {
12
12
  }
13
13
  }
14
14
 
15
- export async function callHybridAI(
16
- baseUrl: string,
17
- apiKey: string,
15
+ interface StreamToolCallDelta {
16
+ index?: number;
17
+ id?: string;
18
+ type?: 'function';
19
+ function?: {
20
+ name?: string;
21
+ arguments?: string;
22
+ };
23
+ }
24
+
25
+ interface StreamChoiceChunk {
26
+ delta?: {
27
+ role?: string;
28
+ content?: string | null;
29
+ tool_calls?: StreamToolCallDelta[];
30
+ };
31
+ message?: {
32
+ role?: string;
33
+ content?: string | null;
34
+ tool_calls?: ToolCall[];
35
+ };
36
+ finish_reason?: string | null;
37
+ }
38
+
39
+ interface StreamChunkPayload {
40
+ id?: string;
41
+ model?: string;
42
+ usage?: ChatCompletionResponse['usage'];
43
+ choices?: StreamChoiceChunk[];
44
+ }
45
+
46
+ function buildRequestBody(
18
47
  model: string,
19
48
  chatbotId: string,
20
49
  enableRag: boolean,
21
50
  messages: ChatMessage[],
22
51
  tools: ToolDefinition[],
23
- ): Promise<ChatCompletionResponse> {
24
- const url = `${baseUrl}/v1/chat/completions`;
25
-
26
- const body: Record<string, unknown> = {
52
+ ): Record<string, unknown> {
53
+ return {
27
54
  model,
28
55
  chatbot_id: chatbotId,
29
56
  messages,
@@ -31,6 +58,65 @@ export async function callHybridAI(
31
58
  tool_choice: 'auto',
32
59
  enable_rag: enableRag,
33
60
  };
61
+ }
62
+
63
+ function parseStreamPayloadLine(rawLine: string): string | null {
64
+ const trimmed = rawLine.trim();
65
+ if (!trimmed) return null;
66
+ if (trimmed.startsWith(':')) return null;
67
+ if (trimmed.startsWith('event:')) return null;
68
+ if (trimmed.startsWith('id:')) return null;
69
+ if (trimmed.startsWith('data:')) {
70
+ return trimmed.slice(5).trim();
71
+ }
72
+ return trimmed;
73
+ }
74
+
75
+ function ensureToolCall(toolCalls: ToolCall[], index: number): ToolCall {
76
+ while (toolCalls.length <= index) {
77
+ toolCalls.push({
78
+ id: '',
79
+ type: 'function',
80
+ function: {
81
+ name: '',
82
+ arguments: '',
83
+ },
84
+ });
85
+ }
86
+ return toolCalls[index];
87
+ }
88
+
89
+ function mergeToolCallDelta(target: ToolCall, delta: StreamToolCallDelta): void {
90
+ if (typeof delta.id === 'string' && delta.id) {
91
+ target.id = target.id ? `${target.id}${delta.id}` : delta.id;
92
+ }
93
+ if (typeof delta.type === 'string') {
94
+ target.type = delta.type;
95
+ }
96
+ if (delta.function) {
97
+ if (typeof delta.function.name === 'string' && delta.function.name) {
98
+ target.function.name = target.function.name
99
+ ? `${target.function.name}${delta.function.name}`
100
+ : delta.function.name;
101
+ }
102
+ if (typeof delta.function.arguments === 'string' && delta.function.arguments) {
103
+ target.function.arguments += delta.function.arguments;
104
+ }
105
+ }
106
+ }
107
+
108
+ export async function callHybridAI(
109
+ baseUrl: string,
110
+ apiKey: string,
111
+ model: string,
112
+ chatbotId: string,
113
+ enableRag: boolean,
114
+ messages: ChatMessage[],
115
+ tools: ToolDefinition[],
116
+ ): Promise<ChatCompletionResponse> {
117
+ const url = `${baseUrl}/v1/chat/completions`;
118
+
119
+ const body = buildRequestBody(model, chatbotId, enableRag, messages, tools);
34
120
 
35
121
  const response = await fetch(url, {
36
122
  method: 'POST',
@@ -48,3 +134,179 @@ export async function callHybridAI(
48
134
 
49
135
  return (await response.json()) as ChatCompletionResponse;
50
136
  }
137
+
138
+ export async function callHybridAIStream(
139
+ baseUrl: string,
140
+ apiKey: string,
141
+ model: string,
142
+ chatbotId: string,
143
+ enableRag: boolean,
144
+ messages: ChatMessage[],
145
+ tools: ToolDefinition[],
146
+ onTextDelta: (delta: string) => void,
147
+ ): Promise<ChatCompletionResponse> {
148
+ const url = `${baseUrl}/v1/chat/completions`;
149
+ const body = {
150
+ ...buildRequestBody(model, chatbotId, enableRag, messages, tools),
151
+ stream: true,
152
+ };
153
+
154
+ const response = await fetch(url, {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ Accept: 'text/event-stream, application/x-ndjson, application/json',
159
+ Authorization: `Bearer ${apiKey}`,
160
+ },
161
+ body: JSON.stringify(body),
162
+ });
163
+
164
+ if (!response.ok) {
165
+ const text = await response.text();
166
+ throw new HybridAIRequestError(response.status, text);
167
+ }
168
+
169
+ const contentType = (response.headers.get('content-type') || '').toLowerCase();
170
+ if (
171
+ contentType.includes('application/json')
172
+ && !contentType.includes('ndjson')
173
+ && !contentType.includes('event-stream')
174
+ ) {
175
+ return (await response.json()) as ChatCompletionResponse;
176
+ }
177
+
178
+ if (!response.body) {
179
+ return (await response.json()) as ChatCompletionResponse;
180
+ }
181
+
182
+ const reader = response.body.getReader();
183
+ const decoder = new TextDecoder();
184
+
185
+ let buffer = '';
186
+ let streamId = '';
187
+ let streamModel = model;
188
+ let finishReason: string | null = null;
189
+ let usage: ChatCompletionResponse['usage'] | undefined;
190
+ let role: string = 'assistant';
191
+ let textContent = '';
192
+ const toolCalls: ToolCall[] = [];
193
+ let sawPayload = false;
194
+ let streamDone = false;
195
+
196
+ const consumePayload = (payloadText: string): void => {
197
+ if (!payloadText || payloadText === '[DONE]') {
198
+ if (payloadText === '[DONE]') streamDone = true;
199
+ return;
200
+ }
201
+
202
+ let payload: StreamChunkPayload;
203
+ try {
204
+ payload = JSON.parse(payloadText) as StreamChunkPayload;
205
+ } catch {
206
+ return;
207
+ }
208
+
209
+ sawPayload = true;
210
+ if (typeof payload.id === 'string' && payload.id) streamId = payload.id;
211
+ if (typeof payload.model === 'string' && payload.model) streamModel = payload.model;
212
+ if (payload.usage && typeof payload.usage === 'object') usage = payload.usage;
213
+
214
+ const choice = Array.isArray(payload.choices) ? payload.choices[0] : undefined;
215
+ if (!choice) return;
216
+
217
+ if (choice.message) {
218
+ const message = choice.message;
219
+ if (typeof message.role === 'string' && message.role) role = message.role;
220
+ if (typeof message.content === 'string') {
221
+ const nextContent = message.content;
222
+ const delta = nextContent.startsWith(textContent)
223
+ ? nextContent.slice(textContent.length)
224
+ : nextContent;
225
+ textContent = nextContent;
226
+ if (delta) onTextDelta(delta);
227
+ }
228
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
229
+ toolCalls.length = 0;
230
+ for (const call of message.tool_calls) {
231
+ toolCalls.push({
232
+ id: call.id || '',
233
+ type: call.type || 'function',
234
+ function: {
235
+ name: call.function?.name || '',
236
+ arguments: call.function?.arguments || '',
237
+ },
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ if (choice.delta) {
244
+ const delta = choice.delta;
245
+ if (typeof delta.role === 'string' && delta.role) role = delta.role;
246
+ if (typeof delta.content === 'string' && delta.content) {
247
+ textContent += delta.content;
248
+ onTextDelta(delta.content);
249
+ }
250
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) {
251
+ for (const callDelta of delta.tool_calls) {
252
+ const index = typeof callDelta.index === 'number' && callDelta.index >= 0 ? callDelta.index : 0;
253
+ const target = ensureToolCall(toolCalls, index);
254
+ mergeToolCallDelta(target, callDelta);
255
+ }
256
+ }
257
+ }
258
+
259
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
260
+ finishReason = choice.finish_reason;
261
+ }
262
+ };
263
+
264
+ try {
265
+ while (!streamDone) {
266
+ const { done, value } = await reader.read();
267
+ if (done) break;
268
+
269
+ buffer += decoder.decode(value, { stream: true });
270
+ const lines = buffer.split('\n');
271
+ buffer = lines.pop() || '';
272
+
273
+ for (const rawLine of lines) {
274
+ const payloadText = parseStreamPayloadLine(rawLine);
275
+ if (!payloadText) continue;
276
+ consumePayload(payloadText);
277
+ if (streamDone) break;
278
+ }
279
+ }
280
+
281
+ if (!streamDone && buffer.trim()) {
282
+ const payloadText = parseStreamPayloadLine(buffer);
283
+ if (payloadText) {
284
+ consumePayload(payloadText);
285
+ }
286
+ }
287
+ } finally {
288
+ reader.releaseLock();
289
+ decoder.decode();
290
+ }
291
+
292
+ if (!sawPayload) {
293
+ throw new Error('Streaming response ended without payload');
294
+ }
295
+
296
+ const finalFinishReason = finishReason || (toolCalls.length > 0 ? 'tool_calls' : 'stop');
297
+ return {
298
+ id: streamId || 'stream',
299
+ model: streamModel,
300
+ choices: [
301
+ {
302
+ message: {
303
+ role,
304
+ content: textContent || null,
305
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
306
+ },
307
+ finish_reason: finalFinishReason,
308
+ },
309
+ ],
310
+ ...(usage ? { usage } : {}),
311
+ };
312
+ }