polygram 0.38.1 → 0.38.3

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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Typed content-free schema for `events.detail_json`.
3
+ *
4
+ * Telemetry is a durable text sink written from hundreds of call sites, in
5
+ * this repository and in Orchestra, which calls `db.logEvent` directly. A
6
+ * field-name allowlist is not enough on its own: names like `error` or
7
+ * `reason` are permanent invitations for a message body to be logged under an
8
+ * approved name. So every field declares a TYPE, and a value that does not
9
+ * satisfy it is dropped.
10
+ *
11
+ * The permitted types are all content-free by construction:
12
+ *
13
+ * id opaque identifier (session key, turn id, chat id, digest-like)
14
+ * token short enum / code / classification, no spaces
15
+ * int count, length, byte size, duration, epoch
16
+ * number cost, ratio
17
+ * bool flag
18
+ * digest hex fingerprint of a non-secret (a request id, a config)
19
+ * ids bounded array of ids or tokens
20
+ * shape nested object whose own fields are typed the same way
21
+ *
22
+ * There is no free-text type. A caller with something to say says it as a
23
+ * code (`error_code`), a classification (`error_class`), a size
24
+ * (`stderr_len`) or a flag (`quarantined`) — never as prose. Fields that used
25
+ * to carry prose (`error`, `message`, `path`, `old_value`, `value`, `topics`,
26
+ * `stderr_tail`, `pane_tail`, `excerpt_head`, `text`, `original`, `note`,
27
+ * `name`) are absent from the schema, so they are dropped wherever they are
28
+ * logged, including from Orchestra.
29
+ *
30
+ * Dropped field NAMES are reported so a loss is visible; values never are.
31
+ * A field carrying `undefined` is simply absent, not a loss, and is not
32
+ * reported.
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ // Fields deliberately absent. Kept as a list so the intent survives review:
38
+ // each one carried a message body, a reply, a TUI tail or a filesystem path.
39
+ const REMOVED_FIELDS = Object.freeze([
40
+ 'text', 'text_preview', 'original', 'pane_tail', 'paneTail', 'excerpt_head',
41
+ 'note', 'name', 'error', 'message', 'path', 'old_value', 'value', 'topics',
42
+ 'stderr_tail', 'prompt', 'caption', 'transcript', 'body', 'content',
43
+ 'snippet', 'preview', 'answer', 'question', 'raw_truncated', 'parse_error',
44
+ ]);
45
+
46
+ // `trigger` is KEPT, typed as a token: producers use it for a cause label
47
+ // ('boot', 'auto'), and the type rejects the user's own phrasing that once
48
+ // rode in on this name. `name` is not kept — a filename is often a single
49
+ // token, so typing alone would not stop it.
50
+
51
+ const ID = /^[A-Za-z0-9_.:@\/-]{1,128}$/;
52
+ const TOKEN = /^[A-Za-z0-9/][A-Za-z0-9_./:@-]{0,63}$/;
53
+ const DIGEST = /^[a-f0-9]{8,64}$/;
54
+
55
+ const isInt = (v) => Number.isSafeInteger(v);
56
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
57
+
58
+ const TYPES = {
59
+ id: (v) => (typeof v === 'string' && ID.test(v)) || isInt(v),
60
+ token: (v) => typeof v === 'string' && TOKEN.test(v),
61
+ int: isInt,
62
+ number: isNumber,
63
+ bool: (v) => typeof v === 'boolean',
64
+ digest: (v) => typeof v === 'string' && DIGEST.test(v),
65
+ // A code is a short symbol OR a numeric exit/status code — both bounded,
66
+ // neither able to carry a sentence.
67
+ code: (v) => (typeof v === 'string' && TOKEN.test(v)) || isInt(v),
68
+ counts: (v) => {
69
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
70
+ const proto = Object.getPrototypeOf(v);
71
+ if (proto !== Object.prototype && proto !== null) return false;
72
+ const entries = Object.entries(v);
73
+ return entries.length <= 32
74
+ && entries.every(([k, n]) => TOKEN.test(k) && Number.isSafeInteger(n));
75
+ },
76
+ ids: (v) => Array.isArray(v) && v.length <= 64
77
+ && v.every((item) => (typeof item === 'string' && ID.test(item)) || isInt(item)),
78
+ };
79
+
80
+ // Nested shapes. Only these keys may appear inside the named field, with the
81
+ // same typing rules, so a transcript tail cannot ride one level down.
82
+ const SHAPES = {
83
+ busy_probe: {
84
+ busy: 'bool', streaming: 'bool', in_flight: 'bool',
85
+ pending_turns: 'int', captured: 'bool',
86
+ },
87
+ before: { model: 'token', effort: 'token', agent: 'token', pm: 'token' },
88
+ after: { model: 'token', effort: 'token', agent: 'token', pm: 'token' },
89
+ };
90
+
91
+ /**
92
+ * field name → type. Grouped by where the field comes from so the list stays
93
+ * reviewable: Orchestra's process/guard telemetry lands here under its own
94
+ * names, unchanged.
95
+ */
96
+ const FIELDS = {
97
+ // ── identity / routing ──────────────────────────────────────────────
98
+ bot: 'token',
99
+ bot_name: 'token',
100
+ chat_id: 'id',
101
+ thread_id: 'id',
102
+ session_key: 'id',
103
+ session_id: 'id',
104
+ claude_session_id: 'id',
105
+ provider_session_id: 'id',
106
+ msg_id: 'id',
107
+ message_id: 'id',
108
+ source_msg_id: 'id',
109
+ source_message_id: 'id',
110
+ target_msg_id: 'id',
111
+ anchor_msg_id: 'id',
112
+ update_id: 'id',
113
+ turn_id: 'id',
114
+ tool_call_id: 'id',
115
+ tool_use_id: 'id',
116
+ attempt_id: 'id',
117
+ target_attempt_id: 'id',
118
+ retry_attempt_id: 'id',
119
+ generation_id: 'id',
120
+ request_id: 'id',
121
+ restart_request_id: 'id',
122
+ restartRequestId: 'id',
123
+ invocationId: 'id',
124
+ reservation_id: 'id',
125
+ linked_input_id: 'id',
126
+ item_id: 'id',
127
+ agent_id: 'id',
128
+ daemon_instance_id: 'id',
129
+ invocation_id: 'id',
130
+ pid: 'int',
131
+ tmux_name: 'id',
132
+ tmux_session: 'id',
133
+ user_id: 'id',
134
+ // The Telegram display name is user-controlled free text, so it is not a
135
+ // telemetry field: `user_id` identifies the actor without it.
136
+ preview_turn_id: 'id',
137
+ reply_turn_id: 'id',
138
+ resumed_session_id: 'id',
139
+ threadId: 'id',
140
+ from_user: 'id',
141
+ from_chat: 'id',
142
+ by: 'id',
143
+ owner: 'id',
144
+ id: 'id',
145
+ new_id: 'id',
146
+ old_chat_id: 'id',
147
+ new_chat_id: 'id',
148
+ original_thread_id: 'id',
149
+ requested_thread_id: 'id',
150
+ echoed_thread_id: 'id',
151
+ expected: 'id',
152
+ topic_ids: 'ids',
153
+ pending_turn_ids: 'ids',
154
+ fields: 'ids',
155
+ reasons: 'ids',
156
+ signals: 'ids',
157
+
158
+ // ── classification / state ──────────────────────────────────────────
159
+ kind: 'token',
160
+ action: 'token',
161
+ status: 'token',
162
+ state: 'token',
163
+ from_state: 'token',
164
+ to_state: 'token',
165
+ decision: 'token',
166
+ behavior: 'token',
167
+ outcome: 'token',
168
+ outcome_code: 'code',
169
+ disposition: 'token',
170
+ phase: 'token',
171
+ stage: 'token',
172
+ branch: 'token',
173
+ reason: 'token',
174
+ backend: 'token',
175
+ provider: 'token',
176
+ model: 'token',
177
+ from_model: 'token',
178
+ effort: 'token',
179
+ from_effort: 'token',
180
+ agent: 'token',
181
+ agent_type: 'token',
182
+ pipeline: 'token',
183
+ source: 'token',
184
+ session_source: 'token',
185
+ transport: 'token',
186
+ method: 'token',
187
+ match_type: 'token',
188
+ scope: 'token',
189
+ field: 'token',
190
+ command: 'token',
191
+ command_kind: 'token',
192
+ cancel_mode: 'token',
193
+ kill_reason: 'token',
194
+ shutdown_reason: 'token',
195
+ restart_trigger: 'token',
196
+ trigger: 'token',
197
+ hook_event_name: 'token',
198
+ // SDK hook telemetry: the hook's own classification, whether a Stop hook
199
+ // was already active, and when the daemon received it. All bounded; the
200
+ // hook's payload body is not a field here.
201
+ hook_type: 'token',
202
+ stop_hook_active: 'bool',
203
+ received_at_ms: 'int',
204
+ parse_error_code: 'code',
205
+ result_subtype: 'token',
206
+ subtype: 'token',
207
+ type: 'token',
208
+ tier: 'token',
209
+ priority: 'token',
210
+ language: 'token',
211
+ api_root: 'token',
212
+ tool: 'token',
213
+ tool_name: 'token',
214
+ via: 'token',
215
+ migration: 'token',
216
+ process_state: 'token',
217
+ package_version: 'token',
218
+ from_emoji: 'token',
219
+ to_emoji: 'token',
220
+ error_code: 'code',
221
+ error_class: 'code',
222
+ error_name: 'code',
223
+ errCode: 'code',
224
+ api_error_code: 'code',
225
+ queue_drain_error_code: 'code',
226
+ stop_error_code: 'code',
227
+ feedback_clear_error_code: 'code',
228
+ cause_code: 'code',
229
+ code: 'code',
230
+ prev: 'token',
231
+ next: 'token',
232
+ value_type: 'token',
233
+ when: 'token',
234
+
235
+ // ── digests (of non-secret material) ────────────────────────────────
236
+ restart_request_sha256: 'digest',
237
+ generation_digest: 'digest',
238
+ attempt_id_hash: 'digest',
239
+ from_hint_hash: 'digest',
240
+ to_hint_hash: 'digest',
241
+
242
+ // ── sizes, counts, timings ──────────────────────────────────────────
243
+ text_len: 'int',
244
+ original_len: 'int',
245
+ final_len: 'int',
246
+ reply_len: 'int',
247
+ rescued_len: 'int',
248
+ ack_len: 'int',
249
+ prev_len: 'int',
250
+ new_len: 'int',
251
+ old_len: 'int',
252
+ len: 'int',
253
+ length: 'int',
254
+ error_len: 'int',
255
+ stderr_len: 'int',
256
+ parse_error_len: 'int',
257
+ raw_len: 'int',
258
+ chars: 'int',
259
+ char_count: 'int',
260
+ bytes: 'int',
261
+ byte_size: 'int',
262
+ size: 'int',
263
+ transcript_bytes: 'int',
264
+ post_tokens: 'int',
265
+ pre_tokens: 'int',
266
+ count: 'int',
267
+ total: 'int',
268
+ cap: 'int',
269
+ default: 'int',
270
+ diagnostic: 'int',
271
+ limit: 'int',
272
+ threshold: 'int',
273
+ before: 'shape',
274
+ after: 'shape',
275
+ row_count: 'int',
276
+ chunks: 'int',
277
+ replies: 'int',
278
+ reply_count: 'int',
279
+ receipt_count: 'int',
280
+ interim_count: 'int',
281
+ block_count: 'int',
282
+ media_count: 'int',
283
+ photo_count: 'int',
284
+ video_count: 'int',
285
+ animation_count: 'int',
286
+ shell_count: 'int',
287
+ open_count: 'int',
288
+ pending_count: 'int',
289
+ pending_turns: 'int',
290
+ queue_len: 'int',
291
+ queue_cap: 'int',
292
+ accepted_count: 'int',
293
+ rejected_count: 'int',
294
+ dropped_count: 'int',
295
+ dropped_turns: 'int',
296
+ deleted_attempts: 'int',
297
+ deleted_generations: 'int',
298
+ drained_pendings: 'int',
299
+ resume_intents_recorded: 'int',
300
+ topics_cleared: 'int',
301
+ imported: 'int',
302
+ files: 'int', // the inbox sweep's file COUNT, not a list of names
303
+ q_index: 'int',
304
+ calls: 'int',
305
+ attempt: 'int',
306
+ active_turn_count: 'int',
307
+ pending_delivery_count: 'int',
308
+ background_owner_count: 'int',
309
+ background_terminal_count: 'int',
310
+ num_assistant_messages: 'int',
311
+ num_tool_uses: 'int',
312
+ policy_version: 'int',
313
+ retention_days: 'int',
314
+ days_left: 'int',
315
+ oom_kill_delta: 'int',
316
+ observed_at_ms: 'int',
317
+ expected_activity_epoch: 'int',
318
+ observed_activity_epoch: 'int',
319
+ original_ts: 'int',
320
+ ts: 'int',
321
+ duration_ms: 'int',
322
+ duration_sec: 'number',
323
+ elapsed_ms: 'int',
324
+ elapsed: 'number',
325
+ idle_ms: 'int',
326
+ lag_ms: 'int',
327
+ stall_ms: 'int',
328
+ wait_ms: 'int',
329
+ window_ms: 'int',
330
+ timeout_ms: 'int',
331
+ turn_timeout_ms: 'int',
332
+ hard_backstop_ms: 'int',
333
+ retry_after_ms: 'int',
334
+ last_hook_age_ms: 'int',
335
+ refresh_token_expires_at: 'int',
336
+ after_failures: 'int',
337
+ failures: 'int',
338
+ cost_usd: 'number',
339
+ cost: 'number',
340
+ total_cost: 'number',
341
+ budget: 'number',
342
+ pct: 'number',
343
+
344
+ // ── flags ───────────────────────────────────────────────────────────
345
+ ok: 'bool',
346
+ applied: 'bool',
347
+ aborted: 'bool',
348
+ ambiguous: 'bool',
349
+ active: 'int', // a live-process COUNT, not a flag
350
+ attempted: 'bool',
351
+ attributed: 'bool',
352
+ already_delivered: 'bool',
353
+ busy: 'bool',
354
+ busy_probe: 'shape',
355
+ captured: 'bool',
356
+ clean: 'bool',
357
+ closed: 'bool',
358
+ consumed_acked: 'bool',
359
+ continuation_authorized: 'bool',
360
+ deferred: 'bool',
361
+ deferred_for_subagent: 'bool',
362
+ deletion_failed: 'bool',
363
+ delivered: 'bool',
364
+ eligible: 'bool',
365
+ exact_match: 'bool',
366
+ failed: 'bool',
367
+ first_event: 'bool',
368
+ had_active: 'bool',
369
+ had_stop: 'bool',
370
+ has_stop_data: 'bool',
371
+ in_flight: 'bool',
372
+ in_flight_at_signal: 'bool',
373
+ interim_only: 'bool',
374
+ is_error: 'bool',
375
+ killed_background_shell: 'bool',
376
+ media_delivered: 'bool',
377
+ media_failed: 'bool',
378
+ noticed: 'bool',
379
+ notice_failed: 'bool',
380
+ oom_shutdown: 'bool',
381
+ pinned: 'bool',
382
+ quarantined: 'bool',
383
+ queue_drained: 'bool',
384
+ recovered: 'bool',
385
+ replay: 'bool',
386
+ replay_marked: 'bool',
387
+ requested: 'bool',
388
+ rescued: 'bool',
389
+ resumed: 'bool',
390
+ sent: 'bool',
391
+ should_query: 'bool',
392
+ skipped: 'bool',
393
+ stop_verified: 'bool',
394
+ streaming: 'bool',
395
+ stopped: 'bool',
396
+ text_failed: 'bool',
397
+ via_text_fallback: 'bool',
398
+ background_terminal_registry_complete: 'bool',
399
+
400
+ // ── background sweep + lifecycle diagnostics ────────────────────────
401
+ // The secret sweep's own summary: counts of what it scanned, changed and
402
+ // flagged, plus per-rule counts. Nothing here is derived from a secret's
403
+ // value.
404
+ scanned: 'int',
405
+ redactedMsgs: 'int',
406
+ redactions: 'int',
407
+ flagged: 'int',
408
+ ruleCounts: 'counts',
409
+ dryRun: 'bool',
410
+ reachedCap: 'bool',
411
+ remaining: 'int',
412
+ deleted: 'bool',
413
+ dropped: 'bool',
414
+ overflow: 'bool',
415
+
416
+ // ── Orchestra's camelCase counters and identifiers ──────────────────
417
+ // Orchestra logs directly into this sink and names some fields in camel
418
+ // case. They are the same content-free counters and ids as their snake_case
419
+ // neighbours; listing them keeps its telemetry intact without asking an
420
+ // external package to rename anything.
421
+ sessionKey: 'id',
422
+ turnId: 'id',
423
+ totalCost: 'number',
424
+ newCost: 'number',
425
+ turnTimeoutMs: 'int',
426
+ queueCap: 'int',
427
+ drainedPendings: 'int',
428
+ pinnedSkipped: 'int',
429
+ callback: 'token',
430
+ background_shell: 'bool',
431
+ fired: 'bool',
432
+
433
+ // ── audit of this boundary itself ───────────────────────────────────
434
+ dropped_fields: 'ids',
435
+ dropped_field_count: 'int',
436
+ };
437
+
438
+ const REMOVED_NAMES = new Set(REMOVED_FIELDS);
439
+ const SHAPE_FIELD_NAMES = new Set(
440
+ Object.values(SHAPES).flatMap((shape) => Object.keys(shape)),
441
+ );
442
+
443
+ // Deeper than this is malformed rather than legitimate telemetry.
444
+ const MAX_DEPTH = 8;
445
+
446
+ const isPlainObject = (v) => {
447
+ if (v === null || typeof v !== 'object') return false;
448
+ const proto = Object.getPrototypeOf(v);
449
+ return proto === Object.prototype || proto === null;
450
+ };
451
+
452
+ function accepts(type, value, key) {
453
+ if (type === 'shape') {
454
+ if (!isPlainObject(value)) return false;
455
+ return true; // fields are checked individually by the caller
456
+ }
457
+ const check = TYPES[type];
458
+ return typeof check === 'function' ? check(value, key) : false;
459
+ }
460
+
461
+ /**
462
+ * Filter a telemetry payload down to the typed schema.
463
+ *
464
+ * A rejected field's NAME is only echoed back when it belongs to this
465
+ * schema's own closed vocabulary — a declared field whose value failed its
466
+ * type, or one of the deliberately removed names. Anything else is counted
467
+ * and nothing more: a key can be caller-controlled (a payload spread from a
468
+ * map keyed by user data puts arbitrary text in the key position), so an
469
+ * unknown name is content, not a diagnostic.
470
+ *
471
+ * @param {object} detail
472
+ * @returns {{ detail:object, dropped:string[], droppedCount:number }} Absent
473
+ * (`undefined`) fields are not losses and are not counted.
474
+ */
475
+ function enforceEventDetailSchema(detail) {
476
+ const dropped = new Set();
477
+ let droppedCount = 0;
478
+
479
+ const knownName = (key) => (
480
+ Object.prototype.hasOwnProperty.call(FIELDS, key)
481
+ || REMOVED_NAMES.has(key)
482
+ || SHAPE_FIELD_NAMES.has(key)
483
+ );
484
+ const reject = (key) => {
485
+ droppedCount += 1;
486
+ if (knownName(key)) dropped.add(key);
487
+ };
488
+
489
+ const filter = (obj, fieldMap, depth) => {
490
+ const out = {};
491
+ for (const [key, value] of Object.entries(obj)) {
492
+ if (value === undefined) continue; // absent, not dropped
493
+ const type = fieldMap[key];
494
+ if (!type) { reject(key); continue; }
495
+ if (type === 'shape') {
496
+ const shape = SHAPES[key];
497
+ if (!shape || depth >= MAX_DEPTH || !accepts(type, value, key)) {
498
+ reject(key);
499
+ continue;
500
+ }
501
+ out[key] = filter(value, shape, depth + 1);
502
+ continue;
503
+ }
504
+ if (value === null) { out[key] = null; continue; }
505
+ if (!accepts(type, value, key)) { reject(key); continue; }
506
+ out[key] = value;
507
+ }
508
+ return out;
509
+ };
510
+
511
+ return {
512
+ detail: isPlainObject(detail) ? filter(detail, FIELDS, 0) : {},
513
+ dropped: [...dropped].sort(),
514
+ droppedCount,
515
+ };
516
+ }
517
+
518
+ module.exports = {
519
+ enforceEventDetailSchema,
520
+ EVENT_DETAIL_FIELDS: FIELDS,
521
+ EVENT_DETAIL_SHAPES: SHAPES,
522
+ REMOVED_FIELDS,
523
+ };
@@ -10,6 +10,11 @@
10
10
  */
11
11
 
12
12
  const crypto = require('crypto');
13
+ const { sanitizeForDurableWrite } = require('../secret-detect');
14
+
15
+ const durableText = (value) => (
16
+ typeof value === 'string' ? sanitizeForDurableWrite(value).text : value
17
+ );
13
18
 
14
19
  // Crockford-ish base32: no 0/O/1/I/L to avoid hand-entry confusion.
15
20
  const ALPHA = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
@@ -165,7 +170,10 @@ function createStore(rawDb, now = () => Date.now()) {
165
170
  issued_by_user_id,
166
171
  issued_ts,
167
172
  expires_ts,
168
- note,
173
+ // The note is operator-typed free text; a declared credential in it
174
+ // must not reach the row, and the pairing copy below inherits the
175
+ // masked value.
176
+ note: durableText(note),
169
177
  });
170
178
  return { code, issued_ts, expires_ts, bot_name, chat_id, scope, note };
171
179
  },
@@ -34,8 +34,11 @@ function sweepSecrets(rawDb, opts = {}) {
34
34
  ORDER BY id LIMIT ?`);
35
35
  const updText = rawDb.prepare('UPDATE messages SET text = ? WHERE id = ?');
36
36
  const stamp = rawDb.prepare('UPDATE messages SET secret_scanned_at = ? WHERE id = ?');
37
+ // The audit records what was redacted and where. It deliberately stores no
38
+ // digest of the value: an unsalted hash of a guessable secret is a
39
+ // correlation handle for the secret itself.
37
40
  const insAudit = rawDb.prepare(`INSERT INTO secret_redactions
38
- (chat_id, msg_id, rule, tier, length, sha256, action, ts) VALUES (?,?,?,?,?,?,?,?)`);
41
+ (chat_id, msg_id, rule, tier, length, action, ts) VALUES (?,?,?,?,?,?,?)`);
39
42
 
40
43
  let scanned = 0; let redactedMsgs = 0; let redactions = 0; let flagged = 0;
41
44
  const ruleCounts = {};
@@ -49,8 +52,8 @@ function sweepSecrets(rawDb, opts = {}) {
49
52
  const res = redactText(row.text, { redactTiers });
50
53
  if (!dryRun) {
51
54
  if (res.changed) updText.run(res.text, row.id); // FTS re-indexes via the UPDATE trigger
52
- for (const r of res.redacted) insAudit.run(String(row.chat_id), row.msg_id, r.rule, r.tier, r.length, r.sha256, 'redacted', now);
53
- for (const f of res.flagged) insAudit.run(String(row.chat_id), row.msg_id, f.rule, f.tier, f.length, f.sha256, 'flagged', now);
55
+ for (const r of res.redacted) insAudit.run(String(row.chat_id), row.msg_id, r.rule, r.tier, r.length, 'redacted', now);
56
+ for (const f of res.flagged) insAudit.run(String(row.chat_id), row.msg_id, f.rule, f.tier, f.length, 'flagged', now);
54
57
  stamp.run(now, row.id);
55
58
  }
56
59
  if (res.changed) redactedMsgs += 1;
@@ -63,7 +63,7 @@ function migrateJsonToDb(db, sessionsJsonPath, configChats = {}) {
63
63
  const quarantine = `${sessionsJsonPath}.malformed-${stamp}`;
64
64
  try { fs.renameSync(sessionsJsonPath, quarantine); } catch {}
65
65
  if (db?.logEvent) {
66
- try { db.logEvent('sessions-json-malformed', { path: sessionsJsonPath, error: err.message, quarantined_to: quarantine }); } catch {}
66
+ try { db.logEvent('sessions-json-malformed', { error_class: err.name || 'Error', quarantined: true }); } catch {}
67
67
  }
68
68
  return { imported: 0, renamed: true, reason: `malformed-json: ${err.message}` };
69
69
  }
@@ -72,7 +72,7 @@ function migrateJsonToDb(db, sessionsJsonPath, configChats = {}) {
72
72
  const quarantine = `${sessionsJsonPath}.malformed-${stamp}`;
73
73
  try { fs.renameSync(sessionsJsonPath, quarantine); } catch {}
74
74
  if (db?.logEvent) {
75
- try { db.logEvent('sessions-json-malformed', { path: sessionsJsonPath, error: 'not an object', quarantined_to: quarantine }); } catch {}
75
+ try { db.logEvent('sessions-json-malformed', { error_code: 'not-an-object', quarantined: true }); } catch {}
76
76
  }
77
77
  return { imported: 0, renamed: true, reason: 'malformed-json: not an object' };
78
78
  }