@colin4k1024/tsp 2.5.2 → 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/bin/lib/install-surface.js +5 -0
- 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 +24 -22
- package/hooks/strategic-compact/README.md +11 -12
- package/manifests/install-components.json +40 -0
- package/manifests/install-modules.json +141 -31
- package/manifests/install-profiles.json +2 -0
- package/package.json +2 -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 -74
- 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/request.js +1 -1
- package/scripts/lib/install-manifests.js +9 -1
- package/scripts/lib/install-targets/cangming-home.js +143 -0
- package/scripts/lib/install-targets/codewhale-home.js +187 -0
- package/scripts/lib/install-targets/registry.js +4 -0
- 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 +193 -0
- package/scripts/test-cangming-install.js +105 -0
- package/skills/strategic-compact/SKILL.md +11 -2
|
@@ -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,78 +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
|
-
const bridge = readBridgeMetrics(sessionKey(data));
|
|
164
|
-
if (bridge) {
|
|
165
|
-
return {
|
|
166
|
-
...bridge,
|
|
167
|
-
contextLimit,
|
|
168
|
-
contextSize: Math.round((bridge.usagePct / 100) * contextLimit),
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return null;
|
|
102
|
+
return contextWindow.resolveContextMetrics(data);
|
|
173
103
|
}
|
|
174
104
|
|
|
175
105
|
function buildSuggestions(usagePct, contextSize) {
|
|
@@ -288,8 +218,9 @@ function shouldEmit(sessionId, urgency, usagePct) {
|
|
|
288
218
|
}
|
|
289
219
|
}
|
|
290
220
|
|
|
291
|
-
function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggestions, source }) {
|
|
221
|
+
function buildContextMessage({ usagePct, remainingPct, compactCount, urgency, savings, suggestions, source }) {
|
|
292
222
|
const remainingPart = remainingPct == null ? '' : ` | remaining: ${clampPct(remainingPct)}%`;
|
|
223
|
+
const compactPart = compactCount == null ? '' : ` | compact count: ${compactCount}`;
|
|
293
224
|
const actionByUrgency = {
|
|
294
225
|
advisory: 'Context usage is approaching the compaction threshold. Be mindful of context budget; avoid starting large new explorations.',
|
|
295
226
|
medium: 'Finish the current small step, then run `/compact` before more broad reading or implementation.',
|
|
@@ -299,7 +230,7 @@ function buildContextMessage({ usagePct, remainingPct, urgency, savings, suggest
|
|
|
299
230
|
|
|
300
231
|
return [
|
|
301
232
|
'## Strategic Compact Triggered',
|
|
302
|
-
`- Context usage: ${usagePct}%${remainingPart} | urgency: ${urgency} | source: ${source}`,
|
|
233
|
+
`- Context usage: ${usagePct}%${remainingPart}${compactPart} | urgency: ${urgency} | source: ${source}`,
|
|
303
234
|
`- Action: ${actionByUrgency[urgency] || actionByUrgency.medium}`,
|
|
304
235
|
`- Estimated savings: ~${savings} tokens`,
|
|
305
236
|
...suggestions.map(item => `- ${item.action}: ${item.target} - ${item.reason}`),
|
|
@@ -353,6 +284,7 @@ function buildHookOutput(rawInput) {
|
|
|
353
284
|
const additionalContext = buildContextMessage({
|
|
354
285
|
usagePct: metrics.usagePct,
|
|
355
286
|
remainingPct: metrics.remainingPct,
|
|
287
|
+
compactCount: metrics.compactCount,
|
|
356
288
|
urgency,
|
|
357
289
|
savings,
|
|
358
290
|
suggestions,
|
|
@@ -368,6 +300,9 @@ function buildHookOutput(rawInput) {
|
|
|
368
300
|
should_compact: true,
|
|
369
301
|
urgency,
|
|
370
302
|
context_usage_ratio: metrics.usagePct,
|
|
303
|
+
context_remaining_percentage: metrics.remainingPct,
|
|
304
|
+
context_remaining_tokens: metrics.remainingTokens,
|
|
305
|
+
compact_count: metrics.compactCount,
|
|
371
306
|
context_source: metrics.source,
|
|
372
307
|
estimated_token_savings: savings,
|
|
373
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
|
+
};
|