@pikku/core 0.12.74 → 0.12.78
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 +397 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/in-memory-workflow-service.d.ts +1 -0
- package/dist/services/in-memory-workflow-service.js +20 -0
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- 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.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/in-memory-workflow-service.ts +29 -0
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +12 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createReadStream } from 'fs';
|
|
2
|
+
import { mkdir, stat, writeFile } from 'fs/promises';
|
|
3
|
+
import { normalize, resolve } from 'path';
|
|
4
|
+
import { Readable } from 'stream';
|
|
5
|
+
import { signedContentPath } from './local-content.js';
|
|
6
|
+
const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
7
|
+
const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a key against the content root, or `null` if it escapes. `normalize`
|
|
10
|
+
* first so `..` segments are collapsed before the prefix check, and the
|
|
11
|
+
* comparison carries a trailing separator so a sibling directory whose name
|
|
12
|
+
* merely starts with the root's cannot pass as being inside it.
|
|
13
|
+
*/
|
|
14
|
+
const toTargetPath = (basePath, key) => {
|
|
15
|
+
const normalizedBasePath = resolve(basePath);
|
|
16
|
+
const targetPath = resolve(normalizedBasePath, normalize(key));
|
|
17
|
+
return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
|
|
18
|
+
};
|
|
19
|
+
const parseSizeLimit = (sizeLimit) => {
|
|
20
|
+
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new Error(`Invalid size limit: ${sizeLimit}`);
|
|
23
|
+
}
|
|
24
|
+
const value = Number(match[1]);
|
|
25
|
+
const unit = (match[2] ?? 'b').toLowerCase();
|
|
26
|
+
const multiplier = unit === 'gb'
|
|
27
|
+
? 1024 * 1024 * 1024
|
|
28
|
+
: unit === 'mb'
|
|
29
|
+
? 1024 * 1024
|
|
30
|
+
: unit === 'kb'
|
|
31
|
+
? 1024
|
|
32
|
+
: 1;
|
|
33
|
+
return value * multiplier;
|
|
34
|
+
};
|
|
35
|
+
const text = (status, body) => new Response(body, {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
38
|
+
});
|
|
39
|
+
export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) => {
|
|
40
|
+
// Logged at most once. An unverifiable request is attacker-triggerable, so
|
|
41
|
+
// this reports a startup misconfiguration rather than per-request news.
|
|
42
|
+
let loggedMissingJWT = false;
|
|
43
|
+
const validateSignedAssetRequest = async (requestUrl) => {
|
|
44
|
+
const signedAtValue = requestUrl.searchParams.get('signedAt');
|
|
45
|
+
const expiresAtValue = requestUrl.searchParams.get('expiresAt');
|
|
46
|
+
const notBeforeValue = requestUrl.searchParams.get('notBefore');
|
|
47
|
+
const signature = requestUrl.searchParams.get('signature');
|
|
48
|
+
if (!signedAtValue || !expiresAtValue) {
|
|
49
|
+
return { ok: false, status: 403, body: 'Signed URL required' };
|
|
50
|
+
}
|
|
51
|
+
const signedAt = Number(signedAtValue);
|
|
52
|
+
const expiresAt = Number(expiresAtValue);
|
|
53
|
+
const notBefore = notBeforeValue == null ? undefined : Number(notBeforeValue);
|
|
54
|
+
if (!Number.isFinite(signedAt) ||
|
|
55
|
+
!Number.isFinite(expiresAt) ||
|
|
56
|
+
(notBefore != null && !Number.isFinite(notBefore))) {
|
|
57
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' };
|
|
58
|
+
}
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (now > expiresAt || (notBefore != null && now < notBefore)) {
|
|
61
|
+
return { ok: false, status: 403, body: 'Signed URL expired' };
|
|
62
|
+
}
|
|
63
|
+
const jwt = getJWT();
|
|
64
|
+
if (!jwt) {
|
|
65
|
+
if (!loggedMissingJWT) {
|
|
66
|
+
loggedMissingJWT = true;
|
|
67
|
+
logger.error('pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.');
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' };
|
|
70
|
+
}
|
|
71
|
+
if (!signature) {
|
|
72
|
+
return { ok: false, status: 403, body: 'Signed URL signature required' };
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const payload = await jwt.decode(signature);
|
|
76
|
+
// Every claim is compared, the path included: without it a signature
|
|
77
|
+
// minted for one asset would read any other.
|
|
78
|
+
if (payload.signedAt !== signedAt ||
|
|
79
|
+
payload.expiresAt !== expiresAt ||
|
|
80
|
+
payload.notBefore !== notBefore ||
|
|
81
|
+
payload.path !== signedContentPath(requestUrl.pathname)) {
|
|
82
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true };
|
|
89
|
+
};
|
|
90
|
+
const handleUpload = async (request, pathname) => {
|
|
91
|
+
const key = contentKey(pathname, content.uploadUrlPrefix);
|
|
92
|
+
const targetPath = toTargetPath(content.localFileUploadPath, key);
|
|
93
|
+
if (!targetPath) {
|
|
94
|
+
return text(400, 'Invalid path');
|
|
95
|
+
}
|
|
96
|
+
const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb');
|
|
97
|
+
// Counted as it arrives and abandoned the moment it goes over, so an
|
|
98
|
+
// oversized upload costs the limit rather than its own size — `arrayBuffer()`
|
|
99
|
+
// would have to hold all of it first, which hands an unauthenticated caller
|
|
100
|
+
// a way to spend the server's memory. Mirrors node-http-server's
|
|
101
|
+
// `readRequestBody`, which aborts the same way.
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let bytesRead = 0;
|
|
104
|
+
const reader = request.body?.getReader();
|
|
105
|
+
if (reader) {
|
|
106
|
+
try {
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
if (done)
|
|
110
|
+
break;
|
|
111
|
+
bytesRead += value.byteLength;
|
|
112
|
+
if (bytesRead > maxBytes) {
|
|
113
|
+
await reader.cancel();
|
|
114
|
+
return text(413, 'Content too large');
|
|
115
|
+
}
|
|
116
|
+
chunks.push(Buffer.from(value));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
|
124
|
+
await writeFile(targetPath, Buffer.concat(chunks));
|
|
125
|
+
return new Response(null, { status: 200 });
|
|
126
|
+
};
|
|
127
|
+
const handleAsset = async (request, requestUrl, pathname) => {
|
|
128
|
+
const key = contentKey(pathname, content.assetUrlPrefix);
|
|
129
|
+
const targetPath = toTargetPath(content.localFileUploadPath, key);
|
|
130
|
+
if (!targetPath) {
|
|
131
|
+
return text(400, 'Invalid path');
|
|
132
|
+
}
|
|
133
|
+
const signed = await validateSignedAssetRequest(requestUrl);
|
|
134
|
+
if (!signed.ok) {
|
|
135
|
+
return text(signed.status, signed.body);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const file = await stat(targetPath);
|
|
139
|
+
if (!file.isFile()) {
|
|
140
|
+
return new Response(null, { status: 404 });
|
|
141
|
+
}
|
|
142
|
+
const headers = {
|
|
143
|
+
'content-length': String(file.size),
|
|
144
|
+
'content-type': 'application/octet-stream',
|
|
145
|
+
};
|
|
146
|
+
if (request.method === 'HEAD') {
|
|
147
|
+
return new Response(null, { status: 200, headers });
|
|
148
|
+
}
|
|
149
|
+
// Streamed rather than buffered: assets are user uploads, and their size
|
|
150
|
+
// is bounded by `sizeLimit` at write time, not by anything here.
|
|
151
|
+
return new Response(Readable.toWeb(createReadStream(targetPath)), { status: 200, headers });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return new Response(null, { status: 404 });
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
return async (request) => {
|
|
158
|
+
let requestUrl;
|
|
159
|
+
try {
|
|
160
|
+
requestUrl = new URL(request.url);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const pathname = decodeURIComponent(requestUrl.pathname);
|
|
166
|
+
if (request.method === 'PUT' &&
|
|
167
|
+
matchesPrefix(pathname, content.uploadUrlPrefix)) {
|
|
168
|
+
return handleUpload(request, pathname);
|
|
169
|
+
}
|
|
170
|
+
if ((request.method === 'GET' || request.method === 'HEAD') &&
|
|
171
|
+
matchesPrefix(pathname, content.assetUrlPrefix)) {
|
|
172
|
+
return handleAsset(request, requestUrl, pathname);
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type SecretValue } from '../secret-value.js';
|
|
2
|
+
import type { SecretService, SecretValues } from './secret-service.js';
|
|
2
3
|
import type { VariablesService } from './variables-service.js';
|
|
3
4
|
export declare class LocalSecretService implements SecretService {
|
|
4
5
|
private variables;
|
|
5
6
|
private localSecrets;
|
|
6
7
|
private parseSecret;
|
|
7
8
|
constructor(variables?: VariablesService);
|
|
8
|
-
getSecret<T = string>(key: string): Promise<T
|
|
9
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
9
10
|
setSecret(key: string, value: unknown): Promise<void>;
|
|
10
11
|
hasSecret(key: string): Promise<boolean>;
|
|
11
12
|
deleteSecret(key: string): Promise<void>;
|
|
12
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T
|
|
13
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
13
14
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createSecretValue, isSecretValue, } from '../secret-value.js';
|
|
1
2
|
import { LocalVariablesService } from './local-variables.js';
|
|
2
3
|
export class LocalSecretService {
|
|
3
4
|
variables;
|
|
@@ -16,16 +17,19 @@ export class LocalSecretService {
|
|
|
16
17
|
async getSecret(key) {
|
|
17
18
|
const localValue = this.localSecrets.get(key);
|
|
18
19
|
if (localValue) {
|
|
19
|
-
return this.parseSecret(localValue);
|
|
20
|
+
return createSecretValue(this.parseSecret(localValue));
|
|
20
21
|
}
|
|
21
22
|
const value = await this.variables.get(key);
|
|
22
23
|
if (value) {
|
|
23
|
-
return this.parseSecret(value);
|
|
24
|
+
return createSecretValue(this.parseSecret(value));
|
|
24
25
|
}
|
|
25
26
|
throw new Error('Requested secret not found');
|
|
26
27
|
}
|
|
27
28
|
async setSecret(key, value) {
|
|
28
|
-
|
|
29
|
+
// Storing the wrapper would serialize it to '[secret]', so unwrap first —
|
|
30
|
+
// writing a secret back to the vault is exactly what this method is for.
|
|
31
|
+
const raw = isSecretValue(value) ? value.reveal() : value;
|
|
32
|
+
this.localSecrets.set(key, typeof raw === 'string' ? raw : JSON.stringify(raw));
|
|
29
33
|
}
|
|
30
34
|
async hasSecret(key) {
|
|
31
35
|
if (this.localSecrets.has(key)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Safe } from '../secret-value.js';
|
|
1
2
|
export declare enum LogLevel {
|
|
2
3
|
'trace' = 0,
|
|
3
4
|
'debug' = 1,
|
|
@@ -6,12 +7,28 @@ export declare enum LogLevel {
|
|
|
6
7
|
'error' = 4,
|
|
7
8
|
'critical' = 5
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* A log line is the easiest place to leak a vault secret, so every parameter is
|
|
12
|
+
* `Safe<>`-guarded: a `SecretValue` anywhere in the message or the metadata,
|
|
13
|
+
* however deeply nested, collapses to `never` and fails the build. Reveal it
|
|
14
|
+
* first if you genuinely mean to log it.
|
|
15
|
+
*/
|
|
9
16
|
export interface Logger {
|
|
10
|
-
info
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
info<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
|
|
18
|
+
[K in keyof A]: Safe<A[K]>;
|
|
19
|
+
}): void;
|
|
20
|
+
warn<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
|
|
21
|
+
[K in keyof A]: Safe<A[K]>;
|
|
22
|
+
}): void;
|
|
23
|
+
error<M extends string | Record<string, any> | Error, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
|
|
24
|
+
[K in keyof A]: Safe<A[K]>;
|
|
25
|
+
}): void;
|
|
26
|
+
debug<A extends unknown[]>(message: string, ...meta: {
|
|
27
|
+
[K in keyof A]: Safe<A[K]>;
|
|
28
|
+
}): void;
|
|
29
|
+
trace?<A extends unknown[]>(message: string, ...meta: {
|
|
30
|
+
[K in keyof A]: Safe<A[K]>;
|
|
31
|
+
}): void;
|
|
15
32
|
setLevel(level: LogLevel): void;
|
|
16
33
|
/** A logger carrying a traceId on every entry, taken per-request to correlate calls. */
|
|
17
34
|
scope?(traceId: string): Logger;
|
|
@@ -32,7 +32,7 @@ export class QueueWebhookService extends WebhookService {
|
|
|
32
32
|
let secret = input.secret;
|
|
33
33
|
if (secret === undefined && webhookConfig?.secret) {
|
|
34
34
|
// Naive read: caching is the secret service's concern, not ours.
|
|
35
|
-
secret = await services.secrets.getSecret(webhookConfig.secret);
|
|
35
|
+
secret = (await services.secrets.getSecret(webhookConfig.secret)).reveal();
|
|
36
36
|
if (!secret) {
|
|
37
37
|
services.logger.error(`Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`);
|
|
38
38
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SecretValue } from '../secret-value.js';
|
|
2
|
+
import type { SecretService, SecretValues } from './secret-service.js';
|
|
2
3
|
export declare class ScopedSecretService implements SecretService {
|
|
3
4
|
private secrets;
|
|
4
5
|
private allowedKeys;
|
|
5
6
|
constructor(secrets: SecretService, allowedKeys: Set<string>);
|
|
6
7
|
private assertAllowed;
|
|
7
|
-
getSecret<T = string>(key: string): Promise<T
|
|
8
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
8
9
|
hasSecret(key: string): Promise<boolean>;
|
|
9
10
|
setSecret(_key: string, _value: unknown): Promise<void>;
|
|
10
11
|
deleteSecret(_key: string): Promise<void>;
|
|
11
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T
|
|
12
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
12
13
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { SecretValue } from '../secret-value.js';
|
|
2
|
+
/** A record of secrets, each still wrapped. */
|
|
3
|
+
export type SecretValues<T> = {
|
|
4
|
+
[K in keyof T]: SecretValue<T[K]>;
|
|
5
|
+
};
|
|
1
6
|
export interface SecretService {
|
|
2
|
-
/** Throws if the secret is not found. */
|
|
3
|
-
getSecret<T = string>(key: string): Promise<T
|
|
7
|
+
/** Throws if the secret is not found. Unwrap the result with `.reveal()`. */
|
|
8
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
4
9
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
5
10
|
hasSecret(key: string): Promise<boolean>;
|
|
6
11
|
setSecret(key: string, value: unknown): Promise<void>;
|
|
@@ -10,5 +15,5 @@ export interface SecretService {
|
|
|
10
15
|
* must handle keys absent at runtime. Pass a shape as `T` to avoid casting,
|
|
11
16
|
* e.g. `getSecrets<{ FOO: string; BAR: { id: string } }>(['FOO', 'BAR'])`.
|
|
12
17
|
*/
|
|
13
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T
|
|
18
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
14
19
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SecretValue } from '../secret-value.js';
|
|
2
|
+
import type { SecretService, SecretValues } from './secret-service.js';
|
|
2
3
|
export interface CredentialStatus {
|
|
3
4
|
secretId: string;
|
|
4
5
|
name: string;
|
|
@@ -20,12 +21,12 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
|
|
|
20
21
|
private credentialsMeta;
|
|
21
22
|
private cache;
|
|
22
23
|
constructor(secrets: SecretService, credentialsMeta: Record<string, CredentialMeta>);
|
|
23
|
-
getSecret<K extends keyof TMap & string>(key: K): Promise<TMap[K]
|
|
24
|
-
getSecret<T = string>(key: string): Promise<T
|
|
24
|
+
getSecret<K extends keyof TMap & string>(key: K): Promise<SecretValue<TMap[K]>>;
|
|
25
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
25
26
|
hasSecret(key: string): Promise<boolean>;
|
|
26
27
|
setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
|
|
27
28
|
deleteSecret(key: string): Promise<void>;
|
|
28
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T
|
|
29
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
29
30
|
getAllStatus(): Promise<CredentialStatus[]>;
|
|
30
31
|
getMissing(): Promise<CredentialStatus[]>;
|
|
31
32
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Safe } from '../secret-value.js';
|
|
1
2
|
export interface SendWebhookInput {
|
|
2
3
|
url: string;
|
|
3
4
|
/** Sent as the `X-Pikku-Event` header. */
|
|
@@ -76,7 +77,7 @@ export declare const PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME = "pikku-outgoing-webhook
|
|
|
76
77
|
export declare const DEFAULT_WEBHOOK_SIGNATURE_HEADER = "X-Pikku-Signature";
|
|
77
78
|
export declare const DEFAULT_WEBHOOK_RETRIES = 3;
|
|
78
79
|
export declare abstract class WebhookService {
|
|
79
|
-
abstract send(input:
|
|
80
|
+
abstract send<T extends SendWebhookInput>(input: Safe<T>): Promise<SendWebhookResult>;
|
|
80
81
|
/** Produces the header value, `sha256=<hex>`, not the bare digest. */
|
|
81
82
|
protected sign(secret: string, body: string): string;
|
|
82
83
|
/** Public because receivers verify with it; they share the signing scheme. */
|
|
@@ -542,16 +542,16 @@ export function defineServiceTests(config) {
|
|
|
542
542
|
endpoint: 'https://api.example.com',
|
|
543
543
|
});
|
|
544
544
|
const result = await service.getSecret('api-key');
|
|
545
|
-
assert.deepEqual(result, {
|
|
545
|
+
assert.deepEqual(result.reveal(), {
|
|
546
546
|
token: 'sk-123',
|
|
547
547
|
endpoint: 'https://api.example.com',
|
|
548
548
|
});
|
|
549
549
|
});
|
|
550
|
-
test('getSecret
|
|
550
|
+
test('getSecret wraps the raw string rather than returning it', async () => {
|
|
551
551
|
const service = await factory({ key: kek });
|
|
552
552
|
await service.setSecret('string-secret', 'plain-value');
|
|
553
553
|
const result = await service.getSecret('string-secret');
|
|
554
|
-
assert.strictEqual(result, 'plain-value');
|
|
554
|
+
assert.strictEqual(result.reveal(), 'plain-value');
|
|
555
555
|
});
|
|
556
556
|
test('hasSecret returns true/false', async () => {
|
|
557
557
|
const service = await factory({ key: kek });
|
|
@@ -569,7 +569,7 @@ export function defineServiceTests(config) {
|
|
|
569
569
|
await service.setSecret('upsert-key', { v: 1 });
|
|
570
570
|
await service.setSecret('upsert-key', { v: 2 });
|
|
571
571
|
const result = await service.getSecret('upsert-key');
|
|
572
|
-
assert.deepEqual(result, { v: 2 });
|
|
572
|
+
assert.deepEqual(result.reveal(), { v: 2 });
|
|
573
573
|
});
|
|
574
574
|
test('deleteSecret removes the key', async () => {
|
|
575
575
|
const service = await factory({ key: kek });
|
|
@@ -588,7 +588,7 @@ export function defineServiceTests(config) {
|
|
|
588
588
|
previousKey: kek,
|
|
589
589
|
});
|
|
590
590
|
const before = await rotatedService.getSecret('rotate-test');
|
|
591
|
-
assert.deepEqual(before, { important: 'data' });
|
|
591
|
+
assert.deepEqual(before.reveal(), { important: 'data' });
|
|
592
592
|
assert.ok(rotatedService.rotateKEK);
|
|
593
593
|
const count = await rotatedService.rotateKEK();
|
|
594
594
|
assert.ok(count > 0);
|
|
@@ -597,7 +597,7 @@ export function defineServiceTests(config) {
|
|
|
597
597
|
keyVersion: 2,
|
|
598
598
|
});
|
|
599
599
|
const after = await newOnlyService.getSecret('rotate-test');
|
|
600
|
-
assert.deepEqual(after, { important: 'data' });
|
|
600
|
+
assert.deepEqual(after.reveal(), { important: 'data' });
|
|
601
601
|
});
|
|
602
602
|
test('rotateKEK throws without previousKey', async () => {
|
|
603
603
|
const service = await factory({ key: kek });
|
|
@@ -25,6 +25,7 @@ import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.j
|
|
|
25
25
|
import type { AIEmbeddingService } from '../services/ai-embedding-service.js';
|
|
26
26
|
import type { AIRunStateService } from '../services/ai-run-state-service.js';
|
|
27
27
|
import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
|
|
28
|
+
import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js';
|
|
28
29
|
import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
|
|
29
30
|
import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
|
|
30
31
|
import type { CredentialService } from '../services/credential-service.js';
|
|
@@ -79,11 +80,12 @@ export type FunctionRuntimeMeta = {
|
|
|
79
80
|
*/
|
|
80
81
|
auth?: boolean;
|
|
81
82
|
/**
|
|
82
|
-
* The author's declaration that this function
|
|
83
|
-
* body. Carries no runtime effect — it records a gate codegen cannot see,
|
|
84
|
-
* an audit is not left to guess whether a sessionless function is open.
|
|
83
|
+
* The author's declaration that this function's permission check lives in its
|
|
84
|
+
* own body. Carries no runtime effect — it records a gate codegen cannot see,
|
|
85
|
+
* so an audit is not left to guess whether a sessionless function is open.
|
|
86
|
+
* Refused unless `allow.permissionsInBody` is set in `pikku.config.json`.
|
|
85
87
|
*/
|
|
86
|
-
|
|
88
|
+
permissionsInBody?: boolean;
|
|
87
89
|
remote?: boolean;
|
|
88
90
|
/**
|
|
89
91
|
* A step RPC: invoked by name only from a scenario run and refused
|
|
@@ -127,6 +129,19 @@ export type FunctionRuntimeMeta = {
|
|
|
127
129
|
scenarioStepAddon?: string;
|
|
128
130
|
/** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
|
|
129
131
|
scenarioStepTemplate?: string;
|
|
132
|
+
/**
|
|
133
|
+
* The function's `audit` config, resolved — `audit: true` reads as
|
|
134
|
+
* `{ durability: 'best-effort' }`. Absent means the function records nothing:
|
|
135
|
+
* `auditLog.write()` from an unmarked function is dropped with a warning, so
|
|
136
|
+
* this is the only place a reader can see which functions have a trail at all
|
|
137
|
+
* without running them.
|
|
138
|
+
*
|
|
139
|
+
* Informational. The runner resolves audit from the live function config, not
|
|
140
|
+
* from here, so meta and runtime cannot disagree about whether audit is on.
|
|
141
|
+
*/
|
|
142
|
+
audit?: {
|
|
143
|
+
durability: AuditDurability;
|
|
144
|
+
};
|
|
130
145
|
/** Keeps the full `SecretService`. Set by the inspector, read by the runner. */
|
|
131
146
|
secretBroker?: boolean;
|
|
132
147
|
version?: number;
|
|
@@ -255,6 +270,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
255
270
|
*/
|
|
256
271
|
webhookService?: WebhookService;
|
|
257
272
|
metaService?: MetaService;
|
|
273
|
+
/**
|
|
274
|
+
* Where virtual-user runs are recorded. A run is dispatched and answered for
|
|
275
|
+
* later, so this store is the only trace it leaves — see
|
|
276
|
+
* {@link VirtualUserRunStore}.
|
|
277
|
+
*/
|
|
278
|
+
virtualUserRunStore?: VirtualUserRunStore;
|
|
258
279
|
/** V8 precise-coverage collector (`pikku dev --coverage` only) */
|
|
259
280
|
coverageService?: CoverageService;
|
|
260
281
|
audit?: AuditService;
|
|
@@ -305,7 +305,11 @@ export function wrapChannelWithAGUI(inner, options) {
|
|
|
305
305
|
send({
|
|
306
306
|
type: 'CUSTOM',
|
|
307
307
|
name: 'pikku:audio-delta',
|
|
308
|
-
value: {
|
|
308
|
+
value: {
|
|
309
|
+
data: event.data,
|
|
310
|
+
format: event.format,
|
|
311
|
+
...(event.text === undefined ? {} : { text: event.text }),
|
|
312
|
+
},
|
|
309
313
|
});
|
|
310
314
|
break;
|
|
311
315
|
}
|
|
@@ -313,6 +317,14 @@ export function wrapChannelWithAGUI(inner, options) {
|
|
|
313
317
|
send({ type: 'CUSTOM', name: 'pikku:audio-done', value: {} });
|
|
314
318
|
break;
|
|
315
319
|
}
|
|
320
|
+
case 'transcript': {
|
|
321
|
+
send({
|
|
322
|
+
type: 'CUSTOM',
|
|
323
|
+
name: 'pikku:transcript',
|
|
324
|
+
value: { text: event.text },
|
|
325
|
+
});
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
316
328
|
}
|
|
317
329
|
},
|
|
318
330
|
};
|
|
@@ -660,7 +660,13 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
|
|
|
660
660
|
const contextMessages = await loadContextMessages(memoryConfig, storage, input, workingMemoryJsonSchema);
|
|
661
661
|
const userContent = input.attachments?.length
|
|
662
662
|
? [
|
|
663
|
-
|
|
663
|
+
// Omitted when there is nothing to say. An attachment on its own is a
|
|
664
|
+
// real turn — a spoken one carries audio and no text at all — and an
|
|
665
|
+
// empty text part alongside it is a part providers are entitled to
|
|
666
|
+
// reject, for a caller who never wrote one.
|
|
667
|
+
...(input.message
|
|
668
|
+
? [{ type: 'text', text: input.message }]
|
|
669
|
+
: []),
|
|
664
670
|
...input.attachments.map((a) => ({
|
|
665
671
|
type: a.type,
|
|
666
672
|
data: a.data,
|
|
@@ -73,6 +73,8 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
|
|
|
73
73
|
}),
|
|
74
74
|
...(agent.aiMiddleware ?? []),
|
|
75
75
|
];
|
|
76
|
+
// One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
|
|
77
|
+
const sharedNotes = {};
|
|
76
78
|
let modifiedMessages = runnerParams.messages;
|
|
77
79
|
let modifiedInstructions = runnerParams.instructions;
|
|
78
80
|
for (const mw of aiMiddlewares) {
|
|
@@ -80,6 +82,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
|
|
|
80
82
|
const result = await mw.modifyInput(singletonServices, {
|
|
81
83
|
messages: modifiedMessages,
|
|
82
84
|
instructions: modifiedInstructions,
|
|
85
|
+
shared: sharedNotes,
|
|
83
86
|
});
|
|
84
87
|
modifiedMessages = result.messages;
|
|
85
88
|
modifiedInstructions = result.instructions;
|
|
@@ -87,6 +90,12 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
|
|
|
87
90
|
}
|
|
88
91
|
runnerParams.messages = modifiedMessages;
|
|
89
92
|
runnerParams.instructions = modifiedInstructions;
|
|
93
|
+
// History records what the model was asked, which for a spoken turn is the
|
|
94
|
+
// transcript rather than the base64 audio that arrived — see the same note on
|
|
95
|
+
// the streaming path. Identity-checked, because a middleware may legitimately
|
|
96
|
+
// replace the message list with something unrelated to this turn.
|
|
97
|
+
const lastModified = modifiedMessages[modifiedMessages.length - 1];
|
|
98
|
+
const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
|
|
90
99
|
const runId = await aiRunState.createRun({
|
|
91
100
|
agentName,
|
|
92
101
|
threadId,
|
|
@@ -181,7 +190,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
|
|
|
181
190
|
args: a.args,
|
|
182
191
|
});
|
|
183
192
|
const completedStepsForSave = accumulatedSteps.slice(0, -1);
|
|
184
|
-
await saveMessages(storage, threadId, input.resourceId, memoryConfig,
|
|
193
|
+
await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, { text: '', steps: completedStepsForSave });
|
|
185
194
|
if (storage) {
|
|
186
195
|
await storage.saveMessages(threadId, [
|
|
187
196
|
{
|
|
@@ -242,7 +251,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
|
|
|
242
251
|
outputMessages = modResult.messages;
|
|
243
252
|
}
|
|
244
253
|
}
|
|
245
|
-
await saveMessages(storage, threadId, input.resourceId, memoryConfig,
|
|
254
|
+
await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, {
|
|
246
255
|
...result,
|
|
247
256
|
text: outputText,
|
|
248
257
|
uiSpec: structuredOutput.uiSpec,
|
|
@@ -416,6 +425,8 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
|
|
|
416
425
|
}),
|
|
417
426
|
...(agent.aiMiddleware ?? []),
|
|
418
427
|
];
|
|
428
|
+
// One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
|
|
429
|
+
const sharedNotes = {};
|
|
419
430
|
let modifiedMessages = trimmedMessages;
|
|
420
431
|
let modifiedInstructions = instructions;
|
|
421
432
|
for (const mw of aiMiddlewares) {
|
|
@@ -423,6 +434,7 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
|
|
|
423
434
|
const result = await mw.modifyInput(singletonServices, {
|
|
424
435
|
messages: modifiedMessages,
|
|
425
436
|
instructions: modifiedInstructions,
|
|
437
|
+
shared: sharedNotes,
|
|
426
438
|
});
|
|
427
439
|
modifiedMessages = result.messages;
|
|
428
440
|
modifiedInstructions = result.instructions;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { pikkuState, getSingletonServices } from '../../pikku-state.js';
|
|
2
2
|
import { AIProviderNotConfiguredError } from '../../errors/errors.js';
|
|
3
3
|
import { randomUUID } from './ai-agent-utils.js';
|
|
4
|
+
import { SPOKEN_TRANSCRIPT } from './voice-input.js';
|
|
4
5
|
import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
|
|
5
6
|
import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
|
|
6
7
|
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertResourcePrincipalOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, CREDENTIAL_REQUIRED, } from './ai-agent-prepare.js';
|
|
@@ -411,6 +412,8 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
411
412
|
}),
|
|
412
413
|
...(agent.aiMiddleware ?? []),
|
|
413
414
|
];
|
|
415
|
+
// One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
|
|
416
|
+
const sharedNotes = {};
|
|
414
417
|
let modifiedMessages = runnerParams.messages;
|
|
415
418
|
let modifiedInstructions = runnerParams.instructions;
|
|
416
419
|
for (const mw of aiMiddlewares) {
|
|
@@ -418,6 +421,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
418
421
|
const result = await mw.modifyInput(singletonServices, {
|
|
419
422
|
messages: modifiedMessages,
|
|
420
423
|
instructions: modifiedInstructions,
|
|
424
|
+
shared: sharedNotes,
|
|
421
425
|
});
|
|
422
426
|
modifiedMessages = result.messages;
|
|
423
427
|
modifiedInstructions = result.instructions;
|
|
@@ -425,6 +429,23 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
425
429
|
}
|
|
426
430
|
runnerParams.messages = modifiedMessages;
|
|
427
431
|
runnerParams.instructions = modifiedInstructions;
|
|
432
|
+
// Sent on the raw channel, ahead of the run. A voice client sent audio and so
|
|
433
|
+
// has no idea what it said; until this arrives its own message is a blank
|
|
434
|
+
// bubble. Ahead of the run rather than alongside it because the answer starts
|
|
435
|
+
// streaming within a few hundred milliseconds, and a question that appears
|
|
436
|
+
// after its answer reads as the wrong question.
|
|
437
|
+
const transcript = sharedNotes[SPOKEN_TRANSCRIPT];
|
|
438
|
+
if (typeof transcript === 'string') {
|
|
439
|
+
channel.send({ type: 'transcript', text: transcript });
|
|
440
|
+
}
|
|
441
|
+
// What goes into thread history is what the model was actually asked. For a
|
|
442
|
+
// spoken turn that is not what arrived over the wire: the wire carried a
|
|
443
|
+
// base64 audio blob, and persisting it would write megabytes of unreadable
|
|
444
|
+
// data into the history while losing the only readable record of the turn.
|
|
445
|
+
// Identity-checked rather than assumed — a middleware is free to rewrite the
|
|
446
|
+
// message list into something with no relation to the turn.
|
|
447
|
+
const lastModified = modifiedMessages[modifiedMessages.length - 1];
|
|
448
|
+
const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
|
|
428
449
|
const runId = await aiRunState.createRun({
|
|
429
450
|
agentName,
|
|
430
451
|
threadId,
|
|
@@ -442,7 +463,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
442
463
|
runnerParams.abortSignal = interruptHandle.signal;
|
|
443
464
|
runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle);
|
|
444
465
|
if (storage) {
|
|
445
|
-
await storage.saveMessages(threadId, [
|
|
466
|
+
await storage.saveMessages(threadId, [persistedUserMessage]);
|
|
446
467
|
}
|
|
447
468
|
const streamMiddleware = aiMiddlewares
|
|
448
469
|
.filter((mw) => mw.modifyOutputStream)
|
|
@@ -455,6 +476,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
455
476
|
event,
|
|
456
477
|
allEvents,
|
|
457
478
|
state,
|
|
479
|
+
shared: sharedNotes,
|
|
458
480
|
// Sends downstream directly, so a hook can hand back the fast event
|
|
459
481
|
// now and push the slow one when it is ready.
|
|
460
482
|
emit: next,
|
|
@@ -841,6 +863,8 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
|
|
|
841
863
|
}),
|
|
842
864
|
...(agent.aiMiddleware ?? []),
|
|
843
865
|
];
|
|
866
|
+
// One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
|
|
867
|
+
const sharedNotes = {};
|
|
844
868
|
let modifiedMessages = trimmedMessages;
|
|
845
869
|
let modifiedInstructions = instructions;
|
|
846
870
|
for (const mw of aiMiddlewares) {
|
|
@@ -848,6 +872,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
|
|
|
848
872
|
const result = await mw.modifyInput(singletonServices, {
|
|
849
873
|
messages: modifiedMessages,
|
|
850
874
|
instructions: modifiedInstructions,
|
|
875
|
+
shared: sharedNotes,
|
|
851
876
|
});
|
|
852
877
|
modifiedMessages = result.messages;
|
|
853
878
|
modifiedInstructions = result.instructions;
|
|
@@ -869,6 +894,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
|
|
|
869
894
|
event,
|
|
870
895
|
allEvents,
|
|
871
896
|
state,
|
|
897
|
+
shared: sharedNotes,
|
|
872
898
|
// Sends downstream directly, so a hook can hand back the fast event
|
|
873
899
|
// now and push the slow one when it is ready.
|
|
874
900
|
emit: next,
|