@mafdet/cli 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +20 -14
- package/README.zh-CN.md +19 -14
- package/dist/agent/context-budget.d.ts +37 -0
- package/dist/agent/context-budget.d.ts.map +1 -0
- package/dist/agent/context-budget.js +105 -0
- package/dist/agent/loop.d.ts +11 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +20 -0
- package/dist/agent/tools/run-command.d.ts +28 -0
- package/dist/agent/tools/run-command.d.ts.map +1 -1
- package/dist/agent/tools/run-command.js +34 -13
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +118 -18
- package/dist/tui/agent-surface.d.ts.map +1 -1
- package/dist/tui/agent-surface.js +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ 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.3 - 2026-08-11
|
|
10
|
+
|
|
11
|
+
A long agent run no longer walks into the model's context window, a one-shot
|
|
12
|
+
chat can be saved, and the Windows timeout kill now walks the process tree.
|
|
13
|
+
|
|
14
|
+
**Automatic compaction.** When a run gets close to the model's context window,
|
|
15
|
+
the oldest tool exchanges are dropped from what is sent and you are told it
|
|
16
|
+
happened. The size is measured rather than estimated: every response reports
|
|
17
|
+
how many input tokens it actually cost, which is the provider's own count of
|
|
18
|
+
exactly the conversation we sent. What gets dropped is always a whole exchange
|
|
19
|
+
|
|
20
|
+
- an assistant tool call and the results answering it are inseparable - and
|
|
21
|
+
the system prompt and your original goal are never touched. A model whose
|
|
22
|
+
context length the catalogue does not publish is left alone, because guessing
|
|
23
|
+
a window would either truncate a conversation that fit or fail to truncate one
|
|
24
|
+
that did not. Your saved transcript is unaffected; only what is sent gets
|
|
25
|
+
shorter.
|
|
26
|
+
|
|
27
|
+
**`mafdet chat "..." --save`** records a one-shot chat, through exactly the
|
|
28
|
+
same store as an interactive session - the same redaction, the same private
|
|
29
|
+
files, the same `mafdet sessions delete`. Chats are still not saved by
|
|
30
|
+
default, `--no-history` still wins over `--save`, and a chat you cancel is not
|
|
31
|
+
saved at all.
|
|
32
|
+
|
|
33
|
+
**Windows command timeouts** now use `taskkill /T`, then `taskkill /T /F`,
|
|
34
|
+
where POSIX uses SIGTERM then SIGKILL on the process group. Note the standing
|
|
35
|
+
caveat: this path has not been run on a real Windows machine, so a surviving
|
|
36
|
+
grandchild process there remains a known limitation rather than a fixed bug.
|
|
37
|
+
|
|
9
38
|
## 0.4.2 - 2026-08-11
|
|
10
39
|
|
|
11
40
|
Conversations are now saved on this computer, and a session's cost can be
|
package/README.md
CHANGED
|
@@ -77,20 +77,20 @@ prompt as an argument, or use `--input <file>`.
|
|
|
77
77
|
|
|
78
78
|
## Commands
|
|
79
79
|
|
|
80
|
-
| Command
|
|
81
|
-
|
|
|
82
|
-
| `mafdet doctor`
|
|
83
|
-
| `mafdet models list [--surface chat\|embeddings\|speech] [--agent]`
|
|
84
|
-
| `mafdet models show <id>`
|
|
85
|
-
| `mafdet chat [prompt] [--model] [--system <file>] [--input <file>] [--no-stream] [--quiet]` | Send a prompt; streams by default |
|
|
86
|
-
| `mafdet agent "<goal>" [--allow-write] [--allow-command "<prefix>"]`
|
|
87
|
-
| `mafdet agent` (no goal, needs a terminal)
|
|
88
|
-
| `mafdet embed [text\|<file>] --model <id> [--output <file>]`
|
|
89
|
-
| `mafdet speech [text\|<file>] --model <id> --output <file> --voice <name>`
|
|
90
|
-
| `mafdet account balance\|usage\|subscription`
|
|
91
|
-
| `mafdet sessions list\|show\|usage\|delete`
|
|
92
|
-
| `mafdet sessions usage <id> --refresh`
|
|
93
|
-
| `mafdet config list`
|
|
80
|
+
| Command | What it does |
|
|
81
|
+
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
82
|
+
| `mafdet doctor` | One pass over key, endpoints, model, tool-calling, balance, workspace, git |
|
|
83
|
+
| `mafdet models list [--surface chat\|embeddings\|speech] [--agent]` | Models this key can call, joined with catalogue metadata |
|
|
84
|
+
| `mafdet models show <id>` | One model in detail, including whether the agent may drive it |
|
|
85
|
+
| `mafdet chat [prompt] [--model] [--system <file>] [--input <file>] [--no-stream] [--quiet] [--save]` | Send a prompt; streams by default |
|
|
86
|
+
| `mafdet agent "<goal>" [--allow-write] [--allow-command "<prefix>"]` | Local agent; read-only unless told otherwise |
|
|
87
|
+
| `mafdet agent` (no goal, needs a terminal) | Interactive session |
|
|
88
|
+
| `mafdet embed [text\|<file>] --model <id> [--output <file>]` | Text to vector |
|
|
89
|
+
| `mafdet speech [text\|<file>] --model <id> --output <file> --voice <name>` | Text to an audio file |
|
|
90
|
+
| `mafdet account balance\|usage\|subscription` | Wallet, spend and plan (needs the `account:read` scope) |
|
|
91
|
+
| `mafdet sessions list\|show\|usage\|delete` | Conversations saved on this computer (never calls a model) |
|
|
92
|
+
| `mafdet sessions usage <id> --refresh` | Replace the local estimate with the billing ledger's figure |
|
|
93
|
+
| `mafdet config list` | The resolved settings and where each came from |
|
|
94
94
|
|
|
95
95
|
`--json` on any query command emits one complete JSON document; `--plain`
|
|
96
96
|
emits tab-separated rows with no header, for `cut` and `awk`.
|
|
@@ -216,10 +216,16 @@ Turning it off and cleaning up:
|
|
|
216
216
|
|
|
217
217
|
```bash
|
|
218
218
|
mafdet --no-history # this run is not recorded at all
|
|
219
|
+
mafdet chat "..." --save # a one-shot chat is saved only when asked
|
|
219
220
|
mafdet sessions list # what is saved, and the path when nothing is
|
|
220
221
|
mafdet sessions delete <id> --yes
|
|
221
222
|
```
|
|
222
223
|
|
|
224
|
+
A single `mafdet chat` is not a conversation anyone resumes, so it is not
|
|
225
|
+
recorded unless `--save` is given. `--no-history` still wins over `--save`, and
|
|
226
|
+
a chat cancelled with Ctrl+C is not saved at all — half a reply is a worse
|
|
227
|
+
record than none.
|
|
228
|
+
|
|
223
229
|
`--no-history` writes nothing — not an empty directory, not an index entry.
|
|
224
230
|
|
|
225
231
|
Deleting removes the local messages and this session's usage mapping. It does
|
package/README.zh-CN.md
CHANGED
|
@@ -60,20 +60,20 @@ mafdet doctor
|
|
|
60
60
|
|
|
61
61
|
## 命令
|
|
62
62
|
|
|
63
|
-
| 命令
|
|
64
|
-
|
|
|
65
|
-
| `mafdet doctor`
|
|
66
|
-
| `mafdet models list [--surface chat\|embeddings\|speech] [--agent]`
|
|
67
|
-
| `mafdet models show <id>`
|
|
68
|
-
| `mafdet chat [提示词] [--model] [--system <文件>] [--input <文件>] [--no-stream] [--quiet]` | 发一次对话,默认流式 |
|
|
69
|
-
| `mafdet embed [文本\|<文件>] --model <id> [--output <文件>]`
|
|
70
|
-
| `mafdet speech [文本\|<文件>] --model <id> --output <文件> --voice <音色>`
|
|
71
|
-
| `mafdet agent "<目标>" [--allow-write] [--allow-command "<前缀>"]`
|
|
72
|
-
| `mafdet agent`(不带目标,需终端)
|
|
73
|
-
| `mafdet account balance\|usage\|subscription`
|
|
74
|
-
| `mafdet sessions list\|show\|usage\|delete`
|
|
75
|
-
| `mafdet sessions usage <id> --refresh`
|
|
76
|
-
| `mafdet config list`
|
|
63
|
+
| 命令 | 作用 |
|
|
64
|
+
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
65
|
+
| `mafdet doctor` | 一次检查 Node、两个端点、Key、工作区、git、模型与工具调用能力、余额 |
|
|
66
|
+
| `mafdet models list [--surface chat\|embeddings\|speech] [--agent]` | 这把 Key 能调用的模型,并附上目录元数据 |
|
|
67
|
+
| `mafdet models show <id>` | 单个模型详情,含 Agent 是否可以驱动它 |
|
|
68
|
+
| `mafdet chat [提示词] [--model] [--system <文件>] [--input <文件>] [--no-stream] [--quiet] [--save]` | 发一次对话,默认流式 |
|
|
69
|
+
| `mafdet embed [文本\|<文件>] --model <id> [--output <文件>]` | 文本转向量 |
|
|
70
|
+
| `mafdet speech [文本\|<文件>] --model <id> --output <文件> --voice <音色>` | 文本转音频 |
|
|
71
|
+
| `mafdet agent "<目标>" [--allow-write] [--allow-command "<前缀>"]` | 本地 Agent,默认只读 |
|
|
72
|
+
| `mafdet agent`(不带目标,需终端) | 交互会话 |
|
|
73
|
+
| `mafdet account balance\|usage\|subscription` | 钱包、用量、套餐(需 `account:read` scope) |
|
|
74
|
+
| `mafdet sessions list\|show\|usage\|delete` | 保存在这台电脑上的会话(不会调用模型) |
|
|
75
|
+
| `mafdet sessions usage <id> --refresh` | 用账单账本的权威金额替换本地估算 |
|
|
76
|
+
| `mafdet config list` | 当前生效的配置及其来源 |
|
|
77
77
|
|
|
78
78
|
查询类命令都支持 `--json`(输出一份完整 JSON 文档)与 `--plain`(无表头的 TSV,方便
|
|
79
79
|
`cut`、`awk` 处理)。
|
|
@@ -186,10 +186,15 @@ Linux 与 macOS 上文件权限 `0600`、目录 `0700`;**Windows 没有等价
|
|
|
186
186
|
|
|
187
187
|
```bash
|
|
188
188
|
mafdet --no-history # 这一次运行完全不记录
|
|
189
|
+
mafdet chat "..." --save # 一次性对话只有明确要求时才保存
|
|
189
190
|
mafdet sessions list # 已保存的会话;没有时会告诉你路径
|
|
190
191
|
mafdet sessions delete <id> --yes
|
|
191
192
|
```
|
|
192
193
|
|
|
194
|
+
一次 `mafdet chat` 不是有人会去恢复的对话,所以**不带 `--save` 就不保存**。
|
|
195
|
+
`--no-history` 仍然压过 `--save`;被 Ctrl+C 取消的对话**完全不保存**——
|
|
196
|
+
半个回复是比没有更糟的记录。
|
|
197
|
+
|
|
193
198
|
`--no-history` 什么都不留——不留空目录,也不留索引条目。
|
|
194
199
|
|
|
195
200
|
删除会移除本地消息和这个会话的用量映射。它**不会**移除 API 用量、账单记录或扣费:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ChatMessage } from "./loop.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fraction of the usable window at which we start shortening.
|
|
4
|
+
*
|
|
5
|
+
* Not 1.0, and the gap is not caution for its own sake: the request that
|
|
6
|
+
* measured `prompt_tokens` is smaller than the next one will be, because the
|
|
7
|
+
* assistant reply, its tool results and the next user message all land in
|
|
8
|
+
* between and none of them are counted yet.
|
|
9
|
+
*/
|
|
10
|
+
export declare const COMPACT_AT = 0.75;
|
|
11
|
+
/** Room left for the reply, which shares the window with the input. */
|
|
12
|
+
export declare function replyReserve(contextLength: number): number;
|
|
13
|
+
/** Tool exchanges kept whatever happens, so the recent work stays intact. */
|
|
14
|
+
export declare const KEEP_RECENT_EXCHANGES = 2;
|
|
15
|
+
/**
|
|
16
|
+
* Is the conversation close enough to the window to shorten it?
|
|
17
|
+
*
|
|
18
|
+
* `lastPromptTokens` is what the provider counted for the previous request in
|
|
19
|
+
* this run. Zero means nothing has been measured yet, and an unknown window
|
|
20
|
+
* means we have nothing to compare against.
|
|
21
|
+
*/
|
|
22
|
+
export declare function shouldCompact(contextLength: number | undefined, lastPromptTokens: number): boolean;
|
|
23
|
+
export interface PruneResult {
|
|
24
|
+
/** Messages removed from the array. */
|
|
25
|
+
readonly removed: number;
|
|
26
|
+
/** Complete tool exchanges dropped. */
|
|
27
|
+
readonly exchanges: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Drop the oldest complete tool exchanges, in place.
|
|
31
|
+
*
|
|
32
|
+
* The system prompt and the opening goal are never touched - without the goal
|
|
33
|
+
* the model no longer knows what it is doing, which is a worse failure than
|
|
34
|
+
* running out of context.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pruneToolExchanges(history: ChatMessage[], keepRecent?: number): PruneResult;
|
|
37
|
+
//# sourceMappingURL=context-budget.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// §7.4 · Keeping a long run inside the model's context window.
|
|
2
|
+
//
|
|
3
|
+
// The plan asks for "总上下文按模型窗口设置预算,接近上限时压缩历史". Two decisions
|
|
4
|
+
// shape what that can honestly be:
|
|
5
|
+
//
|
|
6
|
+
// The size of the history is *measured*, not estimated. Every response carries
|
|
7
|
+
// `prompt_tokens` for the request that produced it, which is the provider's own
|
|
8
|
+
// count of exactly the conversation we sent. Estimating instead would mean
|
|
9
|
+
// shipping a tokenizer per provider - CLI-000B already found the gateway's own
|
|
10
|
+
// reservation under-counts badly because it cannot see the tool schema, and a
|
|
11
|
+
// second wrong estimate here would be no better.
|
|
12
|
+
//
|
|
13
|
+
// And what gets dropped is whole tool exchanges, never individual messages.
|
|
14
|
+
// CLI-014 established the rule the hard way: an assistant message carrying
|
|
15
|
+
// `tool_calls` and the `tool` results answering them are inseparable, because
|
|
16
|
+
// the API rejects a tool result with no matching call and a call with no
|
|
17
|
+
// result. So the unit of removal is the pair, or nothing.
|
|
18
|
+
//
|
|
19
|
+
// When the model's context length is unknown, nothing happens at all. A guess
|
|
20
|
+
// at the window would either truncate a conversation that fit, or fail to
|
|
21
|
+
// truncate one that did not.
|
|
22
|
+
/**
|
|
23
|
+
* Fraction of the usable window at which we start shortening.
|
|
24
|
+
*
|
|
25
|
+
* Not 1.0, and the gap is not caution for its own sake: the request that
|
|
26
|
+
* measured `prompt_tokens` is smaller than the next one will be, because the
|
|
27
|
+
* assistant reply, its tool results and the next user message all land in
|
|
28
|
+
* between and none of them are counted yet.
|
|
29
|
+
*/
|
|
30
|
+
export const COMPACT_AT = 0.75;
|
|
31
|
+
/** Room left for the reply, which shares the window with the input. */
|
|
32
|
+
export function replyReserve(contextLength) {
|
|
33
|
+
return Math.min(4096, Math.floor(contextLength / 8));
|
|
34
|
+
}
|
|
35
|
+
/** Tool exchanges kept whatever happens, so the recent work stays intact. */
|
|
36
|
+
export const KEEP_RECENT_EXCHANGES = 2;
|
|
37
|
+
/**
|
|
38
|
+
* Is the conversation close enough to the window to shorten it?
|
|
39
|
+
*
|
|
40
|
+
* `lastPromptTokens` is what the provider counted for the previous request in
|
|
41
|
+
* this run. Zero means nothing has been measured yet, and an unknown window
|
|
42
|
+
* means we have nothing to compare against.
|
|
43
|
+
*/
|
|
44
|
+
export function shouldCompact(contextLength, lastPromptTokens) {
|
|
45
|
+
if (contextLength === undefined || contextLength <= 0)
|
|
46
|
+
return false;
|
|
47
|
+
if (lastPromptTokens <= 0)
|
|
48
|
+
return false;
|
|
49
|
+
const usable = contextLength - replyReserve(contextLength);
|
|
50
|
+
return usable > 0 && lastPromptTokens >= usable * COMPACT_AT;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The assistant-with-tool_calls messages and the tool results that answer them.
|
|
54
|
+
*
|
|
55
|
+
* An exchange is only counted when every one of its calls has a result: a
|
|
56
|
+
* partial one is the tail of a run still in progress, and removing half of it
|
|
57
|
+
* is precisely the shape the API refuses.
|
|
58
|
+
*/
|
|
59
|
+
function exchangesIn(history) {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (let i = 0; i < history.length; i += 1) {
|
|
62
|
+
const message = history[i];
|
|
63
|
+
if (message === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
const calls = message.role === "assistant" ? message.tool_calls : undefined;
|
|
66
|
+
if (calls === undefined || calls.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
const answered = new Set();
|
|
69
|
+
let end = i + 1;
|
|
70
|
+
while (end < history.length && history[end]?.role === "tool") {
|
|
71
|
+
const id = history[end]?.tool_call_id;
|
|
72
|
+
if (typeof id === "string")
|
|
73
|
+
answered.add(id);
|
|
74
|
+
end += 1;
|
|
75
|
+
}
|
|
76
|
+
if (calls.every((call) => answered.has(call.id))) {
|
|
77
|
+
found.push({ start: i, end });
|
|
78
|
+
}
|
|
79
|
+
i = end - 1;
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Drop the oldest complete tool exchanges, in place.
|
|
85
|
+
*
|
|
86
|
+
* The system prompt and the opening goal are never touched - without the goal
|
|
87
|
+
* the model no longer knows what it is doing, which is a worse failure than
|
|
88
|
+
* running out of context.
|
|
89
|
+
*/
|
|
90
|
+
export function pruneToolExchanges(history, keepRecent = KEEP_RECENT_EXCHANGES) {
|
|
91
|
+
const exchanges = exchangesIn(history);
|
|
92
|
+
const droppable = exchanges.slice(0, Math.max(0, exchanges.length - keepRecent));
|
|
93
|
+
if (droppable.length === 0)
|
|
94
|
+
return { removed: 0, exchanges: 0 };
|
|
95
|
+
const drop = new Set();
|
|
96
|
+
for (const exchange of droppable) {
|
|
97
|
+
for (let i = exchange.start; i < exchange.end; i += 1)
|
|
98
|
+
drop.add(i);
|
|
99
|
+
}
|
|
100
|
+
const kept = history.filter((_, index) => !drop.has(index));
|
|
101
|
+
const removed = history.length - kept.length;
|
|
102
|
+
history.length = 0;
|
|
103
|
+
history.push(...kept);
|
|
104
|
+
return { removed, exchanges: droppable.length };
|
|
105
|
+
}
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -106,6 +106,17 @@ export type AgentEvent = {
|
|
|
106
106
|
type: "rate_limited";
|
|
107
107
|
waitMs: number;
|
|
108
108
|
attempt: number;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* §7.4 · The history was shortened to stay inside the model's window.
|
|
112
|
+
* Surfaced rather than done quietly: the user is entitled to know that
|
|
113
|
+
* earlier tool output is no longer being sent.
|
|
114
|
+
*/
|
|
115
|
+
| {
|
|
116
|
+
type: "compacted";
|
|
117
|
+
exchanges: number;
|
|
118
|
+
messages: number;
|
|
119
|
+
promptTokens: number;
|
|
109
120
|
};
|
|
110
121
|
export interface ChatMessage {
|
|
111
122
|
role: "system" | "user" | "assistant" | "tool";
|
package/dist/agent/loop.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
|
package/dist/agent/loop.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError } from "../api/errors.js";
|
|
2
|
+
import { pruneToolExchanges, shouldCompact } from "./context-budget.js";
|
|
2
3
|
import { toolToJsonSchema, } from "./tools/types.js";
|
|
3
4
|
/** Plan §7.4. */
|
|
4
5
|
export const DEFAULT_LIMITS = {
|
|
@@ -44,6 +45,8 @@ export async function runAgent(options) {
|
|
|
44
45
|
completionTokens: 0,
|
|
45
46
|
costMicroUsd: 0n,
|
|
46
47
|
};
|
|
48
|
+
/** What the provider counted for the previous request; 0 until one lands. */
|
|
49
|
+
let lastPromptTokens = 0;
|
|
47
50
|
for (let turn = 1; turn <= limits.maxTurns; turn += 1) {
|
|
48
51
|
if (options.signal?.aborted)
|
|
49
52
|
return stop(report, "cancelled");
|
|
@@ -55,6 +58,22 @@ export async function runAgent(options) {
|
|
|
55
58
|
}
|
|
56
59
|
report.turns = turn;
|
|
57
60
|
options.onEvent?.({ type: "turn", turn });
|
|
61
|
+
// §7.4 · Shorten before asking, using what the provider counted last time.
|
|
62
|
+
// Only whole tool exchanges go, and only when the window is known.
|
|
63
|
+
if (shouldCompact(options.pricing?.contextLength, lastPromptTokens)) {
|
|
64
|
+
const pruned = pruneToolExchanges(messages);
|
|
65
|
+
if (pruned.exchanges > 0) {
|
|
66
|
+
options.onEvent?.({
|
|
67
|
+
type: "compacted",
|
|
68
|
+
exchanges: pruned.exchanges,
|
|
69
|
+
messages: pruned.removed,
|
|
70
|
+
promptTokens: lastPromptTokens,
|
|
71
|
+
});
|
|
72
|
+
// Reset the measurement: the next response measures the shorter
|
|
73
|
+
// history, and reusing the old figure would compact again every turn.
|
|
74
|
+
lastPromptTokens = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
58
77
|
let response;
|
|
59
78
|
try {
|
|
60
79
|
response = await requestWithRateLimitHandling(options, limits, {
|
|
@@ -74,6 +93,7 @@ export async function runAgent(options) {
|
|
|
74
93
|
return stop(report, "error", error instanceof Error ? error.message : String(error));
|
|
75
94
|
}
|
|
76
95
|
const usage = response.usage;
|
|
96
|
+
lastPromptTokens = usage?.prompt_tokens ?? lastPromptTokens;
|
|
77
97
|
report.promptTokens += usage?.prompt_tokens ?? 0;
|
|
78
98
|
report.completionTokens += usage?.completion_tokens ?? 0;
|
|
79
99
|
// Cost comes from returned usage, never from the reservation: CLI-000B
|
|
@@ -15,6 +15,34 @@ export interface CommandResult {
|
|
|
15
15
|
* failure mode where a "timed out" test run keeps holding a port.
|
|
16
16
|
*/
|
|
17
17
|
export declare function executeCommand(argv: readonly string[], cwd: string, timeoutMs?: number): Promise<CommandResult>;
|
|
18
|
+
/**
|
|
19
|
+
* What "kill the whole tree" means on this platform.
|
|
20
|
+
*
|
|
21
|
+
* Pure, and separated from doing it, because the Windows branch cannot be
|
|
22
|
+
* executed here: there is no Windows machine in this project, and a kill path
|
|
23
|
+
* that has never run is exactly the kind of code that is wrong in a way only
|
|
24
|
+
* production finds. Keeping the decision pure means the *shape* of the command
|
|
25
|
+
* is under test on both platforms even though only one of them can be run.
|
|
26
|
+
*
|
|
27
|
+
* POSIX: a negative pid signals the process group, which is why the child is
|
|
28
|
+
* spawned detached. Killing the parent alone leaves grandchildren running -
|
|
29
|
+
* the familiar case where a "timed out" test runner keeps holding a port.
|
|
30
|
+
*
|
|
31
|
+
* Windows has no process groups in that sense. `taskkill /T` walks the child
|
|
32
|
+
* tree instead, and `/F` is the forceful pass: without it taskkill asks
|
|
33
|
+
* politely, which a console process is free to ignore - the same distinction
|
|
34
|
+
* as SIGTERM and SIGKILL.
|
|
35
|
+
*/
|
|
36
|
+
export type KillPlan = {
|
|
37
|
+
readonly kind: "signal";
|
|
38
|
+
readonly target: number;
|
|
39
|
+
readonly signal: NodeJS.Signals;
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: "spawn";
|
|
42
|
+
readonly command: string;
|
|
43
|
+
readonly args: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
export declare function killPlan(pid: number, force: boolean, platform?: string): KillPlan;
|
|
18
46
|
export declare const runCommandTool: ToolDefinition;
|
|
19
47
|
export declare const COMMAND_TOOLS: ToolDefinition[];
|
|
20
48
|
//# sourceMappingURL=run-command.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/run-command.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA+B,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAG1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAA2B,GACrC,OAAO,CAAC,aAAa,CAAC,CAkDxB;
|
|
1
|
+
{"version":3,"file":"run-command.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/run-command.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA+B,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,eAAO,MAAM,kBAAkB,SAAU,CAAC;AAG1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,MAA2B,GACrC,OAAO,CAAC,aAAa,CAAC,CAkDxB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,QAAQ,GAChB;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC;CACjC,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC,CAAC;AAEN,wBAAgB,QAAQ,CACtB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAyB,GAClC,QAAQ,CAaV;AA8BD,eAAO,MAAM,cAAc,EAAE,cAmC5B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,cAAc,EAAqB,CAAC"}
|
|
@@ -48,8 +48,8 @@ export async function executeCommand(argv, cwd, timeoutMs = COMMAND_TIMEOUT_MS)
|
|
|
48
48
|
const timer = setTimeout(() => {
|
|
49
49
|
timedOut = true;
|
|
50
50
|
killTree(child.pid);
|
|
51
|
-
// A process ignoring
|
|
52
|
-
setTimeout(() => killTree(child.pid,
|
|
51
|
+
// A process ignoring the polite request still has to go.
|
|
52
|
+
setTimeout(() => killTree(child.pid, true), 2000).unref();
|
|
53
53
|
}, timeoutMs);
|
|
54
54
|
const finish = (code, signal) => {
|
|
55
55
|
clearTimeout(timer);
|
|
@@ -59,23 +59,44 @@ export async function executeCommand(argv, cwd, timeoutMs = COMMAND_TIMEOUT_MS)
|
|
|
59
59
|
child.on("close", (code, signal) => finish(code, signal));
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
export function killPlan(pid, force, platform = process.platform) {
|
|
63
|
+
if (platform === "win32") {
|
|
64
|
+
return {
|
|
65
|
+
kind: "spawn",
|
|
66
|
+
command: "taskkill",
|
|
67
|
+
args: ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
kind: "signal",
|
|
72
|
+
target: -pid,
|
|
73
|
+
signal: force ? "SIGKILL" : "SIGTERM",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
62
76
|
/**
|
|
63
|
-
* Kill the whole
|
|
77
|
+
* Kill the whole tree.
|
|
64
78
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* child is killed and the limitation is stated rather than papered over
|
|
69
|
-
* (no Windows machine here to verify a taskkill /T path).
|
|
79
|
+
* ⚠️ The Windows branch is UNVERIFIED on a real machine. Its shape is tested;
|
|
80
|
+
* whether `taskkill /T` actually reaches every grandchild of a Node-spawned
|
|
81
|
+
* process is not something this project has been able to check.
|
|
70
82
|
*/
|
|
71
|
-
function killTree(pid,
|
|
83
|
+
function killTree(pid, force = false) {
|
|
72
84
|
if (pid === undefined)
|
|
73
85
|
return;
|
|
86
|
+
const plan = killPlan(pid, force);
|
|
74
87
|
try {
|
|
75
|
-
if (
|
|
76
|
-
process.kill(
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
if (plan.kind === "signal") {
|
|
89
|
+
process.kill(plan.target, plan.signal);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Detached and ignored: this is fire-and-forget cleanup, and a taskkill
|
|
93
|
+
// that fails must not become an unhandled error on the way out.
|
|
94
|
+
const killer = spawn(plan.command, [...plan.args], {
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
});
|
|
98
|
+
killer.on("error", () => { });
|
|
99
|
+
killer.unref();
|
|
79
100
|
}
|
|
80
101
|
catch {
|
|
81
102
|
// Already exited, or never became a group leader.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAOxE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAatB,MAAM,WAAW,MAAO,SAAQ,SAAS;IACvC,qDAAqD;IACrD,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAcD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,CAAC,CA8BnB;AAoSD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,KAAK,EAAE,WAAW,GACjB,MAAM,GAAG,SAAS,CASpB"}
|
package/dist/commands/chat.js
CHANGED
|
@@ -2,15 +2,23 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { fetchCatalog } from "../api/models.js";
|
|
3
3
|
import { readChatStream } from "../api/sse-parser.js";
|
|
4
4
|
import { ApiError } from "../api/errors.js";
|
|
5
|
+
import { sessionsDir } from "../config/paths.js";
|
|
6
|
+
import { keyDigest } from "../security/redact.js";
|
|
7
|
+
import { upsertIndexEntry } from "../sessions/index-file.js";
|
|
8
|
+
import { decideRecording } from "../sessions/policy.js";
|
|
9
|
+
import { SessionWriter } from "../sessions/store.js";
|
|
5
10
|
import { ExitCode } from "../output/exit-codes.js";
|
|
6
11
|
import { formatUsd } from "../output/money.js";
|
|
7
12
|
import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
|
|
8
13
|
const USAGE = `Usage:
|
|
9
14
|
mafdet chat [prompt] [--model <id>] [--system <file>] [--input <file>]
|
|
10
|
-
[--no-stream] [--max-tokens <n>] [--quiet]
|
|
15
|
+
[--no-stream] [--max-tokens <n>] [--quiet] [--save]
|
|
11
16
|
|
|
12
17
|
The prompt comes from an argument, from --input <file>, or from stdin when
|
|
13
18
|
neither is given. Use "--input -" to force reading stdin.
|
|
19
|
+
|
|
20
|
+
A single chat is not saved unless --save is given. Saved conversations stay
|
|
21
|
+
on this computer; see 'mafdet sessions'.
|
|
14
22
|
`;
|
|
15
23
|
export async function chatCommand(argv, io) {
|
|
16
24
|
try {
|
|
@@ -51,6 +59,8 @@ async function parseArgs(argv, configuredModel, io) {
|
|
|
51
59
|
let inputFile;
|
|
52
60
|
let stream = true;
|
|
53
61
|
let quiet = false;
|
|
62
|
+
let save = false;
|
|
63
|
+
let noHistory = false;
|
|
54
64
|
let maxTokens;
|
|
55
65
|
for (let i = 0; i < argv.length; i += 1) {
|
|
56
66
|
const arg = argv[i];
|
|
@@ -65,6 +75,10 @@ async function parseArgs(argv, configuredModel, io) {
|
|
|
65
75
|
stream = false;
|
|
66
76
|
else if (arg === "--quiet")
|
|
67
77
|
quiet = true;
|
|
78
|
+
else if (arg === "--save")
|
|
79
|
+
save = true;
|
|
80
|
+
else if (arg === "--no-history")
|
|
81
|
+
noHistory = true;
|
|
68
82
|
else if (arg === "--max-tokens") {
|
|
69
83
|
const raw = next();
|
|
70
84
|
maxTokens = Number(raw);
|
|
@@ -123,6 +137,8 @@ async function parseArgs(argv, configuredModel, io) {
|
|
|
123
137
|
stream,
|
|
124
138
|
maxTokens,
|
|
125
139
|
quiet,
|
|
140
|
+
save,
|
|
141
|
+
noHistory,
|
|
126
142
|
};
|
|
127
143
|
}
|
|
128
144
|
function readTextFile(path, flag) {
|
|
@@ -137,26 +153,117 @@ function readTextFile(path, flag) {
|
|
|
137
153
|
});
|
|
138
154
|
}
|
|
139
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* CLI-024/§20.4 · Save a one-shot chat, when asked.
|
|
158
|
+
*
|
|
159
|
+
* A single `mafdet chat` is not a conversation anyone resumes, so it is not
|
|
160
|
+
* recorded by default; `--save` opts in and `--no-history` still wins over it.
|
|
161
|
+
* Everything written goes through the same store as an interactive session, so
|
|
162
|
+
* the same redaction, the same 0600 files and the same `mafdet sessions
|
|
163
|
+
* delete` apply - there is no second, weaker path to disk.
|
|
164
|
+
*
|
|
165
|
+
* Failures here are swallowed: losing the record of a chat is bad, losing the
|
|
166
|
+
* answer the user is waiting for is worse.
|
|
167
|
+
*/
|
|
168
|
+
function saveChat(args, io, answer, usage, requestId, model) {
|
|
169
|
+
const decision = decideRecording({
|
|
170
|
+
surface: "one-shot",
|
|
171
|
+
noHistory: args.noHistory,
|
|
172
|
+
save: args.save,
|
|
173
|
+
});
|
|
174
|
+
if (!decision.record)
|
|
175
|
+
return;
|
|
176
|
+
const env = io.env ?? process.env;
|
|
177
|
+
try {
|
|
178
|
+
const root = sessionsDir(env);
|
|
179
|
+
const writer = SessionWriter.create({
|
|
180
|
+
root,
|
|
181
|
+
title: chatTitle(args.prompt),
|
|
182
|
+
modelId: args.model,
|
|
183
|
+
workspace: io.cwd ?? process.cwd(),
|
|
184
|
+
mode: "chat",
|
|
185
|
+
});
|
|
186
|
+
if (args.system !== undefined) {
|
|
187
|
+
writer.append({ type: "user_message", content: args.system });
|
|
188
|
+
}
|
|
189
|
+
writer.append({ type: "user_message", content: args.prompt });
|
|
190
|
+
writer.append({ type: "assistant_message", content: answer });
|
|
191
|
+
if (usage) {
|
|
192
|
+
const micros = estimateChargeMicroUsd(model, usage);
|
|
193
|
+
writer.append({
|
|
194
|
+
type: "usage",
|
|
195
|
+
...(requestId === undefined ? {} : { request_id: requestId }),
|
|
196
|
+
model_id: args.model,
|
|
197
|
+
prompt_tokens: usage.prompt_tokens,
|
|
198
|
+
completion_tokens: usage.completion_tokens,
|
|
199
|
+
estimated_charge_micro_usd: (micros ?? 0n).toString(),
|
|
200
|
+
settlement_status: "pending",
|
|
201
|
+
api_key_ref: keyDigest(readKey(io)),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
writer.close();
|
|
205
|
+
upsertIndexEntry(root, writer.metadata);
|
|
206
|
+
if (!args.quiet) {
|
|
207
|
+
io.stderr(`saved as ${writer.sessionId} (local only)\n`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
io.stderr(`mafdet chat: not saved: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** The first line, trimmed to something that fits a listing. */
|
|
215
|
+
function chatTitle(prompt) {
|
|
216
|
+
const line = prompt.split("\n").find((l) => l.trim().length > 0) ?? "Chat";
|
|
217
|
+
const trimmed = line.trim();
|
|
218
|
+
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed;
|
|
219
|
+
}
|
|
220
|
+
function readKey(io) {
|
|
221
|
+
return (io.env ?? process.env).MAFDET_API_KEY ?? "";
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The catalogue entry for pricing, fetched once and shared.
|
|
225
|
+
*
|
|
226
|
+
* A pricing lookup must never fail the call that already succeeded, so a
|
|
227
|
+
* failure here is an absent price rather than an error - the tokens are still
|
|
228
|
+
* reported, and `sessions usage --refresh` can still get the settled figure.
|
|
229
|
+
*/
|
|
230
|
+
async function priceModel(runtime, modelId, needed) {
|
|
231
|
+
if (!needed)
|
|
232
|
+
return undefined;
|
|
233
|
+
try {
|
|
234
|
+
return (await fetchCatalog(runtime.accountPlane)).find((m) => m.modelId === modelId);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
140
240
|
async function streamChat(runtime, body, args, io) {
|
|
141
|
-
const { response } = await runtime.dataPlane.postStream("/chat/completions", body, {
|
|
241
|
+
const { response, requestId } = await runtime.dataPlane.postStream("/chat/completions", body, {
|
|
142
242
|
signal: io.signal,
|
|
143
243
|
});
|
|
144
244
|
// Text goes to stdout as it arrives; everything else is stderr, so
|
|
145
245
|
// `mafdet chat ... > answer.txt` captures only the answer.
|
|
146
246
|
const result = await readChatStream(response, { onText: io.stdout }, io.signal);
|
|
147
247
|
io.stdout("\n");
|
|
248
|
+
const model = await priceModel(runtime, args.model, !args.quiet || args.save);
|
|
148
249
|
if (io.signal?.aborted) {
|
|
149
250
|
io.stderr("mafdet: cancelled - the connection was closed mid-generation\n");
|
|
150
|
-
|
|
251
|
+
summarise(args, result.usage, io, model, { partial: true });
|
|
252
|
+
// A cancelled chat is not saved: the answer is incomplete, and an archive
|
|
253
|
+
// of half a reply is a worse record than none.
|
|
151
254
|
return ExitCode.Cancelled;
|
|
152
255
|
}
|
|
153
|
-
|
|
256
|
+
summarise(args, result.usage, io, model);
|
|
257
|
+
saveChat(args, io, result.text, result.usage, requestId, model);
|
|
154
258
|
return ExitCode.Ok;
|
|
155
259
|
}
|
|
156
260
|
async function completeChat(runtime, body, args, io) {
|
|
157
|
-
const { data } = await runtime.dataPlane.postJson("/chat/completions", body, { signal: io.signal });
|
|
158
|
-
|
|
159
|
-
|
|
261
|
+
const { data, requestId } = await runtime.dataPlane.postJson("/chat/completions", body, { signal: io.signal });
|
|
262
|
+
const answer = data.choices?.[0]?.message?.content ?? "";
|
|
263
|
+
io.stdout(`${answer}\n`);
|
|
264
|
+
const model = await priceModel(runtime, args.model, !args.quiet || args.save);
|
|
265
|
+
summarise(args, data.usage, io, model);
|
|
266
|
+
saveChat(args, io, answer, data.usage, requestId, model);
|
|
160
267
|
return ExitCode.Ok;
|
|
161
268
|
}
|
|
162
269
|
/**
|
|
@@ -165,20 +272,13 @@ async function completeChat(runtime, body, args, io) {
|
|
|
165
272
|
* verified against the ledger to the micro-dollar. It is still labelled as an
|
|
166
273
|
* estimate: the wallet, not the client, is the authority.
|
|
167
274
|
*/
|
|
168
|
-
|
|
275
|
+
function summarise(args, usage, io, model, options = {}) {
|
|
169
276
|
if (args.quiet || !usage)
|
|
170
277
|
return;
|
|
171
278
|
let cost = "";
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const micros = estimateChargeMicroUsd(model, usage);
|
|
176
|
-
if (micros !== undefined)
|
|
177
|
-
cost = `, ~${formatUsd(micros)}`;
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
// A pricing lookup must never fail the call that already succeeded.
|
|
181
|
-
}
|
|
279
|
+
const micros = estimateChargeMicroUsd(model, usage);
|
|
280
|
+
if (micros !== undefined)
|
|
281
|
+
cost = `, ~${formatUsd(micros)}`;
|
|
182
282
|
const prefix = options.partial ? "partial: " : "";
|
|
183
283
|
io.stderr(`${prefix}${usage.prompt_tokens} in / ${usage.completion_tokens} out tokens${cost}\n`);
|
|
184
284
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-surface.d.ts","sourceRoot":"","sources":["../../src/tui/agent-surface.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEhE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,IAAI,CACpB,cAAc,EACd,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAC1D,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;IACnD,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAC;IACpC,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,UAAU,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,cAAc,CAAC,
|
|
1
|
+
{"version":3,"file":"agent-surface.d.ts","sourceRoot":"","sources":["../../src/tui/agent-surface.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEhE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAClD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,IAAI,CACpB,cAAc,EACd,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAC1D,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,WAAW,GAAG,SAAS,CAAC;IACnD,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,eAAe,CAAC;IACpC,yDAAyD;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,UAAU,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,cAAc,CAAC,CAyEzB;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAgBR"}
|
|
@@ -67,6 +67,13 @@ export async function runAgentSurface(options) {
|
|
|
67
67
|
// when someone is deciding whether to press Ctrl+C.
|
|
68
68
|
options.stdout.write(dim(` rate limited - waiting ${Math.round(event.waitMs / 1000)}s\n`));
|
|
69
69
|
}
|
|
70
|
+
if (event.type === "compacted") {
|
|
71
|
+
// Said out loud: from here the model can no longer see those results,
|
|
72
|
+
// which is the sort of thing that otherwise looks like it forgot.
|
|
73
|
+
// The saved transcript still has them - only what we send got shorter.
|
|
74
|
+
options.stdout.write(dim(` context near the limit (${event.promptTokens} tokens) - dropped ` +
|
|
75
|
+
`${event.exchanges} earlier tool exchange(s) from what is sent\n`));
|
|
76
|
+
}
|
|
70
77
|
};
|
|
71
78
|
const sessionOptions = {
|
|
72
79
|
...options.session,
|