@frontera-sdk/cli 1.53.10 → 1.53.12
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 +5 -5
- package/src/api/platform-api.ts +43 -0
- package/src/blueprint/compile.ts +35 -2
- package/src/blueprint/dataset-revision.ts +4 -3
- package/src/blueprint/diff.ts +4 -1
- package/src/commands/automation/dev.ts +1 -1
- package/src/commands/blueprint/declarative.ts +106 -44
- package/src/commands/knowledge/index-commands.ts +1 -1
- package/src/commands/local/steps.ts +4 -4
- package/src/commands/local/sync-request.ts +229 -152
- package/src/commands/local/sync.ts +308 -44
- package/src/commands/registry.ts +5 -2
- package/src/commands/voice/index-commands.ts +363 -0
- package/src/flag-help.ts +19 -0
- package/src/forge/touches.ts +2 -0
- package/src/local/local-blueprint-schemas.ts +6 -2
- package/src/local/local-person-steps.ts +17 -16
- package/src/local/local-portability.ts +5 -3
- package/src/local/local-schemas.ts +41 -0
- package/src/local/local-sync-link.ts +255 -55
- package/src/local/local-sync-plan.ts +1 -1
- package/src/local/local-sync-precheck.ts +16 -2
- package/src/local/local-synthetic-data.ts +12 -7
- package/src/mcp/tools/project-tools.ts +9 -2
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import { resolveAgentRef } from '../agent/resolve'
|
|
5
|
+
import { flagString, type Command, type CommandContext } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An Agent's phone numbers (the WhatsApp numbers people call to talk to it)
|
|
9
|
+
* and calls the Agent makes. The organization and workspace are the signed-in
|
|
10
|
+
* person's, never typed.
|
|
11
|
+
*
|
|
12
|
+
* Every verb acts as a person. The Voice application checks the person's own
|
|
13
|
+
* access to the Agent (and the Voice manager role for changes), so no API key
|
|
14
|
+
* reaches these routes; the dispatcher refuses a key before any request and
|
|
15
|
+
* names where a person does this in the platform.
|
|
16
|
+
*
|
|
17
|
+
* Voice sets a number up when it is added: the number's WhatsApp calls are
|
|
18
|
+
* pointed at the deployment's phone system through the workspace's WhatsApp
|
|
19
|
+
* channel. No credential passes through the CLI.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface NumberRow {
|
|
23
|
+
id: string
|
|
24
|
+
number: string
|
|
25
|
+
source: string
|
|
26
|
+
channelId: string | null
|
|
27
|
+
enabled: boolean
|
|
28
|
+
callHours: { timeZone: string; windows: { day: string; open: string; close: string }[] } | null
|
|
29
|
+
status: string
|
|
30
|
+
statusReason: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const WHERE = 'the Agent → Voice → Phone numbers'
|
|
34
|
+
const WEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as const
|
|
35
|
+
const DAY_ALIASES: Record<string, (typeof WEEK)[number]> = { mon: 'MONDAY', tue: 'TUESDAY', wed: 'WEDNESDAY', thu: 'THURSDAY', fri: 'FRIDAY', sat: 'SATURDAY', sun: 'SUNDAY' }
|
|
36
|
+
|
|
37
|
+
function api(ctx: CommandContext): PlatformApi {
|
|
38
|
+
return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function agentOf(ctx: CommandContext, client: PlatformApi, usage: string): Promise<string> {
|
|
42
|
+
const ref = ctx.positional[0]
|
|
43
|
+
if (!ref) throw new UsageError('missing <agent>', `${usage} — see \`frontera agent list\``)
|
|
44
|
+
return resolveAgentRef(client, ref)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `628123456789` as `+628123456789`. */
|
|
48
|
+
const shown = (digits: string) => `+${digits}`
|
|
49
|
+
|
|
50
|
+
function hoursText(hours: NumberRow['callHours']): string {
|
|
51
|
+
if (!hours) return 'any time'
|
|
52
|
+
const first = hours.windows[0]
|
|
53
|
+
if (!first) return `closed (${hours.timeZone})`
|
|
54
|
+
const t = (hhmm: string) => `${hhmm.slice(0, 2)}:${hhmm.slice(2)}`
|
|
55
|
+
const days = new Set(hours.windows.map((w) => w.day)).size
|
|
56
|
+
return `${t(first.open)}-${t(first.close)} ${hours.timeZone}${days === 7 ? '' : `, ${days} days`}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `--hours 08:00-17:00` (every day) or `--hours mon-fri@08:00-17:00`, with
|
|
61
|
+
* `--time-zone`. Absent means any time.
|
|
62
|
+
*/
|
|
63
|
+
export function parseHours(raw: string | undefined, timeZone: string | undefined): NumberRow['callHours'] {
|
|
64
|
+
if (!raw) return null
|
|
65
|
+
const m = /^(?:([a-z]{3})(?:-([a-z]{3}))?@)?([0-2][0-9]):?([0-5][0-9])-([0-2][0-9]):?([0-5][0-9])$/i.exec(raw.trim())
|
|
66
|
+
if (!m) throw new UsageError(`"${raw}" is not call hours`, 'write them as 08:00-17:00 (every day) or mon-fri@08:00-17:00')
|
|
67
|
+
const [, fromDay, toDay, oh, om, ch, cm] = m
|
|
68
|
+
const open = `${oh}${om}`
|
|
69
|
+
const close = `${ch}${cm}`
|
|
70
|
+
if (open > '2359' || (close > '2359' && close !== '2400')) throw new UsageError(`"${raw}" is not a time of day`, 'times run from 00:00 to 23:59; 24:00 closes at the end of the day')
|
|
71
|
+
if (open === close) throw new UsageError(`"${raw}" opens and closes at the same time`, 'give a window, e.g. 08:00-17:00; 22:00-06:00 runs past midnight')
|
|
72
|
+
let days: readonly (typeof WEEK)[number][] = WEEK
|
|
73
|
+
if (fromDay) {
|
|
74
|
+
const a = DAY_ALIASES[fromDay.toLowerCase()]
|
|
75
|
+
const b = DAY_ALIASES[(toDay ?? fromDay).toLowerCase()]
|
|
76
|
+
if (!a || !b) throw new UsageError(`"${raw}" names an unknown day`, 'days are mon, tue, wed, thu, fri, sat, sun')
|
|
77
|
+
days = WEEK.slice(WEEK.indexOf(a), WEEK.indexOf(b) + 1)
|
|
78
|
+
if (days.length === 0) throw new UsageError(`"${raw}" runs backwards through the week`, 'write the earlier day first, e.g. mon-fri')
|
|
79
|
+
}
|
|
80
|
+
return { timeZone: timeZone ?? 'Asia/Jakarta', windows: days.map((day) => ({ day, open, close })) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function numberOf(client: PlatformApi, agentId: string, ref: string | undefined, usage: string): Promise<NumberRow> {
|
|
84
|
+
if (!ref) throw new UsageError('missing <number>', usage)
|
|
85
|
+
const { numbers } = await client.voiceNumbers(agentId)
|
|
86
|
+
const digits = ref.replace(/[^0-9]/g, '')
|
|
87
|
+
const found = (numbers as NumberRow[]).find((n) => n.id === ref || (digits.length >= 7 && n.number === digits))
|
|
88
|
+
if (!found) throw new CliError(`this Agent has no number "${ref}"`, { code: 'NOT_FOUND', hint: 'run `frontera voice numbers <agent>` to see them' })
|
|
89
|
+
return found
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const lineOf = (n: NumberRow) => [shown(n.number), n.status + (n.enabled ? '' : ' (off)'), n.source, hoursText(n.callHours), n.statusReason ?? '']
|
|
93
|
+
|
|
94
|
+
const SESSION = { authLane: 'session' as const, sessionAlternative: WHERE }
|
|
95
|
+
|
|
96
|
+
const list: Command = {
|
|
97
|
+
meta: {
|
|
98
|
+
noun: 'voice',
|
|
99
|
+
verb: 'numbers',
|
|
100
|
+
...SESSION,
|
|
101
|
+
args: [{ name: 'agent', required: true, description: 'Agent id or slug' }],
|
|
102
|
+
flags: {},
|
|
103
|
+
summary: 'List the phone numbers an Agent answers',
|
|
104
|
+
examples: ['frontera voice numbers front-desk', 'frontera voice numbers front-desk --json'],
|
|
105
|
+
},
|
|
106
|
+
async run(ctx) {
|
|
107
|
+
const client = api(ctx)
|
|
108
|
+
const view = await client.voiceNumbers(await agentOf(ctx, client, 'frontera voice numbers <agent>'))
|
|
109
|
+
const rows = view.numbers as NumberRow[]
|
|
110
|
+
return {
|
|
111
|
+
data: view,
|
|
112
|
+
text: !view.available
|
|
113
|
+
? 'Phone numbers are not set up on this deployment.'
|
|
114
|
+
: rows.length === 0
|
|
115
|
+
? 'This Agent has no phone numbers.'
|
|
116
|
+
: table(['number', 'status', 'source', 'hours', 'note'], rows.map(lineOf), [undefined, undefined, undefined, 36, 50]),
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const add: Command = {
|
|
122
|
+
meta: {
|
|
123
|
+
noun: 'voice',
|
|
124
|
+
verb: 'number-add',
|
|
125
|
+
...SESSION,
|
|
126
|
+
args: [
|
|
127
|
+
{ name: 'agent', required: true, description: 'Agent id or slug' },
|
|
128
|
+
{ name: 'number', required: false, description: 'the phone number, international form (+62…); a WhatsApp number is read from its channel' },
|
|
129
|
+
],
|
|
130
|
+
flags: { channel: 'string', hours: 'string', 'time-zone': 'string' },
|
|
131
|
+
summary: 'Add a WhatsApp number to an Agent and set it up',
|
|
132
|
+
examples: [
|
|
133
|
+
'frontera voice number-add front-desk +6281234567890 --channel <id>',
|
|
134
|
+
'frontera voice number-add desk <number> --channel <id> --hours 08:00-17:00',
|
|
135
|
+
'frontera voice number-add desk <number> --channel <id> --hours sat@0900-1200',
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
async run(ctx) {
|
|
139
|
+
const usage = 'frontera voice number-add <agent> [<number>] --channel <whatsapp-channel-id>'
|
|
140
|
+
const client = api(ctx)
|
|
141
|
+
const agentId = await agentOf(ctx, client, usage)
|
|
142
|
+
const number = ctx.positional[1]
|
|
143
|
+
const channelId = flagString(ctx, 'channel')
|
|
144
|
+
if (!channelId) throw new UsageError('missing --channel', 'name the workspace’s WhatsApp channel the number belongs to')
|
|
145
|
+
// A WhatsApp number is the one its channel serves; a typed one is only checked against it.
|
|
146
|
+
const callHours = parseHours(flagString(ctx, 'hours'), flagString(ctx, 'time-zone'))
|
|
147
|
+
const { number: added } = await client.addVoiceNumber(agentId, { ...(number ? { number } : {}), source: 'whatsapp', channelId, callHours })
|
|
148
|
+
const n = added as NumberRow
|
|
149
|
+
return {
|
|
150
|
+
data: n,
|
|
151
|
+
text:
|
|
152
|
+
n.status === 'active'
|
|
153
|
+
? `added ${shown(n.number)}: calls to it reach this Agent (${hoursText(n.callHours)})`
|
|
154
|
+
: `added ${shown(n.number)}, but it is not set up yet: ${n.statusReason ?? n.status}\n try again: frontera voice number-apply ${ctx.positional[0]} ${shown(n.number)}`,
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const apply: Command = {
|
|
160
|
+
meta: {
|
|
161
|
+
noun: 'voice',
|
|
162
|
+
verb: 'number-apply',
|
|
163
|
+
...SESSION,
|
|
164
|
+
args: [
|
|
165
|
+
{ name: 'agent', required: true, description: 'Agent id or slug' },
|
|
166
|
+
{ name: 'number', required: true, description: 'the number, or its id' },
|
|
167
|
+
],
|
|
168
|
+
flags: {},
|
|
169
|
+
summary: 'Set a phone number up again after it needed attention',
|
|
170
|
+
examples: ['frontera voice number-apply front-desk +6281234567890'],
|
|
171
|
+
},
|
|
172
|
+
async run(ctx) {
|
|
173
|
+
const usage = 'frontera voice number-apply <agent> <number>'
|
|
174
|
+
const client = api(ctx)
|
|
175
|
+
const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
|
|
176
|
+
const n = (await client.applyVoiceNumber(line.id)).number as NumberRow
|
|
177
|
+
return { data: n, text: n.status === 'active' ? `${shown(n.number)} is set up` : `${shown(n.number)} still needs attention: ${n.statusReason ?? n.status}` }
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toggle(verb: 'number-enable' | 'number-disable', enabled: boolean): Command {
|
|
182
|
+
return {
|
|
183
|
+
meta: {
|
|
184
|
+
noun: 'voice',
|
|
185
|
+
verb,
|
|
186
|
+
...SESSION,
|
|
187
|
+
args: [
|
|
188
|
+
{ name: 'agent', required: true, description: 'Agent id or slug' },
|
|
189
|
+
{ name: 'number', required: true, description: 'the number, or its id' },
|
|
190
|
+
],
|
|
191
|
+
flags: {},
|
|
192
|
+
summary: enabled ? 'Answer calls to a phone number again' : 'Refuse new calls to a phone number, keeping it',
|
|
193
|
+
examples: [`frontera voice ${verb} front-desk +6281234567890`],
|
|
194
|
+
},
|
|
195
|
+
async run(ctx) {
|
|
196
|
+
const usage = `frontera voice ${verb} <agent> <number>`
|
|
197
|
+
const client = api(ctx)
|
|
198
|
+
const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
|
|
199
|
+
const n = (await client.setVoiceNumberEnabled(line.id, enabled)).number as NumberRow
|
|
200
|
+
return { data: n, text: enabled ? `${shown(n.number)} answers calls again (${n.status})` : `${shown(n.number)} refuses new calls` }
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hoursCmd: Command = {
|
|
206
|
+
meta: {
|
|
207
|
+
noun: 'voice',
|
|
208
|
+
verb: 'number-hours',
|
|
209
|
+
...SESSION,
|
|
210
|
+
args: [
|
|
211
|
+
{ name: 'agent', required: true, description: 'Agent id or slug' },
|
|
212
|
+
{ name: 'number', required: true, description: 'the number, or its id' },
|
|
213
|
+
],
|
|
214
|
+
flags: { hours: 'string', 'time-zone': 'string' },
|
|
215
|
+
summary: 'Change when a phone number answers, and apply it again',
|
|
216
|
+
examples: ['frontera voice number-hours desk <number> --hours 08:00-17:00', 'frontera voice number-hours desk <number> --hours any'],
|
|
217
|
+
},
|
|
218
|
+
async run(ctx) {
|
|
219
|
+
const usage = 'frontera voice number-hours <agent> <number> --hours <08:00-17:00 | mon-fri@08:00-17:00 | any>'
|
|
220
|
+
const raw = flagString(ctx, 'hours')
|
|
221
|
+
if (!raw) throw new UsageError('missing --hours', usage)
|
|
222
|
+
const client = api(ctx)
|
|
223
|
+
const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
|
|
224
|
+
const callHours = raw === 'any' ? null : parseHours(raw, flagString(ctx, 'time-zone'))
|
|
225
|
+
const n = (await client.setVoiceNumberHours(line.id, callHours)).number as NumberRow
|
|
226
|
+
return { data: n, text: n.status === 'active' ? `${shown(n.number)} now answers ${hoursText(n.callHours)}` : `${shown(n.number)} saved ${hoursText(n.callHours)} but still needs attention: ${n.statusReason ?? n.status}` }
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const remove: Command = {
|
|
231
|
+
meta: {
|
|
232
|
+
noun: 'voice',
|
|
233
|
+
verb: 'number-remove',
|
|
234
|
+
...SESSION,
|
|
235
|
+
args: [
|
|
236
|
+
{ name: 'agent', required: true, description: 'Agent id or slug' },
|
|
237
|
+
{ name: 'number', required: true, description: 'the number, or its id' },
|
|
238
|
+
],
|
|
239
|
+
flags: {},
|
|
240
|
+
summary: 'Remove a phone number: its calls stop reaching the Agent, its digits are free again',
|
|
241
|
+
examples: ['frontera voice number-remove front-desk +6281234567890'],
|
|
242
|
+
},
|
|
243
|
+
async run(ctx) {
|
|
244
|
+
const usage = 'frontera voice number-remove <agent> <number>'
|
|
245
|
+
const client = api(ctx)
|
|
246
|
+
const line = await numberOf(client, await agentOf(ctx, client, usage), ctx.positional[1], usage)
|
|
247
|
+
await client.removeVoiceNumber(line.id)
|
|
248
|
+
return { data: { id: line.id, number: line.number, removed: true }, text: `removed ${shown(line.number)}; its past calls keep their records` }
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** What a refusal means, in words. */
|
|
253
|
+
const REFUSALS: Record<string, string> = {
|
|
254
|
+
not_enabled: 'Voice is not turned on for this organization',
|
|
255
|
+
not_available: 'Voice is not available in this workspace',
|
|
256
|
+
not_licensed: 'the licence does not include Voice',
|
|
257
|
+
read_only: 'Voice is read-only right now',
|
|
258
|
+
suspended: 'Voice is suspended for this organization',
|
|
259
|
+
agent_unpublished: 'the Agent is not published',
|
|
260
|
+
voice_off: 'voice calls are off for this Agent',
|
|
261
|
+
no_voice_chosen: 'the Agent has no speaking voice',
|
|
262
|
+
transcription_model_missing: 'the Agent has no transcription model',
|
|
263
|
+
usage_limit: 'this month’s call minutes are used up',
|
|
264
|
+
speech_provider_missing: 'the organization has no speech provider key',
|
|
265
|
+
concurrency_limit: 'too many calls are running; try again in a minute',
|
|
266
|
+
number_disabled: 'the number is turned off',
|
|
267
|
+
number_not_active: 'the number is not set up yet; run `frontera voice number-apply`',
|
|
268
|
+
no_call_permission: 'this person has not given the number permission to call on WhatsApp; run `frontera voice call-permission-request`',
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const digitsOf = (raw: string | undefined, usage: string) => {
|
|
272
|
+
const digits = (raw ?? '').replace(/[^0-9]/g, '')
|
|
273
|
+
if (!raw) throw new UsageError('missing --to', usage)
|
|
274
|
+
if (digits.length < 7) throw new UsageError(`"${raw}" is not a phone number`, 'give it in international form, e.g. +628…')
|
|
275
|
+
return digits
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const call: Command = {
|
|
279
|
+
meta: {
|
|
280
|
+
noun: 'voice',
|
|
281
|
+
verb: 'call',
|
|
282
|
+
...SESSION,
|
|
283
|
+
args: [{ name: 'agent', required: true, description: 'Agent id or slug' }],
|
|
284
|
+
flags: { number: 'string', to: 'string' },
|
|
285
|
+
summary: 'Have an Agent phone a number, from one of its own numbers (signed-in person only; API keys are refused)',
|
|
286
|
+
examples: ['frontera voice call front-desk --number <id> --to +628…'],
|
|
287
|
+
},
|
|
288
|
+
async run(ctx) {
|
|
289
|
+
const usage = 'frontera voice call <agent> --number <number-id> --to <number>'
|
|
290
|
+
const client = api(ctx)
|
|
291
|
+
const agentId = await agentOf(ctx, client, usage)
|
|
292
|
+
const numberId = flagString(ctx, 'number')
|
|
293
|
+
if (!numberId) throw new UsageError('missing --number', 'the id of the Agent’s number to call from — see `frontera voice numbers`')
|
|
294
|
+
const to = digitsOf(flagString(ctx, 'to'), usage)
|
|
295
|
+
try {
|
|
296
|
+
const started = await client.startVoiceCall(agentId, { numberId, to })
|
|
297
|
+
return { data: started, text: `calling ${shown(to)}; the call is session ${started.callId} in the Agent’s Sessions` }
|
|
298
|
+
} catch (caught) {
|
|
299
|
+
// The SDK keeps the response body as `details`: `{ code: 'VOICE_CALL_REFUSED', reason, callId }`.
|
|
300
|
+
const reason = (caught as { details?: { reason?: string } }).details?.reason
|
|
301
|
+
if (reason && REFUSALS[reason]) throw new CliError(`the call was refused: ${REFUSALS[reason]}`, { code: 'REFUSED', hint: reason })
|
|
302
|
+
throw caught
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const permissionText = (p: { status: string; expiresAt?: string; canCall: boolean; canRequest: boolean }) =>
|
|
308
|
+
p.canCall
|
|
309
|
+
? `can call (${p.status}${p.expiresAt ? `, until ${p.expiresAt}` : ''})`
|
|
310
|
+
: `no permission${p.canRequest ? ': ask with `frontera voice call-permission-request` (WhatsApp allows 1 request a day, 2 a week)' : ' and a request cannot be sent right now'}`
|
|
311
|
+
|
|
312
|
+
const callPermission: Command = {
|
|
313
|
+
meta: {
|
|
314
|
+
noun: 'voice',
|
|
315
|
+
verb: 'call-permission',
|
|
316
|
+
...SESSION,
|
|
317
|
+
args: [{ name: 'numberId', required: true, description: 'the id of a WhatsApp number (see `frontera voice numbers`)' }],
|
|
318
|
+
flags: { to: 'string' },
|
|
319
|
+
summary: 'Check whether a WhatsApp number may call a person',
|
|
320
|
+
examples: ['frontera voice call-permission <number-id> --to +628…'],
|
|
321
|
+
},
|
|
322
|
+
async run(ctx) {
|
|
323
|
+
const usage = 'frontera voice call-permission <number-id> --to <number>'
|
|
324
|
+
const numberId = ctx.positional[0]
|
|
325
|
+
if (!numberId) throw new UsageError('missing <numberId>', usage)
|
|
326
|
+
const { permission } = await api(ctx).voiceCallPermission(numberId, digitsOf(flagString(ctx, 'to'), usage))
|
|
327
|
+
return { data: permission, text: permissionText(permission) }
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const callPermissionRequest: Command = {
|
|
332
|
+
meta: {
|
|
333
|
+
noun: 'voice',
|
|
334
|
+
verb: 'call-permission-request',
|
|
335
|
+
...SESSION,
|
|
336
|
+
args: [{ name: 'numberId', required: true, description: 'the id of a WhatsApp number (see `frontera voice numbers`)' }],
|
|
337
|
+
flags: { to: 'string', message: 'string' },
|
|
338
|
+
summary: 'Ask a person for permission to call them on WhatsApp',
|
|
339
|
+
examples: ['frontera voice call-permission-request <number-id> --to +628…', 'frontera voice call-permission-request <id> --to <n> --message Hi'],
|
|
340
|
+
},
|
|
341
|
+
async run(ctx) {
|
|
342
|
+
const usage = 'frontera voice call-permission-request <number-id> --to <number> [--message <text>]'
|
|
343
|
+
const numberId = ctx.positional[0]
|
|
344
|
+
if (!numberId) throw new UsageError('missing <numberId>', usage)
|
|
345
|
+
const message = flagString(ctx, 'message')
|
|
346
|
+
const to = digitsOf(flagString(ctx, 'to'), usage)
|
|
347
|
+
await api(ctx).requestVoiceCallPermission(numberId, { to, ...(message ? { message } : {}) })
|
|
348
|
+
return { data: { requested: true }, text: `asked ${shown(to)} for permission; WhatsApp allows 1 request a day and 2 a week. Check with \`frontera voice call-permission\`` }
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const voiceCommands: Command[] = [
|
|
353
|
+
list,
|
|
354
|
+
add,
|
|
355
|
+
apply,
|
|
356
|
+
toggle('number-enable', true),
|
|
357
|
+
toggle('number-disable', false),
|
|
358
|
+
hoursCmd,
|
|
359
|
+
remove,
|
|
360
|
+
call,
|
|
361
|
+
callPermission,
|
|
362
|
+
callPermissionRequest,
|
|
363
|
+
]
|
package/src/flag-help.ts
CHANGED
|
@@ -22,6 +22,13 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
22
22
|
'no-snapshot': 'don’t save the records first (by default a load saves them, and a refused record puts them back)',
|
|
23
23
|
batch: 'how many records are submitted at once (1 to 16, default 4)',
|
|
24
24
|
agent: 'agent slug this binding is for — one agent, no wildcard',
|
|
25
|
+
// Voice phone numbers.
|
|
26
|
+
channel: 'the workspace’s WhatsApp channel the number belongs to, by id',
|
|
27
|
+
hours: 'when the number answers: 08:00-17:00 (every day), mon-fri@08:00-17:00, or 22:00-06:00 past midnight; absent or `any` means any time',
|
|
28
|
+
'time-zone': 'the time zone of --hours, e.g. Asia/Jakarta (the default)',
|
|
29
|
+
number: 'the id of the Agent’s phone number to call from (see `frontera voice numbers`)',
|
|
30
|
+
to: 'the number being called, in international form',
|
|
31
|
+
message: 'text sent with the permission request',
|
|
25
32
|
app: 'a Custom App by its slug: the App may start the Function as the person using it',
|
|
26
33
|
apps: 'list the Custom Apps that may start it, instead of the agents',
|
|
27
34
|
off: 'keep the binding, switched off',
|
|
@@ -267,6 +274,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
267
274
|
'org-key': '`-` to read an organization key (sk-org-…) from stdin, after the workspace key; widens the Blueprint to the whole model',
|
|
268
275
|
blueprint: 'workspace (the slice granted to the workspace, default) or organization (the whole model; needs an organization key)',
|
|
269
276
|
relink: 'point a project already linked to another workspace at this one',
|
|
277
|
+
target: 'local sync: a named target of the project (sync.targets in frontera.yaml), matched exactly, to connect, propose to, apply on, diff or resolve against, or disconnect; without it, the project’s link',
|
|
278
|
+
production: 'local sync connect --target: mark the target as production in status and messages (every target is reached only by a command that names it)',
|
|
270
279
|
preview: 'local sync: list what a pull would bring, or a push would propose, file by file, and change nothing; app deploy: make a preview deployment (the default), said explicitly as a pipeline must',
|
|
271
280
|
kinds: 'comma-separated: blueprint, skills, functions, plugins, knowledge, workflows, agents, evaluations',
|
|
272
281
|
drafts: 'comma-separated items (agent/<slug>, workflow/<slug>, function/<slug>, blueprint) to read as their draft instead of the live version; the Blueprint’s draft takes an organization key',
|
|
@@ -340,6 +349,15 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
|
340
349
|
header: 'name: value',
|
|
341
350
|
framework: 'next|react',
|
|
342
351
|
port: 'number',
|
|
352
|
+
line: 'id',
|
|
353
|
+
host: 'host',
|
|
354
|
+
transport: 'udp|tcp',
|
|
355
|
+
address: 'ip[/bits]',
|
|
356
|
+
username: 'name',
|
|
357
|
+
realm: 'realm',
|
|
358
|
+
number: 'id',
|
|
359
|
+
to: 'number',
|
|
360
|
+
message: 'text',
|
|
343
361
|
origin: 'url',
|
|
344
362
|
'token-fd': 'fd',
|
|
345
363
|
'expect-revision': 'hash',
|
|
@@ -381,6 +399,7 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
|
381
399
|
'workspace-key': '-',
|
|
382
400
|
'org-key': '-',
|
|
383
401
|
blueprint: 'workspace|organization',
|
|
402
|
+
target: 'name',
|
|
384
403
|
kinds: 'a,b',
|
|
385
404
|
drafts: 'kind/name,…',
|
|
386
405
|
snapshot: 'id',
|
package/src/forge/touches.ts
CHANGED
|
@@ -89,6 +89,8 @@ export const NOUN_ALIASES: Record<string, ForgeArtifactKind> = { function: 'auto
|
|
|
89
89
|
export const AGENT_PROXY_NOUNS: Record<string, { verbs: readonly string[]; agentAt: number }> = {
|
|
90
90
|
capability: { verbs: ['grant', 'revoke', 'bind', 'unbind'], agentAt: 0 },
|
|
91
91
|
pack: { verbs: ['apply', 'detach'], agentAt: 1 },
|
|
92
|
+
// Phone numbers belong to the Agent they name; `call*` act on a number id (no Agent to name).
|
|
93
|
+
voice: { verbs: ['number-add', 'number-apply', 'number-enable', 'number-disable', 'number-hours', 'number-remove'], agentAt: 0 },
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/**
|
|
@@ -85,7 +85,7 @@ const OBJECT_TYPE: JsonSchema = {
|
|
|
85
85
|
properties: {
|
|
86
86
|
type: 'array',
|
|
87
87
|
minItems: 1,
|
|
88
|
-
description: 'Every field. Each names the column it reads in backing.mapping.',
|
|
88
|
+
description: 'Every field. Each names the column it reads in backing.mapping, except an edit-only one.',
|
|
89
89
|
items: {
|
|
90
90
|
type: 'object',
|
|
91
91
|
properties: {
|
|
@@ -100,6 +100,10 @@ const OBJECT_TYPE: JsonSchema = {
|
|
|
100
100
|
sharedField: { anyOf: [ref('The shared field it implements', 'shared-field'), { type: 'null' }], description: 'The shared field it implements, by apiName; null detaches it.' },
|
|
101
101
|
semanticType: { anyOf: [ref('The semantic type it carries', 'semantic-type'), { type: 'null' }], description: 'The semantic type its values follow (allowed values, bounds), by apiName; null detaches it.' },
|
|
102
102
|
calculation: { type: 'object', description: 'A calculated field. A location names its latitude and longitude fields: { kind: location, latitude, longitude }.' },
|
|
103
|
+
editOnly: {
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
description: 'true: the field reads no column. Its values exist only where an Action writes them (data an App owns), and a record no Action has written reads it as empty. Leave it out of backing.mapping and list it in backing.editable. It is a string, number, boolean, date or timestamp, never required, unique, the primaryKey or the title, and the type reads a dataset. Set when the field is added: it cannot be switched on or off or given another value type later, because the values Actions wrote would not follow. renamedFrom renames it and keeps them. A field added under a removed one\'s name starts empty.',
|
|
106
|
+
},
|
|
103
107
|
renamedFrom: {
|
|
104
108
|
type: 'string',
|
|
105
109
|
pattern: '^[a-z][A-Za-z0-9]{0,99}$',
|
|
@@ -126,7 +130,7 @@ const OBJECT_TYPE: JsonSchema = {
|
|
|
126
130
|
dataset: text('The dataset’s apiName (datasets/<apiName>.json). Naming another dataset moves the type to it on `frontera blueprint apply` and in a change request’s Apply; each property keeps its column. A change request creates a new type bound to it, in the same release.'),
|
|
127
131
|
mapping: {
|
|
128
132
|
type: 'object',
|
|
129
|
-
description: 'property apiName → its column, or { column, field } for a field inside a column.',
|
|
133
|
+
description: 'property apiName → its column, or { column, field } for a field inside a column. An edit-only property has no entry.',
|
|
130
134
|
additionalProperties: {
|
|
131
135
|
anyOf: [
|
|
132
136
|
{ type: 'string', minLength: 1 },
|
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
|
|
|
4
4
|
import { writeFileAtomic } from '../fs-atomic'
|
|
5
5
|
import { getProfile } from '../profiles'
|
|
6
6
|
import { localProject } from './local-project'
|
|
7
|
-
import {
|
|
7
|
+
import { projectTargets, targetOf, targetProfileName } from './local-sync-link'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Person-steps (Forge improvement plan T4, N4): a step only the person may
|
|
@@ -133,8 +133,8 @@ export function readPersonSteps(root: string): PersonStep[] {
|
|
|
133
133
|
} catch {
|
|
134
134
|
return []
|
|
135
135
|
}
|
|
136
|
-
const
|
|
137
|
-
return
|
|
136
|
+
const targets = steps.some((s) => s.profile) ? stepTargets(root) : []
|
|
137
|
+
return targets.length ? steps.map((s) => aimedAt(s, targets)) : steps
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -143,9 +143,10 @@ export function readPersonSteps(root: string): PersonStep[] {
|
|
|
143
143
|
* folder's path, so it changes when the folder is moved or renamed, while the
|
|
144
144
|
* steps file moves with the folder.
|
|
145
145
|
*/
|
|
146
|
-
function aimedAt(step: PersonStep,
|
|
146
|
+
function aimedAt(step: PersonStep, targets: StepTarget[]): PersonStep {
|
|
147
147
|
const was = step.profile
|
|
148
|
-
|
|
148
|
+
const current = targets.find((t) => t.target === step.target)
|
|
149
|
+
if (!was || !current || was === current.profile) return step
|
|
149
150
|
const swap = (command: string) => command.split(' ')
|
|
150
151
|
.map((word, i, words) => (word === was && words[i - 1] === '--profile' ? current.profile : word === `--profile=${was}` ? `--profile=${current.profile}` : word))
|
|
151
152
|
.join(' ')
|
|
@@ -303,16 +304,15 @@ export function checkProblem(check: StepCheck, readOnly: readonly string[]): str
|
|
|
303
304
|
return null
|
|
304
305
|
}
|
|
305
306
|
|
|
306
|
-
/** Where a folder's link points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
|
|
307
|
+
/** Where a folder's link or one of its named targets points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
|
|
307
308
|
export interface StepTarget {
|
|
308
309
|
target: string
|
|
309
310
|
profile: string
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
/** The folder's
|
|
313
|
-
export function
|
|
314
|
-
|
|
315
|
-
return link ? { target: targetOf(link), profile: targetProfileName(localProject(root), link) } : null
|
|
313
|
+
/** The folder's link, then its named targets; none when it delivers nowhere. */
|
|
314
|
+
export function stepTargets(root: string): StepTarget[] {
|
|
315
|
+
return projectTargets(localProject(root)).map((t) => ({ target: targetOf(t.link), profile: targetProfileName(t.owner, t.link) }))
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
/**
|
|
@@ -324,12 +324,13 @@ export function currentTarget(root: string): StepTarget | null {
|
|
|
324
324
|
* is refused; adding a profile by hand would aim the step at a profile
|
|
325
325
|
* nothing keeps in step with the link.
|
|
326
326
|
*/
|
|
327
|
-
export function staleProblem(step: PersonStep,
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
export function staleProblem(step: PersonStep, targets: StepTarget[]): string | null {
|
|
328
|
+
const at = targets.find((t) => t.target === step.target)
|
|
329
|
+
if (step.target && !at) {
|
|
330
|
+
return `it was recorded for ${step.target}, and this folder now links to ${targets.map((t) => t.target).join(', ') || 'no workspace'}: link this folder back to it with \`frontera local sync connect --relink\`, or dismiss this step`
|
|
330
331
|
}
|
|
331
332
|
if (!step.profile) return null
|
|
332
|
-
if (
|
|
333
|
+
if (!(at ? [at] : targets).some((t) => t.profile === step.profile)) {
|
|
333
334
|
return `it names the profile ${step.profile}, which is not this folder's for its link, and it records no target to aim it at this folder's: dismiss this step`
|
|
334
335
|
}
|
|
335
336
|
if (!getProfile(step.profile)) {
|
|
@@ -415,13 +416,13 @@ export async function runStepChecks(
|
|
|
415
416
|
now = new Date(),
|
|
416
417
|
): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
|
|
417
418
|
const steps = readPersonSteps(root)
|
|
418
|
-
const
|
|
419
|
+
const targets = stepTargets(root)
|
|
419
420
|
const done: PersonStep[] = []
|
|
420
421
|
const problems: Array<{ id: string; problem: string }> = []
|
|
421
422
|
let checked = 0
|
|
422
423
|
for (const step of steps) {
|
|
423
424
|
if (step.status !== 'pending' || !step.check) continue
|
|
424
|
-
const problem = staleProblem(step,
|
|
425
|
+
const problem = staleProblem(step, targets) ?? checkProblem(step.check, readOnly)
|
|
425
426
|
if (problem) {
|
|
426
427
|
problems.push({ id: step.id, problem })
|
|
427
428
|
continue
|
|
@@ -253,14 +253,16 @@ const MEDIA_CATALOG_COLUMNS = new Set(['media_item_id', 'path', 'mime_type', 'si
|
|
|
253
253
|
/**
|
|
254
254
|
* The Media Set whose catalog an object type reads, by its API name: the
|
|
255
255
|
* catalog's dataset is `media_<set>`, and every column the type reads is one
|
|
256
|
-
* the catalog publishes. Null for a type over
|
|
257
|
-
* passages (other columns), or a dataset named
|
|
256
|
+
* the catalog publishes (an edit-only field reads none). Null for a type over
|
|
257
|
+
* a set's extracted text or passages (other columns), or a dataset named
|
|
258
|
+
* otherwise.
|
|
258
259
|
*/
|
|
259
260
|
export function mediaCatalogSet(doc: Doc): string | null {
|
|
260
261
|
const m = typeof doc.backing?.dataset === 'string' ? /^media_([A-Za-z][A-Za-z0-9_]*)$/.exec(doc.backing.dataset) : null
|
|
261
262
|
if (!m) return null
|
|
262
263
|
const mapping = (doc.backing?.mapping ?? {}) as Record<string, unknown>
|
|
263
|
-
const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; column?: unknown }>
|
|
264
|
+
const properties = ((Array.isArray(doc.properties) ? doc.properties : []) as Array<{ apiName?: string; column?: unknown; editOnly?: unknown }>)
|
|
265
|
+
.filter((p) => p.editOnly !== true)
|
|
264
266
|
const columns = properties.map((p) => (typeof p.column === 'string' ? p.column : typeof mapping[p.apiName ?? ''] === 'string' ? (mapping[p.apiName ?? ''] as string) : null))
|
|
265
267
|
return columns.every((c) => c !== null && MEDIA_CATALOG_COLUMNS.has(c)) ? m[1]! : null
|
|
266
268
|
}
|
|
@@ -7,6 +7,7 @@ import type { LocalProject } from './local-project'
|
|
|
7
7
|
import { isAlive, readState } from './local-state'
|
|
8
8
|
import { PROCEDURE_TOOL_FORMS, PROCEDURE_TOOL_GROUPS } from './local-agent-checks'
|
|
9
9
|
import { DEFAULT_HANDOVER_REASON_KEYS } from './local-operating'
|
|
10
|
+
import { TARGET_NAME } from './local-sync-link'
|
|
10
11
|
import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -47,6 +48,7 @@ export const SCHEMA_KINDS = [
|
|
|
47
48
|
'blueprint-object-set',
|
|
48
49
|
'blueprint-workspace-grants',
|
|
49
50
|
'secrets',
|
|
51
|
+
'sync',
|
|
50
52
|
] as const
|
|
51
53
|
export type SchemaKind = (typeof SCHEMA_KINDS)[number]
|
|
52
54
|
|
|
@@ -66,6 +68,7 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
|
|
|
66
68
|
knowledge: 'knowledge/<name>/knowledge.json',
|
|
67
69
|
skill: 'skills/<name>/SKILL.md (its front matter)',
|
|
68
70
|
secrets: 'secrets.json',
|
|
71
|
+
sync: 'frontera.yaml (its sync block)',
|
|
69
72
|
...BLUEPRINT_SCHEMA_FILES,
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -740,6 +743,43 @@ const SECRETS_SCHEMA: JsonSchema = {
|
|
|
740
743
|
additionalProperties: false,
|
|
741
744
|
}
|
|
742
745
|
|
|
746
|
+
/** A workspace the project reaches, as `local-sync-link.ts` reads it. */
|
|
747
|
+
const SYNC_LINK_PROPERTIES = {
|
|
748
|
+
platform: { type: 'string', description: 'The platform’s API origin, e.g. https://api.acme.frontera.example.' },
|
|
749
|
+
workspace: { type: 'string', description: 'The workspace’s id.' },
|
|
750
|
+
workspaceName: { type: 'string', description: 'Its name, shown by `sync status` and Forge; refreshed on connect.' },
|
|
751
|
+
organization: { type: 'string', description: 'The organization’s id, when an organization key was given.' },
|
|
752
|
+
organizationName: { type: 'string' },
|
|
753
|
+
blueprint: { enum: ['workspace', 'organization'], description: '`workspace`: the slice of the Blueprint granted to the workspace (default). `organization`: the whole model.' },
|
|
754
|
+
models: { type: 'object', additionalProperties: { type: 'string' }, description: 'A model only this computer has → the workspace’s model it goes as when proposed (`local sync map-model`).' },
|
|
755
|
+
} as const
|
|
756
|
+
|
|
757
|
+
/** `frontera.yaml`'s `sync` block: the project's link and its named targets (`local-sync-link.ts`). */
|
|
758
|
+
const SYNC_SCHEMA: JsonSchema = {
|
|
759
|
+
$schema: DRAFT,
|
|
760
|
+
title: 'The workspaces a project pulls from and delivers to (frontera.yaml sync)',
|
|
761
|
+
description:
|
|
762
|
+
'Written by `frontera local sync connect`, which checks the keys and keeps them in this computer’s keychain, never in the file. The block’s own fields are the project’s link: what it pulls from, and where `push` and `request` act by default. `targets` are workspaces it delivers to by name (`local sync push --target <name>`, `request apply --target <name>`), each with its own keys, stored with `frontera local sync connect --target <name>`. A teammate who clones the project runs those connects to store their own keys.',
|
|
763
|
+
type: 'object',
|
|
764
|
+
properties: {
|
|
765
|
+
...SYNC_LINK_PROPERTIES,
|
|
766
|
+
targets: {
|
|
767
|
+
type: 'object',
|
|
768
|
+
description: 'Named targets, by name: lowercase letters, digits and dashes, starting with a letter (staging, production). Each is another workspace than the link and than every other target. `platform` is an https address (the scheme may be left out); an entry the CLI cannot use is left out, and `sync status` says why.',
|
|
769
|
+
propertyNames: { pattern: TARGET_NAME.source },
|
|
770
|
+
additionalProperties: {
|
|
771
|
+
type: 'object',
|
|
772
|
+
// No `models`: a push to a target maps models with the project's own `sync.models`.
|
|
773
|
+
properties: {
|
|
774
|
+
...Object.fromEntries(Object.entries(SYNC_LINK_PROPERTIES).filter(([key]) => key !== 'models')),
|
|
775
|
+
production: { type: 'boolean', description: 'Marks a production target in `sync status` and in messages, set with `connect --target <name> --production`. Every target, production or not, is reached only by a command that names it, and the link is never pointed at a target’s workspace.' },
|
|
776
|
+
},
|
|
777
|
+
required: ['platform', 'workspace'],
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
}
|
|
782
|
+
|
|
743
783
|
/** `apps/<slug>.json`, as a pull writes it (`appDocument` in `local-apps.ts`; `appSettingsCandidate` in the service). */
|
|
744
784
|
const APP_SCHEMA: JsonSchema = {
|
|
745
785
|
$schema: DRAFT,
|
|
@@ -788,6 +828,7 @@ export function cliSchema(kind: SchemaKind): JsonSchema | null {
|
|
|
788
828
|
if (kind === 'knowledge') return KNOWLEDGE_SCHEMA
|
|
789
829
|
if (kind === 'skill') return SKILL_SCHEMA
|
|
790
830
|
if (kind === 'secrets') return SECRETS_SCHEMA
|
|
831
|
+
if (kind === 'sync') return SYNC_SCHEMA
|
|
791
832
|
if (kind in BLUEPRINT_SCHEMAS) return BLUEPRINT_SCHEMAS[kind as keyof typeof BLUEPRINT_SCHEMAS]
|
|
792
833
|
return null
|
|
793
834
|
}
|