@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.
Files changed (74) 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 +65 -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/business-information/contracts.d.ts +3 -1
  35. package/node_modules/@noodle-borg/service/dist/business-information/workspace-permissions.js +1 -0
  36. package/node_modules/@noodle-borg/service/dist/channels/composition.js +10 -1
  37. package/node_modules/@noodle-borg/service/dist/channels/provider.js +13 -13
  38. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +31 -5
  39. package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +63 -7
  40. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +13 -0
  41. package/node_modules/@noodle-borg/service/dist/conversation-history/customer.js +118 -0
  42. package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +147 -24
  43. package/node_modules/@noodle-borg/service/dist/conversation-history/preview.js +43 -0
  44. package/node_modules/@noodle-borg/service/dist/routes/assistant-conversations.js +52 -0
  45. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +6 -0
  46. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +3 -0
  47. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +2 -1
  48. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +6 -11
  49. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +6 -0
  50. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +16 -0
  51. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +5 -17
  52. package/node_modules/@noodle-borg/service/dist/routes/business-information-activity.js +6 -5
  53. package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +67 -13
  54. package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +4 -2
  55. package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +4 -2
  56. package/node_modules/@noodle-borg/service/dist/routes/business-information.js +4 -0
  57. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +0 -10
  58. package/node_modules/@noodle-borg/service/dist/service-history-projections.js +35 -4
  59. package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +32 -0
  60. package/node_modules/@noodle-borg/service/dist/service.js +4 -3
  61. package/node_modules/@noodle-borg/service/package.json +1 -1
  62. package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.d.ts +0 -28
  63. package/node_modules/@noodle-borg/wire-contracts/dist/application-activity.js +0 -18
  64. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +190 -0
  65. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +92 -0
  66. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +3 -0
  67. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +6 -0
  68. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +182 -0
  69. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +78 -2
  70. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  71. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  72. package/node_modules/@noodleseed/assistant/README.md +19 -0
  73. package/node_modules/@noodleseed/assistant/package.json +1 -1
  74. 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 text never reaches audit.
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
- const forgotten = await local(() => 'conversationId' in request
46
- ? this.options.store.forget(tenant, request.conversationId)
47
- : this.options.store.forgetSubject(tenant, request.subject));
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
- audit: audit('conversation.forgotten', {
51
- conversations: forgotten.conversations,
52
- items: forgotten.items,
53
- kind: 'conversationId' in request ? 'conversation' : request.subject.kind,
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 conversation = await local(() => this.options.store.read(tenant, id.data, this.#now()));
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: detail(conversation) },
133
+ data: { conversation: shown(conversation, now - notBefore) },
68
134
  }),
69
- audit: audit('conversation.read', { conversationId: id.data }),
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 now = this.#now();
85
- const read = await Promise.all(page.summaries.map((row) => this.options.store.read(tenant, row.id, now)));
86
- return read.filter((row) => row !== undefined).map(detail);
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
- audit: audit('conversation.exported', { count: conversations.length }),
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: this.#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
- function decodeConversationCursor(raw, binding) {
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, } from '../application-activity.js';
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
- : suffix === 'settings'
13
- ? req.method === 'PATCH'
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
- : suffix === 'settings'
24
- ? ['GET', 'PATCH']
24
+ : history
25
+ ? ['GET', 'PUT']
25
26
  : ['GET'];
26
27
  if (!allowed.includes(req.method ?? '')) {
27
28
  res.setHeader('allow', allowed.join(', '));