lynkr 8.0.0 → 9.0.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 (128) hide show
  1. package/.lynkr/telemetry.db +0 -0
  2. package/.lynkr/telemetry.db-shm +0 -0
  3. package/.lynkr/telemetry.db-wal +0 -0
  4. package/README.md +196 -322
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/package.json +4 -3
  7. package/src/api/openai-router.js +64 -13
  8. package/src/api/providers-handler.js +171 -3
  9. package/src/api/router.js +9 -2
  10. package/src/clients/circuit-breaker.js +10 -247
  11. package/src/clients/codex-process.js +342 -0
  12. package/src/clients/codex-utils.js +143 -0
  13. package/src/clients/databricks.js +210 -63
  14. package/src/clients/resilience.js +540 -0
  15. package/src/clients/retry.js +22 -167
  16. package/src/clients/standard-tools.js +23 -0
  17. package/src/config/index.js +77 -0
  18. package/src/context/compression.js +42 -9
  19. package/src/context/distill.js +492 -0
  20. package/src/orchestrator/index.js +48 -8
  21. package/src/routing/complexity-analyzer.js +258 -5
  22. package/src/routing/index.js +12 -2
  23. package/src/routing/latency-tracker.js +148 -0
  24. package/src/routing/model-tiers.js +2 -0
  25. package/src/routing/quality-scorer.js +113 -0
  26. package/src/routing/telemetry.js +464 -0
  27. package/src/server.js +13 -12
  28. package/src/tools/code-graph.js +538 -0
  29. package/src/tools/code-mode.js +304 -0
  30. package/src/tools/index.js +4 -0
  31. package/src/tools/lazy-loader.js +18 -0
  32. package/src/tools/mcp-remote.js +7 -0
  33. package/src/tools/smart-selection.js +11 -0
  34. package/src/tools/tinyfish.js +358 -0
  35. package/src/tools/truncate.js +1 -0
  36. package/src/utils/payload.js +206 -0
  37. package/src/utils/perf-timer.js +80 -0
  38. package/.github/FUNDING.yml +0 -15
  39. package/.github/workflows/README.md +0 -215
  40. package/.github/workflows/ci.yml +0 -69
  41. package/.github/workflows/index.yml +0 -62
  42. package/.github/workflows/web-tools-tests.yml +0 -56
  43. package/CITATIONS.bib +0 -6
  44. package/DEPLOYMENT.md +0 -1001
  45. package/LYNKR-TUI-PLAN.md +0 -984
  46. package/PERFORMANCE-REPORT.md +0 -866
  47. package/PLAN-per-client-model-routing.md +0 -252
  48. package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
  49. package/docs/BingSiteAuth.xml +0 -4
  50. package/docs/docs-style.css +0 -478
  51. package/docs/docs.html +0 -198
  52. package/docs/google5be250e608e6da39.html +0 -1
  53. package/docs/index.html +0 -577
  54. package/docs/index.md +0 -584
  55. package/docs/robots.txt +0 -4
  56. package/docs/sitemap.xml +0 -44
  57. package/docs/style.css +0 -1223
  58. package/docs/toon-integration-spec.md +0 -130
  59. package/documentation/README.md +0 -101
  60. package/documentation/api.md +0 -806
  61. package/documentation/claude-code-cli.md +0 -679
  62. package/documentation/codex-cli.md +0 -397
  63. package/documentation/contributing.md +0 -571
  64. package/documentation/cursor-integration.md +0 -734
  65. package/documentation/docker.md +0 -874
  66. package/documentation/embeddings.md +0 -762
  67. package/documentation/faq.md +0 -713
  68. package/documentation/features.md +0 -403
  69. package/documentation/headroom.md +0 -519
  70. package/documentation/installation.md +0 -758
  71. package/documentation/memory-system.md +0 -476
  72. package/documentation/production.md +0 -636
  73. package/documentation/providers.md +0 -1009
  74. package/documentation/routing.md +0 -476
  75. package/documentation/testing.md +0 -629
  76. package/documentation/token-optimization.md +0 -325
  77. package/documentation/tools.md +0 -697
  78. package/documentation/troubleshooting.md +0 -969
  79. package/final-test.js +0 -33
  80. package/headroom-sidecar/config.py +0 -93
  81. package/headroom-sidecar/requirements.txt +0 -14
  82. package/headroom-sidecar/server.py +0 -451
  83. package/monitor-agents.sh +0 -31
  84. package/scripts/audit-log-reader.js +0 -399
  85. package/scripts/compact-dictionary.js +0 -204
  86. package/scripts/test-deduplication.js +0 -448
  87. package/src/db/database.sqlite +0 -0
  88. package/te +0 -11622
  89. package/test/README.md +0 -212
  90. package/test/azure-openai-config.test.js +0 -213
  91. package/test/azure-openai-error-resilience.test.js +0 -238
  92. package/test/azure-openai-format-conversion.test.js +0 -354
  93. package/test/azure-openai-integration.test.js +0 -287
  94. package/test/azure-openai-routing.test.js +0 -175
  95. package/test/azure-openai-streaming.test.js +0 -171
  96. package/test/bedrock-integration.test.js +0 -457
  97. package/test/comprehensive-test-suite.js +0 -928
  98. package/test/config-validation.test.js +0 -207
  99. package/test/cursor-integration.test.js +0 -484
  100. package/test/format-conversion.test.js +0 -578
  101. package/test/hybrid-routing-integration.test.js +0 -269
  102. package/test/hybrid-routing-performance.test.js +0 -428
  103. package/test/llamacpp-integration.test.js +0 -882
  104. package/test/lmstudio-integration.test.js +0 -347
  105. package/test/memory/extractor.test.js +0 -398
  106. package/test/memory/retriever.test.js +0 -613
  107. package/test/memory/retriever.test.js.bak +0 -585
  108. package/test/memory/search.test.js +0 -537
  109. package/test/memory/search.test.js.bak +0 -389
  110. package/test/memory/store.test.js +0 -344
  111. package/test/memory/store.test.js.bak +0 -312
  112. package/test/memory/surprise.test.js +0 -300
  113. package/test/memory-performance.test.js +0 -472
  114. package/test/openai-integration.test.js +0 -683
  115. package/test/openrouter-error-resilience.test.js +0 -418
  116. package/test/passthrough-mode.test.js +0 -385
  117. package/test/performance-benchmark.js +0 -351
  118. package/test/performance-tests.js +0 -528
  119. package/test/routing.test.js +0 -225
  120. package/test/toon-compression.test.js +0 -131
  121. package/test/web-tools.test.js +0 -329
  122. package/test-agents-simple.js +0 -43
  123. package/test-cli-connection.sh +0 -33
  124. package/test-learning-unit.js +0 -126
  125. package/test-learning.js +0 -112
  126. package/test-parallel-agents.sh +0 -124
  127. package/test-parallel-direct.js +0 -155
  128. package/test-subagents.sh +0 -117
@@ -0,0 +1,358 @@
1
+ const { URL } = require("url");
2
+ const { Agent } = require("undici");
3
+ const config = require("../config");
4
+ const logger = require("../logger");
5
+ const { registerTool } = require(".");
6
+
7
+ /**
8
+ * Dedicated HTTP agent for TinyFish SSE streams.
9
+ * The default webAgent in web-client.js has a 30s bodyTimeout which is too
10
+ * short for browser-automation tasks that can take up to 120s.
11
+ */
12
+ const sseAgent = new Agent({
13
+ connections: 10,
14
+ pipelining: 1,
15
+ keepAliveTimeout: 60000,
16
+ connectTimeout: 15000,
17
+ bodyTimeout: 0, // no body timeout — we manage timeout via AbortController
18
+ headersTimeout: 15000,
19
+ maxRedirections: 3,
20
+ strictContentLength: false,
21
+ });
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Argument normalisers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function normalizeUrl(args) {
28
+ const raw = args.url ?? args.uri ?? args.href ?? args.target_url;
29
+ if (typeof raw !== "string" || raw.trim().length === 0) {
30
+ throw new Error("web_agent requires a non-empty url string.");
31
+ }
32
+ // Validate URL
33
+ try {
34
+ new URL(raw.trim());
35
+ } catch {
36
+ throw new Error(`web_agent received an invalid URL: ${raw}`);
37
+ }
38
+ return raw.trim();
39
+ }
40
+
41
+ function normalizeGoal(args) {
42
+ const goal = args.goal ?? args.task ?? args.prompt ?? args.instruction;
43
+ if (typeof goal !== "string" || goal.trim().length === 0) {
44
+ throw new Error("web_agent requires a non-empty goal string.");
45
+ }
46
+ return goal.trim();
47
+ }
48
+
49
+ function resolveBrowserProfile(args) {
50
+ const profile = args.browser_profile ?? args.browserProfile ?? config.tinyfish.browserProfile;
51
+ if (profile === "stealth") return "stealth";
52
+ return "lite";
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // SSE stream consumer
57
+ // ---------------------------------------------------------------------------
58
+
59
+ async function consumeSSEStream(response, timeoutMs) {
60
+ const reader = response.body.getReader();
61
+ const decoder = new TextDecoder();
62
+ let buffer = "";
63
+ const startTime = Date.now();
64
+
65
+ try {
66
+ while (true) {
67
+ if (Date.now() - startTime > timeoutMs) {
68
+ const err = new Error(`TinyFish SSE stream timed out after ${timeoutMs}ms`);
69
+ err.code = "ETIMEDOUT";
70
+ err.status = 504;
71
+ throw err;
72
+ }
73
+
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+
77
+ buffer += decoder.decode(value, { stream: true });
78
+
79
+ // SSE events are separated by double newlines
80
+ const parts = buffer.split("\n\n");
81
+ // Keep the last (possibly incomplete) chunk in the buffer
82
+ buffer = parts.pop() || "";
83
+
84
+ for (const part of parts) {
85
+ // Extract the data: line(s)
86
+ const lines = part.split("\n");
87
+ let dataStr = "";
88
+ for (const line of lines) {
89
+ if (line.startsWith("data: ")) {
90
+ dataStr += line.slice(6);
91
+ } else if (line.startsWith("data:")) {
92
+ dataStr += line.slice(5);
93
+ }
94
+ }
95
+
96
+ if (!dataStr) continue;
97
+
98
+ let event;
99
+ try {
100
+ event = JSON.parse(dataStr);
101
+ } catch {
102
+ // Not valid JSON — skip this SSE frame
103
+ logger.debug({ raw: dataStr.slice(0, 200) }, "TinyFish: non-JSON SSE frame, skipping");
104
+ continue;
105
+ }
106
+
107
+ logger.debug(
108
+ { type: event.type, status: event.status },
109
+ "TinyFish SSE event"
110
+ );
111
+
112
+ if (event.type === "COMPLETE" || event.type === "complete") {
113
+ const status = (event.status ?? "").toUpperCase();
114
+ if (status === "COMPLETED" || status === "SUCCESS") {
115
+ return event.resultJson ?? event.result ?? event.data ?? event;
116
+ }
117
+ // Task failed
118
+ const errMsg = event.error ?? event.message ?? "TinyFish task failed";
119
+ const err = new Error(typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
120
+ err.code = "TINYFISH_TASK_FAILED";
121
+ err.status = 502;
122
+ throw err;
123
+ }
124
+ }
125
+ }
126
+
127
+ // Stream ended without a COMPLETE event
128
+ const err = new Error("TinyFish SSE stream ended without a COMPLETE event");
129
+ err.code = "TINYFISH_INCOMPLETE";
130
+ err.status = 502;
131
+ throw err;
132
+ } finally {
133
+ reader.releaseLock();
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Core API call
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function callTinyFishAPI({ url, goal, browserProfile, proxyConfig, timeoutMs }) {
142
+ const endpoint = config.tinyfish.endpoint;
143
+ const apiKey = config.tinyfish.apiKey;
144
+
145
+ if (!apiKey) {
146
+ return {
147
+ ok: false,
148
+ status: 503,
149
+ content: JSON.stringify({
150
+ error: "tinyfish_not_configured",
151
+ message:
152
+ "TinyFish API key is not configured. Set TINYFISH_API_KEY in your .env file. Get a key from https://tinyfish.ai",
153
+ }, null, 2),
154
+ };
155
+ }
156
+
157
+ const body = {
158
+ url,
159
+ goal,
160
+ browserProfile,
161
+ };
162
+
163
+ if (proxyConfig) {
164
+ body.proxy = proxyConfig;
165
+ }
166
+
167
+ const controller = new AbortController();
168
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
169
+
170
+ try {
171
+ const response = await fetch(endpoint, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ "X-API-Key": apiKey,
176
+ Accept: "text/event-stream",
177
+ },
178
+ body: JSON.stringify(body),
179
+ signal: controller.signal,
180
+ dispatcher: sseAgent,
181
+ });
182
+
183
+ // Handle non-2xx responses before attempting SSE parse
184
+ if (!response.ok) {
185
+ const text = await response.text().catch(() => "");
186
+ const shouldRetry = response.status === 429 || response.status >= 500;
187
+
188
+ if (shouldRetry) {
189
+ // Retry once with 2s backoff
190
+ logger.warn(
191
+ { status: response.status, body: text.slice(0, 200) },
192
+ "TinyFish API error, retrying once"
193
+ );
194
+ await new Promise((r) => setTimeout(r, 2000));
195
+
196
+ const retryController = new AbortController();
197
+ const retryTimeout = setTimeout(() => retryController.abort(), timeoutMs);
198
+ try {
199
+ const retryResponse = await fetch(endpoint, {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ "X-API-Key": apiKey,
204
+ Accept: "text/event-stream",
205
+ },
206
+ body: JSON.stringify(body),
207
+ signal: retryController.signal,
208
+ dispatcher: sseAgent,
209
+ });
210
+
211
+ if (!retryResponse.ok) {
212
+ const retryText = await retryResponse.text().catch(() => "");
213
+ const err = new Error(
214
+ `TinyFish API error (${retryResponse.status}): ${retryResponse.statusText}`
215
+ );
216
+ err.status = retryResponse.status;
217
+ err.body = retryText;
218
+ throw err;
219
+ }
220
+
221
+ const result = await consumeSSEStream(retryResponse, timeoutMs);
222
+ return {
223
+ ok: true,
224
+ status: 200,
225
+ result,
226
+ };
227
+ } finally {
228
+ clearTimeout(retryTimeout);
229
+ }
230
+ }
231
+
232
+ const err = new Error(
233
+ `TinyFish API error (${response.status}): ${response.statusText}`
234
+ );
235
+ err.status = response.status;
236
+ err.body = text;
237
+ throw err;
238
+ }
239
+
240
+ const result = await consumeSSEStream(response, timeoutMs);
241
+ return {
242
+ ok: true,
243
+ status: 200,
244
+ result,
245
+ };
246
+ } catch (error) {
247
+ if (error.name === "AbortError") {
248
+ const err = new Error(`TinyFish request timed out after ${timeoutMs}ms`);
249
+ err.code = "ETIMEDOUT";
250
+ err.status = 504;
251
+ throw err;
252
+ }
253
+ throw error;
254
+ } finally {
255
+ clearTimeout(timeout);
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Tool registration
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function registerTinyFishTool() {
264
+ registerTool(
265
+ "web_agent",
266
+ async ({ args = {} }) => {
267
+ const url = normalizeUrl(args);
268
+ const goal = normalizeGoal(args);
269
+ const browserProfile = resolveBrowserProfile(args);
270
+ const timeoutMs = config.tinyfish.timeoutMs;
271
+
272
+ // Build proxy config if enabled
273
+ let proxyConfig = null;
274
+ if (config.tinyfish.proxyEnabled) {
275
+ proxyConfig = {
276
+ enabled: true,
277
+ country: config.tinyfish.proxyCountry,
278
+ };
279
+ }
280
+
281
+ try {
282
+ const response = await callTinyFishAPI({
283
+ url,
284
+ goal,
285
+ browserProfile,
286
+ proxyConfig,
287
+ timeoutMs,
288
+ });
289
+
290
+ // Guard clause: not configured
291
+ if (!response.ok && response.status === 503) {
292
+ return response;
293
+ }
294
+
295
+ const resultStr =
296
+ typeof response.result === "string"
297
+ ? response.result
298
+ : JSON.stringify(response.result, null, 2);
299
+
300
+ logger.debug(
301
+ {
302
+ url,
303
+ goal: goal.slice(0, 100),
304
+ browserProfile,
305
+ resultLength: resultStr.length,
306
+ },
307
+ "TinyFish web_agent completed"
308
+ );
309
+
310
+ return {
311
+ ok: true,
312
+ status: 200,
313
+ content: resultStr,
314
+ metadata: {
315
+ url,
316
+ goal,
317
+ browserProfile,
318
+ resultLength: resultStr.length,
319
+ },
320
+ };
321
+ } catch (err) {
322
+ logger.error(
323
+ { err, url, goal: goal.slice(0, 100) },
324
+ "web_agent request failed"
325
+ );
326
+ return {
327
+ ok: false,
328
+ status: err.status ?? 500,
329
+ content: JSON.stringify(
330
+ {
331
+ error: err.code ?? "web_agent_failed",
332
+ message: err.message,
333
+ url,
334
+ ...(err.status ? { http_status: err.status } : {}),
335
+ },
336
+ null,
337
+ 2
338
+ ),
339
+ metadata: {
340
+ url,
341
+ goal,
342
+ error_code: err.code,
343
+ ...(err.status ? { http_status: err.status } : {}),
344
+ },
345
+ };
346
+ }
347
+ },
348
+ { category: "tinyfish" }
349
+ );
350
+ }
351
+
352
+ function registerTinyFishTools() {
353
+ registerTinyFishTool();
354
+ }
355
+
356
+ module.exports = {
357
+ registerTinyFishTools,
358
+ };
@@ -8,6 +8,7 @@ const TRUNCATION_LIMITS = {
8
8
  Glob: { maxChars: 8000, strategy: 'head' },
9
9
  WebFetch: { maxChars: 16000, strategy: 'head' },
10
10
  WebSearch: { maxChars: 12000, strategy: 'head' },
11
+ WebAgent: { maxChars: 16000, strategy: 'head' },
11
12
  LSP: { maxChars: 8000, strategy: 'head' },
12
13
  Edit: { maxChars: 8000, strategy: 'middle' },
13
14
  Write: { maxChars: 8000, strategy: 'middle' },
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Smart Payload Cloning & Size Estimation
3
+ *
4
+ * Optimizes deep-cloning of LLM request payloads to avoid
5
+ * wasting memory on large base64 media blocks that will be
6
+ * discarded by flattenBlocks() for most providers.
7
+ *
8
+ * @module utils/payload
9
+ */
10
+
11
+ const logger = require('../logger');
12
+
13
+ /**
14
+ * Estimate the byte size of message content without full serialization.
15
+ * Scans for base64 image/audio data blocks and text blocks.
16
+ *
17
+ * @param {Object} payload - Request payload
18
+ * @returns {number} Estimated size in bytes
19
+ */
20
+ function estimateContentSize(payload) {
21
+ if (!payload || !Array.isArray(payload.messages)) return 0;
22
+
23
+ let size = 0;
24
+ for (const msg of payload.messages) {
25
+ if (!msg) continue;
26
+
27
+ if (typeof msg.content === 'string') {
28
+ size += msg.content.length;
29
+ continue;
30
+ }
31
+
32
+ if (!Array.isArray(msg.content)) continue;
33
+
34
+ for (const block of msg.content) {
35
+ if (!block || typeof block !== 'object') continue;
36
+
37
+ if (block.text) {
38
+ size += block.text.length;
39
+ }
40
+ // Anthropic image format
41
+ if (block.source?.data) {
42
+ size += block.source.data.length;
43
+ }
44
+ // OpenAI image_url format (inline base64)
45
+ if (block.image_url?.url && block.image_url.url.startsWith('data:')) {
46
+ size += block.image_url.url.length;
47
+ }
48
+ // tool_result content
49
+ if (block.type === 'tool_result' && typeof block.content === 'string') {
50
+ size += block.content.length;
51
+ }
52
+ }
53
+ }
54
+
55
+ return size;
56
+ }
57
+
58
+ /**
59
+ * Check if any message content block has base64 media data exceeding threshold.
60
+ *
61
+ * @param {Object} payload - Request payload
62
+ * @param {number} threshold - Size threshold in bytes (default 1MB)
63
+ * @returns {boolean}
64
+ */
65
+ function hasLargeMedia(payload, threshold = 1_048_576) {
66
+ if (!payload || !Array.isArray(payload.messages)) return false;
67
+
68
+ for (const msg of payload.messages) {
69
+ if (!msg || !Array.isArray(msg.content)) continue;
70
+
71
+ for (const block of msg.content) {
72
+ if (!block || typeof block !== 'object') continue;
73
+
74
+ // Anthropic base64 image
75
+ if (block.source?.data && block.source.data.length > threshold) {
76
+ return true;
77
+ }
78
+ // OpenAI inline base64 image
79
+ if (block.image_url?.url && block.image_url.url.length > threshold) {
80
+ return true;
81
+ }
82
+ }
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ // Block types that flattenBlocks() discards (returns empty string)
89
+ const HEAVY_BLOCK_TYPES = new Set(['image', 'audio', 'image_url', 'video']);
90
+
91
+ /**
92
+ * Check if a content block is a heavy media block that flattenBlocks() discards.
93
+ * @param {Object} block
94
+ * @returns {boolean}
95
+ */
96
+ function isHeavyMediaBlock(block) {
97
+ if (!block || typeof block !== 'object') return false;
98
+ if (HEAVY_BLOCK_TYPES.has(block.type)) return true;
99
+ if (block.source?.type === 'base64') return true;
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Clone payload optimized for providers that will flatten content.
105
+ * Skips cloning heavy media blocks since flattenBlocks() discards them.
106
+ *
107
+ * @param {Object} payload
108
+ * @returns {Object} Cloned payload with media placeholders
109
+ */
110
+ function cloneWithFlattenAwareness(payload) {
111
+ const clean = { ...payload };
112
+
113
+ // Deep-clone messages array but skip heavy media blocks
114
+ if (Array.isArray(payload.messages)) {
115
+ clean.messages = payload.messages.map(msg => {
116
+ if (!msg) return msg;
117
+ const cloned = { ...msg };
118
+
119
+ if (Array.isArray(msg.content)) {
120
+ cloned.content = msg.content.map(block => {
121
+ if (!block || typeof block !== 'object') return block;
122
+
123
+ // Skip heavy media blocks — flattenBlocks() produces "" for these
124
+ if (isHeavyMediaBlock(block)) {
125
+ return { type: block.type, _skipped: true };
126
+ }
127
+
128
+ // Shallow clone small blocks (text, tool_result, tool_use)
129
+ if (block.type === 'tool_result' && typeof block.content === 'object') {
130
+ return { ...block, content: JSON.parse(JSON.stringify(block.content)) };
131
+ }
132
+ return { ...block };
133
+ });
134
+ } else if (typeof msg.content === 'object' && msg.content !== null) {
135
+ cloned.content = { ...msg.content };
136
+ }
137
+
138
+ // Clone tool_calls if present
139
+ if (Array.isArray(msg.tool_calls)) {
140
+ cloned.tool_calls = JSON.parse(JSON.stringify(msg.tool_calls));
141
+ }
142
+
143
+ return cloned;
144
+ });
145
+ }
146
+
147
+ // Deep-clone small arrays that get mutated
148
+ if (Array.isArray(payload.tools)) {
149
+ clean.tools = JSON.parse(JSON.stringify(payload.tools));
150
+ }
151
+ if (Array.isArray(payload.system)) {
152
+ clean.system = JSON.parse(JSON.stringify(payload.system));
153
+ } else if (typeof payload.system === 'string') {
154
+ clean.system = payload.system;
155
+ }
156
+
157
+ return clean;
158
+ }
159
+
160
+ /**
161
+ * Smart deep-clone a request payload.
162
+ *
163
+ * - If willFlatten is true: skips cloning heavy media blocks (they'll be discarded)
164
+ * - If willFlatten is false: uses structuredClone (faster than JSON round-trip)
165
+ * - Falls back to JSON.parse(JSON.stringify()) for compatibility
166
+ *
167
+ * @param {Object} payload - Request payload to clone
168
+ * @param {Object} options
169
+ * @param {boolean} options.willFlatten - Whether flattenBlocks() will run (true for most providers)
170
+ * @returns {Object} Cloned payload
171
+ */
172
+ function clonePayloadSmart(payload, options = {}) {
173
+ if (!payload) return {};
174
+
175
+ const { willFlatten = false } = options;
176
+
177
+ // Fast path: provider will flatten content — skip cloning media blocks
178
+ if (willFlatten && Array.isArray(payload.messages)) {
179
+ const hasMedia = payload.messages.some(msg =>
180
+ Array.isArray(msg?.content) && msg.content.some(isHeavyMediaBlock)
181
+ );
182
+ if (hasMedia) {
183
+ logger.debug('[payload] Using flatten-aware clone (skipping media blocks)');
184
+ return cloneWithFlattenAwareness(payload);
185
+ }
186
+ }
187
+
188
+ // Medium path: structuredClone (faster, no string intermediate)
189
+ if (typeof structuredClone === 'function') {
190
+ try {
191
+ return structuredClone(payload);
192
+ } catch {
193
+ // structuredClone can fail on functions, symbols, etc.
194
+ }
195
+ }
196
+
197
+ // Slow path: JSON round-trip (original behavior)
198
+ return JSON.parse(JSON.stringify(payload));
199
+ }
200
+
201
+ module.exports = {
202
+ estimateContentSize,
203
+ hasLargeMedia,
204
+ clonePayloadSmart,
205
+ isHeavyMediaBlock,
206
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Request Performance Timer
3
+ *
4
+ * Lightweight timing instrumentation for the request hot path.
5
+ * Enable with LOG_LEVEL=debug or PERF_TIMER=true to see per-request
6
+ * breakdown of where time is spent.
7
+ *
8
+ * Usage:
9
+ * const timer = createTimer('processMessage');
10
+ * timer.mark('sanitizePayload');
11
+ * // ... do work ...
12
+ * timer.mark('cacheCheck');
13
+ * // ... do work ...
14
+ * timer.done(); // logs full breakdown
15
+ *
16
+ * @module utils/perf-timer
17
+ */
18
+
19
+ const { performance } = require('perf_hooks');
20
+ const logger = require('../logger');
21
+
22
+ const ENABLED = process.env.PERF_TIMER === 'true';
23
+
24
+ /**
25
+ * Create a performance timer for a named operation.
26
+ * @param {string} name - Timer name (e.g., 'processMessage', 'invokeModel')
27
+ * @returns {{ mark: (label: string) => void, done: () => Object }}
28
+ */
29
+ function createTimer(name) {
30
+ if (!ENABLED) {
31
+ // No-op when disabled — zero overhead
32
+ return {
33
+ mark() {},
34
+ done() { return null; },
35
+ };
36
+ }
37
+
38
+ const start = performance.now();
39
+ const marks = [];
40
+ let lastMark = start;
41
+
42
+ return {
43
+ /**
44
+ * Record a checkpoint.
45
+ * @param {string} label - What just completed
46
+ */
47
+ mark(label) {
48
+ const now = performance.now();
49
+ marks.push({
50
+ label,
51
+ elapsed: now - lastMark,
52
+ cumulative: now - start,
53
+ });
54
+ lastMark = now;
55
+ },
56
+
57
+ /**
58
+ * Finish timing and log the breakdown.
59
+ * @returns {Object} Timing breakdown
60
+ */
61
+ done() {
62
+ const total = performance.now() - start;
63
+ const breakdown = {};
64
+
65
+ for (const m of marks) {
66
+ breakdown[m.label] = `${m.elapsed.toFixed(2)}ms`;
67
+ }
68
+
69
+ logger.info({
70
+ timer: name,
71
+ totalMs: total.toFixed(2),
72
+ breakdown,
73
+ }, `[perf] ${name}: ${total.toFixed(1)}ms`);
74
+
75
+ return { name, totalMs: total, marks, breakdown };
76
+ },
77
+ };
78
+ }
79
+
80
+ module.exports = { createTimer };
@@ -1,15 +0,0 @@
1
- # These are supported funding model platforms
2
-
3
- github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
- patreon: # Replace with a single Patreon username
5
- open_collective: # Replace with a single Open Collective username
6
- ko_fi: # Replace with a single Ko-fi username
7
- tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
- community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
- liberapay: # Replace with a single Liberapay username
10
- issuehunt: # Replace with a single IssueHunt username
11
- lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
- polar: # Replace with a single Polar username
13
- buy_me_a_coffee: srinivasveera
14
- thanks_dev: # Replace with a single thanks.dev username
15
- custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']