@colin4k1024/tsp 2.5.3 → 2.5.4
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/commands/goal.md +7 -1
- package/commands/heartbeat.md +34 -1
- package/commands/loop-start.md +34 -6
- package/commands/triage.md +8 -1
- package/hooks/README.md +2 -2
- package/hooks/harness-statusline.js +9 -30
- package/hooks/strategic-compact/README.md +11 -12
- package/manifests/install-components.json +40 -0
- package/manifests/install-modules.json +43 -0
- package/manifests/install-profiles.json +2 -0
- package/package.json +1 -1
- package/schemas/loop-spec.schema.json +124 -0
- package/scripts/hooks/pre-compact.js +39 -8
- package/scripts/hooks/session-start-goal-resume.js +3 -20
- package/scripts/hooks/suggest-compact.js +9 -115
- package/scripts/lib/completion-oracle.js +4 -27
- package/scripts/lib/context-window-state.js +129 -0
- package/scripts/lib/context-window.js +294 -0
- package/scripts/lib/heartbeat-scheduler.js +40 -25
- package/scripts/lib/install-targets/registry.js +1 -1
- package/scripts/lib/loop-oracle.js +5 -0
- package/scripts/lib/loop-spec.js +168 -0
- package/scripts/lib/loop-state-store.js +221 -0
- package/scripts/lib/transcript-usage.js +11 -1
- package/skills/goframe-v2/examples/practices/quick-demo/manifest/config/config.yaml +14 -14
- package/skills/repo-scan/SKILL.md +63 -63
- package/skills/strategic-compact/SKILL.md +11 -2
- package/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_platform_artifacts.cpython-311.pyc +0 -0
- package/scripts/__pycache__/install_platform.cpython-311.pyc +0 -0
- package/scripts/__pycache__/langfuse_trace.cpython-311.pyc +0 -0
- package/scripts/__pycache__/query_audit_logs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scan_leaked_keys.cpython-311.pyc +0 -0
- package/scripts/__pycache__/team_skills_platform.cpython-311.pyc +0 -0
- package/scripts/__pycache__/team_skills_platform.cpython-313.pyc +0 -0
- package/scripts/__pycache__/validate_library.cpython-311.pyc +0 -0
- package/scripts/__pycache__/validate_workflow_state.cpython-311.pyc +0 -0
- package/scripts/evolution/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/evolution/__pycache__/store.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/__init__.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/mcp_health_check.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/observe.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/session_end.cpython-311.pyc +0 -0
- package/scripts/hooks/__pycache__/session_start.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/audit_logger.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/audit_query.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/hook_contract.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/memory_store.cpython-311.pyc +0 -0
- package/scripts/lib/__pycache__/utils.cpython-311.pyc +0 -0
|
@@ -12,6 +12,7 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const crypto = require('crypto');
|
|
15
|
+
const contextWindow = require('../lib/context-window');
|
|
15
16
|
|
|
16
17
|
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
|
17
18
|
const DEFAULT_CONTEXT_LIMIT = 200000;
|
|
@@ -98,119 +99,7 @@ function readBridgeMetrics(sessionId) {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function resolveContextMetrics(data) {
|
|
101
|
-
|
|
102
|
-
let cw = {};
|
|
103
|
-
if (data.context_window && typeof data.context_window === 'object') {
|
|
104
|
-
cw = data.context_window;
|
|
105
|
-
} else if (data.context_window != null) {
|
|
106
|
-
// Malformed: context_window is present but not an object.
|
|
107
|
-
// Attempt to extract a numeric value (some Claude Code versions
|
|
108
|
-
// may pass context_size as a bare number).
|
|
109
|
-
const numericValue = toNumber(data.context_window);
|
|
110
|
-
if (numericValue != null && numericValue > 0) {
|
|
111
|
-
const usagePct = clampPct((numericValue / contextLimit) * 100);
|
|
112
|
-
return {
|
|
113
|
-
usagePct,
|
|
114
|
-
remainingPct: null,
|
|
115
|
-
contextLimit,
|
|
116
|
-
contextSize: numericValue,
|
|
117
|
-
source: 'stdin.context_window_raw',
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
if (process.env.STRATEGIC_COMPACT_DEBUG === '1') {
|
|
121
|
-
process.stderr.write(
|
|
122
|
-
`[strategic-compact] context_window is ${typeof data.context_window}: ${JSON.stringify(data.context_window).slice(0, 100)}\n`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const stdinUsed = toNumber(cw.used_percentage);
|
|
128
|
-
if (stdinUsed != null) {
|
|
129
|
-
const usagePct = clampPct(stdinUsed);
|
|
130
|
-
return {
|
|
131
|
-
usagePct,
|
|
132
|
-
remainingPct: toNumber(cw.remaining_percentage),
|
|
133
|
-
contextLimit,
|
|
134
|
-
contextSize: Math.round((usagePct / 100) * contextLimit),
|
|
135
|
-
source: 'stdin.used_percentage',
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const stdinRemaining = toNumber(cw.remaining_percentage);
|
|
140
|
-
if (stdinRemaining != null) {
|
|
141
|
-
const usagePct = normalizeRemainingToUsed(stdinRemaining);
|
|
142
|
-
return {
|
|
143
|
-
usagePct,
|
|
144
|
-
remainingPct: clampPct(stdinRemaining),
|
|
145
|
-
contextLimit,
|
|
146
|
-
contextSize: Math.round((usagePct / 100) * contextLimit),
|
|
147
|
-
source: 'stdin.remaining_percentage',
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const envSize = toNumber(process.env.CLAUDE_CONTEXT_SIZE);
|
|
152
|
-
if (envSize != null && envSize > 0) {
|
|
153
|
-
const usagePct = clampPct((envSize / contextLimit) * 100);
|
|
154
|
-
return {
|
|
155
|
-
usagePct,
|
|
156
|
-
remainingPct: null,
|
|
157
|
-
contextLimit,
|
|
158
|
-
contextSize: envSize,
|
|
159
|
-
source: 'env',
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Transcript JSONL usage parsing (CCometixLine approach)
|
|
164
|
-
const transcriptPath = data.transcript_path;
|
|
165
|
-
const modelId = (data.model && data.model.id) || process.env.CLAUDE_MODEL || null;
|
|
166
|
-
if (transcriptPath && typeof transcriptPath === 'string') {
|
|
167
|
-
try {
|
|
168
|
-
const { resolveTranscriptMetrics } = require('../lib/transcript-usage');
|
|
169
|
-
const transcriptMetrics = resolveTranscriptMetrics(transcriptPath, modelId);
|
|
170
|
-
if (transcriptMetrics) {
|
|
171
|
-
return {
|
|
172
|
-
usagePct: clampPct(transcriptMetrics.usagePct),
|
|
173
|
-
remainingPct: null,
|
|
174
|
-
contextLimit: transcriptMetrics.contextLimit,
|
|
175
|
-
contextSize: transcriptMetrics.contextTokens,
|
|
176
|
-
source: 'transcript_usage',
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
} catch (_) {
|
|
180
|
-
// transcript parsing failed — fall through
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const bridge = readBridgeMetrics(sessionKey(data));
|
|
185
|
-
if (bridge) {
|
|
186
|
-
return {
|
|
187
|
-
...bridge,
|
|
188
|
-
contextLimit,
|
|
189
|
-
contextSize: Math.round((bridge.usagePct / 100) * contextLimit),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Final fallback: estimate from transcript file size
|
|
194
|
-
if (transcriptPath && typeof transcriptPath === 'string') {
|
|
195
|
-
try {
|
|
196
|
-
const stat = fs.statSync(transcriptPath);
|
|
197
|
-
const estimatedTokens = Math.round(stat.size * 0.25);
|
|
198
|
-
if (estimatedTokens > 0) {
|
|
199
|
-
const usagePct = clampPct((estimatedTokens / contextLimit) * 100);
|
|
200
|
-
return {
|
|
201
|
-
usagePct,
|
|
202
|
-
remainingPct: null,
|
|
203
|
-
contextLimit,
|
|
204
|
-
contextSize: estimatedTokens,
|
|
205
|
-
source: 'transcript_size',
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
} catch (_) {
|
|
209
|
-
// transcript not accessible — fall through
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return null;
|
|
102
|
+
return contextWindow.resolveContextMetrics(data);
|
|
214
103
|
}
|
|
215
104
|
|
|
216
105
|
function buildSuggestions(usagePct, contextSize) {
|
|
@@ -329,8 +218,9 @@ function shouldEmit(sessionId, urgency, usagePct) {
|
|
|
329
218
|
}
|
|
330
219
|
}
|
|
331
220
|
|
|
332
|
-
function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggestions, source }) {
|
|
221
|
+
function buildContextMessage({ usagePct, remainingPct, compactCount, urgency, savings, suggestions, source }) {
|
|
333
222
|
const remainingPart = remainingPct == null ? '' : ` | remaining: ${clampPct(remainingPct)}%`;
|
|
223
|
+
const compactPart = compactCount == null ? '' : ` | compact count: ${compactCount}`;
|
|
334
224
|
const actionByUrgency = {
|
|
335
225
|
advisory: 'Context usage is approaching the compaction threshold. Be mindful of context budget; avoid starting large new explorations.',
|
|
336
226
|
medium: 'Finish the current small step, then run `/compact` before more broad reading or implementation.',
|
|
@@ -340,7 +230,7 @@ function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggest
|
|
|
340
230
|
|
|
341
231
|
return [
|
|
342
232
|
'## Strategic Compact Triggered',
|
|
343
|
-
`- Context usage: ${usagePct}%${remainingPart} | urgency: ${urgency} | source: ${source}`,
|
|
233
|
+
`- Context usage: ${usagePct}%${remainingPart}${compactPart} | urgency: ${urgency} | source: ${source}`,
|
|
344
234
|
`- Action: ${actionByUrgency[urgency] || actionByUrgency.medium}`,
|
|
345
235
|
`- Estimated savings: ~${savings} tokens`,
|
|
346
236
|
...suggestions.map(item => `- ${item.action}: ${item.target} - ${item.reason}`),
|
|
@@ -394,6 +284,7 @@ function buildHookOutput(rawInput) {
|
|
|
394
284
|
const additionalContext = buildContextMessage({
|
|
395
285
|
usagePct: metrics.usagePct,
|
|
396
286
|
remainingPct: metrics.remainingPct,
|
|
287
|
+
compactCount: metrics.compactCount,
|
|
397
288
|
urgency,
|
|
398
289
|
savings,
|
|
399
290
|
suggestions,
|
|
@@ -409,6 +300,9 @@ function buildHookOutput(rawInput) {
|
|
|
409
300
|
should_compact: true,
|
|
410
301
|
urgency,
|
|
411
302
|
context_usage_ratio: metrics.usagePct,
|
|
303
|
+
context_remaining_percentage: metrics.remainingPct,
|
|
304
|
+
context_remaining_tokens: metrics.remainingTokens,
|
|
305
|
+
compact_count: metrics.compactCount,
|
|
412
306
|
context_source: metrics.source,
|
|
413
307
|
estimated_token_savings: savings,
|
|
414
308
|
suggestions,
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { execSync } = require('child_process');
|
|
5
|
-
const
|
|
6
|
-
const fs = require('fs');
|
|
5
|
+
const loopStateStore = require('./loop-state-store');
|
|
7
6
|
|
|
8
7
|
const GOAL_STATES = {
|
|
9
8
|
active: 'active',
|
|
@@ -236,38 +235,16 @@ function resumeGoal(goal) {
|
|
|
236
235
|
}
|
|
237
236
|
}
|
|
238
237
|
|
|
239
|
-
// Goal persistence
|
|
240
|
-
|
|
241
|
-
function getGoalsDir() {
|
|
242
|
-
const home = process.env.HOME || process.env.USERPROFILE;
|
|
243
|
-
const dir = path.join(home, '.claude', 'goals');
|
|
244
|
-
if (!fs.existsSync(dir)) {
|
|
245
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
246
|
-
}
|
|
247
|
-
return dir;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
238
|
function saveGoal(goal) {
|
|
251
|
-
|
|
252
|
-
fs.writeFileSync(filePath, JSON.stringify(goal, null, 2), 'utf-8');
|
|
253
|
-
return filePath;
|
|
239
|
+
return loopStateStore.saveGoal(goal);
|
|
254
240
|
}
|
|
255
241
|
|
|
256
242
|
function loadGoal(goalId) {
|
|
257
|
-
|
|
258
|
-
if (!fs.existsSync(filePath)) return null;
|
|
259
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
243
|
+
return loopStateStore.loadGoal(goalId);
|
|
260
244
|
}
|
|
261
245
|
|
|
262
246
|
function listGoals(filter) {
|
|
263
|
-
|
|
264
|
-
if (!fs.existsSync(dir)) return [];
|
|
265
|
-
|
|
266
|
-
return fs.readdirSync(dir)
|
|
267
|
-
.filter(f => f.endsWith('.json'))
|
|
268
|
-
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8')))
|
|
269
|
-
.filter(g => !filter || g.state === filter)
|
|
270
|
-
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
247
|
+
return loopStateStore.listGoals(filter);
|
|
271
248
|
}
|
|
272
249
|
|
|
273
250
|
function getActiveGoals() {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
function sanitizeSegment(raw, fallback = 'default') {
|
|
9
|
+
const value = String(raw || '').trim();
|
|
10
|
+
if (!value) return fallback;
|
|
11
|
+
const sanitized = value
|
|
12
|
+
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
13
|
+
.replace(/-{2,}/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '')
|
|
15
|
+
.slice(-80);
|
|
16
|
+
return sanitized || crypto.createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function projectStateDir(cwd = process.cwd()) {
|
|
20
|
+
return path.join(path.resolve(cwd || process.cwd()), '.tsp', 'context');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getContextStateDir(options = {}) {
|
|
24
|
+
if (process.env.TSP_CONTEXT_STATE_DIR) {
|
|
25
|
+
return path.resolve(process.env.TSP_CONTEXT_STATE_DIR);
|
|
26
|
+
}
|
|
27
|
+
if (options.stateDir) {
|
|
28
|
+
return path.resolve(options.stateDir);
|
|
29
|
+
}
|
|
30
|
+
if (options.projectRoot || options.cwd) {
|
|
31
|
+
return projectStateDir(options.projectRoot || options.cwd);
|
|
32
|
+
}
|
|
33
|
+
return projectStateDir(process.cwd());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureDir(dirPath) {
|
|
37
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
38
|
+
return dirPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCompactStatePath(options = {}) {
|
|
42
|
+
return path.join(getContextStateDir(options), 'compact-state.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readJson(filePath, fallback) {
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
48
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeJson(filePath, value) {
|
|
55
|
+
ensureDir(path.dirname(filePath));
|
|
56
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
57
|
+
return filePath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveSessionId(input = {}, options = {}) {
|
|
61
|
+
return sanitizeSegment(
|
|
62
|
+
options.sessionId ||
|
|
63
|
+
input.session_id ||
|
|
64
|
+
input.sessionId ||
|
|
65
|
+
process.env.CLAUDE_SESSION_ID ||
|
|
66
|
+
process.env.CODEX_SESSION_ID ||
|
|
67
|
+
'default'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function emptyState() {
|
|
72
|
+
return {
|
|
73
|
+
version: 1,
|
|
74
|
+
totalCompactCount: 0,
|
|
75
|
+
sessions: {},
|
|
76
|
+
updatedAt: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadCompactState(options = {}) {
|
|
81
|
+
return readJson(getCompactStatePath(options), emptyState());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getSessionCompactCount(input = {}, options = {}) {
|
|
85
|
+
const state = loadCompactState(options);
|
|
86
|
+
const sessionId = resolveSessionId(input, options);
|
|
87
|
+
return Number(state.sessions?.[sessionId]?.compactCount || 0) || 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function recordCompactEvent(input = {}, options = {}) {
|
|
91
|
+
const statePath = getCompactStatePath(options);
|
|
92
|
+
const state = loadCompactState(options);
|
|
93
|
+
const sessionId = resolveSessionId(input, options);
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
const current = state.sessions[sessionId] || {
|
|
96
|
+
sessionId,
|
|
97
|
+
compactCount: 0,
|
|
98
|
+
firstCompactedAt: now,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
current.compactCount = (Number(current.compactCount || 0) || 0) + 1;
|
|
102
|
+
current.lastCompactedAt = now;
|
|
103
|
+
current.transcriptPath = input.transcript_path || input.transcriptPath || current.transcriptPath || null;
|
|
104
|
+
current.cwd = input.cwd || input.workspace?.current_dir || options.cwd || process.cwd();
|
|
105
|
+
|
|
106
|
+
state.version = 1;
|
|
107
|
+
state.totalCompactCount = (Number(state.totalCompactCount || 0) || 0) + 1;
|
|
108
|
+
state.sessions[sessionId] = current;
|
|
109
|
+
state.updatedAt = now;
|
|
110
|
+
|
|
111
|
+
writeJson(statePath, state);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
statePath,
|
|
115
|
+
sessionId,
|
|
116
|
+
sessionCompactCount: current.compactCount,
|
|
117
|
+
totalCompactCount: state.totalCompactCount,
|
|
118
|
+
updatedAt: now,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
getContextStateDir,
|
|
124
|
+
getCompactStatePath,
|
|
125
|
+
loadCompactState,
|
|
126
|
+
getSessionCompactCount,
|
|
127
|
+
recordCompactEvent,
|
|
128
|
+
resolveSessionId,
|
|
129
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { resolveTranscriptMetrics, resolveContextLimit } = require('./transcript-usage');
|
|
8
|
+
const { getSessionCompactCount, resolveSessionId } = require('./context-window-state');
|
|
9
|
+
|
|
10
|
+
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
|
11
|
+
const STALE_BRIDGE_SECONDS = 120;
|
|
12
|
+
|
|
13
|
+
function toNumber(value) {
|
|
14
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
15
|
+
if (typeof value === 'string' && value.trim()) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clampPct(value) {
|
|
23
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeRemainingToUsed(remainingPct) {
|
|
27
|
+
const usableRemaining = Math.max(
|
|
28
|
+
0,
|
|
29
|
+
((remainingPct - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100
|
|
30
|
+
);
|
|
31
|
+
return clampPct(100 - usableRemaining);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function firstNumber(object, keys) {
|
|
35
|
+
if (!object || typeof object !== 'object') return null;
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
const value = toNumber(object[key]);
|
|
38
|
+
if (value != null) return value;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function modelIdFromInput(data = {}) {
|
|
44
|
+
return data.model?.id || data.model?.name || process.env.CLAUDE_MODEL || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function contextLimitFrom(data, candidate) {
|
|
48
|
+
return (
|
|
49
|
+
firstNumber(candidate, ['context_limit', 'contextLimit', 'limit', 'model_context_window']) ||
|
|
50
|
+
firstNumber(data, ['context_limit', 'contextLimit']) ||
|
|
51
|
+
resolveContextLimit(modelIdFromInput(data))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function metricFromCandidate(data, candidate, source) {
|
|
56
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
57
|
+
|
|
58
|
+
const contextLimit = contextLimitFrom(data, candidate);
|
|
59
|
+
let remainingPct = firstNumber(candidate, [
|
|
60
|
+
'remaining_percentage',
|
|
61
|
+
'remainingPercentage',
|
|
62
|
+
'remaining_pct',
|
|
63
|
+
'remainingPct',
|
|
64
|
+
'remaining_percent',
|
|
65
|
+
]);
|
|
66
|
+
let usagePct = firstNumber(candidate, [
|
|
67
|
+
'used_percentage',
|
|
68
|
+
'usedPercentage',
|
|
69
|
+
'used_pct',
|
|
70
|
+
'usedPct',
|
|
71
|
+
'usagePct',
|
|
72
|
+
'usage_percentage',
|
|
73
|
+
]);
|
|
74
|
+
let remainingTokens = firstNumber(candidate, [
|
|
75
|
+
'remaining_tokens',
|
|
76
|
+
'remainingTokens',
|
|
77
|
+
'available_tokens',
|
|
78
|
+
'availableTokens',
|
|
79
|
+
]);
|
|
80
|
+
let contextTokens = firstNumber(candidate, [
|
|
81
|
+
'context_tokens',
|
|
82
|
+
'contextTokens',
|
|
83
|
+
'used_tokens',
|
|
84
|
+
'usedTokens',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
if (remainingPct == null && remainingTokens != null && contextLimit > 0) {
|
|
88
|
+
remainingPct = (remainingTokens / contextLimit) * 100;
|
|
89
|
+
}
|
|
90
|
+
if (remainingTokens == null && remainingPct != null && contextLimit > 0) {
|
|
91
|
+
remainingTokens = Math.max(0, Math.round((remainingPct / 100) * contextLimit));
|
|
92
|
+
}
|
|
93
|
+
if (usagePct == null && remainingPct != null) {
|
|
94
|
+
usagePct = normalizeRemainingToUsed(remainingPct);
|
|
95
|
+
}
|
|
96
|
+
if (remainingPct == null && usagePct != null) {
|
|
97
|
+
remainingPct = clampPct(100 - usagePct);
|
|
98
|
+
}
|
|
99
|
+
if (contextTokens == null && remainingTokens != null && contextLimit > 0) {
|
|
100
|
+
contextTokens = Math.max(0, contextLimit - remainingTokens);
|
|
101
|
+
}
|
|
102
|
+
if (contextTokens == null && usagePct != null && contextLimit > 0) {
|
|
103
|
+
contextTokens = Math.round((usagePct / 100) * contextLimit);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (usagePct == null && contextTokens == null && remainingPct == null) return null;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
usagePct: usagePct != null ? clampPct(usagePct) : clampPct((contextTokens / contextLimit) * 100),
|
|
110
|
+
remainingPct: remainingPct != null ? clampPct(remainingPct) : null,
|
|
111
|
+
remainingTokens: remainingTokens != null ? Math.max(0, Math.round(remainingTokens)) : null,
|
|
112
|
+
contextLimit,
|
|
113
|
+
contextSize: contextTokens != null ? Math.max(0, Math.round(contextTokens)) : 0,
|
|
114
|
+
source,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readJsonEnv(names) {
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
const raw = process.env[name];
|
|
121
|
+
if (!raw) continue;
|
|
122
|
+
try {
|
|
123
|
+
return { name, value: JSON.parse(raw) };
|
|
124
|
+
} catch (_) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readJsonFileEnv(names) {
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const filePath = process.env[name];
|
|
134
|
+
if (!filePath) continue;
|
|
135
|
+
try {
|
|
136
|
+
return { name, value: JSON.parse(fs.readFileSync(filePath, 'utf8')) };
|
|
137
|
+
} catch (_) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function explicitFrameworkCandidates(data = {}) {
|
|
145
|
+
const candidates = [];
|
|
146
|
+
const envJson = readJsonEnv(['TSP_CONTEXT_WINDOW_JSON', 'CCOMETIXLINE_CONTEXT_JSON']);
|
|
147
|
+
if (envJson) candidates.push([envJson.value, `env.${envJson.name}`]);
|
|
148
|
+
|
|
149
|
+
const fileJson = readJsonFileEnv(['TSP_CONTEXT_WINDOW_FILE', 'CCOMETIXLINE_CONTEXT_FILE']);
|
|
150
|
+
if (fileJson) candidates.push([fileJson.value, `file.${fileJson.name}`]);
|
|
151
|
+
|
|
152
|
+
if (data.ccometixline?.context_window) candidates.push([data.ccometixline.context_window, 'stdin.ccometixline.context_window']);
|
|
153
|
+
if (data.ccometixline?.contextWindow) candidates.push([data.ccometixline.contextWindow, 'stdin.ccometixline.contextWindow']);
|
|
154
|
+
if (data.ccometixline_context_window) candidates.push([data.ccometixline_context_window, 'stdin.ccometixline_context_window']);
|
|
155
|
+
if (data.context_metrics) candidates.push([data.context_metrics, 'stdin.context_metrics']);
|
|
156
|
+
|
|
157
|
+
return candidates;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readBridgeMetrics(data = {}) {
|
|
161
|
+
const sessionId = resolveSessionId(data);
|
|
162
|
+
if (!sessionId) return null;
|
|
163
|
+
|
|
164
|
+
const bridgePath = path.join(os.tmpdir(), `harness-ctx-${sessionId}.json`);
|
|
165
|
+
if (!fs.existsSync(bridgePath)) return null;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
|
|
169
|
+
const now = Math.floor(Date.now() / 1000);
|
|
170
|
+
if (bridge.timestamp && now - bridge.timestamp > STALE_BRIDGE_SECONDS) return null;
|
|
171
|
+
return metricFromCandidate(data, bridge, 'bridge');
|
|
172
|
+
} catch (_) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function transcriptMetrics(data = {}) {
|
|
178
|
+
const transcriptPath = data.transcript_path;
|
|
179
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
180
|
+
|
|
181
|
+
const metrics = resolveTranscriptMetrics(transcriptPath, modelIdFromInput(data));
|
|
182
|
+
if (!metrics) return null;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
usagePct: clampPct(metrics.usagePct),
|
|
186
|
+
remainingPct: metrics.remainingPct,
|
|
187
|
+
remainingTokens: metrics.remainingTokens,
|
|
188
|
+
contextLimit: metrics.contextLimit,
|
|
189
|
+
contextSize: metrics.contextTokens,
|
|
190
|
+
source: 'transcript_usage',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function transcriptSizeMetrics(data = {}) {
|
|
195
|
+
const transcriptPath = data.transcript_path;
|
|
196
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const stat = fs.statSync(transcriptPath);
|
|
200
|
+
const estimatedTokens = Math.round(stat.size * 0.25);
|
|
201
|
+
if (estimatedTokens <= 0) return null;
|
|
202
|
+
const contextLimit = resolveContextLimit(modelIdFromInput(data));
|
|
203
|
+
const usagePct = clampPct((estimatedTokens / contextLimit) * 100);
|
|
204
|
+
return {
|
|
205
|
+
usagePct,
|
|
206
|
+
remainingPct: clampPct(100 - usagePct),
|
|
207
|
+
remainingTokens: Math.max(0, contextLimit - estimatedTokens),
|
|
208
|
+
contextLimit,
|
|
209
|
+
contextSize: estimatedTokens,
|
|
210
|
+
source: 'transcript_size',
|
|
211
|
+
};
|
|
212
|
+
} catch (_) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fallbackSessionKey(data = {}) {
|
|
218
|
+
const raw = data.session_id || process.env.CLAUDE_SESSION_ID;
|
|
219
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
220
|
+
return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(-64) || 'default';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const pidKey = `pid-${process.pid}`;
|
|
224
|
+
const cwd = data.cwd || process.cwd();
|
|
225
|
+
const cwdHash = crypto.createHash('sha256').update(String(cwd)).digest('hex').slice(0, 16);
|
|
226
|
+
return `${pidKey}-${cwdHash}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function attachCompactState(metrics, data = {}) {
|
|
230
|
+
if (!metrics) return null;
|
|
231
|
+
return {
|
|
232
|
+
...metrics,
|
|
233
|
+
compactCount: getSessionCompactCount(data, { cwd: data.cwd || data.workspace?.current_dir || process.cwd() }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveContextMetrics(data = {}) {
|
|
238
|
+
for (const [candidate, source] of explicitFrameworkCandidates(data)) {
|
|
239
|
+
const metrics = metricFromCandidate(data, candidate, source);
|
|
240
|
+
if (metrics) return attachCompactState(metrics, data);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (data.context_window && typeof data.context_window === 'object') {
|
|
244
|
+
const contextWindowSource = firstNumber(data.context_window, ['used_percentage', 'usedPercentage']) != null
|
|
245
|
+
? 'stdin.used_percentage'
|
|
246
|
+
: 'stdin.remaining_percentage';
|
|
247
|
+
const metrics = metricFromCandidate(data, data.context_window, contextWindowSource);
|
|
248
|
+
if (metrics) return attachCompactState(metrics, data);
|
|
249
|
+
} else if (data.context_window != null) {
|
|
250
|
+
const numericValue = toNumber(data.context_window);
|
|
251
|
+
const contextLimit = resolveContextLimit(modelIdFromInput(data));
|
|
252
|
+
if (numericValue != null && numericValue > 0) {
|
|
253
|
+
return attachCompactState({
|
|
254
|
+
usagePct: clampPct((numericValue / contextLimit) * 100),
|
|
255
|
+
remainingPct: null,
|
|
256
|
+
remainingTokens: null,
|
|
257
|
+
contextLimit,
|
|
258
|
+
contextSize: numericValue,
|
|
259
|
+
source: 'stdin.context_window_raw',
|
|
260
|
+
}, data);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const envSize = toNumber(process.env.CLAUDE_CONTEXT_SIZE);
|
|
265
|
+
if (envSize != null && envSize > 0) {
|
|
266
|
+
const contextLimit = toNumber(process.env.CLAUDE_CONTEXT_LIMIT) || resolveContextLimit(modelIdFromInput(data));
|
|
267
|
+
const usagePct = clampPct((envSize / contextLimit) * 100);
|
|
268
|
+
return attachCompactState({
|
|
269
|
+
usagePct,
|
|
270
|
+
remainingPct: clampPct(100 - usagePct),
|
|
271
|
+
remainingTokens: Math.max(0, contextLimit - envSize),
|
|
272
|
+
contextLimit,
|
|
273
|
+
contextSize: envSize,
|
|
274
|
+
source: 'env',
|
|
275
|
+
}, data);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return attachCompactState(
|
|
279
|
+
transcriptMetrics(data) ||
|
|
280
|
+
readBridgeMetrics(data) ||
|
|
281
|
+
transcriptSizeMetrics(data),
|
|
282
|
+
data
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
AUTO_COMPACT_BUFFER_PCT,
|
|
288
|
+
toNumber,
|
|
289
|
+
clampPct,
|
|
290
|
+
normalizeRemainingToUsed,
|
|
291
|
+
metricFromCandidate,
|
|
292
|
+
resolveContextMetrics,
|
|
293
|
+
fallbackSessionKey,
|
|
294
|
+
};
|