@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.
- package/dist/commands/solutions-activity.d.ts.map +1 -1
- package/dist/commands/solutions-activity.js +9 -35
- package/dist/commands/solutions-activity.js.map +1 -1
- package/dist/commands/solutions-conversations.d.ts.map +1 -1
- package/dist/commands/solutions-conversations.js +60 -6
- package/dist/commands/solutions-conversations.js.map +1 -1
- package/dist/commands/solutions-history.d.ts +9 -0
- package/dist/commands/solutions-history.d.ts.map +1 -0
- package/dist/commands/solutions-history.js +166 -0
- package/dist/commands/solutions-history.js.map +1 -0
- package/dist/commands/solutions-ops.d.ts.map +1 -1
- package/dist/commands/solutions-ops.js +3 -0
- package/dist/commands/solutions-ops.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts.map +1 -1
- package/dist/commands/whatsapp-ops.js +0 -7
- package/dist/commands/whatsapp-ops.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +14 -0
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +6 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +5 -2
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +18 -0
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +14 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +0 -3
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +50 -5
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +82 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +3 -42
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +8 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +7 -0
- package/node_modules/@noodle-borg/service/dist/application-activity.js +76 -21
- package/node_modules/@noodle-borg/service/dist/application-history-settings.js +108 -0
- package/node_modules/@noodle-borg/service/dist/assistant-composition.js +11 -2
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +10 -1
- package/node_modules/@noodle-borg/service/dist/channels/provider.js +13 -13
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +31 -5
- package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +63 -7
- package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +13 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/customer.js +118 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +133 -24
- package/node_modules/@noodle-borg/service/dist/routes/assistant-conversations.js +52 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +6 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +3 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +2 -1
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +6 -11
- package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +6 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +16 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant.js +5 -17
- package/node_modules/@noodle-borg/service/dist/routes/business-information-activity.js +6 -5
- package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +36 -11
- package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +4 -2
- package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +4 -2
- package/node_modules/@noodle-borg/service/dist/routes/business-information.js +4 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +0 -10
- package/node_modules/@noodle-borg/service/dist/service-history-projections.js +32 -4
- package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +32 -0
- package/node_modules/@noodle-borg/service/dist/service.js +4 -3
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.d.ts +0 -28
- package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.js +0 -18
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +190 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +92 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +3 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +6 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +176 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +74 -2
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodleseed/assistant/README.md +19 -0
- package/node_modules/@noodleseed/assistant/package.json +1 -1
- 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
|
-
...
|
|
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,
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
37
|
+
const PAYMENT_BLOCKS = new Set([141006]);
|
|
38
38
|
/**
|
|
39
|
-
* Parse a Cloud API health read. The aggregate `can_send_message` is
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|