@triflux/core 10.0.0-alpha.1 → 10.0.0-alpha.2
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/hook-adaptive-collector.mjs +86 -0
- package/hooks/hook-manager.mjs +15 -2
- package/hooks/hook-registry.json +37 -4
- package/hooks/mcp-config-watcher.mjs +2 -7
- package/hooks/safety-guard.mjs +37 -0
- package/hub/account-broker.mjs +251 -0
- package/hub/adaptive-diagnostic.mjs +323 -0
- package/hub/adaptive-inject.mjs +186 -0
- package/hub/adaptive-memory.mjs +163 -0
- package/hub/adaptive.mjs +143 -0
- package/hub/cli-adapter-base.mjs +89 -1
- package/hub/codex-adapter.mjs +12 -3
- package/hub/codex-compat.mjs +11 -78
- package/hub/codex-preflight.mjs +20 -1
- package/hub/gemini-adapter.mjs +1 -0
- package/hub/index.mjs +34 -0
- package/hub/lib/cache-guard.mjs +114 -0
- package/hub/lib/known-errors.json +72 -0
- package/hub/lib/memory-store.mjs +748 -0
- package/hub/lib/ssh-command.mjs +150 -0
- package/hub/lib/uuidv7.mjs +44 -0
- package/hub/memory-doctor.mjs +480 -0
- package/hub/middleware/request-logger.mjs +80 -0
- package/hub/router.mjs +1 -1
- package/hub/team-bridge.mjs +21 -19
- package/hud/constants.mjs +7 -0
- package/hud/context-monitor.mjs +403 -0
- package/hud/hud-qos-status.mjs +8 -4
- package/hud/providers/claude.mjs +5 -0
- package/hud/renderers.mjs +32 -14
- package/hud/utils.mjs +26 -0
- package/package.json +3 -2
- package/scripts/lib/claudemd-scanner.mjs +218 -0
- package/scripts/lib/handoff.mjs +171 -0
- package/scripts/lib/mcp-guard-engine.mjs +20 -6
- package/scripts/lib/skill-template.mjs +222 -0
- package/scripts/lib/claudemd-manager.mjs +0 -325
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { withRequestContext, getCorrelationId } from '../../scripts/lib/context.mjs';
|
|
14
14
|
import { createModuleLogger } from '../../scripts/lib/logger.mjs';
|
|
15
|
+
import { createContextMonitor } from '../../hud/context-monitor.mjs';
|
|
15
16
|
|
|
16
17
|
const log = createModuleLogger('hub');
|
|
18
|
+
const contextMonitor = createContextMonitor();
|
|
17
19
|
|
|
18
20
|
const SKIP_PATHS = new Set(['/health', '/healthz', '/status', '/ready']);
|
|
21
|
+
const MAX_CAPTURE_BYTES = 256 * 1024;
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* 원본 request handler를 래핑하여 로깅 + 컨텍스트 전파를 추가한다.
|
|
@@ -44,6 +47,50 @@ export function wrapRequestHandler(handler) {
|
|
|
44
47
|
},
|
|
45
48
|
() => {
|
|
46
49
|
const startTime = process.hrtime.bigint();
|
|
50
|
+
const reqChunks = [];
|
|
51
|
+
let reqBytes = 0;
|
|
52
|
+
let reqOverflow = false;
|
|
53
|
+
|
|
54
|
+
req.on('data', (chunk) => {
|
|
55
|
+
if (reqOverflow) return;
|
|
56
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
57
|
+
reqBytes += buf.length;
|
|
58
|
+
if (reqBytes > MAX_CAPTURE_BYTES) {
|
|
59
|
+
reqOverflow = true;
|
|
60
|
+
reqChunks.length = 0;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
reqChunks.push(buf);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const resChunks = [];
|
|
67
|
+
let resBytes = 0;
|
|
68
|
+
let resOverflow = false;
|
|
69
|
+
|
|
70
|
+
const originalWrite = res.write.bind(res);
|
|
71
|
+
const originalEnd = res.end.bind(res);
|
|
72
|
+
|
|
73
|
+
function captureResponseChunk(chunk) {
|
|
74
|
+
if (resOverflow || chunk == null) return;
|
|
75
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
76
|
+
resBytes += buf.length;
|
|
77
|
+
if (resBytes > MAX_CAPTURE_BYTES) {
|
|
78
|
+
resOverflow = true;
|
|
79
|
+
resChunks.length = 0;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
resChunks.push(buf);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
res.write = function writePatched(chunk, ...args) {
|
|
86
|
+
captureResponseChunk(chunk);
|
|
87
|
+
return originalWrite(chunk, ...args);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
res.end = function endPatched(chunk, ...args) {
|
|
91
|
+
captureResponseChunk(chunk);
|
|
92
|
+
return originalEnd(chunk, ...args);
|
|
93
|
+
};
|
|
47
94
|
|
|
48
95
|
// 응답 헤더에 상관 ID 포함
|
|
49
96
|
const cid = getCorrelationId();
|
|
@@ -55,15 +102,48 @@ export function wrapRequestHandler(handler) {
|
|
|
55
102
|
const level = res.statusCode >= 500 ? 'error'
|
|
56
103
|
: res.statusCode >= 400 ? 'warn'
|
|
57
104
|
: 'info';
|
|
105
|
+
const reqBodyText = reqOverflow ? '' : Buffer.concat(reqChunks).toString('utf8');
|
|
106
|
+
const resBodyText = resOverflow ? '' : Buffer.concat(resChunks).toString('utf8');
|
|
107
|
+
const tokenSummary = contextMonitor.record({
|
|
108
|
+
requestBody: reqBodyText,
|
|
109
|
+
requestBytes: reqBytes || Number(req.headers['content-length'] || 0),
|
|
110
|
+
responseBody: resBodyText,
|
|
111
|
+
responseBytes: resBytes || Number(res.getHeader('content-length') || 0),
|
|
112
|
+
});
|
|
58
113
|
|
|
59
114
|
log[level](
|
|
60
115
|
{
|
|
61
116
|
status: res.statusCode,
|
|
62
117
|
duration: Math.round(duration * 100) / 100,
|
|
63
118
|
contentLength: res.getHeader('content-length') || 0,
|
|
119
|
+
tokenUsage: {
|
|
120
|
+
request: tokenSummary.requestTokens,
|
|
121
|
+
response: tokenSummary.responseTokens,
|
|
122
|
+
total: tokenSummary.totalTokens,
|
|
123
|
+
context: tokenSummary.display,
|
|
124
|
+
warningLevel: tokenSummary.warningLevel,
|
|
125
|
+
overheadMs: tokenSummary.overheadMs,
|
|
126
|
+
},
|
|
64
127
|
},
|
|
65
128
|
'http.response',
|
|
66
129
|
);
|
|
130
|
+
|
|
131
|
+
if (tokenSummary.warningLevel === 'critical') {
|
|
132
|
+
log.error(
|
|
133
|
+
{ context: tokenSummary.display, message: tokenSummary.warningMessage },
|
|
134
|
+
'context.critical',
|
|
135
|
+
);
|
|
136
|
+
} else if (tokenSummary.warningLevel === 'warn') {
|
|
137
|
+
log.warn(
|
|
138
|
+
{ context: tokenSummary.display, message: tokenSummary.warningMessage },
|
|
139
|
+
'context.warn',
|
|
140
|
+
);
|
|
141
|
+
} else if (tokenSummary.warningLevel === 'info') {
|
|
142
|
+
log.info(
|
|
143
|
+
{ context: tokenSummary.display, message: tokenSummary.warningMessage },
|
|
144
|
+
'context.info',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
67
147
|
});
|
|
68
148
|
|
|
69
149
|
handler(req, res);
|
package/hub/router.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// hub/router.mjs — 실시간 라우팅/수신함 상태 관리자
|
|
2
2
|
// SQLite는 감사 로그만 담당하고, 실제 배달 상태는 메모리에서 관리한다.
|
|
3
3
|
import { EventEmitter, once } from 'node:events';
|
|
4
|
-
import { uuidv7 } from './
|
|
4
|
+
import { uuidv7 } from './lib/uuidv7.mjs';
|
|
5
5
|
|
|
6
6
|
const ASSIGN_PENDING_STATUSES = new Set(['queued', 'running']);
|
|
7
7
|
|
package/hub/team-bridge.mjs
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// registry 패턴으로 구현을 주입받는다.
|
|
4
|
-
// remote 미설치 시 graceful no-op 반환.
|
|
1
|
+
// @triflux/core — team-bridge 인터페이스
|
|
2
|
+
// remote 패키지가 런타임에 구현을 주입한다.
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} TeamBridge
|
|
6
|
+
* @property {(args?: object) => Promise<object>} teamInfo
|
|
7
|
+
* @property {(args?: object) => Promise<object>} teamTaskList
|
|
8
|
+
* @property {(args?: object) => Promise<object>} teamTaskUpdate
|
|
9
|
+
* @property {(args?: object) => Promise<object>} teamSendMessage
|
|
10
|
+
*/
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
teamTaskList: noTeam,
|
|
14
|
-
teamTaskUpdate: noTeam,
|
|
15
|
-
teamSendMessage: noTeam,
|
|
16
|
-
};
|
|
12
|
+
/** @type {TeamBridge | null} */
|
|
13
|
+
let _bridge = null;
|
|
17
14
|
|
|
15
|
+
/**
|
|
16
|
+
* @param {TeamBridge | null} impl
|
|
17
|
+
*/
|
|
18
18
|
export function registerTeamBridge(impl) {
|
|
19
|
-
|
|
19
|
+
_bridge = impl;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export function
|
|
22
|
+
/**
|
|
23
|
+
* @returns {TeamBridge | null}
|
|
24
|
+
*/
|
|
25
|
+
export function getTeamBridge() {
|
|
26
|
+
return _bridge;
|
|
27
|
+
}
|
package/hud/constants.mjs
CHANGED
|
@@ -12,6 +12,13 @@ export const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_account
|
|
|
12
12
|
|
|
13
13
|
// tfx-multi 상태 (v2.2 HUD 통합)
|
|
14
14
|
export const TEAM_STATE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "team-state.json");
|
|
15
|
+
export const CONTEXT_MONITOR_CACHE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "context-monitor.json");
|
|
16
|
+
export const CONTEXT_MONITOR_LEGACY_PATH = join(homedir(), ".omc", "state", "context-monitor.json");
|
|
17
|
+
export const CONTEXT_MONITOR_LOG_DIR = join(homedir(), ".omc", "logs");
|
|
18
|
+
|
|
19
|
+
// 원격 프로브 캐시 (tfx-remote-spawn)
|
|
20
|
+
export const REMOTE_ENV_CACHE_DIR = join(homedir(), ".claude", "cache", "tfx-hub", "remote-env");
|
|
21
|
+
export const REMOTE_ENV_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
|
|
15
22
|
|
|
16
23
|
// Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
|
|
17
24
|
export const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CONTEXT_MONITOR_CACHE_PATH,
|
|
8
|
+
CONTEXT_MONITOR_LEGACY_PATH,
|
|
9
|
+
CONTEXT_MONITOR_LOG_DIR,
|
|
10
|
+
} from "./constants.mjs";
|
|
11
|
+
import { clampPercent, formatTokenCount, readJsonMigrate } from "./utils.mjs";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
14
|
+
const MAX_CAPTURE_BYTES = 256 * 1024;
|
|
15
|
+
const MAX_TOP_KEYS = 20;
|
|
16
|
+
|
|
17
|
+
const WARNING_LEVELS = Object.freeze({
|
|
18
|
+
ok: { min: 0, message: "" },
|
|
19
|
+
info: { min: 60, message: "컨텍스트 절반 이상 사용" },
|
|
20
|
+
warn: { min: 80, message: "압축 권장" },
|
|
21
|
+
critical: { min: 90, message: "에이전트 분할 또는 세션 교체 권장" },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Unlike clampPercent (rounds), this preserves decimals for precise threshold comparison.
|
|
25
|
+
function clampThresholdPercent(value) {
|
|
26
|
+
const numeric = Number(value);
|
|
27
|
+
if (!Number.isFinite(numeric)) return 0;
|
|
28
|
+
return Math.max(0, Math.min(100, numeric));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeWriteJson(filePath, data) {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
34
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
35
|
+
} catch {
|
|
36
|
+
// noop
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeText(input) {
|
|
41
|
+
if (typeof input === "string") return input;
|
|
42
|
+
if (input == null) return "";
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(input);
|
|
45
|
+
} catch {
|
|
46
|
+
return String(input);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeJsonParse(raw) {
|
|
51
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toTokenEstimate(bytesOrText) {
|
|
60
|
+
if (typeof bytesOrText === "number" && Number.isFinite(bytesOrText)) {
|
|
61
|
+
if (bytesOrText <= 0) return 0;
|
|
62
|
+
return Math.max(1, Math.ceil(bytesOrText / 4));
|
|
63
|
+
}
|
|
64
|
+
const text = normalizeText(bytesOrText);
|
|
65
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
66
|
+
if (bytes <= 0) return 0;
|
|
67
|
+
return Math.max(1, Math.ceil(bytes / 4));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeUsage(usage) {
|
|
71
|
+
if (!usage || typeof usage !== "object") return null;
|
|
72
|
+
const input = Number(usage.input_tokens ?? usage.inputTokens ?? 0);
|
|
73
|
+
const output = Number(usage.output_tokens ?? usage.outputTokens ?? 0);
|
|
74
|
+
const cacheCreation = Number(
|
|
75
|
+
usage.cache_creation_input_tokens
|
|
76
|
+
?? usage.cacheCreationInputTokens
|
|
77
|
+
?? usage.cache_creation_tokens
|
|
78
|
+
?? 0,
|
|
79
|
+
);
|
|
80
|
+
const cacheRead = Number(
|
|
81
|
+
usage.cache_read_input_tokens
|
|
82
|
+
?? usage.cacheReadInputTokens
|
|
83
|
+
?? usage.cache_read_tokens
|
|
84
|
+
?? 0,
|
|
85
|
+
);
|
|
86
|
+
const totalCandidate = Number(usage.total_tokens ?? usage.totalTokens ?? Number.NaN);
|
|
87
|
+
const total = Number.isFinite(totalCandidate) && totalCandidate > 0
|
|
88
|
+
? totalCandidate
|
|
89
|
+
: input + output + cacheCreation + cacheRead;
|
|
90
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
91
|
+
return {
|
|
92
|
+
input: Math.max(0, Math.round(input)),
|
|
93
|
+
output: Math.max(0, Math.round(output)),
|
|
94
|
+
cacheCreation: Math.max(0, Math.round(cacheCreation)),
|
|
95
|
+
cacheRead: Math.max(0, Math.round(cacheRead)),
|
|
96
|
+
total: Math.max(0, Math.round(total)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractUsage(payload) {
|
|
101
|
+
if (!payload || typeof payload !== "object") return null;
|
|
102
|
+
|
|
103
|
+
const queue = [payload];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
|
|
106
|
+
while (queue.length > 0) {
|
|
107
|
+
const current = queue.shift();
|
|
108
|
+
if (!current || typeof current !== "object") continue;
|
|
109
|
+
if (seen.has(current)) continue;
|
|
110
|
+
seen.add(current);
|
|
111
|
+
|
|
112
|
+
const directUsage = normalizeUsage(current.usage);
|
|
113
|
+
if (directUsage) return directUsage;
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(current.content)) {
|
|
116
|
+
for (const item of current.content) queue.push(item);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const key of ["result", "payload", "response", "message", "data"]) {
|
|
120
|
+
if (current[key] && typeof current[key] === "object") {
|
|
121
|
+
queue.push(current[key]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function bumpCounter(target, key, tokens) {
|
|
129
|
+
if (!key) return;
|
|
130
|
+
const prev = target[key] || 0;
|
|
131
|
+
target[key] = prev + tokens;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pushTopKeys(inputMap, maxKeys = MAX_TOP_KEYS) {
|
|
135
|
+
const entries = Object.entries(inputMap || {});
|
|
136
|
+
entries.sort((a, b) => b[1] - a[1]);
|
|
137
|
+
return Object.fromEntries(entries.slice(0, maxKeys));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractFileKeys(args) {
|
|
141
|
+
if (!args || typeof args !== "object") return [];
|
|
142
|
+
const out = [];
|
|
143
|
+
const add = (value) => {
|
|
144
|
+
if (typeof value !== "string" || !value.trim()) return;
|
|
145
|
+
out.push(value.trim());
|
|
146
|
+
};
|
|
147
|
+
add(args.path);
|
|
148
|
+
add(args.file);
|
|
149
|
+
add(args.filename);
|
|
150
|
+
add(args.relative_path);
|
|
151
|
+
add(args.requestFilePath);
|
|
152
|
+
add(args.responseFilePath);
|
|
153
|
+
if (Array.isArray(args.paths)) {
|
|
154
|
+
for (const p of args.paths) add(p);
|
|
155
|
+
}
|
|
156
|
+
return Array.from(new Set(out)).slice(0, 5);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function detectSkillHints(payloadText) {
|
|
160
|
+
if (!payloadText) return [];
|
|
161
|
+
const matches = payloadText.match(/\$[a-z0-9_-]+/gi) || [];
|
|
162
|
+
return Array.from(new Set(matches.map((m) => m.replace(/^\$/, "")))).slice(0, 5);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function estimateTokens(input) {
|
|
166
|
+
return toTokenEstimate(input);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseUsageFromPayload(payload) {
|
|
170
|
+
return extractUsage(payload);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function classifyContextThreshold(percent) {
|
|
174
|
+
const p = clampThresholdPercent(percent);
|
|
175
|
+
if (p >= WARNING_LEVELS.critical.min) return { level: "critical", message: WARNING_LEVELS.critical.message };
|
|
176
|
+
if (p >= WARNING_LEVELS.warn.min) return { level: "warn", message: WARNING_LEVELS.warn.message };
|
|
177
|
+
if (p >= WARNING_LEVELS.info.min) return { level: "info", message: WARNING_LEVELS.info.message };
|
|
178
|
+
return { level: "ok", message: "" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatContextUsage(usedTokens, limitTokens, percent = null) {
|
|
182
|
+
const used = Math.max(0, Math.round(Number(usedTokens) || 0));
|
|
183
|
+
const limit = Math.max(1, Math.round(Number(limitTokens) || DEFAULT_CONTEXT_LIMIT));
|
|
184
|
+
const pct = percent == null ? clampPercent((used / limit) * 100) : clampPercent(percent);
|
|
185
|
+
return `${formatTokenCount(used)}/${formatTokenCount(limit)} (${pct}%)`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readContextMonitorSnapshot() {
|
|
189
|
+
return readJsonMigrate(CONTEXT_MONITOR_CACHE_PATH, CONTEXT_MONITOR_LEGACY_PATH, null);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getStdinContextUsage(stdin) {
|
|
193
|
+
const limitTokens = Number(stdin?.context_window?.context_window_size || 0);
|
|
194
|
+
const nativePercent = Number(stdin?.context_window?.used_percentage);
|
|
195
|
+
const usage = stdin?.context_window?.current_usage || {};
|
|
196
|
+
const explicitUsed = Number(usage.total_tokens || 0);
|
|
197
|
+
const calculatedUsed = Number(usage.input_tokens || 0)
|
|
198
|
+
+ Number(usage.cache_creation_input_tokens || 0)
|
|
199
|
+
+ Number(usage.cache_read_input_tokens || 0);
|
|
200
|
+
const usedTokens = explicitUsed > 0 ? explicitUsed : calculatedUsed;
|
|
201
|
+
|
|
202
|
+
if (limitTokens > 0 && usedTokens > 0) {
|
|
203
|
+
return {
|
|
204
|
+
usedTokens: Math.round(usedTokens),
|
|
205
|
+
limitTokens: Math.round(limitTokens),
|
|
206
|
+
percent: clampPercent((usedTokens / limitTokens) * 100),
|
|
207
|
+
source: "stdin.tokens",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (limitTokens > 0 && Number.isFinite(nativePercent)) {
|
|
212
|
+
const percent = clampPercent(nativePercent);
|
|
213
|
+
return {
|
|
214
|
+
usedTokens: Math.round((limitTokens * percent) / 100),
|
|
215
|
+
limitTokens: Math.round(limitTokens),
|
|
216
|
+
percent,
|
|
217
|
+
source: "stdin.percent",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function buildContextUsageView(stdin, snapshot = null) {
|
|
224
|
+
const stdinUsage = getStdinContextUsage(stdin);
|
|
225
|
+
const monitor = snapshot || readContextMonitorSnapshot();
|
|
226
|
+
const fallbackLimit = Number(monitor?.limitTokens || DEFAULT_CONTEXT_LIMIT);
|
|
227
|
+
|
|
228
|
+
const usedTokens = stdinUsage?.usedTokens
|
|
229
|
+
?? Number(monitor?.usedTokens || 0);
|
|
230
|
+
const limitTokens = stdinUsage?.limitTokens
|
|
231
|
+
?? Math.max(1, fallbackLimit);
|
|
232
|
+
const percent = stdinUsage?.percent
|
|
233
|
+
?? (limitTokens > 0 ? clampPercent((usedTokens / limitTokens) * 100) : 0);
|
|
234
|
+
|
|
235
|
+
const warning = classifyContextThreshold(percent);
|
|
236
|
+
return {
|
|
237
|
+
usedTokens,
|
|
238
|
+
limitTokens,
|
|
239
|
+
percent,
|
|
240
|
+
display: formatContextUsage(usedTokens, limitTokens, percent),
|
|
241
|
+
warningLevel: warning.level,
|
|
242
|
+
warningMessage: warning.message,
|
|
243
|
+
warningTag: warning.level === "warn" ? "⚠ 압축 권장"
|
|
244
|
+
: warning.level === "critical" ? "‼ 분할 권장"
|
|
245
|
+
: warning.level === "info" ? "ℹ 절반 이상 사용"
|
|
246
|
+
: "",
|
|
247
|
+
source: stdinUsage?.source || (monitor ? "monitor" : "none"),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createContextMonitor(options = {}) {
|
|
252
|
+
const limitTokens = Number(options.limitTokens || DEFAULT_CONTEXT_LIMIT);
|
|
253
|
+
const cachePath = options.cachePath || CONTEXT_MONITOR_CACHE_PATH;
|
|
254
|
+
const logsDir = options.logsDir || CONTEXT_MONITOR_LOG_DIR;
|
|
255
|
+
const sessionId = options.sessionId || randomUUID().slice(0, 8);
|
|
256
|
+
const registerExitHooks = options.registerExitHooks !== false;
|
|
257
|
+
|
|
258
|
+
const state = {
|
|
259
|
+
sessionId,
|
|
260
|
+
startedAt: new Date().toISOString(),
|
|
261
|
+
updatedAt: null,
|
|
262
|
+
limitTokens,
|
|
263
|
+
usedTokens: 0,
|
|
264
|
+
requestTokens: 0,
|
|
265
|
+
responseTokens: 0,
|
|
266
|
+
exactUsageTokens: 0,
|
|
267
|
+
totalUpdates: 0,
|
|
268
|
+
maxPercent: 0,
|
|
269
|
+
warningLevel: "ok",
|
|
270
|
+
warningMessage: "",
|
|
271
|
+
bySkill: {},
|
|
272
|
+
byFile: {},
|
|
273
|
+
byTool: {},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const writeSnapshot = () => {
|
|
277
|
+
const percent = clampPercent((state.usedTokens / state.limitTokens) * 100);
|
|
278
|
+
const warning = classifyContextThreshold(percent);
|
|
279
|
+
state.maxPercent = Math.max(state.maxPercent, percent);
|
|
280
|
+
state.warningLevel = warning.level;
|
|
281
|
+
state.warningMessage = warning.message;
|
|
282
|
+
state.updatedAt = new Date().toISOString();
|
|
283
|
+
safeWriteJson(cachePath, {
|
|
284
|
+
...state,
|
|
285
|
+
display: formatContextUsage(state.usedTokens, state.limitTokens, percent),
|
|
286
|
+
percent,
|
|
287
|
+
bySkill: pushTopKeys(state.bySkill),
|
|
288
|
+
byFile: pushTopKeys(state.byFile),
|
|
289
|
+
byTool: pushTopKeys(state.byTool),
|
|
290
|
+
});
|
|
291
|
+
return { percent, warning };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const writeReport = (reason = "shutdown") => {
|
|
295
|
+
const percent = clampPercent((state.usedTokens / state.limitTokens) * 100);
|
|
296
|
+
const warning = classifyContextThreshold(percent);
|
|
297
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
298
|
+
const reportPath = join(logsDir, `context-usage-${state.sessionId}-${ts}.json`);
|
|
299
|
+
safeWriteJson(reportPath, {
|
|
300
|
+
sessionId: state.sessionId,
|
|
301
|
+
reason,
|
|
302
|
+
startedAt: state.startedAt,
|
|
303
|
+
endedAt: new Date().toISOString(),
|
|
304
|
+
summary: {
|
|
305
|
+
usedTokens: state.usedTokens,
|
|
306
|
+
limitTokens: state.limitTokens,
|
|
307
|
+
percent,
|
|
308
|
+
warningLevel: warning.level,
|
|
309
|
+
warningMessage: warning.message,
|
|
310
|
+
requestTokens: state.requestTokens,
|
|
311
|
+
responseTokens: state.responseTokens,
|
|
312
|
+
exactUsageTokens: state.exactUsageTokens,
|
|
313
|
+
updates: state.totalUpdates,
|
|
314
|
+
},
|
|
315
|
+
breakdown: {
|
|
316
|
+
skills: pushTopKeys(state.bySkill),
|
|
317
|
+
files: pushTopKeys(state.byFile),
|
|
318
|
+
tools: pushTopKeys(state.byTool),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
return reportPath;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const record = ({
|
|
325
|
+
requestBody = null,
|
|
326
|
+
requestBytes = 0,
|
|
327
|
+
responseBody = null,
|
|
328
|
+
responseBytes = 0,
|
|
329
|
+
toolName = "",
|
|
330
|
+
} = {}) => {
|
|
331
|
+
const started = process.hrtime.bigint();
|
|
332
|
+
const reqObj = typeof requestBody === "object" ? requestBody : safeJsonParse(String(requestBody || ""));
|
|
333
|
+
const resObj = typeof responseBody === "object" ? responseBody : safeJsonParse(String(responseBody || ""));
|
|
334
|
+
|
|
335
|
+
const usage = parseUsageFromPayload(resObj);
|
|
336
|
+
const requestTokens = requestBytes > 0 ? toTokenEstimate(requestBytes) : toTokenEstimate(requestBody);
|
|
337
|
+
const responseTokens = usage?.total ?? (responseBytes > 0 ? toTokenEstimate(responseBytes) : toTokenEstimate(responseBody));
|
|
338
|
+
const totalTokens = Math.max(0, requestTokens + responseTokens);
|
|
339
|
+
|
|
340
|
+
const method = reqObj?.method || reqObj?.params?.name || "";
|
|
341
|
+
const name = toolName || reqObj?.params?.name || reqObj?.tool || "";
|
|
342
|
+
const args = reqObj?.params?.arguments || reqObj?.arguments || reqObj?.params || {};
|
|
343
|
+
const payloadText = normalizeText(requestBody).slice(0, MAX_CAPTURE_BYTES);
|
|
344
|
+
const skills = detectSkillHints(payloadText);
|
|
345
|
+
const files = extractFileKeys(args);
|
|
346
|
+
|
|
347
|
+
if (name) bumpCounter(state.byTool, String(name), totalTokens);
|
|
348
|
+
if (method?.includes("tool")) {
|
|
349
|
+
bumpCounter(state.byTool, String(method), totalTokens);
|
|
350
|
+
}
|
|
351
|
+
for (const file of files) bumpCounter(state.byFile, file, totalTokens);
|
|
352
|
+
for (const skill of skills) bumpCounter(state.bySkill, skill, totalTokens);
|
|
353
|
+
|
|
354
|
+
state.requestTokens += requestTokens;
|
|
355
|
+
state.responseTokens += responseTokens;
|
|
356
|
+
state.exactUsageTokens += usage?.total || 0;
|
|
357
|
+
state.usedTokens += totalTokens;
|
|
358
|
+
state.totalUpdates += 1;
|
|
359
|
+
|
|
360
|
+
const { percent, warning } = writeSnapshot();
|
|
361
|
+
const overheadMs = Number(process.hrtime.bigint() - started) / 1_000_000;
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
requestTokens,
|
|
365
|
+
responseTokens,
|
|
366
|
+
totalTokens,
|
|
367
|
+
usedTokens: state.usedTokens,
|
|
368
|
+
limitTokens: state.limitTokens,
|
|
369
|
+
percent,
|
|
370
|
+
warningLevel: warning.level,
|
|
371
|
+
warningMessage: warning.message,
|
|
372
|
+
display: formatContextUsage(state.usedTokens, state.limitTokens, percent),
|
|
373
|
+
overheadMs: Math.round(overheadMs * 1000) / 1000,
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
let reportWritten = false;
|
|
378
|
+
const flush = (reason = "shutdown") => {
|
|
379
|
+
if (reportWritten) return null;
|
|
380
|
+
reportWritten = true;
|
|
381
|
+
return writeReport(reason);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (registerExitHooks) {
|
|
385
|
+
const flushOnExit = () => {
|
|
386
|
+
try { flush("process.exit"); } catch {}
|
|
387
|
+
};
|
|
388
|
+
process.once("exit", flushOnExit);
|
|
389
|
+
process.once("SIGINT", flushOnExit);
|
|
390
|
+
process.once("SIGTERM", flushOnExit);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
record,
|
|
395
|
+
flush,
|
|
396
|
+
snapshot: () => ({
|
|
397
|
+
...state,
|
|
398
|
+
bySkill: pushTopKeys(state.bySkill),
|
|
399
|
+
byFile: pushTopKeys(state.byFile),
|
|
400
|
+
byTool: pushTopKeys(state.byTool),
|
|
401
|
+
}),
|
|
402
|
+
};
|
|
403
|
+
}
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -13,14 +13,16 @@ import {
|
|
|
13
13
|
GEMINI_PRO_POOL, GEMINI_FLASH_POOL,
|
|
14
14
|
} from "./constants.mjs";
|
|
15
15
|
import {
|
|
16
|
-
readJson, readStdinJson,
|
|
16
|
+
readJson, readStdinJson, getProviderAccountId, getCliArgValue,
|
|
17
17
|
} from "./utils.mjs";
|
|
18
18
|
import { selectTier } from "./terminal.mjs";
|
|
19
19
|
|
|
20
20
|
// Claude provider
|
|
21
21
|
import {
|
|
22
22
|
readClaudeUsageSnapshot, scheduleClaudeUsageRefresh, fetchClaudeUsage,
|
|
23
|
+
readClaudeContextSnapshot,
|
|
23
24
|
} from "./providers/claude.mjs";
|
|
25
|
+
import { buildContextUsageView } from "./context-monitor.mjs";
|
|
24
26
|
|
|
25
27
|
// Codex provider
|
|
26
28
|
import {
|
|
@@ -79,6 +81,7 @@ async function main() {
|
|
|
79
81
|
const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
|
|
80
82
|
const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
|
|
81
83
|
const claudeUsageSnapshot = readClaudeUsageSnapshot();
|
|
84
|
+
const contextSnapshot = readClaudeContextSnapshot();
|
|
82
85
|
if (claudeUsageSnapshot.shouldRefresh) {
|
|
83
86
|
scheduleClaudeUsageRefresh();
|
|
84
87
|
}
|
|
@@ -99,6 +102,7 @@ async function main() {
|
|
|
99
102
|
|
|
100
103
|
// 실측 데이터 추출
|
|
101
104
|
const stdin = await stdinPromise;
|
|
105
|
+
const contextView = buildContextUsageView(stdin, contextSnapshot);
|
|
102
106
|
const codexEmail = getCodexEmail();
|
|
103
107
|
const geminiEmail = getGeminiEmail();
|
|
104
108
|
const codexBuckets = codexSnapshot.buckets;
|
|
@@ -145,7 +149,7 @@ async function main() {
|
|
|
145
149
|
|
|
146
150
|
// nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
|
|
147
151
|
if (CURRENT_TIER === "nano") {
|
|
148
|
-
const microLine = getMicroLine(
|
|
152
|
+
const microLine = getMicroLine(contextView, claudeUsageSnapshot.data, codexBuckets,
|
|
149
153
|
geminiSession, geminiBucket, combinedSvPct);
|
|
150
154
|
process.stdout.write(`\x1b[0m${microLine}\n`);
|
|
151
155
|
return;
|
|
@@ -160,7 +164,7 @@ async function main() {
|
|
|
160
164
|
};
|
|
161
165
|
|
|
162
166
|
const rows = [
|
|
163
|
-
...getClaudeRows(CURRENT_TIER,
|
|
167
|
+
...getClaudeRows(CURRENT_TIER, contextView, claudeUsageSnapshot.data, combinedSvPct),
|
|
164
168
|
getProviderRow(CURRENT_TIER, "codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
|
|
165
169
|
codexQuotaData, codexEmail, codexSv, null),
|
|
166
170
|
getProviderRow(CURRENT_TIER, "gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
|
|
@@ -194,7 +198,7 @@ async function main() {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
// 선행 개행: 알림 배너(노란 글씨)가 빈 첫 줄에 오도록 → HUD 내용 보호
|
|
197
|
-
const contextPercent =
|
|
201
|
+
const contextPercent = contextView.percent;
|
|
198
202
|
const leadingBreaks = contextPercent >= 85 ? "\n\n" : "\n";
|
|
199
203
|
// 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
|
|
200
204
|
const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DEFAULT_OAUTH_CLIENT_ID, CLAUDE_REFRESH_FLAG,
|
|
15
15
|
} from "../constants.mjs";
|
|
16
16
|
import { readJson, writeJsonSafe, clampPercent, advanceToNextCycle } from "../utils.mjs";
|
|
17
|
+
import { readContextMonitorSnapshot } from "../context-monitor.mjs";
|
|
17
18
|
|
|
18
19
|
// OMC 활성 여부에 따라 캐시 TTL 동적 결정
|
|
19
20
|
function getClaudeUsageStaleMs() {
|
|
@@ -307,3 +308,7 @@ export function scheduleClaudeUsageRefresh() {
|
|
|
307
308
|
writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
|
|
308
309
|
}
|
|
309
310
|
}
|
|
311
|
+
|
|
312
|
+
export function readClaudeContextSnapshot() {
|
|
313
|
+
return readContextMonitorSnapshot();
|
|
314
|
+
}
|