@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.
Files changed (49) hide show
  1. package/commands/goal.md +7 -1
  2. package/commands/heartbeat.md +34 -1
  3. package/commands/loop-start.md +34 -6
  4. package/commands/triage.md +8 -1
  5. package/hooks/README.md +2 -2
  6. package/hooks/harness-statusline.js +9 -30
  7. package/hooks/strategic-compact/README.md +11 -12
  8. package/manifests/install-components.json +40 -0
  9. package/manifests/install-modules.json +43 -0
  10. package/manifests/install-profiles.json +2 -0
  11. package/package.json +1 -1
  12. package/schemas/loop-spec.schema.json +124 -0
  13. package/scripts/hooks/pre-compact.js +39 -8
  14. package/scripts/hooks/session-start-goal-resume.js +3 -20
  15. package/scripts/hooks/suggest-compact.js +9 -115
  16. package/scripts/lib/completion-oracle.js +4 -27
  17. package/scripts/lib/context-window-state.js +129 -0
  18. package/scripts/lib/context-window.js +294 -0
  19. package/scripts/lib/heartbeat-scheduler.js +40 -25
  20. package/scripts/lib/install-targets/registry.js +1 -1
  21. package/scripts/lib/loop-oracle.js +5 -0
  22. package/scripts/lib/loop-spec.js +168 -0
  23. package/scripts/lib/loop-state-store.js +221 -0
  24. package/scripts/lib/transcript-usage.js +11 -1
  25. package/skills/goframe-v2/examples/practices/quick-demo/manifest/config/config.yaml +14 -14
  26. package/skills/repo-scan/SKILL.md +63 -63
  27. package/skills/strategic-compact/SKILL.md +11 -2
  28. package/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/build_platform_artifacts.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/install_platform.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/langfuse_trace.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/query_audit_logs.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/scan_leaked_keys.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/team_skills_platform.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/team_skills_platform.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/validate_library.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/validate_workflow_state.cpython-311.pyc +0 -0
  38. package/scripts/evolution/__pycache__/__init__.cpython-311.pyc +0 -0
  39. package/scripts/evolution/__pycache__/store.cpython-311.pyc +0 -0
  40. package/scripts/hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  41. package/scripts/hooks/__pycache__/mcp_health_check.cpython-311.pyc +0 -0
  42. package/scripts/hooks/__pycache__/observe.cpython-311.pyc +0 -0
  43. package/scripts/hooks/__pycache__/session_end.cpython-311.pyc +0 -0
  44. package/scripts/hooks/__pycache__/session_start.cpython-311.pyc +0 -0
  45. package/scripts/lib/__pycache__/audit_logger.cpython-311.pyc +0 -0
  46. package/scripts/lib/__pycache__/audit_query.cpython-311.pyc +0 -0
  47. package/scripts/lib/__pycache__/hook_contract.cpython-311.pyc +0 -0
  48. package/scripts/lib/__pycache__/memory_store.cpython-311.pyc +0 -0
  49. 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
- const contextLimit = toNumber(process.env.CLAUDE_CONTEXT_LIMIT) || DEFAULT_CONTEXT_LIMIT;
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 path = require('path');
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
- const filePath = path.join(getGoalsDir(), `${goal.goalId}.json`);
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
- const filePath = path.join(getGoalsDir(), `${goalId}.json`);
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
- const dir = getGoalsDir();
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
+ };