@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 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 | 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]` | 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 |
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` | 一次检查 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]` | 发一次对话,默认流式 |
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` | 当前生效的配置及其来源 |
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
+ }
@@ -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";
@@ -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":"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"}
@@ -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;AAwBD,eAAO,MAAM,cAAc,EAAE,cAmC5B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,cAAc,EAAqB,CAAC"}
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 SIGTERM still has to go.
52
- setTimeout(() => killTree(child.pid, "SIGKILL"), 2000).unref();
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 process group, not just the child.
77
+ * Kill the whole tree.
64
78
  *
65
- * Killing the parent alone leaves grandchildren running - the familiar case
66
- * where a "timed out" test runner keeps holding a port. The negative pid is
67
- * POSIX; Windows has no process groups in this sense, so there the direct
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, signal = "SIGTERM") {
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 (process.platform === "win32")
76
- process.kill(pid, signal);
77
- else
78
- process.kill(-pid, signal);
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;AAExE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAUtB,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;AAWD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,CAAC,CA8BnB;AAwLD,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,YAAY,GAAG,SAAS,EAC/B,KAAK,EAAE,WAAW,GACjB,MAAM,GAAG,SAAS,CASpB"}
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"}
@@ -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
- await summarise(runtime, args, result.usage, io, { partial: true });
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
- await summarise(runtime, args, result.usage, io);
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
- io.stdout(`${data.choices?.[0]?.message?.content ?? ""}\n`);
159
- await summarise(runtime, args, data.usage, io);
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
- async function summarise(runtime, args, usage, io, options = {}) {
275
+ function summarise(args, usage, io, model, options = {}) {
169
276
  if (args.quiet || !usage)
170
277
  return;
171
278
  let cost = "";
172
- try {
173
- const catalog = await fetchCatalog(runtime.accountPlane);
174
- const model = catalog.find((m) => m.modelId === args.model);
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,CA8DzB;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAgBR"}
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Mafdet AI command line — model calls, account queries, and a permissioned local agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",