@delexec/ops 0.1.0

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.
Files changed (55) hide show
  1. package/README.md +3 -0
  2. package/README.zh-CN.md +6 -0
  3. package/node_modules/@delexec/caller-controller/README.md +3 -0
  4. package/node_modules/@delexec/caller-controller/README.zh-CN.md +6 -0
  5. package/node_modules/@delexec/caller-controller/package.json +53 -0
  6. package/node_modules/@delexec/caller-controller/src/server.js +127 -0
  7. package/node_modules/@delexec/caller-controller-core/README.md +3 -0
  8. package/node_modules/@delexec/caller-controller-core/README.zh-CN.md +6 -0
  9. package/node_modules/@delexec/caller-controller-core/package.json +26 -0
  10. package/node_modules/@delexec/caller-controller-core/src/index.js +1612 -0
  11. package/node_modules/@delexec/caller-skill-adapter/package.json +12 -0
  12. package/node_modules/@delexec/caller-skill-adapter/src/server.js +1042 -0
  13. package/node_modules/@delexec/caller-skill-mcp-adapter/README.md +65 -0
  14. package/node_modules/@delexec/caller-skill-mcp-adapter/package.json +16 -0
  15. package/node_modules/@delexec/caller-skill-mcp-adapter/src/server.js +527 -0
  16. package/node_modules/@delexec/responder-controller/README.md +3 -0
  17. package/node_modules/@delexec/responder-controller/README.zh-CN.md +6 -0
  18. package/node_modules/@delexec/responder-controller/package.json +53 -0
  19. package/node_modules/@delexec/responder-controller/src/server.js +254 -0
  20. package/node_modules/@delexec/responder-runtime-core/README.md +3 -0
  21. package/node_modules/@delexec/responder-runtime-core/README.zh-CN.md +6 -0
  22. package/node_modules/@delexec/responder-runtime-core/package.json +26 -0
  23. package/node_modules/@delexec/responder-runtime-core/src/executors.js +326 -0
  24. package/node_modules/@delexec/responder-runtime-core/src/index.js +1202 -0
  25. package/node_modules/@delexec/runtime-utils/README.md +3 -0
  26. package/node_modules/@delexec/runtime-utils/README.zh-CN.md +6 -0
  27. package/node_modules/@delexec/runtime-utils/package.json +23 -0
  28. package/node_modules/@delexec/runtime-utils/src/index.js +338 -0
  29. package/node_modules/@delexec/sqlite-store/README.md +3 -0
  30. package/node_modules/@delexec/sqlite-store/README.zh-CN.md +6 -0
  31. package/node_modules/@delexec/sqlite-store/package.json +26 -0
  32. package/node_modules/@delexec/sqlite-store/src/index.js +68 -0
  33. package/node_modules/@delexec/transport-email/README.md +3 -0
  34. package/node_modules/@delexec/transport-email/README.zh-CN.md +6 -0
  35. package/node_modules/@delexec/transport-email/package.json +23 -0
  36. package/node_modules/@delexec/transport-email/src/index.js +185 -0
  37. package/node_modules/@delexec/transport-emailengine/README.md +3 -0
  38. package/node_modules/@delexec/transport-emailengine/README.zh-CN.md +6 -0
  39. package/node_modules/@delexec/transport-emailengine/package.json +26 -0
  40. package/node_modules/@delexec/transport-emailengine/src/index.js +210 -0
  41. package/node_modules/@delexec/transport-gmail/README.md +3 -0
  42. package/node_modules/@delexec/transport-gmail/README.zh-CN.md +6 -0
  43. package/node_modules/@delexec/transport-gmail/package.json +26 -0
  44. package/node_modules/@delexec/transport-gmail/src/index.js +295 -0
  45. package/node_modules/@delexec/transport-relay-http/README.md +3 -0
  46. package/node_modules/@delexec/transport-relay-http/README.zh-CN.md +6 -0
  47. package/node_modules/@delexec/transport-relay-http/package.json +23 -0
  48. package/node_modules/@delexec/transport-relay-http/src/index.js +124 -0
  49. package/package.json +64 -0
  50. package/src/cli.js +1571 -0
  51. package/src/config.js +1180 -0
  52. package/src/example-hotline-worker.js +65 -0
  53. package/src/example-hotline.js +196 -0
  54. package/src/logging.js +56 -0
  55. package/src/supervisor.js +3070 -0
package/src/config.js ADDED
@@ -0,0 +1,1180 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ ensureOpsDirectories,
7
+ getOpsConfigFile,
8
+ getOpsEnvFile,
9
+ getOpsHomeDir,
10
+ getOpsSecretsFile,
11
+ getResponderConfigFile,
12
+ readEnvFile,
13
+ readJsonFile,
14
+ secretStoreExists,
15
+ unlockSecretStore,
16
+ updateEnvFile,
17
+ writeJsonFile,
18
+ writeSecretValues
19
+ } from "@delexec/runtime-utils";
20
+
21
+ export const DEFAULT_PORTS = Object.freeze({
22
+ supervisor: 8079,
23
+ relay: 8090,
24
+ caller: 8081,
25
+ responder: 8082,
26
+ skill_adapter: 8091,
27
+ mcp_adapter: 8092
28
+ });
29
+
30
+ export const DEFAULT_TRANSPORT_TYPE = "local";
31
+ export const DEFAULT_EMAIL_PROVIDER = "emailengine";
32
+ export const DEFAULT_EMAIL_POLL_INTERVAL_MS = 5000;
33
+
34
+ const TRANSPORT_SECRET_ENV_KEYS = Object.freeze({
35
+ emailengine: {
36
+ access_token: "TRANSPORT_EMAILENGINE_ACCESS_TOKEN"
37
+ },
38
+ gmail: {
39
+ client_secret: "TRANSPORT_GMAIL_CLIENT_SECRET",
40
+ refresh_token: "TRANSPORT_GMAIL_REFRESH_TOKEN"
41
+ }
42
+ });
43
+
44
+ export const OPS_SECRET_KEYS = Object.freeze({
45
+ caller_api_key: "caller_api_key",
46
+ responder_platform_api_key: "responder_platform_api_key",
47
+ transport_emailengine_access_token: "transport_emailengine_access_token",
48
+ transport_gmail_client_secret: "transport_gmail_client_secret",
49
+ transport_gmail_refresh_token: "transport_gmail_refresh_token",
50
+ platform_admin_api_key: "platform_admin_api_key"
51
+ });
52
+
53
+ const LEGACY_SECRET_CONFIG_PATHS = Object.freeze({
54
+ [OPS_SECRET_KEYS.caller_api_key]: ["caller", "api_key"],
55
+ [OPS_SECRET_KEYS.platform_admin_api_key]: ["platform_console", "admin_api_key"]
56
+ });
57
+
58
+ function resolveDefaultPorts() {
59
+ return {
60
+ supervisor: Number(process.env.OPS_PORT_SUPERVISOR || DEFAULT_PORTS.supervisor),
61
+ relay: Number(process.env.OPS_PORT_RELAY || DEFAULT_PORTS.relay),
62
+ caller: Number(process.env.OPS_PORT_CALLER || DEFAULT_PORTS.caller),
63
+ responder: Number(process.env.OPS_PORT_RESPONDER || DEFAULT_PORTS.responder),
64
+ skill_adapter: Number(process.env.OPS_PORT_SKILL_ADAPTER || DEFAULT_PORTS.skill_adapter),
65
+ mcp_adapter: Number(process.env.OPS_PORT_MCP_ADAPTER || DEFAULT_PORTS.mcp_adapter)
66
+ };
67
+ }
68
+
69
+ function randomResponderId() {
70
+ return `responder_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
71
+ }
72
+
73
+ function encodePemForEnv(pem) {
74
+ return pem.replace(/\n/g, "\\n");
75
+ }
76
+
77
+ function decodePemFromEnv(pem) {
78
+ return pem ? pem.replace(/\\n/g, "\n") : null;
79
+ }
80
+
81
+ function normalizedString(value) {
82
+ if (value === undefined || value === null) {
83
+ return null;
84
+ }
85
+ const trimmed = String(value).trim();
86
+ return trimmed ? trimmed : null;
87
+ }
88
+
89
+ function normalizePollInterval(value) {
90
+ const parsed = Number(value);
91
+ if (!Number.isFinite(parsed) || parsed <= 0) {
92
+ return DEFAULT_EMAIL_POLL_INTERVAL_MS;
93
+ }
94
+ return Math.trunc(parsed);
95
+ }
96
+
97
+ function ensureStringList(value, fallback = []) {
98
+ if (!Array.isArray(value)) {
99
+ return [...fallback];
100
+ }
101
+ return value.map((item) => String(item)).filter(Boolean);
102
+ }
103
+
104
+ const INVALID_INPUT_DESCRIPTION_PATTERNS = [
105
+ /^source text\.?$/i,
106
+ /^source text that should be summarized\.?$/i,
107
+ /^optional summarization instruction or emphasis\.?$/i,
108
+ /^instruction for the hotline\.?$/i,
109
+ /^optional task context\.?$/i,
110
+ /^describe the expected task result\.?$/i,
111
+ /^optional context for the task\.?$/i
112
+ ];
113
+
114
+ function defaultTransportConfig() {
115
+ return {
116
+ type: DEFAULT_TRANSPORT_TYPE,
117
+ relay_http: {
118
+ base_url: null
119
+ },
120
+ email: {
121
+ provider: DEFAULT_EMAIL_PROVIDER,
122
+ mode: "shared_mailbox",
123
+ sender: null,
124
+ receiver: null,
125
+ poll_interval_ms: DEFAULT_EMAIL_POLL_INTERVAL_MS,
126
+ emailengine: {
127
+ base_url: null,
128
+ account: null
129
+ },
130
+ gmail: {
131
+ client_id: null,
132
+ user: null
133
+ }
134
+ }
135
+ };
136
+ }
137
+
138
+ function getLegacyTransportBaseUrl(config, env) {
139
+ return (
140
+ normalizedString(config?.runtime?.external_relay?.base_url) ||
141
+ normalizedString(env.TRANSPORT_BASE_URL) ||
142
+ null
143
+ );
144
+ }
145
+
146
+ export function normalizeTransportConfig(config = {}, env = {}) {
147
+ const defaults = defaultTransportConfig();
148
+ const source = config?.runtime?.transport || null;
149
+ const legacyBaseUrl = getLegacyTransportBaseUrl(config, env);
150
+ const envType = normalizedString(env.TRANSPORT_TYPE);
151
+ const type = envType || normalizedString(source?.type) || (legacyBaseUrl ? "relay_http" : DEFAULT_TRANSPORT_TYPE);
152
+ const provider = normalizedString(source?.email?.provider) || DEFAULT_EMAIL_PROVIDER;
153
+
154
+ return {
155
+ type,
156
+ relay_http: {
157
+ base_url: normalizedString(source?.relay_http?.base_url) || (type === "relay_http" ? legacyBaseUrl : null)
158
+ },
159
+ email: {
160
+ provider,
161
+ mode: "shared_mailbox",
162
+ sender: normalizedString(source?.email?.sender),
163
+ receiver: normalizedString(source?.email?.receiver),
164
+ poll_interval_ms: normalizePollInterval(source?.email?.poll_interval_ms || defaults.email.poll_interval_ms),
165
+ emailengine: {
166
+ base_url: normalizedString(source?.email?.emailengine?.base_url),
167
+ account: normalizedString(source?.email?.emailengine?.account)
168
+ },
169
+ gmail: {
170
+ client_id: normalizedString(source?.email?.gmail?.client_id),
171
+ user: normalizedString(source?.email?.gmail?.user)
172
+ }
173
+ }
174
+ };
175
+ }
176
+
177
+ export function readTransportSecretsFromEnv(env = {}) {
178
+ return {
179
+ emailengine: {
180
+ access_token:
181
+ normalizedString(env[TRANSPORT_SECRET_ENV_KEYS.emailengine.access_token]) ||
182
+ normalizedString(env[OPS_SECRET_KEYS.transport_emailengine_access_token])
183
+ },
184
+ gmail: {
185
+ client_secret:
186
+ normalizedString(env[TRANSPORT_SECRET_ENV_KEYS.gmail.client_secret]) ||
187
+ normalizedString(env[OPS_SECRET_KEYS.transport_gmail_client_secret]),
188
+ refresh_token:
189
+ normalizedString(env[TRANSPORT_SECRET_ENV_KEYS.gmail.refresh_token]) ||
190
+ normalizedString(env[OPS_SECRET_KEYS.transport_gmail_refresh_token])
191
+ }
192
+ };
193
+ }
194
+
195
+ export function redactTransportConfig(config = {}, env = {}) {
196
+ const transport = normalizeTransportConfig({ runtime: { transport: config } }, env);
197
+ const secrets = readTransportSecretsFromEnv(env);
198
+ return {
199
+ ...transport,
200
+ email: {
201
+ ...transport.email,
202
+ emailengine: {
203
+ ...transport.email.emailengine,
204
+ access_token_configured: Boolean(secrets.emailengine.access_token)
205
+ },
206
+ gmail: {
207
+ ...transport.email.gmail,
208
+ client_secret_configured: Boolean(secrets.gmail.client_secret),
209
+ refresh_token_configured: Boolean(secrets.gmail.refresh_token)
210
+ }
211
+ }
212
+ };
213
+ }
214
+
215
+ export function buildTransportEnvUpdates(transportConfig = {}, env = {}) {
216
+ const transport = normalizeTransportConfig({ runtime: { transport: transportConfig } }, env);
217
+ const updates = {
218
+ TRANSPORT_TYPE: transport.type,
219
+ TRANSPORT_PROVIDER: transport.type === "email" ? transport.email.provider : null,
220
+ TRANSPORT_BASE_URL:
221
+ transport.type === "relay_http"
222
+ ? transport.relay_http.base_url
223
+ : env.TRANSPORT_BASE_URL || null,
224
+ TRANSPORT_EMAIL_PROVIDER: transport.type === "email" ? transport.email.provider : env.TRANSPORT_EMAIL_PROVIDER || null,
225
+ TRANSPORT_EMAIL_MODE: transport.type === "email" ? transport.email.mode : env.TRANSPORT_EMAIL_MODE || null,
226
+ TRANSPORT_EMAIL_SENDER: transport.type === "email" ? transport.email.sender : env.TRANSPORT_EMAIL_SENDER || null,
227
+ TRANSPORT_EMAIL_RECEIVER: transport.type === "email" ? transport.email.receiver : env.TRANSPORT_EMAIL_RECEIVER || null,
228
+ TRANSPORT_EMAIL_POLL_INTERVAL_MS:
229
+ transport.type === "email" ? String(transport.email.poll_interval_ms) : env.TRANSPORT_EMAIL_POLL_INTERVAL_MS || null,
230
+ TRANSPORT_EMAILENGINE_BASE_URL:
231
+ transport.type === "email" && transport.email.provider === "emailengine"
232
+ ? transport.email.emailengine.base_url
233
+ : env.TRANSPORT_EMAILENGINE_BASE_URL || null,
234
+ TRANSPORT_EMAILENGINE_ACCOUNT:
235
+ transport.type === "email" && transport.email.provider === "emailengine"
236
+ ? transport.email.emailengine.account
237
+ : env.TRANSPORT_EMAILENGINE_ACCOUNT || null,
238
+ TRANSPORT_GMAIL_CLIENT_ID:
239
+ transport.type === "email" && transport.email.provider === "gmail"
240
+ ? transport.email.gmail.client_id
241
+ : env.TRANSPORT_GMAIL_CLIENT_ID || null,
242
+ TRANSPORT_GMAIL_USER:
243
+ transport.type === "email" && transport.email.provider === "gmail"
244
+ ? transport.email.gmail.user
245
+ : env.TRANSPORT_GMAIL_USER || null
246
+ };
247
+
248
+ return updates;
249
+ }
250
+
251
+ export function buildTransportSecretEnvUpdates(transportConfig = {}, body = {}, currentEnv = {}) {
252
+ const transport = normalizeTransportConfig({ runtime: { transport: transportConfig } }, currentEnv);
253
+ const updates = {};
254
+
255
+ const emailengineSecret = normalizedString(body?.email?.emailengine?.access_token);
256
+ if (emailengineSecret) {
257
+ updates[TRANSPORT_SECRET_ENV_KEYS.emailengine.access_token] = emailengineSecret;
258
+ }
259
+
260
+ const gmailClientSecret = normalizedString(body?.email?.gmail?.client_secret);
261
+ if (gmailClientSecret) {
262
+ updates[TRANSPORT_SECRET_ENV_KEYS.gmail.client_secret] = gmailClientSecret;
263
+ }
264
+
265
+ const gmailRefreshToken = normalizedString(body?.email?.gmail?.refresh_token);
266
+ if (gmailRefreshToken) {
267
+ updates[TRANSPORT_SECRET_ENV_KEYS.gmail.refresh_token] = gmailRefreshToken;
268
+ }
269
+
270
+ if (transport.type !== "email" || transport.email.provider !== "emailengine") {
271
+ const current = normalizedString(currentEnv[TRANSPORT_SECRET_ENV_KEYS.emailengine.access_token]);
272
+ if (current) {
273
+ updates[TRANSPORT_SECRET_ENV_KEYS.emailengine.access_token] = current;
274
+ }
275
+ }
276
+ if (transport.type !== "email" || transport.email.provider !== "gmail") {
277
+ const currentClientSecret = normalizedString(currentEnv[TRANSPORT_SECRET_ENV_KEYS.gmail.client_secret]);
278
+ const currentRefreshToken = normalizedString(currentEnv[TRANSPORT_SECRET_ENV_KEYS.gmail.refresh_token]);
279
+ if (currentClientSecret) {
280
+ updates[TRANSPORT_SECRET_ENV_KEYS.gmail.client_secret] = currentClientSecret;
281
+ }
282
+ if (currentRefreshToken) {
283
+ updates[TRANSPORT_SECRET_ENV_KEYS.gmail.refresh_token] = currentRefreshToken;
284
+ }
285
+ }
286
+
287
+ return updates;
288
+ }
289
+
290
+ export function buildTransportSecretUpdates(body = {}) {
291
+ const updates = {};
292
+ const emailengineSecret = normalizedString(body?.email?.emailengine?.access_token);
293
+ if (emailengineSecret) {
294
+ updates[OPS_SECRET_KEYS.transport_emailengine_access_token] = emailengineSecret;
295
+ }
296
+ const gmailClientSecret = normalizedString(body?.email?.gmail?.client_secret);
297
+ if (gmailClientSecret) {
298
+ updates[OPS_SECRET_KEYS.transport_gmail_client_secret] = gmailClientSecret;
299
+ }
300
+ const gmailRefreshToken = normalizedString(body?.email?.gmail?.refresh_token);
301
+ if (gmailRefreshToken) {
302
+ updates[OPS_SECRET_KEYS.transport_gmail_refresh_token] = gmailRefreshToken;
303
+ }
304
+ return updates;
305
+ }
306
+
307
+ export function generateSigningKeyPair() {
308
+ const pair = crypto.generateKeyPairSync("ed25519");
309
+ return {
310
+ publicKeyPem: pair.publicKey.export({ type: "spki", format: "pem" }).toString(),
311
+ privateKeyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString()
312
+ };
313
+ }
314
+
315
+ export function createDefaultOpsConfig(env = {}) {
316
+ const ports = resolveDefaultPorts();
317
+ const resolvedEnv = {
318
+ ...process.env,
319
+ ...env
320
+ };
321
+ return {
322
+ platform: {
323
+ enabled: false,
324
+ base_url: resolvedEnv.PLATFORM_API_BASE_URL || "http://127.0.0.1:8080"
325
+ },
326
+ platform_console: {
327
+ base_url: resolvedEnv.PLATFORM_API_BASE_URL || "http://127.0.0.1:8080"
328
+ },
329
+ caller: {
330
+ enabled: true,
331
+ api_key: null,
332
+ api_key_configured: Boolean(resolvedEnv.CALLER_PLATFORM_API_KEY || resolvedEnv.PLATFORM_API_KEY),
333
+ contact_email: resolvedEnv.CALLER_CONTACT_EMAIL || null,
334
+ registration_mode: resolvedEnv.CALLER_CONTACT_EMAIL ? "local_only" : null
335
+ },
336
+ responder: {
337
+ enabled: false,
338
+ responder_id: resolvedEnv.RESPONDER_ID || null,
339
+ display_name: "Local Responder",
340
+ hotlines: []
341
+ },
342
+ preferences: {
343
+ task_types: {},
344
+ caller_policy: {
345
+ mode: "manual",
346
+ responderWhitelist: [],
347
+ hotlineWhitelist: [],
348
+ blocklist: []
349
+ }
350
+ },
351
+ runtime: {
352
+ ports,
353
+ external_relay: null,
354
+ transport: defaultTransportConfig()
355
+ }
356
+ };
357
+ }
358
+
359
+ function inferPlatformEnabled(config = {}, env = {}) {
360
+ if (typeof config?.platform?.enabled === "boolean") {
361
+ return config.platform.enabled;
362
+ }
363
+ const hotlines = Array.isArray(config?.responder?.hotlines) ? config.responder.hotlines : [];
364
+ const hasSubmittedHotline = hotlines.some((item) => item?.submitted_for_review === true);
365
+ const hasPlatformReviewState = hotlines.some((item) => {
366
+ const status = normalizedString(item?.review_status);
367
+ return status && status !== "local_only";
368
+ });
369
+ const hasResponderPlatformCredential = Boolean(normalizedString(env.RESPONDER_PLATFORM_API_KEY));
370
+ const hasAdminPlatformCredential = Boolean(normalizedString(env.PLATFORM_ADMIN_API_KEY));
371
+ return hasSubmittedHotline || hasPlatformReviewState || hasResponderPlatformCredential || hasAdminPlatformCredential;
372
+ }
373
+
374
+ export function ensureOpsState() {
375
+ ensureOpsDirectories();
376
+ const envFile = getOpsEnvFile();
377
+ const fileEnv = readEnvFile(envFile);
378
+ const env = {
379
+ ...fileEnv,
380
+ ...process.env
381
+ };
382
+ const secretsFile = getOpsSecretsFile();
383
+ const opsConfigFile = getOpsConfigFile();
384
+ let config = readJsonFile(opsConfigFile, null);
385
+
386
+ if (!config) {
387
+ const legacyResponder = readJsonFile(getResponderConfigFile(), null);
388
+ config = createDefaultOpsConfig(env);
389
+ if (legacyResponder) {
390
+ config.responder = {
391
+ enabled: legacyResponder.enabled !== false,
392
+ responder_id: legacyResponder.responder_id || env.RESPONDER_ID || null,
393
+ display_name: legacyResponder.display_name || "Local Responder",
394
+ hotlines: Array.isArray(legacyResponder.hotlines) ? legacyResponder.hotlines : []
395
+ };
396
+ }
397
+ }
398
+
399
+ config.platform ||= { base_url: env.PLATFORM_API_BASE_URL || process.env.PLATFORM_API_BASE_URL || "http://127.0.0.1:8080" };
400
+ config.platform.enabled = inferPlatformEnabled(config, env);
401
+ config.platform_console ||= { base_url: config.platform.base_url || env.PLATFORM_API_BASE_URL || "http://127.0.0.1:8080" };
402
+ config.caller ||= {
403
+ enabled: true,
404
+ api_key: null,
405
+ api_key_configured: false,
406
+ contact_email: env.CALLER_CONTACT_EMAIL || process.env.CALLER_CONTACT_EMAIL || null,
407
+ registration_mode: null
408
+ };
409
+ const callerApiKey =
410
+ config.caller.api_key ||
411
+ env.CALLER_PLATFORM_API_KEY ||
412
+ env.PLATFORM_API_KEY ||
413
+ process.env.CALLER_PLATFORM_API_KEY ||
414
+ process.env.PLATFORM_API_KEY ||
415
+ null;
416
+ config.caller.api_key = normalizedString(config.caller.api_key);
417
+ config.caller.registration_mode = normalizedString(config.caller.registration_mode) || (callerApiKey ? "platform" : null);
418
+ config.caller.api_key_configured = Boolean(callerApiKey);
419
+ config.responder ||= {
420
+ enabled: false,
421
+ responder_id: env.RESPONDER_ID || process.env.RESPONDER_ID || null,
422
+ display_name: "Local Responder",
423
+ hotlines: []
424
+ };
425
+ config.preferences ||= {
426
+ task_types: {},
427
+ caller_policy: {
428
+ mode: "manual",
429
+ responderWhitelist: [],
430
+ hotlineWhitelist: [],
431
+ blocklist: []
432
+ }
433
+ };
434
+ config.preferences.task_types ||= {};
435
+ config.preferences.caller_policy ||= {
436
+ mode: "manual",
437
+ responderWhitelist: [],
438
+ hotlineWhitelist: [],
439
+ blocklist: []
440
+ };
441
+ config.preferences.caller_policy.mode ||= "manual";
442
+ config.preferences.caller_policy.responderWhitelist ||= [];
443
+ config.preferences.caller_policy.hotlineWhitelist ||= [];
444
+ config.preferences.caller_policy.blocklist ||= [];
445
+ const defaultPorts = resolveDefaultPorts();
446
+ config.runtime ||= { ports: defaultPorts, external_relay: null, transport: defaultTransportConfig() };
447
+ config.runtime.ports ||= defaultPorts;
448
+ config.runtime.transport = normalizeTransportConfig(config, env);
449
+
450
+ for (const [key, value] of Object.entries(defaultPorts)) {
451
+ config.runtime.ports[key] ||= value;
452
+ }
453
+
454
+ return { envFile, opsConfigFile, secretsFile, env, config };
455
+ }
456
+
457
+ function getLegacyConfigSecret(config, secretKey) {
458
+ const pathSegments = LEGACY_SECRET_CONFIG_PATHS[secretKey];
459
+ if (!pathSegments) {
460
+ return null;
461
+ }
462
+ let current = config;
463
+ for (const segment of pathSegments) {
464
+ current = current?.[segment];
465
+ }
466
+ return normalizedString(current);
467
+ }
468
+
469
+ export function readLegacyOpsSecrets(state) {
470
+ const env = state?.env || {};
471
+ const config = state?.config || {};
472
+ const transport = readTransportSecretsFromEnv(env);
473
+ return {
474
+ [OPS_SECRET_KEYS.caller_api_key]:
475
+ getLegacyConfigSecret(config, OPS_SECRET_KEYS.caller_api_key) ||
476
+ normalizedString(env.CALLER_PLATFORM_API_KEY) ||
477
+ normalizedString(env.PLATFORM_API_KEY),
478
+ [OPS_SECRET_KEYS.responder_platform_api_key]: normalizedString(env.RESPONDER_PLATFORM_API_KEY),
479
+ [OPS_SECRET_KEYS.transport_emailengine_access_token]: transport.emailengine.access_token,
480
+ [OPS_SECRET_KEYS.transport_gmail_client_secret]: transport.gmail.client_secret,
481
+ [OPS_SECRET_KEYS.transport_gmail_refresh_token]: transport.gmail.refresh_token,
482
+ [OPS_SECRET_KEYS.platform_admin_api_key]:
483
+ getLegacyConfigSecret(config, OPS_SECRET_KEYS.platform_admin_api_key) ||
484
+ normalizedString(env.PLATFORM_ADMIN_API_KEY)
485
+ };
486
+ }
487
+
488
+ export function listLegacySecretKeys(state) {
489
+ return Object.entries(readLegacyOpsSecrets(state))
490
+ .filter(([, value]) => normalizedString(value))
491
+ .map(([key]) => key);
492
+ }
493
+
494
+ export function getConfiguredSecretFile() {
495
+ return getOpsSecretsFile();
496
+ }
497
+
498
+ export function hasEncryptedSecretStore() {
499
+ return secretStoreExists(getConfiguredSecretFile());
500
+ }
501
+
502
+ export function unlockOpsSecrets(passphrase) {
503
+ return unlockSecretStore(getConfiguredSecretFile(), passphrase).secrets;
504
+ }
505
+
506
+ export function writeOpsSecrets(passphrase, updates) {
507
+ return writeSecretValues(getConfiguredSecretFile(), passphrase, updates);
508
+ }
509
+
510
+ export function readResolvedOpsSecrets(state, unlockedSecrets = null) {
511
+ const legacy = readLegacyOpsSecrets(state);
512
+ const encrypted = unlockedSecrets || {};
513
+ return {
514
+ caller_api_key: normalizedString(encrypted[OPS_SECRET_KEYS.caller_api_key]) || legacy[OPS_SECRET_KEYS.caller_api_key] || null,
515
+ responder_platform_api_key:
516
+ normalizedString(encrypted[OPS_SECRET_KEYS.responder_platform_api_key]) || legacy[OPS_SECRET_KEYS.responder_platform_api_key] || null,
517
+ transport: {
518
+ emailengine: {
519
+ access_token:
520
+ normalizedString(encrypted[OPS_SECRET_KEYS.transport_emailengine_access_token]) ||
521
+ legacy[OPS_SECRET_KEYS.transport_emailengine_access_token] ||
522
+ null
523
+ },
524
+ gmail: {
525
+ client_secret:
526
+ normalizedString(encrypted[OPS_SECRET_KEYS.transport_gmail_client_secret]) ||
527
+ legacy[OPS_SECRET_KEYS.transport_gmail_client_secret] ||
528
+ null,
529
+ refresh_token:
530
+ normalizedString(encrypted[OPS_SECRET_KEYS.transport_gmail_refresh_token]) ||
531
+ legacy[OPS_SECRET_KEYS.transport_gmail_refresh_token] ||
532
+ null
533
+ }
534
+ },
535
+ platform_admin_api_key:
536
+ normalizedString(encrypted[OPS_SECRET_KEYS.platform_admin_api_key]) || legacy[OPS_SECRET_KEYS.platform_admin_api_key] || null
537
+ };
538
+ }
539
+
540
+ export function scrubLegacySecrets(state) {
541
+ if (!state?.config || !state?.envFile) {
542
+ return state;
543
+ }
544
+ if (state.config.caller) {
545
+ state.config.caller.api_key = null;
546
+ state.config.caller.api_key_configured = true;
547
+ }
548
+ state.config.platform_console ||= {};
549
+ state.config.platform_console.admin_api_key = null;
550
+ writeJsonFile(state.opsConfigFile, state.config);
551
+ state.env = updateEnvFile(
552
+ state.envFile,
553
+ {
554
+ CALLER_PLATFORM_API_KEY: null,
555
+ PLATFORM_API_KEY: null,
556
+ RESPONDER_PLATFORM_API_KEY: null,
557
+ PLATFORM_ADMIN_API_KEY: null,
558
+ TRANSPORT_EMAILENGINE_ACCESS_TOKEN: null,
559
+ TRANSPORT_GMAIL_CLIENT_SECRET: null,
560
+ TRANSPORT_GMAIL_REFRESH_TOKEN: null
561
+ },
562
+ { removeNull: true }
563
+ );
564
+ return state;
565
+ }
566
+
567
+ export function saveOpsState({ envFile, opsConfigFile, env, config }) {
568
+ const encryptedStoreConfigured = secretStoreExists(getConfiguredSecretFile());
569
+ const resolvedCallerApiKey =
570
+ normalizedString(env.CALLER_PLATFORM_API_KEY) ||
571
+ normalizedString(env.PLATFORM_API_KEY) ||
572
+ normalizedString(config.caller?.api_key);
573
+ const resolvedResponderPlatformApiKey = normalizedString(env.RESPONDER_PLATFORM_API_KEY);
574
+ const resolvedPlatformAdminApiKey =
575
+ normalizedString(env.PLATFORM_ADMIN_API_KEY) ||
576
+ normalizedString(config.platform_console?.admin_api_key);
577
+ const transportSecrets = readTransportSecretsFromEnv(env);
578
+
579
+ config.caller ||= {};
580
+ config.caller.api_key = null;
581
+ config.caller.api_key_configured = Boolean(config.caller.api_key_configured || resolvedCallerApiKey);
582
+ config.platform_console ||= {};
583
+ config.platform_console.admin_api_key = null;
584
+ writeJsonFile(opsConfigFile, config);
585
+ const transportEnv = buildTransportEnvUpdates(config.runtime?.transport || {}, env);
586
+ const relayBaseUrl =
587
+ normalizeTransportConfig(config, env).type === "local"
588
+ ? `http://127.0.0.1:${config.runtime?.ports?.relay || DEFAULT_PORTS.relay}`
589
+ : transportEnv.TRANSPORT_BASE_URL;
590
+ const updates = {
591
+ PLATFORM_API_BASE_URL: config.platform?.base_url || env.PLATFORM_API_BASE_URL || null,
592
+ CALLER_PLATFORM_API_KEY: encryptedStoreConfigured ? null : resolvedCallerApiKey,
593
+ PLATFORM_API_KEY: encryptedStoreConfigured ? null : resolvedCallerApiKey,
594
+ CALLER_CONTACT_EMAIL: config.caller?.contact_email || env.CALLER_CONTACT_EMAIL || null,
595
+ RESPONDER_PLATFORM_API_KEY: encryptedStoreConfigured ? null : resolvedResponderPlatformApiKey,
596
+ RESPONDER_ID: config.responder?.responder_id || env.RESPONDER_ID || null,
597
+ HOTLINE_IDS: (config.responder?.hotlines || []).map((item) => item.hotline_id).filter(Boolean).join(","),
598
+ TRANSPORT_BASE_URL: relayBaseUrl,
599
+ TRANSPORT_TYPE: transportEnv.TRANSPORT_TYPE,
600
+ TRANSPORT_PROVIDER: transportEnv.TRANSPORT_PROVIDER,
601
+ TRANSPORT_EMAIL_PROVIDER: transportEnv.TRANSPORT_EMAIL_PROVIDER,
602
+ TRANSPORT_EMAIL_MODE: transportEnv.TRANSPORT_EMAIL_MODE,
603
+ TRANSPORT_EMAIL_SENDER: transportEnv.TRANSPORT_EMAIL_SENDER,
604
+ TRANSPORT_EMAIL_RECEIVER: transportEnv.TRANSPORT_EMAIL_RECEIVER,
605
+ TRANSPORT_EMAIL_POLL_INTERVAL_MS: transportEnv.TRANSPORT_EMAIL_POLL_INTERVAL_MS,
606
+ TRANSPORT_EMAILENGINE_BASE_URL: transportEnv.TRANSPORT_EMAILENGINE_BASE_URL,
607
+ TRANSPORT_EMAILENGINE_ACCOUNT: transportEnv.TRANSPORT_EMAILENGINE_ACCOUNT,
608
+ TRANSPORT_GMAIL_CLIENT_ID: transportEnv.TRANSPORT_GMAIL_CLIENT_ID,
609
+ TRANSPORT_GMAIL_USER: transportEnv.TRANSPORT_GMAIL_USER,
610
+ TRANSPORT_EMAILENGINE_ACCESS_TOKEN: encryptedStoreConfigured ? null : transportSecrets.emailengine.access_token,
611
+ TRANSPORT_GMAIL_CLIENT_SECRET: encryptedStoreConfigured ? null : transportSecrets.gmail.client_secret,
612
+ TRANSPORT_GMAIL_REFRESH_TOKEN: encryptedStoreConfigured ? null : transportSecrets.gmail.refresh_token,
613
+ PLATFORM_ADMIN_API_KEY: encryptedStoreConfigured ? null : resolvedPlatformAdminApiKey,
614
+ PORT: null
615
+ };
616
+ return updateEnvFile(envFile, updates, { removeNull: true });
617
+ }
618
+
619
+ export function ensureResponderIdentity(state, { responderId = null, displayName = null } = {}) {
620
+ const { env, config } = state;
621
+ const currentResponderId = responderId || config.responder?.responder_id || env.RESPONDER_ID || randomResponderId();
622
+ config.responder ||= {};
623
+ config.responder.responder_id = currentResponderId;
624
+ config.responder.display_name = displayName || config.responder.display_name || "Local Responder";
625
+ config.responder.hotlines ||= [];
626
+
627
+ if (!env.RESPONDER_SIGNING_PUBLIC_KEY_PEM || !env.RESPONDER_SIGNING_PRIVATE_KEY_PEM) {
628
+ const signing = generateSigningKeyPair();
629
+ updateEnvFile(state.envFile, {
630
+ RESPONDER_SIGNING_PUBLIC_KEY_PEM: encodePemForEnv(signing.publicKeyPem),
631
+ RESPONDER_SIGNING_PRIVATE_KEY_PEM: encodePemForEnv(signing.privateKeyPem),
632
+ RESPONDER_ID: currentResponderId
633
+ });
634
+ state.env = readEnvFile(state.envFile);
635
+ }
636
+
637
+ return {
638
+ responder_id: currentResponderId,
639
+ display_name: config.responder.display_name,
640
+ public_key_pem: decodePemFromEnv(state.env.RESPONDER_SIGNING_PUBLIC_KEY_PEM),
641
+ private_key_pem: decodePemFromEnv(state.env.RESPONDER_SIGNING_PRIVATE_KEY_PEM)
642
+ };
643
+ }
644
+
645
+ export function upsertHotline(state, definition) {
646
+ state.config.responder ||= { enabled: false, responder_id: null, display_name: "Local Responder", hotlines: [] };
647
+ state.config.responder.hotlines ||= [];
648
+ state.config.responder.hotlines = [
649
+ ...state.config.responder.hotlines.filter((item) => item.hotline_id !== definition.hotline_id),
650
+ definition
651
+ ];
652
+ return definition;
653
+ }
654
+
655
+ export function setHotlineEnabled(state, hotlineId, enabled) {
656
+ state.config.responder ||= { enabled: false, responder_id: null, display_name: "Local Responder", hotlines: [] };
657
+ state.config.responder.hotlines ||= [];
658
+ const item = state.config.responder.hotlines.find((entry) => entry.hotline_id === hotlineId);
659
+ if (!item) {
660
+ return null;
661
+ }
662
+ item.enabled = enabled;
663
+ return item;
664
+ }
665
+
666
+ export function removeHotline(state, hotlineId) {
667
+ state.config.responder ||= { enabled: false, responder_id: null, display_name: "Local Responder", hotlines: [] };
668
+ state.config.responder.hotlines ||= [];
669
+ const existing = state.config.responder.hotlines.find((entry) => entry.hotline_id === hotlineId);
670
+ if (!existing) {
671
+ return null;
672
+ }
673
+ state.config.responder.hotlines = state.config.responder.hotlines.filter((entry) => entry.hotline_id !== hotlineId);
674
+ removeManagedLocalFile(
675
+ existing?.metadata?.registration?.draft_file || getHotlineRegistrationDraftFile(existing.hotline_id),
676
+ getHotlineRegistrationDraftsDir()
677
+ );
678
+ removeManagedLocalFile(
679
+ existing?.metadata?.local?.integration_file || getHotlineLocalIntegrationFile(existing.hotline_id),
680
+ getHotlineLocalIntegrationsDir()
681
+ );
682
+ removeManagedLocalFile(
683
+ existing?.metadata?.local?.hook_file || getHotlineLocalHookFile(existing.hotline_id),
684
+ getHotlineLocalHooksDir()
685
+ );
686
+ return existing;
687
+ }
688
+
689
+ function sanitizeHotlineIdForFileName(hotlineId) {
690
+ return String(hotlineId || "")
691
+ .toLowerCase()
692
+ .replace(/[^a-z0-9.-]+/g, "-")
693
+ .replace(/^-+|-+$/g, "");
694
+ }
695
+
696
+ export function getHotlineRegistrationDraftsDir() {
697
+ return path.join(getOpsHomeDir(), "hotline-registration-drafts");
698
+ }
699
+
700
+ export function getHotlineRegistrationDraftFile(hotlineId) {
701
+ const safeName = sanitizeHotlineIdForFileName(hotlineId) || "hotline";
702
+ return path.join(getHotlineRegistrationDraftsDir(), `${safeName}.registration.json`);
703
+ }
704
+
705
+ export function getHotlineLocalIntegrationsDir() {
706
+ return path.join(getOpsHomeDir(), "hotline-integrations");
707
+ }
708
+
709
+ export function getHotlineLocalHooksDir() {
710
+ return path.join(getOpsHomeDir(), "hotline-hooks");
711
+ }
712
+
713
+ export function getHotlineLocalIntegrationFile(hotlineId) {
714
+ const safeName = sanitizeHotlineIdForFileName(hotlineId) || "hotline";
715
+ return path.join(getHotlineLocalIntegrationsDir(), `${safeName}.integration.json`);
716
+ }
717
+
718
+ export function getHotlineLocalHookFile(hotlineId) {
719
+ const safeName = sanitizeHotlineIdForFileName(hotlineId) || "hotline";
720
+ return path.join(getHotlineLocalHooksDir(), `${safeName}.hooks.json`);
721
+ }
722
+
723
+ function buildHotlineLocalIntegration(definition = {}) {
724
+ return {
725
+ schema_version: 1,
726
+ updated_at: new Date().toISOString(),
727
+ source: "delexec-ops",
728
+ hotline_id: definition.hotline_id,
729
+ display_name: definition.display_name || definition.hotline_id,
730
+ adapter_type: definition.adapter_type || "process",
731
+ adapter: definition.adapter || null,
732
+ timeouts: definition.timeouts || null,
733
+ task_types: ensureStringList(definition.task_types),
734
+ capabilities: ensureStringList(definition.capabilities),
735
+ tags: ensureStringList(definition.tags),
736
+ project: definition?.metadata?.project || null,
737
+ note: "Machine-local hotline integration config. Keep responder-specific commands, URLs, paths, and hook references here instead of inside git-tracked files."
738
+ };
739
+ }
740
+
741
+ function buildDefaultHotlineHookConfig(definition = {}) {
742
+ return {
743
+ schema_version: 1,
744
+ hotline_id: definition.hotline_id,
745
+ source: "delexec-ops",
746
+ hooks: {
747
+ before_invoke: null,
748
+ after_success: null,
749
+ after_error: null
750
+ },
751
+ note: "Optional machine-local hook commands or script paths. Keep these under DELEXEC_HOME and out of the repository."
752
+ };
753
+ }
754
+
755
+ function removeManagedLocalFile(filePath, expectedDir) {
756
+ if (!filePath || !expectedDir) {
757
+ return;
758
+ }
759
+ const resolvedFile = path.resolve(filePath);
760
+ const resolvedDir = path.resolve(expectedDir);
761
+ if (resolvedFile !== resolvedDir && !resolvedFile.startsWith(`${resolvedDir}${path.sep}`)) {
762
+ return;
763
+ }
764
+ if (fs.existsSync(resolvedFile)) {
765
+ fs.rmSync(resolvedFile, { force: true });
766
+ }
767
+ }
768
+
769
+ export function ensureHotlineLocalIntegration(definition) {
770
+ ensureOpsDirectories();
771
+ const integrationFile = getHotlineLocalIntegrationFile(definition.hotline_id);
772
+ const hookFile = getHotlineLocalHookFile(definition.hotline_id);
773
+ const existingHooks = readJsonFile(hookFile, null);
774
+
775
+ writeJsonFile(integrationFile, buildHotlineLocalIntegration(definition));
776
+ if (!existingHooks) {
777
+ writeJsonFile(hookFile, buildDefaultHotlineHookConfig(definition));
778
+ }
779
+
780
+ definition.metadata ||= {};
781
+ definition.metadata.local ||= {};
782
+ definition.metadata.local.integration_file = integrationFile;
783
+ definition.metadata.local.hook_file = hookFile;
784
+
785
+ return {
786
+ integration_file: integrationFile,
787
+ hook_file: hookFile,
788
+ hooks_created: !existingHooks
789
+ };
790
+ }
791
+
792
+ function buildDefaultContractProfile(definition = {}) {
793
+ const hotlineId = String(definition.hotline_id || "").trim();
794
+ const displayName = String(definition.display_name || hotlineId || "Local Hotline").trim();
795
+ const taskTypes = ensureStringList(definition.task_types);
796
+ const capabilities = ensureStringList(definition.capabilities);
797
+ const textSummarize =
798
+ taskTypes.includes("text_summarize") ||
799
+ capabilities.includes("text.summarize") ||
800
+ hotlineId.includes("summary");
801
+ const pdfParse =
802
+ taskTypes.includes("document_parse") ||
803
+ capabilities.includes("document.parse.pdf") ||
804
+ hotlineId.includes("pdf.parse") ||
805
+ hotlineId.includes("mineru");
806
+
807
+ if (definition.input_schema || definition.output_schema) {
808
+ return {
809
+ profile_key: taskTypes[0] || capabilities[0] || "explicit_contract",
810
+ description: definition.description || `Use ${displayName} for this configured local task.`,
811
+ summary: definition.summary || `Send a request to ${displayName} using its configured local contract.`,
812
+ template_ref: definition.template_ref || `docs/templates/hotlines/${hotlineId}/`,
813
+ input_schema: definition.input_schema || {
814
+ type: "object",
815
+ additionalProperties: false,
816
+ properties: {}
817
+ },
818
+ output_schema: definition.output_schema || {
819
+ type: "object",
820
+ additionalProperties: false,
821
+ properties: {}
822
+ },
823
+ input_examples: Array.isArray(definition.input_examples) ? definition.input_examples : [],
824
+ output_examples: Array.isArray(definition.output_examples) ? definition.output_examples : [],
825
+ input_summary: definition.input_summary || "Use the configured input schema for this local hotline.",
826
+ output_summary: definition.output_summary || "Returns the configured output schema for this local hotline.",
827
+ recommended_for: Array.isArray(definition.recommended_for) ? definition.recommended_for : [],
828
+ not_recommended_for: Array.isArray(definition.not_recommended_for) ? definition.not_recommended_for : [],
829
+ limitations: Array.isArray(definition.limitations) ? definition.limitations : []
830
+ };
831
+ }
832
+
833
+ if (textSummarize) {
834
+ return {
835
+ profile_key: "text_summarize",
836
+ description: `Use ${displayName} when you want a concise summary of project notes, workspace updates, or other text you provide.`,
837
+ summary: `Paste the text you want summarized and optionally tell the hotline what to emphasize.`,
838
+ template_ref: `docs/templates/hotlines/${hotlineId}/`,
839
+ input_schema: {
840
+ type: "object",
841
+ additionalProperties: false,
842
+ required: ["text"],
843
+ properties: {
844
+ text: {
845
+ type: "string",
846
+ description: "Paste the text you want summarized. Include enough context so the summary can stand on its own.",
847
+ minLength: 1
848
+ },
849
+ instruction: {
850
+ type: "string",
851
+ description: "Optional: explain what the summary should emphasize, such as blockers, next steps, risks, or action items."
852
+ }
853
+ }
854
+ },
855
+ output_schema: {
856
+ type: "object",
857
+ additionalProperties: false,
858
+ required: ["summary"],
859
+ properties: {
860
+ summary: {
861
+ type: "string",
862
+ description: "Summary of the provided source text."
863
+ }
864
+ }
865
+ },
866
+ input_examples: [
867
+ {
868
+ title: "Basic summary request",
869
+ input: {
870
+ text: "CHG-2026-003 is in progress. The responder registration flow now supports registration drafts and single-hotline submission.",
871
+ instruction: "Summarize the current status and call out the next engineering step."
872
+ }
873
+ }
874
+ ],
875
+ output_examples: [
876
+ {
877
+ title: "Basic summary result",
878
+ output: {
879
+ summary: "The registration flow now supports drafts and single-hotline submission. The next engineering step is validating the updated responder UI end to end."
880
+ }
881
+ }
882
+ ],
883
+ input_summary: "Paste the text you want summarized. Optionally add an instruction describing what to emphasize, such as blockers, next steps, or risks.",
884
+ output_summary: "You will receive a concise summary suitable for status updates, review notes, or quick progress reports."
885
+ };
886
+ }
887
+
888
+ if (pdfParse) {
889
+ return {
890
+ profile_key: "document_parse_pdf",
891
+ description: `Use ${displayName} when you need to parse a local PDF on this machine and produce markdown output.`,
892
+ summary: "Provide an absolute local PDF path. The hotline will run the configured local parser and return the generated markdown artifact.",
893
+ template_ref: `docs/templates/hotlines/${hotlineId}/`,
894
+ input_schema: {
895
+ type: "object",
896
+ additionalProperties: false,
897
+ required: ["pdf_path"],
898
+ properties: {
899
+ pdf_path: {
900
+ type: "string",
901
+ description: "Absolute path to a readable local PDF file on this machine."
902
+ },
903
+ parse_method: {
904
+ type: "string",
905
+ description: "Optional parse mode. Use a parser-supported value such as auto, txt, or ocr."
906
+ },
907
+ instruction: {
908
+ type: "string",
909
+ description: "Optional parsing guidance or downstream emphasis for the generated markdown."
910
+ }
911
+ }
912
+ },
913
+ output_schema: {
914
+ type: "object",
915
+ additionalProperties: false,
916
+ required: ["markdown_file"],
917
+ properties: {
918
+ markdown_file: {
919
+ type: "string",
920
+ description: "Absolute path to the generated markdown file."
921
+ }
922
+ }
923
+ },
924
+ input_examples: [
925
+ {
926
+ title: "Parse a local PDF",
927
+ input: {
928
+ pdf_path: "/absolute/path/to/document.pdf",
929
+ parse_method: "auto",
930
+ instruction: "Keep headings and tables when possible."
931
+ }
932
+ }
933
+ ],
934
+ output_examples: [
935
+ {
936
+ title: "Markdown parse result",
937
+ output: {
938
+ markdown_file: "/tmp/delexec-parse-xxxx/document.md"
939
+ }
940
+ }
941
+ ],
942
+ input_summary: "Provide an absolute local pdf_path. Optionally include parse_method and an instruction.",
943
+ output_summary: "Returns the generated markdown file path and a markdown artifact payload."
944
+ };
945
+ }
946
+
947
+ return {
948
+ profile_key: "generic_task",
949
+ description: `Use ${displayName} when you want this hotline to handle a project-specific task for the text or context you provide.`,
950
+ summary: `Describe the task you want completed and include any context the responder should use.`,
951
+ template_ref: `docs/templates/hotlines/${hotlineId}/`,
952
+ input_schema: {
953
+ type: "object",
954
+ additionalProperties: false,
955
+ required: ["prompt"],
956
+ properties: {
957
+ prompt: {
958
+ type: "string",
959
+ description: "Describe the task you want the hotline to complete in one clear instruction."
960
+ },
961
+ context: {
962
+ type: "string",
963
+ description: "Optional: add background, constraints, or extra context the hotline should consider when completing the task."
964
+ }
965
+ }
966
+ },
967
+ output_schema: {
968
+ type: "object",
969
+ additionalProperties: false,
970
+ required: ["result"],
971
+ properties: {
972
+ result: {
973
+ type: "string",
974
+ description: "Primary response returned by the hotline."
975
+ }
976
+ }
977
+ },
978
+ input_examples: [
979
+ {
980
+ title: "Basic request",
981
+ input: {
982
+ prompt: "Review this implementation plan and identify the next concrete step.",
983
+ context: "The goal is to improve the responder registration workflow."
984
+ }
985
+ }
986
+ ],
987
+ output_examples: [
988
+ {
989
+ title: "Basic response",
990
+ output: {
991
+ result: "The next concrete step is wiring the registration draft flow into the responder review UI."
992
+ }
993
+ }
994
+ ],
995
+ input_summary: "Describe the task you want completed. Add any optional context, constraints, or background the hotline should consider.",
996
+ output_summary: "You will receive the primary task result produced from your prompt and context."
997
+ };
998
+ }
999
+
1000
+ export function buildHotlineRegistrationDraft(state, definition, existingDraft = null) {
1001
+ const fallbackContactEmail = state?.config?.caller?.contact_email || state?.env?.CALLER_CONTACT_EMAIL || null;
1002
+ const taskTypes = ensureStringList(definition.task_types);
1003
+ const capabilities = ensureStringList(definition.capabilities);
1004
+ const tags = ensureStringList(definition.tags);
1005
+ const profile = buildDefaultContractProfile(definition);
1006
+ const generatedProfileKey = String(profile.profile_key || "generic_task");
1007
+ const existingProfileKey = String(existingDraft?.draft_meta?.generated_profile || "").trim();
1008
+ const reusableDraft = existingProfileKey === generatedProfileKey ? existingDraft : null;
1009
+ const generated = {
1010
+ draft_meta: {
1011
+ schema_version: 1,
1012
+ generated_at: new Date().toISOString(),
1013
+ source: "delexec-ops",
1014
+ generated_profile: generatedProfileKey,
1015
+ editable: [
1016
+ "description",
1017
+ "summary",
1018
+ "template_ref",
1019
+ "input_schema",
1020
+ "output_schema",
1021
+ "input_attachments",
1022
+ "output_attachments",
1023
+ "input_examples",
1024
+ "output_examples",
1025
+ "input_summary",
1026
+ "output_summary",
1027
+ "recommended_for",
1028
+ "not_recommended_for",
1029
+ "limitations",
1030
+ "contact_email",
1031
+ "support_email"
1032
+ ]
1033
+ },
1034
+ hotline_id: definition.hotline_id,
1035
+ display_name: definition.display_name || definition.hotline_id,
1036
+ description: profile.description,
1037
+ summary: profile.summary,
1038
+ template_ref: profile.template_ref,
1039
+ task_types: taskTypes,
1040
+ capabilities,
1041
+ tags,
1042
+ input_schema: profile.input_schema,
1043
+ output_schema: profile.output_schema,
1044
+ input_attachments: null,
1045
+ output_attachments: null,
1046
+ input_examples: profile.input_examples,
1047
+ output_examples: profile.output_examples,
1048
+ input_summary: profile.input_summary,
1049
+ output_summary: profile.output_summary,
1050
+ recommended_for: Array.isArray(profile.recommended_for) ? profile.recommended_for : [],
1051
+ not_recommended_for: Array.isArray(profile.not_recommended_for) ? profile.not_recommended_for : [],
1052
+ limitations: Array.isArray(profile.limitations) ? profile.limitations : [],
1053
+ contact_email: fallbackContactEmail,
1054
+ support_email: null
1055
+ };
1056
+ return {
1057
+ ...generated,
1058
+ ...(reusableDraft || {}),
1059
+ draft_meta: {
1060
+ ...generated.draft_meta,
1061
+ ...(reusableDraft?.draft_meta || {})
1062
+ },
1063
+ hotline_id: definition.hotline_id,
1064
+ display_name: reusableDraft?.display_name || definition.display_name || definition.hotline_id,
1065
+ task_types: taskTypes,
1066
+ capabilities,
1067
+ tags
1068
+ };
1069
+ }
1070
+
1071
+ export function ensureHotlineRegistrationDraft(state, definition) {
1072
+ ensureOpsDirectories();
1073
+ const localIntegration = ensureHotlineLocalIntegration(definition);
1074
+ const draftFile = getHotlineRegistrationDraftFile(definition.hotline_id);
1075
+ const existingDraft = readJsonFile(draftFile, null);
1076
+ const draft = buildHotlineRegistrationDraft(state, definition, existingDraft);
1077
+ writeJsonFile(draftFile, draft);
1078
+ definition.metadata ||= {};
1079
+ definition.metadata.registration ||= {};
1080
+ definition.metadata.registration.draft_file = draftFile;
1081
+ return {
1082
+ draft_file: draftFile,
1083
+ integration_file: localIntegration.integration_file,
1084
+ hook_file: localIntegration.hook_file,
1085
+ created: !existingDraft,
1086
+ draft
1087
+ };
1088
+ }
1089
+
1090
+ export function loadHotlineRegistrationDraft(state, hotline) {
1091
+ if (hotline) {
1092
+ ensureHotlineLocalIntegration(hotline);
1093
+ }
1094
+ const draftFile = hotline?.metadata?.registration?.draft_file || getHotlineRegistrationDraftFile(hotline?.hotline_id);
1095
+ const draft = readJsonFile(draftFile, null);
1096
+ return draft ? { draft_file: draftFile, draft } : { draft_file: draftFile, draft: null };
1097
+ }
1098
+
1099
+ function normalizeGuidanceText(value) {
1100
+ if (typeof value !== "string") {
1101
+ return "";
1102
+ }
1103
+ return value.trim();
1104
+ }
1105
+
1106
+ function isValidInputFieldGuidance(value) {
1107
+ const text = normalizeGuidanceText(value);
1108
+ if (!text) {
1109
+ return false;
1110
+ }
1111
+ return !INVALID_INPUT_DESCRIPTION_PATTERNS.some((pattern) => pattern.test(text));
1112
+ }
1113
+
1114
+ export function validateHotlineRegistrationDraft(draft) {
1115
+ const fields = [];
1116
+ const properties = draft?.input_schema?.properties;
1117
+ if (properties && typeof properties === "object") {
1118
+ for (const [name, definition] of Object.entries(properties)) {
1119
+ const description = definition && typeof definition === "object" ? definition.description : null;
1120
+ if (!isValidInputFieldGuidance(description)) {
1121
+ fields.push(name);
1122
+ }
1123
+ }
1124
+ }
1125
+ if (fields.length > 0) {
1126
+ return {
1127
+ ok: false,
1128
+ code: "HOTLINE_INPUT_GUIDANCE_REQUIRED",
1129
+ message: "every input field must include caller-facing guidance in input_schema.properties.<field>.description",
1130
+ fields
1131
+ };
1132
+ }
1133
+ return { ok: true, fields: [] };
1134
+ }
1135
+
1136
+ export function buildHotlineOnboardingBody(state, hotline, responderIdentity) {
1137
+ let { draft_file, draft } = loadHotlineRegistrationDraft(state, hotline);
1138
+ if (!draft) {
1139
+ const created = ensureHotlineRegistrationDraft(state, hotline);
1140
+ draft_file = created.draft_file;
1141
+ draft = created.draft;
1142
+ }
1143
+ const validation = validateHotlineRegistrationDraft(draft);
1144
+ if (!validation.ok) {
1145
+ const error = new Error(validation.message);
1146
+ error.code = validation.code;
1147
+ error.fields = validation.fields;
1148
+ throw error;
1149
+ }
1150
+ const source = draft || {};
1151
+ return {
1152
+ draft_file,
1153
+ used_draft: Boolean(draft),
1154
+ body: {
1155
+ responder_id: responderIdentity.responder_id,
1156
+ hotline_id: hotline.hotline_id,
1157
+ display_name: source.display_name || hotline.display_name || hotline.hotline_id,
1158
+ responder_public_key_pem: responderIdentity.public_key_pem,
1159
+ description: source.description || null,
1160
+ summary: source.summary || null,
1161
+ template_ref: source.template_ref || `docs/templates/hotlines/${hotline.hotline_id}/`,
1162
+ task_types: ensureStringList(source.task_types, hotline.task_types || []),
1163
+ capabilities: ensureStringList(source.capabilities, hotline.capabilities || []),
1164
+ tags: ensureStringList(source.tags, hotline.tags || []),
1165
+ input_schema: source.input_schema || null,
1166
+ output_schema: source.output_schema || null,
1167
+ input_attachments: source.input_attachments || null,
1168
+ output_attachments: source.output_attachments || null,
1169
+ input_examples: Array.isArray(source.input_examples) ? source.input_examples : null,
1170
+ output_examples: Array.isArray(source.output_examples) ? source.output_examples : null,
1171
+ recommended_for: Array.isArray(source.recommended_for) ? source.recommended_for : null,
1172
+ not_recommended_for: Array.isArray(source.not_recommended_for) ? source.not_recommended_for : null,
1173
+ limitations: Array.isArray(source.limitations) ? source.limitations : null,
1174
+ input_summary: source.input_summary || null,
1175
+ output_summary: source.output_summary || null,
1176
+ contact_email: source.contact_email || state?.config?.caller?.contact_email || null,
1177
+ support_email: source.support_email || null
1178
+ }
1179
+ };
1180
+ }