bosun 0.40.21 → 0.41.1
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/.env.example +8 -0
- package/README.md +20 -0
- package/agent/agent-custom-tools.mjs +23 -5
- package/agent/agent-event-bus.mjs +248 -6
- package/agent/agent-pool.mjs +131 -30
- package/agent/agent-work-analyzer.mjs +8 -16
- package/agent/primary-agent.mjs +81 -7
- package/agent/retry-queue.mjs +164 -0
- package/bench/swebench/bosun-swebench.mjs +5 -0
- package/bosun.config.example.json +25 -0
- package/bosun.schema.json +825 -183
- package/cli.mjs +267 -8
- package/config/config-doctor.mjs +51 -2
- package/config/config.mjs +232 -5
- package/github/github-auth-manager.mjs +70 -19
- package/infra/library-manager.mjs +894 -60
- package/infra/monitor.mjs +701 -69
- package/infra/runtime-accumulator.mjs +376 -84
- package/infra/session-tracker.mjs +95 -28
- package/infra/test-runtime.mjs +267 -0
- package/lib/codebase-audit.mjs +133 -18
- package/package.json +30 -8
- package/server/setup-web-server.mjs +29 -1
- package/server/ui-server.mjs +1571 -49
- package/setup.mjs +27 -24
- package/shell/codex-shell.mjs +34 -3
- package/shell/copilot-shell.mjs +50 -8
- package/task/msg-hub.mjs +193 -0
- package/task/pipeline.mjs +544 -0
- package/task/task-claims.mjs +6 -10
- package/task/task-cli.mjs +38 -2
- package/task/task-executor-pipeline.mjs +143 -0
- package/task/task-executor.mjs +36 -27
- package/telegram/get-telegram-chat-id.mjs +57 -47
- package/ui/components/chat-view.js +18 -1
- package/ui/components/workspace-switcher.js +321 -9
- package/ui/demo-defaults.js +17830 -10433
- package/ui/demo.html +9 -1
- package/ui/modules/router.js +1 -1
- package/ui/modules/settings-schema.js +2 -0
- package/ui/modules/state.js +54 -57
- package/ui/modules/voice-client-sdk.js +376 -37
- package/ui/modules/voice-client.js +173 -33
- package/ui/setup.html +68 -2
- package/ui/styles/components.css +571 -1
- package/ui/styles.css +201 -1
- package/ui/tabs/dashboard.js +74 -0
- package/ui/tabs/library.js +410 -55
- package/ui/tabs/logs.js +10 -0
- package/ui/tabs/settings.js +178 -99
- package/ui/tabs/tasks.js +1083 -507
- package/ui/tabs/telemetry.js +34 -0
- package/ui/tabs/workflow-canvas-utils.mjs +38 -1
- package/ui/tabs/workflows.js +1275 -402
- package/voice/voice-agents-sdk.mjs +2 -2
- package/voice/voice-relay.mjs +28 -20
- package/workflow/declarative-workflows.mjs +145 -0
- package/workflow/msg-hub.mjs +237 -0
- package/workflow/pipeline-workflows.mjs +287 -0
- package/workflow/pipeline.mjs +828 -315
- package/workflow/project-detection.mjs +559 -0
- package/workflow/workflow-cli.mjs +128 -0
- package/workflow/workflow-contract.mjs +433 -232
- package/workflow/workflow-engine.mjs +510 -47
- package/workflow/workflow-nodes/custom-loader.mjs +251 -0
- package/workflow/workflow-nodes.mjs +2024 -184
- package/workflow/workflow-templates.mjs +118 -24
- package/workflow-templates/agents.mjs +20 -20
- package/workflow-templates/bosun-native.mjs +212 -2
- package/workflow-templates/code-quality.mjs +20 -14
- package/workflow-templates/continuation-loop.mjs +339 -0
- package/workflow-templates/github.mjs +516 -40
- package/workflow-templates/planning.mjs +446 -17
- package/workflow-templates/reliability.mjs +65 -12
- package/workflow-templates/task-batch.mjs +27 -10
- package/workflow-templates/task-execution.mjs +752 -0
- package/workflow-templates/task-lifecycle.mjs +117 -14
- package/workspace/context-cache.mjs +66 -18
- package/workspace/workspace-manager.mjs +153 -1
- package/workflow-templates/issue-continuation.mjs +0 -243
|
@@ -291,10 +291,10 @@ export async function createRealtimeSession(agent, provider, config = {}, option
|
|
|
291
291
|
turnDetection: {
|
|
292
292
|
type: turnDetection,
|
|
293
293
|
...(turnDetection === "server_vad"
|
|
294
|
-
? { threshold: 0.
|
|
294
|
+
? { threshold: 0.75, prefix_padding_ms: 500, silence_duration_ms: 1200 }
|
|
295
295
|
: {}),
|
|
296
296
|
...(turnDetection === "semantic_vad"
|
|
297
|
-
? { eagerness: "
|
|
297
|
+
? { eagerness: "low" }
|
|
298
298
|
: {}),
|
|
299
299
|
},
|
|
300
300
|
},
|
package/voice/voice-relay.mjs
CHANGED
|
@@ -324,6 +324,7 @@ function sanitizeVoiceCallContext(context = {}) {
|
|
|
324
324
|
const rawEnabledMcpServers = Array.isArray(context?.enabledMcpServers)
|
|
325
325
|
? context.enabledMcpServers.map((s) => String(s || "").trim()).filter(Boolean)
|
|
326
326
|
: [];
|
|
327
|
+
const rawVoiceAgentSkillsContent = String(context?.voiceAgentSkillsContent || "").trim();
|
|
327
328
|
|
|
328
329
|
return {
|
|
329
330
|
sessionId: rawSessionId || null,
|
|
@@ -335,6 +336,7 @@ function sanitizeVoiceCallContext(context = {}) {
|
|
|
335
336
|
voiceAgentInstructions: rawVoiceAgentInstructions || null,
|
|
336
337
|
voiceToolCapabilityPrompt: rawVoiceToolCapabilityPrompt || null,
|
|
337
338
|
voiceAgentSkills: rawVoiceAgentSkills,
|
|
339
|
+
voiceAgentSkillsContent: rawVoiceAgentSkillsContent || null,
|
|
338
340
|
enabledMcpServers: rawEnabledMcpServers,
|
|
339
341
|
};
|
|
340
342
|
}
|
|
@@ -410,6 +412,14 @@ async function buildSessionScopedInstructions(baseInstructions, callContext = {}
|
|
|
410
412
|
}
|
|
411
413
|
}
|
|
412
414
|
|
|
415
|
+
// ── Prepend voice agent identity as primary instructions ─────────────
|
|
416
|
+
// voiceInstructions defines who the agent IS (e.g. "You are Nova").
|
|
417
|
+
// It must come BEFORE the base instructions so the model adopts the
|
|
418
|
+
// persona rather than falling back to its default identity.
|
|
419
|
+
if (context.voiceAgentInstructions) {
|
|
420
|
+
baseInstructions = `${context.voiceAgentInstructions}\n\n${baseInstructions}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
413
423
|
const suffix = [
|
|
414
424
|
"",
|
|
415
425
|
"## Bosun Voice Call Context",
|
|
@@ -420,16 +430,15 @@ async function buildSessionScopedInstructions(baseInstructions, callContext = {}
|
|
|
420
430
|
context.voiceAgentName
|
|
421
431
|
? `Active voice agent name: ${context.voiceAgentName}.`
|
|
422
432
|
: "",
|
|
423
|
-
context.voiceAgentInstructions
|
|
424
|
-
? `Voice agent instruction emphasis: ${context.voiceAgentInstructions}`
|
|
425
|
-
: "",
|
|
426
433
|
context.voiceToolCapabilityPrompt || "",
|
|
427
434
|
context.enabledMcpServers?.length
|
|
428
435
|
? `Enabled MCP servers for this session: ${context.enabledMcpServers.join(", ")}.`
|
|
429
436
|
: "",
|
|
430
|
-
context.
|
|
431
|
-
?
|
|
432
|
-
:
|
|
437
|
+
context.voiceAgentSkillsContent
|
|
438
|
+
? `## Voice Agent Skills\n${context.voiceAgentSkillsContent}`
|
|
439
|
+
: context.voiceAgentSkills?.length
|
|
440
|
+
? `Voice agent skills: ${context.voiceAgentSkills.join(", ")}.`
|
|
441
|
+
: "",
|
|
433
442
|
context.executor
|
|
434
443
|
? `Preferred executor for delegated work: ${context.executor}.`
|
|
435
444
|
: "Preferred executor for delegated work: use configured default.",
|
|
@@ -948,6 +957,7 @@ export function getVoiceConfig(forceReload = false) {
|
|
|
948
957
|
);
|
|
949
958
|
|
|
950
959
|
const instructions = voice.instructions || `You are Bosun, a helpful voice assistant for the VirtEngine development platform.
|
|
960
|
+
You are NOT ChatGPT — never identify yourself as ChatGPT or any other AI assistant. Your name is Bosun.
|
|
951
961
|
You help developers manage tasks, steer coding agents, monitor builds, and navigate the workspace.
|
|
952
962
|
Be concise and conversational. When users ask about code or tasks, use the available tools.
|
|
953
963
|
For complex operations like writing code or creating PRs, delegate to the appropriate agent.
|
|
@@ -1226,16 +1236,16 @@ async function createOpenAIEphemeralToken(cfg, toolDefinitions = [], callContext
|
|
|
1226
1236
|
turn_detection: {
|
|
1227
1237
|
type: cfg.turnDetection,
|
|
1228
1238
|
...(cfg.turnDetection === "server_vad" ? {
|
|
1229
|
-
threshold: 0.
|
|
1230
|
-
prefix_padding_ms:
|
|
1231
|
-
silence_duration_ms:
|
|
1239
|
+
threshold: 0.75,
|
|
1240
|
+
prefix_padding_ms: 500,
|
|
1241
|
+
silence_duration_ms: 1800,
|
|
1232
1242
|
create_response: true,
|
|
1233
|
-
interrupt_response:
|
|
1243
|
+
interrupt_response: false,
|
|
1234
1244
|
} : {}),
|
|
1235
1245
|
...(cfg.turnDetection === "semantic_vad" ? {
|
|
1236
|
-
eagerness: "
|
|
1246
|
+
eagerness: "low",
|
|
1237
1247
|
create_response: true,
|
|
1238
|
-
interrupt_response:
|
|
1248
|
+
interrupt_response: false,
|
|
1239
1249
|
} : {}),
|
|
1240
1250
|
},
|
|
1241
1251
|
...(transcriptionEnabled ? { input_audio_transcription: { model: transcriptionModel } } : {}),
|
|
@@ -1314,8 +1324,6 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
|
|
|
1314
1324
|
}
|
|
1315
1325
|
|
|
1316
1326
|
const sessionConfig = {
|
|
1317
|
-
// GA protocol (gpt-realtime-1.5 etc.) requires type: "realtime" in the POST body.
|
|
1318
|
-
// Preview protocol does not support this field — omit it to avoid 400s.
|
|
1319
1327
|
...(isAzureGaProtocol(deployment) ? { type: "realtime" } : {}),
|
|
1320
1328
|
model: deployment,
|
|
1321
1329
|
voice: voiceId,
|
|
@@ -1327,16 +1335,16 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
|
|
|
1327
1335
|
turn_detection: {
|
|
1328
1336
|
type: cfg.turnDetection,
|
|
1329
1337
|
...(cfg.turnDetection === "server_vad" ? {
|
|
1330
|
-
threshold: 0.
|
|
1331
|
-
prefix_padding_ms:
|
|
1332
|
-
silence_duration_ms:
|
|
1338
|
+
threshold: 0.75,
|
|
1339
|
+
prefix_padding_ms: 500,
|
|
1340
|
+
silence_duration_ms: 1800,
|
|
1333
1341
|
create_response: true,
|
|
1334
|
-
interrupt_response:
|
|
1342
|
+
interrupt_response: false,
|
|
1335
1343
|
} : {}),
|
|
1336
1344
|
...(cfg.turnDetection === "semantic_vad" ? {
|
|
1337
|
-
eagerness: "
|
|
1345
|
+
eagerness: "low",
|
|
1338
1346
|
create_response: true,
|
|
1339
|
-
interrupt_response:
|
|
1347
|
+
interrupt_response: false,
|
|
1340
1348
|
} : {}),
|
|
1341
1349
|
},
|
|
1342
1350
|
...(transcriptionEnabled ? { input_audio_transcription: { model: transcriptionModel } } : {}),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { execWithRetry, getPoolSdkName } from "../agent/agent-pool.mjs";
|
|
4
|
+
import { loadConfig } from "../config/config.mjs";
|
|
5
|
+
import {
|
|
6
|
+
BUILTIN_WORKFLOWS,
|
|
7
|
+
createConfiguredPipeline,
|
|
8
|
+
listWorkflowDefinitions,
|
|
9
|
+
resolveWorkflowDefinition,
|
|
10
|
+
runConfiguredWorkflow as runPipelineConfiguredWorkflow,
|
|
11
|
+
} from "./pipeline.mjs";
|
|
12
|
+
|
|
13
|
+
function normalizeWorkflowDefinition(name, definition = {}, config = {}) {
|
|
14
|
+
const resolved = resolveWorkflowDefinition(name, {
|
|
15
|
+
...(config.workflows || {}),
|
|
16
|
+
[name]: definition,
|
|
17
|
+
});
|
|
18
|
+
return resolved?.definition || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listConfiguredWorkflows(config = {}) {
|
|
22
|
+
return listWorkflowDefinitions(config.workflows || {}).map((entry) => {
|
|
23
|
+
const definition = entry?.definition || {};
|
|
24
|
+
const rawAgents = Array.isArray(definition.stages) && definition.stages.length > 0
|
|
25
|
+
? definition.stages
|
|
26
|
+
: Array.isArray(definition.agents)
|
|
27
|
+
? definition.agents
|
|
28
|
+
: [];
|
|
29
|
+
return {
|
|
30
|
+
id: definition.id || entry?.name,
|
|
31
|
+
name: definition.name || entry?.name,
|
|
32
|
+
type: definition.type || "sequential",
|
|
33
|
+
description: definition.description || "",
|
|
34
|
+
source: entry?.source || "unknown",
|
|
35
|
+
agents: rawAgents.map((agent, index) => {
|
|
36
|
+
if (typeof agent === "string") {
|
|
37
|
+
return { id: agent, name: agent, role: agent, sdk: null };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
id: agent?.id || agent?.name || `agent-${index + 1}`,
|
|
41
|
+
name: agent?.name || agent?.id || `agent-${index + 1}`,
|
|
42
|
+
role: agent?.stage || agent?.role || null,
|
|
43
|
+
sdk: agent?.sdk || agent?.executor || null,
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildConsensus(result) {
|
|
51
|
+
const votes = new Map();
|
|
52
|
+
for (const output of result.outputs || []) {
|
|
53
|
+
const text = String(output.summary || output.output || "").trim();
|
|
54
|
+
if (!text) continue;
|
|
55
|
+
votes.set(text, (votes.get(text) || 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
let winner = null;
|
|
58
|
+
for (const [text, count] of votes.entries()) {
|
|
59
|
+
if (!winner || count > winner.count) winner = { text, count };
|
|
60
|
+
}
|
|
61
|
+
return winner;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createWorkflowAgentPool(options = {}) {
|
|
65
|
+
if (options.dryRun === true) {
|
|
66
|
+
return {
|
|
67
|
+
getPoolSdkName,
|
|
68
|
+
async execWithRetry(prompt, execOptions = {}) {
|
|
69
|
+
const sdk = execOptions.sdk || getPoolSdkName() || "codex";
|
|
70
|
+
return {
|
|
71
|
+
success: true,
|
|
72
|
+
output: `[${sdk}] ${String(prompt).split("\n")[0]}`,
|
|
73
|
+
sdk,
|
|
74
|
+
attempts: 1,
|
|
75
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { execWithRetry, getPoolSdkName };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runConfiguredWorkflow(name, input, options = {}) {
|
|
84
|
+
const config = options.config || loadConfig();
|
|
85
|
+
const result = await runPipelineConfiguredWorkflow(name, input, {
|
|
86
|
+
workflows: config.workflows || {},
|
|
87
|
+
services: {
|
|
88
|
+
agentPool: options.services?.agentPool || createWorkflowAgentPool(options),
|
|
89
|
+
},
|
|
90
|
+
runOptions: options.runOptions || {},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const resolved = resolveWorkflowDefinition(name, config.workflows || {});
|
|
94
|
+
const workflow = resolved?.definition || { id: name, name, type: "sequential" };
|
|
95
|
+
const status = result.success ? "success" : "failed";
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
status,
|
|
99
|
+
workflow: {
|
|
100
|
+
id: workflow.id || name,
|
|
101
|
+
name: workflow.name || name,
|
|
102
|
+
type: workflow.type || "sequential",
|
|
103
|
+
},
|
|
104
|
+
consensus:
|
|
105
|
+
String(workflow.id || name) === "consensus-vote" ? buildConsensus(result) : undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadWorkflowInputFromFile(pathValue) {
|
|
110
|
+
const fullPath = resolve(pathValue);
|
|
111
|
+
const raw = readFileSync(fullPath, "utf8");
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(raw);
|
|
114
|
+
} catch {
|
|
115
|
+
return raw;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createWorkflowInstance(definition, options = {}) {
|
|
120
|
+
return createConfiguredPipeline(definition, {
|
|
121
|
+
services: {
|
|
122
|
+
agentPool: options.services?.agentPool || createWorkflowAgentPool(options),
|
|
123
|
+
},
|
|
124
|
+
hub: options.hub,
|
|
125
|
+
createHub: options.createHub,
|
|
126
|
+
hubOptions: options.hubOptions,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
BUILTIN_WORKFLOWS,
|
|
132
|
+
normalizeWorkflowDefinition,
|
|
133
|
+
listConfiguredWorkflows,
|
|
134
|
+
loadWorkflowInputFromFile,
|
|
135
|
+
createWorkflowInstance,
|
|
136
|
+
runConfiguredWorkflow,
|
|
137
|
+
};
|
|
138
|
+
export default {
|
|
139
|
+
BUILTIN_WORKFLOWS,
|
|
140
|
+
normalizeWorkflowDefinition,
|
|
141
|
+
listConfiguredWorkflows,
|
|
142
|
+
loadWorkflowInputFromFile,
|
|
143
|
+
createWorkflowInstance,
|
|
144
|
+
runConfiguredWorkflow,
|
|
145
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module msg-hub
|
|
3
|
+
* @description Lightweight reference-passing message hub for active agents.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HISTORY_LIMIT = 100;
|
|
10
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 4096;
|
|
11
|
+
const DISALLOWED_KEYS = new Set([
|
|
12
|
+
"context",
|
|
13
|
+
"conversation",
|
|
14
|
+
"history",
|
|
15
|
+
"messages",
|
|
16
|
+
"raw",
|
|
17
|
+
"transcript",
|
|
18
|
+
]);
|
|
19
|
+
const ALLOWED_KEYS = [
|
|
20
|
+
"kind",
|
|
21
|
+
"taskId",
|
|
22
|
+
"taskIds",
|
|
23
|
+
"workflowId",
|
|
24
|
+
"runId",
|
|
25
|
+
"branch",
|
|
26
|
+
"filePaths",
|
|
27
|
+
"paths",
|
|
28
|
+
"summary",
|
|
29
|
+
"metadata",
|
|
30
|
+
"source",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function truncateText(value, limit = 320) {
|
|
34
|
+
const text = String(value ?? "").trim();
|
|
35
|
+
if (!text) return "";
|
|
36
|
+
if (text.length <= limit) return text;
|
|
37
|
+
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeParticipant(participant) {
|
|
41
|
+
if (typeof participant === "string") {
|
|
42
|
+
return {
|
|
43
|
+
id: participant,
|
|
44
|
+
name: participant,
|
|
45
|
+
onMessage: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (!participant || typeof participant !== "object") {
|
|
49
|
+
throw new TypeError("MsgHub participant must be a string or object.");
|
|
50
|
+
}
|
|
51
|
+
const id = String(participant.id || participant.name || randomUUID()).trim();
|
|
52
|
+
if (!id) throw new Error("MsgHub participant must have an id or name.");
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name: String(participant.name || id),
|
|
56
|
+
onMessage:
|
|
57
|
+
typeof participant.onMessage === "function"
|
|
58
|
+
? participant.onMessage.bind(participant)
|
|
59
|
+
: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sanitizeReference(message, maxBytes) {
|
|
64
|
+
const raw =
|
|
65
|
+
message && typeof message === "object" && !Array.isArray(message)
|
|
66
|
+
? message
|
|
67
|
+
: { summary: String(message ?? "") };
|
|
68
|
+
const ref = {};
|
|
69
|
+
|
|
70
|
+
for (const key of ALLOWED_KEYS) {
|
|
71
|
+
if (raw[key] == null) continue;
|
|
72
|
+
if (key === "summary") {
|
|
73
|
+
ref.summary = truncateText(raw.summary);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (key === "filePaths" || key === "paths") {
|
|
77
|
+
const values = Array.isArray(raw[key])
|
|
78
|
+
? raw[key].map((entry) => String(entry || "").trim()).filter(Boolean)
|
|
79
|
+
: [];
|
|
80
|
+
if (values.length > 0) ref[key] = [...new Set(values)];
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
ref[key] = raw[key];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
87
|
+
if (key.startsWith("_")) continue;
|
|
88
|
+
if (DISALLOWED_KEYS.has(key) || ALLOWED_KEYS.includes(key)) continue;
|
|
89
|
+
if (value == null) continue;
|
|
90
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
91
|
+
if (!ref.metadata || typeof ref.metadata !== "object") ref.metadata = {};
|
|
92
|
+
ref.metadata[key] = typeof value === "string" ? truncateText(value, 120) : value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let serialized = JSON.stringify(ref);
|
|
97
|
+
if (Buffer.byteLength(serialized, "utf8") <= maxBytes) {
|
|
98
|
+
return ref;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (ref.metadata && typeof ref.metadata === "object") {
|
|
102
|
+
ref.metadata = { note: "trimmed" };
|
|
103
|
+
}
|
|
104
|
+
if (ref.summary) {
|
|
105
|
+
ref.summary = truncateText(ref.summary, 160);
|
|
106
|
+
}
|
|
107
|
+
serialized = JSON.stringify(ref);
|
|
108
|
+
if (Buffer.byteLength(serialized, "utf8") <= maxBytes) {
|
|
109
|
+
return ref;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
kind: ref.kind || "reference",
|
|
114
|
+
taskId: ref.taskId || null,
|
|
115
|
+
branch: ref.branch || null,
|
|
116
|
+
summary: truncateText(ref.summary || "reference trimmed", 96),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class MsgHub extends EventEmitter {
|
|
121
|
+
static async create(participants = [], options = {}) {
|
|
122
|
+
return new MsgHub(participants, options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
constructor(participants = [], options = {}) {
|
|
126
|
+
super();
|
|
127
|
+
this.options = {
|
|
128
|
+
autoBroadcast: options.autoBroadcast !== false,
|
|
129
|
+
historyLimit: Number(options.historyLimit || DEFAULT_HISTORY_LIMIT),
|
|
130
|
+
maxMessageBytes: Number(options.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES),
|
|
131
|
+
};
|
|
132
|
+
this._participants = new Map();
|
|
133
|
+
this._history = [];
|
|
134
|
+
this._closed = false;
|
|
135
|
+
for (const participant of participants) {
|
|
136
|
+
this.add(participant);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
add(participant) {
|
|
141
|
+
if (this._closed) throw new Error("MsgHub is closed.");
|
|
142
|
+
const normalized = normalizeParticipant(participant);
|
|
143
|
+
this._participants.set(normalized.id, normalized);
|
|
144
|
+
this.emit("participant:added", {
|
|
145
|
+
participantId: normalized.id,
|
|
146
|
+
participantName: normalized.name,
|
|
147
|
+
size: this._participants.size,
|
|
148
|
+
});
|
|
149
|
+
return normalized;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
remove(participantOrId) {
|
|
153
|
+
const id = typeof participantOrId === "string"
|
|
154
|
+
? participantOrId
|
|
155
|
+
: participantOrId?.id || participantOrId?.name || "";
|
|
156
|
+
const removed = this._participants.delete(String(id || "").trim());
|
|
157
|
+
if (removed) {
|
|
158
|
+
this.emit("participant:removed", {
|
|
159
|
+
participantId: String(id || "").trim(),
|
|
160
|
+
size: this._participants.size,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return removed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
listParticipants() {
|
|
167
|
+
return [...this._participants.values()].map((participant) => ({
|
|
168
|
+
id: participant.id,
|
|
169
|
+
name: participant.name,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get history() {
|
|
174
|
+
return this._history.map((entry) => ({ ...entry }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
subscribe(listener) {
|
|
178
|
+
this.on("message", listener);
|
|
179
|
+
return () => this.off("message", listener);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async publish(from, message, options = {}) {
|
|
183
|
+
if (this._closed) throw new Error("MsgHub is closed.");
|
|
184
|
+
const senderId = typeof from === "string"
|
|
185
|
+
? String(from).trim()
|
|
186
|
+
: String(from?.id || from?.name || "").trim();
|
|
187
|
+
if (senderId && !this._participants.has(senderId)) {
|
|
188
|
+
this.add(senderId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const to = Array.isArray(options.to)
|
|
192
|
+
? options.to.map((entry) => String(entry || "").trim()).filter(Boolean)
|
|
193
|
+
: null;
|
|
194
|
+
const recipients = to || [...this._participants.keys()].filter((id) => id !== senderId);
|
|
195
|
+
const envelope = {
|
|
196
|
+
id: randomUUID(),
|
|
197
|
+
from: senderId || null,
|
|
198
|
+
to: recipients,
|
|
199
|
+
message: sanitizeReference(message, this.options.maxMessageBytes),
|
|
200
|
+
timestamp: new Date().toISOString(),
|
|
201
|
+
timestampMs: Date.now(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
this._history.push(envelope);
|
|
205
|
+
if (this._history.length > this.options.historyLimit) {
|
|
206
|
+
this._history.splice(0, this._history.length - this.options.historyLimit);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.emit("message", envelope);
|
|
210
|
+
if (this.options.autoBroadcast) {
|
|
211
|
+
for (const recipientId of recipients) {
|
|
212
|
+
const participant = this._participants.get(recipientId);
|
|
213
|
+
if (!participant?.onMessage) continue;
|
|
214
|
+
try {
|
|
215
|
+
await participant.onMessage(envelope, this);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.emit("delivery:error", {
|
|
218
|
+
participantId: recipientId,
|
|
219
|
+
error: String(error?.message || error),
|
|
220
|
+
envelope,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return envelope;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async close() {
|
|
229
|
+
if (this._closed) return;
|
|
230
|
+
this._closed = true;
|
|
231
|
+
this._participants.clear();
|
|
232
|
+
this.emit("closed", { closedAt: new Date().toISOString() });
|
|
233
|
+
this.removeAllListeners();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default MsgHub;
|