@frontera-sdk/functions 1.50.80 → 1.50.82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/functions",
3
- "version": "1.50.80",
3
+ "version": "1.50.82",
4
4
  "description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -42,7 +42,7 @@
42
42
  "typescript": "^5.9.3"
43
43
  },
44
44
  "dependencies": {
45
- "@frontera-sdk/blueprint": "1.50.80",
45
+ "@frontera-sdk/blueprint": "1.50.82",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
@@ -34,6 +34,7 @@ import type {
34
34
  AutomationContext,
35
35
  BlueprintQueryOptions,
36
36
  BlueprintQueryResult,
37
+ ConversationTranscript,
37
38
  HttpRequest,
38
39
  HttpResponse,
39
40
  PluginCallResult,
@@ -159,6 +160,98 @@ function summarize<T>(
159
160
  return size <= DETAIL_MAX_CHARS ? detail : { omitted: 'summary too large', chars: size }
160
161
  }
161
162
 
163
+ /**
164
+ * Every transcript `ctx.conversation.transcript()` has handed out, its `turns`
165
+ * array, and each of its turns.
166
+ *
167
+ * A step's return value is copied into its trace row, and the trace is exactly
168
+ * where a person's own words must not be kept. Branding the objects — not
169
+ * inspecting their shape — is what makes the check exact: a value that merely
170
+ * looks like a transcript is the author's own data and stays readable. Each
171
+ * turn is branded too, so `turns.filter(…)`, `turns.slice()` or one picked
172
+ * turn is still recognized. Held weakly, so nothing here keeps a transcript
173
+ * alive.
174
+ */
175
+ const handedOut = new WeakMap<object, 'transcript' | 'turns' | 'turn'>()
176
+
177
+ function brandTranscript(transcript: ConversationTranscript | null): ConversationTranscript | null {
178
+ if (transcript) {
179
+ handedOut.set(transcript, 'transcript')
180
+ if (Array.isArray(transcript.turns)) {
181
+ handedOut.set(transcript.turns, 'turns')
182
+ for (const turn of transcript.turns) {
183
+ if (typeof turn === 'object' && turn !== null) handedOut.set(turn, 'turn')
184
+ }
185
+ }
186
+ }
187
+ return transcript
188
+ }
189
+
190
+ const TRANSCRIPT_SEARCH_DEPTH = 4
191
+ const TRANSCRIPT_SEARCH_NODES = 200
192
+
193
+ /**
194
+ * How many handed-out turns a step result carries, or null when it carries
195
+ * none. Searched through arrays and plain objects up to four levels down and
196
+ * at most 200 values — `{ data: { transcript } }` and `{ request: turns.at(-2) }`
197
+ * are as much a leak as the transcript itself — so a large result pays a fixed
198
+ * cost. Text copied OUT of a turn is a plain string and is not recognized.
199
+ *
200
+ * Fail-closed: a result too big or too deep to search completely is reported
201
+ * as not inspected, and the step records that instead of the result.
202
+ */
203
+ function transcriptTurnsIn(
204
+ out: unknown,
205
+ ): { kind: 'found'; turnCount: number } | { kind: 'not-inspected' } | { kind: 'clean' } {
206
+ const turns = new Set<object>()
207
+ let found = false
208
+ let complete = true
209
+ const queue: Array<{ value: object; depth: number }> = []
210
+ if (typeof out === 'object' && out !== null) queue.push({ value: out, depth: 0 })
211
+ // `queue` only grows up to the node budget, so the whole search allocates a
212
+ // bounded amount however large the result is: children are walked lazily
213
+ // and the walk stops at the first child that would not fit.
214
+ for (let next = 0; next < queue.length; next++) {
215
+ const { value, depth } = queue[next]!
216
+ const brand = handedOut.get(value)
217
+ if (brand) {
218
+ found = true
219
+ const held = brand === 'turn' ? [value] : brand === 'turns' ? (value as unknown[]) : (value as ConversationTranscript).turns
220
+ for (const turn of held) if (typeof turn === 'object' && turn !== null) turns.add(turn)
221
+ continue
222
+ }
223
+ // Any object is searched, not only plain ones: a class instance serializes
224
+ // its own fields, and a turn could be one of them.
225
+ for (const child of objectChildren(value)) {
226
+ if (depth >= TRANSCRIPT_SEARCH_DEPTH || queue.length >= TRANSCRIPT_SEARCH_NODES) {
227
+ // Something here is left unsearched. The nodes already queued are
228
+ // still visited, so a transcript among them is still counted.
229
+ complete = false
230
+ break
231
+ }
232
+ queue.push({ value: child, depth: depth + 1 })
233
+ }
234
+ }
235
+ if (found) return { kind: 'found', turnCount: turns.size }
236
+ return complete ? { kind: 'clean' } : { kind: 'not-inspected' }
237
+ }
238
+
239
+ /** The object-valued children of an array or object, one at a time. */
240
+ function* objectChildren(value: object): Generator<object> {
241
+ if (Array.isArray(value)) {
242
+ for (let i = 0; i < value.length; i++) {
243
+ const child: unknown = value[i]
244
+ if (typeof child === 'object' && child !== null) yield child
245
+ }
246
+ return
247
+ }
248
+ for (const key in value) {
249
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue
250
+ const child: unknown = (value as Record<string, unknown>)[key]
251
+ if (typeof child === 'object' && child !== null) yield child
252
+ }
253
+ }
254
+
162
255
  /**
163
256
  * What an author-declared step records about its own return value.
164
257
  *
@@ -169,8 +262,16 @@ function summarize<T>(
169
262
  * that returns a thousand warehouse rows must not write them a second time into
170
263
  * the audit trail.
171
264
  */
172
- function stepResultDetail(out: unknown): Record<string, unknown> | undefined {
265
+ function stepResultDetail(out: unknown, transcriptHandedOut = false): Record<string, unknown> | undefined {
173
266
  if (out === undefined) return undefined
267
+ // Searched only once this execution has handed out a transcript: a run that
268
+ // never read one cannot return one, and its steps keep recording exactly
269
+ // what they always did — including results too large or deep to search.
270
+ if (transcriptHandedOut) {
271
+ const search = transcriptTurnsIn(out)
272
+ if (search.kind === 'found') return { omitted: 'conversation transcript', turnCount: search.turnCount }
273
+ if (search.kind === 'not-inspected') return { omitted: 'result not inspected' }
274
+ }
174
275
  const size = jsonSize(out)
175
276
  if (size === null) return { omitted: 'result not serializable' }
176
277
  return size <= DETAIL_MAX_CHARS
@@ -560,6 +661,9 @@ export function buildContext(deps: Deps): AutomationContext {
560
661
  */
561
662
  const namesThisExecution = new Set<string>()
562
663
 
664
+ /** Set once `ctx.conversation.transcript()` has returned a transcript in this execution. */
665
+ let transcriptHandedOut = false
666
+
563
667
  /**
564
668
  * Run `fn` as a durable step.
565
669
  *
@@ -606,7 +710,7 @@ export function buildContext(deps: Deps): AutomationContext {
606
710
  await completeStep(stepId, {
607
711
  status: 'ok',
608
712
  durationMs: Date.now() - t0,
609
- detail: stepResultDetail(out),
713
+ detail: stepResultDetail(out, transcriptHandedOut),
610
714
  })
611
715
  return out
612
716
  } catch (err) {
@@ -1076,5 +1180,28 @@ export function buildContext(deps: Deps): AutomationContext {
1076
1180
  }),
1077
1181
  ) as Promise<BlueprintQueryResult<T>>,
1078
1182
  },
1183
+
1184
+ conversation: {
1185
+ transcript: () =>
1186
+ step('conversation', 'transcript', async () => {
1187
+ requireGrant('conversation:read')
1188
+ // No argument on purpose: the service reads the conversation the
1189
+ // PLATFORM recorded as this run's trigger, so there is nothing an
1190
+ // author could name to read someone else's.
1191
+ const res = await scoped('/ctx/conversation-transcript', { method: 'POST', body: '{}' })
1192
+ if (!res.ok) throw new Error(`ctx.conversation.transcript → ${res.status} ${await refusal(res)}`)
1193
+ const transcript = brandTranscript(
1194
+ ((await res.json()) as { data: { transcript: ConversationTranscript | null } }).data.transcript,
1195
+ )
1196
+ if (transcript) transcriptHandedOut = true
1197
+ return transcript
1198
+ },
1199
+ // The turn count only. The text is what the person wrote, and step
1200
+ // details are rendered verbatim and kept as long as the run is. A step
1201
+ // that RETURNS the transcript is summarized the same way — see
1202
+ // `stepResultDetail`.
1203
+ (out) => (out ? { turnCount: out.turns.length } : { transcript: false }),
1204
+ ) as Promise<ConversationTranscript | null>,
1205
+ },
1079
1206
  }
1080
1207
  }
package/src/testing.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  AutomationContext,
13
13
  BlueprintQueryOptions,
14
14
  BlueprintQueryResult,
15
+ ConversationTranscript,
15
16
  Grant,
16
17
  HttpRequest,
17
18
  HttpResponse,
@@ -47,8 +48,8 @@ import type {
47
48
  * for a refusal, and on `calls` for what ran.
48
49
  */
49
50
  export interface TestCall {
50
- kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file'
51
- /** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */
51
+ kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file' | 'conversation'
52
+ /** Step name, log message, agent slug, `install:capability`, URL, object type, Action apiName, or `transcript`. */
52
53
  label: string
53
54
  /** Present on a step: how it ended. */
54
55
  status?: 'ok' | 'error'
@@ -88,6 +89,12 @@ export interface TestContextOptions {
88
89
  >
89
90
  /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */
90
91
  http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse
92
+ /**
93
+ * What `ctx.conversation.transcript()` returns. Omitted, it returns `null` —
94
+ * a run that was not started from a conversation, which is a real answer and
95
+ * a branch worth testing.
96
+ */
97
+ conversation?: ConversationTranscript | null
91
98
  /** Rows per object type. An unstubbed type returns no rows, which is a real
92
99
  * answer and usually the branch worth testing. */
93
100
  blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
@@ -317,6 +324,14 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
317
324
  return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>
318
325
  },
319
326
  },
327
+
328
+ conversation: {
329
+ async transcript(): Promise<ConversationTranscript | null> {
330
+ requireGrant('conversation:read')
331
+ calls.push({ kind: 'conversation', label: 'transcript' })
332
+ return options.conversation ?? null
333
+ },
334
+ },
320
335
  }
321
336
 
322
337
  return { ctx, calls, steps, logs }
package/src/types.ts CHANGED
@@ -46,6 +46,8 @@ export type AutomationTrigger =
46
46
  */
47
47
  export type Grant =
48
48
  | 'blueprint:read'
49
+ /** Read the conversation that started the run — see `ctx.conversation`. */
50
+ | 'conversation:read'
49
51
  | `agent:${string}:run`
50
52
  /**
51
53
  * One capability of one Plugin install: `plugin:<install>:<capability>`.
@@ -242,6 +244,38 @@ export interface ResolvedFileHandle {
242
244
  name: string | null
243
245
  }
244
246
 
247
+ /**
248
+ * The conversation that started a run, as the person saw it: what they wrote
249
+ * and what the agent wrote back, as text.
250
+ *
251
+ * Only the stretch that belongs to this run is included — earlier requests in
252
+ * the same conversation, already handled by earlier runs that read a
253
+ * transcript and finished successfully or are still running, and anything
254
+ * before a quiet gap of more than six hours are left out.
255
+ *
256
+ * Replaced with `[removed]`: card numbers of the major card brands (Visa,
257
+ * Mastercard, American Express, Discover, UnionPay), with the digit groups
258
+ * separated by a single space or dash, or written together (an expiry or
259
+ * security code after the number stays); Singapore NRIC/FIN numbers; and —
260
+ * within the six words after "password", "passcode", "PIN", "OTP", "one-time
261
+ * code" or "verification code" (or the Indonesian and Malay "kata sandi",
262
+ * "sandi", "kode verifikasi", "kode OTP", "kata laluan", "kod pengesahan"), or
263
+ * the first six words of the person's reply right after the agent asks for one
264
+ * of these — any word containing a digit.
265
+ * Words without a digit stay. This removes obvious secrets; it is not a guarantee: keep the
266
+ * transcript where only the people who need it can read it.
267
+ */
268
+ export interface ConversationTranscript {
269
+ /** The agent the person was talking to. */
270
+ agentName: string
271
+ /** ISO 8601 time of the first included turn. */
272
+ startedAt: string
273
+ /** ISO 8601 time of the last included turn. */
274
+ endedAt: string
275
+ /** Oldest first. A card the agent showed reads as one line of text. */
276
+ turns: Array<{ role: 'user' | 'agent'; text: string; at: string }>
277
+ }
278
+
245
279
  /** What `ctx.plugin(install).call(...)` resolves to. */
246
280
  export interface PluginCallResult<T = unknown> {
247
281
  /** Whatever the capability returned. Shape is the plugin's, not the platform's. */
@@ -467,6 +501,35 @@ export interface AutomationContext {
467
501
  options?: BlueprintQueryOptions,
468
502
  ): Promise<BlueprintQueryResult<T>>
469
503
  }
504
+ conversation: {
505
+ /**
506
+ * The conversation that started THIS run, as the person saw it.
507
+ *
508
+ * `null` when the run was not started from a conversation (a schedule, a
509
+ * manual run, an App), when that conversation was deleted, and in dry (dev)
510
+ * runs.
511
+ *
512
+ * Call it inside a `ctx.step.run` step — required, not a style choice.
513
+ * Outside a step it reads the conversation again on every resumption, and
514
+ * what it returns can change between reads: an earlier run from the same
515
+ * conversation that finishes, or reads, in the meantime moves where this
516
+ * run's part begins. Act on it in that same step and return only a
517
+ * summary, because step results are kept to resume the run.
518
+ *
519
+ * When a step returns the transcript,
520
+ * its `turns` or any of its turns — alone or inside objects and arrays up
521
+ * to four levels deep — its trace row records only the turn count; a
522
+ * result too large or too deep to check completely is not recorded at all.
523
+ * Everything else is recorded as written: text copied out of it, including
524
+ * new objects built from its turns (`turns.map(t => ({ role: t.role, text:
525
+ * t.text }))`), a copy restored after the run resumes, an error message
526
+ * built from it, and the Function's final return value (recorded as the
527
+ * run's result). Requires the
528
+ * `conversation:read` grant. Reads only the run's own conversation — there
529
+ * is no way to name another one.
530
+ */
531
+ transcript(): Promise<ConversationTranscript | null>
532
+ }
470
533
  /**
471
534
  * Durable steps. See `StepApi`.
472
535
  *