@mafdet/cli 0.4.4 → 0.4.6

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 (48) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +4 -6
  3. package/README.zh-CN.md +4 -3
  4. package/dist/agent/command-policy.d.ts.map +1 -1
  5. package/dist/agent/command-policy.js +19 -6
  6. package/dist/agent/context-budget.d.ts +19 -1
  7. package/dist/agent/context-budget.d.ts.map +1 -1
  8. package/dist/agent/context-budget.js +87 -12
  9. package/dist/agent/loop.d.ts +16 -1
  10. package/dist/agent/loop.d.ts.map +1 -1
  11. package/dist/agent/loop.js +34 -2
  12. package/dist/agent/session.d.ts +14 -0
  13. package/dist/agent/session.d.ts.map +1 -1
  14. package/dist/agent/session.js +15 -4
  15. package/dist/api/models.d.ts.map +1 -1
  16. package/dist/api/models.js +28 -2
  17. package/dist/api/sse-parser.d.ts +15 -0
  18. package/dist/api/sse-parser.d.ts.map +1 -1
  19. package/dist/api/sse-parser.js +39 -3
  20. package/dist/commands/agent.d.ts.map +1 -1
  21. package/dist/commands/agent.js +88 -30
  22. package/dist/commands/sessions.d.ts.map +1 -1
  23. package/dist/commands/sessions.js +45 -10
  24. package/dist/output/money.d.ts +9 -0
  25. package/dist/output/money.d.ts.map +1 -1
  26. package/dist/output/money.js +14 -0
  27. package/dist/sessions/index-file.d.ts +2 -0
  28. package/dist/sessions/index-file.d.ts.map +1 -1
  29. package/dist/sessions/index-file.js +3 -0
  30. package/dist/sessions/open.d.ts.map +1 -1
  31. package/dist/sessions/open.js +3 -1
  32. package/dist/sessions/resume.d.ts +1 -0
  33. package/dist/sessions/resume.d.ts.map +1 -1
  34. package/dist/sessions/resume.js +5 -3
  35. package/dist/tui/agent-surface.d.ts.map +1 -1
  36. package/dist/tui/agent-surface.js +18 -12
  37. package/dist/tui/app.d.ts +14 -0
  38. package/dist/tui/app.d.ts.map +1 -1
  39. package/dist/tui/app.js +45 -11
  40. package/dist/tui/picker.d.ts.map +1 -1
  41. package/dist/tui/picker.js +2 -1
  42. package/dist/tui/session-centre.d.ts +5 -0
  43. package/dist/tui/session-centre.d.ts.map +1 -1
  44. package/dist/tui/session-centre.js +14 -4
  45. package/dist/tui/start-agent.d.ts +28 -0
  46. package/dist/tui/start-agent.d.ts.map +1 -1
  47. package/dist/tui/start-agent.js +39 -9
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,87 @@ The version is the contract for the JSON output shapes and the exit codes.
6
6
  Being 0.x, semver still permits a break in a minor bump; the intent is not to
7
7
  use that latitude, and the promise becomes binding at 1.0.0.
8
8
 
9
+ ## 0.4.6 - 2026-08-12
10
+
11
+ A third audit round. Two of its findings were things the previous round's
12
+ notes recorded as fixed, and the pattern is worth naming: a partial fix logged
13
+ as complete is not looked at again.
14
+
15
+ **An interactive `mafdet agent` now records your questions.** It saved the
16
+ model's replies, the tool runs and the usage, and none of the prompts - so
17
+ resuming such a session could not continue a conversation that was only half
18
+ there. The TUI wrapped its own reader to record them and the command surface
19
+ did not; recording now happens in the session loop, where neither can forget.
20
+
21
+ **`--max-cost-usd` refuses before spending, not after.** When the catalogue
22
+ publishes no price for a model, the run used to make one billable request and
23
+ discover the problem from the response. Whether a price exists is known before
24
+ anything is sent.
25
+
26
+ **A run nobody could price no longer reports `$0.00`.** The command summary,
27
+ the TUI summary, `/cost` and the JSON report all showed zero for a run whose
28
+ model had no published price; they now say how many requests the figure does
29
+ not cover, as `sessions usage` already did.
30
+
31
+ **Context compaction accounts for what cannot shrink.** The estimate assumed
32
+ tokens fall in proportion to messages, but the system prompt and tool schemas
33
+ are re-sent every turn - so it could decide a history had come inside the
34
+ window when only the removable part had.
35
+
36
+ **Model catalogue text is sanitised where it enters.** Terminal escape
37
+ sequences were being stripped sink by sink, and the model picker was still
38
+ missing after two rounds; cleaning the catalogue on arrival leaves nothing
39
+ downstream to forget. Fixed with it: stripping a sequence no longer eats the
40
+ text that follows it, which an 8-bit OSC terminated by BEL used to do.
41
+
42
+ Also: the stream's total deadline actually bounds the wait instead of allowing
43
+ one more idle period; compaction reports how many turns it dropped rather than
44
+ always one; the session picker and the per-model and per-key JSON carry the
45
+ unpriced count.
46
+
47
+ ## 0.4.5 - 2026-08-12
48
+
49
+ A second audit round, and the security fix in it is the important one.
50
+
51
+ **With no tool-calling model, the interactive session no longer opens.** It
52
+ used to print a warning and then offer every chat model - so the one case with
53
+ nothing safe to offer was the case that offered everything, and the agent then
54
+ ran on a model whose tool calling had never been verified. `mafdet agent`
55
+ refused the same model outright, so the two surfaces disagreed about
56
+ admission. They no longer do.
57
+
58
+ **A cost ceiling that cannot be enforced stops the run.** When the catalogue
59
+ has no price for a model, each turn counted as zero towards `--max-cost-usd`,
60
+ so the ceiling quietly stopped being one while the run kept spending. Asking
61
+ for a limit we cannot apply now ends the run and says why.
62
+
63
+ **Context compaction keeps going until the history fits.** It used to drop a
64
+ single tool exchange and send regardless, which changes nothing when the
65
+ history is several times over the window - the request then failed upstream
66
+ and was billed for the attempt.
67
+
68
+ **A stream is closed when the answer ends.** `[DONE]` means the reply is
69
+ complete, not that the socket is; an upstream that kept the connection open
70
+ held ours too. There is also a total deadline now, because a trickle of bytes
71
+ defeats an idle one.
72
+
73
+ **More of what reaches the terminal is sanitised**: the answer from `mafdet
74
+ agent`, the interactive session's answer, the session picker, the resume
75
+ notice and the remaining `sessions` columns. The stripper also handles the
76
+ 8-bit C1 forms, which do the same things without an escape character.
77
+
78
+ **An unpriced request is no longer displayed as `$0.00`** in `sessions list`,
79
+ `show` or `usage`, and the JSON carries `unpriced_requests` so a script can
80
+ tell the difference too.
81
+
82
+ Also: `sessions list` refuses stray arguments like the other subcommands;
83
+ `mafdet agent` documents `--title`, `--no-history` and `--read-only`, shows
84
+ the first-save notice, and closes its recorder even when the session throws;
85
+ resuming no longer asks you to pick a model it is going to ignore, and the
86
+ banner names the model actually in use; and the Windows command-timeout note
87
+ now says the `taskkill` path is written but unverified, rather than describing
88
+ the implementation it replaced.
89
+
9
90
  ## 0.4.4 - 2026-08-11
10
91
 
11
92
  A security fix and twenty-one other corrections, all from an external audit of
package/README.md CHANGED
@@ -3,13 +3,11 @@
3
3
  The official Mafdet AI command line: model calls, account queries, and a
4
4
  permissioned local agent.
5
5
 
6
- > **0.4.4.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
6
+ > **0.4.6.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
7
7
  > advertises a command it cannot serve, so `mafdet help` always lists exactly
8
8
  > what works.
9
9
  >
10
- > macOS and Windows are not yet verified. On Windows the command timeout
11
- > relies on POSIX process groups, so only the direct child is terminated and a
12
- > grandchild can survive.
10
+ > macOS and Windows are not yet verified. On Windows the command timeout uses `taskkill /T`, which is written but has never been run on a real Windows machine - so whether every grandchild process is reached there is unverified rather than fixed.
13
11
 
14
12
  ## Requirements
15
13
 
@@ -25,8 +23,8 @@ Or build it from this repository:
25
23
 
26
24
  ```bash
27
25
  pnpm --filter @mafdet/cli build
28
- cd packages/cli && npm pack # -> mafdet-cli-0.4.4.tgz
29
- npm install -g ./mafdet-cli-0.4.4.tgz
26
+ cd packages/cli && npm pack # -> mafdet-cli-0.4.6.tgz
27
+ npm install -g ./mafdet-cli-0.4.6.tgz
30
28
  ```
31
29
 
32
30
  ## The interactive session
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mafdet AI 官方命令行:模型调用、账户查询,以及一个受权限约束的本地 Agent。
4
4
 
5
- > **0.4.4。** 英文版见 [README.md](./README.md)。
5
+ > **0.4.6。** 英文版见 [README.md](./README.md)。
6
6
 
7
7
  ## 环境要求
8
8
 
@@ -55,8 +55,9 @@ mafdet doctor
55
55
  的默认状态,中文系统为 cp936)也不会变成乱码。管道传入 prompt 也可以,但 PowerShell
56
56
  默认按 UTF-16 走管道——建议直接把 prompt 作为参数,或用 `--input <文件>`。
57
57
 
58
- ⚠️ **macOS 与 Windows 尚未验收**。Windows 上有一个已知差异:命令超时终止依赖 POSIX 的
59
- 进程组语义,Windows 只会终止直接子进程,孙子进程可能存活。
58
+ ⚠️ **macOS 与 Windows 尚未验收**。Windows 上的命令超时终止已改用 `taskkill /T`,
59
+ 但**从未在真 Windows 上执行过**——它是否真的能杀到每一个孙子进程,属于**未验证**,
60
+ 不是「已修复」。
60
61
 
61
62
  ## 命令
62
63
 
@@ -1 +1 @@
1
- {"version":3,"file":"command-policy.d.ts","sourceRoot":"","sources":["../../src/agent/command-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAeH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BlD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,SAAS,MAAM,EAAO,GAChC,cAAc,CAwChB;AAmBD,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAe9C"}
1
+ {"version":3,"file":"command-policy.d.ts","sourceRoot":"","sources":["../../src/agent/command-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAeH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BlD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,SAAS,MAAM,EAAO,GAChC,cAAc,CAwChB;AAmBD,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA4B9C"}
@@ -119,15 +119,28 @@ function matchesAllowlist(argv, allowlist) {
119
119
  /** Terminal control sequences, stripped before any output is shown or sent. */
120
120
  export function stripAnsi(text) {
121
121
  return (text
122
- // CSI: ESC [ ... final byte - colours, cursor moves, screen clears.
122
+ // Whole sequences first, in both their 7-bit and 8-bit forms, and only
123
+ // then the leftover control characters.
124
+ //
125
+ // The order is load-bearing and was wrong once: removing C0 controls
126
+ // before parsing the 8-bit OSC took away the BEL that terminates it, so
127
+ // the pattern ran to the end of the string and swallowed the ordinary
128
+ // text after the sequence. Failing safe, but destroying output.
129
+ //
130
+ // CSI: colours, cursor moves, screen clears.
123
131
  .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
124
- // OSC: ESC ] ... BEL (or ST). The payload has to go too - stripping only
125
- // the introducer would leave "0;retitled" sitting in the output.
126
- .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
127
- // Other single-character escapes, including OSC introducers.
132
+ .replace(/\u009b[0-?]*[ -/]*[@-~]/g, "")
133
+ // OSC: window titles and the like. The payload goes with the
134
+ // introducer - stripping only the introducer would leave "0;retitled"
135
+ // sitting in the output. Terminated by BEL or by ST in either form.
136
+ .replace(/\u001b\][^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g, "")
137
+ .replace(/\u009d[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g, "")
138
+ // Other single-character escapes.
128
139
  .replace(/\u001b[@-Z\\-_]/g, "")
129
140
  // Remaining C0 controls. Tab, newline and carriage return survive; the
130
141
  // rest can retitle a window, move the cursor or hide what follows, and a
131
142
  // model's output is not a trusted source of terminal instructions.
132
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""));
143
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
144
+ // Remaining C1, including NEL (U+0085) and any lone introducer.
145
+ .replace(/[\u0080-\u009f]/g, ""));
133
146
  }
@@ -47,7 +47,10 @@ export declare function pruneToolExchanges(history: ChatMessage[], keepRecent?:
47
47
  * unanswered: CLI-014's rule holds by construction. The system prompt and the
48
48
  * first user message - the goal - always stay.
49
49
  */
50
- export declare function dropOldestTurns(history: ChatMessage[], keepTurns: number): number;
50
+ export declare function dropOldestTurns(history: ChatMessage[], keepTurns: number): {
51
+ removed: number;
52
+ turns: number;
53
+ };
51
54
  export interface CompactionResult extends PruneResult {
52
55
  /** Whole turns dropped, when exchanges were not enough. */
53
56
  readonly turns: number;
@@ -66,4 +69,19 @@ export interface CompactionResult extends PruneResult {
66
69
  * upstream and be billed for the attempt.
67
70
  */
68
71
  export declare function compactForBudget(history: ChatMessage[], keepRecent?: number): CompactionResult;
72
+ /**
73
+ * R-2 · Shorten until it fits, or until nothing more can go.
74
+ *
75
+ * `compactForBudget` used to return the moment it dropped a single tool
76
+ * exchange, and the caller then sent the request regardless. One exchange out
77
+ * of a history three times over the window changes nothing, so the request
78
+ * still failed upstream - and was billed for the attempt.
79
+ *
80
+ * `estimate` maps the current history back to a token count. There is no
81
+ * tokenizer here (see the note at the top of this file), so it is derived from
82
+ * the last measurement: tokens per message, applied to what is left. Rough, but
83
+ * it is the only honest figure available and it is monotone in the right
84
+ * direction, which is what a loop needs.
85
+ */
86
+ export declare function compactUntilItFits(history: ChatMessage[], contextLength: number | undefined, lastPromptTokens: number, keepRecent?: number): CompactionResult;
69
87
  //# sourceMappingURL=context-budget.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,MAAM,GAChB,MAAM,CAuBR;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CAYlB"}
1
+ {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,MAAM,GAChB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA0BpC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CASlB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,EACxB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CAoClB"}
@@ -125,10 +125,10 @@ export function dropOldestTurns(history, keepTurns) {
125
125
  // The first user message is the goal and is not a droppable turn, so there
126
126
  // have to be more than `keepTurns` turns after it before anything can go.
127
127
  if (userAt.length <= keepTurns + 1)
128
- return 0;
128
+ return { removed: 0, turns: 0 };
129
129
  const cut = userAt[userAt.length - keepTurns];
130
130
  if (cut === undefined)
131
- return 0;
131
+ return { removed: 0, turns: 0 };
132
132
  const kept = [];
133
133
  if (history[0]?.role === "system")
134
134
  kept.push(history[0]);
@@ -138,10 +138,13 @@ export function dropOldestTurns(history, keepTurns) {
138
138
  kept.push(...history.slice(cut));
139
139
  const removed = history.length - kept.length;
140
140
  if (removed <= 0)
141
- return 0;
141
+ return { removed: 0, turns: 0 };
142
142
  history.length = 0;
143
143
  history.push(...kept);
144
- return removed;
144
+ // T-7 · The count of *turns*, not of messages. Reporting a fixed 1 made the
145
+ // display say "dropped 1 earlier turn" after removing eight of them.
146
+ const turns = userAt.filter((at) => at < cut && at !== userAt[0]).length;
147
+ return { removed, turns };
145
148
  }
146
149
  /**
147
150
  * Shorten the history as much as can be done safely.
@@ -156,14 +159,86 @@ export function dropOldestTurns(history, keepTurns) {
156
159
  */
157
160
  export function compactForBudget(history, keepRecent = KEEP_RECENT_EXCHANGES) {
158
161
  const pruned = pruneToolExchanges(history, keepRecent);
159
- if (pruned.exchanges > 0) {
160
- return { ...pruned, turns: 0, exhausted: false };
161
- }
162
- const turnsRemoved = dropOldestTurns(history, keepRecent);
162
+ const dropped = dropOldestTurns(history, keepRecent);
163
163
  return {
164
- removed: turnsRemoved,
165
- exchanges: 0,
166
- turns: turnsRemoved > 0 ? 1 : 0,
167
- exhausted: turnsRemoved === 0,
164
+ removed: pruned.removed + dropped.removed,
165
+ exchanges: pruned.exchanges,
166
+ turns: dropped.turns,
167
+ exhausted: pruned.exchanges === 0 && dropped.removed === 0,
168
168
  };
169
169
  }
170
+ /**
171
+ * R-2 · Shorten until it fits, or until nothing more can go.
172
+ *
173
+ * `compactForBudget` used to return the moment it dropped a single tool
174
+ * exchange, and the caller then sent the request regardless. One exchange out
175
+ * of a history three times over the window changes nothing, so the request
176
+ * still failed upstream - and was billed for the attempt.
177
+ *
178
+ * `estimate` maps the current history back to a token count. There is no
179
+ * tokenizer here (see the note at the top of this file), so it is derived from
180
+ * the last measurement: tokens per message, applied to what is left. Rough, but
181
+ * it is the only honest figure available and it is monotone in the right
182
+ * direction, which is what a loop needs.
183
+ */
184
+ export function compactUntilItFits(history, contextLength, lastPromptTokens, keepRecent = KEEP_RECENT_EXCHANGES) {
185
+ const startedWith = history.length;
186
+ const floor = fixedOverhead(history, lastPromptTokens);
187
+ const perMessage = startedWith > 0 ? Math.max(0, lastPromptTokens - floor) / startedWith : 0;
188
+ let exchanges = 0;
189
+ let turns = 0;
190
+ for (;;) {
191
+ // T-2 · The floor does not shrink. The system prompt and the tool schemas
192
+ // are re-sent every turn whatever the history looks like, so treating the
193
+ // whole measurement as proportional to the message count said a
194
+ // conversation had come inside the window when only the removable part
195
+ // had. Everything below the floor is not ours to drop.
196
+ const estimate = floor + Math.round(perMessage * history.length);
197
+ if (!shouldCompact(contextLength, estimate)) {
198
+ return {
199
+ removed: startedWith - history.length,
200
+ exchanges,
201
+ turns,
202
+ exhausted: false,
203
+ };
204
+ }
205
+ const step = compactForBudget(history, keepRecent);
206
+ if (step.exhausted) {
207
+ return {
208
+ removed: startedWith - history.length,
209
+ exchanges,
210
+ turns,
211
+ // Still over budget with nothing left that is safe to drop.
212
+ exhausted: true,
213
+ };
214
+ }
215
+ exchanges += step.exchanges;
216
+ turns += step.turns;
217
+ }
218
+ }
219
+ /**
220
+ * T-2 · What the next request costs before any history is added.
221
+ *
222
+ * The system prompt is measurable; the tool schemas are not from here - the
223
+ * loop knows them but this module does not, and CLI-000B found the gateway's
224
+ * own reservation undercounts badly for exactly that reason. So it is
225
+ * approximated from the one message we can size, with a floor that keeps the
226
+ * estimate conservative: over-estimating the fixed part makes compaction try
227
+ * harder, which is the direction that fails safe.
228
+ */
229
+ function fixedOverhead(history, lastPromptTokens) {
230
+ const system = history.find((m) => m.role === "system");
231
+ if (system === undefined || lastPromptTokens <= 0)
232
+ return 0;
233
+ const chars = (system.content ?? "").length;
234
+ // Roughly four characters per token, doubled to stand in for the tool
235
+ // schemas that are sent alongside it and cannot be measured here.
236
+ const approx = Math.round((chars / 4) * 2);
237
+ // Bounded by the measurement, because the fixed part cannot exceed the whole
238
+ // request - but NOT halved. Capping it lower was the unsafe direction: it
239
+ // let the loop conclude that dropping a few messages had brought a history
240
+ // inside a window the system prompt alone did not fit in. Over-estimating
241
+ // makes compaction try harder and, at worst, stop with an honest message;
242
+ // under-estimating sends a request that fails upstream and is billed.
243
+ return Math.min(approx, lastPromptTokens);
244
+ }
@@ -14,7 +14,13 @@ export declare const DEFAULT_LIMITS: {
14
14
  export type AgentLimits = typeof DEFAULT_LIMITS;
15
15
  export type StopReason = "completed" | "max_turns" | "timeout" | "max_cost" | "cancelled" | "rate_limited"
16
16
  /** F-008 · Over the model's window with nothing left that is safe to drop. */
17
- | "context_limit" | "error";
17
+ | "context_limit"
18
+ /**
19
+ * R-5 · A cost ceiling was asked for and cannot be honoured, because the
20
+ * catalogue has no price for this model. Distinct from `max_cost`, which
21
+ * means the ceiling was reached.
22
+ */
23
+ | "cost_unknown" | "error";
18
24
  export interface AgentReport {
19
25
  stopReason: StopReason;
20
26
  /** The model's last text answer, if it produced one. */
@@ -30,6 +36,13 @@ export interface AgentReport {
30
36
  promptTokens: number;
31
37
  completionTokens: number;
32
38
  costMicroUsd: bigint;
39
+ /**
40
+ * T-4 · Requests the cost above does not include, because the catalogue had
41
+ * no price for them. Without it every live summary shows `$0.00` for a run
42
+ * that was not free - the session store learned to say "unknown" and the
43
+ * surfaces the user actually watches did not.
44
+ */
45
+ unpricedRequests: number;
33
46
  /** Set when the run ended because something failed. */
34
47
  error?: string;
35
48
  }
@@ -122,6 +135,8 @@ export type AgentEvent = {
122
135
  | {
123
136
  type: "compacted";
124
137
  exchanges: number;
138
+ /** Whole conversation turns dropped, when exchanges were not enough. */
139
+ turns: number;
125
140
  messages: number;
126
141
  promptTokens: number;
127
142
  };
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,cAAc,EACpB,MAAM,kBAAkB,CAAC;AAE1B,iBAAiB;AACjB,eAAO,MAAM,cAAc;;;IAGzB,iFAAiF;;;qBAGnD,MAAM,GAAG,SAAS;CACjD,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,OAAO,cAAc,CAAC;AAEhD,MAAM,MAAM,UAAU,GAClB,WAAW,GACX,WAAW,GACX,SAAS,GACT,UAAU,GACV,WAAW,GACX,cAAc;AAChB,8EAA8E;GAC5E,eAAe;AACjB;;;;GAIG;GACD,cAAc,GACd,OAAO,CAAC;AAEZ,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,UAAU,CAAC;IACvB,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,kEAAkE;IAClE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC,uDAAuD;IACvD,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAChC;;;;;GAKG;GACD;IACE,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,MAAM,EAAE,OAAO,CAAC;CACjB,GACD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAC3D;;;;GAIG;GACD;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEN,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;IAC/C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,CAAC,EAAE,QAAQ,EAAE,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,QAAQ;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/C;AAYD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAkN1E;AAqID,+EAA+E;AAC/E,wBAAgB,SAAS,CACvB,iBAAiB,EAAE,MAAM,GAAG,SAAS,EACrC,OAAO,EAAE,MAAM,EACf,MAAM,GAAE,MAAM,MAAoB,GACjC,MAAM,CAOR"}
@@ -1,5 +1,5 @@
1
1
  import { ApiError } from "../api/errors.js";
2
- import { compactForBudget, shouldCompact } from "./context-budget.js";
2
+ import { compactUntilItFits, shouldCompact } from "./context-budget.js";
3
3
  import { toolToJsonSchema, } from "./tools/types.js";
4
4
  /** Plan §7.4. */
5
5
  export const DEFAULT_LIMITS = {
@@ -44,9 +44,22 @@ export async function runAgent(options) {
44
44
  promptTokens: 0,
45
45
  completionTokens: 0,
46
46
  costMicroUsd: 0n,
47
+ unpricedRequests: 0,
47
48
  };
48
49
  /** What the provider counted for the previous request; 0 until one lands. */
49
50
  let lastPromptTokens = 0;
51
+ /** R-5 · Turns whose cost the catalogue could not tell us. */
52
+ let unpricedTurns = 0;
53
+ // T-3 · Known before anything is sent. Waiting for the first response to
54
+ // discover it meant a run with an explicit ceiling still made one billable
55
+ // request that could never be counted against it.
56
+ if (limits.maxCostMicroUsd !== undefined &&
57
+ (!options.pricing?.inputPricePer1mMicroUsd ||
58
+ !options.pricing.outputPricePer1mMicroUsd)) {
59
+ return stop(report, "cost_unknown", `--max-cost-usd cannot be enforced: the catalogue publishes no price for ` +
60
+ `${options.model}, so nothing can be counted towards the ceiling. ` +
61
+ `Re-run without the limit to continue anyway.`);
62
+ }
50
63
  for (let turn = 1; turn <= limits.maxTurns; turn += 1) {
51
64
  if (options.signal?.aborted)
52
65
  return stop(report, "cancelled");
@@ -56,11 +69,23 @@ export async function runAgent(options) {
56
69
  report.costMicroUsd >= limits.maxCostMicroUsd) {
57
70
  return stop(report, "max_cost");
58
71
  }
72
+ // R-5 · An unpriceable turn counted as 0 towards the ceiling, so
73
+ // `--max-cost-usd` silently stopped being a ceiling at all and the run
74
+ // continued spending real money. A limit that cannot be enforced has to
75
+ // stop the run, not be quietly abandoned.
76
+ if (limits.maxCostMicroUsd !== undefined && unpricedTurns > 0) {
77
+ return stop(report, "cost_unknown", `--max-cost-usd cannot be enforced: the catalogue has no price for ` +
78
+ `${options.model}, so ${unpricedTurns} request(s) could not be counted ` +
79
+ `towards the ceiling. Re-run without the limit to continue anyway.`);
80
+ }
59
81
  report.turns = turn;
60
82
  options.onEvent?.({ type: "turn", turn });
61
83
  // §7.4 · Shorten before asking, using what the provider counted last time.
62
84
  if (shouldCompact(options.pricing?.contextLength, lastPromptTokens)) {
63
- const compacted = compactForBudget(messages);
85
+ // R-2 · Keep going until the estimate is inside the budget. Dropping one
86
+ // exchange and sending anyway left a history that was still over the
87
+ // window: the request failed upstream and was billed for the attempt.
88
+ const compacted = compactUntilItFits(messages, options.pricing?.contextLength, lastPromptTokens);
64
89
  if (compacted.exhausted) {
65
90
  // F-008 · Nothing left that can be dropped without breaking the
66
91
  // conversation. Sending anyway means a request over the window, which
@@ -72,6 +97,9 @@ export async function runAgent(options) {
72
97
  options.onEvent?.({
73
98
  type: "compacted",
74
99
  exchanges: compacted.exchanges,
100
+ // R-2 · Reported separately: a pure conversation drops turns and no
101
+ // exchanges, and the display said "dropped 0 earlier tool exchanges".
102
+ turns: compacted.turns,
75
103
  messages: compacted.removed,
76
104
  promptTokens: lastPromptTokens,
77
105
  });
@@ -116,6 +144,10 @@ export async function runAgent(options) {
116
144
  costMicroUsd: turnCost,
117
145
  priced: canPrice(options.pricing, usage),
118
146
  });
147
+ if (!canPrice(options.pricing, usage)) {
148
+ unpricedTurns += 1;
149
+ report.unpricedRequests += 1;
150
+ }
119
151
  const choice = response.choices?.[0];
120
152
  const message = choice?.message;
121
153
  const calls = message?.tool_calls ?? [];
@@ -32,6 +32,18 @@ export interface SessionOptions {
32
32
  * the same events are drawn.
33
33
  */
34
34
  onEvent?: (event: AgentEvent) => void;
35
+ /**
36
+ * T-1 · The user's own words, which never appear in the agent event stream.
37
+ *
38
+ * Called from the loop rather than from each surface's `readLine`, because
39
+ * that is exactly how it came to be missing: the TUI wrapped its reader and
40
+ * `mafdet agent` did not, so a command-mode session recorded the model's
41
+ * replies and none of the questions - and resuming it could not continue the
42
+ * conversation, because half of it was not there.
43
+ */
44
+ onGoal?: (goal: string) => void;
45
+ /** R-4 · False when stdout is a pipe or a file, which get exact bytes. */
46
+ isTty?: boolean;
35
47
  /**
36
48
  * A conversation to continue, rebuilt from a saved session (CLI-024).
37
49
  * Appended to in place, exactly as the fresh array would be.
@@ -46,6 +58,8 @@ export interface SessionSummary {
46
58
  promptTokens: number;
47
59
  completionTokens: number;
48
60
  costMicroUsd: bigint;
61
+ /** T-4 · Requests the cost above does not include; see AgentReport. */
62
+ unpricedRequests: number;
49
63
  }
50
64
  /**
51
65
  * The interactive session (plan §4.4).
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/agent/session.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAY,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAapE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,oEAAoE;IACpE,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,cAAc,CAAC,CA8FzB;AAgHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/agent/session.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,OAAO,EAAY,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAapE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,oEAAoE;IACpE,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,0EAA0E;IAC1E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,cAAc,CAAC,CAwGzB;AAsHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}
@@ -1,7 +1,8 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { isAgentEligible } from "../api/models.js";
3
- import { formatUsd } from "../output/money.js";
3
+ import { formatUsdWithUnpriced } from "../output/money.js";
4
4
  import { renderRecord } from "../output/format.js";
5
+ import { safeForTerminal } from "../output/terminal-safe.js";
5
6
  import { runAgent } from "./loop.js";
6
7
  const HELP = `Session commands:
7
8
  /model [id] Show or switch the model
@@ -31,6 +32,7 @@ export async function runSession(options) {
31
32
  promptTokens: 0,
32
33
  completionTokens: 0,
33
34
  costMicroUsd: 0n,
35
+ unpricedRequests: 0,
34
36
  };
35
37
  let model = options.model;
36
38
  let pricing = options.pricing;
@@ -68,6 +70,7 @@ export async function runSession(options) {
68
70
  break;
69
71
  continue;
70
72
  }
73
+ options.onGoal?.(line);
71
74
  summary.goals += 1;
72
75
  const report = await runAgent({
73
76
  client: options.client,
@@ -94,8 +97,13 @@ export async function runSession(options) {
94
97
  summary.promptTokens += report.promptTokens;
95
98
  summary.completionTokens += report.completionTokens;
96
99
  summary.costMicroUsd += report.costMicroUsd;
97
- if (report.finalText)
98
- options.stdout(`${report.finalText}\n`);
100
+ summary.unpricedRequests += report.unpricedRequests;
101
+ // R-4 · The model's answer reaches a terminal here too. The TUI wraps
102
+ // this writer, but `mafdet agent` without a goal does not, so the
103
+ // sanitising belongs at the sink rather than at one of its callers.
104
+ if (report.finalText) {
105
+ options.stdout(`${safeForTerminal(report.finalText, options.isTty ?? true)}\n`);
106
+ }
99
107
  if (report.stopReason !== "completed") {
100
108
  options.stderr(`[${report.stopReason}${report.error ? `: ${report.error}` : ""}]\n`);
101
109
  }
@@ -175,7 +183,10 @@ async function handleCommand(line, context) {
175
183
  "tokens",
176
184
  `${context.summary.promptTokens} in / ${context.summary.completionTokens} out`,
177
185
  ],
178
- ["cost", `~${formatUsd(context.summary.costMicroUsd)}`],
186
+ [
187
+ "cost",
188
+ `~${formatUsdWithUnpriced(context.summary.costMicroUsd, context.summary.unpricedRequests)}`,
189
+ ],
179
190
  ]));
180
191
  return false;
181
192
  case "/compact": {
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/api/models.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,mDAAmD;IACnD,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAQxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,SAAS,CAuBX;AAED,8CAA8C;AAC9C,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAG3B;AAED,2EAA2E;AAC3E,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,YAAY,GAAG,SAAS,CAY1B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,OAAO,EAAE,MAAM;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBAFhB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAO5B"}
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/api/models.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,mDAAmD;IACnD,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAQxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,SAAS,CAuBX;AAED,8CAA8C;AAC9C,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAQ3B;AAED,2EAA2E;AAC3E,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AA0BD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,YAAY,GAAG,SAAS,CAY1B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,OAAO,EAAE,MAAM;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBAFhB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAO5B"}
@@ -1,3 +1,4 @@
1
+ import { stripAnsi } from "../agent/command-policy.js";
1
2
  /**
2
3
  * Agent admission. Every condition is something the catalogue states outright;
3
4
  * nothing is inferred from the model id, the provider or the display name —
@@ -51,12 +52,37 @@ export function intersectModels(dataPlane, catalog) {
51
52
  /** GET /v1/models — the key's access list. */
52
53
  export async function fetchAccessibleModels(client) {
53
54
  const { data } = await client.getJson("/models");
54
- return data.data ?? [];
55
+ // T-5 · Same reason as `cleanModel`: these ids are printed as the "unlisted"
56
+ // set, so they reach a terminal without ever passing through the catalogue.
57
+ return (data.data ?? []).map((model) => ({
58
+ ...model,
59
+ id: typeof model.id === "string" ? stripAnsi(model.id) : model.id,
60
+ }));
55
61
  }
56
62
  /** GET /models on the business API — public catalogue, no key required. */
57
63
  export async function fetchCatalog(client) {
58
64
  const { data } = await client.getJson("/models");
59
- return data.data ?? [];
65
+ return (data.data ?? []).map(cleanModel);
66
+ }
67
+ /**
68
+ * T-5 · Strip terminal control sequences from catalogue text, once, here.
69
+ *
70
+ * The catalogue is remote data that ends up on a terminal in the picker, in
71
+ * `models list`, in `models show`, in `doctor` and in error messages. F-010
72
+ * was fixed sink by sink across two review rounds and the picker was still
73
+ * missing, which is the argument for doing it where the data enters instead:
74
+ * every renderer downstream, present and future, then has nothing to forget.
75
+ *
76
+ * The model id is cleaned too. It is sent back to the API, so in principle
77
+ * this changes what we would ask for - but an id carrying a control character
78
+ * is not a model id, and declining to echo one back is right either way.
79
+ */
80
+ function cleanModel(model) {
81
+ const clean = (value) => typeof value === "string" ? stripAnsi(value) : value;
82
+ return Object.fromEntries(Object.entries(model).map(([key, value]) => [
83
+ key,
84
+ Array.isArray(value) ? value.map(clean) : clean(value),
85
+ ]));
60
86
  }
61
87
  /**
62
88
  * Check the model serves the endpoint the command needs, before spending a