@triflux/core 10.50.2 → 10.51.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.
- package/hooks/agy-session-hook.mjs +11 -56
- package/hooks/codex-session-hook.mjs +11 -101
- package/hub/bridge.mjs +1 -79
- package/hub/cli-adapter-base.mjs +12 -90
- package/hub/codex-adapter.mjs +0 -1
- package/hub/codex-preflight.mjs +5 -28
- package/hub/index.mjs +1 -1
- package/hub/lib/known-errors.json +2 -2
- package/hub/lib/process-utils.mjs +0 -123
- package/hub/lib/spawn-trace.mjs +3 -2
- package/hub/lib/timeout-defaults.mjs +0 -1
- package/hub/role-contract.mjs +0 -2
- package/hud/cli-policy.mjs +0 -22
- package/hud/constants.mjs +11 -26
- package/hud/hud-qos-status.mjs +23 -51
- package/hud/providers/gemini.mjs +105 -20
- package/hud/renderers.mjs +50 -104
- package/hud/utils.mjs +2 -2
- package/package.json +1 -3
- package/scripts/lib/agent-route-policy.mjs +162 -14
- package/scripts/lib/claudemd-scanner.mjs +2 -199
- package/scripts/lib/cli-claude.mjs +0 -2
- package/scripts/lib/context.mjs +3 -19
- package/scripts/lib/env-probe.mjs +5 -64
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/session-presence.mjs +1 -1
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/dynamic-routing-engine.mjs +0 -511
- package/hub/lib/risk-tier.mjs +0 -53
- package/hub/lib/ssh-command.mjs +0 -231
- package/hub/lib/staged-file-detect.mjs +0 -43
- package/hub/lib/tfx-route-args.mjs +0 -353
- package/hub/routing-snapshot.mjs +0 -261
- package/mesh/index.mjs +0 -69
- package/mesh/mesh-budget.mjs +0 -137
- package/mesh/mesh-heartbeat.mjs +0 -100
- package/mesh/mesh-protocol.mjs +0 -96
- package/mesh/mesh-queue.mjs +0 -163
- package/mesh/mesh-registry.mjs +0 -78
- package/mesh/mesh-router.mjs +0 -77
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
package/hub/routing-snapshot.mjs
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
// hub/routing-snapshot.mjs
|
|
2
|
-
//
|
|
3
|
-
// PRD: .omx/plans/prd-dynamic-routing-engine.md
|
|
4
|
-
//
|
|
5
|
-
// Phase 0 — pure read-only context projector. Wraps HUD providers and the
|
|
6
|
-
// AccountBroker public snapshot into a single deterministic snapshot that
|
|
7
|
-
// `evaluateDynamicRoute(request, snapshot, policy)` consumes.
|
|
8
|
-
//
|
|
9
|
-
// Phase 0 contract:
|
|
10
|
-
// - no callers yet (dormant). Only `evaluateDynamicRoute()` is meant to read this.
|
|
11
|
-
// - lazy import of HUD providers + broker so this module imports cleanly even
|
|
12
|
-
// when those surfaces are absent in tests.
|
|
13
|
-
// - never mutates upstream state — strictly read-only.
|
|
14
|
-
// - all output fields are derivable from `publicSnapshot()` / HUD redacted API,
|
|
15
|
-
// so writing the resulting snapshot to disk does not leak credentials.
|
|
16
|
-
|
|
17
|
-
const CLAUDE_HUD_MODULE = "../hud/providers/claude.mjs";
|
|
18
|
-
const CODEX_HUD_MODULE = "../hud/providers/codex.mjs";
|
|
19
|
-
const BROKER_MODULE = "./account-broker.mjs";
|
|
20
|
-
|
|
21
|
-
async function loadHudClaude() {
|
|
22
|
-
try {
|
|
23
|
-
return await import(CLAUDE_HUD_MODULE);
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function loadHudCodex() {
|
|
30
|
-
try {
|
|
31
|
-
return await import(CODEX_HUD_MODULE);
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function loadBroker() {
|
|
38
|
-
try {
|
|
39
|
-
const mod = await import(BROKER_MODULE);
|
|
40
|
-
return mod.broker ?? mod.default ?? null;
|
|
41
|
-
} catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function normalizeClaudeUsage(raw, now) {
|
|
47
|
-
if (!raw || typeof raw !== "object") return null;
|
|
48
|
-
const data = raw.data ?? raw;
|
|
49
|
-
if (!data) return null;
|
|
50
|
-
return {
|
|
51
|
-
fiveHourPercent: numericOrNull(data.fiveHourPercent),
|
|
52
|
-
weeklyPercent: numericOrNull(data.weeklyPercent),
|
|
53
|
-
fiveHourResetsAt: toEpoch(data.fiveHourResetsAt),
|
|
54
|
-
weeklyResetsAt: toEpoch(data.weeklyResetsAt),
|
|
55
|
-
observedAt: toEpoch(data.observedAt) ?? now,
|
|
56
|
-
isStale: Boolean(raw.isStale ?? data.isStale),
|
|
57
|
-
source: data.source ?? "hud/providers/claude.mjs",
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function normalizeCodexUsage(raw, now) {
|
|
62
|
-
if (!raw || typeof raw !== "object") return null;
|
|
63
|
-
const buckets = raw.buckets ?? raw.data?.buckets ?? raw;
|
|
64
|
-
if (!buckets || typeof buckets !== "object") return null;
|
|
65
|
-
const entries = Object.entries(buckets);
|
|
66
|
-
if (entries.length === 0) return null;
|
|
67
|
-
const out = {};
|
|
68
|
-
for (const [id, bucket] of entries) {
|
|
69
|
-
if (!bucket || typeof bucket !== "object") continue;
|
|
70
|
-
out[id] = {
|
|
71
|
-
primary: normalizeCodexBucketLeg(bucket.primary),
|
|
72
|
-
secondary: normalizeCodexBucketLeg(bucket.secondary),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
buckets: out,
|
|
77
|
-
observedAt: toEpoch(raw.observedAt) ?? now,
|
|
78
|
-
isStale: Boolean(raw.shouldRefresh === false ? false : raw.isStale),
|
|
79
|
-
source: raw.source ?? "hud/providers/codex.mjs",
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function normalizeCodexBucketLeg(leg) {
|
|
84
|
-
if (!leg || typeof leg !== "object") return null;
|
|
85
|
-
return {
|
|
86
|
-
usedPercent: numericOrNull(leg.used_percent ?? leg.usedPercent),
|
|
87
|
-
resetsAt: toEpoch(leg.resets_at ?? leg.resetsAt),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function normalizeBrokerSnapshot(raw) {
|
|
92
|
-
if (!Array.isArray(raw)) return null;
|
|
93
|
-
const grouped = { codex: [], claude: [], gemini: [] };
|
|
94
|
-
for (const entry of raw) {
|
|
95
|
-
if (!entry || typeof entry !== "object") continue;
|
|
96
|
-
const provider = entry.provider;
|
|
97
|
-
if (!grouped[provider]) continue;
|
|
98
|
-
grouped[provider].push({
|
|
99
|
-
id: entry.id,
|
|
100
|
-
tier: entry.tier ?? "unknown",
|
|
101
|
-
busy: Boolean(entry.busy),
|
|
102
|
-
cooldownUntil: toEpoch(entry.cooldownUntil),
|
|
103
|
-
lastUsedAt: toEpoch(entry.lastUsedAt),
|
|
104
|
-
remainingMs: numericOrNull(entry.remainingMs),
|
|
105
|
-
circuitState: entry.circuitState ?? "closed",
|
|
106
|
-
failureCount: numericOrNull(entry.failureCount) ?? 0,
|
|
107
|
-
expiresAt: toEpoch(entry.expiresAt),
|
|
108
|
-
drainAfter: toEpoch(entry.drainAfter),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
return grouped;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function numericOrNull(v) {
|
|
115
|
-
if (v === null || v === undefined) return null;
|
|
116
|
-
const n = Number(v);
|
|
117
|
-
return Number.isFinite(n) ? n : null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function toEpoch(v) {
|
|
121
|
-
if (v === null || v === undefined) return null;
|
|
122
|
-
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
123
|
-
const parsed = Date.parse(v);
|
|
124
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function buildRoutingSnapshot({
|
|
128
|
-
forceRefresh = false,
|
|
129
|
-
now = Date.now(),
|
|
130
|
-
} = {}) {
|
|
131
|
-
const [hudClaude, hudCodex, broker] = await Promise.all([
|
|
132
|
-
loadHudClaude(),
|
|
133
|
-
loadHudCodex(),
|
|
134
|
-
loadBroker(),
|
|
135
|
-
]);
|
|
136
|
-
|
|
137
|
-
let claude = null;
|
|
138
|
-
if (hudClaude?.readClaudeUsageSnapshot) {
|
|
139
|
-
try {
|
|
140
|
-
claude = normalizeClaudeUsage(
|
|
141
|
-
hudClaude.readClaudeUsageSnapshot({ forceRefresh }),
|
|
142
|
-
now,
|
|
143
|
-
);
|
|
144
|
-
} catch (err) {
|
|
145
|
-
claude = {
|
|
146
|
-
error: String(err?.message ?? err),
|
|
147
|
-
observedAt: now,
|
|
148
|
-
isStale: true,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
let codex = null;
|
|
154
|
-
if (hudCodex?.readCodexRateLimitSnapshot) {
|
|
155
|
-
try {
|
|
156
|
-
codex = normalizeCodexUsage(
|
|
157
|
-
hudCodex.readCodexRateLimitSnapshot({ forceRefresh }),
|
|
158
|
-
now,
|
|
159
|
-
);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
codex = {
|
|
162
|
-
error: String(err?.message ?? err),
|
|
163
|
-
observedAt: now,
|
|
164
|
-
isStale: true,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let brokerState = null;
|
|
170
|
-
if (broker?.publicSnapshot) {
|
|
171
|
-
try {
|
|
172
|
-
brokerState = normalizeBrokerSnapshot(broker.publicSnapshot());
|
|
173
|
-
} catch (err) {
|
|
174
|
-
brokerState = { error: String(err?.message ?? err) };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
schemaVersion: 1,
|
|
180
|
-
observedAt: now,
|
|
181
|
-
claude,
|
|
182
|
-
codex,
|
|
183
|
-
broker: brokerState,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Phase 1 — in-process snapshot cache.
|
|
188
|
-
// 의도: caller (Phase 1+ conductor / swarm-hypervisor) 가 spawn loop 에서
|
|
189
|
-
// 매번 HUD/broker IO 를 트리거하지 않도록 정해진 TTL 안에서 같은 snapshot
|
|
190
|
-
// 을 재사용한다. cache 는 모듈-local 이라 process 단위 (테스트 격리 위해
|
|
191
|
-
// `resetSnapshotCache()` 노출).
|
|
192
|
-
|
|
193
|
-
const DEFAULT_CACHE_TTL_MS = 900_000; // policy freshness max 와 정합
|
|
194
|
-
|
|
195
|
-
let _cache = null; // { snapshot, fetchedAt }
|
|
196
|
-
|
|
197
|
-
export function resetSnapshotCache() {
|
|
198
|
-
_cache = null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Cached snapshot shortcut. cache TTL 안이면 재사용, 아니면 새로 빌드.
|
|
203
|
-
*
|
|
204
|
-
* @param {{
|
|
205
|
-
* forceRefresh?: boolean, // true 면 cache 무시하고 새로 빌드
|
|
206
|
-
* maxAgeMs?: number, // override cache TTL (default 900_000)
|
|
207
|
-
* now?: number, // pure-fn: caller 가 epoch 주입 (default Date.now())
|
|
208
|
-
* builder?: function, // 테스트용 builder 주입 (default buildRoutingSnapshot)
|
|
209
|
-
* }} [opts]
|
|
210
|
-
* @returns {Promise<object>}
|
|
211
|
-
*/
|
|
212
|
-
export async function getDefaultSnapshot(opts = {}) {
|
|
213
|
-
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
214
|
-
const maxAgeMs =
|
|
215
|
-
typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : DEFAULT_CACHE_TTL_MS;
|
|
216
|
-
const builder = opts.builder || buildRoutingSnapshot;
|
|
217
|
-
const forceRefresh = Boolean(opts.forceRefresh);
|
|
218
|
-
|
|
219
|
-
if (!forceRefresh && _cache && now - _cache.fetchedAt < maxAgeMs) {
|
|
220
|
-
return _cache.snapshot;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const snapshot = await builder({ forceRefresh, now });
|
|
224
|
-
_cache = { snapshot, fetchedAt: now };
|
|
225
|
-
return snapshot;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Synchronous cache shortcut. cache TTL 안에 fresh snapshot이 있으면 즉시 반환,
|
|
230
|
-
* 아니면 null. caller (sync hot path — e.g. conductor.spawnSession) 가 broker.lease
|
|
231
|
-
* atomic 인변량을 깨지 않고 dynamic routing decision 을 sync 로 평가할 수 있도록 한다.
|
|
232
|
-
* builder 호출(IO)은 하지 않는다 — cache miss 면 caller 가 fallback decision 사용.
|
|
233
|
-
*
|
|
234
|
-
* @param {{
|
|
235
|
-
* maxAgeMs?: number, // override cache TTL (default 900_000)
|
|
236
|
-
* now?: number, // pure-fn: caller 가 epoch 주입 (default Date.now())
|
|
237
|
-
* }} [opts]
|
|
238
|
-
* @returns {object|null} cache fresh이면 snapshot, 아니면 null
|
|
239
|
-
*/
|
|
240
|
-
export function tryGetCachedSnapshot(opts = {}) {
|
|
241
|
-
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
242
|
-
const maxAgeMs =
|
|
243
|
-
typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : DEFAULT_CACHE_TTL_MS;
|
|
244
|
-
if (_cache && now - _cache.fetchedAt < maxAgeMs) {
|
|
245
|
-
return _cache.snapshot;
|
|
246
|
-
}
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Test helpers — pure normalizers exposed so unit tests can build snapshots
|
|
251
|
-
// from mock provider responses without running real HUD I/O.
|
|
252
|
-
export const __internal__ = {
|
|
253
|
-
normalizeClaudeUsage,
|
|
254
|
-
normalizeCodexUsage,
|
|
255
|
-
normalizeBrokerSnapshot,
|
|
256
|
-
// Phase 1 cache 테스트 helpers
|
|
257
|
-
getCacheStateForTest: () => (_cache ? { ..._cache } : null),
|
|
258
|
-
setCacheForTest: (state) => {
|
|
259
|
-
_cache = state ? { ...state } : null;
|
|
260
|
-
},
|
|
261
|
-
};
|
package/mesh/index.mjs
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export { createMeshBudget } from "./mesh-budget.mjs";
|
|
5
|
-
export { createHeartbeatMonitor } from "./mesh-heartbeat.mjs";
|
|
6
|
-
export {
|
|
7
|
-
createMessage,
|
|
8
|
-
deserialize,
|
|
9
|
-
MSG_TYPES,
|
|
10
|
-
serialize,
|
|
11
|
-
validate,
|
|
12
|
-
} from "./mesh-protocol.mjs";
|
|
13
|
-
export { createMessageQueue } from "./mesh-queue.mjs";
|
|
14
|
-
export { createRegistry } from "./mesh-registry.mjs";
|
|
15
|
-
export { routeMessage, routeOrDeadLetter } from "./mesh-router.mjs";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Loads skills assigned to a specific agent from a skills directory.
|
|
19
|
-
* Reuses the same directory-scan approach as generateSkillDocs().
|
|
20
|
-
*
|
|
21
|
-
* @param {string} agentId - The agent identifier
|
|
22
|
-
* @param {string} skillsDir - Path to the skills directory
|
|
23
|
-
* @returns {Promise<string[]>} Array of skill names available to this agent
|
|
24
|
-
*/
|
|
25
|
-
export async function loadSkillsForAgent(agentId, skillsDir) {
|
|
26
|
-
if (!agentId || typeof agentId !== "string") {
|
|
27
|
-
throw new TypeError("agentId must be a non-empty string");
|
|
28
|
-
}
|
|
29
|
-
if (!skillsDir || typeof skillsDir !== "string") {
|
|
30
|
-
throw new TypeError("skillsDir must be a non-empty string");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let entries;
|
|
34
|
-
try {
|
|
35
|
-
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
36
|
-
} catch {
|
|
37
|
-
return [];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const skills = [];
|
|
41
|
-
for (const entry of entries) {
|
|
42
|
-
if (!entry.isDirectory()) continue;
|
|
43
|
-
const skillName = entry.name;
|
|
44
|
-
const skillPath = join(skillsDir, skillName, "SKILL.md");
|
|
45
|
-
let skillContent = null;
|
|
46
|
-
try {
|
|
47
|
-
const { readFileSync } = await import("node:fs");
|
|
48
|
-
skillContent = readFileSync(skillPath, "utf8");
|
|
49
|
-
} catch {
|
|
50
|
-
// Skill has no SKILL.md — include it anyway
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// If SKILL.md mentions the agentId or no agent restriction, include it
|
|
54
|
-
const isRestricted = skillContent
|
|
55
|
-
? /^agents?\s*:/im.test(skillContent)
|
|
56
|
-
: false;
|
|
57
|
-
|
|
58
|
-
if (!isRestricted) {
|
|
59
|
-
skills.push(skillName);
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (skillContent?.includes(agentId)) {
|
|
64
|
-
skills.push(skillName);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return skills;
|
|
69
|
-
}
|
package/mesh/mesh-budget.mjs
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Warning level thresholds mirroring context-monitor.mjs classifyContextThreshold().
|
|
3
|
-
* Reproduced here to avoid cross-module dependency.
|
|
4
|
-
*/
|
|
5
|
-
const WARNING_LEVELS = Object.freeze({
|
|
6
|
-
critical: 90,
|
|
7
|
-
warn: 80,
|
|
8
|
-
info: 60,
|
|
9
|
-
ok: 0,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Clamps a percentage value to [0, 100].
|
|
14
|
-
* @param {number} value
|
|
15
|
-
* @returns {number}
|
|
16
|
-
*/
|
|
17
|
-
function clampPercent(value) {
|
|
18
|
-
const n = Number(value);
|
|
19
|
-
if (!Number.isFinite(n)) return 0;
|
|
20
|
-
return Math.min(100, Math.max(0, n));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Classifies a usage percentage into a warning level.
|
|
25
|
-
* Mirrors context-monitor.mjs classifyContextThreshold().
|
|
26
|
-
* @param {number} percent
|
|
27
|
-
* @returns {{ level: string, message: string }}
|
|
28
|
-
*/
|
|
29
|
-
function classifyLevel(percent) {
|
|
30
|
-
const p = clampPercent(percent);
|
|
31
|
-
if (p >= WARNING_LEVELS.critical)
|
|
32
|
-
return { level: "critical", message: "에이전트 분할 또는 세션 교체 권장" };
|
|
33
|
-
if (p >= WARNING_LEVELS.warn) return { level: "warn", message: "압축 권장" };
|
|
34
|
-
if (p >= WARNING_LEVELS.info)
|
|
35
|
-
return { level: "info", message: "컨텍스트 절반 이상 사용" };
|
|
36
|
-
return { level: "ok", message: "" };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Creates a per-agent token budget manager.
|
|
41
|
-
* @returns {object} Budget API
|
|
42
|
-
*/
|
|
43
|
-
export function createMeshBudget() {
|
|
44
|
-
// Map<agentId, { allocated: number, consumed: number }>
|
|
45
|
-
const budgets = new Map();
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Allocates a token budget to an agent.
|
|
49
|
-
* @param {string} agentId
|
|
50
|
-
* @param {number} tokenLimit
|
|
51
|
-
*/
|
|
52
|
-
function allocate(agentId, tokenLimit) {
|
|
53
|
-
if (!agentId || typeof agentId !== "string") {
|
|
54
|
-
throw new TypeError("agentId must be a non-empty string");
|
|
55
|
-
}
|
|
56
|
-
const limit = Math.max(0, Math.round(Number(tokenLimit) || 0));
|
|
57
|
-
const existing = budgets.get(agentId);
|
|
58
|
-
budgets.set(agentId, {
|
|
59
|
-
allocated: limit,
|
|
60
|
-
consumed: existing?.consumed ?? 0,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Records token consumption for an agent.
|
|
66
|
-
* @param {string} agentId
|
|
67
|
-
* @param {number} tokens
|
|
68
|
-
* @returns {{ remaining: number, percent: number, level: string }}
|
|
69
|
-
*/
|
|
70
|
-
function consume(agentId, tokens) {
|
|
71
|
-
const budget = budgets.get(agentId);
|
|
72
|
-
if (!budget) {
|
|
73
|
-
throw new Error(`No budget allocated for agent: ${agentId}`);
|
|
74
|
-
}
|
|
75
|
-
const amount = Math.max(0, Math.round(Number(tokens) || 0));
|
|
76
|
-
const updated = {
|
|
77
|
-
allocated: budget.allocated,
|
|
78
|
-
consumed: budget.consumed + amount,
|
|
79
|
-
};
|
|
80
|
-
budgets.set(agentId, updated);
|
|
81
|
-
|
|
82
|
-
const remaining = Math.max(0, updated.allocated - updated.consumed);
|
|
83
|
-
const percent =
|
|
84
|
-
updated.allocated > 0
|
|
85
|
-
? clampPercent((updated.consumed / updated.allocated) * 100)
|
|
86
|
-
: 100;
|
|
87
|
-
const { level } = classifyLevel(percent);
|
|
88
|
-
return { remaining, percent, level };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Returns the budget status for an agent.
|
|
93
|
-
* @param {string} agentId
|
|
94
|
-
* @returns {{ allocated: number, consumed: number, remaining: number, level: string }}
|
|
95
|
-
*/
|
|
96
|
-
function getStatus(agentId) {
|
|
97
|
-
const budget = budgets.get(agentId);
|
|
98
|
-
if (!budget) {
|
|
99
|
-
return { allocated: 0, consumed: 0, remaining: 0, level: "ok" };
|
|
100
|
-
}
|
|
101
|
-
const remaining = Math.max(0, budget.allocated - budget.consumed);
|
|
102
|
-
const percent =
|
|
103
|
-
budget.allocated > 0
|
|
104
|
-
? clampPercent((budget.consumed / budget.allocated) * 100)
|
|
105
|
-
: 0;
|
|
106
|
-
const { level } = classifyLevel(percent);
|
|
107
|
-
return {
|
|
108
|
-
allocated: budget.allocated,
|
|
109
|
-
consumed: budget.consumed,
|
|
110
|
-
remaining,
|
|
111
|
-
level,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Resets consumed tokens for all agents (keeps allocations).
|
|
117
|
-
*/
|
|
118
|
-
function resetAll() {
|
|
119
|
-
for (const [agentId, budget] of budgets) {
|
|
120
|
-
budgets.set(agentId, { allocated: budget.allocated, consumed: 0 });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Returns a snapshot of all current allocations.
|
|
126
|
-
* @returns {Map<string, { allocated: number, consumed: number }>}
|
|
127
|
-
*/
|
|
128
|
-
function listAllocations() {
|
|
129
|
-
const snap = new Map();
|
|
130
|
-
for (const [id, b] of budgets) {
|
|
131
|
-
snap.set(id, Object.freeze({ ...b }));
|
|
132
|
-
}
|
|
133
|
-
return snap;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return { allocate, consume, getStatus, resetAll, listAllocations };
|
|
137
|
-
}
|
package/mesh/mesh-heartbeat.mjs
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
const DEFAULT_INTERVAL_MS = 30_000;
|
|
2
|
-
const DEFAULT_THRESHOLD_MS = 60_000;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Creates a heartbeat monitor that tracks agent liveness.
|
|
6
|
-
*
|
|
7
|
-
* @param {object} registry - A mesh-registry instance
|
|
8
|
-
* @param {object} [opts]
|
|
9
|
-
* @param {number} [opts.intervalMs=30000] - Scan interval
|
|
10
|
-
* @param {number} [opts.thresholdMs=60000] - Stale threshold
|
|
11
|
-
* @param {function} [opts.onStale] - Called with agentId when stale detected
|
|
12
|
-
* @returns {object} HeartbeatMonitor API
|
|
13
|
-
*/
|
|
14
|
-
export function createHeartbeatMonitor(registry, opts = {}) {
|
|
15
|
-
const {
|
|
16
|
-
intervalMs = DEFAULT_INTERVAL_MS,
|
|
17
|
-
thresholdMs = DEFAULT_THRESHOLD_MS,
|
|
18
|
-
onStale,
|
|
19
|
-
} = opts;
|
|
20
|
-
|
|
21
|
-
/** @type {Map<string, number>} agentId → last heartbeat timestamp */
|
|
22
|
-
const heartbeats = new Map();
|
|
23
|
-
let timer = null;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Records a heartbeat for an agent.
|
|
27
|
-
* @param {string} agentId
|
|
28
|
-
*/
|
|
29
|
-
function recordHeartbeat(agentId) {
|
|
30
|
-
if (!agentId || typeof agentId !== "string") {
|
|
31
|
-
throw new TypeError("agentId must be a non-empty string");
|
|
32
|
-
}
|
|
33
|
-
heartbeats.set(agentId, Date.now());
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Returns agent IDs whose last heartbeat exceeds the threshold.
|
|
38
|
-
* Only considers agents currently registered in the registry.
|
|
39
|
-
*
|
|
40
|
-
* @param {number} [customThresholdMs] - Override default threshold
|
|
41
|
-
* @returns {string[]}
|
|
42
|
-
*/
|
|
43
|
-
function getStaleAgents(customThresholdMs) {
|
|
44
|
-
const threshold = customThresholdMs ?? thresholdMs;
|
|
45
|
-
const now = Date.now();
|
|
46
|
-
const registered = registry.listAll();
|
|
47
|
-
const stale = [];
|
|
48
|
-
|
|
49
|
-
for (const agent of registered) {
|
|
50
|
-
const lastBeat = heartbeats.get(agent.agentId);
|
|
51
|
-
if (lastBeat === undefined || now - lastBeat >= threshold) {
|
|
52
|
-
stale.push(agent.agentId);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return stale;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Runs a single scan: finds stale agents and invokes onStale callback.
|
|
60
|
-
*/
|
|
61
|
-
function scan() {
|
|
62
|
-
const stale = getStaleAgents();
|
|
63
|
-
if (typeof onStale === "function") {
|
|
64
|
-
for (const agentId of stale) {
|
|
65
|
-
onStale(agentId);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Starts periodic heartbeat scanning.
|
|
72
|
-
* @param {number} [customIntervalMs]
|
|
73
|
-
*/
|
|
74
|
-
function start(customIntervalMs) {
|
|
75
|
-
stop();
|
|
76
|
-
const interval = customIntervalMs ?? intervalMs;
|
|
77
|
-
timer = setInterval(scan, interval);
|
|
78
|
-
timer.unref?.();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Stops periodic scanning.
|
|
83
|
-
*/
|
|
84
|
-
function stop() {
|
|
85
|
-
if (timer !== null) {
|
|
86
|
-
clearInterval(timer);
|
|
87
|
-
timer = null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Removes heartbeat record for an agent.
|
|
93
|
-
* @param {string} agentId
|
|
94
|
-
*/
|
|
95
|
-
function remove(agentId) {
|
|
96
|
-
heartbeats.delete(agentId);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return { recordHeartbeat, getStaleAgents, start, stop, scan, remove };
|
|
100
|
-
}
|
package/mesh/mesh-protocol.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
export const MSG_TYPES = Object.freeze({
|
|
4
|
-
REQUEST: "request",
|
|
5
|
-
RESPONSE: "response",
|
|
6
|
-
EVENT: "event",
|
|
7
|
-
HEARTBEAT: "heartbeat",
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const VALID_TYPES = new Set(Object.values(MSG_TYPES));
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Creates an immutable mesh message.
|
|
14
|
-
* @param {string} type - One of MSG_TYPES values
|
|
15
|
-
* @param {string} from - Sender agent ID
|
|
16
|
-
* @param {string} to - Recipient agent ID (or "*" for broadcast)
|
|
17
|
-
* @param {unknown} payload - Message payload
|
|
18
|
-
* @returns {Readonly<object>}
|
|
19
|
-
*/
|
|
20
|
-
export function createMessage(type, from, to, payload = null) {
|
|
21
|
-
if (!VALID_TYPES.has(type)) {
|
|
22
|
-
throw new TypeError(`Invalid message type: ${type}`);
|
|
23
|
-
}
|
|
24
|
-
if (!from || typeof from !== "string") {
|
|
25
|
-
throw new TypeError("from must be a non-empty string");
|
|
26
|
-
}
|
|
27
|
-
if (!to || typeof to !== "string") {
|
|
28
|
-
throw new TypeError("to must be a non-empty string");
|
|
29
|
-
}
|
|
30
|
-
return Object.freeze({
|
|
31
|
-
type,
|
|
32
|
-
from,
|
|
33
|
-
to,
|
|
34
|
-
payload,
|
|
35
|
-
timestamp: new Date().toISOString(),
|
|
36
|
-
correlationId: randomUUID(),
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Serializes a message to a JSON string.
|
|
42
|
-
* @param {object} message
|
|
43
|
-
* @returns {string}
|
|
44
|
-
*/
|
|
45
|
-
export function serialize(message) {
|
|
46
|
-
return JSON.stringify(message);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Deserializes a JSON string to a message object.
|
|
51
|
-
* @param {string} raw
|
|
52
|
-
* @returns {object}
|
|
53
|
-
*/
|
|
54
|
-
export function deserialize(raw) {
|
|
55
|
-
if (typeof raw !== "string") {
|
|
56
|
-
throw new TypeError("raw must be a string");
|
|
57
|
-
}
|
|
58
|
-
let parsed;
|
|
59
|
-
try {
|
|
60
|
-
parsed = JSON.parse(raw);
|
|
61
|
-
} catch {
|
|
62
|
-
throw new SyntaxError(`Failed to parse message: ${raw}`);
|
|
63
|
-
}
|
|
64
|
-
return parsed;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Validates a message object.
|
|
69
|
-
* @param {unknown} message
|
|
70
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
71
|
-
*/
|
|
72
|
-
export function validate(message) {
|
|
73
|
-
const errors = [];
|
|
74
|
-
|
|
75
|
-
if (!message || typeof message !== "object") {
|
|
76
|
-
return { valid: false, errors: ["message must be an object"] };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!VALID_TYPES.has(message.type)) {
|
|
80
|
-
errors.push(`Invalid type: ${message.type}`);
|
|
81
|
-
}
|
|
82
|
-
if (!message.from || typeof message.from !== "string") {
|
|
83
|
-
errors.push("from must be a non-empty string");
|
|
84
|
-
}
|
|
85
|
-
if (!message.to || typeof message.to !== "string") {
|
|
86
|
-
errors.push("to must be a non-empty string");
|
|
87
|
-
}
|
|
88
|
-
if (!message.timestamp || typeof message.timestamp !== "string") {
|
|
89
|
-
errors.push("timestamp must be a non-empty string");
|
|
90
|
-
}
|
|
91
|
-
if (!message.correlationId || typeof message.correlationId !== "string") {
|
|
92
|
-
errors.push("correlationId must be a non-empty string");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { valid: errors.length === 0, errors };
|
|
96
|
-
}
|