@noodleseed/one 0.177.0 → 0.178.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.
Files changed (71) hide show
  1. package/dist/commands/solutions-activity.d.ts.map +1 -1
  2. package/dist/commands/solutions-activity.js +9 -35
  3. package/dist/commands/solutions-activity.js.map +1 -1
  4. package/dist/commands/solutions-conversations.d.ts.map +1 -1
  5. package/dist/commands/solutions-conversations.js +60 -6
  6. package/dist/commands/solutions-conversations.js.map +1 -1
  7. package/dist/commands/solutions-history.d.ts +9 -0
  8. package/dist/commands/solutions-history.d.ts.map +1 -0
  9. package/dist/commands/solutions-history.js +166 -0
  10. package/dist/commands/solutions-history.js.map +1 -0
  11. package/dist/commands/solutions-ops.d.ts.map +1 -1
  12. package/dist/commands/solutions-ops.js +3 -0
  13. package/dist/commands/solutions-ops.js.map +1 -1
  14. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  15. package/dist/commands/whatsapp-ops.js +0 -7
  16. package/dist/commands/whatsapp-ops.js.map +1 -1
  17. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  18. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +14 -0
  19. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +6 -0
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +5 -2
  22. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  23. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +18 -0
  24. package/node_modules/@noodle-borg/authoring/dist/assistant.js +14 -1
  25. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +0 -3
  26. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +50 -5
  27. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +82 -0
  28. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +3 -42
  29. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +8 -0
  30. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +7 -0
  31. package/node_modules/@noodle-borg/service/dist/application-activity.js +76 -21
  32. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +108 -0
  33. package/node_modules/@noodle-borg/service/dist/assistant-composition.js +11 -2
  34. package/node_modules/@noodle-borg/service/dist/channels/composition.js +10 -1
  35. package/node_modules/@noodle-borg/service/dist/channels/provider.js +13 -13
  36. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +31 -5
  37. package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +63 -7
  38. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +13 -0
  39. package/node_modules/@noodle-borg/service/dist/conversation-history/customer.js +118 -0
  40. package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +133 -24
  41. package/node_modules/@noodle-borg/service/dist/routes/assistant-conversations.js +52 -0
  42. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +6 -0
  43. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +3 -0
  44. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +2 -1
  45. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +6 -11
  46. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +6 -0
  47. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +16 -0
  48. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +5 -17
  49. package/node_modules/@noodle-borg/service/dist/routes/business-information-activity.js +6 -5
  50. package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +36 -11
  51. package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +4 -2
  52. package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +4 -2
  53. package/node_modules/@noodle-borg/service/dist/routes/business-information.js +4 -0
  54. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +0 -10
  55. package/node_modules/@noodle-borg/service/dist/service-history-projections.js +32 -4
  56. package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +32 -0
  57. package/node_modules/@noodle-borg/service/dist/service.js +4 -3
  58. package/node_modules/@noodle-borg/service/package.json +1 -1
  59. package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.d.ts +0 -28
  60. package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.js +0 -18
  61. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +190 -0
  62. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +92 -0
  63. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +3 -0
  64. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +6 -0
  65. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +176 -0
  66. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +74 -2
  67. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  68. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  69. package/node_modules/@noodleseed/assistant/README.md +19 -0
  70. package/node_modules/@noodleseed/assistant/package.json +1 -1
  71. package/package.json +2 -2
@@ -0,0 +1,108 @@
1
+ import { authenticatedSurfaceOf, messagingSurfaceOf, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { ALL_CONVERSATION_SOURCES, } from './conversation-history/contracts.js';
3
+ const WIRE_SOURCES = {
4
+ websiteVisitors: 'website_visitors',
5
+ signedInCustomers: 'signed_in_customers',
6
+ whatsapp: 'whatsapp',
7
+ };
8
+ /**
9
+ * The surfaces the served application declared `history: false` on (ADR 0241 decision 11), so staff
10
+ * see which conversation options the application has disabled rather than the business.
11
+ */
12
+ export function historyDisabledSurfaces(assistant) {
13
+ return [
14
+ ...(publicSurfaceOf(assistant)?.history === false ? ['publicWebsite'] : []),
15
+ ...(authenticatedSurfaceOf(assistant)?.history === false
16
+ ? ['authenticatedWebsite']
17
+ : []),
18
+ ...(messagingSurfaceOf(assistant)?.history === false ? ['publicMessaging'] : []),
19
+ ];
20
+ }
21
+ /** A setting created lazily on first read never opts in: only new installations record by default. */
22
+ export function lazyHistorySetting(allowance) {
23
+ return {
24
+ days: allowance.defaultDays,
25
+ conversationDays: null,
26
+ sources: ALL_CONVERSATION_SOURCES,
27
+ };
28
+ }
29
+ /** A newly created installation records conversations from day one (ADR 0241 decisions 7 and 8). */
30
+ export function newInstallationHistorySetting(allowance) {
31
+ return { ...lazyHistorySetting(allowance), conversationDays: allowance.defaultDays };
32
+ }
33
+ export function historySettingsProjection(setting, allowance, revision, canEdit) {
34
+ const days = setting.conversationDays;
35
+ return {
36
+ revision,
37
+ canEdit,
38
+ activity: {
39
+ retentionDays: Math.min(setting.days, allowance.maximumDays),
40
+ maximumDays: allowance.maximumDays,
41
+ defaultDays: allowance.defaultDays,
42
+ },
43
+ conversations: {
44
+ state: days === null ? 'not_enabled' : days === 0 ? 'off' : 'on',
45
+ ...(days ? { retentionDays: Math.min(days, allowance.maximumDays) } : {}),
46
+ sources: {
47
+ websiteVisitors: setting.sources.website_visitors,
48
+ signedInCustomers: setting.sources.signed_in_customers,
49
+ whatsapp: setting.sources.whatsapp,
50
+ },
51
+ },
52
+ };
53
+ }
54
+ /** Days currently recorded: never-opted-in and Off both keep nothing new. */
55
+ function recordedDays(setting, maximumDays) {
56
+ return Math.min(setting.conversationDays ?? 0, maximumDays);
57
+ }
58
+ /**
59
+ * Applies a change, or returns undefined when a requested duration exceeds the plan maximum. `shortened` is the
60
+ * new conversation duration when the change shortens or turns off recorded conversation history.
61
+ */
62
+ export function planHistorySettingsChange(current, change, maximumDays) {
63
+ const conversationDays = change.conversations === undefined
64
+ ? current.conversationDays
65
+ : change.conversations === 'off'
66
+ ? 0
67
+ : change.conversations.retentionDays;
68
+ // Only requested durations meet the cap: a value stored before a downgrade stays, read as clamped.
69
+ if ((change.activityDays ?? 0) > maximumDays ||
70
+ (typeof change.conversations === 'object' && change.conversations.retentionDays > maximumDays))
71
+ return undefined;
72
+ const days = change.activityDays ?? current.days;
73
+ const sources = { ...current.sources };
74
+ const changed = [];
75
+ for (const [wire, source] of Object.entries(WIRE_SOURCES)) {
76
+ const value = change.sources?.[wire];
77
+ if (value === undefined || value === sources[source])
78
+ continue;
79
+ sources[source] = value;
80
+ changed.push(wire);
81
+ }
82
+ const next = { days, conversationDays, sources };
83
+ const before = recordedDays(current, maximumDays);
84
+ const after = recordedDays(next, maximumDays);
85
+ return {
86
+ next,
87
+ ...(before > 0 && after < before ? { shortened: after } : {}),
88
+ audit: {
89
+ activityDays: days,
90
+ conversationDays: conversationDays === null
91
+ ? 'not_enabled'
92
+ : conversationDays === 0
93
+ ? 'off'
94
+ : conversationDays,
95
+ sourcesChanged: changed.length === 0 ? 'none' : changed.join(','),
96
+ },
97
+ };
98
+ }
99
+ /** The capture policy for a stored setting; absent until an Owner/Admin or a new installation opts in. */
100
+ export function conversationPolicyFromSetting(setting, allowance) {
101
+ return {
102
+ maximumDays: allowance.maximumDays,
103
+ ...(setting?.conversationDays === null || setting === undefined
104
+ ? {}
105
+ : { conversationDays: setting.conversationDays, sources: { ...setting.sources } }),
106
+ };
107
+ }
108
+ //# sourceMappingURL=application-history-settings.js.map
@@ -1,8 +1,11 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { ConversationCapture } from './conversation-history/capture.js';
3
+ import { CustomerConversations } from './conversation-history/customer.js';
2
4
  /** Browser and messaging resolve model, execution and tenant policy from one composition. */
3
5
  export function assistantRouteDependencies(options, core) {
6
+ const { conversationPolicy, ...route } = core;
4
7
  return {
5
- ...core,
8
+ ...route,
6
9
  ...(options.publicEmbeds ? { publicEmbeds: options.publicEmbeds } : {}),
7
10
  ...(options.elevations ? { elevations: options.elevations } : {}),
8
11
  ...(options.elevationCoordinator ? { elevationCoordinator: options.elevationCoordinator } : {}),
@@ -16,10 +19,16 @@ export function assistantRouteDependencies(options, core) {
16
19
  ...(options.clock ? { clock: options.clock } : {}),
17
20
  ...(options.conversationHistory
18
21
  ? {
19
- conversations: new ConversationCapture(options.conversationHistory.store, options.conversationHistory.policy, {
22
+ conversations: new ConversationCapture(options.conversationHistory.store, conversationPolicy, {
20
23
  now: () => (options.clock?.() ?? new Date()).getTime(),
21
24
  ...(core.logger ? { logger: core.logger } : {}),
22
25
  }),
26
+ customerConversations: new CustomerConversations({
27
+ store: options.conversationHistory.store,
28
+ policy: conversationPolicy,
29
+ identityKey: options.conversationHistory.identityKey ?? randomBytes(32).toString('hex'),
30
+ now: () => (options.clock?.() ?? new Date()).getTime(),
31
+ }),
23
32
  }
24
33
  : {}),
25
34
  };
@@ -28,7 +28,7 @@ export function createWhatsAppRuntime(options, deps, history) {
28
28
  throw new Error('history policy unavailable');
29
29
  const installation = (await history.installations?.listInstallations(tenant.org))?.find((item) => item.scope.app === tenant.app && item.scope.env === tenant.env);
30
30
  return installation && history.activity
31
- ? Math.min(allowance.maximumDays, (await history.activity.settings(installation.scope, false)).projection
31
+ ? Math.min(allowance.maximumDays, (await history.activity.settings(installation.scope, false)).projection.activity
32
32
  .retentionDays)
33
33
  : allowance.maximumDays;
34
34
  },
@@ -36,4 +36,13 @@ export function createWhatsAppRuntime(options, deps, history) {
36
36
  : {}),
37
37
  }, deps);
38
38
  }
39
+ /** What business routes may do with WhatsApp: read its projection and erase a participant's memory. */
40
+ export function whatsappBusinessRoutes(whatsapp) {
41
+ return whatsapp
42
+ ? {
43
+ readWhatsApp: whatsapp.projection.bind(whatsapp),
44
+ forgetWhatsApp: whatsapp.forgetParticipant.bind(whatsapp),
45
+ }
46
+ : {};
47
+ }
39
48
  //# sourceMappingURL=composition.js.map
@@ -29,16 +29,16 @@ export async function boundedJson(response) {
29
29
  }
30
30
  const sendState = z.enum(['AVAILABLE', 'LIMITED', 'BLOCKED']);
31
31
  /**
32
- * Health errors that block only business-initiated conversations. 141006 reads "There is an error
33
- * with the payment method. This will block business initiated conversations." (observed on a WABA
34
- * with no payment method, 2026-09-22). Channels only reply inside the customer service window,
35
- * where non-template messages are free (https://developers.facebook.com/docs/whatsapp/pricing/).
32
+ * 141006 reads "There is an error with the payment method" (observed on a WABA with no payment
33
+ * method, 2026-09-22). Meta requires a business to "first attach a payment method ... before they
34
+ * can begin messaging" (Embedded Signup overview and business customer support, read 2026-09-22),
35
+ * so the block stands; it is only named so readiness can say what to fix.
36
36
  */
37
- const REPLY_SAFE_BLOCKS = new Set([141006]);
37
+ const PAYMENT_BLOCKS = new Set([141006]);
38
38
  /**
39
- * Parse a Cloud API health read. The aggregate `can_send_message` is BLOCKED when any entity is;
40
- * it is downgraded to LIMITED only when the phone number itself can send and every blocked entity
41
- * names nothing but reply-safe errors.
39
+ * Parse a Cloud API health read. The aggregate `can_send_message` is authoritative. When the phone
40
+ * number itself can send and every blocked entity names only payment errors, the block carries
41
+ * `payment_method_required`.
42
42
  */
43
43
  export async function cloudApiHealth(response) {
44
44
  if (!response.ok)
@@ -66,20 +66,20 @@ export async function cloudApiHealth(response) {
66
66
  throw new ChannelError('provider_response_invalid');
67
67
  const { can_send_message: aggregate, entities = [] } = parsed.data.health_status;
68
68
  const phone = entities.find((entity) => entity.entity_type === 'PHONE_NUMBER');
69
- const replySafe = aggregate === 'BLOCKED' &&
69
+ const paymentOnly = aggregate === 'BLOCKED' &&
70
70
  phone !== undefined &&
71
71
  phone.can_send_message !== 'BLOCKED' &&
72
72
  entities
73
73
  .filter((entity) => entity.can_send_message === 'BLOCKED')
74
74
  .every(({ errors = [] }) => {
75
- return (errors.length > 0 && errors.every((error) => REPLY_SAFE_BLOCKS.has(error.error_code)));
75
+ return errors.length > 0 && errors.every((error) => PAYMENT_BLOCKS.has(error.error_code));
76
76
  });
77
- const status = replySafe ? 'LIMITED' : aggregate;
78
77
  return {
79
78
  phoneNumberId: parsed.data.id,
80
79
  // LIMITED meets provider messaging requirements; provider limits still govern each send.
81
- canSend: status !== 'BLOCKED',
82
- status,
80
+ canSend: aggregate !== 'BLOCKED',
81
+ status: aggregate,
82
+ ...(paymentOnly ? { reason: 'payment_method_required' } : {}),
83
83
  };
84
84
  }
85
85
  export async function cloudApiSetBlocked(request, phone, blocked) {
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { beginChannelProviderBlock, CHANNEL_RETENTION_MS, ChannelCoordinator, ChannelError, channelDigest, channelProviderBlocks, collectionSpecFor, finishChannelProviderBlock, messagingSurfaceOf, projectArtifactForSurface, resolveInvocationContextSnapshot, WHATSAPP_CONFIRMATION_EXPIRY_MS, withAssistantTurnExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
2
3
  import { guardedFetch } from '@noodle-borg/connector-http';
3
4
  import { executePreparedTool, prepareToolForConfirmation } from '@noodle-borg/runtime';
5
+ import { messagingSurfaceHistory } from '../conversation-history/capture.js';
4
6
  import { runAgentTurn } from '../routes/assistant-agent.js';
5
7
  import { resolveAssistantKnowledge } from '../routes/assistant-knowledge.js';
6
8
  import { resolveAssistantModelBinding } from '../routes/assistant-model-binding.js';
@@ -124,7 +126,9 @@ export class WhatsAppRuntime {
124
126
  if (health.phoneNumberId !== binding.phoneNumberId)
125
127
  throw new ChannelError('asset_mismatch');
126
128
  if (!health.canSend)
127
- throw new ChannelError('provider_messaging_blocked');
129
+ throw new ChannelError(health.reason === 'payment_method_required'
130
+ ? 'provider_payment_method_required'
131
+ : 'provider_messaging_blocked');
128
132
  return health.status === 'LIMITED' ? 'provider_messaging_limited' : undefined;
129
133
  });
130
134
  await check('webhook', async () => {
@@ -188,6 +192,20 @@ export class WhatsAppRuntime {
188
192
  };
189
193
  }
190
194
  }
195
+ /**
196
+ * Erases one participant's working memory on the tenant's binding (ADR 0241): their context and every
197
+ * message body and reply, keeping content-free controls. False when the tenant has no binding or the
198
+ * reference cannot name a participant, which never reaches the channel store.
199
+ */
200
+ async forgetParticipant(tenant, participantId, actor) {
201
+ if (!/^p_[a-f0-9]{64}$/.test(participantId))
202
+ return false;
203
+ const binding = await this.channels.get(tenant);
204
+ if (!binding)
205
+ return false;
206
+ await this.channels.forget(binding.id, participantId, actor, `history-forget:${randomUUID()}`);
207
+ return true;
208
+ }
191
209
  async providerBlock(id, participantId, blocked, actor, key, protectedPhone) {
192
210
  const operation = await beginChannelProviderBlock(this.channels.store, id, participantId, blocked ? 'blocked' : 'unblocked', actor, key, this.channels.now(), protectedPhone);
193
211
  if (!operation)
@@ -280,11 +298,14 @@ export class WhatsAppRuntime {
280
298
  ...(send.event.buttons === undefined ? {} : { buttons: send.event.buttons }),
281
299
  });
282
300
  await this.channels.sent(id, send.event.id, send.event.lease, result);
283
- // Content-free delivery evidence: no address, text or credential.
301
+ // Content-free delivery evidence: no address, text or credential. A wamid base64-encodes
302
+ // the recipient's phone number, so only a short digest of it is logged.
284
303
  this.deps.logger?.info('assistant.channel.reply', {
285
304
  provider: send.binding.provider,
286
305
  bindingId: id,
287
- providerMessageId: result.providerMessageId,
306
+ providerMessageRef: result.providerMessageId === undefined
307
+ ? undefined
308
+ : channelDigest(result.providerMessageId).slice(0, 16),
288
309
  state: result.state,
289
310
  code: result.code,
290
311
  });
@@ -423,8 +444,13 @@ export class WhatsAppRuntime {
423
444
  });
424
445
  if (failure || !text.trim())
425
446
  throw new ChannelError(failure ?? 'answer_unavailable');
447
+ // WhatsApp has no footer, so a recorded channel states its window here (ADR 0241 decision 17).
448
+ const days = participant.history.length === 0
449
+ ? ((await this.deps.conversations?.retentionDays(binding.tenant, 'whatsapp', messagingSurfaceHistory(artifact.server.assistant))) ?? 0)
450
+ : 0;
451
+ const kept = days > 0 ? ` Chats are kept for ${days} ${days === 1 ? 'day' : 'days'}.` : '';
426
452
  const disclosure = participant.history.length === 0
427
- ? `I’m ${artifact.server.branding?.name ?? artifact.server.title}’s AI assistant.\n\n`
453
+ ? `I’m ${artifact.server.branding?.name ?? artifact.server.title}’s AI assistant.${kept}\n\n`
428
454
  : '';
429
455
  answer = disclosure + text.trim();
430
456
  }
@@ -441,7 +467,7 @@ export class WhatsAppRuntime {
441
467
  user: scrub(heard),
442
468
  assistant: scrub(bounded),
443
469
  receivedAt: event.receivedAt,
444
- });
470
+ }, messagingSurfaceHistory(artifact.server.assistant));
445
471
  }
446
472
  catch (error) {
447
473
  const code = error instanceof ChannelError ? error.code : 'answer_failed';
@@ -1,6 +1,35 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { authenticatedSurfaceOf, messagingSurfaceOf, publicSurfaceOf, surfaceBindingForOrigin, } from '@noodle-borg/assistant-gateway/portable';
2
3
  import { maskPaymentCards } from '../payment-card.js';
3
4
  import { CHANNEL_CONVERSATION_GAP_MS, effectiveConversationDays, } from './contracts.js';
5
+ /** The session wire contract's bound on a stated window; the stored setting never exceeds it. */
6
+ const MAX_NOTICE_DAYS = 365;
7
+ /**
8
+ * The surface a web session is bound to, read from the served assistant. A record minted before
9
+ * `boundSurface` existed derives it as the session target does; an unreadable assistant never records,
10
+ * because nothing proves this caller may be kept.
11
+ */
12
+ export function sessionSurfaceHistory(assistant, session) {
13
+ if (assistant === undefined)
14
+ return { historyDisabled: true };
15
+ const binding = session.boundSurface ??
16
+ (session.publicEmbedId !== undefined
17
+ ? 'public'
18
+ : surfaceBindingForOrigin(assistant, session.origin).kind);
19
+ return websiteSurfaceHistory(assistant, binding);
20
+ }
21
+ /** The website surface a mint binds to; a pre-surfaces or unowned binding declares nothing. */
22
+ export function websiteSurfaceHistory(assistant, binding) {
23
+ const surface = binding === 'public'
24
+ ? publicSurfaceOf(assistant)
25
+ : binding === 'authenticated'
26
+ ? authenticatedSurfaceOf(assistant)
27
+ : undefined;
28
+ return { historyDisabled: surface?.history === false };
29
+ }
30
+ export function messagingSurfaceHistory(assistant) {
31
+ return { historyDisabled: messagingSurfaceOf(assistant)?.history === false };
32
+ }
4
33
  /**
5
34
  * Records what the customer saw beside each existing working-memory write. Capture is best-effort by
6
35
  * contract: a failure is logged as scalars and never fails or delays the customer's turn.
@@ -17,7 +46,7 @@ export class ConversationCapture {
17
46
  this.#logger = options.logger;
18
47
  }
19
48
  /** Visible rows only: narration carries tool output and untagged rows fail closed. */
20
- recordSessionTurn(session, rows) {
49
+ recordSessionTurn(session, rows, surface) {
21
50
  const at = this.#now();
22
51
  const items = rows
23
52
  .filter((row) => row.kind === 'visible' && row.content.trim())
@@ -27,10 +56,10 @@ export class ConversationCapture {
27
56
  text: maskPaymentCards(row.content),
28
57
  at,
29
58
  }));
30
- return this.#recordSession(session, items);
59
+ return this.#recordSession(session, items, surface);
31
60
  }
32
- recordSessionOutcome(session, outcome) {
33
- return this.#recordSession(session, [{ kind: 'outcome', ...outcome, at: this.#now() }]);
61
+ recordSessionOutcome(session, outcome, surface) {
62
+ return this.#recordSession(session, [{ kind: 'outcome', ...outcome, at: this.#now() }], surface);
34
63
  }
35
64
  /** Sign-in moves the conversation in progress to the verified customer (ADR 0241 decision 4). */
36
65
  reownSession(session) {
@@ -41,7 +70,29 @@ export class ConversationCapture {
41
70
  await this.store.reown(session.tenant, sessionConversationId(session.id), subject);
42
71
  });
43
72
  }
44
- recordChannelTurn(turn) {
73
+ /**
74
+ * The window every channel states to the person it records (ADR 0241 decision 17); 0 means this
75
+ * source or surface is not recorded. Never throws: a notice must not fail a session or a reply.
76
+ */
77
+ async retentionDays(tenant, source, surface) {
78
+ if (surface.historyDisabled)
79
+ return 0;
80
+ try {
81
+ const days = effectiveConversationDays(await this.policy(tenant), source);
82
+ return Number.isInteger(days) && days <= MAX_NOTICE_DAYS ? days : 0;
83
+ }
84
+ catch {
85
+ this.#logger?.warn('assistant.history.policy_failed', {
86
+ org: tenant.org,
87
+ app: tenant.app,
88
+ env: tenant.env,
89
+ });
90
+ return 0;
91
+ }
92
+ }
93
+ recordChannelTurn(turn, surface) {
94
+ if (surface.historyDisabled)
95
+ return Promise.resolve();
45
96
  return this.#guard(turn.tenant, async () => {
46
97
  const days = effectiveConversationDays(await this.policy(turn.tenant), 'whatsapp');
47
98
  if (days === 0)
@@ -55,9 +106,9 @@ export class ConversationCapture {
55
106
  ].filter((item) => item.text.trim()), days);
56
107
  });
57
108
  }
58
- #recordSession(session, items) {
109
+ #recordSession(session, items, surface) {
59
110
  const subject = sessionSubject(session);
60
- if (!subject || items.length === 0)
111
+ if (surface.historyDisabled || !subject || items.length === 0)
61
112
  return Promise.resolve();
62
113
  return this.#guard(session.tenant, async () => {
63
114
  const source = subject.kind === 'anonymous' ? 'website_visitors' : 'signed_in_customers';
@@ -96,4 +147,9 @@ function sessionSubject(session) {
96
147
  function sessionConversationId(sessionId) {
97
148
  return `cv_${createHash('sha256').update(sessionId).digest('base64url').slice(0, 22)}`;
98
149
  }
150
+ /** The additive session-response notice (ADR 0241 decision 17); empty for an unrecorded caller. */
151
+ export async function sessionHistoryNotice(conversations, tenant, source, surface) {
152
+ const retentionDays = (await conversations?.retentionDays(tenant, source, surface)) ?? 0;
153
+ return retentionDays > 0 ? { history: { retentionDays } } : {};
154
+ }
99
155
  //# sourceMappingURL=capture.js.map
@@ -5,6 +5,19 @@
5
5
  export const CONVERSATION_DAY_MS = 86_400_000;
6
6
  /** A WhatsApp conversation ends after this much silence, matching the provider's service window. */
7
7
  export const CHANNEL_CONVERSATION_GAP_MS = CONVERSATION_DAY_MS;
8
+ export const ALL_CONVERSATION_SOURCES = {
9
+ website_visitors: true,
10
+ signed_in_customers: true,
11
+ whatsapp: true,
12
+ };
13
+ /**
14
+ * The latest expiry a shorter window allows an item from `at`; Off (0 days) ends every item now. The
15
+ * stored expiry becomes the lesser of this and its own, so a window never lengthens history.
16
+ */
17
+ export function conversationExpiryBound(at, input) {
18
+ return input.days === 0 ? input.now : at + input.days * CONVERSATION_DAY_MS;
19
+ }
20
+ export const CONVERSATION_NOTE_LIMIT = 100;
8
21
  /** Days to keep a new item from this source; 0 means capture nothing. */
9
22
  export function effectiveConversationDays(policy, source) {
10
23
  if (policy?.conversationDays === undefined || policy.sources?.[source] === false)
@@ -0,0 +1,118 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { canonicalJson } from '@noodle-borg/compiler';
3
+ import { ASSISTANT_CONVERSATION_PREVIEW_MAXIMUM, AssistantConversationForgetUserRequestSchema, AssistantConversationListRequestSchema, AssistantConversationListResponseSchema, ConversationForgetResponseSchema, } from '@noodle-borg/wire-contracts';
4
+ import { ConversationHistoryError, conversationReadBound, decodeConversationCursor, encodeConversationCursor, } from './operator.js';
5
+ const DEFAULT_PAGE = 20;
6
+ /**
7
+ * The customer-backend projection of conversation history (ADR 0241 decision 12). An embed client lists
8
+ * or forgets one of its own verified users by the `user.id` its session exchange sent, which capture
9
+ * stored as the customer subject reference. Audit details are counts only, never text or the user id.
10
+ */
11
+ export class CustomerConversations {
12
+ options;
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ async list(client, body) {
17
+ const parsed = AssistantConversationListRequestSchema.safeParse(body);
18
+ if (!parsed.success)
19
+ throw new ConversationHistoryError('conversation_invalid');
20
+ const { user, cursor } = parsed.data;
21
+ const limit = parsed.data.limit ?? DEFAULT_PAGE;
22
+ const subject = customer(user.id);
23
+ const binding = this.#hash({
24
+ purpose: 'assistant-client-conversations-v1',
25
+ client: client.id,
26
+ tenant: client.tenant,
27
+ ref: subject.ref,
28
+ limit,
29
+ });
30
+ const after = decodeConversationCursor(cursor, binding);
31
+ const now = this.options.now?.() ?? Date.now();
32
+ const policy = await this.options.policy(client.tenant).catch(() => {
33
+ throw new ConversationHistoryError('conversation_unavailable');
34
+ });
35
+ // A business that has not opted in records nothing, so it has nothing to list.
36
+ if (policy !== undefined && !Number.isInteger(policy.maximumDays))
37
+ throw new ConversationHistoryError('conversation_unavailable');
38
+ const notBefore = policy === undefined ? now + 1 : conversationReadBound(policy, now);
39
+ const rows = await this.options.store.list(client.tenant, {
40
+ now,
41
+ notBefore,
42
+ limit: limit + 1,
43
+ channel: 'website',
44
+ subject,
45
+ ...(after ? { after } : {}),
46
+ });
47
+ const page = rows.slice(0, limit);
48
+ const read = await Promise.all(page.map((row) => this.options.store.read(client.tenant, row.id, now, notBefore)));
49
+ // A conversation purged or forgotten between list and read simply drops out of the page.
50
+ const conversations = read
51
+ .filter((conversation) => conversation?.subject.kind === 'customer' && conversation.subject.ref === subject.ref)
52
+ .map(row);
53
+ const last = page.at(-1);
54
+ return {
55
+ response: AssistantConversationListResponseSchema.parse({
56
+ ok: true,
57
+ data: {
58
+ conversations,
59
+ ...(rows.length > limit && last
60
+ ? { nextCursor: encodeConversationCursor(binding, last) }
61
+ : {}),
62
+ },
63
+ }),
64
+ audit: { eventType: 'conversation.listed', details: { count: conversations.length } },
65
+ };
66
+ }
67
+ /** Erasure never consults the recording policy: a business that stopped recording can still forget. */
68
+ async forgetUser(client, body) {
69
+ const parsed = AssistantConversationForgetUserRequestSchema.safeParse(body);
70
+ if (!parsed.success)
71
+ throw new ConversationHistoryError('conversation_invalid');
72
+ const forgotten = await this.options.store.forgetSubject(client.tenant, customer(parsed.data.user.id));
73
+ return {
74
+ response: ConversationForgetResponseSchema.parse({ ok: true, data: { forgotten } }),
75
+ audit: {
76
+ eventType: 'conversation.forgotten',
77
+ details: {
78
+ conversations: forgotten.conversations,
79
+ items: forgotten.items,
80
+ kind: 'customer',
81
+ },
82
+ },
83
+ };
84
+ }
85
+ #hash(value) {
86
+ return createHmac('sha256', this.options.identityKey)
87
+ .update(canonicalJson(value))
88
+ .digest('hex');
89
+ }
90
+ }
91
+ function customer(ref) {
92
+ return { kind: 'customer', ref };
93
+ }
94
+ function row(conversation) {
95
+ const preview = previewOf(conversation);
96
+ return {
97
+ id: conversation.id,
98
+ channel: conversation.channel,
99
+ startedAt: new Date(conversation.startedAt).toISOString(),
100
+ lastMessageAt: new Date(conversation.lastMessageAt).toISOString(),
101
+ ...(preview ? { preview } : {}),
102
+ };
103
+ }
104
+ /** The first visible question, whitespace-collapsed and cut without splitting a surrogate pair. */
105
+ function previewOf(conversation) {
106
+ const first = conversation.items.find((item) => item.kind === 'message' && item.role === 'user');
107
+ if (first?.kind !== 'message')
108
+ return undefined;
109
+ let text = first.text.replace(/\s+/g, ' ').trim();
110
+ if (text.length > ASSISTANT_CONVERSATION_PREVIEW_MAXIMUM) {
111
+ text = text.slice(0, ASSISTANT_CONVERSATION_PREVIEW_MAXIMUM);
112
+ if (/[\uD800-\uDBFF]$/.test(text))
113
+ text = text.slice(0, -1);
114
+ text = text.trimEnd();
115
+ }
116
+ return text || undefined;
117
+ }
118
+ //# sourceMappingURL=customer.js.map