@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
@@ -0,0 +1,1042 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { ensureOpsDirectories, getOpsHomeDir, readJsonFile, writeJsonFile } from "@delexec/runtime-utils";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const OPS_SESSION_HEADER = "X-Ops-Session";
12
+
13
+ function loadDisplayHintsMap() {
14
+ const map = new Map();
15
+ try {
16
+ const contractsRoot = path.resolve(__dirname, "../../../packages/caller-controller-core/node_modules/@delexec/contracts");
17
+ const altRoot = path.resolve(__dirname, "../../../../node_modules/@delexec/contracts");
18
+ const localRoot = path.resolve(__dirname, "../../../../../repos/protocol/docs/templates/hotlines");
19
+ const roots = [
20
+ localRoot,
21
+ path.join(contractsRoot, "templates/hotlines"),
22
+ path.join(altRoot, "templates/hotlines")
23
+ ];
24
+ for (const root of roots) {
25
+ if (!fs.existsSync(root)) continue;
26
+ for (const hotlineId of fs.readdirSync(root)) {
27
+ const hintsPath = path.join(root, hotlineId, "output_display_hints.json");
28
+ if (fs.existsSync(hintsPath)) {
29
+ try {
30
+ map.set(hotlineId, JSON.parse(fs.readFileSync(hintsPath, "utf8")));
31
+ } catch {
32
+ // ignore malformed hints
33
+ }
34
+ }
35
+ }
36
+ if (map.size > 0) break;
37
+ }
38
+ } catch {
39
+ // non-fatal
40
+ }
41
+ return map;
42
+ }
43
+
44
+ const displayHintsMap = loadDisplayHintsMap();
45
+
46
+ function sendJson(res, statusCode, data) {
47
+ res.writeHead(statusCode, {
48
+ "content-type": "application/json; charset=utf-8",
49
+ "access-control-allow-origin": "*",
50
+ "access-control-allow-methods": "GET,POST,OPTIONS",
51
+ "access-control-allow-headers": "Content-Type"
52
+ });
53
+ res.end(JSON.stringify(data));
54
+ }
55
+
56
+ function structuredError(code, message, extra = {}) {
57
+ return {
58
+ ok: false,
59
+ error: {
60
+ code,
61
+ message,
62
+ retryable: false,
63
+ ...extra
64
+ }
65
+ };
66
+ }
67
+
68
+ function normalizedString(value) {
69
+ if (value === undefined || value === null) {
70
+ return null;
71
+ }
72
+ const trimmed = String(value).trim();
73
+ return trimmed || null;
74
+ }
75
+
76
+ function nowIso() {
77
+ return new Date().toISOString();
78
+ }
79
+
80
+ function buildCallerHeaders() {
81
+ const headers = {};
82
+ if (process.env.CALLER_PLATFORM_API_KEY || process.env.PLATFORM_API_KEY) {
83
+ headers["X-Platform-Api-Key"] = process.env.CALLER_PLATFORM_API_KEY || process.env.PLATFORM_API_KEY;
84
+ }
85
+ return headers;
86
+ }
87
+
88
+ function callerBaseUrl() {
89
+ return process.env.CALLER_CONTROLLER_BASE_URL || `http://127.0.0.1:${process.env.CALLER_CONTROLLER_PORT || 8081}`;
90
+ }
91
+
92
+ function supervisorBaseUrl() {
93
+ return process.env.OPS_SUPERVISOR_BASE_URL || `http://127.0.0.1:${process.env.OPS_PORT_SUPERVISOR || 8079}`;
94
+ }
95
+
96
+ function readOpsSessionToken() {
97
+ const session = readJsonFile(path.join(ensureOpsDirectories(), "run", "session.json"), null);
98
+ if (!session?.token || !session?.expires_at) {
99
+ return null;
100
+ }
101
+ const expiresAt = Date.parse(session.expires_at);
102
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
103
+ return null;
104
+ }
105
+ return String(session.token);
106
+ }
107
+
108
+ function sanitizeHotlineIdForFileName(hotlineId) {
109
+ return String(hotlineId || "")
110
+ .toLowerCase()
111
+ .replace(/[^a-z0-9.-]+/g, "-")
112
+ .replace(/^-+|-+$/g, "");
113
+ }
114
+
115
+ function ensureCallerSkillDirectories() {
116
+ ensureOpsDirectories();
117
+ const preparedDir = path.join(getOpsHomeDir(), "prepared-requests");
118
+ fs.mkdirSync(preparedDir, { recursive: true, mode: 0o700 });
119
+ return {
120
+ preparedDir,
121
+ draftDir: path.join(getOpsHomeDir(), "hotline-registration-drafts")
122
+ };
123
+ }
124
+
125
+ function getHotlineRegistrationDraftFile(hotlineId) {
126
+ const { draftDir } = ensureCallerSkillDirectories();
127
+ const safeName = sanitizeHotlineIdForFileName(hotlineId) || "hotline";
128
+ return path.join(draftDir, `${safeName}.registration.json`);
129
+ }
130
+
131
+ function generatePreparedRequestId() {
132
+ return `prep_${crypto.randomUUID()}`;
133
+ }
134
+
135
+ function getPreparedRequestFile(preparedRequestId) {
136
+ const { preparedDir } = ensureCallerSkillDirectories();
137
+ return path.join(preparedDir, `${preparedRequestId}.json`);
138
+ }
139
+
140
+ function loadPreparedRequest(preparedRequestId) {
141
+ const filePath = getPreparedRequestFile(preparedRequestId);
142
+ return {
143
+ filePath,
144
+ record: readJsonFile(filePath, null)
145
+ };
146
+ }
147
+
148
+ function savePreparedRequest(record) {
149
+ const filePath = getPreparedRequestFile(record.prepared_request_id);
150
+ writeJsonFile(filePath, record);
151
+ return filePath;
152
+ }
153
+
154
+ function invalidatePriorPreparedRequests(hotlineId, agentSessionId) {
155
+ if (!agentSessionId) {
156
+ return;
157
+ }
158
+ const { preparedDir } = ensureCallerSkillDirectories();
159
+ if (!fs.existsSync(preparedDir)) {
160
+ return;
161
+ }
162
+ for (const name of fs.readdirSync(preparedDir)) {
163
+ if (!name.endsWith(".json")) continue;
164
+ const filePath = path.join(preparedDir, name);
165
+ const record = readJsonFile(filePath, null);
166
+ if (!record) continue;
167
+ if (record.hotline_id !== hotlineId) continue;
168
+ if (record.source_agent_session_id !== agentSessionId) continue;
169
+ if (!["draft", "ready"].includes(record.status)) continue;
170
+ record.status = "invalidated";
171
+ record.updated_at = nowIso();
172
+ writeJsonFile(filePath, record);
173
+ }
174
+ }
175
+
176
+ function isSafeLocalTextPath(targetPath) {
177
+ if (!targetPath || !path.isAbsolute(targetPath)) {
178
+ return { ok: false, reason: "LOCAL_FILE_PATH_REQUIRED" };
179
+ }
180
+ const ext = path.extname(targetPath).toLowerCase();
181
+ if (![".md", ".txt"].includes(ext)) {
182
+ return { ok: false, reason: "LOCAL_FILE_UNSUPPORTED_EXTENSION" };
183
+ }
184
+ const normalized = path.resolve(targetPath);
185
+ const parentDir = path.dirname(normalized);
186
+ const tempDir = fs.existsSync(parentDir)
187
+ ? fs.realpathSync.native(parentDir)
188
+ : path.resolve(parentDir);
189
+ const mineruTempPrefixes = [
190
+ path.join("/var", "folders"),
191
+ path.join("/private", "var", "folders")
192
+ ];
193
+ const explicitReadableRoots = [
194
+ "/Users/hejiajiudeeyu/Documents",
195
+ "/tmp"
196
+ ];
197
+ const inExplicitRoot = explicitReadableRoots.some((root) => normalized.startsWith(path.resolve(root) + path.sep) || normalized === path.resolve(root));
198
+ const inMineruTemp = mineruTempPrefixes.some((prefix) => tempDir.startsWith(prefix));
199
+ if (!inExplicitRoot && !inMineruTemp) {
200
+ return { ok: false, reason: "LOCAL_FILE_PATH_NOT_ALLOWED" };
201
+ }
202
+ return { ok: true, path: normalized };
203
+ }
204
+
205
+ function readLocalTextFile(targetPath) {
206
+ const guard = isSafeLocalTextPath(targetPath);
207
+ if (!guard.ok) {
208
+ return {
209
+ status: 400,
210
+ body: structuredError(guard.reason, "Local file path is not allowed or not supported", {
211
+ path: targetPath || null
212
+ })
213
+ };
214
+ }
215
+ if (!fs.existsSync(guard.path)) {
216
+ return {
217
+ status: 404,
218
+ body: structuredError("LOCAL_FILE_NOT_FOUND", "Local text file does not exist", {
219
+ path: guard.path
220
+ })
221
+ };
222
+ }
223
+ const stats = fs.statSync(guard.path);
224
+ if (!stats.isFile()) {
225
+ return {
226
+ status: 400,
227
+ body: structuredError("LOCAL_FILE_NOT_REGULAR", "Local path must point to a regular file", {
228
+ path: guard.path
229
+ })
230
+ };
231
+ }
232
+ const maxBytes = Number(process.env.LOCAL_TEXT_FILE_MAX_BYTES || 120000);
233
+ const content = fs.readFileSync(guard.path, "utf8");
234
+ const truncated = Buffer.byteLength(content, "utf8") > maxBytes;
235
+ const finalContent = truncated ? content.slice(0, maxBytes) : content;
236
+ return {
237
+ status: 200,
238
+ body: {
239
+ ok: true,
240
+ path: guard.path,
241
+ content: finalContent,
242
+ truncated,
243
+ bytes_read: Buffer.byteLength(finalContent, "utf8")
244
+ }
245
+ };
246
+ }
247
+
248
+ async function parseJsonBody(req) {
249
+ return new Promise((resolve, reject) => {
250
+ const chunks = [];
251
+ req.on("data", (chunk) => chunks.push(chunk));
252
+ req.on("end", () => {
253
+ if (chunks.length === 0) {
254
+ resolve({});
255
+ return;
256
+ }
257
+ try {
258
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
259
+ } catch {
260
+ reject(new Error("invalid_json"));
261
+ }
262
+ });
263
+ req.on("error", reject);
264
+ });
265
+ }
266
+
267
+ async function requestRawJson(baseUrl, pathname, { method = "GET", headers = {}, body } = {}) {
268
+ const response = await fetch(new URL(pathname, baseUrl), {
269
+ method,
270
+ headers: {
271
+ ...headers,
272
+ ...(body === undefined ? {} : { "content-type": "application/json; charset=utf-8" })
273
+ },
274
+ body: body === undefined ? undefined : JSON.stringify(body)
275
+ });
276
+ const text = await response.text();
277
+ return {
278
+ status: response.status,
279
+ body: text ? JSON.parse(text) : null
280
+ };
281
+ }
282
+
283
+ async function requestJson(baseUrl, pathname, { method = "GET", body } = {}) {
284
+ return requestRawJson(baseUrl, pathname, {
285
+ method,
286
+ headers: buildCallerHeaders(),
287
+ body
288
+ });
289
+ }
290
+
291
+ async function requestSupervisorJson(pathname) {
292
+ const token = readOpsSessionToken();
293
+ return requestRawJson(supervisorBaseUrl(), pathname, {
294
+ headers: token ? { [OPS_SESSION_HEADER]: token } : {}
295
+ });
296
+ }
297
+
298
+ async function requestCatalogItems(query = "") {
299
+ const suffix = query ? `?${query}` : "";
300
+ try {
301
+ const supervisor = await requestSupervisorJson(`/catalog/hotlines${suffix}`);
302
+ if (supervisor.status === 200) {
303
+ return supervisor;
304
+ }
305
+ } catch {
306
+ // The skill adapter can run without the Ops supervisor in tests or minimal setups.
307
+ }
308
+ return requestJson(callerBaseUrl(), `/controller/hotlines${suffix}`);
309
+ }
310
+
311
+ async function requestCatalogDetail(hotlineId) {
312
+ try {
313
+ const supervisor = await requestSupervisorJson(`/catalog/hotlines/${encodeURIComponent(hotlineId)}`);
314
+ if (supervisor.status === 200) {
315
+ return supervisor;
316
+ }
317
+ } catch {
318
+ // Fall back to the caller controller catalog below.
319
+ }
320
+ return null;
321
+ }
322
+
323
+ function mapRequestState(request, result = null) {
324
+ const resultPackage = result?.result_package || request?.result_package || null;
325
+ return {
326
+ request_id: request?.request_id || null,
327
+ status: request?.status || "UNKNOWN",
328
+ hotline_id: request?.hotline_id || resultPackage?.hotline_id || null,
329
+ responder_id: request?.responder_id || resultPackage?.responder_id || null,
330
+ result: resultPackage?.output || null,
331
+ error: resultPackage?.error || null,
332
+ result_package: resultPackage,
333
+ human_summary: resultPackage?.human_summary || null
334
+ };
335
+ }
336
+
337
+ function buildCallerSkillManifest() {
338
+ return {
339
+ skill: {
340
+ name: "caller-skill",
341
+ version: "0.1.0",
342
+ mode: "local_only",
343
+ description: "Progressive-disclosure caller skill for local hotline discovery, preparation, dispatch, and result reporting."
344
+ },
345
+ actions: [
346
+ {
347
+ name: "search_hotlines_brief",
348
+ method: "POST",
349
+ path: "/skills/caller/search-hotlines-brief",
350
+ description: "Fuzzy narrowing from a large hotline space into a short candidate list."
351
+ },
352
+ {
353
+ name: "search_hotlines_detailed",
354
+ method: "POST",
355
+ path: "/skills/caller/search-hotlines-detailed",
356
+ description: "Detailed comparison for a small candidate set before selection."
357
+ },
358
+ {
359
+ name: "read_hotline",
360
+ method: "GET",
361
+ path: "/skills/caller/hotlines/:hotlineId",
362
+ description: "Read the selected hotline contract and caller-facing template."
363
+ },
364
+ {
365
+ name: "prepare_request",
366
+ method: "POST",
367
+ path: "/skills/caller/prepare-request",
368
+ description: "Validate and normalize candidate input against the hotline schema."
369
+ },
370
+ {
371
+ name: "send_request",
372
+ method: "POST",
373
+ path: "/skills/caller/send-request",
374
+ description: "Send a previously prepared request and optionally wait for terminal state."
375
+ },
376
+ {
377
+ name: "report_response",
378
+ method: "GET",
379
+ path: "/skills/caller/requests/:requestId/report",
380
+ description: "Read and normalize request terminal state for agent consumption."
381
+ }
382
+ ],
383
+ orchestration: {
384
+ search_phase_order: "flexible",
385
+ execution_phase_order: [
386
+ "read_hotline",
387
+ "prepare_request",
388
+ "send_request",
389
+ "report_response"
390
+ ],
391
+ go_back_after_read_to: [
392
+ "search_hotlines_brief",
393
+ "search_hotlines_detailed"
394
+ ],
395
+ polling_owner: "adapter"
396
+ }
397
+ };
398
+ }
399
+
400
+ function mapCatalogBriefItem(item, score = null, matchReason = null) {
401
+ const source = item.source || (item.review_status && item.review_status !== "local_only" ? "platform" : "local");
402
+ return {
403
+ hotline_id: item.hotline_id,
404
+ display_name: item.display_name || item.hotline_id,
405
+ short_description: item.description || null,
406
+ task_types: item.task_types || [],
407
+ source,
408
+ match_reason: matchReason,
409
+ score
410
+ };
411
+ }
412
+
413
+ function mapCatalogDetailedItem(item, draftInfo = null) {
414
+ const draft = draftInfo?.draft || null;
415
+ const localOnly = item.source === "local" || !item.review_status || item.review_status === "local_only";
416
+ return {
417
+ hotline_id: item.hotline_id,
418
+ responder_id: item.responder_id,
419
+ display_name: draft?.display_name || item.display_name || item.hotline_id,
420
+ description: draft?.description || item.description || null,
421
+ input_summary: draft?.input_summary || draft?.summary || null,
422
+ output_summary: draft?.output_summary || null,
423
+ task_types: draft?.task_types || item.task_types || [],
424
+ draft_ready: Boolean(draft),
425
+ local_only: localOnly,
426
+ review_status: item.review_status || "local_only"
427
+ };
428
+ }
429
+
430
+ function computeCatalogMatch(item, queryTerms = [], taskGoalTerms = [], taskType = null) {
431
+ const haystacks = [
432
+ item.hotline_id,
433
+ item.display_name,
434
+ item.description,
435
+ ...(item.task_types || []),
436
+ ...(item.capabilities || []),
437
+ ...(item.tags || [])
438
+ ]
439
+ .filter(Boolean)
440
+ .map((entry) => String(entry).toLowerCase());
441
+
442
+ const allTerms = [...queryTerms, ...taskGoalTerms].filter(Boolean);
443
+ let score = 0;
444
+ const matched = new Set();
445
+
446
+ for (const term of allTerms) {
447
+ if (haystacks.some((value) => value.includes(term))) {
448
+ score += queryTerms.includes(term) ? 2 : 1;
449
+ matched.add(term);
450
+ }
451
+ }
452
+
453
+ if (taskType && (item.task_types || []).some((entry) => String(entry).toLowerCase() === taskType.toLowerCase())) {
454
+ score += 3;
455
+ matched.add(`task_type:${taskType}`);
456
+ }
457
+
458
+ const matchReason = matched.size > 0 ? `matches ${Array.from(matched).join(", ")}` : null;
459
+ return { score, matchReason };
460
+ }
461
+
462
+ function tokenizeSearchText(value) {
463
+ return normalizedString(value)
464
+ ? normalizedString(value)
465
+ .toLowerCase()
466
+ .split(/[^a-z0-9._-]+/i)
467
+ .map((term) => term.trim())
468
+ .filter(Boolean)
469
+ : [];
470
+ }
471
+
472
+ async function waitForTerminalRequest(requestId, { timeoutMs, intervalMs } = {}) {
473
+ const startedAt = Date.now();
474
+ const maxWaitMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : Number(process.env.SKILL_MAX_WAIT_MS || 30000);
475
+ const pollEveryMs = Number.isFinite(Number(intervalMs)) ? Number(intervalMs) : Number(process.env.SKILL_POLL_INTERVAL_MS || 250);
476
+ while (Date.now() - startedAt < maxWaitMs) {
477
+ const request = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}`);
478
+ if (request.status !== 200) {
479
+ return request;
480
+ }
481
+ const result = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/result`);
482
+ if (["SUCCEEDED", "FAILED", "UNVERIFIED", "TIMED_OUT"].includes(request.body?.status) || result.body?.available === true) {
483
+ return {
484
+ status: 200,
485
+ body: mapRequestState(request.body, result.body)
486
+ };
487
+ }
488
+ await new Promise((resolve) => setTimeout(resolve, pollEveryMs));
489
+ }
490
+ return {
491
+ status: 200,
492
+ body: {
493
+ request_id: requestId,
494
+ status: "PENDING",
495
+ result: null,
496
+ error: {
497
+ code: "SKILL_WAIT_TIMEOUT",
498
+ message: "request did not reach terminal state before skill timeout",
499
+ retryable: true
500
+ },
501
+ result_package: null,
502
+ human_summary: null
503
+ }
504
+ };
505
+ }
506
+
507
+ async function resolveCatalogTarget(hotlineId, responderId = null) {
508
+ const supervisorTarget = responderId ? null : await requestCatalogDetail(hotlineId);
509
+ if (supervisorTarget?.status === 200) {
510
+ return {
511
+ status: 200,
512
+ body: supervisorTarget.body
513
+ };
514
+ }
515
+
516
+ const params = new URLSearchParams();
517
+ if (hotlineId) {
518
+ params.set("hotline_id", hotlineId);
519
+ }
520
+ if (responderId) {
521
+ params.set("responder_id", responderId);
522
+ }
523
+ const catalog = await requestCatalogItems(params.toString());
524
+ if (catalog.status !== 200) {
525
+ return catalog;
526
+ }
527
+ const selected = (catalog.body?.items || []).find((item) => {
528
+ if (item.hotline_id !== hotlineId) {
529
+ return false;
530
+ }
531
+ if (responderId && item.responder_id !== responderId) {
532
+ return false;
533
+ }
534
+ return true;
535
+ });
536
+ if (!selected) {
537
+ return {
538
+ status: 404,
539
+ body: structuredError("HOTLINE_NOT_FOUND", "no catalog hotline matched the requested hotlineId", {
540
+ hotline_id: hotlineId,
541
+ responder_id: responderId
542
+ })
543
+ };
544
+ }
545
+ return {
546
+ status: 200,
547
+ body: selected
548
+ };
549
+ }
550
+
551
+ function loadHotlineDraft(hotlineId) {
552
+ const draftFile = getHotlineRegistrationDraftFile(hotlineId);
553
+ return {
554
+ draft_file: draftFile,
555
+ draft: readJsonFile(draftFile, null)
556
+ };
557
+ }
558
+
559
+ function buildReadHotlineResponse(selected, draftInfo) {
560
+ const draft = draftInfo.draft || {};
561
+ const localOnly = selected.source === "local" || !selected.review_status || selected.review_status === "local_only";
562
+ return {
563
+ hotline_id: selected.hotline_id,
564
+ responder_id: selected.responder_id,
565
+ display_name: draft.display_name || selected.display_name || selected.hotline_id,
566
+ description: draft.description || selected.description || null,
567
+ input_summary: draft.input_summary || draft.summary || null,
568
+ output_summary: draft.output_summary || null,
569
+ input_schema: draft.input_schema || null,
570
+ output_schema: draft.output_schema || null,
571
+ draft_ready: Boolean(draftInfo.draft),
572
+ draft_file: draftInfo.draft_file,
573
+ local_only: localOnly,
574
+ review_status: selected.review_status || "local_only",
575
+ task_types: draft.task_types || selected.task_types || [],
576
+ output_display_hints: displayHintsMap.get(selected.hotline_id) ?? null
577
+ };
578
+ }
579
+
580
+ function normalizeValueBySchema(schema, value, field, errors, warnings) {
581
+ if (!schema || typeof schema !== "object") {
582
+ return value;
583
+ }
584
+ const type = schema.type;
585
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
586
+ errors.push({
587
+ field,
588
+ code: "INVALID_ENUM_VALUE",
589
+ message: `${field} must be one of: ${schema.enum.join(", ")}`
590
+ });
591
+ return value;
592
+ }
593
+ if (!type) {
594
+ return value;
595
+ }
596
+ switch (type) {
597
+ case "string": {
598
+ if (typeof value !== "string") {
599
+ errors.push({
600
+ field,
601
+ code: "INVALID_TYPE",
602
+ message: `${field} must be a string`
603
+ });
604
+ return value;
605
+ }
606
+ const trimmed = value.trim();
607
+ if (value !== trimmed) {
608
+ warnings.push({
609
+ field,
610
+ code: "STRING_TRIMMED",
611
+ message: `${field} was trimmed`
612
+ });
613
+ }
614
+ return trimmed;
615
+ }
616
+ case "number":
617
+ if (typeof value !== "number" || !Number.isFinite(value)) {
618
+ errors.push({
619
+ field,
620
+ code: "INVALID_TYPE",
621
+ message: `${field} must be a number`
622
+ });
623
+ }
624
+ return value;
625
+ case "integer":
626
+ if (!Number.isInteger(value)) {
627
+ errors.push({
628
+ field,
629
+ code: "INVALID_TYPE",
630
+ message: `${field} must be an integer`
631
+ });
632
+ }
633
+ return value;
634
+ case "boolean":
635
+ if (typeof value !== "boolean") {
636
+ errors.push({
637
+ field,
638
+ code: "INVALID_TYPE",
639
+ message: `${field} must be a boolean`
640
+ });
641
+ }
642
+ return value;
643
+ case "array":
644
+ if (!Array.isArray(value)) {
645
+ errors.push({
646
+ field,
647
+ code: "INVALID_TYPE",
648
+ message: `${field} must be an array`
649
+ });
650
+ }
651
+ return value;
652
+ case "object":
653
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
654
+ errors.push({
655
+ field,
656
+ code: "INVALID_TYPE",
657
+ message: `${field} must be an object`
658
+ });
659
+ }
660
+ return value;
661
+ default:
662
+ return value;
663
+ }
664
+ }
665
+
666
+ function validatePreparedInput(inputSchema, candidateInput) {
667
+ const errors = [];
668
+ const warnings = [];
669
+ if (!inputSchema || typeof inputSchema !== "object" || inputSchema.type !== "object") {
670
+ return {
671
+ normalized_input: candidateInput || {},
672
+ errors: [
673
+ {
674
+ field: null,
675
+ code: "HOTLINE_INPUT_SCHEMA_UNSUPPORTED",
676
+ message: "hotline input schema must be an object schema"
677
+ }
678
+ ],
679
+ warnings
680
+ };
681
+ }
682
+ const input = candidateInput && typeof candidateInput === "object" && !Array.isArray(candidateInput) ? candidateInput : {};
683
+ const properties = inputSchema.properties && typeof inputSchema.properties === "object" ? inputSchema.properties : {};
684
+ const required = Array.isArray(inputSchema.required) ? inputSchema.required : [];
685
+ const additionalProperties = inputSchema.additionalProperties;
686
+ const normalized = {};
687
+
688
+ for (const [field, value] of Object.entries(input)) {
689
+ if (!Object.prototype.hasOwnProperty.call(properties, field)) {
690
+ if (additionalProperties === false) {
691
+ errors.push({
692
+ field,
693
+ code: "UNEXPECTED_FIELD",
694
+ message: `${field} is not allowed by the hotline input schema`
695
+ });
696
+ } else {
697
+ normalized[field] = value;
698
+ }
699
+ continue;
700
+ }
701
+ normalized[field] = normalizeValueBySchema(properties[field], value, field, errors, warnings);
702
+ }
703
+
704
+ for (const field of required) {
705
+ if (!Object.prototype.hasOwnProperty.call(normalized, field)) {
706
+ errors.push({
707
+ field,
708
+ code: "REQUIRED_FIELD_MISSING",
709
+ message: `${field} is required`
710
+ });
711
+ continue;
712
+ }
713
+ const def = properties[field];
714
+ if (def?.type === "string" && typeof normalized[field] === "string" && normalized[field].length === 0) {
715
+ errors.push({
716
+ field,
717
+ code: "EMPTY_STRING_NOT_ALLOWED",
718
+ message: `${field} must not be empty`
719
+ });
720
+ }
721
+ }
722
+
723
+ return {
724
+ normalized_input: normalized,
725
+ errors,
726
+ warnings
727
+ };
728
+ }
729
+
730
+ function buildPreparedRequestRecord({ hotlineId, selected, draft, normalizedInput, errors, warnings, agentSessionId }) {
731
+ const preparedRequestId = generatePreparedRequestId();
732
+ const status = errors.length > 0 ? "draft" : "ready";
733
+ const createdAt = nowIso();
734
+ return {
735
+ prepared_request_id: preparedRequestId,
736
+ hotline_id: hotlineId,
737
+ responder_id: selected.responder_id,
738
+ task_type: draft?.task_types?.[0] || selected.task_types?.[0] || null,
739
+ expected_signer_public_key_pem: selected.responder_public_key_pem || null,
740
+ output_schema: draft?.output_schema || null,
741
+ normalized_input: normalizedInput,
742
+ errors,
743
+ warnings,
744
+ review: {
745
+ required: false,
746
+ status: "not_required"
747
+ },
748
+ status,
749
+ request_id: null,
750
+ created_at: createdAt,
751
+ updated_at: createdAt,
752
+ expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
753
+ source_agent_session_id: agentSessionId
754
+ };
755
+ }
756
+
757
+ function preparedRequestExpired(record) {
758
+ return Boolean(record?.expires_at) && Date.parse(record.expires_at) <= Date.now();
759
+ }
760
+
761
+ async function buildRequestReport(requestId) {
762
+ const request = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}`);
763
+ if (request.status !== 200) {
764
+ return request;
765
+ }
766
+ const result = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/result`);
767
+ return {
768
+ status: 200,
769
+ body: mapRequestState(request.body, result.body)
770
+ };
771
+ }
772
+
773
+ export function createCallerSkillAdapterServer() {
774
+ return http.createServer(async (req, res) => {
775
+ const method = req.method || "GET";
776
+ const url = new URL(req.url || "/", "http://localhost");
777
+ const pathname = url.pathname;
778
+
779
+ try {
780
+ if (method === "OPTIONS") {
781
+ sendJson(res, 204, {});
782
+ return;
783
+ }
784
+
785
+ if (method === "GET" && pathname === "/healthz") {
786
+ sendJson(res, 200, { ok: true, service: "caller-skill-adapter" });
787
+ return;
788
+ }
789
+
790
+ if (method === "GET" && pathname === "/skills/caller/manifest") {
791
+ sendJson(res, 200, buildCallerSkillManifest());
792
+ return;
793
+ }
794
+
795
+ if (method === "POST" && pathname === "/skills/local-file/read") {
796
+ const body = await parseJsonBody(req);
797
+ const result = readLocalTextFile(normalizedString(body.path));
798
+ sendJson(res, result.status, result.body);
799
+ return;
800
+ }
801
+
802
+ if (method === "POST" && pathname === "/skills/caller/search-hotlines-brief") {
803
+ const body = await parseJsonBody(req);
804
+ const queryTerms = tokenizeSearchText(body.query);
805
+ const taskGoalTerms = tokenizeSearchText(body.task_goal || body.taskGoal);
806
+ const taskType = normalizedString(body.task_type || body.taskType);
807
+ const limit = Math.max(1, Math.min(Number(body.limit || 8), 25));
808
+ const catalog = await requestCatalogItems();
809
+ if (catalog.status !== 200) {
810
+ sendJson(res, catalog.status, catalog.body);
811
+ return;
812
+ }
813
+
814
+ const ranked = (catalog.body?.items || [])
815
+ .map((item) => {
816
+ const { score, matchReason } = computeCatalogMatch(item, queryTerms, taskGoalTerms, taskType);
817
+ return {
818
+ item: mapCatalogBriefItem(item, score, matchReason),
819
+ score
820
+ };
821
+ })
822
+ .filter((entry) => queryTerms.length === 0 && taskGoalTerms.length === 0 && !taskType ? true : entry.score > 0)
823
+ .sort((left, right) => right.score - left.score || left.item.hotline_id.localeCompare(right.item.hotline_id))
824
+ .slice(0, limit)
825
+ .map((entry) => entry.item);
826
+
827
+ sendJson(res, 200, { items: ranked });
828
+ return;
829
+ }
830
+
831
+ if (method === "POST" && pathname === "/skills/caller/search-hotlines-detailed") {
832
+ const body = await parseJsonBody(req);
833
+ const hotlineIds = Array.isArray(body.hotline_ids || body.hotlineIds)
834
+ ? (body.hotline_ids || body.hotlineIds).map((entry) => normalizedString(entry)).filter(Boolean)
835
+ : [];
836
+ if (hotlineIds.length === 0) {
837
+ sendJson(res, 400, structuredError("HOTLINE_IDS_REQUIRED", "hotline_ids must contain at least one hotline id"));
838
+ return;
839
+ }
840
+
841
+ const catalog = await requestCatalogItems();
842
+ if (catalog.status !== 200) {
843
+ sendJson(res, catalog.status, catalog.body);
844
+ return;
845
+ }
846
+
847
+ const items = hotlineIds
848
+ .map((hotlineId) => (catalog.body?.items || []).find((item) => item.hotline_id === hotlineId))
849
+ .filter(Boolean)
850
+ .map((item) => mapCatalogDetailedItem(item, loadHotlineDraft(item.hotline_id)));
851
+
852
+ sendJson(res, 200, { items });
853
+ return;
854
+ }
855
+
856
+ const readHotlineMatch = pathname.match(/^\/skills\/caller\/hotlines\/([^/]+)$/);
857
+ if (method === "GET" && readHotlineMatch) {
858
+ const hotlineId = decodeURIComponent(readHotlineMatch[1]);
859
+ const target = await resolveCatalogTarget(hotlineId);
860
+ if (target.status !== 200) {
861
+ sendJson(res, target.status, target.body);
862
+ return;
863
+ }
864
+ const draftInfo = loadHotlineDraft(hotlineId);
865
+ if (!draftInfo.draft) {
866
+ sendJson(res, 404, structuredError("HOTLINE_DRAFT_NOT_FOUND", "hotline registration draft was not found", {
867
+ hotline_id: hotlineId,
868
+ draft_file: draftInfo.draft_file
869
+ }));
870
+ return;
871
+ }
872
+ sendJson(res, 200, buildReadHotlineResponse(target.body, draftInfo));
873
+ return;
874
+ }
875
+
876
+ if (method === "POST" && pathname === "/skills/caller/prepare-request") {
877
+ const body = await parseJsonBody(req);
878
+ const hotlineId = normalizedString(body.hotline_id || body.hotlineId);
879
+ if (!hotlineId) {
880
+ sendJson(res, 400, structuredError("HOTLINE_ID_REQUIRED", "hotline_id is required"));
881
+ return;
882
+ }
883
+ const target = await resolveCatalogTarget(hotlineId, normalizedString(body.responder_id || body.responderId));
884
+ if (target.status !== 200) {
885
+ sendJson(res, target.status, target.body);
886
+ return;
887
+ }
888
+ const draftInfo = loadHotlineDraft(hotlineId);
889
+ if (!draftInfo.draft) {
890
+ sendJson(res, 404, structuredError("HOTLINE_DRAFT_NOT_FOUND", "hotline registration draft was not found", {
891
+ hotline_id: hotlineId,
892
+ draft_file: draftInfo.draft_file
893
+ }));
894
+ return;
895
+ }
896
+
897
+ const agentSessionId = normalizedString(body.agent_session_id || body.agentSessionId);
898
+ invalidatePriorPreparedRequests(hotlineId, agentSessionId);
899
+
900
+ const validation = validatePreparedInput(draftInfo.draft.input_schema, body.input);
901
+ const record = buildPreparedRequestRecord({
902
+ hotlineId,
903
+ selected: target.body,
904
+ draft: draftInfo.draft,
905
+ normalizedInput: validation.normalized_input,
906
+ errors: validation.errors,
907
+ warnings: validation.warnings,
908
+ agentSessionId
909
+ });
910
+ savePreparedRequest(record);
911
+
912
+ sendJson(res, 200, {
913
+ prepared_request_id: record.prepared_request_id,
914
+ hotline_id: record.hotline_id,
915
+ status: record.status,
916
+ normalized_input: record.normalized_input,
917
+ errors: record.errors,
918
+ warnings: record.warnings,
919
+ review: record.review,
920
+ expires_at: record.expires_at
921
+ });
922
+ return;
923
+ }
924
+
925
+ if (method === "POST" && pathname === "/skills/caller/send-request") {
926
+ const body = await parseJsonBody(req);
927
+ const preparedRequestId = normalizedString(body.prepared_request_id || body.preparedRequestId);
928
+ if (!preparedRequestId) {
929
+ sendJson(res, 400, structuredError("PREPARED_REQUEST_ID_REQUIRED", "prepared_request_id is required"));
930
+ return;
931
+ }
932
+ const { filePath, record } = loadPreparedRequest(preparedRequestId);
933
+ if (!record) {
934
+ sendJson(res, 404, structuredError("PREPARED_REQUEST_NOT_FOUND", "prepared request was not found", {
935
+ prepared_request_id: preparedRequestId
936
+ }));
937
+ return;
938
+ }
939
+ if (preparedRequestExpired(record)) {
940
+ record.status = "expired";
941
+ record.updated_at = nowIso();
942
+ writeJsonFile(filePath, record);
943
+ sendJson(res, 409, structuredError("PREPARED_REQUEST_EXPIRED", "prepared request has expired", {
944
+ prepared_request_id: preparedRequestId
945
+ }));
946
+ return;
947
+ }
948
+ if (record.status !== "ready") {
949
+ sendJson(res, 409, structuredError("PREPARED_REQUEST_NOT_READY", "prepared request is not ready to send", {
950
+ prepared_request_id: preparedRequestId,
951
+ status: record.status,
952
+ errors: record.errors || []
953
+ }));
954
+ return;
955
+ }
956
+
957
+ const createBody = {
958
+ responder_id: record.responder_id,
959
+ hotline_id: record.hotline_id,
960
+ expected_signer_public_key_pem: record.expected_signer_public_key_pem,
961
+ task_type: record.task_type,
962
+ input: record.normalized_input,
963
+ payload: record.normalized_input,
964
+ output_schema: record.output_schema
965
+ };
966
+
967
+ const created = await requestJson(callerBaseUrl(), "/controller/requests", {
968
+ method: "POST",
969
+ body: createBody
970
+ });
971
+ if (created.status !== 201) {
972
+ sendJson(res, created.status, created.body);
973
+ return;
974
+ }
975
+
976
+ const requestId = created.body?.request_id;
977
+ await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/contract-draft`, {
978
+ method: "POST",
979
+ body: {
980
+ ...createBody,
981
+ task_input: record.normalized_input
982
+ }
983
+ });
984
+
985
+ const dispatched = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/dispatch`, {
986
+ method: "POST",
987
+ body: {
988
+ ...createBody,
989
+ task_input: record.normalized_input
990
+ }
991
+ });
992
+ if (![200, 202].includes(dispatched.status)) {
993
+ sendJson(res, dispatched.status, dispatched.body);
994
+ return;
995
+ }
996
+
997
+ record.status = "sent";
998
+ record.request_id = requestId;
999
+ record.updated_at = nowIso();
1000
+ writeJsonFile(filePath, record);
1001
+
1002
+ const wait = body.wait !== false;
1003
+ if (!wait) {
1004
+ sendJson(res, 202, {
1005
+ request_id: requestId,
1006
+ hotline_id: record.hotline_id,
1007
+ status: "PENDING"
1008
+ });
1009
+ return;
1010
+ }
1011
+
1012
+ const terminal = await waitForTerminalRequest(requestId);
1013
+ sendJson(res, terminal.status, terminal.body);
1014
+ return;
1015
+ }
1016
+
1017
+ const reportMatch = pathname.match(/^\/skills\/caller\/requests\/([^/]+)\/report$/);
1018
+ if (method === "GET" && reportMatch) {
1019
+ const requestId = decodeURIComponent(reportMatch[1]);
1020
+ const report = await buildRequestReport(requestId);
1021
+ sendJson(res, report.status, report.body);
1022
+ return;
1023
+ }
1024
+
1025
+ sendJson(res, 404, structuredError("NOT_FOUND", "unknown route"));
1026
+ } catch (error) {
1027
+ sendJson(
1028
+ res,
1029
+ 500,
1030
+ structuredError("SKILL_ADAPTER_RUNTIME_ERROR", error instanceof Error ? error.message : "unknown_error")
1031
+ );
1032
+ }
1033
+ });
1034
+ }
1035
+
1036
+ if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
1037
+ const port = Number(process.env.PORT || 8091);
1038
+ const server = createCallerSkillAdapterServer();
1039
+ server.listen(port, "0.0.0.0", () => {
1040
+ console.log(`[caller-skill-adapter] listening on ${port}`);
1041
+ });
1042
+ }