@triflux/core 10.1.0 → 10.26.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 (145) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +191 -31
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +262 -34
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +1051 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +22 -21
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/schema/delegator-tools.schema.json +1 -1
  35. package/hub/delegator/service.mjs +93 -64
  36. package/hub/delegator/tool-definitions.mjs +35 -35
  37. package/hub/dynamic-routing-engine.mjs +511 -0
  38. package/hub/fullcycle.mjs +34 -18
  39. package/hub/gemini-adapter.mjs +82 -108
  40. package/hub/hitl.mjs +202 -143
  41. package/hub/intent.mjs +167 -38
  42. package/hub/lib/bash-path.mjs +73 -0
  43. package/hub/lib/cache-guard.mjs +43 -17
  44. package/hub/lib/env-detect.mjs +145 -0
  45. package/hub/lib/hosts-compat.mjs +319 -0
  46. package/hub/lib/mcp-response-cache.mjs +239 -0
  47. package/hub/lib/memory-store.mjs +285 -111
  48. package/hub/lib/path-utils.mjs +165 -0
  49. package/hub/lib/phase-manager.mjs +493 -0
  50. package/hub/lib/process-utils.mjs +797 -104
  51. package/hub/lib/prompt-tmp.mjs +33 -0
  52. package/hub/lib/risk-tier.mjs +53 -0
  53. package/hub/lib/spawn-trace.mjs +561 -0
  54. package/hub/lib/ssh-command.mjs +44 -24
  55. package/hub/lib/ssh-retry.mjs +5 -1
  56. package/hub/lib/staged-file-detect.mjs +43 -0
  57. package/hub/lib/state-snapshot.mjs +293 -0
  58. package/hub/lib/tfx-route-args.mjs +309 -0
  59. package/hub/lib/timeline-adapter.mjs +190 -0
  60. package/hub/lib/trace-recorder.mjs +153 -0
  61. package/hub/lib/uuidv7.mjs +4 -3
  62. package/hub/memory-doctor.mjs +266 -106
  63. package/hub/middleware/quota-middleware.mjs +70 -0
  64. package/hub/middleware/request-logger.mjs +61 -34
  65. package/hub/paths.mjs +9 -9
  66. package/hub/pipeline/gates/confidence.mjs +34 -15
  67. package/hub/pipeline/gates/consensus.mjs +27 -15
  68. package/hub/pipeline/gates/index.mjs +7 -3
  69. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  70. package/hub/pipeline/index.mjs +77 -42
  71. package/hub/pipeline/state.mjs +10 -10
  72. package/hub/pipeline/transitions.mjs +40 -23
  73. package/hub/platform.mjs +65 -48
  74. package/hub/quality/deslop.mjs +70 -49
  75. package/hub/reflexion.mjs +87 -13
  76. package/hub/research.mjs +33 -25
  77. package/hub/router.mjs +941 -791
  78. package/hub/routing/complexity.mjs +132 -29
  79. package/hub/routing/index.mjs +17 -12
  80. package/hub/routing/q-learning.mjs +78 -30
  81. package/hub/routing-snapshot.mjs +261 -0
  82. package/hub/session-fingerprint.mjs +127 -61
  83. package/hub/state.mjs +86 -44
  84. package/hub/team/claude-daemon-control.mjs +1130 -0
  85. package/hub/team/retry-state-machine.mjs +336 -0
  86. package/hub/token-mode.mjs +72 -71
  87. package/hub/workers/worker-utils.mjs +23 -17
  88. package/hud/colors.mjs +27 -9
  89. package/hud/constants.mjs +190 -27
  90. package/hud/context-monitor.mjs +133 -44
  91. package/hud/hud-qos-status.mjs +157 -50
  92. package/hud/mission-board.mjs +56 -0
  93. package/hud/providers/claude.mjs +398 -114
  94. package/hud/providers/codex.mjs +96 -21
  95. package/hud/providers/gemini.mjs +210 -52
  96. package/hud/renderers.mjs +440 -127
  97. package/hud/terminal.mjs +101 -31
  98. package/hud/utils.mjs +81 -39
  99. package/mesh/index.mjs +69 -0
  100. package/mesh/mesh-budget.mjs +137 -0
  101. package/mesh/mesh-heartbeat.mjs +100 -0
  102. package/mesh/mesh-protocol.mjs +96 -0
  103. package/mesh/mesh-queue.mjs +163 -0
  104. package/mesh/mesh-registry.mjs +78 -0
  105. package/mesh/mesh-router.mjs +77 -0
  106. package/package.json +5 -1
  107. package/scripts/lib/agent-json.mjs +27 -0
  108. package/scripts/lib/async.mjs +174 -0
  109. package/scripts/lib/claudemd-scanner.mjs +6 -1
  110. package/scripts/lib/cli-agy.mjs +62 -0
  111. package/scripts/lib/cli-claude.mjs +78 -0
  112. package/scripts/lib/cli-codex.mjs +199 -0
  113. package/scripts/lib/cli-gemini.mjs +37 -0
  114. package/scripts/lib/codex-recovery.sh +50 -0
  115. package/scripts/lib/context.mjs +3 -3
  116. package/scripts/lib/cross-review-utils.mjs +13 -5
  117. package/scripts/lib/doctor-env-checks.mjs +121 -0
  118. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  119. package/scripts/lib/env-probe.mjs +58 -30
  120. package/scripts/lib/env.mjs +14 -0
  121. package/scripts/lib/gemini-profiles.mjs +44 -10
  122. package/scripts/lib/handoff.mjs +33 -17
  123. package/scripts/lib/hook-utils.mjs +8 -6
  124. package/scripts/lib/hub.mjs +48 -0
  125. package/scripts/lib/keyword-rules.mjs +49 -20
  126. package/scripts/lib/logger.mjs +24 -24
  127. package/scripts/lib/mcp-filter.mjs +391 -247
  128. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  129. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  130. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  131. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  132. package/scripts/lib/mcp-health.mjs +773 -0
  133. package/scripts/lib/mcp-manifest.mjs +24 -14
  134. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  135. package/scripts/lib/pid.mjs +63 -0
  136. package/scripts/lib/process-utils.mjs +26 -0
  137. package/scripts/lib/psmux-info.mjs +117 -12
  138. package/scripts/lib/quota.mjs +47 -0
  139. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  140. package/scripts/lib/skill-state.mjs +220 -0
  141. package/scripts/lib/skill-template.mjs +32 -7
  142. package/scripts/lib/team.mjs +75 -0
  143. package/scripts/lib/timeout.mjs +66 -0
  144. package/scripts/lib/tmp.mjs +26 -0
  145. package/scripts/lib/toml.mjs +113 -0
@@ -0,0 +1,319 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const HOSTS_LOCATIONS = [
6
+ ["references", "hosts.json"],
7
+ ["skills", "tfx-remote-spawn", "references", "hosts.json"],
8
+ ["packages", "triflux", "references", "hosts.json"],
9
+ ];
10
+
11
+ let migrated = false;
12
+
13
+ function readJsonFile(path) {
14
+ return JSON.parse(readFileSync(path, "utf8"));
15
+ }
16
+
17
+ export function userStateHostsPath() {
18
+ if (process.env.TFX_HOSTS_USER_STATE_DISABLE === "1") return null;
19
+ if (process.env.TFX_HOSTS_USER_STATE) return process.env.TFX_HOSTS_USER_STATE;
20
+ if (process.platform === "win32") {
21
+ return join(
22
+ process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
23
+ "triflux",
24
+ "hosts.json",
25
+ );
26
+ }
27
+ return join(homedir(), ".config", "triflux", "hosts.json");
28
+ }
29
+
30
+ function candidatePaths(repoRoot) {
31
+ const root = repoRoot || process.cwd();
32
+ const repoRootCandidates = HOSTS_LOCATIONS.map((segments) =>
33
+ join(root, ...segments),
34
+ );
35
+ const userPath = userStateHostsPath();
36
+ return userPath ? [userPath, ...repoRootCandidates] : repoRootCandidates;
37
+ }
38
+
39
+ export function migrateLegacyHosts(repoRoot) {
40
+ const to = userStateHostsPath();
41
+ let from = null;
42
+ if (!to) {
43
+ return {
44
+ migrated: false,
45
+ from: null,
46
+ to: null,
47
+ reason: "user-state-disabled",
48
+ };
49
+ }
50
+ try {
51
+ if (existsSync(to)) {
52
+ return { migrated: false, from: null, to, reason: "already-exists" };
53
+ }
54
+
55
+ const root = repoRoot || process.cwd();
56
+ from =
57
+ HOSTS_LOCATIONS.map((segments) => join(root, ...segments)).find((path) =>
58
+ existsSync(path),
59
+ ) || null;
60
+ if (!from) {
61
+ return { migrated: false, from: null, to, reason: "not-found" };
62
+ }
63
+
64
+ mkdirSync(dirname(to), { recursive: true });
65
+ copyFileSync(from, to);
66
+ return { migrated: true, from, to };
67
+ } catch (error) {
68
+ return {
69
+ migrated: false,
70
+ from,
71
+ to,
72
+ reason: error instanceof Error ? error.message : String(error),
73
+ };
74
+ }
75
+ }
76
+
77
+ function canonicalOs(rawOs) {
78
+ const value = String(rawOs || "")
79
+ .trim()
80
+ .toLowerCase();
81
+ if (!value) return "linux";
82
+ if (
83
+ value === "win32" ||
84
+ value === "windows" ||
85
+ value.startsWith("windows-")
86
+ ) {
87
+ return "windows";
88
+ }
89
+ if (value === "macos" || value === "darwin" || value.includes("darwin")) {
90
+ return "darwin";
91
+ }
92
+ return "linux";
93
+ }
94
+
95
+ function normalizeCapabilitiesArray(rawArray, rawMap) {
96
+ const fromArray = Array.isArray(rawArray)
97
+ ? rawArray.map((item) => String(item).trim()).filter(Boolean)
98
+ : [];
99
+ const fromMap =
100
+ rawMap && typeof rawMap === "object"
101
+ ? Object.entries(rawMap)
102
+ .filter(([, enabled]) => Boolean(enabled))
103
+ .map(([name]) => String(name).trim().replace(/_/g, "-"))
104
+ : [];
105
+ return [...new Set([...fromArray, ...fromMap])];
106
+ }
107
+
108
+ function normalizeCapabilitiesMap(rawMap, rawArray) {
109
+ const normalized = {};
110
+ if (rawMap && typeof rawMap === "object") {
111
+ for (const [key, enabled] of Object.entries(rawMap)) {
112
+ normalized[String(key).trim()] = Boolean(enabled);
113
+ }
114
+ }
115
+ if (Array.isArray(rawArray)) {
116
+ for (const item of rawArray) {
117
+ const key = String(item).trim().replace(/-/g, "_");
118
+ if (key) normalized[key] = true;
119
+ }
120
+ }
121
+ return normalized;
122
+ }
123
+
124
+ function normalizeLastProbe(rawProbe) {
125
+ if (!rawProbe || typeof rawProbe !== "object") {
126
+ return null;
127
+ }
128
+ const probe = {};
129
+ if (typeof rawProbe.ok === "boolean") probe.ok = rawProbe.ok;
130
+ if (rawProbe.ts) probe.ts = String(rawProbe.ts);
131
+ if (Number.isFinite(rawProbe.latency_ms))
132
+ probe.latency_ms = rawProbe.latency_ms;
133
+ return Object.keys(probe).length > 0 ? probe : null;
134
+ }
135
+
136
+ function normalizeResources(rawHost) {
137
+ const rawResources =
138
+ rawHost.resources && typeof rawHost.resources === "object"
139
+ ? rawHost.resources
140
+ : {};
141
+ const rawSpecs =
142
+ rawHost.specs && typeof rawHost.specs === "object" ? rawHost.specs : {};
143
+ return { ...rawResources, ...rawSpecs };
144
+ }
145
+
146
+ export function normalizeHost(rawHost = {}, name = "") {
147
+ const sshUser = rawHost.ssh_user || rawHost.ssh?.user || rawHost.user || null;
148
+ const sshHost = rawHost.ssh?.host || rawHost.host || null;
149
+ const resources = normalizeResources(rawHost);
150
+ const tailscale = {
151
+ ip: rawHost.tailscale?.ip || null,
152
+ dns: rawHost.tailscale?.dns || null,
153
+ ssh_mode: rawHost.tailscale?.ssh_mode || null,
154
+ };
155
+ const capabilities = normalizeCapabilitiesArray(
156
+ rawHost.capabilities,
157
+ rawHost.capabilities_v2,
158
+ );
159
+ const capabilities_v2 = normalizeCapabilitiesMap(
160
+ rawHost.capabilities_v2,
161
+ rawHost.capabilities,
162
+ );
163
+
164
+ return {
165
+ name,
166
+ description: rawHost.description || name,
167
+ aliases: Array.isArray(rawHost.aliases)
168
+ ? [
169
+ ...new Set(
170
+ rawHost.aliases
171
+ .map((alias) => String(alias).trim())
172
+ .filter(Boolean),
173
+ ),
174
+ ]
175
+ : [],
176
+ default_dir: rawHost.default_dir || "~",
177
+ os: canonicalOs(rawHost.os),
178
+ ssh_user: sshUser,
179
+ ssh: {
180
+ ...(rawHost.ssh && typeof rawHost.ssh === "object" ? rawHost.ssh : {}),
181
+ user: sshUser,
182
+ host: sshHost,
183
+ },
184
+ tailscale,
185
+ capabilities,
186
+ capabilities_v2,
187
+ last_probe: normalizeLastProbe(rawHost.last_probe),
188
+ resources,
189
+ specs: { ...resources },
190
+ raw: { ...rawHost },
191
+ };
192
+ }
193
+
194
+ export function readHosts(repoRoot) {
195
+ if (!migrated) {
196
+ migrateLegacyHosts(repoRoot);
197
+ migrated = true;
198
+ }
199
+
200
+ for (const path of candidatePaths(repoRoot)) {
201
+ if (!existsSync(path)) continue;
202
+ const parsed = readJsonFile(path);
203
+ const normalizedHosts = Object.fromEntries(
204
+ Object.entries(parsed.hosts || {}).map(([name, host]) => [
205
+ name,
206
+ normalizeHost(host, name),
207
+ ]),
208
+ );
209
+ return {
210
+ path,
211
+ raw: parsed,
212
+ hosts: normalizedHosts,
213
+ default_host:
214
+ parsed.default_host && normalizedHosts[parsed.default_host]
215
+ ? parsed.default_host
216
+ : null,
217
+ triggers: Array.isArray(parsed.triggers)
218
+ ? parsed.triggers.map((item) => String(item).trim()).filter(Boolean)
219
+ : [],
220
+ };
221
+ }
222
+
223
+ return {
224
+ path: null,
225
+ raw: { hosts: {} },
226
+ hosts: {},
227
+ default_host: null,
228
+ triggers: [],
229
+ };
230
+ }
231
+
232
+ export function resolveHost(nameOrAlias, repoRoot) {
233
+ if (!nameOrAlias) return null;
234
+ const registry = readHosts(repoRoot);
235
+ const needle = String(nameOrAlias).trim();
236
+ if (!needle) return null;
237
+
238
+ if (registry.hosts[needle]) {
239
+ return { name: needle, host: registry.hosts[needle], registry };
240
+ }
241
+
242
+ const lowered = needle.toLowerCase();
243
+ for (const [name, host] of Object.entries(registry.hosts)) {
244
+ const aliases = new Set([
245
+ ...host.aliases,
246
+ host.tailscale.ip,
247
+ host.tailscale.dns,
248
+ host.ssh.host,
249
+ host.ssh_user ? `${host.ssh_user}@${name}` : null,
250
+ host.ssh_user && host.tailscale.ip
251
+ ? `${host.ssh_user}@${host.tailscale.ip}`
252
+ : null,
253
+ host.ssh_user && host.tailscale.dns
254
+ ? `${host.ssh_user}@${host.tailscale.dns}`
255
+ : null,
256
+ host.ssh_user && host.ssh.host
257
+ ? `${host.ssh_user}@${host.ssh.host}`
258
+ : null,
259
+ ]);
260
+ for (const alias of aliases) {
261
+ if (alias && String(alias).toLowerCase() === lowered) {
262
+ return { name, host, registry };
263
+ }
264
+ }
265
+ }
266
+
267
+ return null;
268
+ }
269
+
270
+ export function readHost(nameOrAlias, repoRoot) {
271
+ return resolveHost(nameOrAlias, repoRoot)?.host ?? null;
272
+ }
273
+
274
+ export function selfTestFixtures() {
275
+ const v1 = normalizeHost(
276
+ {
277
+ description: "legacy",
278
+ aliases: ["desk"],
279
+ default_dir: "~/Desktop/Projects",
280
+ os: "win32",
281
+ ssh_user: "SSAFY",
282
+ tailscale: { ip: "100.64.0.1", dns: "desk.ts.net" },
283
+ capabilities: ["codex", "claude"],
284
+ },
285
+ "ultra4",
286
+ );
287
+ const v2 = normalizeHost(
288
+ {
289
+ description: "modern",
290
+ aliases: ["mac"],
291
+ default_dir: "~/projects",
292
+ os: "darwin kernel",
293
+ ssh: { user: "tellang" },
294
+ tailscale: {
295
+ ip: "100.64.0.2",
296
+ dns: "mac.ts.net",
297
+ ssh_mode: "ssh-over-vpn",
298
+ },
299
+ capabilities_v2: { codex: true, claude: true, high_memory: true },
300
+ last_probe: { ok: true, ts: "2026-04-18T12:34:56Z", latency_ms: 143 },
301
+ },
302
+ "m2",
303
+ );
304
+ return {
305
+ v1,
306
+ v2,
307
+ checks: {
308
+ v1_os: v1.os === "windows",
309
+ v1_ssh_user: v1.ssh.user === "SSAFY" && v1.ssh_user === "SSAFY",
310
+ v2_os: v2.os === "darwin",
311
+ v2_caps: v2.capabilities.includes("high-memory"),
312
+ v2_probe: v2.last_probe?.ok === true && v2.last_probe?.latency_ms === 143,
313
+ },
314
+ };
315
+ }
316
+
317
+ if (process.argv.includes("--self-test")) {
318
+ process.stdout.write(`${JSON.stringify(selfTestFixtures(), null, 2)}\n`);
319
+ }
@@ -0,0 +1,239 @@
1
+ // hub/lib/mcp-response-cache.mjs — MCP 응답 캐시 (LRU + TTL)
2
+ // Deep 스킬에서 3개 모델이 같은 context7 문서를 요청하면 1번만 호출, 나머지는 캐시 hit.
3
+ // 파일 기반 영속 + 메모리 기반 핫캐시 이중 구조.
4
+
5
+ import { createHash } from "node:crypto";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const DEFAULT_CACHE_DIR = join(homedir(), ".triflux", "mcp-cache");
18
+ const DEFAULT_MAX_ENTRIES = 500;
19
+
20
+ // 서버별 TTL (ms)
21
+ const SERVER_TTL = Object.freeze({
22
+ context7: 60 * 60 * 1000, // 1시간 — 라이브러리 문서는 자주 안 바뀜
23
+ "brave-search": 10 * 60 * 1000, // 10분 — 웹 검색
24
+ exa: 10 * 60 * 1000, // 10분
25
+ tavily: 10 * 60 * 1000, // 10분
26
+ serena: 5 * 60 * 1000, // 5분 — 코드 분석 (코드 변경 가능)
27
+ jira: 2 * 60 * 1000, // 2분 — 이슈 상태 자주 변경
28
+ notion: 5 * 60 * 1000, // 5분
29
+ _default: 5 * 60 * 1000, // 기본 5분
30
+ });
31
+
32
+ function hashKey(server, method, params) {
33
+ const payload = JSON.stringify({ server, method, params });
34
+ return createHash("sha256").update(payload).digest("hex").slice(0, 16);
35
+ }
36
+
37
+ /**
38
+ * MCP 응답 캐시 생성.
39
+ * @param {object} [opts]
40
+ * @param {string} [opts.cacheDir] — 캐시 디렉토리
41
+ * @param {number} [opts.maxEntries] — 최대 엔트리 수 (LRU)
42
+ * @returns {McpResponseCache}
43
+ */
44
+ export function createMcpResponseCache(opts = {}) {
45
+ const cacheDir = opts.cacheDir || DEFAULT_CACHE_DIR;
46
+ const maxEntries = opts.maxEntries || DEFAULT_MAX_ENTRIES;
47
+
48
+ mkdirSync(cacheDir, { recursive: true });
49
+
50
+ // 메모리 핫캐시 (Map — insertion order = LRU)
51
+ const hot = new Map();
52
+
53
+ let hits = 0;
54
+ let misses = 0;
55
+
56
+ function getTtl(server) {
57
+ return SERVER_TTL[server] || SERVER_TTL._default;
58
+ }
59
+
60
+ function getCachePath(key) {
61
+ return join(cacheDir, `${key}.json`);
62
+ }
63
+
64
+ /**
65
+ * 캐시 조회.
66
+ * @param {string} server — MCP 서버 이름
67
+ * @param {string} method — MCP 메서드
68
+ * @param {*} params — 요청 파라미터
69
+ * @returns {{ hit: boolean, data?: * }}
70
+ */
71
+ function get(server, method, params) {
72
+ const key = hashKey(server, method, params);
73
+ const ttl = getTtl(server);
74
+
75
+ // 1) 메모리 핫캐시
76
+ if (hot.has(key)) {
77
+ const entry = hot.get(key);
78
+ if (Date.now() - entry.ts < ttl) {
79
+ hits++;
80
+ // LRU: 삭제 후 재삽입 (Map은 insertion order)
81
+ hot.delete(key);
82
+ hot.set(key, entry);
83
+ return { hit: true, data: entry.data };
84
+ }
85
+ hot.delete(key);
86
+ }
87
+
88
+ // 2) 파일 캐시
89
+ const path = getCachePath(key);
90
+ if (existsSync(path)) {
91
+ try {
92
+ const entry = JSON.parse(readFileSync(path, "utf8"));
93
+ if (Date.now() - entry.ts < ttl) {
94
+ hits++;
95
+ hot.set(key, entry);
96
+ evictIfNeeded();
97
+ return { hit: true, data: entry.data };
98
+ }
99
+ // expired — 삭제
100
+ try {
101
+ unlinkSync(path);
102
+ } catch {
103
+ /* ignore */
104
+ }
105
+ } catch {
106
+ /* corrupt file */
107
+ }
108
+ }
109
+
110
+ misses++;
111
+ return { hit: false };
112
+ }
113
+
114
+ /**
115
+ * 캐시 저장.
116
+ * @param {string} server
117
+ * @param {string} method
118
+ * @param {*} params
119
+ * @param {*} data — 응답 데이터
120
+ */
121
+ function set(server, method, params, data) {
122
+ const key = hashKey(server, method, params);
123
+ const entry = { ts: Date.now(), server, method, data };
124
+
125
+ // 메모리
126
+ hot.set(key, entry);
127
+ evictIfNeeded();
128
+
129
+ // 파일
130
+ try {
131
+ writeFileSync(getCachePath(key), JSON.stringify(entry), "utf8");
132
+ } catch {
133
+ /* best-effort */
134
+ }
135
+ }
136
+
137
+ function evictIfNeeded() {
138
+ while (hot.size > maxEntries) {
139
+ const oldest = hot.keys().next().value;
140
+ hot.delete(oldest);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 만료된 파일 캐시 정리.
146
+ * @returns {number} 삭제된 파일 수
147
+ */
148
+ function prune() {
149
+ let pruned = 0;
150
+ try {
151
+ for (const name of readdirSync(cacheDir)) {
152
+ if (!name.endsWith(".json")) continue;
153
+ const path = join(cacheDir, name);
154
+ try {
155
+ const entry = JSON.parse(readFileSync(path, "utf8"));
156
+ const ttl = getTtl(entry.server || "_default");
157
+ if (Date.now() - entry.ts >= ttl) {
158
+ unlinkSync(path);
159
+ pruned++;
160
+ }
161
+ } catch {
162
+ // corrupt — 삭제
163
+ try {
164
+ unlinkSync(path);
165
+ pruned++;
166
+ } catch {
167
+ /* ignore */
168
+ }
169
+ }
170
+ }
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ return pruned;
175
+ }
176
+
177
+ /**
178
+ * 캐시 통계.
179
+ * @returns {{ hits, misses, hotSize, diskFiles, hitRate }}
180
+ */
181
+ function stats() {
182
+ let diskFiles = 0;
183
+ try {
184
+ diskFiles = readdirSync(cacheDir).filter((n) =>
185
+ n.endsWith(".json"),
186
+ ).length;
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+
191
+ const total = hits + misses;
192
+ return {
193
+ hits,
194
+ misses,
195
+ hotSize: hot.size,
196
+ diskFiles,
197
+ hitRate: total > 0 ? `${((hits / total) * 100).toFixed(1)}%` : "0%",
198
+ };
199
+ }
200
+
201
+ /** 전체 캐시 초기화. */
202
+ function clear() {
203
+ hot.clear();
204
+ try {
205
+ for (const name of readdirSync(cacheDir)) {
206
+ if (name.endsWith(".json")) {
207
+ try {
208
+ unlinkSync(join(cacheDir, name));
209
+ } catch {
210
+ /* ignore */
211
+ }
212
+ }
213
+ }
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ hits = 0;
218
+ misses = 0;
219
+ }
220
+
221
+ return Object.freeze({
222
+ get,
223
+ set,
224
+ prune,
225
+ stats,
226
+ clear,
227
+ get cacheDir() {
228
+ return cacheDir;
229
+ },
230
+ });
231
+ }
232
+
233
+ // 싱글톤 인스턴스 (Hub 프로세스 내에서 공유)
234
+ let _instance = null;
235
+
236
+ export function getMcpResponseCache(opts) {
237
+ if (!_instance) _instance = createMcpResponseCache(opts);
238
+ return _instance;
239
+ }