@noodleseed/one 0.177.0 → 0.179.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 +65 -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/business-information/contracts.d.ts +3 -1
- package/node_modules/@noodle-borg/service/dist/business-information/workspace-permissions.js +1 -0
- 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 +147 -24
- package/node_modules/@noodle-borg/service/dist/conversation-history/preview.js +43 -0
- 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 +67 -13
- 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 +35 -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 +182 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +78 -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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { canonicalJson } from '@noodle-borg/compiler';
|
|
3
|
-
import { ConversationExportResponseSchema, ConversationForgetRequestSchema, ConversationForgetResponseSchema, ConversationIdSchema, ConversationListResponseSchema, ConversationShowResponseSchema, } from '@noodle-borg/wire-contracts';
|
|
3
|
+
import { ConversationExportResponseSchema, ConversationForgetRequestSchema, ConversationForgetResponseSchema, ConversationIdSchema, ConversationListResponseSchema, ConversationReviewRequestSchema, ConversationReviewStatusSchema, ConversationShowResponseSchema, } from '@noodle-borg/wire-contracts';
|
|
4
|
+
import { maskPaymentCards } from '../payment-card.js';
|
|
5
|
+
import { CONVERSATION_DAY_MS, CONVERSATION_NOTE_LIMIT, } from './contracts.js';
|
|
4
6
|
const runLocal = (operation) => operation();
|
|
5
7
|
const MESSAGES = {
|
|
6
8
|
conversation_invalid: 'Invalid conversation request.',
|
|
@@ -17,16 +19,22 @@ export class ConversationHistoryError extends Error {
|
|
|
17
19
|
const PAGE = { list: { default: 50, maximum: 100 }, export: { default: 25, maximum: 25 } };
|
|
18
20
|
/**
|
|
19
21
|
* The staff projection of conversation history (ADR 0241). Callers authorize before and after, and every
|
|
20
|
-
* read, export and forget returns identifier-only audit details; message
|
|
22
|
+
* read, review, note, export and forget returns identifier-only audit details; message and note text
|
|
23
|
+
* never reach audit.
|
|
21
24
|
*/
|
|
22
25
|
export class ApplicationConversations {
|
|
23
26
|
options;
|
|
24
27
|
constructor(options) {
|
|
25
28
|
this.options = options;
|
|
26
29
|
}
|
|
30
|
+
/** Whether this installation serves a Preview environment; failure keeps production rules. */
|
|
31
|
+
async isPreview(scope) {
|
|
32
|
+
const tenant = { org: scope.org, app: scope.app, env: scope.env };
|
|
33
|
+
return (await this.options.preview?.(tenant).catch(() => false)) ?? false;
|
|
34
|
+
}
|
|
27
35
|
async project(scope, action, input, local = runLocal) {
|
|
28
36
|
const tenant = { org: scope.org, app: scope.app, env: scope.env };
|
|
29
|
-
const allowed = action === 'list' || action === 'export' ? ['limit', 'cursor', 'channel'] : [];
|
|
37
|
+
const allowed = action === 'list' || action === 'export' ? ['limit', 'cursor', 'channel', 'status'] : [];
|
|
30
38
|
const parameters = new Map();
|
|
31
39
|
for (const [key, value] of input.parameters) {
|
|
32
40
|
if (!allowed.includes(key) || parameters.has(key))
|
|
@@ -37,36 +45,94 @@ export class ApplicationConversations {
|
|
|
37
45
|
eventType,
|
|
38
46
|
details,
|
|
39
47
|
});
|
|
48
|
+
if (action === 'review') {
|
|
49
|
+
const id = ConversationIdSchema.safeParse(input.conversationId);
|
|
50
|
+
if (!id.success)
|
|
51
|
+
throw new ConversationHistoryError('conversation_not_found');
|
|
52
|
+
const parsed = ConversationReviewRequestSchema.safeParse(input.body);
|
|
53
|
+
if (!parsed.success || (parsed.data.note !== undefined && !input.actor))
|
|
54
|
+
throw invalid();
|
|
55
|
+
const { reviewStatus, note } = parsed.data;
|
|
56
|
+
const now = this.#now();
|
|
57
|
+
const notBefore = await this.#notBefore(tenant, now);
|
|
58
|
+
const conversation = await local(async () => {
|
|
59
|
+
const current = await this.options.store.read(tenant, id.data, now, notBefore);
|
|
60
|
+
if (!current)
|
|
61
|
+
throw new ConversationHistoryError('conversation_not_found');
|
|
62
|
+
if (note !== undefined && current.notes.length >= CONVERSATION_NOTE_LIMIT)
|
|
63
|
+
throw invalid();
|
|
64
|
+
if (reviewStatus !== undefined)
|
|
65
|
+
await this.options.store.setReviewStatus(tenant, id.data, reviewStatus);
|
|
66
|
+
if (note !== undefined)
|
|
67
|
+
await this.options.store.addNote(tenant, id.data, {
|
|
68
|
+
author: String(input.actor),
|
|
69
|
+
text: maskPaymentCards(note),
|
|
70
|
+
at: now,
|
|
71
|
+
});
|
|
72
|
+
return this.options.store.read(tenant, id.data, now, notBefore);
|
|
73
|
+
});
|
|
74
|
+
if (!conversation)
|
|
75
|
+
throw new ConversationHistoryError('conversation_not_found');
|
|
76
|
+
return {
|
|
77
|
+
response: ConversationShowResponseSchema.parse({
|
|
78
|
+
ok: true,
|
|
79
|
+
data: { conversation: shown(conversation, now - notBefore) },
|
|
80
|
+
}),
|
|
81
|
+
audits: [
|
|
82
|
+
...(reviewStatus === undefined
|
|
83
|
+
? []
|
|
84
|
+
: [audit('conversation.reviewed', { conversationId: id.data, reviewStatus })]),
|
|
85
|
+
...(note === undefined ? [] : [audit('conversation.noted', { conversationId: id.data })]),
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
40
89
|
if (action === 'forget') {
|
|
41
90
|
const parsed = ConversationForgetRequestSchema.safeParse(input.body);
|
|
42
91
|
if (!parsed.success)
|
|
43
92
|
throw invalid();
|
|
44
93
|
const request = parsed.data;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
94
|
+
// A WhatsApp participant's working memory goes with their history (ADR 0241); a web session
|
|
95
|
+
// expires on its own, so a customer has none to erase.
|
|
96
|
+
const { forgotten, workingMemory } = await local(async () => {
|
|
97
|
+
if ('conversationId' in request)
|
|
98
|
+
return {
|
|
99
|
+
forgotten: await this.options.store.forget(tenant, request.conversationId),
|
|
100
|
+
workingMemory: false,
|
|
101
|
+
};
|
|
102
|
+
const erased = await this.options.store.forgetSubject(tenant, request.subject);
|
|
103
|
+
return {
|
|
104
|
+
forgotten: erased,
|
|
105
|
+
workingMemory: request.subject.kind === 'participant' &&
|
|
106
|
+
(await input.forgetWorkingMemory?.(tenant, request.subject.ref, String(input.actor))) === true,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
48
109
|
return {
|
|
49
110
|
response: ConversationForgetResponseSchema.parse({ ok: true, data: { forgotten } }),
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
111
|
+
audits: [
|
|
112
|
+
audit('conversation.forgotten', {
|
|
113
|
+
conversations: forgotten.conversations,
|
|
114
|
+
items: forgotten.items,
|
|
115
|
+
kind: 'conversationId' in request ? 'conversation' : request.subject.kind,
|
|
116
|
+
workingMemory,
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
55
119
|
};
|
|
56
120
|
}
|
|
57
121
|
if (action === 'show') {
|
|
58
122
|
const id = ConversationIdSchema.safeParse(input.conversationId);
|
|
59
123
|
if (!id.success)
|
|
60
124
|
throw new ConversationHistoryError('conversation_not_found');
|
|
61
|
-
const
|
|
125
|
+
const now = this.#now();
|
|
126
|
+
const notBefore = await this.#notBefore(tenant, now);
|
|
127
|
+
const conversation = await local(() => this.options.store.read(tenant, id.data, now, notBefore));
|
|
62
128
|
if (!conversation)
|
|
63
129
|
throw new ConversationHistoryError('conversation_not_found');
|
|
64
130
|
return {
|
|
65
131
|
response: ConversationShowResponseSchema.parse({
|
|
66
132
|
ok: true,
|
|
67
|
-
data: { conversation:
|
|
133
|
+
data: { conversation: shown(conversation, now - notBefore) },
|
|
68
134
|
}),
|
|
69
|
-
|
|
135
|
+
audits: [audit('conversation.read', { conversationId: id.data })],
|
|
70
136
|
};
|
|
71
137
|
}
|
|
72
138
|
const page = await this.#page(scope, tenant, action, parameters, local);
|
|
@@ -77,13 +143,16 @@ export class ApplicationConversations {
|
|
|
77
143
|
data: {
|
|
78
144
|
conversations: page.summaries.map(summary),
|
|
79
145
|
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
146
|
+
...((await this.isPreview(scope)) ? { preview: { retentionDays: page.days } } : {}),
|
|
80
147
|
},
|
|
81
148
|
}),
|
|
149
|
+
audits: [],
|
|
82
150
|
};
|
|
83
151
|
const conversations = await local(async () => {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
152
|
+
const read = await Promise.all(page.summaries.map((row) => this.options.store.read(tenant, row.id, page.now, page.notBefore)));
|
|
153
|
+
return read
|
|
154
|
+
.filter((row) => row !== undefined)
|
|
155
|
+
.map((row) => detail(row, page.now - page.notBefore));
|
|
87
156
|
});
|
|
88
157
|
return {
|
|
89
158
|
response: ConversationExportResponseSchema.parse({
|
|
@@ -93,18 +162,20 @@ export class ApplicationConversations {
|
|
|
93
162
|
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
94
163
|
},
|
|
95
164
|
}),
|
|
96
|
-
|
|
165
|
+
audits: [audit('conversation.exported', { count: conversations.length })],
|
|
97
166
|
};
|
|
98
167
|
}
|
|
99
168
|
async #page(scope, tenant, purpose, parameters, local) {
|
|
100
169
|
const bounds = PAGE[purpose];
|
|
101
170
|
const limit = parameters.has('limit') ? Number(parameters.get('limit')) : bounds.default;
|
|
102
171
|
const channel = parameters.get('channel');
|
|
172
|
+
const status = parameters.get('status');
|
|
103
173
|
const cursor = parameters.get('cursor');
|
|
104
174
|
if (!Number.isInteger(limit) ||
|
|
105
175
|
limit < 1 ||
|
|
106
176
|
limit > bounds.maximum ||
|
|
107
177
|
(channel !== undefined && channel !== 'website' && channel !== 'whatsapp') ||
|
|
178
|
+
(status !== undefined && !ConversationReviewStatusSchema.safeParse(status).success) ||
|
|
108
179
|
(cursor !== undefined && (!cursor || cursor.length > 2048)))
|
|
109
180
|
throw invalid();
|
|
110
181
|
const binding = this.#hash({
|
|
@@ -112,26 +183,46 @@ export class ApplicationConversations {
|
|
|
112
183
|
scope,
|
|
113
184
|
limit,
|
|
114
185
|
channel: channel ?? null,
|
|
186
|
+
status: status ?? null,
|
|
115
187
|
});
|
|
116
188
|
const after = decodeConversationCursor(cursor, binding);
|
|
189
|
+
const now = this.#now();
|
|
190
|
+
const { notBefore, days } = await this.#window(tenant, now);
|
|
117
191
|
const rows = await local(() => this.options.store.list(tenant, {
|
|
118
|
-
now
|
|
192
|
+
now,
|
|
193
|
+
notBefore,
|
|
119
194
|
limit: limit + 1,
|
|
120
195
|
...(after ? { after } : {}),
|
|
121
196
|
...(channel ? { channel: channel } : {}),
|
|
197
|
+
...(status ? { reviewStatus: status } : {}),
|
|
122
198
|
}));
|
|
123
199
|
const summaries = rows.slice(0, limit);
|
|
124
200
|
const last = summaries.at(-1);
|
|
125
201
|
return {
|
|
202
|
+
now,
|
|
203
|
+
notBefore,
|
|
204
|
+
days,
|
|
126
205
|
summaries,
|
|
127
|
-
nextCursor: rows.length > limit && last
|
|
128
|
-
? Buffer.from(JSON.stringify({ binding, lastMessageAt: last.lastMessageAt, id: last.id })).toString('base64url')
|
|
129
|
-
: undefined,
|
|
206
|
+
nextCursor: rows.length > limit && last ? encodeConversationCursor(binding, last) : undefined,
|
|
130
207
|
};
|
|
131
208
|
}
|
|
132
209
|
#now() {
|
|
133
210
|
return this.options.now?.() ?? Date.now();
|
|
134
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Oldest visible item time under the live window. Items the window hides are not deleted: they purge
|
|
214
|
+
* at their stamped expiry, so restoring a longer plan before then shows them again. That recovers
|
|
215
|
+
* nothing deleted, which is all the business history contract forbids.
|
|
216
|
+
*/
|
|
217
|
+
async #notBefore(tenant, now) {
|
|
218
|
+
return (await this.#window(tenant, now)).notBefore;
|
|
219
|
+
}
|
|
220
|
+
async #window(tenant, now) {
|
|
221
|
+
const policy = await this.options.policy(tenant).catch(() => undefined);
|
|
222
|
+
if (!policy || !Number.isInteger(policy.maximumDays))
|
|
223
|
+
throw new ConversationHistoryError('conversation_unavailable');
|
|
224
|
+
return { days: conversationWindowDays(policy), notBefore: conversationReadBound(policy, now) };
|
|
225
|
+
}
|
|
135
226
|
#hash(value) {
|
|
136
227
|
return createHmac('sha256', this.options.identityKey)
|
|
137
228
|
.update(canonicalJson(value))
|
|
@@ -149,11 +240,30 @@ function summary(row) {
|
|
|
149
240
|
startedAt: new Date(row.startedAt).toISOString(),
|
|
150
241
|
lastMessageAt: new Date(row.lastMessageAt).toISOString(),
|
|
151
242
|
itemCount: row.itemCount,
|
|
243
|
+
reviewStatus: row.reviewStatus,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The last visible item's effective removal: its stored expiry, or sooner where the live window of
|
|
248
|
+
* `windowMs` hides it first.
|
|
249
|
+
*/
|
|
250
|
+
function removalAt(conversation, windowMs) {
|
|
251
|
+
return Math.max(...conversation.items.map((item) => Math.min(item.expiresAt, item.at + windowMs)));
|
|
252
|
+
}
|
|
253
|
+
function shown(conversation, windowMs) {
|
|
254
|
+
return {
|
|
255
|
+
...detail(conversation, windowMs),
|
|
256
|
+
notes: conversation.notes.map((note) => ({
|
|
257
|
+
author: note.author,
|
|
258
|
+
text: note.text,
|
|
259
|
+
at: new Date(note.at).toISOString(),
|
|
260
|
+
})),
|
|
152
261
|
};
|
|
153
262
|
}
|
|
154
|
-
function detail(conversation) {
|
|
263
|
+
function detail(conversation, windowMs) {
|
|
155
264
|
return {
|
|
156
265
|
...summary({ ...conversation, itemCount: conversation.items.length }),
|
|
266
|
+
expiresAt: new Date(removalAt(conversation, windowMs)).toISOString(),
|
|
157
267
|
items: conversation.items.map((item) => item.kind === 'message'
|
|
158
268
|
? { kind: item.kind, role: item.role, text: item.text, at: new Date(item.at).toISOString() }
|
|
159
269
|
: {
|
|
@@ -165,7 +275,20 @@ function detail(conversation) {
|
|
|
165
275
|
}),
|
|
166
276
|
};
|
|
167
277
|
}
|
|
168
|
-
|
|
278
|
+
/** Oldest visible item time under a policy's live window; Off hides every item. */
|
|
279
|
+
export function conversationReadBound(policy, now) {
|
|
280
|
+
const days = conversationWindowDays(policy);
|
|
281
|
+
return days === 0 ? now + 1 : now - days * CONVERSATION_DAY_MS;
|
|
282
|
+
}
|
|
283
|
+
/** The live window in days: the chosen duration under the plan maximum; 0 hides everything. */
|
|
284
|
+
function conversationWindowDays(policy) {
|
|
285
|
+
return Math.max(0, Math.min(policy.conversationDays ?? 0, policy.maximumDays));
|
|
286
|
+
}
|
|
287
|
+
/** An opaque keyset position bound to the purpose, scope and page shape that `binding` hashes. */
|
|
288
|
+
export function encodeConversationCursor(binding, last) {
|
|
289
|
+
return Buffer.from(JSON.stringify({ binding, lastMessageAt: last.lastMessageAt, id: last.id })).toString('base64url');
|
|
290
|
+
}
|
|
291
|
+
export function decodeConversationCursor(raw, binding) {
|
|
169
292
|
if (raw === undefined)
|
|
170
293
|
return undefined;
|
|
171
294
|
try {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ALL_CONVERSATION_SOURCES, } from './contracts.js';
|
|
2
|
+
/**
|
|
3
|
+
* ADR 0241 decision 18: a Preview environment's conversations are kept this long, whatever the stored
|
|
4
|
+
* opt-in or recording switches, and never longer than the plan maximum.
|
|
5
|
+
*/
|
|
6
|
+
export const PREVIEW_CONVERSATION_DAYS = 3;
|
|
7
|
+
/**
|
|
8
|
+
* A Preview environment is a deployed environment of an app whose production environment is another,
|
|
9
|
+
* designated one. An unknown environment, an app with no resolved production designation, or an
|
|
10
|
+
* unreadable registry keeps production semantics, so a live chat never loses its opt-in or reaches a
|
|
11
|
+
* Builder by default.
|
|
12
|
+
*/
|
|
13
|
+
export function previewEnvironments(registry) {
|
|
14
|
+
return async (tenant) => {
|
|
15
|
+
try {
|
|
16
|
+
const current = await registry.getEnvironment(tenant.org, tenant.app, tenant.env);
|
|
17
|
+
if (current === undefined || current.isProduction)
|
|
18
|
+
return false;
|
|
19
|
+
const environments = await registry.listEnvironments(tenant.org, tenant.app);
|
|
20
|
+
return environments.some((item) => item.isProduction && item.envName !== tenant.env);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A Preview tenant records every source for the fixed window; production policy is returned as is. */
|
|
28
|
+
export function withPreviewConversations(policy, isPreview) {
|
|
29
|
+
return async (tenant) => {
|
|
30
|
+
const current = await policy(tenant);
|
|
31
|
+
if (current === undefined || !(await isPreview(tenant)))
|
|
32
|
+
return current;
|
|
33
|
+
return previewConversationPolicy(current.maximumDays);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function previewConversationPolicy(maximumDays) {
|
|
37
|
+
return {
|
|
38
|
+
maximumDays,
|
|
39
|
+
conversationDays: Math.max(0, Math.min(PREVIEW_CONVERSATION_DAYS, maximumDays)),
|
|
40
|
+
sources: { ...ALL_CONVERSATION_SOURCES },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=preview.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
2
|
+
import { ConversationHistoryError } from '../conversation-history/operator.js';
|
|
3
|
+
import { sendUnauthorized } from '../http-util.js';
|
|
4
|
+
import { basicCredentials } from './assistant-route-http.js';
|
|
5
|
+
/**
|
|
6
|
+
* Backend-only conversation routes for an embed client (ADR 0241 decision 12). They authenticate
|
|
7
|
+
* exactly like the session exchange and never read a tenant from the body; there is no browser CORS
|
|
8
|
+
* surface, because the client secret must never reach a browser.
|
|
9
|
+
*/
|
|
10
|
+
export async function handleAssistantConversations(req, res, action, deps) {
|
|
11
|
+
if (req.method !== 'POST') {
|
|
12
|
+
res.setHeader('allow', 'POST');
|
|
13
|
+
return sendJson(res, 405, { error: 'method not allowed' });
|
|
14
|
+
}
|
|
15
|
+
const credentials = basicCredentials(req);
|
|
16
|
+
if (!credentials)
|
|
17
|
+
return sendUnauthorized(res, 'invalid assistant client');
|
|
18
|
+
const client = await deps.store.authenticateClient(credentials.id, credentials.secret);
|
|
19
|
+
if (!client)
|
|
20
|
+
return sendUnauthorized(res, 'invalid assistant client');
|
|
21
|
+
res.setHeader('cache-control', 'private, no-store');
|
|
22
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
23
|
+
if (!body.ok)
|
|
24
|
+
return sendJson(res, body.status, { error: body.error });
|
|
25
|
+
try {
|
|
26
|
+
const conversations = deps.customerConversations;
|
|
27
|
+
if (!conversations)
|
|
28
|
+
throw new ConversationHistoryError('conversation_unavailable');
|
|
29
|
+
const owner = { id: client.id, tenant: client.tenant };
|
|
30
|
+
const result = action === 'list'
|
|
31
|
+
? await conversations.list(owner, body.value)
|
|
32
|
+
: await conversations.forgetUser(owner, body.value);
|
|
33
|
+
await deps.audit.emit({
|
|
34
|
+
...result.audit,
|
|
35
|
+
org: client.tenant.org,
|
|
36
|
+
app: client.tenant.app,
|
|
37
|
+
env: client.tenant.env,
|
|
38
|
+
decision: 'allow',
|
|
39
|
+
actorSubject: client.id,
|
|
40
|
+
});
|
|
41
|
+
sendJson(res, 200, result.response);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!(error instanceof ConversationHistoryError))
|
|
45
|
+
throw error;
|
|
46
|
+
sendJson(res, error.code === 'conversation_unavailable' ? 503 : 400, {
|
|
47
|
+
code: error.code,
|
|
48
|
+
error: error.message,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=assistant-conversations.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { handleAssistantAppRequest, handleAssistantPreflight, handleAssistantSession, handleAssistantTurn, handleConsoleApprovalNonce, } from './assistant.js';
|
|
2
2
|
import { handleAssistantAppearance } from './assistant-appearance.js';
|
|
3
3
|
import { handleAssistantClients } from './assistant-clients.js';
|
|
4
|
+
import { handleAssistantConversations } from './assistant-conversations.js';
|
|
4
5
|
import { handleAssistantDoctor } from './assistant-doctor.js';
|
|
5
6
|
import { handleAssistantEmbedScript } from './assistant-embed-script.js';
|
|
6
7
|
import { handleAssistantEmbeds } from './assistant-embeds.js';
|
|
@@ -60,6 +61,11 @@ export function dispatchAssistantRoutes(req, res, url, deps) {
|
|
|
60
61
|
if (url.pathname === '/v1/assistant/sessions' && req.method === 'POST') {
|
|
61
62
|
return run(req, res, deps, () => handleAssistantSession(req, res, deps));
|
|
62
63
|
}
|
|
64
|
+
const conversationsMatch = /^\/v1\/assistant\/conversations\/(list|forget-user)$/.exec(url.pathname);
|
|
65
|
+
if (conversationsMatch?.[1] === 'list' || conversationsMatch?.[1] === 'forget-user') {
|
|
66
|
+
const action = conversationsMatch[1];
|
|
67
|
+
return run(req, res, deps, () => handleAssistantConversations(req, res, action, deps));
|
|
68
|
+
}
|
|
63
69
|
if (url.pathname === '/v1/assistant/turns' && req.method === 'POST') {
|
|
64
70
|
return run(req, res, deps, () => handleAssistantTurn(req, res, deps));
|
|
65
71
|
}
|
|
@@ -3,6 +3,7 @@ import { anonymousBehavior } from '@noodle-borg/compiler';
|
|
|
3
3
|
import { claimableStateHandleNames } from '@noodle-borg/runtime';
|
|
4
4
|
import { sendJson } from '@noodle-borg/transport-http';
|
|
5
5
|
import { assistantSessionResponseSchema } from '@noodle-borg/wire-contracts';
|
|
6
|
+
import { sessionHistoryNotice, } from '../conversation-history/capture.js';
|
|
6
7
|
function isMixed(surface) {
|
|
7
8
|
return surface?.mode === 'mixed';
|
|
8
9
|
}
|
|
@@ -147,6 +148,8 @@ export async function elevateAssistantSession(res, deps, input) {
|
|
|
147
148
|
// The armed hint: the widget answers with one { resume: true } turn on the turns endpoint.
|
|
148
149
|
...(elevated.resumeArmed ? { resume: { tool: elevated.tool } } : {}),
|
|
149
150
|
continuedAfterAuthentication: true,
|
|
151
|
+
// The caller is now verified, so the notice states the signed-in customers' window.
|
|
152
|
+
...(await sessionHistoryNotice(deps.conversations, input.client.tenant, 'signed_in_customers', input.surfaceHistory)),
|
|
150
153
|
};
|
|
151
154
|
assistantSessionResponseSchema.parse(body);
|
|
152
155
|
return sendJson(res, 200, body);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assistantConfirmationProposal } from '@noodle-borg/assistant-gateway/portable';
|
|
2
|
+
import { sessionSurfaceHistory } from '../conversation-history/capture.js';
|
|
2
3
|
import { narrateInteractionResolution } from './assistant-agent.js';
|
|
3
4
|
/** Stream the optional post-resolution narration without weakening the durable structured result. */
|
|
4
5
|
export async function narrateResolvedInteraction(res, deps, target, session, tool, action, result, context, suggestions = false) {
|
|
@@ -10,7 +11,7 @@ export async function narrateResolvedInteraction(res, deps, target, session, too
|
|
|
10
11
|
{ role: 'assistant', content: generated.narration, kind: 'visible' },
|
|
11
12
|
];
|
|
12
13
|
await deps.store.appendHistory(session.id, rows);
|
|
13
|
-
await deps.conversations?.recordSessionTurn(session, rows);
|
|
14
|
+
await deps.conversations?.recordSessionTurn(session, rows, sessionSurfaceHistory(target.served.artifact.server.assistant, session));
|
|
14
15
|
}
|
|
15
16
|
if (generated.suggestions.length > 0) {
|
|
16
17
|
await deps.store.replaceLatestSuggestions(session.id, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AssistantInteractionCapacityError, assistantConfirmationReview, assistantConfirmationTransition, assistantInputTransition, assistantInteractionTool, assistantPreparedArgumentReview, assistantPreparedToolContinuation, assistantSafeOutput, assistantToolContinuation, assistantViewAvailableData, auditAssistantInteraction, boundedAssistantExecution, recoverableAssistantView, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
|
|
2
2
|
import { executePreparedTool, executeToolInteractive, hasEphemeralEvidence, resumeTool, resumeToolPreparation, } from '@noodle-borg/runtime';
|
|
3
3
|
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
4
|
+
import { sessionSurfaceHistory } from '../conversation-history/capture.js';
|
|
4
5
|
import { applyBrowserCors, authenticateSession, now, } from './assistant.js';
|
|
5
6
|
import { replayOrConflict, writeUnknownOutcome } from './assistant-interaction-replay.js';
|
|
6
7
|
import { parseInteractionResponse, validateInputContent } from './assistant-interaction-request.js';
|
|
@@ -340,11 +341,7 @@ async function finishExecution(res, deps, session, interaction, context, result,
|
|
|
340
341
|
status: 'failed',
|
|
341
342
|
reasonCode: code,
|
|
342
343
|
});
|
|
343
|
-
await deps.conversations?.recordSessionOutcome(session, {
|
|
344
|
-
interactionId: interaction.id,
|
|
345
|
-
tool: toolName,
|
|
346
|
-
status: 'failed',
|
|
347
|
-
});
|
|
344
|
+
await deps.conversations?.recordSessionOutcome(session, { interactionId: interaction.id, tool: toolName, status: 'failed' }, sessionSurfaceHistory(target.served.artifact.server.assistant, session));
|
|
348
345
|
writeInteractionSseHeaders(res, session.origin);
|
|
349
346
|
writeInteractionResolved(res, interaction.id, 'accept');
|
|
350
347
|
writeInteractionError(res, code, false);
|
|
@@ -375,11 +372,7 @@ async function finishExecution(res, deps, session, interaction, context, result,
|
|
|
375
372
|
kind: 'narration',
|
|
376
373
|
},
|
|
377
374
|
]);
|
|
378
|
-
await deps.conversations?.recordSessionOutcome(session, {
|
|
379
|
-
interactionId: interaction.id,
|
|
380
|
-
tool: toolName,
|
|
381
|
-
status: 'succeeded',
|
|
382
|
-
});
|
|
375
|
+
await deps.conversations?.recordSessionOutcome(session, { interactionId: interaction.id, tool: toolName, status: 'succeeded' }, sessionSurfaceHistory(target.served.artifact.server.assistant, session));
|
|
383
376
|
writeInteractionSseHeaders(res, session.origin);
|
|
384
377
|
writeInteractionResolved(res, interaction.id, 'accept');
|
|
385
378
|
writeToolCompleted(res, interaction.id, toolName, safeOutput);
|
|
@@ -463,7 +456,9 @@ async function resolveStop(req, res, deps, session, interaction, action, suggest
|
|
|
463
456
|
interactionId: interaction.id,
|
|
464
457
|
tool: toolName,
|
|
465
458
|
status: action === 'decline' ? 'declined' : 'cancelled',
|
|
466
|
-
}
|
|
459
|
+
},
|
|
460
|
+
// A vanished target leaves the surface unreadable, which records nothing (fail closed).
|
|
461
|
+
sessionSurfaceHistory(target?.served.artifact.server.assistant, session));
|
|
467
462
|
writeInteractionSseHeaders(res, session.origin);
|
|
468
463
|
writeInteractionResolved(res, interaction.id, action);
|
|
469
464
|
if (target && context) {
|
|
@@ -4,6 +4,7 @@ import { effectiveAssistantBrowserConfiguration, mintPublicSession, publicEmbedT
|
|
|
4
4
|
import { assistantSessionUsageRequestEvent, captureAssistantUsage, captureRefusedAssistantSession, } from '@noodle-borg/observability';
|
|
5
5
|
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
6
6
|
import { assistantSessionResponseSchema } from '@noodle-borg/wire-contracts';
|
|
7
|
+
import { sessionHistoryNotice, websiteSurfaceHistory, } from '../conversation-history/capture.js';
|
|
7
8
|
import { applyBrowserCors, assistantSessionEndpoints, now } from './assistant-route-http.js';
|
|
8
9
|
import { activeAssistantTarget } from './assistant-session-target.js';
|
|
9
10
|
/**
|
|
@@ -35,6 +36,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
|
|
|
35
36
|
let configuration;
|
|
36
37
|
let sponsoredBudget;
|
|
37
38
|
let usageSession;
|
|
39
|
+
let surfaceHistory = { historyDisabled: true };
|
|
38
40
|
const result = await mintPublicSession({ embedId, visitorId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
|
|
39
41
|
embeds,
|
|
40
42
|
counters,
|
|
@@ -59,6 +61,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
|
|
|
59
61
|
!publicSurfaceOf(target.served.artifact.server.assistant)?.origins.includes(input.origin))
|
|
60
62
|
throw new Error('assistant surface changed mid-mint');
|
|
61
63
|
const current = now(deps);
|
|
64
|
+
surfaceHistory = websiteSurfaceHistory(target.served.artifact.server.assistant, 'public');
|
|
62
65
|
configuration = (await effectiveAssistantBrowserConfiguration(target.served.artifact.server, publicEmbedTenant(input.embed), deps.appearance, 'public', target.businessNotice)).effective;
|
|
63
66
|
const created = await deps.store.createSession({
|
|
64
67
|
// A public surface has no client credential, so the embed id stands in as the minting
|
|
@@ -99,6 +102,9 @@ export async function handlePublicAssistantSession(req, res, deps) {
|
|
|
99
102
|
expiresAt: result.expiresAt,
|
|
100
103
|
endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
|
|
101
104
|
...(configuration ? { configuration } : {}),
|
|
105
|
+
...(usageSession
|
|
106
|
+
? await sessionHistoryNotice(deps.conversations, usageSession.tenant, 'website_visitors', surfaceHistory)
|
|
107
|
+
: {}),
|
|
102
108
|
};
|
|
103
109
|
// Parse rather than trust: every published @noodleseed/assistant widget consumes this shape, and the
|
|
104
110
|
// public mint must not be the path that quietly diverges from it (ADR 0151).
|
|
@@ -60,4 +60,20 @@ export function assistantSessionEndpoints(base) {
|
|
|
60
60
|
suggestions: `${base}/v1/assistant/suggestions`,
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
/** HTTP Basic embed-client credentials for the backend-only assistant routes. */
|
|
64
|
+
export function basicCredentials(req) {
|
|
65
|
+
const match = /^Basic\s+(.+)$/i.exec(req.headers.authorization ?? '');
|
|
66
|
+
if (!match?.[1])
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
|
70
|
+
const index = decoded.indexOf(':');
|
|
71
|
+
return index > 0
|
|
72
|
+
? { id: decoded.slice(0, index), secret: decoded.slice(index + 1) }
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
//# sourceMappingURL=assistant-route-http.js.map
|
|
@@ -8,11 +8,12 @@ import { mapExecutionError, mapResourceContents, mapResourcesList, mapToolOutput
|
|
|
8
8
|
import { executeResource } from '@noodle-borg/runtime';
|
|
9
9
|
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
10
10
|
import { assistantMessageTurnRequestSchema, assistantResumeTurnRequestSchema, assistantSessionResponseSchema, } from '@noodle-borg/wire-contracts';
|
|
11
|
+
import { sessionHistoryNotice, sessionSurfaceHistory, websiteSurfaceHistory, } from '../conversation-history/capture.js';
|
|
11
12
|
import { sendForbidden, sendUnauthorized } from '../http-util.js';
|
|
12
13
|
import { createAssistantTurnStats, runAgentTurn } from './assistant-agent.js';
|
|
13
14
|
import { elevateAssistantSession } from './assistant-elevation.js';
|
|
14
15
|
import { executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, resolveAssistantKnowledge, } from './assistant-knowledge.js';
|
|
15
|
-
import { applyBrowserCors, assistantSessionEndpoints, authenticateSession, handleAssistantPreflight, now, } from './assistant-route-http.js';
|
|
16
|
+
import { applyBrowserCors, assistantSessionEndpoints, authenticateSession, basicCredentials, handleAssistantPreflight, now, } from './assistant-route-http.js';
|
|
16
17
|
import { activeAssistantTarget, sessionScopedTarget } from './assistant-session-target.js';
|
|
17
18
|
import { authorizeControlPlane } from './control-plane.js';
|
|
18
19
|
export { applyBrowserCors, authenticateSession, handleAssistantPreflight, now };
|
|
@@ -141,6 +142,7 @@ export async function handleAssistantSession(req, res, deps) {
|
|
|
141
142
|
caller,
|
|
142
143
|
configuration,
|
|
143
144
|
endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
|
|
145
|
+
surfaceHistory: websiteSurfaceHistory(assistant, surfaceBinding.kind),
|
|
144
146
|
});
|
|
145
147
|
}
|
|
146
148
|
const session = await deps.store.createSession({
|
|
@@ -166,6 +168,7 @@ export async function handleAssistantSession(req, res, deps) {
|
|
|
166
168
|
expiresAt: session.session.expiresAt,
|
|
167
169
|
endpoints: assistantSessionEndpoints(deps.serviceBase(req)),
|
|
168
170
|
...(configuration ? { configuration } : {}),
|
|
171
|
+
...(await sessionHistoryNotice(deps.conversations, client.tenant, 'signed_in_customers', websiteSurfaceHistory(assistant, surfaceBinding.kind))),
|
|
169
172
|
};
|
|
170
173
|
// Every published @noodleseed/assistant widget consumes this shape; parse (don't just test)
|
|
171
174
|
// so a contract break fails loudly at mint time instead of stranding deployed widgets (ADR 0151).
|
|
@@ -344,7 +347,7 @@ export async function handleAssistantTurn(req, res, deps) {
|
|
|
344
347
|
];
|
|
345
348
|
try {
|
|
346
349
|
await deps.store.appendHistory(session.id, rows);
|
|
347
|
-
await deps.conversations?.recordSessionTurn(session, rows);
|
|
350
|
+
await deps.conversations?.recordSessionTurn(session, rows, sessionSurfaceHistory(target?.served.artifact.server.assistant, session));
|
|
348
351
|
}
|
|
349
352
|
catch {
|
|
350
353
|
emit({ event: 'error', data: { code: 'conversation_state_failed', retryable: false } });
|
|
@@ -502,19 +505,4 @@ export async function handleAssistantAppRequest(req, res, deps) {
|
|
|
502
505
|
function isRecord(value) {
|
|
503
506
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
504
507
|
}
|
|
505
|
-
function basicCredentials(req) {
|
|
506
|
-
const match = /^Basic\s+(.+)$/i.exec(req.headers.authorization ?? '');
|
|
507
|
-
if (!match?.[1])
|
|
508
|
-
return undefined;
|
|
509
|
-
try {
|
|
510
|
-
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
|
511
|
-
const index = decoded.indexOf(':');
|
|
512
|
-
return index > 0
|
|
513
|
-
? { id: decoded.slice(0, index), secret: decoded.slice(index + 1) }
|
|
514
|
-
: undefined;
|
|
515
|
-
}
|
|
516
|
-
catch {
|
|
517
|
-
return undefined;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
508
|
//# sourceMappingURL=assistant.js.map
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
2
|
-
import { ActivityPolicyError
|
|
2
|
+
import { ActivityPolicyError } from '../application-activity.js';
|
|
3
3
|
import { businessGrantAllows } from '../business-information/model.js';
|
|
4
4
|
import { requireIdentity, requireInstallationPermission, } from './business-information.js';
|
|
5
5
|
export async function handleApplicationActivity(req, res, url, ref, deps) {
|
|
6
6
|
const coordination = ref.action === 'coordination';
|
|
7
|
+
const history = ref.action === 'history';
|
|
7
8
|
const suffix = url.pathname.split('/').at(-1);
|
|
8
9
|
const action = coordination
|
|
9
10
|
? suffix === 'resolve'
|
|
10
11
|
? 'coordination-resolve'
|
|
11
12
|
: 'coordination-list'
|
|
12
|
-
:
|
|
13
|
-
? req.method === '
|
|
13
|
+
: history
|
|
14
|
+
? req.method === 'PUT'
|
|
14
15
|
? 'save-settings'
|
|
15
16
|
: 'settings'
|
|
16
17
|
: suffix === 'preview' || suffix === 'export'
|
|
@@ -20,8 +21,8 @@ export async function handleApplicationActivity(req, res, url, ref, deps) {
|
|
|
20
21
|
? suffix === 'resolve'
|
|
21
22
|
? ['POST']
|
|
22
23
|
: ['GET']
|
|
23
|
-
:
|
|
24
|
-
? ['GET', '
|
|
24
|
+
: history
|
|
25
|
+
? ['GET', 'PUT']
|
|
25
26
|
: ['GET'];
|
|
26
27
|
if (!allowed.includes(req.method ?? '')) {
|
|
27
28
|
res.setHeader('allow', allowed.join(', '));
|