ocuclaw 1.3.2 → 1.3.4

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 (84) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +93 -0
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +58 -63
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/skills/glasses-ui/SKILL.md +19 -3
  84. package/dist/runtime/protocol-adapter.js +0 -387
@@ -2,8 +2,6 @@ import { filterDisplayEmojiText } from "./message-emoji-filter.js";
2
2
  import { stripAllTaggedSpans } from "./tagged-span-strip.js";
3
3
  import { marked } from "marked";
4
4
 
5
- // --- Constants ---
6
-
7
5
  const DEFAULT_AGENT_NAME = "Agent";
8
6
  const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
9
7
  const REPLY_DIRECTIVE_SENTINEL = "\u0000";
@@ -22,7 +20,6 @@ const SYNTHETIC_SESSION_INSTRUCTION_PATTERNS = [
22
20
  /\bdo not mention\b/,
23
21
  /\binternal\b.*\b(?:steps|files|tools|reasoning)\b/,
24
22
  ];
25
- // --- State ---
26
23
 
27
24
  let messages = [];
28
25
  let agentName = DEFAULT_AGENT_NAME;
@@ -30,13 +27,6 @@ let displayEntries = [];
30
27
  let cachedTranscript = "";
31
28
  let transcriptDirty = false;
32
29
 
33
- /**
34
- * Build a display entry for a single message, or null if filtered out.
35
- *
36
- * @param {{role: string, content: string|Array, name?: string}} msg
37
- * @param {{isFirstVisibleEntry?: boolean}} [options]
38
- * @returns {{role: "user"|"assistant", text: string, name: string|null}|null}
39
- */
40
30
  function buildDisplayEntry(msg, options = {}) {
41
31
  if (!msg || (msg.role !== "user" && msg.role !== "assistant")) return null;
42
32
 
@@ -83,10 +73,6 @@ function countSyntheticSessionInstructionSignals(normalizedText) {
83
73
  return count;
84
74
  }
85
75
 
86
- /**
87
- * Detect OpenClaw's synthetic bare /new or /reset starter prompt.
88
- * This is heuristic-based (signals + shape), not exact whole-string matching.
89
- */
90
76
  function isLikelySyntheticSessionStarterPrompt(text) {
91
77
  const normalized = normalizeSessionStarterCandidate(text);
92
78
  if (!normalized) return false;
@@ -100,21 +86,12 @@ function isLikelySyntheticSessionStarterPrompt(text) {
100
86
  return countSyntheticSessionInstructionSignals(normalized) >= 2;
101
87
  }
102
88
 
103
- /**
104
- * Format a display entry with role prefix.
105
- *
106
- * @param {{role: "user"|"assistant", text: string, name: string|null}} entry
107
- * @returns {string}
108
- */
109
89
  function formatEntry(entry) {
110
90
  if (entry.role === "user") return `• ${entry.text}`;
111
91
  const name = entry.name || agentName;
112
92
  return `${name}: ${entry.text}`;
113
93
  }
114
94
 
115
- /**
116
- * Rebuild derived display cache from raw messages.
117
- */
118
95
  function rebuildDisplayCache() {
119
96
  displayEntries = [];
120
97
  for (const msg of messages) {
@@ -126,11 +103,6 @@ function rebuildDisplayCache() {
126
103
  transcriptDirty = true;
127
104
  }
128
105
 
129
- /**
130
- * Return the current transcript string, rebuilding lazily if needed.
131
- *
132
- * @returns {string}
133
- */
134
106
  function getTranscript() {
135
107
  if (!transcriptDirty) return cachedTranscript;
136
108
  cachedTranscript = displayEntries.map((entry) => formatEntry(entry)).join("\n\n");
@@ -138,12 +110,6 @@ function getTranscript() {
138
110
  return cachedTranscript;
139
111
  }
140
112
 
141
- // --- Markdown Pipeline ---
142
-
143
- /**
144
- * Remove OpenClaw inline reply directives from assistant text before display.
145
- * These control transport threading and should never be user-visible.
146
- */
147
113
  function stripReplyDirectives(text) {
148
114
  if (!text) return "";
149
115
 
@@ -162,9 +128,6 @@ function stripReplyDirectives(text) {
162
128
  .trimEnd();
163
129
  }
164
130
 
165
- /**
166
- * Extract plain text from an array of marked inline tokens.
167
- */
168
131
  function renderInlineTokens(tokens) {
169
132
  let out = "";
170
133
  for (const token of tokens) {
@@ -186,13 +149,13 @@ function renderInlineTokens(tokens) {
186
149
  out += token.text || "";
187
150
  break;
188
151
  case "html":
189
- // Strip inline HTML tags
152
+
190
153
  break;
191
154
  case "image":
192
155
  out += token.text || token.title || "";
193
156
  break;
194
157
  default:
195
- // Unknown inline token — include raw text if available
158
+
196
159
  if (token.text) out += token.text;
197
160
  break;
198
161
  }
@@ -200,10 +163,6 @@ function renderInlineTokens(tokens) {
200
163
  return out;
201
164
  }
202
165
 
203
- /**
204
- * Extract plain text from an array of marked block tokens.
205
- * Returns an array of text blocks (each block is a paragraph-level chunk).
206
- */
207
166
  function renderBlockTokens(tokens) {
208
167
  const blocks = [];
209
168
 
@@ -218,10 +177,7 @@ function renderBlockTokens(tokens) {
218
177
  break;
219
178
 
220
179
  case "text":
221
- // Block-level text token (marked wraps list-item content in these).
222
- // Without this case it falls through to default, which recurses over
223
- // the INLINE children as blocks — putting every bold/code span on its
224
- // own line once the list join("\n") runs.
180
+
225
181
  blocks.push(token.tokens ? renderInlineTokens(token.tokens) : token.text);
226
182
  break;
227
183
 
@@ -251,11 +207,11 @@ function renderBlockTokens(tokens) {
251
207
 
252
208
  case "table": {
253
209
  const rows = [];
254
- // Header row
210
+
255
211
  rows.push(
256
212
  token.header.map((cell) => renderInlineTokens(cell.tokens)).join(" | ")
257
213
  );
258
- // Data rows
214
+
259
215
  for (const row of token.rows) {
260
216
  rows.push(
261
217
  row.map((cell) => renderInlineTokens(cell.tokens)).join(" | ")
@@ -266,19 +222,19 @@ function renderBlockTokens(tokens) {
266
222
  }
267
223
 
268
224
  case "hr":
269
- // Skip horizontal rules
225
+
270
226
  break;
271
227
 
272
228
  case "space":
273
- // Skip whitespace tokens
229
+
274
230
  break;
275
231
 
276
232
  case "html":
277
- // Strip block-level HTML
233
+
278
234
  break;
279
235
 
280
236
  default:
281
- // Unknown block token — include text if available
237
+
282
238
  if (token.tokens) {
283
239
  blocks.push(...renderBlockTokens(token.tokens));
284
240
  } else if (token.text) {
@@ -297,14 +253,6 @@ function cleanupDisplayWhitespace(text) {
297
253
  .replace(/[ \t]+$/gm, "");
298
254
  }
299
255
 
300
- /**
301
- * Convert Markdown text to plain text.
302
- * Uses marked.lexer to parse, then walks the AST to extract text.
303
- *
304
- * @param {string} markdown
305
- * @param {{ stripReplyTags?: boolean }} [options]
306
- * @returns {{ text: string }}
307
- */
308
256
  function markdownToPlainText(markdown, options = {}) {
309
257
  if (!markdown) return { text: "" };
310
258
  const source = options.stripReplyTags ? stripReplyDirectives(markdown) : markdown;
@@ -319,15 +267,6 @@ function markdownToPlainText(markdown, options = {}) {
319
267
  return { text };
320
268
  }
321
269
 
322
- // --- Content Extraction ---
323
-
324
- /**
325
- * Extract displayable text from a message's content field.
326
- * Content can be a string or an array of content blocks.
327
- *
328
- * @param {string|Array} content
329
- * @returns {string|null} Text content, or null if no displayable text
330
- */
331
270
  function extractText(content) {
332
271
  if (typeof content === "string") {
333
272
  return content || null;
@@ -352,12 +291,6 @@ function extractText(content) {
352
291
  return `[Image] ${text}`;
353
292
  }
354
293
 
355
- // --- Message Filtering ---
356
-
357
- /**
358
- * Filter messages for display and format them with name prefixes.
359
- * Returns an array of formatted message strings.
360
- */
361
294
  function filterAndFormat() {
362
295
  return displayEntries.map((entry) => ({
363
296
  text: formatEntry(entry),
@@ -365,16 +298,6 @@ function filterAndFormat() {
365
298
  }));
366
299
  }
367
300
 
368
- // --- Turn Grouping ---
369
-
370
- /**
371
- * Group chronological formatted messages into turns.
372
- * Each user message starts a new turn. Assistant messages before the first
373
- * user message form their own turn.
374
- *
375
- * @param {Array<{text: string, role: string}>} formatted
376
- * @returns {Array<Array<{text: string, role: string}>>}
377
- */
378
301
  function groupIntoTurns(formatted) {
379
302
  const turns = [];
380
303
  let current = [];
@@ -394,49 +317,22 @@ function groupIntoTurns(formatted) {
394
317
  return turns;
395
318
  }
396
319
 
397
- // --- Pagination ---
398
-
399
- /**
400
- * Paginate conversation into chronological rolling pages.
401
- * All messages are joined in order, with newest content at the tail of the
402
- * resulting string.
403
- *
404
- * Relay intentionally does not char-split the transcript. Client render code
405
- * owns virtual-page splitting.
406
- *
407
- * @returns {Array<{content: string, subPage: [number, number]|null, turn: null}>}
408
- */
409
320
  function paginate() {
410
321
  if (displayEntries.length === 0) return [];
411
322
 
412
- // Join all messages chronologically (no reversal)
413
323
  const allText = getTranscript();
414
324
 
415
325
  return [{ content: allText, subPage: null, turn: null }];
416
326
  }
417
327
 
418
- // --- Public API ---
419
-
420
328
  const conversationState = {
421
- /**
422
- * Bulk load messages from chat.history and set agent name.
423
- *
424
- * @param {Array<{role: string, content: string|Array}>} msgs
425
- * @param {string} [name] - Agent name for display prefix
426
- */
329
+
427
330
  hydrate(msgs, name) {
428
331
  messages = Array.isArray(msgs) ? [...msgs] : [];
429
332
  if (name) agentName = name;
430
333
  rebuildDisplayCache();
431
334
  },
432
335
 
433
- /**
434
- * Append a single message.
435
- *
436
- * @param {string} role
437
- * @param {string|Array} content
438
- * @param {string} [name] - Optional display name override (used for simulate)
439
- */
440
336
  addMessage(role, content, name) {
441
337
  const msg = { role, content };
442
338
  if (name) msg.name = name;
@@ -457,11 +353,22 @@ const conversationState = {
457
353
  }
458
354
  },
459
355
 
460
- /**
461
- * Update the agent name prefix used in formatted output.
462
- *
463
- * @param {string} name
464
- */
356
+ replaceLatestUserMessage(content, name) {
357
+ let index = messages.length - 1;
358
+ while (index >= 0 && messages[index].role !== "user") {
359
+ index -= 1;
360
+ }
361
+
362
+ const msg = { role: "user", content };
363
+ if (name) msg.name = name;
364
+
365
+ if (index >= 0) {
366
+ messages = messages.slice(0, index);
367
+ }
368
+ messages.push(msg);
369
+ rebuildDisplayCache();
370
+ },
371
+
465
372
  setAgentName(name) {
466
373
  const next = name || DEFAULT_AGENT_NAME;
467
374
  if (agentName === next) return;
@@ -469,36 +376,18 @@ const conversationState = {
469
376
  transcriptDirty = true;
470
377
  },
471
378
 
472
- /**
473
- * Return paginated, filtered, markdown-stripped page array.
474
- *
475
- * @returns {Array<{content: string, subPage: [number, number]|null}>}
476
- */
477
379
  getPages() {
478
380
  return paginate();
479
381
  },
480
382
 
481
- /**
482
- * Return the number of pages.
483
- *
484
- * @returns {number}
485
- */
486
383
  getPageCount() {
487
384
  return displayEntries.length > 0 ? 1 : 0;
488
385
  },
489
386
 
490
- /**
491
- * Return the full unfiltered transcript.
492
- *
493
- * @returns {Array<{role: string, content: string|Array}>}
494
- */
495
387
  getRawMessages() {
496
388
  return [...messages];
497
389
  },
498
390
 
499
- /**
500
- * Reset all state.
501
- */
502
391
  clear() {
503
392
  messages = [];
504
393
  agentName = DEFAULT_AGENT_NAME;
@@ -507,7 +396,6 @@ const conversationState = {
507
396
  transcriptDirty = false;
508
397
  },
509
398
 
510
- // Exposed for testing
511
399
  _markdownToPlainText: markdownToPlainText,
512
400
  _extractText: extractText,
513
401
  _isLikelySyntheticSessionStarterPrompt: isLikelySyntheticSessionStarterPrompt,
@@ -516,6 +404,7 @@ const conversationState = {
516
404
  export const {
517
405
  hydrate,
518
406
  addMessage,
407
+ replaceLatestUserMessage,
519
408
  setAgentName,
520
409
  getPages,
521
410
  getPageCount,
@@ -0,0 +1,52 @@
1
+ export function createBundleCache(opts) {
2
+ const maxEntries = Math.max(1, opts.maxEntries | 0);
3
+ const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs >= 0 ? opts.ttlMs : 5 * 60_000;
4
+ const now = typeof opts.now === "function" ? opts.now : () => Date.now();
5
+
6
+ const store = new Map();
7
+
8
+ function isExpired(entry) {
9
+ if (ttlMs === 0) return false;
10
+ return now() - entry.cachedMs > ttlMs;
11
+ }
12
+
13
+ function dropExpired() {
14
+ for (const [id, entry] of store) {
15
+ if (isExpired(entry)) store.delete(id);
16
+ }
17
+ }
18
+
19
+ function put(id, entry) {
20
+ dropExpired();
21
+ if (store.has(id)) store.delete(id);
22
+ store.set(id, entry);
23
+ while (store.size > maxEntries) {
24
+ const firstKey = store.keys().next().value;
25
+ store.delete(firstKey);
26
+ }
27
+ }
28
+
29
+ function get(id) {
30
+ const entry = store.get(id);
31
+ if (!entry) return null;
32
+ if (isExpired(entry)) {
33
+ store.delete(id);
34
+ return null;
35
+ }
36
+ return entry;
37
+ }
38
+
39
+ function del(id) {
40
+ store.delete(id);
41
+ }
42
+
43
+ function size() {
44
+ return store.size;
45
+ }
46
+
47
+ function sweep() {
48
+ dropExpired();
49
+ }
50
+
51
+ return { put, get, delete: del, size, sweep };
52
+ }
@@ -0,0 +1,60 @@
1
+ export function sanitizeCategoryFilename(cat) {
2
+ return cat.replace(/\./g, "-") + ".jsonl";
3
+ }
4
+
5
+ export function bucketEventsToFiles(input) {
6
+ const { events, ringEvents, ringCapacity, appliedQuery } = input;
7
+ const byCategory = new Map();
8
+ for (const evt of events) {
9
+ if (!byCategory.has(evt.cat)) byCategory.set(evt.cat, []);
10
+ byCategory.get(evt.cat).push(evt);
11
+ }
12
+ const files = new Map();
13
+ const categories = [];
14
+ let overallFrom = null;
15
+ let overallTo = null;
16
+ let totalBytes = 0;
17
+ for (const [cat, list] of byCategory) {
18
+ list.sort((a, b) => a.ts - b.ts || (a.seq || 0) - (b.seq || 0));
19
+ const filename = sanitizeCategoryFilename(cat);
20
+ let bytes = 0;
21
+ let content = "";
22
+ for (const evt of list) {
23
+ const line = JSON.stringify(evt) + "\n";
24
+ content += line;
25
+ bytes += Buffer.byteLength(line, "utf8");
26
+ }
27
+ files.set(filename, content);
28
+ totalBytes += bytes;
29
+ const fromMs = list[0].ts;
30
+ const toMs = list[list.length - 1].ts;
31
+ if (overallFrom === null || fromMs < overallFrom) overallFrom = fromMs;
32
+ if (overallTo === null || toMs > overallTo) overallTo = toMs;
33
+ categories.push({ cat, count: list.length, bytes, fromMs, toMs, file: filename });
34
+ }
35
+ const summary = {
36
+ ringEvents, ringCapacity, totalBytes,
37
+ timeRange: overallFrom !== null && overallTo !== null
38
+ ? { fromMs: overallFrom, toMs: overallTo, spanMs: overallTo - overallFrom } : null,
39
+ categories, appliedQuery,
40
+ };
41
+ return { files, summary };
42
+ }
43
+
44
+ export function renderBundleReadme(summary) {
45
+ const lines = [];
46
+ lines.push("# Debug dump", "");
47
+ lines.push("See `.agents/skills/ocuclaw-debug/SKILL.md` → 'Analyzing bucketed dumps' for usage guidance.", "");
48
+ lines.push(`Ring: ${summary.ringEvents} / ${summary.ringCapacity}`);
49
+ lines.push(`Total bytes: ${summary.totalBytes}`);
50
+ if (summary.timeRange) lines.push(`Time range: ${summary.timeRange.fromMs} → ${summary.timeRange.toMs} (${summary.timeRange.spanMs} ms)`);
51
+ lines.push("", "## Buckets", "");
52
+ for (const c of summary.categories) {
53
+ const eventStr = c.count === 1 ? "1 event" : `${c.count} events`;
54
+ lines.push(`- \`${c.file}\` — ${eventStr}, ${c.bytes} bytes, ${c.fromMs} → ${c.toMs}`);
55
+ }
56
+ if (summary.categories.length === 0) lines.push("- (no events matched the query)");
57
+ lines.push("", "## Cross-category timeline", "");
58
+ lines.push("Per-category files are chronological WITHIN a category only. To reconstruct the global stream, merge-sort all `*.jsonl` by `(ts, seq)` — `seq` is the global monotonic tie-break that makes a same-`ts` merge deterministic.");
59
+ return lines.join("\n") + "\n";
60
+ }
@@ -0,0 +1,123 @@
1
+ export function buildBundlePreview(files, opts) {
2
+ const maxEvents = (opts && typeof opts.maxEvents === "number" && opts.maxEvents > 0) ? opts.maxEvents : 15;
3
+ const maxCharsPerEvent = (opts && typeof opts.maxCharsPerEvent === "number" && opts.maxCharsPerEvent > 0) ? opts.maxCharsPerEvent : 80;
4
+
5
+ const SKIP = new Set(["README.md", "metadata.json", "correlation-liveui.jsonl"]);
6
+
7
+ const catParsed = [];
8
+
9
+ const catNames = [];
10
+
11
+ if (files && typeof files.forEach === "function") {
12
+ files.forEach((content, filename) => {
13
+ if (SKIP.has(filename)) return;
14
+ if (!filename.endsWith(".jsonl")) return;
15
+
16
+ const cat = filename.slice(0, -(".jsonl".length));
17
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
18
+
19
+ const parsed = [];
20
+ for (const line of lines) {
21
+ let obj;
22
+ try {
23
+ obj = JSON.parse(line);
24
+ } catch {
25
+
26
+ continue;
27
+ }
28
+ parsed.push(obj);
29
+ }
30
+ if (parsed.length > 0) {
31
+ catParsed.push(parsed);
32
+ catNames.push(cat);
33
+ }
34
+ });
35
+ }
36
+
37
+ const events = [];
38
+
39
+ let totalParsed = 0;
40
+ for (const parsed of catParsed) {
41
+ totalParsed += parsed.length;
42
+ }
43
+
44
+ if (catParsed.length > 0) {
45
+ const indices = new Array(catParsed.length).fill(0);
46
+ let added = 0;
47
+ let anyProgress = true;
48
+ while (added < maxEvents && anyProgress) {
49
+ anyProgress = false;
50
+ for (let ci = 0; ci < catParsed.length && added < maxEvents; ci++) {
51
+ const idx = indices[ci];
52
+ if (idx >= catParsed[ci].length) continue;
53
+ indices[ci] = idx + 1;
54
+ anyProgress = true;
55
+ const parsed = catParsed[ci][idx];
56
+ const ts = (parsed && typeof parsed.ts === "number") ? parsed.ts : 0;
57
+ const cat = (parsed && typeof parsed.cat === "string" && parsed.cat) ? parsed.cat : catNames[ci];
58
+ const text = extractText(parsed, maxCharsPerEvent);
59
+ events.push({ ts, cat, text });
60
+ added++;
61
+ }
62
+ }
63
+ }
64
+
65
+ const truncated = totalParsed > events.length;
66
+
67
+ return { events, truncated };
68
+ }
69
+
70
+ function extractText(parsed, maxChars) {
71
+ if (!parsed) return truncate("", maxChars);
72
+
73
+ const data = parsed.data;
74
+ const eventName = (typeof parsed.event === "string" && parsed.event) ? parsed.event : "";
75
+
76
+ let text = "";
77
+
78
+ if (data && typeof data === "object" && !Array.isArray(data)) {
79
+
80
+ const TEXT_LIKE_KEYS = ["message", "text", "label", "note", "description", "reason", "state", "error"];
81
+ for (const key of TEXT_LIKE_KEYS) {
82
+ if (typeof data[key] === "string" && data[key]) {
83
+ text = data[key];
84
+ break;
85
+ }
86
+ }
87
+ if (!text) {
88
+
89
+ for (const v of Object.values(data)) {
90
+ if (typeof v === "string" && v) {
91
+ text = v;
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ if (!text) {
97
+
98
+ const preview = {};
99
+ let count = 0;
100
+ for (const [k, v] of Object.entries(data)) {
101
+ if (typeof v === "number" || typeof v === "boolean") {
102
+ preview[k] = v;
103
+ if (++count >= 3) break;
104
+ }
105
+ }
106
+ if (count > 0) {
107
+ text = JSON.stringify(preview);
108
+ }
109
+ }
110
+ }
111
+
112
+ if (!text && eventName) {
113
+ text = eventName;
114
+ }
115
+
116
+ return truncate(text, maxChars);
117
+ }
118
+
119
+ function truncate(s, maxChars) {
120
+ if (typeof s !== "string") return "";
121
+ if (s.length <= maxChars) return s;
122
+ return s.slice(0, maxChars) + "…";
123
+ }