@runwingman/flightdeck-cli 0.2.0

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,774 @@
1
+ /**
2
+ * Bot-safe Yoke helper surface for Wingmen (Agent Chat phase 1).
3
+ *
4
+ * CONTRACT
5
+ * --------
6
+ * This module is the single import boundary Wingmen is allowed to use from
7
+ * Yoke for bot-first workspace bootstrap, auth, group-key loading, and
8
+ * chat-record decrypt/routing normalization. It is designed so a bot actor
9
+ * can run without invoking the interactive Yoke CLI, without touching the
10
+ * local SQLite mirror, and without depending on any CLI global state.
11
+ *
12
+ * Safe for Wingmen import:
13
+ * - BotHelperError (typed error with .code)
14
+ * - createBotWorkspaceKey
15
+ * - loadBotWorkspaceKey
16
+ * - signBotRequest
17
+ * - signWorkspaceRequest
18
+ * - fetchBotGroupKeys
19
+ * - loadBotGroupKeys
20
+ * - decryptChatRecord
21
+ * - extractChatReadableGroups
22
+ * - normalizeThreadId
23
+ * - normalizeChannelParticipants
24
+ * - normalizeChatRoutingContext
25
+ * - normalizeChatInterceptContext
26
+ * - buildAgentInterceptKey
27
+ *
28
+ * Explicitly NOT part of this contract (remain Yoke-only):
29
+ * - src/cli.js and any interactive command entrypoints
30
+ * - src/sync.js long-running sync loop
31
+ * - src/db.js SQLite schema, migrations, and local maintenance
32
+ * - src/config.js CLI/environment bootstrap
33
+ * - src/nostr.js ambient nsec loaders (getConfiguredNsec, bitwarden, etc.)
34
+ *
35
+ * Wingmen must not import those modules for runtime chat interception. If a
36
+ * new capability is needed, extend this helper surface rather than reaching
37
+ * into CLI internals.
38
+ *
39
+ * Error codes emitted by this module:
40
+ * - workspace_auth_failed : ws key blob cannot be loaded for this bot
41
+ * - group_key_missing : no wrapped group key available to decrypt
42
+ * - thread_unresolved : chat message thread_id cannot be normalized
43
+ * - record_decrypt_failed : wrapped payload decrypt rejected
44
+ * - intercept_context_invalid : agent-first intercept helper inputs are incomplete
45
+ */
46
+
47
+ import { getPublicKey, nip19 } from 'nostr-tools';
48
+ import {
49
+ createNip98AuthHeader,
50
+ decodeNsec,
51
+ } from './nostr.js';
52
+ import {
53
+ generateWorkspaceKey,
54
+ decryptWorkspaceKey,
55
+ buildWorkspaceSession,
56
+ } from './workspace-keys.js';
57
+ import {
58
+ decryptRecordPayload,
59
+ inboundChatMessage,
60
+ inboundChannel,
61
+ loadGroupKeyMap,
62
+ } from './translators.js';
63
+
64
+ /**
65
+ * Typed error for the bot helper surface. `code` is one of the shared error
66
+ * codes in the Agent Chat design doc.
67
+ */
68
+ export class BotHelperError extends Error {
69
+ constructor(code, message, { cause } = {}) {
70
+ super(message);
71
+ this.name = 'BotHelperError';
72
+ this.code = code;
73
+ if (cause) this.cause = cause;
74
+ }
75
+ }
76
+
77
+ function normalizeGroupRef(value) {
78
+ const ref = typeof value === 'string' ? value.trim() : '';
79
+ return ref || null;
80
+ }
81
+
82
+ function dedupeSorted(values = []) {
83
+ return [...new Set(values.filter(Boolean))].sort();
84
+ }
85
+
86
+ function resolveSourceAppNpub(record, chatMessage = null) {
87
+ const explicit = typeof chatMessage?.source_app_npub === 'string' ? chatMessage.source_app_npub.trim() : '';
88
+ if (explicit) return explicit;
89
+
90
+ const familyHash = typeof record?.record_family_hash === 'string' ? record.record_family_hash.trim() : '';
91
+ if (!familyHash) return null;
92
+ const separator = familyHash.indexOf(':');
93
+ if (separator <= 0) return null;
94
+ const appNpub = familyHash.slice(0, separator).trim();
95
+ return appNpub || null;
96
+ }
97
+
98
+ function requireBotActor({ botSecret, botNpub }) {
99
+ if (!(botSecret instanceof Uint8Array) || botSecret.byteLength !== 32) {
100
+ throw new BotHelperError(
101
+ 'workspace_auth_failed',
102
+ 'Bot secret must be a 32-byte Uint8Array.',
103
+ );
104
+ }
105
+ if (typeof botNpub !== 'string' || !botNpub.startsWith('npub1')) {
106
+ throw new BotHelperError(
107
+ 'workspace_auth_failed',
108
+ 'Bot npub must be a bech32 npub string.',
109
+ );
110
+ }
111
+ const derivedNpub = nip19.npubEncode(getPublicKey(botSecret));
112
+ if (derivedNpub !== botNpub) {
113
+ throw new BotHelperError(
114
+ 'workspace_auth_failed',
115
+ `Bot npub ${botNpub} does not match secret-derived npub ${derivedNpub}.`,
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Create a fresh workspace session keypair for a bot actor. The bot's
122
+ * runtime secret is used to encrypt the derived ws-key nsec so the bot can
123
+ * reload the same workspace session on the next run without depending on the
124
+ * human root key.
125
+ *
126
+ * @param {object} params
127
+ * @param {Uint8Array} params.botSecret
128
+ * @param {string} params.botNpub
129
+ * @param {string} params.workspaceOwnerNpub
130
+ * @returns {{ blob: object, wsSession: object }}
131
+ */
132
+ export function createBotWorkspaceKey({ botSecret, botNpub, workspaceOwnerNpub }) {
133
+ requireBotActor({ botSecret, botNpub });
134
+ if (typeof workspaceOwnerNpub !== 'string' || !workspaceOwnerNpub.startsWith('npub1')) {
135
+ throw new BotHelperError(
136
+ 'workspace_auth_failed',
137
+ 'workspaceOwnerNpub must be a bech32 npub string.',
138
+ );
139
+ }
140
+ const { blob, wsKeySecret, wsKeyNpub } = generateWorkspaceKey(
141
+ botSecret,
142
+ botNpub,
143
+ workspaceOwnerNpub,
144
+ );
145
+ const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, blob.ws_key_epoch ?? 1, botNpub);
146
+ return { blob, wsSession };
147
+ }
148
+
149
+ /**
150
+ * Build a NIP-98 Authorization header signed by the bot's real identity.
151
+ * Used for privileged bootstrap operations like ws-key registration.
152
+ *
153
+ * @param {object} params
154
+ * @param {Uint8Array} params.botSecret
155
+ * @param {string} params.botNpub
156
+ * @param {string} params.url
157
+ * @param {string} params.method
158
+ * @param {object|string|null} [params.body]
159
+ * @returns {string}
160
+ */
161
+ export function signBotRequest({ botSecret, botNpub, url, method, body = null }) {
162
+ requireBotActor({ botSecret, botNpub });
163
+ if (typeof url !== 'string' || !url) {
164
+ throw new BotHelperError('workspace_auth_failed', 'signBotRequest url is required.');
165
+ }
166
+ if (typeof method !== 'string' || !method) {
167
+ throw new BotHelperError('workspace_auth_failed', 'signBotRequest method is required.');
168
+ }
169
+ return createNip98AuthHeader(url, method, body, botSecret);
170
+ }
171
+
172
+ /**
173
+ * Reload an existing bot workspace session from a persisted blob.
174
+ *
175
+ * @param {object} params
176
+ * @param {object} params.blob - the envelope previously returned by createBotWorkspaceKey
177
+ * @param {Uint8Array} params.botSecret
178
+ * @param {string} params.botNpub
179
+ * @returns {{ wsSession: object }}
180
+ */
181
+ export function loadBotWorkspaceKey({ blob, botSecret, botNpub }) {
182
+ requireBotActor({ botSecret, botNpub });
183
+ if (!blob || typeof blob !== 'object') {
184
+ throw new BotHelperError('workspace_auth_failed', 'Missing workspace key blob.');
185
+ }
186
+ try {
187
+ const { wsKeySecret, wsKeyNpub, wsKeyEpoch } = decryptWorkspaceKey(
188
+ blob,
189
+ botSecret,
190
+ botNpub,
191
+ );
192
+ const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, wsKeyEpoch, botNpub);
193
+ return { wsSession };
194
+ } catch (cause) {
195
+ throw new BotHelperError(
196
+ 'workspace_auth_failed',
197
+ `Unable to load workspace key for ${botNpub}: ${cause.message}`,
198
+ { cause },
199
+ );
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Build a NIP-98 Authorization header for a Tower request, signed by the bot's
205
+ * per-workspace ws key. Wingmen uses this when it does its own HTTP calls
206
+ * without the Yoke client class.
207
+ *
208
+ * @param {object} params
209
+ * @param {object} params.wsSession - from createBotWorkspaceKey/loadBotWorkspaceKey
210
+ * @param {string} params.url
211
+ * @param {string} params.method
212
+ * @param {object|string|null} [params.body]
213
+ * @returns {string}
214
+ */
215
+ export function signWorkspaceRequest({ wsSession, url, method, body = null }) {
216
+ if (!wsSession?.secret || !wsSession?.isWorkspaceKey) {
217
+ throw new BotHelperError(
218
+ 'workspace_auth_failed',
219
+ 'signWorkspaceRequest requires a ws session from createBotWorkspaceKey/loadBotWorkspaceKey.',
220
+ );
221
+ }
222
+ if (typeof url !== 'string' || !url) {
223
+ throw new BotHelperError('workspace_auth_failed', 'signWorkspaceRequest url is required.');
224
+ }
225
+ if (typeof method !== 'string' || !method) {
226
+ throw new BotHelperError('workspace_auth_failed', 'signWorkspaceRequest method is required.');
227
+ }
228
+ return createNip98AuthHeader(url, method, body, wsSession.secret);
229
+ }
230
+
231
+ /**
232
+ * Fetch wrapped group key rows from Tower for the bot actor.
233
+ *
234
+ * This helper is intentionally transport-minimal: it takes a `fetchImpl` so
235
+ * Wingmen can inject its own HTTP/SSE-aware fetch wrapper, and a
236
+ * `backendBaseUrl` so it does not depend on Yoke config singletons.
237
+ *
238
+ * @param {object} params
239
+ * @param {object} params.wsSession
240
+ * @param {string} params.backendBaseUrl - e.g. https://tower.example.com
241
+ * @param {typeof fetch} [params.fetchImpl=fetch]
242
+ * @returns {Promise<Array>} raw wrapped group key rows as returned by Tower
243
+ */
244
+ export async function fetchBotGroupKeys({ wsSession, backendBaseUrl, fetchImpl = fetch }) {
245
+ if (!wsSession?.secret || !wsSession?.isWorkspaceKey) {
246
+ throw new BotHelperError(
247
+ 'workspace_auth_failed',
248
+ 'fetchBotGroupKeys requires a bot ws session.',
249
+ );
250
+ }
251
+ if (typeof backendBaseUrl !== 'string' || !backendBaseUrl) {
252
+ throw new BotHelperError(
253
+ 'workspace_auth_failed',
254
+ 'fetchBotGroupKeys requires backendBaseUrl.',
255
+ );
256
+ }
257
+ const url = new URL(
258
+ `/api/v4/groups/keys?member_npub=${encodeURIComponent(wsSession.npub)}`,
259
+ backendBaseUrl,
260
+ ).toString();
261
+ const authorization = signWorkspaceRequest({ wsSession, url, method: 'GET' });
262
+ let response;
263
+ try {
264
+ response = await fetchImpl(url, {
265
+ method: 'GET',
266
+ headers: { Authorization: authorization },
267
+ });
268
+ } catch (cause) {
269
+ throw new BotHelperError(
270
+ 'workspace_auth_failed',
271
+ `Tower group-key fetch transport error: ${cause.message}`,
272
+ { cause },
273
+ );
274
+ }
275
+ if (!response.ok) {
276
+ const text = await response.text().catch(() => '');
277
+ throw new BotHelperError(
278
+ 'workspace_auth_failed',
279
+ `Tower group-key fetch rejected (${response.status}): ${text || response.statusText}`,
280
+ );
281
+ }
282
+ const payload = await response.json();
283
+ const rows = Array.isArray(payload)
284
+ ? payload
285
+ : Array.isArray(payload?.keys)
286
+ ? payload.keys
287
+ : Array.isArray(payload?.group_keys)
288
+ ? payload.group_keys
289
+ : [];
290
+ return rows;
291
+ }
292
+
293
+ /**
294
+ * Load wrapped key rows into an in-memory group key map the decrypt helper
295
+ * can consume. Does not touch SQLite.
296
+ *
297
+ * @param {object} params
298
+ * @param {object} params.wsSession
299
+ * @param {Uint8Array} params.botSecret
300
+ * @param {string} params.botNpub
301
+ * @param {Array} params.keyRows - wrapped key rows from fetchBotGroupKeys
302
+ * @returns {object} group key map (see translators.loadGroupKeyMap)
303
+ */
304
+ export function loadBotGroupKeys({ wsSession, botSecret, botNpub, keyRows }) {
305
+ if (!wsSession?.secret) {
306
+ throw new BotHelperError(
307
+ 'workspace_auth_failed',
308
+ 'loadBotGroupKeys requires a bot ws session.',
309
+ );
310
+ }
311
+ requireBotActor({ botSecret, botNpub });
312
+ if (!Array.isArray(keyRows)) {
313
+ throw new BotHelperError(
314
+ 'group_key_missing',
315
+ 'loadBotGroupKeys requires an array of wrapped key rows.',
316
+ );
317
+ }
318
+ try {
319
+ return loadGroupKeyMap({ secret: botSecret, npub: botNpub }, keyRows, decodeNsec);
320
+ } catch (cause) {
321
+ throw new BotHelperError(
322
+ 'group_key_missing',
323
+ `Unable to unwrap group keys for bot ${botNpub}: ${cause.message}`,
324
+ { cause },
325
+ );
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Decrypt a chat_message record that Tower SSE announced to the bot.
331
+ *
332
+ * Returns the normalized chat-message model (see translators.inboundChatMessage)
333
+ * so the caller can feed it directly into thread normalization and routing.
334
+ *
335
+ * @param {object} params
336
+ * @param {object} params.record
337
+ * @param {object} params.wsSession
338
+ * @param {object} params.groupKeys - from loadBotGroupKeys
339
+ * @returns {object} normalized inbound chat message
340
+ */
341
+ export function decryptChatRecord({ record, wsSession, groupKeys }) {
342
+ if (!record || typeof record !== 'object') {
343
+ throw new BotHelperError('record_decrypt_failed', 'decryptChatRecord requires a record.');
344
+ }
345
+ if (!wsSession?.secret) {
346
+ throw new BotHelperError(
347
+ 'workspace_auth_failed',
348
+ 'decryptChatRecord requires a bot ws session.',
349
+ );
350
+ }
351
+ if (!groupKeys?.get) {
352
+ throw new BotHelperError(
353
+ 'group_key_missing',
354
+ 'decryptChatRecord requires a group key map from loadBotGroupKeys.',
355
+ );
356
+ }
357
+ let payload;
358
+ try {
359
+ payload = decryptRecordPayload(record, wsSession, groupKeys, wsSession);
360
+ } catch (cause) {
361
+ const isGroupKey = /no matching group key/i.test(cause.message || '');
362
+ throw new BotHelperError(
363
+ isGroupKey ? 'group_key_missing' : 'record_decrypt_failed',
364
+ `Chat record ${record.record_id ?? '<unknown>'} could not be decrypted: ${cause.message}`,
365
+ { cause },
366
+ );
367
+ }
368
+ return inboundChatMessage(record, payload);
369
+ }
370
+
371
+ /**
372
+ * Extract the group identities carried on a chat record and identify which of
373
+ * those groups are locally readable with the currently loaded wrapped-key map.
374
+ *
375
+ * This lets Wingmen do two adjacent agent-first tasks without CLI shell-outs:
376
+ * - inspect the full message encryption-group set for candidate selection
377
+ * - inspect the subset currently readable by the local bot for diagnostics
378
+ *
379
+ * @param {object} params
380
+ * @param {object} params.record
381
+ * @param {object} [params.groupKeys]
382
+ * @returns {{
383
+ * message_group_ids: string[],
384
+ * message_group_npubs: string[],
385
+ * readable_group_ids: string[],
386
+ * readable_group_npubs: string[],
387
+ * }}
388
+ */
389
+ export function extractChatReadableGroups({ record, groupKeys = null }) {
390
+ if (!record || typeof record !== 'object') {
391
+ throw new BotHelperError(
392
+ 'intercept_context_invalid',
393
+ 'extractChatReadableGroups requires a record.',
394
+ );
395
+ }
396
+
397
+ const groupPayloads = Array.isArray(record.group_payloads) ? record.group_payloads : [];
398
+ const messageGroupIds = dedupeSorted(groupPayloads.map((payload) => normalizeGroupRef(payload?.group_id)));
399
+ const messageGroupNpubs = dedupeSorted(groupPayloads.map((payload) => normalizeGroupRef(payload?.group_npub)));
400
+
401
+ if (!groupKeys?.get) {
402
+ return {
403
+ message_group_ids: messageGroupIds,
404
+ message_group_npubs: messageGroupNpubs,
405
+ readable_group_ids: [],
406
+ readable_group_npubs: [],
407
+ };
408
+ }
409
+
410
+ const readableGroupIds = [];
411
+ const readableGroupNpubs = [];
412
+ for (const payload of groupPayloads) {
413
+ const keyVersion = Number.isInteger(payload?.group_epoch) ? payload.group_epoch : undefined;
414
+ const entry = groupKeys.get(payload?.group_id, keyVersion != null ? { keyVersion } : {})
415
+ || groupKeys.get(payload?.group_npub, keyVersion != null ? { keyVersion } : {});
416
+ if (!entry) continue;
417
+ const groupId = normalizeGroupRef(entry.groupId) || normalizeGroupRef(payload?.group_id);
418
+ const groupNpub = normalizeGroupRef(entry.groupNpub) || normalizeGroupRef(payload?.group_npub);
419
+ if (groupId) readableGroupIds.push(groupId);
420
+ if (groupNpub) readableGroupNpubs.push(groupNpub);
421
+ }
422
+
423
+ return {
424
+ message_group_ids: messageGroupIds,
425
+ message_group_npubs: messageGroupNpubs,
426
+ readable_group_ids: dedupeSorted(readableGroupIds),
427
+ readable_group_npubs: dedupeSorted(readableGroupNpubs),
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Normalize a chat message to its canonical thread_id for Agent Chat routing.
433
+ *
434
+ * Resolution rules:
435
+ * - if the decrypted message already exposes an explicit thread_id, trust it
436
+ * - else if there is no parent_message_id, the message is itself the thread root
437
+ * - else walk parent_message_id via `lookupMessage` until a root message is
438
+ * reached; that root's record_id is the thread_id
439
+ *
440
+ * `lookupMessage` is a sync function `(messageId) => chatMessage | null` that
441
+ * returns a prior inbound chat message (or any object with record_id and
442
+ * parent_message_id). If a parent cannot be resolved, throws thread_unresolved.
443
+ *
444
+ * @param {object} chatMessage - a normalized inbound chat message
445
+ * @param {object} [context]
446
+ * @param {(messageId: string) => object|null} [context.lookupMessage]
447
+ * @param {number} [context.maxDepth=256]
448
+ * @returns {string}
449
+ */
450
+ export function normalizeThreadId(chatMessage, context = {}) {
451
+ if (!chatMessage || typeof chatMessage !== 'object') {
452
+ throw new BotHelperError('thread_unresolved', 'normalizeThreadId requires a chat message.');
453
+ }
454
+ const explicit = typeof chatMessage.thread_id === 'string' ? chatMessage.thread_id.trim() : '';
455
+ if (explicit) return explicit;
456
+
457
+ const selfId = typeof chatMessage.record_id === 'string' ? chatMessage.record_id.trim() : '';
458
+ if (!selfId) {
459
+ throw new BotHelperError(
460
+ 'thread_unresolved',
461
+ 'Chat message is missing record_id; cannot normalize thread_id.',
462
+ );
463
+ }
464
+
465
+ const parentId = typeof chatMessage.parent_message_id === 'string'
466
+ ? chatMessage.parent_message_id.trim()
467
+ : '';
468
+ if (!parentId) return selfId;
469
+
470
+ const lookupMessage = context.lookupMessage;
471
+ if (typeof lookupMessage !== 'function') {
472
+ throw new BotHelperError(
473
+ 'thread_unresolved',
474
+ `Chat message ${selfId} has parent_message_id ${parentId} but no lookupMessage was provided.`,
475
+ );
476
+ }
477
+
478
+ const maxDepth = Number.isFinite(context.maxDepth) ? context.maxDepth : 256;
479
+ const seen = new Set([selfId]);
480
+ let currentId = parentId;
481
+ for (let depth = 0; depth < maxDepth; depth++) {
482
+ if (seen.has(currentId)) {
483
+ throw new BotHelperError(
484
+ 'thread_unresolved',
485
+ `Thread walk for ${selfId} hit a cycle at ${currentId}.`,
486
+ );
487
+ }
488
+ seen.add(currentId);
489
+ const parent = lookupMessage(currentId);
490
+ if (!parent || typeof parent.record_id !== 'string') {
491
+ throw new BotHelperError(
492
+ 'thread_unresolved',
493
+ `Thread walk for ${selfId} could not resolve parent ${currentId}.`,
494
+ );
495
+ }
496
+ const parentRecordId = parent.record_id.trim();
497
+ if (!parentRecordId) {
498
+ throw new BotHelperError(
499
+ 'thread_unresolved',
500
+ `Thread walk for ${selfId} resolved parent ${currentId} without a usable record_id.`,
501
+ );
502
+ }
503
+ const nextParent = typeof parent.parent_message_id === 'string'
504
+ ? parent.parent_message_id.trim()
505
+ : '';
506
+ if (!nextParent) return parentRecordId;
507
+ currentId = nextParent;
508
+ }
509
+ throw new BotHelperError(
510
+ 'thread_unresolved',
511
+ `Thread walk for ${selfId} exceeded maxDepth ${maxDepth}.`,
512
+ );
513
+ }
514
+
515
+ /**
516
+ * Normalize the participant npub list for a channel. Accepts either a raw
517
+ * record+payload pair (the shape Tower returns) or an already-normalized
518
+ * inbound channel object.
519
+ *
520
+ * Returns a deduped, sorted array of bech32 npubs. The channel owner is
521
+ * always included.
522
+ *
523
+ * @param {object} input - { record, payload } OR an inboundChannel result
524
+ * @returns {string[]}
525
+ */
526
+ export function normalizeChannelParticipants(input) {
527
+ if (!input || typeof input !== 'object') {
528
+ throw new BotHelperError(
529
+ 'thread_unresolved',
530
+ 'normalizeChannelParticipants requires a channel input.',
531
+ );
532
+ }
533
+ let channel;
534
+ if (Array.isArray(input.participant_npubs) || typeof input.owner_npub === 'string') {
535
+ channel = input;
536
+ } else if (input.record && input.payload) {
537
+ channel = inboundChannel(input.record, input.payload);
538
+ } else {
539
+ throw new BotHelperError(
540
+ 'thread_unresolved',
541
+ 'normalizeChannelParticipants requires { record, payload } or a normalized channel.',
542
+ );
543
+ }
544
+
545
+ const set = new Set();
546
+ if (typeof channel.owner_npub === 'string' && channel.owner_npub.startsWith('npub1')) {
547
+ set.add(channel.owner_npub);
548
+ }
549
+ for (const candidate of (channel.participant_npubs || [])) {
550
+ if (typeof candidate !== 'string') continue;
551
+ const trimmed = candidate.trim();
552
+ if (trimmed.startsWith('npub1')) set.add(trimmed);
553
+ }
554
+ return [...set].sort();
555
+ }
556
+
557
+ /**
558
+ * Build the stable routing context Wingmen uses for Agent Chat evaluation.
559
+ *
560
+ * This composes the canonical shared helpers rather than reimplementing their
561
+ * logic elsewhere:
562
+ * - channel_id comes from the decrypted chat message
563
+ * - thread_id comes from normalizeThreadId()
564
+ * - participant_npubs comes from normalizeChannelParticipants()
565
+ *
566
+ * @param {object} input
567
+ * @param {object} input.chatMessage - normalized inbound chat message
568
+ * @param {object} input.channel - normalized inbound channel OR { record, payload }
569
+ * @param {object} [context]
570
+ * @param {(messageId: string) => object|null} [context.lookupMessage]
571
+ * @param {number} [context.maxDepth=256]
572
+ * @returns {{ record_id: string, channel_id: string, parent_message_id: string|null, thread_id: string, participant_npubs: string[] }}
573
+ */
574
+ export function normalizeChatRoutingContext(input, context = {}) {
575
+ if (!input || typeof input !== 'object') {
576
+ throw new BotHelperError(
577
+ 'thread_unresolved',
578
+ 'normalizeChatRoutingContext requires { chatMessage, channel }.',
579
+ );
580
+ }
581
+ const chatMessage = input.chatMessage;
582
+ if (!chatMessage || typeof chatMessage !== 'object') {
583
+ throw new BotHelperError(
584
+ 'thread_unresolved',
585
+ 'normalizeChatRoutingContext requires a normalized chatMessage.',
586
+ );
587
+ }
588
+
589
+ const recordId = typeof chatMessage.record_id === 'string' ? chatMessage.record_id.trim() : '';
590
+ if (!recordId) {
591
+ throw new BotHelperError(
592
+ 'thread_unresolved',
593
+ 'normalizeChatRoutingContext requires chatMessage.record_id.',
594
+ );
595
+ }
596
+
597
+ const channelId = typeof chatMessage.channel_id === 'string' ? chatMessage.channel_id.trim() : '';
598
+ if (!channelId) {
599
+ throw new BotHelperError(
600
+ 'thread_unresolved',
601
+ `Chat message ${recordId} is missing channel_id; cannot build routing context.`,
602
+ );
603
+ }
604
+
605
+ const channelInput = input.channel;
606
+ if (!channelInput || typeof channelInput !== 'object') {
607
+ throw new BotHelperError(
608
+ 'thread_unresolved',
609
+ `Chat message ${recordId} requires channel context for participant normalization.`,
610
+ );
611
+ }
612
+
613
+ const normalizedChannel = (Array.isArray(channelInput.participant_npubs) || typeof channelInput.owner_npub === 'string')
614
+ ? channelInput
615
+ : channelInput.record && channelInput.payload
616
+ ? inboundChannel(channelInput.record, channelInput.payload)
617
+ : null;
618
+ if (!normalizedChannel) {
619
+ throw new BotHelperError(
620
+ 'thread_unresolved',
621
+ 'normalizeChatRoutingContext requires channel as a normalized channel or { record, payload }.',
622
+ );
623
+ }
624
+
625
+ const normalizedChannelId = typeof normalizedChannel.record_id === 'string'
626
+ ? normalizedChannel.record_id.trim()
627
+ : '';
628
+ if (normalizedChannelId && normalizedChannelId !== channelId) {
629
+ throw new BotHelperError(
630
+ 'thread_unresolved',
631
+ `Chat message ${recordId} references channel ${channelId} but channel context resolved to ${normalizedChannelId}.`,
632
+ );
633
+ }
634
+
635
+ return {
636
+ record_id: recordId,
637
+ channel_id: channelId,
638
+ parent_message_id: typeof chatMessage.parent_message_id === 'string'
639
+ ? chatMessage.parent_message_id.trim() || null
640
+ : null,
641
+ thread_id: normalizeThreadId(chatMessage, context),
642
+ participant_npubs: normalizeChannelParticipants(normalizedChannel),
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Build the stable agent-first chat intercept context Wingmen needs for local
648
+ * candidate selection and per-agent intercept routing.
649
+ *
650
+ * This composes:
651
+ * - workspace_owner_npub from the outer record owner
652
+ * - source_app_npub from the record family hash (or explicit override)
653
+ * - channel/thread routing from normalizeChatRoutingContext()
654
+ * - message/readable group identities from extractChatReadableGroups()
655
+ *
656
+ * @param {object} input
657
+ * @param {object} input.record
658
+ * @param {object} input.chatMessage
659
+ * @param {object} input.channel
660
+ * @param {object} [input.groupKeys]
661
+ * @param {object} [context]
662
+ * @param {(messageId: string) => object|null} [context.lookupMessage]
663
+ * @param {number} [context.maxDepth=256]
664
+ * @returns {{
665
+ * record_id: string,
666
+ * workspace_owner_npub: string,
667
+ * source_app_npub: string|null,
668
+ * channel_id: string,
669
+ * parent_message_id: string|null,
670
+ * thread_id: string,
671
+ * sender_npub: string|null,
672
+ * participant_npubs: string[],
673
+ * message_group_ids: string[],
674
+ * message_group_npubs: string[],
675
+ * readable_group_ids: string[],
676
+ * readable_group_npubs: string[],
677
+ * }}
678
+ */
679
+ export function normalizeChatInterceptContext(input, context = {}) {
680
+ if (!input || typeof input !== 'object') {
681
+ throw new BotHelperError(
682
+ 'intercept_context_invalid',
683
+ 'normalizeChatInterceptContext requires { record, chatMessage, channel }.',
684
+ );
685
+ }
686
+ const record = input.record;
687
+ if (!record || typeof record !== 'object') {
688
+ throw new BotHelperError(
689
+ 'intercept_context_invalid',
690
+ 'normalizeChatInterceptContext requires a record.',
691
+ );
692
+ }
693
+
694
+ const workspaceOwnerNpub = typeof record.owner_npub === 'string' ? record.owner_npub.trim() : '';
695
+ if (!workspaceOwnerNpub) {
696
+ throw new BotHelperError(
697
+ 'intercept_context_invalid',
698
+ 'normalizeChatInterceptContext requires record.owner_npub.',
699
+ );
700
+ }
701
+
702
+ const routing = normalizeChatRoutingContext({
703
+ chatMessage: input.chatMessage,
704
+ channel: input.channel,
705
+ }, context);
706
+ const groups = extractChatReadableGroups({
707
+ record,
708
+ groupKeys: input.groupKeys ?? null,
709
+ });
710
+
711
+ return {
712
+ record_id: routing.record_id,
713
+ workspace_owner_npub: workspaceOwnerNpub,
714
+ source_app_npub: resolveSourceAppNpub(record, input.chatMessage),
715
+ channel_id: routing.channel_id,
716
+ parent_message_id: routing.parent_message_id,
717
+ thread_id: routing.thread_id,
718
+ sender_npub: typeof input.chatMessage?.sender_npub === 'string'
719
+ ? input.chatMessage.sender_npub.trim() || null
720
+ : null,
721
+ participant_npubs: routing.participant_npubs,
722
+ message_group_ids: groups.message_group_ids,
723
+ message_group_npubs: groups.message_group_npubs,
724
+ readable_group_ids: groups.readable_group_ids,
725
+ readable_group_npubs: groups.readable_group_npubs,
726
+ };
727
+ }
728
+
729
+ /**
730
+ * Build the canonical agent-first intercept key:
731
+ *
732
+ * workspace_owner_npub + source_app_npub + channel_id + thread_id + agent_id
733
+ *
734
+ * @param {object} input
735
+ * @param {string} input.workspace_owner_npub
736
+ * @param {string|null} input.source_app_npub
737
+ * @param {string} input.channel_id
738
+ * @param {string} input.thread_id
739
+ * @param {string} input.agent_id
740
+ * @returns {string}
741
+ */
742
+ export function buildAgentInterceptKey(input) {
743
+ if (!input || typeof input !== 'object') {
744
+ throw new BotHelperError(
745
+ 'intercept_context_invalid',
746
+ 'buildAgentInterceptKey requires an input object.',
747
+ );
748
+ }
749
+
750
+ const workspaceOwnerNpub = typeof input.workspace_owner_npub === 'string'
751
+ ? input.workspace_owner_npub.trim()
752
+ : '';
753
+ const sourceAppNpub = typeof input.source_app_npub === 'string'
754
+ ? input.source_app_npub.trim()
755
+ : '';
756
+ const channelId = typeof input.channel_id === 'string' ? input.channel_id.trim() : '';
757
+ const threadId = typeof input.thread_id === 'string' ? input.thread_id.trim() : '';
758
+ const agentId = typeof input.agent_id === 'string' ? input.agent_id.trim() : '';
759
+
760
+ if (!workspaceOwnerNpub || !sourceAppNpub || !channelId || !threadId || !agentId) {
761
+ throw new BotHelperError(
762
+ 'intercept_context_invalid',
763
+ 'buildAgentInterceptKey requires workspace_owner_npub, source_app_npub, channel_id, thread_id, and agent_id.',
764
+ );
765
+ }
766
+
767
+ return [
768
+ workspaceOwnerNpub,
769
+ sourceAppNpub,
770
+ channelId,
771
+ threadId,
772
+ agentId,
773
+ ].join('+');
774
+ }