@frontera-sdk/functions 1.50.81 → 1.50.83
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 +2 -2
- package/src/runtime-context.ts +129 -2
- package/src/testing.ts +17 -2
- package/src/types.ts +68 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.83",
|
|
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.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.50.83",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -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,
|
|
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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `AutomationContext` needs it to resolve. See the packaging note in
|
|
5
5
|
// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —
|
|
6
6
|
// a subset install that cannot resolve it aborts `bun install` outright.
|
|
7
|
-
import type { WhereNode } from '@frontera-sdk/blueprint/types'
|
|
7
|
+
import type { NearestRequest, WhereNode } from '@frontera-sdk/blueprint/types'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Cron, manual, and agent for now; event and webhook land with Event Triggers.
|
|
@@ -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
|
*
|
|
@@ -545,6 +608,10 @@ export interface BlueprintQueryOptions {
|
|
|
545
608
|
* first page. Cursor-based, so a scan stays correct while the table moves
|
|
546
609
|
* underneath it — which a cron-driven automation's table always does. */
|
|
547
610
|
pageToken?: string
|
|
611
|
+
/** Closest first from `nearest.from`, with `_distanceMeters` on each row.
|
|
612
|
+
* `where` must also hold a `nearby` or `withinBbox` on the same location,
|
|
613
|
+
* and `orderBy` must be omitted. */
|
|
614
|
+
nearest?: NearestRequest
|
|
548
615
|
}
|
|
549
616
|
|
|
550
617
|
export interface BlueprintQueryResult<T = Record<string, unknown>> {
|