@hybridaione/hybridclaw 0.1.21 → 0.1.24

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 (113) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +50 -8
  3. package/config.example.json +3 -0
  4. package/container/package-lock.json +2 -2
  5. package/container/package.json +1 -1
  6. package/container/src/browser-tools.ts +53 -3
  7. package/container/src/hybridai-client.ts +270 -8
  8. package/container/src/index.ts +66 -3
  9. package/container/src/token-usage.ts +89 -0
  10. package/container/src/tools.ts +9 -2
  11. package/container/src/types.ts +19 -0
  12. package/container/src/web-fetch.ts +98 -7
  13. package/dist/agent.d.ts +1 -1
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/agent.js +2 -2
  16. package/dist/agent.js.map +1 -1
  17. package/dist/chunk.d.ts +6 -0
  18. package/dist/chunk.d.ts.map +1 -0
  19. package/dist/chunk.js +129 -0
  20. package/dist/chunk.js.map +1 -0
  21. package/dist/container-runner.d.ts +1 -1
  22. package/dist/container-runner.d.ts.map +1 -1
  23. package/dist/container-runner.js +25 -1
  24. package/dist/container-runner.js.map +1 -1
  25. package/dist/conversation.d.ts +4 -0
  26. package/dist/conversation.d.ts.map +1 -1
  27. package/dist/conversation.js +13 -3
  28. package/dist/conversation.js.map +1 -1
  29. package/dist/discord-stream.d.ts +32 -0
  30. package/dist/discord-stream.d.ts.map +1 -0
  31. package/dist/discord-stream.js +196 -0
  32. package/dist/discord-stream.js.map +1 -0
  33. package/dist/discord.d.ts +9 -2
  34. package/dist/discord.d.ts.map +1 -1
  35. package/dist/discord.js +452 -23
  36. package/dist/discord.js.map +1 -1
  37. package/dist/gateway-client.d.ts.map +1 -1
  38. package/dist/gateway-client.js +5 -0
  39. package/dist/gateway-client.js.map +1 -1
  40. package/dist/gateway-service.d.ts +1 -0
  41. package/dist/gateway-service.d.ts.map +1 -1
  42. package/dist/gateway-service.js +60 -2
  43. package/dist/gateway-service.js.map +1 -1
  44. package/dist/gateway-types.d.ts +7 -1
  45. package/dist/gateway-types.d.ts.map +1 -1
  46. package/dist/gateway-types.js.map +1 -1
  47. package/dist/gateway.js +55 -4
  48. package/dist/gateway.js.map +1 -1
  49. package/dist/health.d.ts.map +1 -1
  50. package/dist/health.js +7 -0
  51. package/dist/health.js.map +1 -1
  52. package/dist/heartbeat.d.ts.map +1 -1
  53. package/dist/heartbeat.js +20 -0
  54. package/dist/heartbeat.js.map +1 -1
  55. package/dist/observability-ingest.d.ts.map +1 -1
  56. package/dist/observability-ingest.js +26 -0
  57. package/dist/observability-ingest.js.map +1 -1
  58. package/dist/prompt-hooks.d.ts +2 -0
  59. package/dist/prompt-hooks.d.ts.map +1 -1
  60. package/dist/prompt-hooks.js +29 -0
  61. package/dist/prompt-hooks.js.map +1 -1
  62. package/dist/runtime-config.d.ts +3 -0
  63. package/dist/runtime-config.d.ts.map +1 -1
  64. package/dist/runtime-config.js +17 -1
  65. package/dist/runtime-config.js.map +1 -1
  66. package/dist/scheduled-task-runner.d.ts.map +1 -1
  67. package/dist/scheduled-task-runner.js +20 -0
  68. package/dist/scheduled-task-runner.js.map +1 -1
  69. package/dist/session-maintenance.d.ts.map +1 -1
  70. package/dist/session-maintenance.js +1 -0
  71. package/dist/session-maintenance.js.map +1 -1
  72. package/dist/skills-guard.d.ts +36 -0
  73. package/dist/skills-guard.d.ts.map +1 -0
  74. package/dist/skills-guard.js +607 -0
  75. package/dist/skills-guard.js.map +1 -0
  76. package/dist/skills.d.ts +13 -2
  77. package/dist/skills.d.ts.map +1 -1
  78. package/dist/skills.js +494 -59
  79. package/dist/skills.js.map +1 -1
  80. package/dist/token-efficiency.d.ts +41 -0
  81. package/dist/token-efficiency.d.ts.map +1 -0
  82. package/dist/token-efficiency.js +164 -0
  83. package/dist/token-efficiency.js.map +1 -0
  84. package/dist/types.d.ts +11 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/workspace.d.ts.map +1 -1
  87. package/dist/workspace.js +2 -1
  88. package/dist/workspace.js.map +1 -1
  89. package/docs/index.html +33 -7
  90. package/package.json +1 -1
  91. package/src/agent.ts +15 -1
  92. package/src/chunk.ts +153 -0
  93. package/src/container-runner.ts +24 -0
  94. package/src/conversation.ts +28 -4
  95. package/src/discord-stream.ts +240 -0
  96. package/src/discord.ts +517 -23
  97. package/src/gateway-client.ts +7 -0
  98. package/src/gateway-service.ts +72 -1
  99. package/src/gateway-types.ts +12 -1
  100. package/src/gateway.ts +65 -4
  101. package/src/health.ts +8 -0
  102. package/src/heartbeat.ts +20 -0
  103. package/src/observability-ingest.ts +24 -0
  104. package/src/prompt-hooks.ts +29 -0
  105. package/src/runtime-config.ts +18 -1
  106. package/src/scheduled-task-runner.ts +20 -0
  107. package/src/session-maintenance.ts +1 -0
  108. package/src/skills-guard.ts +736 -0
  109. package/src/skills.ts +570 -61
  110. package/src/token-efficiency.ts +228 -0
  111. package/src/types.ts +12 -0
  112. package/src/workspace.ts +2 -2
  113. package/.hybridclaw/container-image-state.json +0 -5
@@ -50,16 +50,19 @@ import {
50
50
  } from './gateway-types.js';
51
51
  import type {
52
52
  ArtifactMetadata,
53
+ ChatMessage,
53
54
  DelegationSideEffect,
54
55
  DelegationTaskSpec,
55
56
  ScheduledTask,
56
57
  StoredMessage,
58
+ TokenUsageStats,
57
59
  ToolProgressEvent,
58
60
  } from './types.js';
59
61
  import { ensureBootstrapFiles } from './workspace.js';
60
62
  import { buildConversationContext } from './conversation.js';
61
63
  import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
62
64
  import { delegationQueueStatus, enqueueDelegation } from './delegation-manager.js';
65
+ import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
63
66
 
64
67
  const BOT_CACHE_TTL = 300_000; // 5 minutes
65
68
  const MAX_HISTORY_MESSAGES = 40;
@@ -166,6 +169,7 @@ export interface GatewayChatRequest {
166
169
  chatbotId?: GatewayChatRequestBody['chatbotId'];
167
170
  model?: GatewayChatRequestBody['model'];
168
171
  enableRag?: GatewayChatRequestBody['enableRag'];
172
+ onTextDelta?: (delta: string) => void;
169
173
  onToolProgress?: (event: ToolProgressEvent) => void;
170
174
  onProactiveMessage?: (message: ProactiveMessagePayload) => void | Promise<void>;
171
175
  abortSignal?: AbortSignal;
@@ -210,6 +214,49 @@ function plainCommand(text: string): GatewayCommandResult {
210
214
  return { kind: 'plain', text };
211
215
  }
212
216
 
217
+ function buildTokenUsageAuditPayload(
218
+ messages: ChatMessage[],
219
+ resultText: string | null | undefined,
220
+ tokenUsage?: TokenUsageStats,
221
+ ): Record<string, number | boolean> {
222
+ const promptChars = messages.reduce((total, message) => {
223
+ const content = typeof message.content === 'string' ? message.content : '';
224
+ return total + content.length;
225
+ }, 0);
226
+ const completionChars = (resultText || '').length;
227
+
228
+ const fallbackEstimatedPromptTokens = estimateTokenCountFromMessages(messages);
229
+ const fallbackEstimatedCompletionTokens = estimateTokenCountFromText(resultText || '');
230
+ const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || fallbackEstimatedPromptTokens;
231
+ const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || fallbackEstimatedCompletionTokens;
232
+ const estimatedTotalTokens =
233
+ tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
234
+
235
+ const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
236
+ const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
237
+ const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
238
+ const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
239
+ const promptTokens = apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens;
240
+ const completionTokens = apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens;
241
+ const totalTokens = apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens;
242
+
243
+ return {
244
+ modelCalls: tokenUsage ? Math.max(1, tokenUsage.modelCalls) : 0,
245
+ promptChars,
246
+ completionChars,
247
+ promptTokens,
248
+ completionTokens,
249
+ totalTokens,
250
+ estimatedPromptTokens,
251
+ estimatedCompletionTokens,
252
+ estimatedTotalTokens,
253
+ apiUsageAvailable,
254
+ apiPromptTokens,
255
+ apiCompletionTokens,
256
+ apiTotalTokens,
257
+ };
258
+ }
259
+
213
260
  export function getGatewayStatus(): GatewayStatus {
214
261
  return {
215
262
  status: 'ok',
@@ -811,11 +858,31 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
811
858
  ensureBootstrapFiles(agentId);
812
859
 
813
860
  const history = getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
814
- const { messages, skills } = buildConversationContext({
861
+ const { messages, skills, historyStats } = buildConversationContext({
815
862
  agentId,
816
863
  sessionSummary: session.session_summary,
817
864
  history,
818
865
  });
866
+ const historyStart = messages.length > 0 && messages[0].role === 'system' ? 1 : 0;
867
+ recordAuditEvent({
868
+ sessionId: req.sessionId,
869
+ runId,
870
+ event: {
871
+ type: 'context.optimization',
872
+ historyMessagesOriginal: historyStats.originalCount,
873
+ historyMessagesIncluded: historyStats.includedCount,
874
+ historyMessagesDropped: historyStats.droppedCount,
875
+ historyCharsOriginal: historyStats.originalChars,
876
+ historyCharsPreBudget: historyStats.preBudgetChars,
877
+ historyCharsIncluded: historyStats.includedChars,
878
+ historyCharsDropped: historyStats.droppedChars,
879
+ historyMaxChars: historyStats.maxTotalChars,
880
+ historyMaxMessageChars: historyStats.maxMessageChars,
881
+ perMessageTruncatedCount: historyStats.perMessageTruncatedCount,
882
+ middleCompressionApplied: historyStats.middleCompressionApplied,
883
+ historyEstimatedTokens: estimateTokenCountFromMessages(messages.slice(historyStart)),
884
+ },
885
+ });
819
886
  messages.push({
820
887
  role: 'user',
821
888
  content: expandSkillInvocation(req.content, skills),
@@ -833,6 +900,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
833
900
  req.channelId,
834
901
  scheduledTasks,
835
902
  undefined,
903
+ req.onTextDelta,
836
904
  req.onToolProgress,
837
905
  req.abortSignal,
838
906
  );
@@ -851,6 +919,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
851
919
  model,
852
920
  durationMs: Date.now() - startedAt,
853
921
  toolCallCount: toolExecutions.length,
922
+ ...buildTokenUsageAuditPayload(messages, output.result, output.tokenUsage),
854
923
  },
855
924
  });
856
925
 
@@ -944,6 +1013,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
944
1013
  toolsUsed: output.toolsUsed || [],
945
1014
  artifacts: output.artifacts,
946
1015
  toolExecutions,
1016
+ tokenUsage: output.tokenUsage,
947
1017
  error: errorMessage,
948
1018
  };
949
1019
  }
@@ -1009,6 +1079,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1009
1079
  toolsUsed: output.toolsUsed || [],
1010
1080
  artifacts: output.artifacts,
1011
1081
  toolExecutions,
1082
+ tokenUsage: output.tokenUsage,
1012
1083
  };
1013
1084
  } catch (err) {
1014
1085
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -1,3 +1,5 @@
1
+ import type { TokenUsageStats } from './types.js';
2
+
1
3
  export interface GatewayCommandResult {
2
4
  kind: 'plain' | 'info' | 'error';
3
5
  title?: string;
@@ -19,6 +21,7 @@ export interface GatewayChatResult {
19
21
  result: string;
20
22
  durationMs: number;
21
23
  }>;
24
+ tokenUsage?: TokenUsageStats;
22
25
  error?: string;
23
26
  }
24
27
 
@@ -30,12 +33,20 @@ export interface GatewayChatToolProgressEvent {
30
33
  durationMs?: number;
31
34
  }
32
35
 
36
+ export interface GatewayChatTextDeltaEvent {
37
+ type: 'text';
38
+ delta: string;
39
+ }
40
+
33
41
  export interface GatewayChatStreamResultEvent {
34
42
  type: 'result';
35
43
  result: GatewayChatResult;
36
44
  }
37
45
 
38
- export type GatewayChatStreamEvent = GatewayChatToolProgressEvent | GatewayChatStreamResultEvent;
46
+ export type GatewayChatStreamEvent =
47
+ | GatewayChatToolProgressEvent
48
+ | GatewayChatTextDeltaEvent
49
+ | GatewayChatStreamResultEvent;
39
50
 
40
51
  export interface GatewayChatRequestBody {
41
52
  sessionId: string;
package/src/gateway.ts CHANGED
@@ -66,6 +66,53 @@ function buildArtifactAttachments(
66
66
  return attachments;
67
67
  }
68
68
 
69
+ function normalizePathForMatch(value: string): string {
70
+ return value.replace(/\\/g, '/').toLowerCase();
71
+ }
72
+
73
+ function simplifyImageAttachmentNarration(
74
+ text: string,
75
+ artifacts?: ArtifactMetadata[],
76
+ ): string {
77
+ if (!text.trim() || !artifacts || artifacts.length === 0) return text;
78
+
79
+ const imageArtifacts = artifacts.filter((artifact) => artifact.mimeType.startsWith('image/'));
80
+ if (imageArtifacts.length === 0) return text;
81
+
82
+ const pathHints = new Set<string>();
83
+ for (const artifact of imageArtifacts) {
84
+ const normalizedPath = normalizePathForMatch(artifact.path);
85
+ const filename = normalizePathForMatch(artifact.filename);
86
+ if (normalizedPath) pathHints.add(normalizedPath);
87
+ if (filename) pathHints.add(filename);
88
+ if (filename) pathHints.add(`/workspace/.browser-artifacts/${filename}`);
89
+ if (filename) pathHints.add(`.browser-artifacts/${filename}`);
90
+ }
91
+
92
+ const pathishLine = /(^`?\s*(\.\/|\/|~\/|[a-zA-Z]:\\|\.browser-artifacts\/))|([\\/][^\\/\s]+\.[a-zA-Z0-9]{1,8})/;
93
+ const locationNarration = /(workspace|saved to|find it at|located at|liegt unter|pfad|path)/i;
94
+
95
+ let removedPathNarration = false;
96
+ const keptLines: string[] = [];
97
+ for (const line of text.split('\n')) {
98
+ const normalizedLine = normalizePathForMatch(line);
99
+ const mentionsArtifact = Array.from(pathHints).some((hint) => normalizedLine.includes(hint));
100
+ const isPathLine = pathishLine.test(line.trim());
101
+ const isLocationNarration = locationNarration.test(line);
102
+ if (mentionsArtifact && (isPathLine || isLocationNarration)) {
103
+ removedPathNarration = true;
104
+ continue;
105
+ }
106
+ keptLines.push(line);
107
+ }
108
+
109
+ if (!removedPathNarration) return text;
110
+
111
+ const cleaned = keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
112
+ if (cleaned) return cleaned;
113
+ return imageArtifacts.length === 1 ? 'Here it is.' : 'Here they are.';
114
+ }
115
+
69
116
  async function deliverProactiveMessage(
70
117
  channelId: string,
71
118
  text: string,
@@ -151,7 +198,8 @@ async function startDiscordIntegration(): Promise<void> {
151
198
  userId: string,
152
199
  username: string,
153
200
  content: string,
154
- reply: ReplyFn,
201
+ _reply: ReplyFn,
202
+ context,
155
203
  ) => {
156
204
  try {
157
205
  const result = await handleGatewayMessage({
@@ -161,20 +209,33 @@ async function startDiscordIntegration(): Promise<void> {
161
209
  userId,
162
210
  username,
163
211
  content,
212
+ onTextDelta: (delta) => {
213
+ void context.stream.append(delta);
214
+ },
164
215
  onProactiveMessage: async (message) => {
165
216
  await deliverProactiveMessage(channelId, message.text, 'delegate', message.artifacts);
166
217
  },
218
+ abortSignal: context.abortSignal,
167
219
  });
168
220
  if (result.status === 'error') {
169
- await reply(formatError('Agent Error', result.error || 'Unknown error'));
221
+ const errorText = formatError('Agent Error', result.error || 'Unknown error');
222
+ await context.stream.fail(errorText);
170
223
  return;
171
224
  }
172
225
  const attachments = buildArtifactAttachments(result.artifacts);
173
- await reply(buildResponseText(result.result || 'No response from agent.', result.toolsUsed), attachments);
226
+ const userText = simplifyImageAttachmentNarration(
227
+ result.result || 'No response from agent.',
228
+ result.artifacts,
229
+ );
230
+ await context.stream.finalize(
231
+ buildResponseText(userText, result.toolsUsed),
232
+ attachments,
233
+ );
174
234
  } catch (error) {
175
235
  const text = error instanceof Error ? error.message : String(error);
176
236
  logger.error({ error, sessionId, channelId }, 'Discord message handling failed');
177
- await reply(formatError('Gateway Error', text));
237
+ const errorText = formatError('Gateway Error', text);
238
+ await context.stream.fail(errorText);
178
239
  }
179
240
  },
180
241
  async (
package/src/health.ts CHANGED
@@ -146,9 +146,17 @@ async function handleApiChatStream(
146
146
  });
147
147
  };
148
148
 
149
+ const onTextDelta = (delta: string): void => {
150
+ sendEvent({
151
+ type: 'text',
152
+ delta,
153
+ });
154
+ };
155
+
149
156
  try {
150
157
  const result = await handleGatewayMessage({
151
158
  ...chatRequest,
159
+ onTextDelta,
152
160
  onToolProgress,
153
161
  });
154
162
  sendEvent({ type: 'result', result });
package/src/heartbeat.ts CHANGED
@@ -12,6 +12,7 @@ import { appendSessionTranscript } from './session-transcripts.js';
12
12
  import { buildConversationContext } from './conversation.js';
13
13
  import { isWithinActiveHours, proactiveWindowLabel } from './proactive-policy.js';
14
14
  import { emitToolExecutionAuditEvents, makeAuditRunId, recordAuditEvent } from './audit-events.js';
15
+ import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
15
16
 
16
17
  const HEARTBEAT_PROMPT =
17
18
  '[Heartbeat poll] Check HEARTBEAT.md for periodic tasks. If nothing needs attention, reply HEARTBEAT_OK.';
@@ -136,6 +137,14 @@ export function startHeartbeat(
136
137
  runId,
137
138
  toolExecutions: output.toolExecutions || [],
138
139
  });
140
+ const tokenUsage = output.tokenUsage;
141
+ const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || estimateTokenCountFromMessages(messages);
142
+ const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || estimateTokenCountFromText(output.result || '');
143
+ const estimatedTotalTokens = tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
144
+ const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
145
+ const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
146
+ const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
147
+ const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
139
148
  recordAuditEvent({
140
149
  sessionId,
141
150
  runId,
@@ -145,6 +154,17 @@ export function startHeartbeat(
145
154
  model: HYBRIDAI_MODEL,
146
155
  durationMs: Date.now() - startedAt,
147
156
  toolCallCount: (output.toolExecutions || []).length,
157
+ modelCalls: tokenUsage ? Math.max(1, tokenUsage.modelCalls) : 0,
158
+ promptTokens: apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens,
159
+ completionTokens: apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens,
160
+ totalTokens: apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens,
161
+ estimatedPromptTokens,
162
+ estimatedCompletionTokens,
163
+ estimatedTotalTokens,
164
+ apiUsageAvailable,
165
+ apiPromptTokens,
166
+ apiCompletionTokens,
167
+ apiTotalTokens,
148
168
  },
149
169
  });
150
170
  processSideEffects(output, sessionId, heartbeatChannelId);
@@ -222,6 +222,17 @@ function readNullableInteger(payload: Record<string, unknown>, key: string): num
222
222
  return null;
223
223
  }
224
224
 
225
+ function readNullableBoolean(payload: Record<string, unknown>, key: string): boolean | null {
226
+ const value = payload[key];
227
+ if (typeof value === 'boolean') return value;
228
+ if (typeof value === 'string') {
229
+ const normalized = value.trim().toLowerCase();
230
+ if (normalized === 'true') return true;
231
+ if (normalized === 'false') return false;
232
+ }
233
+ return null;
234
+ }
235
+
225
236
  function inferDenied(payload: Record<string, unknown>): boolean {
226
237
  if (typeof payload.denied === 'boolean') return payload.denied;
227
238
  if (payload.approved === false) return true;
@@ -257,6 +268,19 @@ function mapAuditRowToEvent(config: ResolvedIngestConfig, row: StructuredAuditEn
257
268
  denied: inferDenied(payload),
258
269
  error_type: readNullableString(payload, 'errorType'),
259
270
  duration_ms: readNullableInteger(payload, 'durationMs'),
271
+ model_calls: readNullableInteger(payload, 'modelCalls'),
272
+ prompt_chars: readNullableInteger(payload, 'promptChars'),
273
+ completion_chars: readNullableInteger(payload, 'completionChars'),
274
+ prompt_tokens: readNullableInteger(payload, 'promptTokens'),
275
+ completion_tokens: readNullableInteger(payload, 'completionTokens'),
276
+ total_tokens: readNullableInteger(payload, 'totalTokens'),
277
+ estimated_prompt_tokens: readNullableInteger(payload, 'estimatedPromptTokens'),
278
+ estimated_completion_tokens: readNullableInteger(payload, 'estimatedCompletionTokens'),
279
+ estimated_total_tokens: readNullableInteger(payload, 'estimatedTotalTokens'),
280
+ api_usage_available: readNullableBoolean(payload, 'apiUsageAvailable'),
281
+ api_prompt_tokens: readNullableInteger(payload, 'apiPromptTokens'),
282
+ api_completion_tokens: readNullableInteger(payload, 'apiCompletionTokens'),
283
+ api_total_tokens: readNullableInteger(payload, 'apiTotalTokens'),
260
284
  event_uid: buildEventUid(config, row),
261
285
  };
262
286
  }
@@ -6,12 +6,14 @@ import path from 'path';
6
6
 
7
7
  export type PromptHookName = 'bootstrap' | 'memory' | 'safety';
8
8
  export type ExtendedPromptHookName = PromptHookName | 'proactivity';
9
+ export type PromptMode = 'full' | 'minimal' | 'none';
9
10
 
10
11
  export interface PromptHookContext {
11
12
  agentId: string;
12
13
  sessionSummary?: string | null;
13
14
  skills: Skill[];
14
15
  purpose?: 'conversation' | 'memory-flush';
16
+ promptMode?: PromptMode;
15
17
  extraSafetyText?: string;
16
18
  }
17
19
 
@@ -72,6 +74,17 @@ function buildSafetyHook(context: PromptHookContext): string {
72
74
  'After file changes, run commands only when asked; otherwise explicitly offer to run them immediately.',
73
75
  'Only skip file creation when the user explicitly asks for snippet-only or explanation-only output.',
74
76
  '',
77
+ '## Web Retrieval Routing (web_fetch vs browser_*)',
78
+ 'Decision rule: default to `web_fetch` for read-only content retrieval.',
79
+ 'Use browser tools only when at least one of these is true: (1) known app-like/auth-gated URL, (2) interaction is required (click/type/login/scroll), (3) `web_fetch` returned escalation hints, (4) user explicitly requested browser use.',
80
+ 'Prefer browser for: SPAs/client-rendered apps (React/Vue/Angular/Next client routes), dashboards/web apps, social feeds, login/OAuth/cookie-consent/CAPTCHA flows, or API-driven pages that populate after initial render.',
81
+ 'Prefer web_fetch for: docs/wikis/READMEs/articles/reference pages, direct JSON/XML/text/CSV/PDF endpoints, and simple read-only extraction.',
82
+ 'Escalation signals from web_fetch: `escalationHint` present, JavaScript-required pages, empty extraction, SPA shell-only pages, boilerplate-only extraction, or bot-blocked responses (403/429/challenge pages).',
83
+ 'Cost note: browser calls are typically ~10-100x slower/more expensive than web_fetch.',
84
+ 'Browser extraction flow (for read/summarize requests): after `browser_navigate`, call `browser_snapshot` with `mode="full"` before deciding content is unavailable.',
85
+ 'If snapshot content is incomplete, run `browser_scroll` and then `browser_snapshot` again (repeat a few times for long/lazy-loaded pages).',
86
+ 'Do not use `browser_pdf` as a text-reading step; it is an export artifact, not a text extraction tool.',
87
+ '',
75
88
  '## Browser Auth Handling',
76
89
  'When the user explicitly asks for login/auth-flow testing, browser tools may be used on the requested site, including filling credentials and submitting forms.',
77
90
  'Do not invent blanket restrictions such as "browser tools are only for public/unauthenticated pages" unless an actual tool/policy error says so.',
@@ -206,11 +219,27 @@ const PROMPT_HOOKS: PromptHook[] = [
206
219
  },
207
220
  ];
208
221
 
222
+ function resolvePromptMode(context: PromptHookContext): PromptMode {
223
+ if (context.promptMode === 'minimal' || context.promptMode === 'none') return context.promptMode;
224
+ return 'full';
225
+ }
226
+
227
+ function isHookAllowedForMode(hookName: ExtendedPromptHookName, mode: PromptMode): boolean {
228
+ if (mode === 'none') return false;
229
+ if (mode === 'full') return true;
230
+ // Minimal mode keeps only safety + memory durability context.
231
+ return hookName === 'memory' || hookName === 'safety';
232
+ }
233
+
209
234
  export function runPromptHooks(context: PromptHookContext): PromptHookOutput[] {
235
+ const mode = resolvePromptMode(context);
236
+ if (mode === 'none') return [];
237
+
210
238
  const runtime = getRuntimeConfig();
211
239
  const output: PromptHookOutput[] = [];
212
240
 
213
241
  for (const hook of PROMPT_HOOKS) {
242
+ if (!isHookAllowedForMode(hook.name, mode)) continue;
214
243
  if (!hook.isEnabled(runtime)) continue;
215
244
  const content = hook.run(context).trim();
216
245
  if (!content) continue;
@@ -31,6 +31,9 @@ export interface RuntimeSecurityConfig {
31
31
  export interface RuntimeConfig {
32
32
  version: number;
33
33
  security: RuntimeSecurityConfig;
34
+ skills: {
35
+ extraDirs: string[];
36
+ };
34
37
  discord: {
35
38
  prefix: string;
36
39
  };
@@ -126,6 +129,9 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
126
129
  trustModelVersion: '',
127
130
  trustModelAcceptedBy: '',
128
131
  },
132
+ skills: {
133
+ extraDirs: [],
134
+ },
129
135
  discord: {
130
136
  prefix: '!claw',
131
137
  },
@@ -326,6 +332,7 @@ function readLegacyEnvPatch(): DeepPartial<RuntimeConfig> {
326
332
 
327
333
  const patch: Record<string, unknown> = {
328
334
  discord: {},
335
+ skills: {},
329
336
  hybridai: {},
330
337
  container: {},
331
338
  heartbeat: {},
@@ -343,6 +350,7 @@ function readLegacyEnvPatch(): DeepPartial<RuntimeConfig> {
343
350
  };
344
351
 
345
352
  const discord = patch.discord as Record<string, unknown>;
353
+ const skills = patch.skills as Record<string, unknown>;
346
354
  const hybridai = patch.hybridai as Record<string, unknown>;
347
355
  const container = patch.container as Record<string, unknown>;
348
356
  const heartbeat = patch.heartbeat as Record<string, unknown>;
@@ -356,6 +364,7 @@ function readLegacyEnvPatch(): DeepPartial<RuntimeConfig> {
356
364
  const proactiveAutoRetry = proactive.autoRetry as Record<string, unknown>;
357
365
 
358
366
  if (env.DISCORD_PREFIX != null) discord.prefix = env.DISCORD_PREFIX;
367
+ if (env.SKILLS_EXTRA_DIRS != null) skills.extraDirs = env.SKILLS_EXTRA_DIRS;
359
368
 
360
369
  if (env.HYBRIDAI_BASE_URL != null) hybridai.baseUrl = env.HYBRIDAI_BASE_URL;
361
370
  if (env.HYBRIDAI_MODEL != null) hybridai.defaultModel = env.HYBRIDAI_MODEL;
@@ -452,6 +461,7 @@ function normalizeRuntimeConfig(patch?: DeepPartial<RuntimeConfig>): RuntimeConf
452
461
  const raw = patch ?? {};
453
462
 
454
463
  const rawSecurity = isRecord(raw.security) ? raw.security : {};
464
+ const rawSkills = isRecord(raw.skills) ? raw.skills : {};
455
465
  const rawDiscord = isRecord(raw.discord) ? raw.discord : {};
456
466
  const rawHybridAi = isRecord(raw.hybridai) ? raw.hybridai : {};
457
467
  const rawContainer = isRecord(raw.container) ? raw.container : {};
@@ -500,6 +510,9 @@ function normalizeRuntimeConfig(patch?: DeepPartial<RuntimeConfig>): RuntimeConf
500
510
  trustModelVersion: normalizeString(rawSecurity.trustModelVersion, DEFAULT_RUNTIME_CONFIG.security.trustModelVersion, { allowEmpty: true }),
501
511
  trustModelAcceptedBy: normalizeString(rawSecurity.trustModelAcceptedBy, DEFAULT_RUNTIME_CONFIG.security.trustModelAcceptedBy, { allowEmpty: true }),
502
512
  },
513
+ skills: {
514
+ extraDirs: normalizeStringArray(rawSkills.extraDirs, DEFAULT_RUNTIME_CONFIG.skills.extraDirs),
515
+ },
503
516
  discord: {
504
517
  prefix: normalizeString(rawDiscord.prefix, DEFAULT_RUNTIME_CONFIG.discord.prefix, { allowEmpty: false }),
505
518
  },
@@ -799,7 +812,11 @@ function migrateConfigSchemaOnStartup(): void {
799
812
  try {
800
813
  writeConfigFile(migrated);
801
814
  const from = previousVersion == null ? 'unknown' : String(previousVersion);
802
- console.info(`[runtime-config] migrated config schema from v${from} to v${CONFIG_VERSION}`);
815
+ if (previousVersion !== CONFIG_VERSION) {
816
+ console.info(`[runtime-config] migrated config schema from v${from} to v${CONFIG_VERSION}`);
817
+ } else {
818
+ console.info(`[runtime-config] normalized config schema v${CONFIG_VERSION} (filled defaults/canonicalized values)`);
819
+ }
803
820
  } catch (err) {
804
821
  console.warn(`[runtime-config] schema migration failed: ${err instanceof Error ? err.message : String(err)}`);
805
822
  }
@@ -1,6 +1,7 @@
1
1
  import { runAgent } from './agent.js';
2
2
  import { emitToolExecutionAuditEvents, makeAuditRunId, recordAuditEvent } from './audit-events.js';
3
3
  import type { ChatMessage } from './types.js';
4
+ import { estimateTokenCountFromMessages, estimateTokenCountFromText } from './token-efficiency.js';
4
5
 
5
6
  export async function runIsolatedScheduledTask(params: {
6
7
  taskId: number;
@@ -53,6 +54,14 @@ export async function runIsolatedScheduledTask(params: {
53
54
  runId,
54
55
  toolExecutions: output.toolExecutions || [],
55
56
  });
57
+ const tokenUsage = output.tokenUsage;
58
+ const estimatedPromptTokens = tokenUsage?.estimatedPromptTokens || estimateTokenCountFromMessages(messages);
59
+ const estimatedCompletionTokens = tokenUsage?.estimatedCompletionTokens || estimateTokenCountFromText(output.result || '');
60
+ const estimatedTotalTokens = tokenUsage?.estimatedTotalTokens || (estimatedPromptTokens + estimatedCompletionTokens);
61
+ const apiUsageAvailable = tokenUsage?.apiUsageAvailable === true;
62
+ const apiPromptTokens = tokenUsage?.apiPromptTokens || 0;
63
+ const apiCompletionTokens = tokenUsage?.apiCompletionTokens || 0;
64
+ const apiTotalTokens = tokenUsage?.apiTotalTokens || (apiPromptTokens + apiCompletionTokens);
56
65
  recordAuditEvent({
57
66
  sessionId: cronSessionId,
58
67
  runId,
@@ -62,6 +71,17 @@ export async function runIsolatedScheduledTask(params: {
62
71
  model,
63
72
  durationMs: Date.now() - startedAt,
64
73
  toolCallCount: (output.toolExecutions || []).length,
74
+ modelCalls: tokenUsage ? Math.max(1, tokenUsage.modelCalls) : 0,
75
+ promptTokens: apiUsageAvailable ? apiPromptTokens : estimatedPromptTokens,
76
+ completionTokens: apiUsageAvailable ? apiCompletionTokens : estimatedCompletionTokens,
77
+ totalTokens: apiUsageAvailable ? apiTotalTokens : estimatedTotalTokens,
78
+ estimatedPromptTokens,
79
+ estimatedCompletionTokens,
80
+ estimatedTotalTokens,
81
+ apiUsageAvailable,
82
+ apiPromptTokens,
83
+ apiCompletionTokens,
84
+ apiTotalTokens,
65
85
  },
66
86
  });
67
87
 
@@ -78,6 +78,7 @@ function buildSystemPrompt(agentId: string, sessionSummary?: string | null, extr
78
78
  sessionSummary,
79
79
  skills: loadSkills(agentId),
80
80
  purpose: 'memory-flush',
81
+ promptMode: 'minimal',
81
82
  extraSafetyText: extra,
82
83
  });
83
84
  }