codeep 2.16.0 → 2.17.0

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.
@@ -0,0 +1,44 @@
1
+ export type TimelineStageId = 'PLAN' | 'READ' | 'EDIT' | 'VERIFY' | 'SUMMARY';
2
+ export type TimelineStageStatus = 'done' | 'active' | 'pending';
3
+ export interface TimelineAction {
4
+ type: string;
5
+ target: string;
6
+ result: string;
7
+ }
8
+ export interface TimelineStage {
9
+ id: TimelineStageId;
10
+ status: TimelineStageStatus;
11
+ summary: string;
12
+ detail: string;
13
+ }
14
+ export interface TimelineFile {
15
+ type: 'write' | 'edit' | 'delete' | 'mkdir';
16
+ target: string;
17
+ result: string;
18
+ }
19
+ export interface TimelineCheck {
20
+ target: string;
21
+ result: string;
22
+ }
23
+ export interface AgentTimelineModel {
24
+ currentStage: TimelineStageId;
25
+ currentTarget: string;
26
+ stages: TimelineStage[];
27
+ files: TimelineFile[];
28
+ checks: TimelineCheck[];
29
+ progress: number;
30
+ }
31
+ export declare function buildAgentTimelineModel(args: {
32
+ actions: TimelineAction[];
33
+ thinking: string;
34
+ waitingForAI: boolean;
35
+ iteration: number;
36
+ maxIterations: number;
37
+ }): AgentTimelineModel;
38
+ export declare function formatElapsed(milliseconds: number): string;
39
+ /**
40
+ * Truncate to a column budget, not a code-unit budget: Screen.write advances
41
+ * by charWidth, so a CJK/emoji prompt measured with String.length would draw
42
+ * up to twice as wide as the pane it was sized for.
43
+ */
44
+ export declare function truncateMiddle(value: string, maxLength: number): string;
@@ -0,0 +1,157 @@
1
+ import { charWidth, stringWidth } from '../ansi.js';
2
+ const READ_TYPES = new Set(['read', 'search', 'list', 'fetch']);
3
+ const EDIT_TYPES = new Set(['write', 'edit', 'delete', 'mkdir']);
4
+ function stageForAction(type) {
5
+ if (READ_TYPES.has(type))
6
+ return 'READ';
7
+ if (EDIT_TYPES.has(type))
8
+ return 'EDIT';
9
+ if (type === 'command')
10
+ return 'VERIFY';
11
+ return null;
12
+ }
13
+ function parseThinking(thinking) {
14
+ const separator = thinking.indexOf(':');
15
+ if (separator < 0)
16
+ return { type: '', target: '' };
17
+ const type = thinking.slice(0, separator).trim().toLowerCase();
18
+ const knownType = READ_TYPES.has(type) || EDIT_TYPES.has(type) || type === 'command';
19
+ if (!knownType)
20
+ return { type: '', target: '' };
21
+ return {
22
+ type,
23
+ target: thinking.slice(separator + 1).trim(),
24
+ };
25
+ }
26
+ function uniqueByTarget(items) {
27
+ const seen = new Set();
28
+ const result = [];
29
+ for (let index = items.length - 1; index >= 0; index--) {
30
+ const item = items[index];
31
+ if (!item.target || seen.has(item.target))
32
+ continue;
33
+ seen.add(item.target);
34
+ result.unshift(item);
35
+ }
36
+ return result;
37
+ }
38
+ export function buildAgentTimelineModel(args) {
39
+ const thinking = parseThinking(args.thinking);
40
+ const lastAction = args.actions[args.actions.length - 1];
41
+ const explicitStage = !args.waitingForAI ? stageForAction(thinking.type) : null;
42
+ const lastStage = lastAction ? stageForAction(lastAction.type) : null;
43
+ const currentStage = explicitStage
44
+ ?? lastStage
45
+ ?? 'PLAN';
46
+ const currentTarget = explicitStage
47
+ ? thinking.target
48
+ : (lastAction?.target || thinking.target);
49
+ const readActions = args.actions.filter(action => READ_TYPES.has(action.type));
50
+ const fileActions = args.actions.filter((action) => EDIT_TYPES.has(action.type));
51
+ const commandActions = args.actions.filter(action => action.type === 'command');
52
+ const stageStatus = (id, hasCompletedAction) => {
53
+ if (id === currentStage)
54
+ return 'active';
55
+ if (id === 'PLAN' && (args.iteration > 0 || args.actions.length > 0))
56
+ return 'done';
57
+ if (hasCompletedAction)
58
+ return 'done';
59
+ return 'pending';
60
+ };
61
+ const stages = [
62
+ {
63
+ id: 'PLAN',
64
+ status: stageStatus('PLAN', args.iteration > 0),
65
+ summary: 'Understand the request and choose the next safe step',
66
+ detail: args.iteration > 0 ? `${args.iteration} model step${args.iteration === 1 ? '' : 's'}` : 'Building execution plan',
67
+ },
68
+ {
69
+ id: 'READ',
70
+ status: stageStatus('READ', readActions.length > 0),
71
+ summary: 'Inspect project context and relevant files',
72
+ detail: readActions.length > 0
73
+ ? `${readActions.length} read/search action${readActions.length === 1 ? '' : 's'}`
74
+ : 'Waiting for project inspection',
75
+ },
76
+ {
77
+ id: 'EDIT',
78
+ status: stageStatus('EDIT', fileActions.some(action => action.result === 'success')),
79
+ summary: 'Apply focused changes to the workspace',
80
+ detail: fileActions.length > 0
81
+ ? `${uniqueByTarget(fileActions).length} file${uniqueByTarget(fileActions).length === 1 ? '' : 's'} touched`
82
+ : 'No file changes yet',
83
+ },
84
+ {
85
+ id: 'VERIFY',
86
+ status: stageStatus('VERIFY', commandActions.some(action => action.result === 'success')),
87
+ summary: 'Run commands and confirm the result',
88
+ detail: commandActions.length > 0
89
+ ? `${commandActions.length} command${commandActions.length === 1 ? '' : 's'} run`
90
+ : 'Checks pending',
91
+ },
92
+ {
93
+ id: 'SUMMARY',
94
+ status: 'pending',
95
+ summary: 'Summarize changes and next steps',
96
+ detail: 'Appears when the run completes',
97
+ },
98
+ ];
99
+ return {
100
+ currentStage,
101
+ currentTarget,
102
+ stages,
103
+ files: uniqueByTarget(fileActions),
104
+ checks: commandActions.slice(-4).map(action => ({
105
+ target: action.target,
106
+ result: action.result,
107
+ })),
108
+ progress: args.maxIterations > 0
109
+ ? Math.min(Math.max(args.iteration / args.maxIterations, 0), 1)
110
+ : 0,
111
+ };
112
+ }
113
+ export function formatElapsed(milliseconds) {
114
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
115
+ const hours = Math.floor(totalSeconds / 3600);
116
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
117
+ const seconds = totalSeconds % 60;
118
+ if (hours > 0) {
119
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
120
+ }
121
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
122
+ }
123
+ /**
124
+ * Truncate to a column budget, not a code-unit budget: Screen.write advances
125
+ * by charWidth, so a CJK/emoji prompt measured with String.length would draw
126
+ * up to twice as wide as the pane it was sized for.
127
+ */
128
+ export function truncateMiddle(value, maxLength) {
129
+ if (maxLength <= 0)
130
+ return '';
131
+ if (stringWidth(value) <= maxLength)
132
+ return value;
133
+ if (maxLength <= 3)
134
+ return '.'.repeat(maxLength);
135
+ const chars = [...value];
136
+ const leftBudget = Math.ceil((maxLength - 1) / 2);
137
+ const rightBudget = Math.floor((maxLength - 1) / 2);
138
+ let head = '';
139
+ let headWidth = 0;
140
+ for (const char of chars) {
141
+ const w = charWidth(char);
142
+ if (headWidth + w > leftBudget)
143
+ break;
144
+ head += char;
145
+ headWidth += w;
146
+ }
147
+ let tail = '';
148
+ let tailWidth = 0;
149
+ for (let index = chars.length - 1; index >= 0; index--) {
150
+ const w = charWidth(chars[index]);
151
+ if (tailWidth + w > rightBudget)
152
+ break;
153
+ tail = chars[index] + tail;
154
+ tailWidth += w;
155
+ }
156
+ return `${head}…${tail}`;
157
+ }
@@ -11,6 +11,7 @@ export interface StatusInfo {
11
11
  * when non-auto AND the active model supports a graded knob; undefined hides it. */
12
12
  reasoningEffort?: string;
13
13
  projectPath: string;
14
+ branch?: string;
14
15
  hasWriteAccess: boolean;
15
16
  sessionId: string;
16
17
  messageCount: number;
@@ -19,6 +20,7 @@ export interface StatusInfo {
19
20
  promptTokens: number;
20
21
  completionTokens: number;
21
22
  requestCount: number;
23
+ estimatedCost?: number;
22
24
  };
23
25
  }
24
26
  /**
@@ -16,8 +16,8 @@ import { config, loadApiKey, loadAllApiKeys, getCurrentProvider, autoSaveSession
16
16
  import { isProjectDirectory, getProjectContext, } from '../utils/project.js';
17
17
  import { getCurrentVersion, checkForUpdates, getUpdateInstructions } from '../utils/update.js';
18
18
  import { getProviderList, isNoApiKeyProvider, resolveReasoningTier } from '../config/providers.js';
19
- import { getSessionStats, getCostBreakdown } from '../utils/tokenTracker.js';
20
- import { isGitRepository } from '../utils/git.js';
19
+ import { getSessionStats, getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
20
+ import { getGitStatus, isGitRepository } from '../utils/git.js';
21
21
  import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
22
22
  import { checkApiRateLimit } from '../utils/ratelimit.js';
23
23
  import { expandFileAndFolderMentions, expandGitMentions } from '../utils/mentions.js';
@@ -27,6 +27,9 @@ import { logAppError } from '../utils/logger.js';
27
27
  import { executeAgentTask, runAgentTask, } from './agentExecution.js';
28
28
  // ─── Global state ─────────────────────────────────────────────────────────────
29
29
  let projectPath = process.cwd();
30
+ /** Cached header branch. Resolved on first use so `--version`/`--help` never
31
+ * shell out to git, and cached because getStatus runs on every render frame. */
32
+ let gitBranchCache = null;
30
33
  let projectContext = null;
31
34
  let hasWriteAccess = false;
32
35
  let sessionId = getCurrentSessionId();
@@ -34,6 +37,14 @@ let app;
34
37
  /** Human-readable session name derived from the first user message */
35
38
  let sessionDisplayName = null;
36
39
  const addedFiles = new Map();
40
+ /** Branch shown in the persistent header, re-read whenever the project moved
41
+ * or an agent run finished (an agent can check out a different branch). */
42
+ function getHeaderBranch() {
43
+ if (!gitBranchCache || gitBranchCache.path !== projectPath) {
44
+ gitBranchCache = { path: projectPath, branch: getGitStatus(projectPath).branch };
45
+ }
46
+ return gitBranchCache.branch;
47
+ }
37
48
  /** Derive a short display name from a user message (first ~5 words, max 48 chars). */
38
49
  export function deriveSessionName(message) {
39
50
  const clean = message.replace(/\s+/g, ' ').trim();
@@ -55,7 +66,10 @@ function makeCtx() {
55
66
  sessionDisplayName: sessionDisplayName ?? undefined,
56
67
  abortController: agentAbortController,
57
68
  isAgentRunning: () => isAgentRunningFlag,
58
- setAgentRunning: (v) => { isAgentRunningFlag = v; },
69
+ // A finished run may have switched branches — drop the cache so the next
70
+ // header render re-reads it.
71
+ setAgentRunning: (v) => { isAgentRunningFlag = v; if (!v)
72
+ gitBranchCache = null; },
59
73
  setAbortController: (ctrl) => { agentAbortController = ctrl; },
60
74
  formatAddedFilesContext,
61
75
  handleCommand: (cmd, args) => dispatchCommand(cmd, args, makeCtx()),
@@ -77,7 +91,7 @@ function getStatus() {
77
91
  const stats = getSessionStats();
78
92
  // Show the thinking-effort tier beside the model. Resolve the (global) tier
79
93
  // to what THIS model actually runs — e.g. a global 'low' shows as 'high' on
80
- // GLM-5.2, which only grades high|max. 'auto'/unsupported → hidden.
94
+ // Kimi K3, where the global medium tier resolves to high. 'auto'/unsupported → hidden.
81
95
  const resolved = resolveReasoningTier(provider.id, config.get('model'), config.get('reasoningEffort'));
82
96
  const reasoningEffort = resolved !== 'auto' ? resolved : undefined;
83
97
  return {
@@ -87,6 +101,7 @@ function getStatus() {
87
101
  agentMode: config.get('agentMode') || 'off',
88
102
  reasoningEffort,
89
103
  projectPath,
104
+ branch: getHeaderBranch(),
90
105
  hasWriteAccess,
91
106
  sessionId,
92
107
  messageCount: app ? app.getMessages().length : 0,
@@ -95,6 +110,7 @@ function getStatus() {
95
110
  promptTokens: stats.totalPromptTokens,
96
111
  completionTokens: stats.totalCompletionTokens,
97
112
  requestCount: stats.requestCount,
113
+ estimatedCost: stats.estimatedCost,
98
114
  },
99
115
  };
100
116
  }
@@ -162,6 +178,47 @@ async function handleSubmit(message) {
162
178
  runAgentTask(message, false, ctx, () => pendingInteractiveContext, (v) => { pendingInteractiveContext = v; });
163
179
  return;
164
180
  }
181
+ // Captured before the turn starts so the delta can be reported from both the
182
+ // success path and the catch. An aborted or failed turn has still burned
183
+ // tokens, and gracefulShutdown no longer sends a cumulative catch-all that
184
+ // would have swept them up later.
185
+ const tokenReportStart = getRecordCount();
186
+ // Cloud stats are append-only events, so report only this prompt's delta.
187
+ // Sending the full session accumulator after every prompt makes totals grow
188
+ // 1× + 2× + 3× and is the source of the inflated dashboard token count.
189
+ // pingWhenEmpty sends a bare session event when nothing was spent — wanted on
190
+ // the success path, not when the turn failed before reaching the model.
191
+ const reportTurnStats = (pingWhenEmpty) => {
192
+ const sharedFields = {
193
+ sessionId,
194
+ sessionName: sessionDisplayName || sessionId,
195
+ messageCount: app.getMessages().length,
196
+ cliVersion: getCurrentVersion(),
197
+ projectName: projectContext?.name,
198
+ projectId: projectPath ? generateProjectId(projectPath) : undefined,
199
+ language: projectContext?.type,
200
+ isGit: isGitRepository(process.cwd()),
201
+ };
202
+ const costBreakdown = getCostBreakdown(tokenReportStart);
203
+ if (costBreakdown.length === 0) {
204
+ if (pingWhenEmpty) {
205
+ reportStats({ ...sharedFields, model: config.get('model'), provider: config.get('provider') });
206
+ }
207
+ return;
208
+ }
209
+ for (const entry of costBreakdown) {
210
+ reportStats({
211
+ ...sharedFields,
212
+ model: entry.model,
213
+ provider: entry.provider,
214
+ inputTokens: entry.promptTokens || undefined,
215
+ outputTokens: entry.completionTokens || undefined,
216
+ cacheCreationTokens: entry.cacheCreationTokens || undefined,
217
+ cacheReadTokens: entry.cacheReadTokens || undefined,
218
+ estimatedCost: entry.estimatedCost || undefined,
219
+ });
220
+ }
221
+ };
165
222
  try {
166
223
  app.startStreaming();
167
224
  const history = app.getChatHistory();
@@ -226,7 +283,10 @@ async function handleSubmit(message) {
226
283
  language: projectContext?.type,
227
284
  isGit: isGitRepository(process.cwd()),
228
285
  };
229
- const costBreakdown = getCostBreakdown();
286
+ // Cloud stats are append-only events, so report only this prompt's delta.
287
+ // Sending the full session accumulator after every prompt makes totals grow
288
+ // 1× + 2× + 3× and is the source of the inflated dashboard token count.
289
+ const costBreakdown = getCostBreakdown(tokenReportStart);
230
290
  if (costBreakdown.length > 0) {
231
291
  for (const entry of costBreakdown) {
232
292
  reportStats({
@@ -938,31 +998,18 @@ async function gracefulShutdown() {
938
998
  return;
939
999
  const messages = app.getMessages();
940
1000
  autoSaveSession(messages, projectPath);
941
- const { syncSessionAsync, reportStatsAsync, generateProjectId } = require('../utils/codeepCloud.js');
942
- const tokenStats = getSessionStats();
1001
+ const { syncSessionAsync, generateProjectId } = require('../utils/codeepCloud.js');
943
1002
  const projectId = projectPath ? generateProjectId(projectPath) : undefined;
944
- await Promise.all([
945
- syncSessionAsync({
946
- sessionId,
947
- sessionName: sessionId,
948
- projectName: projectContext?.name,
949
- messages,
950
- }),
951
- reportStatsAsync({
952
- model: config.get('model'),
953
- provider: config.get('provider'),
954
- sessionId,
955
- sessionName: sessionId,
956
- messageCount: messages.length,
957
- projectName: projectContext?.name,
958
- projectId,
959
- inputTokens: tokenStats.totalPromptTokens || undefined,
960
- outputTokens: tokenStats.totalCompletionTokens || undefined,
961
- cacheCreationTokens: tokenStats.totalCacheCreationTokens || undefined,
962
- cacheReadTokens: tokenStats.totalCacheReadTokens || undefined,
963
- estimatedCost: tokenStats.estimatedCost || undefined,
964
- }),
965
- ]);
1003
+ // Successful manual and agent turns report their token deltas immediately.
1004
+ // Re-sending the cumulative session total here would count every token a
1005
+ // second time when the append-only dashboard endpoint stores this event.
1006
+ await syncSessionAsync({
1007
+ sessionId,
1008
+ sessionName: sessionDisplayName || sessionId,
1009
+ projectName: projectContext?.name,
1010
+ projectId,
1011
+ messages,
1012
+ });
966
1013
  }
967
1014
  // ─── Last-resort crash handlers ───────────────────────────────────────────────
968
1015
  // Without these, a stray throw or rejected promise (deep in the agent loop or a
@@ -163,7 +163,7 @@ export function buildPausedResult(kind, ctx) {
163
163
  };
164
164
  }
165
165
  const DEFAULT_OPTIONS = {
166
- // Modern models (GLM-5.1, Claude 4.5, GPT-4.1) complete typical coding tasks in
166
+ // Modern models (GLM-5.2, Claude 5, GPT-5.x) complete typical coding tasks in
167
167
  // 3–8 iterations. The old cap of 100 mostly let broken loops wander for minutes
168
168
  // before giving up. 25 is still generous — covers multi-file refactors — without
169
169
  // turning small fixes into marathons. Users can still raise this via /settings.
@@ -18,7 +18,7 @@
18
18
  * name: reviewer
19
19
  * description: Reviews a diff for correctness & security
20
20
  * tools: [read_file, search_code, execute_command] # allowlist; omit = all
21
- * model: glm-5.1 # optional provider/model or model override
21
+ * model: glm-5.2 # optional provider/model or model override
22
22
  * personality: security # optional — reuse a personality preset
23
23
  * maxIterations: 15 # optional budget
24
24
  * ---
@@ -18,7 +18,7 @@
18
18
  * name: reviewer
19
19
  * description: Reviews a diff for correctness & security
20
20
  * tools: [read_file, search_code, execute_command] # allowlist; omit = all
21
- * model: glm-5.1 # optional provider/model or model override
21
+ * model: glm-5.2 # optional provider/model or model override
22
22
  * personality: security # optional — reuse a personality preset
23
23
  * maxIterations: 15 # optional budget
24
24
  * ---
@@ -27,7 +27,7 @@
27
27
  * "createdAt": "...",
28
28
  * "sessionId": "session-2026-05-18-...",
29
29
  * "provider": "z.ai",
30
- * "model": "glm-5.1",
30
+ * "model": "glm-5.2",
31
31
  * "messages": [ ... ],
32
32
  * "filesTouched": ["src/a.ts", "src/b.ts"],
33
33
  * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
@@ -27,7 +27,7 @@
27
27
  * "createdAt": "...",
28
28
  * "sessionId": "session-2026-05-18-...",
29
29
  * "provider": "z.ai",
30
- * "model": "glm-5.1",
30
+ * "model": "glm-5.2",
31
31
  * "messages": [ ... ],
32
32
  * "filesTouched": ["src/a.ts", "src/b.ts"],
33
33
  * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Broad operational-impact estimate for hosted LLM inference.
3
+ *
4
+ * This is deliberately a range, not a meter reading:
5
+ * - 0.3–1.5 J/token covers published H100 inference benchmarks.
6
+ * - 0.27–1.08 L/kWh spans efficient direct cooling and a full-stack
7
+ * production estimate that also captures associated infrastructure.
8
+ *
9
+ * Model size, batching, context length, hardware, data-centre location and
10
+ * provider efficiency can move the real result outside this band. Local
11
+ * models are included because their token usage still consumes electricity,
12
+ * but Codeep cannot measure the device directly.
13
+ */
14
+ export interface ResourceImpactEstimate {
15
+ energyWhLow: number;
16
+ energyWhHigh: number;
17
+ waterMlLow: number;
18
+ waterMlHigh: number;
19
+ }
20
+ export declare function estimateResourceImpact(totalTokens: number): ResourceImpactEstimate;
21
+ export declare function formatResourceImpact(estimate: ResourceImpactEstimate): {
22
+ energy: string;
23
+ water: string;
24
+ };
25
+ export declare function formatResourceImpactReport(totalTokens: number): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Broad operational-impact estimate for hosted LLM inference.
3
+ *
4
+ * This is deliberately a range, not a meter reading:
5
+ * - 0.3–1.5 J/token covers published H100 inference benchmarks.
6
+ * - 0.27–1.08 L/kWh spans efficient direct cooling and a full-stack
7
+ * production estimate that also captures associated infrastructure.
8
+ *
9
+ * Model size, batching, context length, hardware, data-centre location and
10
+ * provider efficiency can move the real result outside this band. Local
11
+ * models are included because their token usage still consumes electricity,
12
+ * but Codeep cannot measure the device directly.
13
+ */
14
+ const ENERGY_JOULES_PER_TOKEN = { low: 0.3, high: 1.5 };
15
+ const WATER_LITRES_PER_KWH = { low: 0.27, high: 1.08 };
16
+ export function estimateResourceImpact(totalTokens) {
17
+ const tokens = Number.isFinite(totalTokens) ? Math.max(0, totalTokens) : 0;
18
+ const energyWhLow = (tokens * ENERGY_JOULES_PER_TOKEN.low) / 3600;
19
+ const energyWhHigh = (tokens * ENERGY_JOULES_PER_TOKEN.high) / 3600;
20
+ return {
21
+ energyWhLow,
22
+ energyWhHigh,
23
+ waterMlLow: (energyWhLow / 1000) * WATER_LITRES_PER_KWH.low * 1000,
24
+ waterMlHigh: (energyWhHigh / 1000) * WATER_LITRES_PER_KWH.high * 1000,
25
+ };
26
+ }
27
+ function compact(value) {
28
+ if (value === 0)
29
+ return '0';
30
+ if (value < 0.01)
31
+ return value.toFixed(3);
32
+ if (value < 1)
33
+ return value.toFixed(2);
34
+ if (value < 10)
35
+ return value.toFixed(1);
36
+ return Math.round(value).toLocaleString('en-US');
37
+ }
38
+ export function formatResourceImpact(estimate) {
39
+ const energy = estimate.energyWhHigh >= 1000
40
+ ? `${compact(estimate.energyWhLow / 1000)}–${compact(estimate.energyWhHigh / 1000)} kWh`
41
+ : `${compact(estimate.energyWhLow)}–${compact(estimate.energyWhHigh)} Wh`;
42
+ const water = estimate.waterMlHigh >= 1000
43
+ ? `${compact(estimate.waterMlLow / 1000)}–${compact(estimate.waterMlHigh / 1000)} L`
44
+ : `${compact(estimate.waterMlLow)}–${compact(estimate.waterMlHigh)} mL`;
45
+ return { energy, water };
46
+ }
47
+ export function formatResourceImpactReport(totalTokens) {
48
+ const formatted = formatResourceImpact(estimateResourceImpact(totalTokens));
49
+ return [
50
+ '### Estimated compute impact',
51
+ `**Electricity:** ${formatted.energy} · **Cooling water:** ${formatted.water}`,
52
+ '_Research-based range, not a provider measurement. Actual usage varies widely by model, hardware, batching, context length, data-centre location, and cooling system._',
53
+ ];
54
+ }