@vietor/easy-agent 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/dist/commands/builtin.js +8 -16
- package/dist/commands/dispatch.js +11 -8
- package/dist/config.js +11 -10
- package/dist/main.js +30 -20
- package/dist/{session-store.js → session-persistence.js} +29 -6
- package/dist/tui/app-header.js +3 -3
- package/dist/tui/app.js +37 -35
- package/dist/tui/spinner.js +2 -2
- package/dist/tui/status-bar.js +3 -3
- package/dist/tui/timeline-view.js +1 -1
- package/dist/tui/todo-view.js +3 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @vietor/easy-agent
|
|
2
2
|
|
|
3
|
-
Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/
|
|
3
|
+
Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/agent-core`.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
@@ -30,20 +30,20 @@ Create `~/.easy-agent.json` in your home directory:
|
|
|
30
30
|
"baseUrl": "https://api.deepseek.com/v1",
|
|
31
31
|
"apiKey": "your-api-key",
|
|
32
32
|
"model": "deepseek-v4-flash",
|
|
33
|
-
"
|
|
33
|
+
"thinkingEffort": "high"
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
`llm.baseUrl`, `llm.apiKey`, and `llm.model` are all required. Point `baseUrl` at any OpenAI-compatible endpoint (OpenAI, Azure OpenAI, local servers, etc.) and set `model` to a model that endpoint serves.
|
|
39
39
|
|
|
40
|
-
#### `
|
|
40
|
+
#### `backend` (optional)
|
|
41
41
|
|
|
42
42
|
Selects the wire protocol the client speaks. Valid values: `"completions"` | `"anthropic"` | `"responses"`. Defaults to `"completions"`.
|
|
43
43
|
|
|
44
|
-
- `"completions"` - OpenAI Chat Completions compatible endpoint (the default). `
|
|
45
|
-
- `"anthropic"` - Anthropic Messages API via the official SDK. Point `baseUrl` at an Anthropic-compatible endpoint (e.g. `https://api.anthropic.com`) and `model` at a Claude model. `
|
|
46
|
-
- `"responses"` - OpenAI Responses API via the official SDK. `
|
|
44
|
+
- `"completions"` - OpenAI Chat Completions compatible endpoint (the default). `thinkingEffort` is sent as `reasoning_effort`.
|
|
45
|
+
- `"anthropic"` - Anthropic Messages API via the official SDK. Point `baseUrl` at an Anthropic-compatible endpoint (e.g. `https://api.anthropic.com`) and `model` at a Claude model. `thinkingEffort` enables extended thinking (`"high"` = 16k budget, `"max"` = 32k).
|
|
46
|
+
- `"responses"` - OpenAI Responses API via the official SDK. `thinkingEffort` is sent as `reasoning.effort`, with reasoning summaries streamed to the UI.
|
|
47
47
|
|
|
48
48
|
```json
|
|
49
49
|
{
|
|
@@ -51,15 +51,15 @@ Selects the wire protocol the client speaks. Valid values: `"completions"` | `"a
|
|
|
51
51
|
"baseUrl": "https://api.anthropic.com",
|
|
52
52
|
"apiKey": "your-api-key",
|
|
53
53
|
"model": "claude-sonnet-5",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"backend": "anthropic",
|
|
55
|
+
"thinkingEffort": "high"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
#### `
|
|
60
|
+
#### `thinkingEffort` (optional)
|
|
61
61
|
|
|
62
|
-
Controls the depth of model
|
|
62
|
+
Controls the depth of model thinking. Valid values: `"high"` | `"max"`. Defaults to `"high"`. When set to `"max"` the model spends more tokens on deeper thinking before responding — useful for complex logic, math, or multi-step analysis. The current setting is shown in the TUI header (e.g. ` · thinking max`). Requires a model that supports the `reasoning_effort` parameter.
|
|
63
63
|
|
|
64
64
|
### Proxy
|
|
65
65
|
|
|
@@ -141,9 +141,9 @@ easy-agent --resume # list all saved sessions for this directory
|
|
|
141
141
|
|
|
142
142
|
Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line summary of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
|
|
143
143
|
|
|
144
|
-
The TUI header shows the model name, version, and configured
|
|
144
|
+
The TUI header shows the model name, version, and configured thinking effort (e.g. `deepseek-v4-flash · thinking high`). While the agent is running, a spinner displays the thinking and reply elapsed time alongside token counts (e.g. `⠋ thinking · think 2s · reply 0s · ↑1.2k · ↓0.4k`). If the LLM emits extended thinking blocks, a collapsible thinking panel appears — press <kbd>t</kbd> to expand/collapse it.
|
|
145
145
|
|
|
146
|
-
A status bar at the bottom shows the context token usage with a progress bar and percentage, along with keyboard shortcut hints: <kbd>ESC</kbd> to abort a running task, <kbd>t</kbd> to toggle
|
|
146
|
+
A status bar at the bottom shows the context token usage with a progress bar and percentage, along with keyboard shortcut hints: <kbd>ESC</kbd> to abort a running task, <kbd>t</kbd> to toggle thinking (when available), and <kbd>/</kbd> for commands.
|
|
147
147
|
|
|
148
148
|
### Built-in tools
|
|
149
149
|
|
|
@@ -155,7 +155,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
|
|
|
155
155
|
- **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
|
|
156
156
|
- **WebFetch** — fetch a URL as markdown or text.
|
|
157
157
|
- **AskUser** — ask the user a question and wait for their answer.
|
|
158
|
-
- **TodoWrite** — track multi-step work as a task list (pending /
|
|
158
|
+
- **TodoWrite** — track multi-step work as a task list (pending / inProgress / completed), shown live as a panel in the TUI.
|
|
159
159
|
- **SubAgent** — delegate investigation (`explore`) or implementation-planning (`plan`) subtasks to a nested read-only sub-agent.
|
|
160
160
|
|
|
161
161
|
### Slash commands
|
|
@@ -166,8 +166,8 @@ A status bar at the bottom shows the context token usage with a progress bar and
|
|
|
166
166
|
| `/clear` | Reset the conversation |
|
|
167
167
|
| `/compact` | Compress the conversation into a summary to free context |
|
|
168
168
|
| `/skill` | List available skills |
|
|
169
|
-
| `/
|
|
170
|
-
| `/
|
|
169
|
+
| `/save` | Save the current session to `session-{timestamp}.jsonl` |
|
|
170
|
+
| `/exit` | Leave the app |
|
|
171
171
|
|
|
172
172
|
## Build from source
|
|
173
173
|
|
package/dist/commands/builtin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
export const clearCommand = {
|
|
3
3
|
name: "clear",
|
|
4
|
-
description: "Clear the
|
|
4
|
+
description: "Clear the session and log",
|
|
5
5
|
async execute(ctx) {
|
|
6
6
|
ctx.session.clear();
|
|
7
7
|
},
|
|
@@ -48,30 +48,23 @@ export const exitCommand = {
|
|
|
48
48
|
name: "exit",
|
|
49
49
|
description: "Exit the conversation",
|
|
50
50
|
async execute(ctx) {
|
|
51
|
-
ctx.
|
|
51
|
+
ctx.requestExit();
|
|
52
52
|
},
|
|
53
53
|
};
|
|
54
|
-
export const
|
|
55
|
-
name: "
|
|
56
|
-
description: "
|
|
54
|
+
export const saveCommand = {
|
|
55
|
+
name: "save",
|
|
56
|
+
description: "Save the session to a JSONL file",
|
|
57
57
|
async execute(ctx) {
|
|
58
58
|
const d = new Date();
|
|
59
59
|
const pad = (n) => String(n).padStart(2, "0");
|
|
60
60
|
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
61
|
-
const file = `
|
|
61
|
+
const file = `session-${ts}.jsonl`;
|
|
62
62
|
const lines = ctx.session
|
|
63
63
|
.export()
|
|
64
64
|
.map((m) => JSON.stringify(m))
|
|
65
65
|
.join("\n");
|
|
66
66
|
writeFileSync(file, lines + "\n", "utf-8");
|
|
67
|
-
ctx.message(`
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
export const quitCommand = {
|
|
71
|
-
name: "quit",
|
|
72
|
-
description: "Exit the conversation",
|
|
73
|
-
async execute(ctx) {
|
|
74
|
-
ctx.session.localStore.set("exitRequested", true);
|
|
67
|
+
ctx.message(`saved to ${file}`);
|
|
75
68
|
},
|
|
76
69
|
};
|
|
77
70
|
export const builtinCommands = [
|
|
@@ -80,6 +73,5 @@ export const builtinCommands = [
|
|
|
80
73
|
compactCommand,
|
|
81
74
|
skillCommand,
|
|
82
75
|
exitCommand,
|
|
83
|
-
|
|
84
|
-
exportCommand,
|
|
76
|
+
saveCommand,
|
|
85
77
|
];
|
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { toErrorMessage } from "@vietor/agent-core/util";
|
|
2
2
|
import { builtinCommands } from "./builtin.js";
|
|
3
3
|
const commands = new Map(builtinCommands.map((c) => [c.name, c]));
|
|
4
|
-
export async function
|
|
4
|
+
export async function executeSlashCommand(name, session, requestExit, persist) {
|
|
5
5
|
const cmd = commands.get(name);
|
|
6
6
|
if (cmd) {
|
|
7
|
-
const ctx = { session, message: session.
|
|
7
|
+
const ctx = { session, message: session.addNotice, error: session.addError, requestExit };
|
|
8
8
|
try {
|
|
9
9
|
await cmd.execute(ctx);
|
|
10
10
|
}
|
|
11
11
|
catch (e) {
|
|
12
|
-
ctx.error(
|
|
12
|
+
ctx.error(toErrorMessage(e));
|
|
13
13
|
}
|
|
14
|
+
persist();
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
16
17
|
try {
|
|
17
|
-
if (await session.runSkill(name))
|
|
18
|
+
if (await session.runSkill(name)) {
|
|
19
|
+
persist();
|
|
18
20
|
return;
|
|
21
|
+
}
|
|
19
22
|
}
|
|
20
23
|
catch (e) {
|
|
21
|
-
session.
|
|
24
|
+
session.addError(toErrorMessage(e));
|
|
22
25
|
return;
|
|
23
26
|
}
|
|
24
|
-
session.
|
|
27
|
+
session.addError(`unknown command: /${name}`);
|
|
25
28
|
}
|
|
26
|
-
export function
|
|
29
|
+
export function slashCommandInfos(session) {
|
|
27
30
|
const map = new Map();
|
|
28
31
|
for (const c of builtinCommands)
|
|
29
32
|
map.set(c.name, { name: c.name, description: c.description });
|
package/dist/config.js
CHANGED
|
@@ -2,19 +2,20 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
|
|
5
|
+
import { DEFAULT_BACKEND, DEFAULT_MAX_INPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_THINKING_EFFORT } from "@vietor/agent-core/util";
|
|
6
|
+
const CONFIG_PATH = ".easy-agent.json";
|
|
6
7
|
const LLMConfigSchema = z.object({
|
|
7
8
|
baseUrl: z.string(),
|
|
8
9
|
apiKey: z.string(),
|
|
9
10
|
model: z.string(),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
maxInputTokens: z.number().int().positive().default(
|
|
13
|
-
maxOutputTokens: z.number().int().positive().default(
|
|
11
|
+
thinkingEffort: z.enum(["high", "max"]).default(DEFAULT_THINKING_EFFORT),
|
|
12
|
+
backend: z.enum(["completions", "anthropic", "responses"]).default(DEFAULT_BACKEND),
|
|
13
|
+
maxInputTokens: z.number().int().positive().default(DEFAULT_MAX_INPUT_TOKENS),
|
|
14
|
+
maxOutputTokens: z.number().int().positive().default(DEFAULT_MAX_OUTPUT_TOKENS),
|
|
14
15
|
});
|
|
15
16
|
const MCPServerConfigSchema = z.union([
|
|
16
17
|
z.object({
|
|
17
|
-
type: z.literal("stdio").
|
|
18
|
+
type: z.literal("stdio").default("stdio"),
|
|
18
19
|
command: z.string(),
|
|
19
20
|
args: z.array(z.string()).optional(),
|
|
20
21
|
env: z.record(z.string(), z.string()).optional(),
|
|
@@ -32,27 +33,27 @@ const ConfigSchema = z.object({
|
|
|
32
33
|
mcpServers: z.record(z.string(), MCPServerConfigSchema).optional(),
|
|
33
34
|
});
|
|
34
35
|
export function loadConfig() {
|
|
35
|
-
const path = join(homedir(),
|
|
36
|
+
const path = join(homedir(), CONFIG_PATH);
|
|
36
37
|
let raw;
|
|
37
38
|
try {
|
|
38
39
|
raw = readFileSync(path, "utf-8");
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
|
-
throw new Error(`Config not found: create ~/${
|
|
42
|
+
throw new Error(`Config not found: create ~/${CONFIG_PATH} (see README for format).`);
|
|
42
43
|
}
|
|
43
44
|
let parsed;
|
|
44
45
|
try {
|
|
45
46
|
parsed = JSON.parse(raw);
|
|
46
47
|
}
|
|
47
48
|
catch {
|
|
48
|
-
throw new Error(`Invalid JSON in ~/${
|
|
49
|
+
throw new Error(`Invalid JSON in ~/${CONFIG_PATH}.`);
|
|
49
50
|
}
|
|
50
51
|
const result = ConfigSchema.safeParse(parsed);
|
|
51
52
|
if (!result.success) {
|
|
52
53
|
const issues = result.error.issues
|
|
53
54
|
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
54
55
|
.join("\n ");
|
|
55
|
-
throw new Error(`Invalid config ~/${
|
|
56
|
+
throw new Error(`Invalid config ~/${CONFIG_PATH}:\n ${issues}`);
|
|
56
57
|
}
|
|
57
58
|
return result.data;
|
|
58
59
|
}
|
package/dist/main.js
CHANGED
|
@@ -3,10 +3,11 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createSession, SYSTEM_PROMPT_BOUNDARY, tryLoadSkills } from "@vietor/agent-core";
|
|
7
|
+
import { tryReadFileText } from "@vietor/agent-core/util";
|
|
7
8
|
import { startApp } from "./tui/app.js";
|
|
8
9
|
import { getPackageInfo } from "./util/package.js";
|
|
9
|
-
import { FileSessionPersistence } from "./session-
|
|
10
|
+
import { FileSessionPersistence } from "./session-persistence.js";
|
|
10
11
|
function buildSystemPromptBase(cwd) {
|
|
11
12
|
return [
|
|
12
13
|
"You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
|
|
@@ -23,7 +24,15 @@ function buildSystemPromptBase(cwd) {
|
|
|
23
24
|
- Trust tool results as ground truth.`,
|
|
24
25
|
].join("\n\n");
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
function assembleSystemPrompt(cwd) {
|
|
28
|
+
const globalPrompt = tryReadFileText(join(homedir(), ".easy-agent", "AGENTS.md")) ??
|
|
29
|
+
tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
|
|
30
|
+
const projectPrompt = tryReadFileText(join(cwd, "AGENTS.md")) ?? tryReadFileText(join(cwd, "CLAUDE.md"));
|
|
31
|
+
return [buildSystemPromptBase(cwd), globalPrompt, projectPrompt]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(SYSTEM_PROMPT_BOUNDARY);
|
|
34
|
+
}
|
|
35
|
+
async function listSessions(store, name) {
|
|
27
36
|
const sessions = await store.listSessions();
|
|
28
37
|
if (!sessions.length) {
|
|
29
38
|
console.log("No previous sessions found in this directory.");
|
|
@@ -34,7 +43,7 @@ async function listSessions(store) {
|
|
|
34
43
|
const title = s.title ? ` ${s.title}` : "";
|
|
35
44
|
console.log(` ${s.id} ${new Date(s.updatedAt).toLocaleString()}${title}`);
|
|
36
45
|
}
|
|
37
|
-
console.log(
|
|
46
|
+
console.log(`\nResume with: ${name} --resume <id>`);
|
|
38
47
|
}
|
|
39
48
|
export async function main(argv = []) {
|
|
40
49
|
const pkg = getPackageInfo();
|
|
@@ -50,7 +59,7 @@ export async function main(argv = []) {
|
|
|
50
59
|
const cwd = process.cwd();
|
|
51
60
|
const store = new FileSessionPersistence(cwd);
|
|
52
61
|
if (opts.resume !== undefined && typeof opts.resume !== "string") {
|
|
53
|
-
await listSessions(store);
|
|
62
|
+
await listSessions(store, program.name());
|
|
54
63
|
return;
|
|
55
64
|
}
|
|
56
65
|
const config = loadConfig();
|
|
@@ -70,48 +79,49 @@ export async function main(argv = []) {
|
|
|
70
79
|
if (!sessionId)
|
|
71
80
|
sessionId = randomUUID();
|
|
72
81
|
const globalSkills = tryLoadSkills(join(homedir(), ".easy-agent", "skills")) ?? tryLoadSkills(join(homedir(), ".claude", "skills"));
|
|
73
|
-
const
|
|
74
|
-
tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
|
|
75
|
-
const projectPrompt = tryReadFileText(join(cwd, "AGENTS.md")) ?? tryReadFileText(join(cwd, "CLAUDE.md"));
|
|
76
|
-
const systemPrompt = [buildSystemPromptBase(cwd), globalPrompt, projectPrompt]
|
|
77
|
-
.filter(Boolean)
|
|
78
|
-
.join(SYSTEM_PROMPT_BOUNDARY);
|
|
82
|
+
const systemPrompt = assembleSystemPrompt(cwd);
|
|
79
83
|
const session = await createSession({
|
|
80
84
|
systemPrompt,
|
|
81
|
-
|
|
85
|
+
llm: config.llm,
|
|
82
86
|
mcpServers: config.mcpServers,
|
|
83
87
|
skills: globalSkills,
|
|
84
|
-
|
|
88
|
+
builtInTools: {
|
|
85
89
|
askUser: true,
|
|
86
90
|
todoWrite: true,
|
|
87
91
|
subAgent: true,
|
|
88
92
|
},
|
|
89
93
|
cwd: cwd,
|
|
90
94
|
sessionId,
|
|
91
|
-
|
|
95
|
+
clientInfo: { name: pkg.name, version: pkg.version },
|
|
92
96
|
});
|
|
93
97
|
if (resume) {
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
98
|
+
const state = await store.load(sessionId);
|
|
99
|
+
if (!state) {
|
|
96
100
|
console.error(`Session not found: ${sessionId}`);
|
|
97
101
|
process.exit(1);
|
|
98
102
|
}
|
|
103
|
+
session.importState(state);
|
|
99
104
|
}
|
|
105
|
+
let saveChain = Promise.resolve();
|
|
106
|
+
const persist = () => {
|
|
107
|
+
const state = session.exportState();
|
|
108
|
+
saveChain = saveChain.catch(() => { }).then(() => store.saveAll(sessionId, state));
|
|
109
|
+
};
|
|
100
110
|
let shuttingDown = false;
|
|
101
111
|
const shutdown = () => {
|
|
102
112
|
if (shuttingDown)
|
|
103
113
|
process.exit(1);
|
|
104
114
|
shuttingDown = true;
|
|
105
115
|
session.dispose();
|
|
106
|
-
|
|
116
|
+
saveChain.catch(() => { }).finally(() => process.exit(0));
|
|
107
117
|
};
|
|
108
118
|
process.once("SIGINT", shutdown);
|
|
109
119
|
process.once("SIGTERM", shutdown);
|
|
110
120
|
process.stdout.write("[2J[H");
|
|
111
|
-
const app = startApp(session);
|
|
121
|
+
const app = startApp(session, persist);
|
|
112
122
|
await app.waitUntilExit().finally(async () => {
|
|
113
123
|
session.dispose();
|
|
114
|
-
await
|
|
115
|
-
console.log(["Resume this session with:",
|
|
124
|
+
await saveChain;
|
|
125
|
+
console.log(["Resume this session with:", `${program.name()} --resume ${sessionId}`].join("\n"));
|
|
116
126
|
});
|
|
117
127
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { appendFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
+
import {} from "@vietor/agent-core";
|
|
7
|
+
import { summarizeText } from "@vietor/agent-core/util";
|
|
8
|
+
const MAX_TITLE_LENGTH = 75;
|
|
9
|
+
const MAX_TITLE_SCAN_BYTES = 64 * 1024;
|
|
6
10
|
function encodeCwd(cwd) {
|
|
7
11
|
return cwd.replace(/[\/\\:]/g, "-");
|
|
8
12
|
}
|
|
@@ -18,10 +22,22 @@ function parseJsonLines(text) {
|
|
|
18
22
|
}
|
|
19
23
|
return out;
|
|
20
24
|
}
|
|
25
|
+
function readFilePrefix(path, maxBytes) {
|
|
26
|
+
const fd = openSync(path, "r");
|
|
27
|
+
try {
|
|
28
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
29
|
+
const n = readSync(fd, buffer, 0, maxBytes, 0);
|
|
30
|
+
return buffer.subarray(0, n).toString("utf-8");
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
export class FileSessionPersistence {
|
|
22
37
|
cwd;
|
|
23
38
|
dir;
|
|
24
39
|
writtenCounts = new Map();
|
|
40
|
+
writtenTodos = new Map();
|
|
25
41
|
constructor(cwd) {
|
|
26
42
|
this.cwd = cwd;
|
|
27
43
|
this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
|
|
@@ -46,6 +62,7 @@ export class FileSessionPersistence {
|
|
|
46
62
|
todos = r.todos;
|
|
47
63
|
}
|
|
48
64
|
this.writtenCounts.set(sessionId, messages.length);
|
|
65
|
+
this.writtenTodos.set(sessionId, todos);
|
|
49
66
|
return { messages, todos };
|
|
50
67
|
}
|
|
51
68
|
async saveAll(sessionId, state) {
|
|
@@ -54,8 +71,13 @@ export class FileSessionPersistence {
|
|
|
54
71
|
const shrink = state.messages.length < written;
|
|
55
72
|
const lines = state.messages
|
|
56
73
|
.slice(shrink ? 0 : written)
|
|
57
|
-
.map((m) => JSON.stringify({ t: "message", m }))
|
|
58
|
-
|
|
74
|
+
.map((m) => JSON.stringify({ t: "message", m }));
|
|
75
|
+
const lastTodos = this.writtenTodos.get(sessionId);
|
|
76
|
+
if (shrink || lastTodos === undefined || !isDeepStrictEqual(lastTodos, state.todos)) {
|
|
77
|
+
lines.push(JSON.stringify({ t: "todo", todos: state.todos }));
|
|
78
|
+
}
|
|
79
|
+
if (lines.length === 0)
|
|
80
|
+
return;
|
|
59
81
|
const path = this.file(sessionId);
|
|
60
82
|
if (shrink) {
|
|
61
83
|
await writeFile(path, lines.join("\n") + "\n", "utf-8");
|
|
@@ -64,6 +86,7 @@ export class FileSessionPersistence {
|
|
|
64
86
|
await appendFile(path, lines.join("\n") + "\n", "utf-8");
|
|
65
87
|
}
|
|
66
88
|
this.writtenCounts.set(sessionId, state.messages.length);
|
|
89
|
+
this.writtenTodos.set(sessionId, state.todos);
|
|
67
90
|
}
|
|
68
91
|
async listSessions() {
|
|
69
92
|
if (!existsSync(this.dir))
|
|
@@ -92,10 +115,10 @@ export class FileSessionPersistence {
|
|
|
92
115
|
const first = this.readFirstUser(path);
|
|
93
116
|
if (!first)
|
|
94
117
|
return undefined;
|
|
95
|
-
return
|
|
118
|
+
return summarizeText(first, MAX_TITLE_LENGTH);
|
|
96
119
|
}
|
|
97
120
|
readFirstUser(path) {
|
|
98
|
-
const first = parseJsonLines(
|
|
121
|
+
const first = parseJsonLines(readFilePrefix(path, MAX_TITLE_SCAN_BYTES))
|
|
99
122
|
.find((r) => r.t === "message" && r.m && r.m.role === "user" && typeof r.m.content === "string");
|
|
100
123
|
return first?.m?.content;
|
|
101
124
|
}
|
package/dist/tui/app-header.js
CHANGED
|
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import { Box, Text, useWindowSize } from "ink";
|
|
4
4
|
import { getPackageInfo } from "../util/package.js";
|
|
5
|
-
export const AppHeader = memo(function AppHeader({ cwd, model,
|
|
5
|
+
export const AppHeader = memo(function AppHeader({ cwd, model, thinkingEffort }) {
|
|
6
6
|
const { columns } = useWindowSize();
|
|
7
7
|
const pkg = getPackageInfo();
|
|
8
|
-
const
|
|
9
|
-
return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkg.version] })] }), _jsx(Text, { dimColor: true, children: `${model}${
|
|
8
|
+
const thinking = ` · thinking ${thinkingEffort}`;
|
|
9
|
+
return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkg.version] })] }), _jsx(Text, { dimColor: true, children: `${model}${thinking}` })] }), _jsx(Text, { dimColor: true, children: cwd })] }));
|
|
10
10
|
});
|
package/dist/tui/app.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
3
|
import { Box, render, Text, useApp, useInput, useWindowSize } from "ink";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { INITIAL_RUN_METRICS } from "@vietor/agent-core";
|
|
5
|
+
import { toErrorMessage } from "@vietor/agent-core/util";
|
|
6
|
+
import { executeSlashCommand, slashCommandInfos } from "../commands/dispatch.js";
|
|
6
7
|
import { Markdown } from "./components/markdown.js";
|
|
7
8
|
import { TimelineView } from "./timeline-view.js";
|
|
8
9
|
import { TodoView } from "./todo-view.js";
|
|
@@ -31,88 +32,89 @@ function useThrottledText(frameMs) {
|
|
|
31
32
|
};
|
|
32
33
|
return { text, append, reset };
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const { columns } = useWindowSize();
|
|
37
|
-
const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
38
|
-
const [runState, setRunState] = useState(createRunState);
|
|
35
|
+
function useSessionStream(session) {
|
|
36
|
+
const [runMetrics, setRunMetrics] = useState(INITIAL_RUN_METRICS);
|
|
39
37
|
const streaming = useThrottledText(STREAM_FRAME_MS);
|
|
40
|
-
const
|
|
41
|
-
const [
|
|
42
|
-
const allCmds = useMemo(() => commandSchemas(session), [session]);
|
|
43
|
-
const pendingQuestion = session.getPendingQuestion();
|
|
38
|
+
const thinking = useThrottledText(STREAM_FRAME_MS);
|
|
39
|
+
const [showThinking, setShowThinking] = useState(false);
|
|
44
40
|
useEffect(() => {
|
|
45
|
-
const unsub = session.
|
|
41
|
+
const unsub = session.onEvent((e) => {
|
|
46
42
|
switch (e.type) {
|
|
47
43
|
case "assistant_delta":
|
|
48
44
|
streaming.append(e.text);
|
|
49
45
|
break;
|
|
50
|
-
case "
|
|
51
|
-
|
|
46
|
+
case "thinking_delta":
|
|
47
|
+
thinking.append(e.text);
|
|
52
48
|
break;
|
|
53
|
-
case "
|
|
54
|
-
|
|
49
|
+
case "thinking_cleared":
|
|
50
|
+
thinking.reset();
|
|
55
51
|
break;
|
|
56
52
|
case "assistant":
|
|
57
|
-
streaming.reset();
|
|
58
|
-
break;
|
|
59
53
|
case "retry":
|
|
60
54
|
case "interrupted":
|
|
61
55
|
streaming.reset();
|
|
62
56
|
break;
|
|
63
|
-
case "
|
|
64
|
-
|
|
57
|
+
case "run_metrics":
|
|
58
|
+
setRunMetrics(e);
|
|
65
59
|
break;
|
|
66
60
|
}
|
|
67
61
|
});
|
|
68
62
|
return unsub;
|
|
69
|
-
}, []);
|
|
63
|
+
}, [session]);
|
|
64
|
+
return { runMetrics, streaming, thinking, showThinking, setShowThinking };
|
|
65
|
+
}
|
|
66
|
+
export function App({ session, persist }) {
|
|
67
|
+
const { exit } = useApp();
|
|
68
|
+
const { columns } = useWindowSize();
|
|
69
|
+
const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
70
|
+
const { runMetrics, streaming, thinking, showThinking, setShowThinking } = useSessionStream(session);
|
|
71
|
+
const allCmds = useMemo(() => slashCommandInfos(session), [session]);
|
|
72
|
+
const pendingQuestion = session.pendingQuestion;
|
|
70
73
|
useInput((_input, key) => {
|
|
71
74
|
if (pendingQuestion) {
|
|
72
75
|
if (key.ctrl && _input === "c")
|
|
73
76
|
session.abort();
|
|
74
77
|
return;
|
|
75
78
|
}
|
|
76
|
-
if (_input === "t" &&
|
|
77
|
-
|
|
79
|
+
if (_input === "t" && runMetrics.running && thinking.text) {
|
|
80
|
+
setShowThinking((v) => !v);
|
|
78
81
|
return;
|
|
79
82
|
}
|
|
80
83
|
if (key.escape) {
|
|
81
84
|
session.abort();
|
|
82
85
|
}
|
|
83
86
|
else if (key.ctrl && _input === "c") {
|
|
84
|
-
if (
|
|
87
|
+
if (runMetrics.running)
|
|
85
88
|
session.abort();
|
|
86
89
|
else
|
|
87
90
|
exit();
|
|
88
91
|
}
|
|
89
92
|
});
|
|
90
93
|
async function handleCommand(name) {
|
|
91
|
-
await
|
|
92
|
-
if (session.localStore.get("exitRequested") != null)
|
|
93
|
-
exit();
|
|
94
|
+
await executeSlashCommand(name, session, exit, persist);
|
|
94
95
|
}
|
|
95
96
|
async function handlePrompt(text) {
|
|
96
97
|
try {
|
|
97
|
-
await session.
|
|
98
|
+
await session.prompt(text);
|
|
99
|
+
persist();
|
|
98
100
|
}
|
|
99
101
|
catch (e) {
|
|
100
|
-
session.
|
|
102
|
+
session.addError(toErrorMessage(e));
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
let runningView = null;
|
|
104
|
-
if (
|
|
106
|
+
if (runMetrics.running) {
|
|
105
107
|
if (pendingQuestion) {
|
|
106
108
|
runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
|
|
107
109
|
}
|
|
108
110
|
else {
|
|
109
111
|
const spinnerLabel = streaming.text ? "replying" : "working";
|
|
110
|
-
runningView = (_jsxs(_Fragment, { children: [
|
|
112
|
+
runningView = (_jsxs(_Fragment, { children: [thinking.text ? renderThinking(thinking.text, showThinking) : null, streaming.text ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { children: streaming.text }) })) : null, _jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: spinnerLabel, thinkingElapsed: runMetrics.thinkingElapsed, replyElapsed: runMetrics.replyElapsed, inputTokens: runMetrics.inputTokens, outputTokens: runMetrics.outputTokens }) })] }));
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
|
-
return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model,
|
|
115
|
+
return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model, thinkingEffort: session.thinkingEffort }), view.timeline.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: view.timeline.map((entry, i) => (_jsx(TimelineView, { entry: entry }, i))) })) : null, runningView, view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, !runMetrics.running ? (_jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })) : null, _jsx(StatusBar, { contextTokens: session.contextTokens, contextLimit: session.contextLimit, running: runMetrics.running, questionPending: !!pendingQuestion, thinkingAvailable: !!thinking.text })] }));
|
|
114
116
|
}
|
|
115
|
-
function
|
|
117
|
+
function renderThinking(text, expanded) {
|
|
116
118
|
const lines = text.split("\n");
|
|
117
119
|
const firstLine = (lines[0] ?? "").slice(0, 80);
|
|
118
120
|
if (expanded) {
|
|
@@ -121,6 +123,6 @@ function renderReasoning(text, expanded) {
|
|
|
121
123
|
const extra = lines.length > 1 ? ` …+${lines.length - 1} lines` : "";
|
|
122
124
|
return (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["\u250A ", firstLine, extra, " (t to expand)"] }) }));
|
|
123
125
|
}
|
|
124
|
-
export function startApp(session) {
|
|
125
|
-
return render(_jsx(App, { session: session }), { exitOnCtrlC: false, incrementalRendering: true });
|
|
126
|
+
export function startApp(session, persist) {
|
|
127
|
+
return render(_jsx(App, { session: session, persist: persist }), { exitOnCtrlC: false, incrementalRendering: true });
|
|
126
128
|
}
|
package/dist/tui/spinner.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Text } from "ink";
|
|
4
|
-
import {
|
|
4
|
+
import { formatCompactNumber, formatDuration } from "@vietor/agent-core/util";
|
|
5
5
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
6
|
export function Spinner({ label, thinkingElapsed, replyElapsed, inputTokens, outputTokens, }) {
|
|
7
7
|
const [frame, setFrame] = useState(0);
|
|
@@ -9,5 +9,5 @@ export function Spinner({ label, thinkingElapsed, replyElapsed, inputTokens, out
|
|
|
9
9
|
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
|
|
10
10
|
return () => clearInterval(id);
|
|
11
11
|
}, []);
|
|
12
|
-
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 work ",
|
|
12
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 work ", formatDuration(thinkingElapsed), " \u00B7 reply ", formatDuration(replyElapsed), " \u00B7 \u2191", formatCompactNumber(inputTokens), " \u00B7 \u2193", formatCompactNumber(outputTokens)] })] }));
|
|
13
13
|
}
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import { Box, Text, useWindowSize } from "ink";
|
|
4
|
-
import { formatCompactNumber } from "@vietor/
|
|
5
|
-
export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit, running, questionPending,
|
|
4
|
+
import { formatCompactNumber } from "@vietor/agent-core/util";
|
|
5
|
+
export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit, running, questionPending, thinkingAvailable }) {
|
|
6
6
|
const { columns } = useWindowSize();
|
|
7
7
|
const pct = Math.min(100, contextLimit > 0 ? Math.round((contextTokens / contextLimit) * 100) : 0);
|
|
8
8
|
const filled = Math.round((pct / 100) * 10);
|
|
@@ -12,7 +12,7 @@ export const StatusBar = memo(function StatusBar({ contextTokens, contextLimit,
|
|
|
12
12
|
if (questionPending)
|
|
13
13
|
hints = "↑↓ select · enter confirm · esc skip";
|
|
14
14
|
else if (running)
|
|
15
|
-
hints =
|
|
15
|
+
hints = thinkingAvailable ? "esc stop · t thinking" : "esc stop";
|
|
16
16
|
else
|
|
17
17
|
hints = "/ commands";
|
|
18
18
|
return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "row", justifyContent: "space-between", borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: `context ${formatCompactNumber(contextTokens)} ` }), _jsx(Text, { color: ctxColor, children: `▕${bar}▏ ${pct}%` })] }), _jsx(Text, { dimColor: true, children: hints })] }));
|
|
@@ -10,7 +10,7 @@ export const TimelineView = memo(function TimelineView({ entry }) {
|
|
|
10
10
|
return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "magenta", children: "\u25C8 " }), _jsx(Text, { dimColor: true, children: "skill " }), _jsx(Text, { color: "magenta", bold: true, children: entry.name })] }) }));
|
|
11
11
|
case "assistant":
|
|
12
12
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Markdown, { children: entry.text }) }));
|
|
13
|
-
case "
|
|
13
|
+
case "tool":
|
|
14
14
|
return _jsx(ToolEntry, { entry: entry });
|
|
15
15
|
case "retry":
|
|
16
16
|
return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u21BB " }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.attempt, "/", entry.max, " \u00B7 ", entry.reason] })] }) }));
|
package/dist/tui/todo-view.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
|
-
import {} from "@vietor/
|
|
4
|
+
import {} from "@vietor/agent-core";
|
|
5
5
|
const GLYPHS = {
|
|
6
6
|
pending: "○",
|
|
7
|
-
|
|
7
|
+
inProgress: "◐",
|
|
8
8
|
completed: "✓",
|
|
9
9
|
};
|
|
10
10
|
const COLORS = {
|
|
11
11
|
pending: "gray",
|
|
12
|
-
|
|
12
|
+
inProgress: "yellow",
|
|
13
13
|
completed: "green",
|
|
14
14
|
};
|
|
15
15
|
export const TodoView = memo(function TodoView({ todos }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vietor/easy-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
|
-
"description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by
|
|
12
|
+
"description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by agent-core",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"react": "^19.2.8",
|
|
24
24
|
"string-width": "^8.2.2",
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@vietor/
|
|
26
|
+
"@vietor/agent-core": "0.7.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.20.1",
|