loom-agent 1.2.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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,910 @@
1
+ const { ProviderRouter } = require('../providers');
2
+ const { getModelMeta } = require('../providers');
3
+ const { getToolDefinitions, getAllToolDefinitions, executeTool } = require('../tools');
4
+ const { agentToolAllowed, filterToolDefs } = require('./agents');
5
+ const { loadConfig, saveConfig } = require('../config/settings');
6
+ const { PermissionManager, normPathArg } = require('./permissions');
7
+ const { emit } = require('./events');
8
+ const hooks = require('./hooks');
9
+ const { match: matchSkill } = require('../skills/skill-matcher.js');
10
+
11
+ // Template written when LOOM.md is auto-created (or via /init).
12
+ const MEMORY_TEMPLATE =
13
+ '# LOOM.md\n\n' +
14
+ '## Project Overview\n<!-- Describe your project here -->\n\n' +
15
+ '## Build Commands\n<!-- e.g., npm run build, make, etc. -->\n\n' +
16
+ '## Test Commands\n<!-- e.g., npm test, pytest, etc. -->\n\n' +
17
+ '## Code Style\n<!-- Coding conventions, linting rules, etc. -->\n\n' +
18
+ '## Architecture\n<!-- Key architectural decisions and patterns -->\n';
19
+
20
+ // Compaction: run when the estimated context exceeds this fraction of the model window.
21
+ const COMPACT_DEFAULT_THRESHOLD = 0.75;
22
+ // Keep this many most-recent messages verbatim; summarize the rest.
23
+ const COMPACT_KEEP_MESSAGES = 8;
24
+ // Never compact conversations shorter than this.
25
+ const COMPACT_MIN_MESSAGES = 6;
26
+
27
+ /**
28
+ * @typedef {Object} SpeedStats
29
+ * @property {number} _turnStart
30
+ * @property {number} _firstTokenAt
31
+ * @property {number} _liveTokens
32
+ * @property {number|null} lastLatencyMs
33
+ * @property {number|null} lastTokensPerSec
34
+ * @property {number|null} lastDurationMs
35
+ * @property {number|null} lastTokens
36
+ * @property {string} lastModel
37
+ */
38
+
39
+ // Aborts surface as DOMException AbortError (anthropic fetch), APIUserAbortError
40
+ // (openai SDK), or wrapped messages containing "aborted". Never let an interrupt
41
+ // leak into a retry or an error bubble.
42
+ function isAbortError(err) {
43
+ if (!err) return false;
44
+ const name = String(err.name || err.error?.name || '');
45
+ if (name === 'AbortError' || name === 'APIUserAbortError') return true;
46
+ return /aborted|cancel(led|ed)/i.test(String(err.message || ''));
47
+ }
48
+
49
+ // Quota/credits exhausted errors — the model is out of tokens (free tiers,
50
+ // daily limits, billing caps). These are switch-to-another-model triggers.
51
+ function isQuotaError(err) {
52
+ if (!err) return false;
53
+ const status = Number(err.status || err.code || err.error?.status || 0);
54
+ if (status === 402) return true;
55
+ if (status === 429) return true;
56
+ const msg = String(err.message || err.error?.message || err.body?.error?.message || '');
57
+ return /quota|exhausted|insufficient.{0,20}(balance|credits|quota)|out of (tokens|credits|balance)|no (more )?(tokens|credits|balance)|rate.?limit|billing limit|payment required|max.?quota|limit reached/i.test(msg);
58
+ }
59
+
60
+ class Session {
61
+ constructor() {
62
+ this.config = loadConfig();
63
+ this.provider = new ProviderRouter();
64
+ this.provider.init();
65
+ this.messages = [];
66
+ this.conversationId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
67
+ this.turnCount = 0;
68
+ this.tokensUsed = 0;
69
+ this.tokensIn = 0;
70
+ this.tokensOut = 0;
71
+ this.sessionCost = 0;
72
+ this.permissions = new PermissionManager();
73
+ // OpenCode-style permission tree (config.permission + config.agent.<id>.permission).
74
+ this.permissions.loadConfig(this.config);
75
+ // Restore saved permission rules ("always allow"/"never") and persist new
76
+ // ones chosen through the TUI permission popup.
77
+ this.permissions.loadRules(this.config.permissionRules || {});
78
+ this.permissions.onRuleChange = (key, value) => {
79
+ try {
80
+ const cfg = loadConfig();
81
+ cfg.permissionRules = cfg.permissionRules || {};
82
+ if (value == null) delete cfg.permissionRules[key];
83
+ else cfg.permissionRules[key] = value;
84
+ saveConfig(cfg);
85
+ this.config = cfg;
86
+ } catch {}
87
+ };
88
+ this.interrupted = false;
89
+ this.abortController = null;
90
+ this.mode = 'build';
91
+ // Agent system (OpenCode-style): null = the mode's default primary agent;
92
+ // a subagent def means THIS session (or this turn) is that agent and its
93
+ // tool filter gates every tool call + the provider tool schema.
94
+ this.agent = null;
95
+ this._agentBlock = null;
96
+ this._isChild = false;
97
+ // Auto-create the project memory file (LOOM.md, like CLAUDE.md) on start.
98
+ this.ensureMemoryFile();
99
+ this.systemPrompt = this.buildSystemPrompt();
100
+ this.todos = [];
101
+ this.compactCount = 0;
102
+ this.lastCompact = null;
103
+ // Start MCP tool discovery in the background so the first turn doesn't
104
+ // stall on server startup (npx downloads, etc.).
105
+ try { require('../mcp/mcp-client').warm(); } catch {}
106
+ }
107
+
108
+ setMode(mode) {
109
+ if (mode === 'plan' || mode === 'chat' || mode === 'build') {
110
+ if (this.mode === mode) return this.mode; // no rebuild if unchanged
111
+ this.mode = mode;
112
+ this.systemPrompt = this.buildSystemPrompt();
113
+ this._skillBlock = ''; // per-turn skill injection (cleared on each send)
114
+ this._skillMatcher = null; // testable override: function(text) -> [skill]
115
+ }
116
+ return this.mode;
117
+ }
118
+
119
+ // Switch provider+model: persist to config, record as recent, and refresh the
120
+ // live router so the change applies immediately (no restart needed).
121
+ setModel(provider, modelId) {
122
+ const { loadConfig, saveConfig, recordModelUse } = require('../config/settings');
123
+ const cfg = loadConfig();
124
+ cfg.provider = provider;
125
+ cfg.model = cfg.model || {};
126
+ cfg.model[provider] = modelId;
127
+ saveConfig(cfg);
128
+ recordModelUse(provider, modelId);
129
+ this.config = loadConfig();
130
+ try { this.provider.init(provider); } catch {}
131
+ emit('model:switch', { from: this.provider.active?.name || '', to: provider + '/' + modelId, reason: 'manual' });
132
+ return this.provider.active;
133
+ }
134
+
135
+ // Try to switch to another usable model when the current one runs out of
136
+ // tokens. Prefers recently-used models (except the failing one), then any
137
+ // other provider that has a key and a different model. Returns the new
138
+ // { provider, model } or null when nothing is available.
139
+ autoSwitchModel(excludeProvider) {
140
+ const { loadConfig, getRecentModels, hasApiKey } = require('../config/settings');
141
+ const { PROVIDERS, PROVIDER_ORDER } = require('../providers/index.js');
142
+ const cfg = loadConfig();
143
+ const currentProvider = excludeProvider || cfg.provider;
144
+ const currentModel = cfg.model?.[currentProvider];
145
+ const tried = this._switchedModels || (this._switchedModels = []);
146
+ const keyOf = (p, m) => p + '/' + m;
147
+
148
+ // Budget level active: only switch to another model that matches the level.
149
+ if (cfg.budgetLevel && cfg.budgetLevel !== 'auto') {
150
+ const { pickModel } = require('./model-router');
151
+ const picked = pickModel(cfg.budgetLevel, { tried });
152
+ if (picked) {
153
+ tried.push(keyOf(picked.provider, picked.model));
154
+ emit('model:switch', { from: keyOf(currentProvider, currentModel), to: keyOf(picked.provider, picked.model), reason: 'quota', level: cfg.budgetLevel });
155
+ return picked;
156
+ }
157
+ }
158
+
159
+ const candidates = [];
160
+ // 1. Recently used models, newest first, skipping the failing one.
161
+ for (const r of getRecentModels()) {
162
+ if (!r || !r.provider || !r.model) continue;
163
+ if (keyOf(r.provider, r.model) === keyOf(currentProvider, currentModel)) continue;
164
+ if (tried.includes(keyOf(r.provider, r.model))) continue;
165
+ if (!hasApiKey(r.provider)) continue;
166
+ candidates.push(r);
167
+ }
168
+ // 2. Any provider (except current) with a key and at least one model.
169
+ if (!candidates.length) {
170
+ for (const p of PROVIDER_ORDER) {
171
+ if (p === currentProvider) continue;
172
+ if (!hasApiKey(p)) continue;
173
+ const mods = (PROVIDERS[p] && PROVIDERS[p].models) || [];
174
+ if (mods.length && !tried.includes(keyOf(p, mods[0].id))) {
175
+ candidates.push({ provider: p, model: mods[0].id });
176
+ }
177
+ }
178
+ }
179
+ const next = candidates[0];
180
+ if (!next) return null;
181
+ tried.push(keyOf(next.provider, next.model));
182
+ this.setModel(next.provider, next.model);
183
+ return { provider: next.provider, model: next.model };
184
+ }
185
+
186
+ buildSystemPrompt() {
187
+ const os = require('os');
188
+ const cwd = process.cwd();
189
+ const memory = this.loadMemory();
190
+ const plat = require('./platform').detect();
191
+ const shell = plat.platform === 'win32' ? 'PowerShell 5.1' : 'bash/zsh';
192
+ const hasWSL = plat.isWSL ? ' (WSL)' : '';
193
+ return `You are Loom, a terminal coding agent: fast, terse, action-first.
194
+
195
+ ## Environment
196
+ - Working directory: ${cwd}
197
+ - OS: ${plat.platform}-${plat.arch}${hasWSL}
198
+ - Shell: ${shell}
199
+ - Today's date: ${new Date().toLocaleDateString()}
200
+
201
+ ## Memory (from LOOM.md)
202
+ ${memory}
203
+
204
+ When the user states a durable preference, correction, or project fact ("we use bun", "never touch X", "deploy via Y"), persist it yourself: append ONE dated bullet under the "## Remembered" heading of ./LOOM.md using the edit tool (create the heading if missing). Keep it terse; don't ask permission for obvious preferences. Don't store secrets or one-off task details.
205
+
206
+ ## Skills
207
+ ${this.loadSkills()}
208
+
209
+ ## Behavior
210
+ - Decide before acting: if the answer needs no external data, reply directly with ZERO tool calls. Simple or conversational questions are answered in one message, nothing runs.
211
+ - When tools ARE needed, act first and batch independent calls (e.g., read a file, grep a symbol, glob files at once). Never call tools speculatively or one at a time when they can be batched.
212
+ - Never call a time/date tool or a sequential-thinking tool — today's date is already in Environment, and you never need to "think" via a tool.
213
+ - Extended thinking is ON when your model supports it: reason BEFORE you act and re-evaluate AFTER every tool result. For complex or multi-step tasks, think hard at each stage (plan → act → check) instead of rushing to the answer — this is what separates a good result from a guessed one.
214
+ - Be terse in the final reply: say what changed and nothing else.
215
+ - Never narrate ("I will now read the file…"). Just call the tool.
216
+ - Prefer edits over full-file writes when the change is small.
217
+ - After writing code, run any test/bench command the user has relied on if one exists.
218
+
219
+ ## Formatting
220
+ - ALWAYS reply in Markdown. The terminal renders it with colors, so make every reply structured and readable:
221
+ - Use **bold** for key terms and important results, \`inline code\` for file paths, symbols and commands, and fenced \`\`\` blocks for any code or config.
222
+ - Use ## headings and - bullet lists to organize longer replies; keep code inside fenced blocks, never inline dumps.
223
+ - Even one-line answers should still use markdown inline styling where it helps (e.g. "Done — **rewrote** \`src/app.ts\`: \`+12 -4\` lines, tests **pass**.").
224
+ - Never reply with a wall of plain unstyled text — the user's UI highlights markdown, and raw text renders as a single flat color.
225
+ - Multi-step work MUST be tracked with the todowrite tool (the UI mirrors it live in a sidebar and in the chat): call it up front with the task list, update item statuses (pending/in_progress/completed/cancelled) as you work, and call it one last time when done. Keep the list items terse ("Fix signup bug", not prose).
226
+
227
+ ## Hard rules
228
+ - Batch independent tool calls into a single response; never send one tool call at a time.
229
+ - You may only issue multiple tool calls if none of them depends on another's result.
230
+ - Never guess a tool result — read it before your next call.
231
+ - Never commit/push without explicit instruction. Never print API keys.
232
+ - When a tool FAILS, do not retry the same call with the same arguments. After the first failure, change the approach: e.g. use read/grep to understand the state before trying an edit again, run a simpler variant of the command, or ask the user for the missing input (API key, path, permission). Three same-goal failures == stop and escalate: say what failed and suggest the fix instead of looping.
233
+ - For long outputs (links, large files), stream whatever the tool gave you in one message — do not emit partial/truncated responses piecemeal.
234
+
235
+ ## Mode
236
+ You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
237
+ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
238
+ - Inspect, edit, and verify the result yourself end-to-end.
239
+ - When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
240
+ - Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
241
+ - If the user wants code changes, tell them to switch to Build mode (Tab or /build) and resend.` : ''}${this.config.outputStyle ? `\n\n## Output style\n${this.config.outputStyle}` : ''}`;
242
+ }
243
+
244
+ // Create ./LOOM.md with a template when missing, so memory exists from the
245
+ // first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
246
+ ensureMemoryFile() {
247
+ if (process.env.LOOM_MEM_AUTO === '0') return null;
248
+ try {
249
+ const fs = require('fs');
250
+ const path = require('path');
251
+ const p = path.join(process.cwd(), 'LOOM.md');
252
+ if (fs.existsSync(p)) return p;
253
+ fs.writeFileSync(p, MEMORY_TEMPLATE);
254
+ return p;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ loadMemory() {
261
+ // Layered memory with @imports (src/core/memory.js): global ~/.loom/LOOM.md
262
+ // then project LOOM.md / .loom/LOOM.md, plus CLAUDE.md compatibility.
263
+ const fs = require('fs');
264
+ const path = require('path');
265
+ const cwd = process.cwd();
266
+ let memory = '';
267
+ try { memory = require('./memory').loadMemory(); } catch {}
268
+ for (const p of [path.join(cwd, '.claude', 'CLAUDE.md')]) {
269
+ try {
270
+ if (!memory && fs.existsSync(p)) {
271
+ memory = '## From ' + path.basename(p) + '\n' + fs.readFileSync(p, 'utf8');
272
+ }
273
+ } catch {}
274
+ }
275
+ if (!memory) memory = '(No memory file found.)';
276
+ return memory;
277
+ }
278
+
279
+ loadSkills() {
280
+ try {
281
+ const { listSkills } = require('../skills/skills-manager');
282
+ const skills = listSkills();
283
+ if (!skills.length) return '(No skills installed. Run /skills to see options.)';
284
+ // Compact index only — full instructions are injected per-turn by the
285
+ // skill matcher when the user's message matches a skill's keywords.
286
+ return skills
287
+ .map((s) => `- ${s.name} (${s.source}): ${s.description || 'no description'}`)
288
+ .join('\n');
289
+ } catch (e) {
290
+ return '(Skills unavailable: ' + e.message + ')';
291
+ }
292
+ }
293
+
294
+ interrupt() {
295
+ this.interrupted = true;
296
+ if (this.abortController) this.abortController.abort();
297
+ }
298
+
299
+ refresh() {
300
+ this.config = loadConfig();
301
+ this.provider.init();
302
+ }
303
+
304
+ async sendUserMessage(text, callbacks = {}, opts = {}) {
305
+ // Optional agent turn ("@agent ..."): the MAIN session runs this one turn
306
+ // as the named subagent — its tool filter gates tools, its prompt block is
307
+ // appended, and the reply is the subagent's own answer.
308
+ const wasAgent = this.agent;
309
+ if (opts.agentId) {
310
+ const { resolveAgent, buildAgentTurnBlock } = require('./agents');
311
+ const agent = resolveAgent(opts.agentId);
312
+ if (!agent) {
313
+ return { type: 'error', content: `Unknown agent: ${opts.agentId}` };
314
+ }
315
+ if (agent.mode !== 'subagent') {
316
+ return { type: 'error', content: `"${agent.name}" is a primary agent — use it directly (Tab / /${agent.id}).` };
317
+ }
318
+ this.agent = agent;
319
+ this._agentBlock = buildAgentTurnBlock(agent);
320
+ this.permissions.setAgent(agent.id);
321
+ } else {
322
+ this.permissions.setAgent(null);
323
+ }
324
+
325
+ // Config is loaded at construction; only provider pivots (setModel, /connect)
326
+ // trigger a refresh. Re-reading disk and re-init'ing the provider on every
327
+ // message was noticeable overhead per turn.
328
+ this.interrupted = false;
329
+
330
+ // Skill auto-trigger: if the user's message mentions a skill keyword
331
+ // (e.g. "slice", "gcode", "cad"), load the matched skill's instructions
332
+ // into the system prompt for this turn only. Zero LLM cost — pure
333
+ // keyword matching on frontmatter.
334
+ this._activeSkill = [];
335
+ let skillBlock = '';
336
+ const t0 = Date.now();
337
+ // this._skillMatcher lets tests inject a fake matcher (avoid filesystem deps).
338
+ const hits = (this._skillMatcher || matchSkill)(text)
339
+ .filter(s => !(this.config.skillDisabled || []).includes(s.name));
340
+ if (hits.length) {
341
+ // hits from skill-matcher have shape {skill, score, matched}; test mocks may
342
+ // return plain skill objects. Normalize to plain skill objects.
343
+ this._activeSkill = hits.map(h => (h.skill || h).name); // newest-first for telemetry
344
+ skillBlock = '\n\n[Active skill for this turn: ' + this._activeSkill.join(', ') + ']\n' +
345
+ hits.map(h => (h.skill || h).instructions || (h.skill || h).description || '').join('\n\n') +
346
+ '\n\nFollow these instructions precisely.';
347
+ this._skillBlock = skillBlock;
348
+ emit('trigger:skill', { skills: this._activeSkill, latencyMs: Date.now() - t0 });
349
+ } else {
350
+ this._skillBlock = '';
351
+ }
352
+
353
+ this.addMessage({ role: 'user', content: text });
354
+ this.turnCount++;
355
+ const turnAgent = this.agent?.id || null;
356
+ emit('turn:start', { text, agent: turnAgent });
357
+ let resp;
358
+ try {
359
+ resp = await this.runTurn(callbacks);
360
+ } finally {
361
+ // Restore the default primary agent after an agent-scoped turn.
362
+ if (opts.agentId) {
363
+ this.agent = wasAgent;
364
+ this._agentBlock = null;
365
+ }
366
+ }
367
+ // stop hook — fires when the whole turn ends (success, error, interrupt).
368
+ try { await hooks.runHook('stop', { reason: resp?.type || 'end' }); } catch {}
369
+ emit('turn:end', {
370
+ text,
371
+ type: resp?.type,
372
+ cost: this.sessionCost,
373
+ model: (this.provider.active?.name || '') + '/' + (this.config.model?.[this.provider.active?.name] || ''),
374
+ level: this.config.budgetLevel || 'auto',
375
+ skills: this._activeSkill || [],
376
+ agent: turnAgent,
377
+ });
378
+ return resp;
379
+ }
380
+
381
+ addMessage(msg) {
382
+ this.messages.push(msg);
383
+ }
384
+
385
+ // ─── Todo state ───
386
+ // The todowrite tool persists into the session so the sidebar shows the
387
+ // real, up-to-date task list instead of regex-scanning replies.
388
+ setTodos(items) {
389
+ const valid = ['pending', 'in_progress', 'completed', 'cancelled'];
390
+ const out = [];
391
+ const byContent = new Map();
392
+ for (const t of Array.isArray(items) ? items : []) {
393
+ const content = String(t?.content || '').trim();
394
+ if (!content) continue;
395
+ byContent.set(content, {
396
+ content,
397
+ status: valid.includes(t?.status) ? t.status : 'pending',
398
+ priority: ['high', 'medium', 'low'].includes(t?.priority) ? t.priority : 'medium',
399
+ });
400
+ }
401
+ // Map preserves first-insert order; later entries for the same content
402
+ // override the status/priority (upsert semantics).
403
+ for (const [content, t] of byContent) {
404
+ out.push({
405
+ content,
406
+ status: t.status,
407
+ priority: t.priority,
408
+ });
409
+ }
410
+ this.todos = out;
411
+ return out;
412
+ }
413
+
414
+ // ─── Compaction ───
415
+ // Rough token estimate from message text (chars/4) — used to decide when
416
+ // the context window is getting full.
417
+ estimateTokens() {
418
+ let chars = 0;
419
+ for (const m of this.messages) {
420
+ chars += String(m.content || '').length;
421
+ for (const tc of m.toolCalls || []) chars += JSON.stringify(tc.input || {}).length;
422
+ }
423
+ return Math.ceil(chars / 4);
424
+ }
425
+
426
+ getContextWindow() {
427
+ const meta = getModelMeta(this.provider.active?.name, this.config.model?.[this.provider.active?.name]);
428
+ return meta?.context || 200000;
429
+ }
430
+
431
+ shouldCompact() {
432
+ if (this.messages.length < COMPACT_MIN_MESSAGES) return false;
433
+ const threshold = this.config.compactThreshold ?? COMPACT_DEFAULT_THRESHOLD;
434
+ return this.estimateTokens() > this.getContextWindow() * threshold;
435
+ }
436
+
437
+ // Real compaction: summarize the older messages with the model and keep the
438
+ // most recent COMPACT_KEEP_MESSAGES verbatim. Falls back to truncation when
439
+ // no provider/key is available or the summary call fails.
440
+ async compact(callbacks = {}) {
441
+ if (this.messages.length <= COMPACT_KEEP_MESSAGES + 2) {
442
+ return { compacted: false, reason: 'conversation too short', removed: 0, method: 'none' };
443
+ }
444
+ const keep = this.messages.slice(-COMPACT_KEEP_MESSAGES);
445
+ const head = this.messages.slice(0, -COMPACT_KEEP_MESSAGES);
446
+ const before = this.estimateTokens();
447
+
448
+ let summary = null;
449
+ const provider = this.provider.active && this.provider.providers[this.provider.active.name];
450
+ if (provider) {
451
+ try {
452
+ const model = this.config.model?.[this.provider.active?.name];
453
+ const resp = await provider.chat(
454
+ head.concat([{
455
+ role: 'user',
456
+ content: 'Summarize the conversation so far between the user and an AI coding agent (Loom). ' +
457
+ 'Keep it factual and dense. Preserve: the task being worked on, every file path mentioned, ' +
458
+ 'all decisions made, any errors/tests, and unfinished steps. Use bullet points. ' +
459
+ 'End with a "## Next steps" section listing exactly what remains. Do not add anything beyond the summary.',
460
+ }]),
461
+ { model, maxTokens: 1500, temperature: 0.2, tools: [], system: 'You are a conversation summarizer. Output only the summary.' }
462
+ );
463
+ summary = resp?.content || null;
464
+ } catch {
465
+ summary = null;
466
+ }
467
+ }
468
+
469
+ if (summary) {
470
+ this.messages = [
471
+ { role: 'system', content: '[Compacted — earlier conversation summarized. Ask for details if you need specifics.]\n\n' + summary.slice(0, 8000) },
472
+ ...keep,
473
+ ];
474
+ this.compactCount = (this.compactCount || 0) + 1;
475
+ this.lastCompact = { at: Date.now(), method: 'summary', removed: head.length };
476
+ if (callbacks.onCompact) callbacks.onCompact({ method: 'summary', removed: head.length, summary });
477
+ return { compacted: true, removed: head.length, method: 'summary', summary, tokensBefore: before, tokensAfter: this.estimateTokens() };
478
+ }
479
+
480
+ // Fallback: drop the oldest messages, keep the recent tail verbatim.
481
+ this.messages = [
482
+ { role: 'system', content: `[Compacted — ${head.length} earlier messages truncated. Ask for details if you need specifics.]` },
483
+ ...keep,
484
+ ];
485
+ this.compactCount = (this.compactCount || 0) + 1;
486
+ this.lastCompact = { at: Date.now(), method: 'truncate', removed: head.length };
487
+ if (callbacks.onCompact) callbacks.onCompact({ method: 'truncate', removed: head.length, summary: null });
488
+ return { compacted: true, removed: head.length, method: 'truncate', summary: null, tokensBefore: before, tokensAfter: this.estimateTokens() };
489
+ }
490
+
491
+ async runTurn(callbacks = {}) {
492
+ let lastContent = '';
493
+ // Doom-loop guard: three identical (tool, args) calls in a row = the model
494
+ // is stuck; the permission.doom_loop rule (default ask) decides.
495
+ /** @type {{ key: string|null, count: number }} */
496
+ let doomRun = { key: null, count: 0 };
497
+ // Accumulate the streamed text so an interrupt preserves partial output
498
+ // instead of returning "(interrupted)" and losing what was already said.
499
+ let streamed = '';
500
+ // Reasoning deltas (o1/deepseek-r1-style extended thinking) streamed ahead
501
+ // of the answer; surfaced to the UI so "+Thought" can show real content.
502
+ let reasoning = '';
503
+ // Per-turn speed telemetry: first-token latency and live tokens/sec.
504
+ /** @type {SpeedStats} */
505
+ const speed = this.speedStats || (this.speedStats = {
506
+ _turnStart: 0, _firstTokenAt: 0, _liveTokens: 0,
507
+ lastLatencyMs: null, lastTokensPerSec: null, lastDurationMs: null, lastTokens: null, lastModel: '',
508
+ });
509
+ const cb = callbacks.onDelta || callbacks.onReasoning
510
+ ? { ...callbacks, onDelta: (txt) => {
511
+ const now = Date.now();
512
+ streamed += txt;
513
+ speed._liveTokens += txt.length / 4;
514
+ if (!speed._firstTokenAt) speed._firstTokenAt = now;
515
+ if (callbacks.onDelta) callbacks.onDelta(txt);
516
+ }, onReasoning: (txt) => {
517
+ reasoning += txt;
518
+ if (callbacks.onReasoning) callbacks.onReasoning(txt);
519
+ } }
520
+ : callbacks;
521
+
522
+ // Auto-compact when the context window is getting full. The user's message
523
+ // is already in this.messages, so the recent tail includes it verbatim.
524
+ if (!this.interrupted && this.shouldCompact()) {
525
+ try {
526
+ const res = await this.compact(callbacks);
527
+ if (res.compacted && callbacks.onAutoCompact) callbacks.onAutoCompact(res);
528
+ } catch {}
529
+ }
530
+
531
+ const finishInterrupted = () => {
532
+ this.interrupted = false;
533
+ // Keep the partial assistant text in the conversation so the model can
534
+ // resume the task on the next turn ("continue").
535
+ if (streamed) {
536
+ this.addMessage({
537
+ role: 'assistant',
538
+ content: streamed,
539
+ interrupted: true,
540
+ });
541
+ }
542
+ return { type: 'text', content: streamed || '(interrupted)', interrupted: true };
543
+ };
544
+
545
+ // No tool-use cap: the model decides when a turn is finished; the user can
546
+ // always Esc-interrupt a runaway loop.
547
+ let ranTools = false;
548
+ let nudged = false;
549
+ while (true) {
550
+ if (this.interrupted) {
551
+ return finishInterrupted();
552
+ }
553
+
554
+ let resp;
555
+ speed._turnStart = Date.now();
556
+ speed._firstTokenAt = 0;
557
+ speed._liveTokens = 0;
558
+ try {
559
+ resp = await this.getResponse(cb);
560
+ } catch (err) {
561
+ if (isAbortError(err)) {
562
+ return finishInterrupted();
563
+ }
564
+ // Model ran out of tokens → auto-switch to another model and retry once.
565
+ if (isQuotaError(err)) {
566
+ const fromKey = (this.provider.active?.name || '?') + '/' + (this.config.model?.[this.provider.active?.name] || '?');
567
+ const switched = this.autoSwitchModel(this.provider.active?.name);
568
+ if (switched) {
569
+ if (callbacks.onModelSwitch) {
570
+ callbacks.onModelSwitch({ from: fromKey, to: switched.provider + '/' + switched.model });
571
+ }
572
+ continue;
573
+ }
574
+ }
575
+ return { type: 'error', content: err.message };
576
+ }
577
+
578
+ if (resp.interrupted) {
579
+ return finishInterrupted();
580
+ }
581
+ if (resp.toolError) {
582
+ return { type: 'error', content: resp.toolError };
583
+ }
584
+ // Explicit error responses (budget router hard-block, no active provider)
585
+ // must reach the caller as errors, not as text.
586
+ if (resp.type === 'error') {
587
+ return resp;
588
+ }
589
+
590
+ if (resp.content) this.lastText = resp.content;
591
+
592
+ // Finalize the speed snapshot for this model call (or tool step).
593
+ if (resp && !resp.interrupted) {
594
+ const dur = Date.now() - speed._turnStart;
595
+ const tokens = (streamed.length || String(resp.content || '').length) / 4;
596
+ speed.lastDurationMs = dur;
597
+ speed.lastTokens = Math.round(tokens);
598
+ speed.lastTokensPerSec = dur > 0 ? Math.round((tokens / dur) * 1000) : 0;
599
+ speed.lastLatencyMs = speed._firstTokenAt ? speed._firstTokenAt - speed._turnStart : dur;
600
+ speed.lastModel = (this.provider.active?.name || '') + '/' + (this.config.model?.[this.provider.active?.name] || '');
601
+ }
602
+
603
+ // Normalize: assistant message + toolCalls already appended by getResponse
604
+ const toolCalls = resp.toolCalls || [];
605
+
606
+ if (!toolCalls.length) {
607
+ const text = String(resp.content || '');
608
+ // Silent-stop guard: models occasionally end a turn with an EMPTY
609
+ // reply right after running tools (truncation / provider hiccup),
610
+ // which reads to the user like "the agent just stopped". One
611
+ // automatic continuation instead of dead-ending with '(no response)'.
612
+ if (!text.trim() && ranTools && !nudged && !this.interrupted) {
613
+ nudged = true;
614
+ this.addMessage({ role: 'user', content: '(your last reply was empty — continue where you left off and finish the task)' });
615
+ continue;
616
+ }
617
+ return { type: 'text', content: text || '(no response)' };
618
+ }
619
+ ranTools = true;
620
+
621
+ // Execute independent tool calls in parallel; results are appended in the
622
+ // original call order so the conversation history stays deterministic.
623
+ const outcomes = await Promise.all(toolCalls.map(async (tc) => {
624
+ if (callbacks.onTool) callbacks.onTool(tc.name, tc.input, tc.id);
625
+
626
+ // Doom-loop guard: three identical (tool, args) calls in a row means
627
+ // the model is stuck repeating itself. The permission.doom_loop rule
628
+ // (default ask) decides whether to allow the third one.
629
+ const doomKey = tc.name + ':' + JSON.stringify(tc.input || {});
630
+ if (doomRun.key === doomKey) doomRun.count++;
631
+ else { doomRun.key = doomKey; doomRun.count = 1; }
632
+ if (doomRun.count === 3) {
633
+ const dAction = this.permissions.resolveKey('doom_loop', doomKey);
634
+ if (dAction === 'deny') {
635
+ return { tc, outcome: { error: 'Doom-loop detected: ' + tc.name + ' called 3 times with identical input. Permission denied.' } };
636
+ }
637
+ if (dAction === 'ask' && !this.permissions.auto && callbacks.onPermissionRequest) {
638
+ const res = await callbacks.onPermissionRequest(tc.name, String(doomKey), 'repeated identical call (doom loop)');
639
+ const approved = res && typeof res === 'object' ? !!res.approved : !!res;
640
+ if (!approved) {
641
+ return { tc, outcome: { error: 'Doom-loop detected: ' + tc.name + ' called 3 times with identical input.' } };
642
+ }
643
+ }
644
+ }
645
+
646
+ // Agent gate (defense in depth — the schema is filtered in getResponse
647
+ // too): a subagent can never call a tool outside its agent's list.
648
+ if (this.agent && !agentToolAllowed(this.agent, tc.name)) {
649
+ return { tc, outcome: { error: `Tool "${tc.name}" is not available to the ${this.agent.name} agent.` } };
650
+ }
651
+
652
+ // OpenCode-style permission gate: every named tool resolves through
653
+ // config.permission (+ config.agent.<agent>.permission) with
654
+ // wildcard patterns and the last matching rule winning. ask'able
655
+ // results flow through the TUI popup; doom_loop/external_directory
656
+ // are checked separately where they apply.
657
+ const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
658
+ task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
659
+ websearch:'websearch' }[tc.name];
660
+ const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
661
+ if (permKey || isMcpAdd) {
662
+ const arg = this.permissions.permissionArg(tc.name, tc.input || {});
663
+ let action = this.permissions.resolve(tc.name, arg);
664
+ // mcp add spawns arbitrary stdio servers with optional env secrets —
665
+ // always ask unless the user saved a rule for the exact command.
666
+ if (isMcpAdd && action === 'allow' && !this.permissions.checkRule(arg)) action = 'ask';
667
+ // External path beyond the working dir? Ask/deny via
668
+ // permission.external_directory, matched against the absolute path.
669
+ if (action === 'allow' && (permKey === 'read' || permKey === 'edit' || permKey === 'glob' || permKey === 'grep') && /[/\\]/.test(arg)) {
670
+ const ext = this.permissions.checkExternal(normPathArg(arg, process.cwd()));
671
+ if (ext !== 'allow') action = ext;
672
+ }
673
+ if (action === 'deny') {
674
+ return { tc, outcome: { error: `Permission denied: ${tc.name} (${permKey || 'mcp add'}).` } };
675
+ }
676
+ if (action === 'ask') {
677
+ if (this.permissions.auto) {
678
+ // Auto mode: only explicit denies are enforced; asks auto-approve.
679
+ } else if (callbacks.onPermissionRequest) {
680
+ const label = this.permissions.getDangerLabel(arg);
681
+ const res = await callbacks.onPermissionRequest(tc.name, arg, label, (tc.input || {}).options);
682
+ // The ask tool is a QUESTION, not a permission: the popup's
683
+ // answer comes back in res.note and becomes the tool result so
684
+ // the model can react to what the user actually said.
685
+ if (tc.name === 'ask') {
686
+ const ans = res && typeof res === 'object' ? (res.note || '') : '';
687
+ if (!ans) return { tc, outcome: { error: 'No answer given.' } };
688
+ return { tc, outcome: { result: ans } };
689
+ }
690
+ const approved = res && typeof res === 'object' ? !!res.approved : !!res;
691
+ if (!approved) {
692
+ const note = res && typeof res === 'object' && res.note ? ' ' + res.note : '';
693
+ return { tc, outcome: { error: 'Permission denied.' + note } };
694
+ }
695
+ } else {
696
+ return { tc, outcome: { error: 'Permission denied — no prompt available.' } };
697
+ }
698
+ }
699
+ }
700
+
701
+ // The permission gate above is the interactive check; mark the command
702
+ // as approved so the tool-layer safety filter doesn't double-block
703
+ // commands the user explicitly allowed.
704
+ const input = (tc.name === 'bash' || isMcpAdd) ? { ...tc.input, _approved: true } : tc.input;
705
+ // preToolUse hook: a user-configured command may veto this call.
706
+ try {
707
+ const gate = await hooks.runHook('preToolUse', { tool: tc.name, input: tc.input });
708
+ if (gate.blocked) return { tc, outcome: { error: 'Blocked by preToolUse hook.' + (gate.reason ? ' ' + gate.reason : '') } };
709
+ } catch {}
710
+ const outcome = await executeTool(tc.name, input, this.mode, {
711
+ parentSession: this,
712
+ signal: this.abortController ? this.abortController.signal : null,
713
+ progress: (ev) => {
714
+ // Subagent progress (task tool) — surfaced to the TUI so the live
715
+ // delegation panel can stream deltas, tool calls and status.
716
+ try { if (callbacks.onSubagent) callbacks.onSubagent(ev); } catch {}
717
+ },
718
+ stream: (chunk, kind) => {
719
+ // Live terminal output (bash tool) — relayed to the TUI so the
720
+ // chat can stream a growing output block while the command runs.
721
+ try { if (callbacks.onToolOutput) callbacks.onToolOutput(tc, chunk, kind); } catch {}
722
+ },
723
+ });
724
+ // postToolUse hook (informational — never blocks).
725
+ try { await hooks.runHook('postToolUse', { tool: tc.name, input: tc.input }); } catch {}
726
+ return { tc, outcome };
727
+ }));
728
+
729
+ for (const { tc, outcome } of outcomes) {
730
+ const text = outcome.error ? `Error: ${outcome.error}` : String(outcome.result ?? '');
731
+ this.addMessage({ role: 'tool', toolCallId: tc.id, content: text });
732
+ // Persist real todo state when the model uses the todowrite tool.
733
+ if (tc.name === 'todowrite' && !outcome.error) {
734
+ this.setTodos(tc.input && tc.input.todos);
735
+ // Notify observers (sidebar, etc.) over the tiny event bus — the TUI
736
+ // subscribes once and the panel updates without a re-render cycle.
737
+ try { emit('todos:changed', this.todos); } catch {}
738
+ }
739
+ if (callbacks.onToolResult) callbacks.onToolResult(tc.name, outcome, tc.input, tc.id);
740
+ }
741
+ }
742
+ }
743
+
744
+ async getResponse(callbacks) {
745
+ const tools = await getAllToolDefinitions(this.mode);
746
+ // Agent-scoped turns (delegation) see only their agent's tool list.
747
+ const toolDefs = this.agent ? filterToolDefs(this.agent, tools) : tools;
748
+
749
+ // Budget router: when a level is active (free/cheap/best), pick the model
750
+ // for THIS call without rewriting the user's saved provider/model. "Free"
751
+ // hard-blocks paid models — no key for a free model means no call.
752
+ const budgetLevel = this.config.budgetLevel;
753
+ if (budgetLevel && budgetLevel !== 'auto') {
754
+ const { pickModel } = require('./model-router');
755
+ const picked = pickModel(budgetLevel, { tried: this._switchedModels || [] });
756
+ if (!picked) {
757
+ return {
758
+ type: 'error',
759
+ content: `No ${budgetLevel}-level model available. Add a key for a provider with ${budgetLevel} models (/connect), or switch /budget auto.`,
760
+ };
761
+ }
762
+ if (this.provider.active?.name !== picked.provider) {
763
+ this.provider.use(picked.provider);
764
+ const { recordModelUse } = require('../config/settings');
765
+ recordModelUse(picked.provider, picked.model);
766
+ }
767
+ this.config.model = this.config.model || {};
768
+ this.config.model[picked.provider] = picked.model;
769
+ this.lastPickedModel = picked;
770
+ }
771
+
772
+ const provider = this.provider.active && this.provider.providers[this.provider.active.name];
773
+ if (!provider) {
774
+ return { type: 'error', content: 'No active provider. Run /connect.' };
775
+ }
776
+
777
+ const model = this.config.model?.[this.provider.active?.name];
778
+
779
+ // Spending governor: once the month's cost reaches the cap, paid turns are
780
+ // hard-blocked. Free models stay allowed — /budget free is the escape
781
+ // hatch. An explicit one-shot confirmation (/budget override) lets exactly
782
+ // one paid turn through before blocking again.
783
+ const { budgetStatus, consumeOverride, formatUsd } = require('./usage');
784
+ const spend = budgetStatus();
785
+ if (spend.over) {
786
+ const { getModelMeta } = require('../providers/index.js');
787
+ const meta = getModelMeta(this.provider.active?.name, model);
788
+ const isFree = !meta || ((meta.priceIn || 0) === 0 && (meta.priceOut || 0) === 0);
789
+ if (!isFree && !spend.overrideUsed) {
790
+ return {
791
+ type: 'error',
792
+ content: `Monthly budget reached — ${formatUsd(spend.monthCostUsd)} of ${formatUsd(spend.budgetUsd)} spent. Run /usage, switch /budget free (all-free routing), raise the cap with /budget <dollars>, or confirm exactly one paid turn with /budget override.`,
793
+ };
794
+ }
795
+ if (!isFree && spend.overrideUsed) {
796
+ consumeOverride();
797
+ }
798
+ }
799
+
800
+ const opts = {
801
+ model,
802
+ maxTokens: this.config.maxTokens || 8192,
803
+ temperature: this.config.temperature ?? 0.7,
804
+ tools: toolDefs,
805
+ system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || ''),
806
+ signal: this.abortController?.signal,
807
+ // Extended thinking: models tagged 'reasoning' reason BEFORE every
808
+ // reply — including after each tool result — so complex tasks get
809
+ // multiple thinking passes (opencode-style), not just the first one.
810
+ // /think off|low|medium|high overrides: 'off' disables reasoning,
811
+ // levels set an explicit budget_tokens for providers that take one.
812
+ reasoning: (() => {
813
+ if (this.config.thinkLevel === 'off') return false;
814
+ const { getModelMeta } = require('../providers/index.js');
815
+ const meta = getModelMeta(this.provider.active?.name, model);
816
+ return !!(meta && meta.tags && meta.tags.includes('reasoning'));
817
+ })(),
818
+ thinkingBudget: ({ low: 2048, medium: 8192, high: 16384 })[this.config.thinkLevel] || undefined,
819
+ };
820
+
821
+ this.abortController = new AbortController();
822
+ opts.signal = this.abortController.signal;
823
+
824
+ let resp;
825
+ if (callbacks.onDelta || callbacks.onReasoning) {
826
+ resp = await provider.stream(this.messages, opts, callbacks.onDelta, callbacks.onReasoning);
827
+ } else {
828
+ resp = await provider.chat(this.messages, opts);
829
+ }
830
+
831
+ if (this.interrupted || (opts.signal && opts.signal.aborted)) {
832
+ return { interrupted: true };
833
+ }
834
+
835
+ if (resp.usage) {
836
+ this.tokensUsed += (resp.usage.totalTokens || resp.usage.total_tokens || 0);
837
+ }
838
+
839
+ this.addMessage({ role: 'assistant', content: resp.content || '', toolCalls: resp.toolCalls, reasoning: resp.reasoning || '' });
840
+ this.recordUsage(resp.usage, model, this.provider.active?.name);
841
+ return resp;
842
+ }
843
+
844
+ // Normalize provider usage (OpenAI: prompt_tokens/completion_tokens, Anthropic: input_tokens/output_tokens)
845
+ // into input/output counts, then add them to the session counters and the persistent lifetime tracker.
846
+ recordUsage(usage, modelId, providerName) {
847
+ if (!usage) return;
848
+ const input = usage.prompt_tokens || usage.input_tokens || usage.promptTokenCount || 0;
849
+ const output = usage.completion_tokens || usage.output_tokens || usage.candidatesTokenCount || 0;
850
+ const total = usage.total_tokens || usage.totalTokens || (input + output);
851
+ if (!input && !output && !total) return;
852
+
853
+ this.tokensIn += input;
854
+ this.tokensOut += output;
855
+ this.tokensUsed += total;
856
+
857
+ const { getModelMeta } = require('../providers');
858
+ const meta = getModelMeta(providerName, modelId);
859
+ let cost = 0;
860
+ if (meta && (meta.priceIn || meta.priceOut)) {
861
+ cost = (input / 1e6) * (meta.priceIn || 0) + (output / 1e6) * (meta.priceOut || 0);
862
+ this.sessionCost += cost;
863
+ }
864
+ const { recordUsage: persist } = require('./usage');
865
+ persist({ inputTokens: input, outputTokens: output, costUsd: cost });
866
+ }
867
+
868
+ // Live + last-turn speed snapshot for the sidebar (tokens/sec, first-token
869
+ // latency). nulls until the first streamed delta.
870
+ getSpeed() {
871
+ /** @type {SpeedStats} */
872
+ const s = this.speedStats || (this.speedStats = {
873
+ _turnStart: 0, _firstTokenAt: 0, _liveTokens: 0,
874
+ lastLatencyMs: null, lastTokensPerSec: null, lastDurationMs: null, lastTokens: null, lastModel: '',
875
+ });
876
+ const now = Date.now();
877
+ const elapsed = now - s._turnStart;
878
+ const liveTps = elapsed > 0 && s._liveTokens > 0 ? Math.round((s._liveTokens / elapsed) * 1000) : 0;
879
+ return {
880
+ live: {
881
+ elapsedMs: elapsed,
882
+ firstTokenMs: s._firstTokenAt ? s._firstTokenAt - s._turnStart : null,
883
+ tokensPerSec: liveTps,
884
+ },
885
+ last: {
886
+ latencyMs: s.lastLatencyMs,
887
+ tokensPerSec: s.lastTokensPerSec,
888
+ durationMs: s.lastDurationMs,
889
+ tokens: s.lastTokens,
890
+ model: s.lastModel,
891
+ },
892
+ };
893
+ }
894
+
895
+ reset() {
896
+ this.messages = [];
897
+ this.turnCount = 0;
898
+ this.tokensUsed = 0;
899
+ this.tokensIn = 0;
900
+ this.tokensOut = 0;
901
+ this.sessionCost = 0;
902
+ this.todos = [];
903
+ this.compactCount = 0;
904
+ this.lastCompact = null;
905
+ this.conversationId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
906
+ this.speedStats = null;
907
+ }
908
+ }
909
+
910
+ module.exports = { Session, MEMORY_TEMPLATE, isQuotaError, isAbortError };