@pikku/core 0.12.64 → 0.12.66
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/CHANGELOG.md +221 -0
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/testing/service-tests.js +37 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/testing/service-tests.ts +49 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1105,6 +1105,109 @@ describe('streamAIAgent', () => {
|
|
|
1105
1105
|
},
|
|
1106
1106
|
])
|
|
1107
1107
|
})
|
|
1108
|
+
/**
|
|
1109
|
+
* `send` is synchronous, so the persisting channel cannot await its flush and
|
|
1110
|
+
* calls it fire-and-forget. Any rejection from storage — a dropped connection,
|
|
1111
|
+
* or a model reusing a toolCallId, which is a primary key in AI storage — then
|
|
1112
|
+
* surfaces as an unhandled rejection and takes the whole server process down
|
|
1113
|
+
* with it. A failed persist must fail (or degrade) the run, not the process.
|
|
1114
|
+
*/
|
|
1115
|
+
test('a storage failure while streaming does not become an unhandled rejection', async () => {
|
|
1116
|
+
addTestAgent('storage-failure-agent')
|
|
1117
|
+
|
|
1118
|
+
const events: AIStreamEvent[] = []
|
|
1119
|
+
const loggedErrors: unknown[] = []
|
|
1120
|
+
|
|
1121
|
+
const mockServices = {
|
|
1122
|
+
logger: {
|
|
1123
|
+
info: () => {},
|
|
1124
|
+
warn: () => {},
|
|
1125
|
+
error: (...args: unknown[]) => {
|
|
1126
|
+
loggedErrors.push(args)
|
|
1127
|
+
},
|
|
1128
|
+
debug: () => {},
|
|
1129
|
+
},
|
|
1130
|
+
aiAgentRunner: {
|
|
1131
|
+
// Emits through the channel the way the real runner does: the persisting
|
|
1132
|
+
// channel flushes fire-and-forget on `usage`, which is the path that
|
|
1133
|
+
// cannot await and so cannot catch.
|
|
1134
|
+
stream: async (
|
|
1135
|
+
_params: unknown,
|
|
1136
|
+
channel: AIStreamChannel
|
|
1137
|
+
): Promise<AIAgentStepResult> => {
|
|
1138
|
+
channel.send({
|
|
1139
|
+
type: 'text-delta',
|
|
1140
|
+
text: 'persisted text',
|
|
1141
|
+
} as AIStreamEvent)
|
|
1142
|
+
channel.send({
|
|
1143
|
+
type: 'usage',
|
|
1144
|
+
tokens: { input: 1, output: 1 },
|
|
1145
|
+
model: 'test/test-model',
|
|
1146
|
+
} as AIStreamEvent)
|
|
1147
|
+
return makeStepResult({ text: 'persisted text' })
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
aiRunState: {
|
|
1151
|
+
createRun: async () => 'run-storage-failure',
|
|
1152
|
+
updateRun: async () => {},
|
|
1153
|
+
},
|
|
1154
|
+
aiStorage: {
|
|
1155
|
+
getThread: async () => undefined,
|
|
1156
|
+
createThread: async () => {},
|
|
1157
|
+
getMessages: async () => [],
|
|
1158
|
+
// Fails only on the assistant/tool write the persisting channel makes,
|
|
1159
|
+
// the way a repeated toolCallId collides on its primary key — the
|
|
1160
|
+
// initial user-message save is awaited and must still succeed.
|
|
1161
|
+
saveMessages: async (_threadId: string, messages: AIMessage[]) => {
|
|
1162
|
+
if (messages.some((m) => m.role !== 'user')) {
|
|
1163
|
+
throw new Error('UNIQUE constraint failed: ai_tool_call.id')
|
|
1164
|
+
}
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
} as any
|
|
1168
|
+
|
|
1169
|
+
pikkuState(null, 'package', 'singletonServices', mockServices)
|
|
1170
|
+
|
|
1171
|
+
const unhandled: unknown[] = []
|
|
1172
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason)
|
|
1173
|
+
process.on('unhandledRejection', onUnhandled)
|
|
1174
|
+
|
|
1175
|
+
try {
|
|
1176
|
+
await streamAIAgent(
|
|
1177
|
+
'storage-failure-agent',
|
|
1178
|
+
{
|
|
1179
|
+
message: 'hello',
|
|
1180
|
+
threadId: 'thread-storage-failure',
|
|
1181
|
+
resourceId: 'resource-storage-failure',
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
channelId: 'channel-storage-failure',
|
|
1185
|
+
openingData: undefined,
|
|
1186
|
+
state: 'open',
|
|
1187
|
+
send: (event: AIStreamEvent) => {
|
|
1188
|
+
events.push(event)
|
|
1189
|
+
},
|
|
1190
|
+
close: () => {},
|
|
1191
|
+
},
|
|
1192
|
+
{}
|
|
1193
|
+
)
|
|
1194
|
+
// unhandledRejection is emitted on a later macrotask, so the floating
|
|
1195
|
+
// flush promise needs a real timer to settle before asserting.
|
|
1196
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
1197
|
+
} finally {
|
|
1198
|
+
process.off('unhandledRejection', onUnhandled)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
assert.deepEqual(
|
|
1202
|
+
unhandled,
|
|
1203
|
+
[],
|
|
1204
|
+
'a storage failure must not escape as an unhandled rejection'
|
|
1205
|
+
)
|
|
1206
|
+
assert.ok(
|
|
1207
|
+
loggedErrors.length > 0,
|
|
1208
|
+
'the storage failure should be reported rather than swallowed'
|
|
1209
|
+
)
|
|
1210
|
+
})
|
|
1108
1211
|
})
|
|
1109
1212
|
|
|
1110
1213
|
describe('ai-agent-stream helpers', () => {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
resolveOwnerResourceId,
|
|
38
38
|
agentSessionScope,
|
|
39
39
|
assertResourceOwner,
|
|
40
|
+
assertAgentAuthorized,
|
|
40
41
|
ToolApprovalRequired,
|
|
41
42
|
ToolCredentialRequired,
|
|
42
43
|
APPROVAL_REQUIRED,
|
|
@@ -57,7 +58,8 @@ type PersistingChannel = AIStreamChannel & {
|
|
|
57
58
|
function createPersistingChannel(
|
|
58
59
|
parent: AIStreamChannel,
|
|
59
60
|
storage: AIStorageService | undefined,
|
|
60
|
-
threadId: string
|
|
61
|
+
threadId: string,
|
|
62
|
+
logger?: { error: (...args: any[]) => void }
|
|
61
63
|
): PersistingChannel {
|
|
62
64
|
let fullText = ''
|
|
63
65
|
let stepText = ''
|
|
@@ -110,6 +112,23 @@ function createPersistingChannel(
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* `send` is synchronous and so cannot await the flush. A rejection would have
|
|
117
|
+
* nothing to propagate to and would take the process down as an unhandled
|
|
118
|
+
* rejection — a model reusing a toolCallId, which is a primary key in AI
|
|
119
|
+
* storage, is enough to trigger it. Persistence from inside `send` is
|
|
120
|
+
* therefore best-effort: the run carries on, and the awaited `flush()` on the
|
|
121
|
+
* suspend paths still surfaces failures to its caller.
|
|
122
|
+
*/
|
|
123
|
+
const flushDetached = () => {
|
|
124
|
+
void flushStep().catch((error) => {
|
|
125
|
+
logger?.error('Failed to persist agent messages', {
|
|
126
|
+
threadId,
|
|
127
|
+
error,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
const channel: PersistingChannel = {
|
|
114
133
|
channelId: parent.channelId,
|
|
115
134
|
openingData: parent.openingData,
|
|
@@ -156,10 +175,10 @@ function createPersistingChannel(
|
|
|
156
175
|
totalUsage.inputTokens += event.tokens.input
|
|
157
176
|
totalUsage.outputTokens += event.tokens.output
|
|
158
177
|
if (event.model) totalUsage.model = event.model
|
|
159
|
-
|
|
178
|
+
flushDetached()
|
|
160
179
|
break
|
|
161
180
|
case 'done':
|
|
162
|
-
|
|
181
|
+
flushDetached()
|
|
163
182
|
break
|
|
164
183
|
}
|
|
165
184
|
}
|
|
@@ -730,7 +749,12 @@ export async function streamAIAgent(
|
|
|
730
749
|
}
|
|
731
750
|
)
|
|
732
751
|
|
|
733
|
-
const persistingChannel = createPersistingChannel(
|
|
752
|
+
const persistingChannel = createPersistingChannel(
|
|
753
|
+
channel,
|
|
754
|
+
storage,
|
|
755
|
+
threadId,
|
|
756
|
+
singletonServices.logger
|
|
757
|
+
)
|
|
734
758
|
|
|
735
759
|
const wrappedChannel =
|
|
736
760
|
allChannelMiddleware.length > 0
|
|
@@ -898,12 +922,19 @@ export async function resumeAIAgent(
|
|
|
898
922
|
)
|
|
899
923
|
}
|
|
900
924
|
|
|
925
|
+
const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
|
|
926
|
+
|
|
927
|
+
// Gate before resolving the approval: recording it is a persisted side
|
|
928
|
+
// effect, so an unauthorized caller must not reach it. Run ownership alone is
|
|
929
|
+
// not enough — a grant revoked while the run was suspended must stop the
|
|
930
|
+
// caller from approving its pending tool calls.
|
|
931
|
+
await assertAgentAuthorized(agent, params, packageName)
|
|
932
|
+
|
|
901
933
|
await aiRunState.resolveApproval(
|
|
902
934
|
input.toolCallId,
|
|
903
935
|
input.approved ? 'approved' : 'denied'
|
|
904
936
|
)
|
|
905
937
|
|
|
906
|
-
const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
|
|
907
938
|
const { storage } = resolveMemoryServices(agent, singletonServices)
|
|
908
939
|
const memoryConfig = agent.memory
|
|
909
940
|
const agentRunner = singletonServices.aiAgentRunner
|
|
@@ -1229,7 +1260,8 @@ async function continueAfterToolResult(
|
|
|
1229
1260
|
const persistingChannel = createPersistingChannel(
|
|
1230
1261
|
channel,
|
|
1231
1262
|
storage,
|
|
1232
|
-
run.threadId
|
|
1263
|
+
run.threadId,
|
|
1264
|
+
singletonServices.logger
|
|
1233
1265
|
)
|
|
1234
1266
|
|
|
1235
1267
|
const wrappedChannel =
|
|
@@ -226,6 +226,7 @@ export type AIAgentMemoryConfig = {
|
|
|
226
226
|
export type CoreAIAgent<
|
|
227
227
|
PikkuPermission = CorePikkuPermission<any, any>,
|
|
228
228
|
PikkuMiddleware = CorePikkuMiddleware<any>,
|
|
229
|
+
Scope extends string = string,
|
|
229
230
|
> = {
|
|
230
231
|
name: string
|
|
231
232
|
description: string
|
|
@@ -259,6 +260,23 @@ export type CoreAIAgent<
|
|
|
259
260
|
middleware?: PikkuMiddleware[]
|
|
260
261
|
channelMiddleware?: CorePikkuChannelMiddleware<any, any>[]
|
|
261
262
|
aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[]
|
|
263
|
+
/**
|
|
264
|
+
* Whether a session is required to run this agent. Defaults to `false`, since
|
|
265
|
+
* agents are commonly invoked from an already-authenticated `pikkuFunc` or
|
|
266
|
+
* from genuinely sessionless contexts (crons, queue workers). Set `true` to
|
|
267
|
+
* require a session at the agent itself. `scopes` and `permissions` are
|
|
268
|
+
* enforced either way.
|
|
269
|
+
*/
|
|
270
|
+
auth?: boolean
|
|
271
|
+
/**
|
|
272
|
+
* Scopes the session must hold to run this agent. All of them are required
|
|
273
|
+
* (AND), and they are checked before `permissions` — unlike permissions,
|
|
274
|
+
* which OR together, a scope can only narrow access.
|
|
275
|
+
*
|
|
276
|
+
* Narrowed to the generated `ScopeId` union in a project's own
|
|
277
|
+
* `pikku-types.gen.ts`, so an undeclared scope is a compile error.
|
|
278
|
+
*/
|
|
279
|
+
scopes?: Scope[]
|
|
262
280
|
permissions?: CorePermissionGroup<PikkuPermission>
|
|
263
281
|
}
|
|
264
282
|
|
|
@@ -421,6 +439,17 @@ export interface AgentRunService {
|
|
|
421
439
|
listThreads(options?: {
|
|
422
440
|
agentName?: string
|
|
423
441
|
resourceId?: string
|
|
442
|
+
/**
|
|
443
|
+
* Restrict results to threads owned by one of these session principals. A
|
|
444
|
+
* thread matches when its `resourceId` is the principal itself or one of its
|
|
445
|
+
* `principal:` sub-partitions, mirroring the composition
|
|
446
|
+
* `resolveOwnerResourceId` writes.
|
|
447
|
+
*
|
|
448
|
+
* Unlike `resourceId`, which is an optional exact-match filter, this is an
|
|
449
|
+
* authorization constraint: an empty array matches nothing. Callers exposing
|
|
450
|
+
* threads over the wire must derive it from the session, never from input.
|
|
451
|
+
*/
|
|
452
|
+
owners?: string[]
|
|
424
453
|
limit?: number
|
|
425
454
|
offset?: number
|
|
426
455
|
}): Promise<AIThread[]>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { voiceInput } from './voice-input.js'
|
|
5
|
+
import type { AIContentPart, AIMessage } from './ai-agent.types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A runner whose `transcribe` reads instance state through `this`, mirroring the
|
|
9
|
+
* real `VercelAIAgentRunner` where `transcribe` calls `this.getModel(...)`. If
|
|
10
|
+
* the middleware grabs the method as a bare reference the receiver is lost and
|
|
11
|
+
* `this` is undefined — which is exactly the regression this guards.
|
|
12
|
+
*/
|
|
13
|
+
class ThisDependentRunner {
|
|
14
|
+
private readonly transcript = 'the transcribed spoken words'
|
|
15
|
+
|
|
16
|
+
async transcribe() {
|
|
17
|
+
return {
|
|
18
|
+
text: this.transcript,
|
|
19
|
+
segments: [],
|
|
20
|
+
language: 'en',
|
|
21
|
+
durationInSeconds: 1,
|
|
22
|
+
warnings: [],
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const audioMessage = (): AIMessage => ({
|
|
28
|
+
id: 'm1',
|
|
29
|
+
role: 'user',
|
|
30
|
+
content: [
|
|
31
|
+
{ type: 'file', mediaType: 'audio/wav', data: 'AAAA' },
|
|
32
|
+
] as AIContentPart[],
|
|
33
|
+
createdAt: new Date(0),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('voiceInput', () => {
|
|
37
|
+
test('transcribes an audio part by calling the runner with its receiver intact', async () => {
|
|
38
|
+
const mw = voiceInput({ model: 'mock/whisper' })
|
|
39
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
40
|
+
|
|
41
|
+
const result = await mw.modifyInput!(services as any, {
|
|
42
|
+
messages: [audioMessage()],
|
|
43
|
+
instructions: 'sys',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const last = result.messages[result.messages.length - 1]!
|
|
47
|
+
const parts = last.content as AIContentPart[]
|
|
48
|
+
assert.equal(parts.length, 1)
|
|
49
|
+
assert.equal(parts[0]!.type, 'text')
|
|
50
|
+
assert.equal(
|
|
51
|
+
(parts[0] as { text: string }).text,
|
|
52
|
+
'the transcribed spoken words'
|
|
53
|
+
)
|
|
54
|
+
assert.ok(!parts.some((p) => p.type === 'file'))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('leaves the message untouched when there is no audio part', async () => {
|
|
58
|
+
const mw = voiceInput({ model: 'mock/whisper' })
|
|
59
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
60
|
+
const messages: AIMessage[] = [
|
|
61
|
+
{
|
|
62
|
+
id: 'm1',
|
|
63
|
+
role: 'user',
|
|
64
|
+
content: 'just text',
|
|
65
|
+
createdAt: new Date(0),
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const result = await mw.modifyInput!(services as any, {
|
|
70
|
+
messages,
|
|
71
|
+
instructions: 'sys',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
assert.equal(result.messages[0]!.content, 'just text')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('throws when an audio part is present but no model is configured', async () => {
|
|
78
|
+
const mw = voiceInput({})
|
|
79
|
+
const services = { aiAgentRunner: new ThisDependentRunner() }
|
|
80
|
+
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
() =>
|
|
83
|
+
mw.modifyInput!(services as any, {
|
|
84
|
+
messages: [audioMessage()],
|
|
85
|
+
instructions: 'sys',
|
|
86
|
+
}),
|
|
87
|
+
/voiceInput requires a transcription model/
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -18,11 +18,7 @@ async function fetchAsUint8Array(
|
|
|
18
18
|
url: string,
|
|
19
19
|
allowedAudioHosts?: string[]
|
|
20
20
|
): Promise<Uint8Array> {
|
|
21
|
-
const response = await safeFetch(
|
|
22
|
-
url,
|
|
23
|
-
{},
|
|
24
|
-
{ allowedHosts: allowedAudioHosts }
|
|
25
|
-
)
|
|
21
|
+
const response = await safeFetch(url, {}, { allowedHosts: allowedAudioHosts })
|
|
26
22
|
const contentLength = response.headers.get('content-length')
|
|
27
23
|
if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
|
|
28
24
|
throw new Error('Audio file exceeds maximum size')
|
|
@@ -41,10 +37,12 @@ export const voiceInput = (config?: {
|
|
|
41
37
|
}) =>
|
|
42
38
|
pikkuAIMiddleware({
|
|
43
39
|
modifyInput: async (services, { messages, instructions }) => {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
const aiAgentRunner = (
|
|
41
|
+
services as {
|
|
42
|
+
aiAgentRunner?: AIAgentRunnerService
|
|
43
|
+
}
|
|
44
|
+
).aiAgentRunner
|
|
45
|
+
if (!aiAgentRunner?.transcribe) return { messages, instructions }
|
|
48
46
|
|
|
49
47
|
const last = messages[messages.length - 1]
|
|
50
48
|
if (!last || last.role !== 'user' || typeof last.content === 'string') {
|
|
@@ -76,7 +74,7 @@ export const voiceInput = (config?: {
|
|
|
76
74
|
const audioData = p.data
|
|
77
75
|
? base64ToUint8Array(p.data)
|
|
78
76
|
: await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
|
|
79
|
-
const result = await
|
|
77
|
+
const result = await aiAgentRunner.transcribe({
|
|
80
78
|
model: config.model,
|
|
81
79
|
audio: audioData,
|
|
82
80
|
...(config.language
|
|
@@ -490,6 +490,10 @@ export async function executeCLI({
|
|
|
490
490
|
return
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
// Non-fatal diagnostics (unknown options are still accepted) go to stderr
|
|
494
|
+
// so they never pollute a command's machine-readable stdout.
|
|
495
|
+
parsed.warnings.forEach((warning) => console.error(`Warning: ${warning}`))
|
|
496
|
+
|
|
493
497
|
if (parsed.errors.length > 0) {
|
|
494
498
|
// Check if any error is about an unknown command
|
|
495
499
|
const hasUnknownCommand = parsed.errors.some(
|
|
@@ -270,6 +270,7 @@ describe('Command Parser', () => {
|
|
|
270
270
|
// Unknown options are allowed (for forward compatibility)
|
|
271
271
|
// They just won't have defaults or validation
|
|
272
272
|
assert.strictEqual(result.options.unknown, true)
|
|
273
|
+
assert.strictEqual(result.errors.length, 0)
|
|
273
274
|
})
|
|
274
275
|
|
|
275
276
|
test('should report error for unknown short flag', () => {
|
|
@@ -525,4 +526,133 @@ describe('Command Parser', () => {
|
|
|
525
526
|
)
|
|
526
527
|
})
|
|
527
528
|
})
|
|
529
|
+
|
|
530
|
+
describe('unknown long options', () => {
|
|
531
|
+
const listMeta: CLIMeta = {
|
|
532
|
+
programs: {
|
|
533
|
+
'test-cli': {
|
|
534
|
+
program: 'test-cli',
|
|
535
|
+
options: {},
|
|
536
|
+
commands: {
|
|
537
|
+
list: {
|
|
538
|
+
pikkuFuncId: 'listFunc',
|
|
539
|
+
positionals: [],
|
|
540
|
+
options: {
|
|
541
|
+
section: { description: 'Section to list', default: 'all' },
|
|
542
|
+
autoApply: {
|
|
543
|
+
description: 'Apply automatically',
|
|
544
|
+
default: false,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
renderers: {},
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
test('warns (but does not error) on an unknown --opt value option', () => {
|
|
555
|
+
const result = parseCLIArguments(
|
|
556
|
+
['list', '--sektion', 'functions'],
|
|
557
|
+
'test-cli',
|
|
558
|
+
listMeta
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
assert.strictEqual(result.errors.length, 0, 'should stay non-fatal')
|
|
562
|
+
assert.strictEqual(result.warnings.length, 1)
|
|
563
|
+
assert.ok(
|
|
564
|
+
result.warnings[0].startsWith('Unknown option: --sektion (ignored)'),
|
|
565
|
+
`unexpected warning: ${result.warnings[0]}`
|
|
566
|
+
)
|
|
567
|
+
// Forward compatibility: the value is still parsed through
|
|
568
|
+
assert.strictEqual(result.options.sektion, 'functions')
|
|
569
|
+
// ...and the real option keeps its default
|
|
570
|
+
assert.strictEqual(result.options.section, 'all')
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
test('warns on an unknown --opt=value option', () => {
|
|
574
|
+
const result = parseCLIArguments(
|
|
575
|
+
['list', '--sektion=functions'],
|
|
576
|
+
'test-cli',
|
|
577
|
+
listMeta
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
assert.strictEqual(result.errors.length, 0)
|
|
581
|
+
assert.strictEqual(result.warnings.length, 1)
|
|
582
|
+
assert.ok(
|
|
583
|
+
result.warnings[0].startsWith('Unknown option: --sektion (ignored)'),
|
|
584
|
+
`unexpected warning: ${result.warnings[0]}`
|
|
585
|
+
)
|
|
586
|
+
assert.strictEqual(result.options.sektion, 'functions')
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test('suggests a near-miss option name', () => {
|
|
590
|
+
const result = parseCLIArguments(
|
|
591
|
+
['list', '--sektion'],
|
|
592
|
+
'test-cli',
|
|
593
|
+
listMeta
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
assert.ok(
|
|
597
|
+
result.warnings[0].includes('Did you mean --section?'),
|
|
598
|
+
`expected a suggestion, got: ${result.warnings[0]}`
|
|
599
|
+
)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('suggests the kebab-case rendering of a camelCase option', () => {
|
|
603
|
+
const result = parseCLIArguments(
|
|
604
|
+
['list', '--auto-aply'],
|
|
605
|
+
'test-cli',
|
|
606
|
+
listMeta
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
assert.ok(
|
|
610
|
+
result.warnings[0].includes('Did you mean --auto-apply?'),
|
|
611
|
+
`expected a suggestion, got: ${result.warnings[0]}`
|
|
612
|
+
)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
test('omits the suggestion when nothing is close', () => {
|
|
616
|
+
const result = parseCLIArguments(
|
|
617
|
+
['list', '--completely-different'],
|
|
618
|
+
'test-cli',
|
|
619
|
+
listMeta
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
assert.strictEqual(
|
|
623
|
+
result.warnings[0],
|
|
624
|
+
'Unknown option: --completely-different (ignored)'
|
|
625
|
+
)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
test('does not warn for a known option (either casing)', () => {
|
|
629
|
+
const kebab = parseCLIArguments(
|
|
630
|
+
['list', '--section', 'functions', '--auto-apply'],
|
|
631
|
+
'test-cli',
|
|
632
|
+
listMeta
|
|
633
|
+
)
|
|
634
|
+
assert.deepStrictEqual(kebab.warnings, [])
|
|
635
|
+
assert.strictEqual(kebab.options.section, 'functions')
|
|
636
|
+
|
|
637
|
+
const camel = parseCLIArguments(
|
|
638
|
+
['list', '--autoApply'],
|
|
639
|
+
'test-cli',
|
|
640
|
+
listMeta
|
|
641
|
+
)
|
|
642
|
+
assert.deepStrictEqual(camel.warnings, [])
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
test('does not warn for --help', () => {
|
|
646
|
+
const result = parseCLIArguments(['list', '--help'], 'test-cli', listMeta)
|
|
647
|
+
|
|
648
|
+
assert.deepStrictEqual(result.warnings, [])
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test('unknown short flags still error, not warn', () => {
|
|
652
|
+
const result = parseCLIArguments(['list', '-x'], 'test-cli', listMeta)
|
|
653
|
+
|
|
654
|
+
assert.ok(result.errors.some((e) => e.includes('Unknown option: -x')))
|
|
655
|
+
assert.deepStrictEqual(result.warnings, [])
|
|
656
|
+
})
|
|
657
|
+
})
|
|
528
658
|
})
|
|
@@ -18,6 +18,71 @@ function toKebabCase(str: string): string {
|
|
|
18
18
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Options the runner handles itself — never reported as unknown. */
|
|
22
|
+
const RESERVED_OPTIONS = new Set(['help'])
|
|
23
|
+
|
|
24
|
+
/** Levenshtein distance, capped-free and dependency-free. Used only to suggest
|
|
25
|
+
* a near-miss option name, so the naive O(n*m) implementation is fine. */
|
|
26
|
+
function levenshtein(a: string, b: string): number {
|
|
27
|
+
if (a === b) return 0
|
|
28
|
+
if (a.length === 0) return b.length
|
|
29
|
+
if (b.length === 0) return a.length
|
|
30
|
+
|
|
31
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
32
|
+
for (let i = 1; i <= a.length; i++) {
|
|
33
|
+
const row = [i]
|
|
34
|
+
for (let j = 1; j <= b.length; j++) {
|
|
35
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
36
|
+
row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
|
|
37
|
+
}
|
|
38
|
+
prev = row
|
|
39
|
+
}
|
|
40
|
+
return prev[b.length]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Finds the closest declared option (distance <= 2) to what the user typed.
|
|
44
|
+
* Compares against the kebab-case rendering, since that is what is typed. */
|
|
45
|
+
function suggestOption(
|
|
46
|
+
typed: string,
|
|
47
|
+
availableOptions: Record<string, CLIOption>
|
|
48
|
+
): string | null {
|
|
49
|
+
let best: string | null = null
|
|
50
|
+
let bestDistance = 3
|
|
51
|
+
|
|
52
|
+
for (const name of Object.keys(availableOptions)) {
|
|
53
|
+
const kebab = toKebabCase(name)
|
|
54
|
+
const distance = Math.min(
|
|
55
|
+
levenshtein(typed, kebab),
|
|
56
|
+
levenshtein(typed, name)
|
|
57
|
+
)
|
|
58
|
+
if (distance < bestDistance) {
|
|
59
|
+
bestDistance = distance
|
|
60
|
+
best = kebab
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return best
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Records a warning that an unknown long option was accepted but ignored.
|
|
68
|
+
* Unknown options stay non-fatal for forward compatibility (a newer command
|
|
69
|
+
* version may understand them) — they are just no longer silent. */
|
|
70
|
+
function warnUnknownOption(
|
|
71
|
+
typed: string,
|
|
72
|
+
availableOptions: Record<string, CLIOption>,
|
|
73
|
+
result: ParsedCommand
|
|
74
|
+
) {
|
|
75
|
+
if (RESERVED_OPTIONS.has(toCamelCase(typed))) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const suggestion = suggestOption(typed, availableOptions)
|
|
80
|
+
result.warnings.push(
|
|
81
|
+
`Unknown option: --${typed} (ignored)` +
|
|
82
|
+
(suggestion ? ` Did you mean --${suggestion}?` : '')
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
21
86
|
/**
|
|
22
87
|
* Result of parsing CLI arguments
|
|
23
88
|
*/
|
|
@@ -27,6 +92,8 @@ export interface ParsedCommand {
|
|
|
27
92
|
positionals: Record<string, any>
|
|
28
93
|
options: Record<string, any>
|
|
29
94
|
errors: string[]
|
|
95
|
+
/** Non-fatal diagnostics (e.g. unknown options that were accepted+ignored) */
|
|
96
|
+
warnings: string[]
|
|
30
97
|
}
|
|
31
98
|
|
|
32
99
|
/**
|
|
@@ -43,6 +110,7 @@ export function parseCLIArguments(
|
|
|
43
110
|
positionals: {},
|
|
44
111
|
options: {},
|
|
45
112
|
errors: [],
|
|
113
|
+
warnings: [],
|
|
46
114
|
}
|
|
47
115
|
|
|
48
116
|
const meta = allMeta.programs[programName]
|
|
@@ -148,7 +216,12 @@ export function parseCLIArguments(
|
|
|
148
216
|
const key = toCamelCase(arg.slice(2, equalIndex))
|
|
149
217
|
const optionDef = availableOptions[key]
|
|
150
218
|
|
|
151
|
-
// Unknown options are allowed for forward compatibility
|
|
219
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
220
|
+
// about so they are not silently dropped by the input schema.
|
|
221
|
+
if (!optionDef) {
|
|
222
|
+
warnUnknownOption(arg.slice(2, equalIndex), availableOptions, result)
|
|
223
|
+
}
|
|
224
|
+
|
|
152
225
|
const value = arg.slice(equalIndex + 1)
|
|
153
226
|
optionArgs[key] = parseOptionValue(value, optionDef)
|
|
154
227
|
} else {
|
|
@@ -156,7 +229,12 @@ export function parseCLIArguments(
|
|
|
156
229
|
const key = toCamelCase(arg.slice(2))
|
|
157
230
|
const optionDef = availableOptions[key]
|
|
158
231
|
|
|
159
|
-
// Unknown options are allowed for forward compatibility
|
|
232
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
233
|
+
// about so they are not silently dropped by the input schema.
|
|
234
|
+
if (!optionDef) {
|
|
235
|
+
warnUnknownOption(arg.slice(2), availableOptions, result)
|
|
236
|
+
}
|
|
237
|
+
|
|
160
238
|
if (optionDef && optionDef.array) {
|
|
161
239
|
// Array option - collect all following non-flag values
|
|
162
240
|
currentIndex++
|
|
@@ -6,6 +6,13 @@ export type CoreCredential<T = unknown> = {
|
|
|
6
6
|
description?: string
|
|
7
7
|
type: 'singleton' | 'wire'
|
|
8
8
|
schema: T
|
|
9
|
+
/**
|
|
10
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
11
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
12
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
13
|
+
* an opaque identifier.
|
|
14
|
+
*/
|
|
15
|
+
docsUrl?: string
|
|
9
16
|
oauth2?: OAuth2CredentialConfig & {
|
|
10
17
|
appCredentialSecretId: string
|
|
11
18
|
}
|
|
@@ -17,6 +24,13 @@ export type CredentialDefinitionMeta = {
|
|
|
17
24
|
description?: string
|
|
18
25
|
type: 'singleton' | 'wire'
|
|
19
26
|
schema?: Record<string, unknown> | string
|
|
27
|
+
/**
|
|
28
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
29
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
30
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
31
|
+
* an opaque identifier.
|
|
32
|
+
*/
|
|
33
|
+
docsUrl?: string
|
|
20
34
|
oauth2?: OAuth2CredentialConfig & {
|
|
21
35
|
appCredentialSecretId: string
|
|
22
36
|
}
|