@triflux/core 10.50.1 → 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.
Files changed (90) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +1 -79
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +3 -9
  8. package/hub/lib/cto-env.mjs +0 -23
  9. package/hub/lib/known-errors.json +2 -2
  10. package/hub/lib/process-utils.mjs +0 -123
  11. package/hub/lib/spawn-trace.mjs +3 -2
  12. package/hub/lib/timeout-defaults.mjs +0 -1
  13. package/hub/paths.mjs +0 -6
  14. package/hub/pipeline/index.mjs +0 -190
  15. package/hub/pipeline/transitions.mjs +0 -10
  16. package/hub/reflexion.mjs +1 -84
  17. package/hub/role-contract.mjs +0 -2
  18. package/hud/cli-policy.mjs +0 -26
  19. package/hud/colors.mjs +0 -13
  20. package/hud/constants.mjs +7 -153
  21. package/hud/context-monitor.mjs +3 -64
  22. package/hud/hud-qos-status.mjs +54 -264
  23. package/hud/providers/claude.mjs +4 -16
  24. package/hud/providers/gemini.mjs +83 -689
  25. package/hud/renderers.mjs +59 -367
  26. package/hud/terminal.mjs +1 -63
  27. package/hud/utils.mjs +2 -26
  28. package/package.json +1 -4
  29. package/scripts/lib/agent-route-policy.mjs +162 -14
  30. package/scripts/lib/claudemd-scanner.mjs +2 -199
  31. package/scripts/lib/cli-claude.mjs +0 -2
  32. package/scripts/lib/context.mjs +3 -19
  33. package/scripts/lib/env-probe.mjs +5 -64
  34. package/scripts/lib/gemini-profiles.mjs +2 -69
  35. package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
  36. package/scripts/lib/machine-profile.mjs +3 -17
  37. package/scripts/lib/mcp-filter.mjs +109 -53
  38. package/scripts/lib/mcp-health.mjs +2 -2
  39. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  40. package/scripts/lib/psmux-info.mjs +25 -104
  41. package/scripts/lib/session-presence.mjs +1 -1
  42. package/scripts/lib/skill-template.mjs +0 -271
  43. package/hub/codex-compat.mjs +0 -12
  44. package/hub/dynamic-routing-engine.mjs +0 -511
  45. package/hub/fullcycle.mjs +0 -113
  46. package/hub/gemini-adapter.mjs +0 -174
  47. package/hub/intent.mjs +0 -321
  48. package/hub/lib/mcp-response-cache.mjs +0 -239
  49. package/hub/lib/path-utils.mjs +0 -165
  50. package/hub/lib/risk-tier.mjs +0 -53
  51. package/hub/lib/ssh-command.mjs +0 -231
  52. package/hub/lib/staged-file-detect.mjs +0 -43
  53. package/hub/lib/tfx-route-args.mjs +0 -353
  54. package/hub/lib/timeline-adapter.mjs +0 -190
  55. package/hub/pipeline/gates/confidence.mjs +0 -75
  56. package/hub/pipeline/gates/consensus.mjs +0 -106
  57. package/hub/pipeline/gates/index.mjs +0 -9
  58. package/hub/pipeline/gates/selfcheck.mjs +0 -120
  59. package/hub/quality/deslop.mjs +0 -274
  60. package/hub/research.mjs +0 -155
  61. package/hub/role-control-events.mjs +0 -113
  62. package/hub/routing/complexity.mjs +0 -269
  63. package/hub/routing/index.mjs +0 -122
  64. package/hub/routing/q-learning.mjs +0 -387
  65. package/hub/routing-snapshot.mjs +0 -261
  66. package/hub/token-mode.mjs +0 -228
  67. package/hud/mission-board.mjs +0 -56
  68. package/hud/providers/cto.mjs +0 -76
  69. package/mesh/index.mjs +0 -69
  70. package/mesh/mesh-budget.mjs +0 -137
  71. package/mesh/mesh-heartbeat.mjs +0 -100
  72. package/mesh/mesh-protocol.mjs +0 -96
  73. package/mesh/mesh-queue.mjs +0 -163
  74. package/mesh/mesh-registry.mjs +0 -78
  75. package/mesh/mesh-router.mjs +0 -77
  76. package/scripts/lib/agent-json.mjs +0 -27
  77. package/scripts/lib/async.mjs +0 -174
  78. package/scripts/lib/cli-agy.mjs +0 -85
  79. package/scripts/lib/cli-codex.mjs +0 -62
  80. package/scripts/lib/cli-gemini.mjs +0 -37
  81. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  82. package/scripts/lib/env.mjs +0 -14
  83. package/scripts/lib/hub.mjs +0 -48
  84. package/scripts/lib/pid.mjs +0 -63
  85. package/scripts/lib/quota.mjs +0 -47
  86. package/scripts/lib/skill-state.mjs +0 -220
  87. package/scripts/lib/team.mjs +0 -75
  88. package/scripts/lib/timeout.mjs +0 -66
  89. package/scripts/lib/tmp.mjs +0 -26
  90. package/scripts/lib/toml.mjs +0 -132
@@ -1,387 +0,0 @@
1
- // hub/routing/q-learning.mjs — 테이블 기반 Q-Learning 동적 라우팅
2
- // agent-map.json 폴백을 유지하면서, 작업 결과 피드백으로 가중치를 학습한다.
3
- // 외부 의존성 없음 (fs, path, crypto만 사용)
4
-
5
- import { createHash } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { createRequire } from "node:module";
8
- import { homedir } from "node:os";
9
- import { join } from "node:path";
10
- import { scoreComplexity } from "./complexity.mjs";
11
-
12
- const _require = createRequire(import.meta.url);
13
-
14
- /** agent-map.json 정적 매핑 (폴백용) */
15
- let AGENT_MAP;
16
- try {
17
- AGENT_MAP = _require("../team/agent-map.json");
18
- } catch {
19
- AGENT_MAP = {};
20
- }
21
-
22
- /** 사용 가능한 CLI 액션 */
23
- const ACTIONS = ["codex", "antigravity", "claude", "haiku", "sonnet"];
24
-
25
- /** 특성 벡터 키워드 (48차원, 에이전트 타입 기반) */
26
- const FEATURE_KEYWORDS = [
27
- // 실행/구현 (codex 친화)
28
- "implement",
29
- "execute",
30
- "build",
31
- "fix",
32
- "debug",
33
- "code",
34
- "refactor",
35
- "test",
36
- // 분석/설계 (claude/sonnet 친화)
37
- "analyze",
38
- "architect",
39
- "plan",
40
- "review",
41
- "security",
42
- "optimize",
43
- "research",
44
- "evaluate",
45
- // 디자인/문서 (Antigravity 친화)
46
- "design",
47
- "ui",
48
- "ux",
49
- "frontend",
50
- "visual",
51
- "document",
52
- "write",
53
- "explain",
54
- // 간단/빠른 (haiku 친화)
55
- "simple",
56
- "quick",
57
- "trivial",
58
- "rename",
59
- "format",
60
- "lint",
61
- "typo",
62
- "minor",
63
- // 한국어 — 실행/구현 (codex 친화)
64
- "구현",
65
- "빌드",
66
- "수정",
67
- "디버깅",
68
- "리팩터링",
69
- "테스트",
70
- // 한국어 — 분석/설계 (claude/sonnet 친화)
71
- "분석",
72
- "아키텍처",
73
- "설계",
74
- "검토",
75
- "보안",
76
- "최적화",
77
- // 한국어 — 디자인/문서 (Antigravity 친화)
78
- "디자인",
79
- "문서화",
80
- // 한국어 — 간단/빠른 (haiku 친화)
81
- "간단",
82
- "사소한",
83
- ];
84
-
85
- /**
86
- * 텍스트에서 48차원 특성 벡터 추출
87
- * 단어 경계를 기준으로 매칭하여 부분 문자열 오탐을 방지한다.
88
- * @param {string} text
89
- * @returns {number[]} 48-dim binary feature vector
90
- */
91
- function extractFeatures(text) {
92
- const lower = text.toLowerCase();
93
- return FEATURE_KEYWORDS.map((kw) => {
94
- // 영문 단일 단어: 단어 경계(\b) 적용
95
- // 한국어 또는 다중 단어 구문: 공백/문장 경계 기반 포함 여부 확인
96
- if (/^[a-z]+$/.test(kw)) {
97
- return new RegExp(`\\b${kw}\\b`).test(lower) ? 1 : 0;
98
- }
99
- return lower.includes(kw) ? 1 : 0;
100
- });
101
- }
102
-
103
- /**
104
- * 특성 벡터를 상태 키로 변환 (해시 기반)
105
- * @param {number[]} features
106
- * @returns {string}
107
- */
108
- function stateKey(features) {
109
- const hash = createHash("sha256").update(features.join(",")).digest("hex");
110
- return hash.slice(0, 16);
111
- }
112
-
113
- /**
114
- * LRU 캐시 (예측 결과 캐싱)
115
- */
116
- class LRUCache {
117
- /** @param {number} maxSize @param {number} ttlMs */
118
- constructor(maxSize = 256, ttlMs = 5 * 60 * 1000) {
119
- this._maxSize = maxSize;
120
- this._ttlMs = ttlMs;
121
- /** @type {Map<string, { value: *, ts: number }>} */
122
- this._cache = new Map();
123
- }
124
-
125
- get(key) {
126
- const entry = this._cache.get(key);
127
- if (!entry) return undefined;
128
- if (Date.now() - entry.ts > this._ttlMs) {
129
- this._cache.delete(key);
130
- return undefined;
131
- }
132
- // LRU: 다시 삽입하여 순서 갱신
133
- this._cache.delete(key);
134
- this._cache.set(key, entry);
135
- return entry.value;
136
- }
137
-
138
- set(key, value) {
139
- if (this._cache.has(key)) this._cache.delete(key);
140
- this._cache.set(key, { value, ts: Date.now() });
141
- // 초과 시 가장 오래된 항목 제거
142
- if (this._cache.size > this._maxSize) {
143
- const oldest = this._cache.keys().next().value;
144
- this._cache.delete(oldest);
145
- }
146
- }
147
-
148
- clear() {
149
- this._cache.clear();
150
- }
151
- }
152
-
153
- /**
154
- * Q-Learning 라우터
155
- * 테이블 기반 Q-Learning으로 작업→CLI 매핑을 학습한다.
156
- */
157
- export class QLearningRouter {
158
- /**
159
- * @param {object} [opts]
160
- * @param {number} [opts.learningRate=0.1] — 학습률 (alpha)
161
- * @param {number} [opts.discountFactor=0.9] — 할인율 (gamma)
162
- * @param {number} [opts.epsilon=0.3] — 탐색 확률 (epsilon-greedy)
163
- * @param {number} [opts.epsilonDecay=0.995] — 엡실론 감쇠율
164
- * @param {number} [opts.epsilonMin=0.05] — 최소 엡실론
165
- * @param {number} [opts.minConfidence=0.6] — 최소 신뢰도 (이하면 폴백)
166
- * @param {string} [opts.modelPath] — Q-table 영속화 경로
167
- * @param {() => number} [opts.random=Math.random] — 테스트/재현용 RNG seam
168
- */
169
- constructor(opts = {}) {
170
- this._lr = opts.learningRate ?? 0.1;
171
- this._gamma = opts.discountFactor ?? 0.9;
172
- this._epsilon = opts.epsilon ?? 0.3;
173
- this._epsilonDecay = opts.epsilonDecay ?? 0.995;
174
- // epsilon=0 시에도 최소 탐색 보장 (pure-exploit 방지)
175
- this._epsilonMin =
176
- opts.epsilonMin ?? Math.max(0.01, Math.min(0.05, this._epsilon));
177
- this._minConfidence = opts.minConfidence ?? 0.6;
178
- this._modelPath =
179
- opts.modelPath ?? join(homedir(), ".omc", "routing-model.json");
180
- this._random =
181
- typeof opts.random === "function" ? opts.random : Math.random;
182
-
183
- /** @type {Map<string, Map<string, number>>} state -> (action -> Q-value) */
184
- this._qTable = new Map();
185
-
186
- /** @type {Map<string, number>} state -> visit count */
187
- this._visitCounts = new Map();
188
-
189
- /** 총 업데이트 횟수 */
190
- this._totalUpdates = 0;
191
-
192
- /** 예측 캐시 */
193
- this._cache = new LRUCache(256, 5 * 60 * 1000);
194
- }
195
-
196
- /**
197
- * 상태에 대한 Q-values 조회 (없으면 초기화)
198
- * @param {string} state
199
- * @returns {Map<string, number>}
200
- */
201
- _getQValues(state) {
202
- if (!this._qTable.has(state)) {
203
- const qValues = new Map();
204
- for (const action of ACTIONS) {
205
- qValues.set(action, 0);
206
- }
207
- this._qTable.set(state, qValues);
208
- }
209
- return this._qTable.get(state);
210
- }
211
-
212
- /**
213
- * 작업 설명으로부터 최적 CLI 타입 예측
214
- * @param {string} taskDescription
215
- * @returns {{ action: string, confidence: number, exploration: boolean, complexity: number }}
216
- */
217
- predict(taskDescription) {
218
- const features = extractFeatures(taskDescription);
219
- const state = stateKey(features);
220
-
221
- // 캐시 확인
222
- const cached = this._cache.get(state);
223
- if (cached) return cached;
224
-
225
- const qValues = this._getQValues(state);
226
- const visits = this._visitCounts.get(state) || 0;
227
-
228
- // 엡실론-그리디: 탐색 vs 활용
229
- const isExploration = this._random() < this._epsilon;
230
-
231
- let action;
232
- if (isExploration) {
233
- // 무작위 탐색
234
- action = ACTIONS[Math.floor(this._random() * ACTIONS.length)];
235
- } else {
236
- // 최적 액션 선택 (최대 Q-value)
237
- let maxQ = -Infinity;
238
- action = ACTIONS[0];
239
- for (const [a, q] of qValues) {
240
- if (q > maxQ) {
241
- maxQ = q;
242
- action = a;
243
- }
244
- }
245
- }
246
-
247
- // 신뢰도 계산: 방문 횟수 기반 (최소 10회 이상이면 안정)
248
- const confidence = visits >= 10 ? Math.min(visits / 50, 1) : visits / 10;
249
-
250
- const { score: complexity } = scoreComplexity(taskDescription);
251
-
252
- const result = {
253
- action,
254
- confidence,
255
- exploration: isExploration,
256
- complexity,
257
- };
258
- // 탐색(랜덤) 결과는 캐싱하지 않음 — 매번 새로운 랜덤 액션 생성
259
- if (!isExploration) this._cache.set(state, result);
260
- return result;
261
- }
262
-
263
- /**
264
- * Q-table 업데이트 (보상 피드백)
265
- * @param {string} taskDescription — 작업 설명
266
- * @param {string} action — 수행한 액션 (CLI 타입)
267
- * @param {number} reward — 보상 (-1 ~ 1)
268
- */
269
- update(taskDescription, action, reward) {
270
- if (!ACTIONS.includes(action)) return;
271
-
272
- const features = extractFeatures(taskDescription);
273
- const state = stateKey(features);
274
- const qValues = this._getQValues(state);
275
- const oldQ = qValues.get(action) || 0;
276
-
277
- // 최대 미래 Q-value (단일 상태이므로 현재 상태의 max)
278
- let maxFutureQ = -Infinity;
279
- for (const [, q] of qValues) {
280
- if (q > maxFutureQ) maxFutureQ = q;
281
- }
282
-
283
- // Q-Learning 업데이트: Q(s,a) = Q(s,a) + lr * (reward + gamma * max_Q(s') - Q(s,a))
284
- const newQ = oldQ + this._lr * (reward + this._gamma * maxFutureQ - oldQ);
285
- qValues.set(action, newQ);
286
-
287
- // 방문 횟수 증가
288
- this._visitCounts.set(state, (this._visitCounts.get(state) || 0) + 1);
289
- this._totalUpdates++;
290
-
291
- // 엡실론 감쇠
292
- this._epsilon = Math.max(
293
- this._epsilonMin,
294
- this._epsilon * this._epsilonDecay,
295
- );
296
-
297
- // 캐시 무효화 (해당 상태)
298
- this._cache.clear();
299
- }
300
-
301
- /**
302
- * Q-table을 JSON 파일로 영속화
303
- */
304
- save() {
305
- const dir = join(this._modelPath, "..");
306
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
307
-
308
- const data = {
309
- version: 1,
310
- epsilon: this._epsilon,
311
- totalUpdates: this._totalUpdates,
312
- qTable: {},
313
- visitCounts: {},
314
- };
315
-
316
- for (const [state, qValues] of this._qTable) {
317
- data.qTable[state] = Object.fromEntries(qValues);
318
- }
319
- for (const [state, count] of this._visitCounts) {
320
- data.visitCounts[state] = count;
321
- }
322
-
323
- writeFileSync(this._modelPath, JSON.stringify(data, null, 2), "utf8");
324
- }
325
-
326
- /**
327
- * 영속화된 Q-table 로드
328
- * @returns {boolean} 로드 성공 여부
329
- */
330
- load() {
331
- if (!existsSync(this._modelPath)) return false;
332
-
333
- try {
334
- const raw = readFileSync(this._modelPath, "utf8");
335
- const data = JSON.parse(raw);
336
- if (data.version !== 1) return false;
337
-
338
- this._epsilon = data.epsilon ?? this._epsilon;
339
- this._totalUpdates = data.totalUpdates ?? 0;
340
- this._qTable = new Map();
341
- this._visitCounts = new Map();
342
-
343
- for (const [state, qObj] of Object.entries(data.qTable || {})) {
344
- const qValues = new Map();
345
- for (const [action, q] of Object.entries(qObj)) {
346
- if (ACTIONS.includes(action)) qValues.set(action, q);
347
- }
348
- this._qTable.set(state, qValues);
349
- }
350
- for (const [state, count] of Object.entries(data.visitCounts || {})) {
351
- this._visitCounts.set(state, count);
352
- }
353
-
354
- this._cache.clear();
355
- return true;
356
- } catch {
357
- return false;
358
- }
359
- }
360
-
361
- /**
362
- * agent-map.json 폴백 조회
363
- * @param {string} agentType — 에이전트 역할명
364
- * @returns {string} CLI 타입
365
- */
366
- static fallback(agentType) {
367
- return AGENT_MAP[agentType] || agentType;
368
- }
369
-
370
- /** 현재 엡실론 값 */
371
- get epsilon() {
372
- return this._epsilon;
373
- }
374
-
375
- /** 총 업데이트 횟수 */
376
- get totalUpdates() {
377
- return this._totalUpdates;
378
- }
379
-
380
- /** Q-table 상태 수 */
381
- get stateCount() {
382
- return this._qTable.size;
383
- }
384
- }
385
-
386
- // 모듈 레벨 export
387
- export { ACTIONS, extractFeatures, FEATURE_KEYWORDS, LRUCache, stateKey };
@@ -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
- };