@triflux/core 10.1.0 → 10.25.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/hooks/agent-route-guard.mjs +14 -10
- package/hooks/cross-review-tracker.mjs +21 -8
- package/hooks/error-context.mjs +20 -8
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +267 -35
- package/hooks/hook-registry.json +343 -256
- package/hooks/hooks.json +49 -1
- package/hooks/keyword-rules.json +183 -23
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hooks/mcp-config-watcher.mjs +6 -2
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +369 -22
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/safety-guard.mjs +254 -33
- package/hooks/session-end-cleanup.mjs +345 -0
- package/hooks/session-start-fast.mjs +214 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +9 -4
- package/hub/account-broker.mjs +1036 -66
- package/hub/adaptive-diagnostic.mjs +75 -48
- package/hub/adaptive-inject.mjs +96 -58
- package/hub/adaptive-memory.mjs +156 -42
- package/hub/adaptive.mjs +60 -31
- package/hub/assign-callbacks.mjs +163 -126
- package/hub/bridge.mjs +831 -290
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -4
- package/hub/codex-preflight.mjs +113 -37
- package/hub/delegator/contracts.mjs +19 -23
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/service.mjs +88 -64
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/fullcycle.mjs +34 -18
- package/hub/gemini-adapter.mjs +77 -95
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +167 -38
- package/hub/lib/bash-path.mjs +73 -0
- package/hub/lib/cache-guard.mjs +43 -17
- package/hub/lib/env-detect.mjs +145 -0
- package/hub/lib/hosts-compat.mjs +319 -0
- package/hub/lib/mcp-response-cache.mjs +239 -0
- package/hub/lib/memory-store.mjs +285 -111
- package/hub/lib/path-utils.mjs +165 -0
- package/hub/lib/phase-manager.mjs +493 -0
- package/hub/lib/process-utils.mjs +797 -104
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/risk-tier.mjs +53 -0
- package/hub/lib/spawn-trace.mjs +561 -0
- package/hub/lib/ssh-command.mjs +44 -24
- package/hub/lib/ssh-retry.mjs +5 -1
- package/hub/lib/staged-file-detect.mjs +43 -0
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/lib/tfx-route-args.mjs +309 -0
- package/hub/lib/timeline-adapter.mjs +190 -0
- package/hub/lib/trace-recorder.mjs +153 -0
- package/hub/lib/uuidv7.mjs +4 -3
- package/hub/memory-doctor.mjs +266 -106
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/middleware/request-logger.mjs +61 -34
- package/hub/paths.mjs +9 -9
- package/hub/pipeline/gates/confidence.mjs +34 -15
- package/hub/pipeline/gates/consensus.mjs +27 -15
- package/hub/pipeline/gates/index.mjs +7 -3
- package/hub/pipeline/gates/selfcheck.mjs +57 -19
- package/hub/pipeline/index.mjs +77 -42
- package/hub/pipeline/state.mjs +10 -10
- package/hub/pipeline/transitions.mjs +40 -23
- package/hub/platform.mjs +65 -48
- package/hub/quality/deslop.mjs +70 -49
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +33 -25
- package/hub/router.mjs +941 -791
- package/hub/routing/complexity.mjs +132 -29
- package/hub/routing/index.mjs +17 -12
- package/hub/routing/q-learning.mjs +76 -28
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/session-fingerprint.mjs +127 -61
- package/hub/state.mjs +86 -44
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/token-mode.mjs +72 -71
- package/hub/workers/worker-utils.mjs +22 -16
- package/hud/colors.mjs +27 -9
- package/hud/constants.mjs +184 -27
- package/hud/context-monitor.mjs +133 -44
- package/hud/hud-qos-status.mjs +153 -50
- package/hud/mission-board.mjs +56 -0
- package/hud/providers/claude.mjs +398 -114
- package/hud/providers/codex.mjs +96 -21
- package/hud/providers/gemini.mjs +184 -51
- package/hud/renderers.mjs +413 -123
- package/hud/terminal.mjs +101 -31
- package/hud/utils.mjs +81 -39
- package/mesh/index.mjs +69 -0
- package/mesh/mesh-budget.mjs +137 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +163 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +77 -0
- package/package.json +5 -1
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +6 -1
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +3 -3
- package/scripts/lib/cross-review-utils.mjs +13 -5
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +58 -30
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +44 -10
- package/scripts/lib/handoff.mjs +33 -17
- package/scripts/lib/hook-utils.mjs +8 -6
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +48 -19
- package/scripts/lib/logger.mjs +24 -24
- package/scripts/lib/mcp-filter.mjs +391 -247
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +1281 -115
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +24 -14
- package/scripts/lib/mcp-server-catalog.mjs +355 -118
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +70 -10
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +44 -14
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +32 -7
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- 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
|
+
}
|