@vietor/easy-agent 0.4.2 → 0.4.4
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 +22 -7
- package/dist/cli.js +1 -1
- package/dist/cmds/builtin.js +1 -1
- package/dist/main.js +65 -6
- package/dist/tui/App.js +29 -17
- package/dist/tui/AppHeader.js +3 -2
- package/dist/tui/PromptOrCommandInput.js +1 -1
- package/dist/tui/StatusBar.js +8 -0
- package/dist/tui/TimelineList.js +9 -0
- package/dist/tui/{LogView.js → TimelineView.js} +3 -3
- package/dist/util/sessionStore.js +104 -0
- package/package.json +2 -2
- package/dist/tui/LogList.js +0 -9
package/README.md
CHANGED
|
@@ -27,15 +27,25 @@ Create `~/.easy-agent.json` in your home directory:
|
|
|
27
27
|
```json
|
|
28
28
|
{
|
|
29
29
|
"llm": {
|
|
30
|
-
"baseUrl": "https://api.
|
|
30
|
+
"baseUrl": "https://api.deepseek.com/v1",
|
|
31
31
|
"apiKey": "your-api-key",
|
|
32
|
-
"model": "
|
|
32
|
+
"model": "deepseek-v4-flash"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
`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.
|
|
38
38
|
|
|
39
|
+
### Proxy
|
|
40
|
+
|
|
41
|
+
The agent automatically routes HTTP requests through a proxy when the standard environment variables are set:
|
|
42
|
+
|
|
43
|
+
- `HTTPS_PROXY` / `https_proxy` — proxy URL for HTTPS (preferred)
|
|
44
|
+
- `HTTP_PROXY` / `http_proxy` — proxy URL for HTTP (fallback)
|
|
45
|
+
- `NO_PROXY` / `no_proxy` — comma-separated hosts/domains to bypass the proxy
|
|
46
|
+
|
|
47
|
+
No extra configuration is needed — just set the env vars before launching `easy-agent`.
|
|
48
|
+
|
|
39
49
|
### MCP servers (optional)
|
|
40
50
|
|
|
41
51
|
Add an `mcpServers` map to expose external tools through the Model Context Protocol. Each entry is either a local process (stdio) or a remote endpoint (Streamable HTTP):
|
|
@@ -63,17 +73,17 @@ A stdio server omits `type` (or sets `"stdio"`); only `command` is required, `ar
|
|
|
63
73
|
|
|
64
74
|
Easy Agent reads instructions files and appends them to the system prompt, so you can set persistent rules, conventions, or preferences. It looks in two places:
|
|
65
75
|
|
|
66
|
-
1. **Global** — your home directory, applied to every conversation. Checks `~/.
|
|
76
|
+
1. **Global** — your home directory, applied to every conversation. Checks `~/.easy-agent/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
|
|
67
77
|
2. **Project** — your current working directory, applied per-project. Checks `./AGENTS.md` first, then `./CLAUDE.md`; uses the first one found.
|
|
68
78
|
|
|
69
79
|
Both global and project files are loaded and concatenated into the system prompt if they exist.
|
|
70
80
|
|
|
71
81
|
### Skills (optional)
|
|
72
82
|
|
|
73
|
-
Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.
|
|
83
|
+
Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.easy-agent/skills/` (or `~/.claude/skills/`) with a `SKILL.md` file:
|
|
74
84
|
|
|
75
85
|
```
|
|
76
|
-
~/.
|
|
86
|
+
~/.easy-agent/skills/
|
|
77
87
|
deploy/
|
|
78
88
|
SKILL.md
|
|
79
89
|
review/
|
|
@@ -98,11 +108,16 @@ Only `name` is required; if omitted the directory name is used. The body is the
|
|
|
98
108
|
Launch the TUI:
|
|
99
109
|
|
|
100
110
|
```bash
|
|
101
|
-
easy-agent
|
|
111
|
+
easy-agent # start a new session
|
|
112
|
+
easy-agent --continue # resume the most recent session
|
|
113
|
+
easy-agent --resume <id> # resume a specific session by ID
|
|
114
|
+
easy-agent --resume # list all saved sessions for this directory
|
|
102
115
|
```
|
|
103
116
|
|
|
104
117
|
Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
|
|
105
118
|
|
|
119
|
+
The TUI automatically adapts to your terminal width. A status bar at the bottom shows the current context token count, with hints for keyboard shortcuts (<kbd>ESC</kbd> to abort a running task, <kbd>/</kbd> for commands).
|
|
120
|
+
|
|
106
121
|
### Built-in tools
|
|
107
122
|
|
|
108
123
|
- **Shell** — run shell commands using this platform's native syntax.
|
|
@@ -128,7 +143,7 @@ Type a prompt and press Enter. The agent streams its reply and calls tools as ne
|
|
|
128
143
|
## Build from source
|
|
129
144
|
|
|
130
145
|
```bash
|
|
131
|
-
git clone
|
|
146
|
+
git clone https://github.com/vietor/easy-agent.git
|
|
132
147
|
cd easy-agent
|
|
133
148
|
pnpm install
|
|
134
149
|
pnpm build # build core → CLI
|
package/dist/cli.js
CHANGED
package/dist/cmds/builtin.js
CHANGED
package/dist/main.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { loadConfig } from "./config.js";
|
|
4
|
-
import { tryLoadSkills, tryReadFileText,
|
|
5
|
+
import { tryLoadSkills, tryReadFileText, createSession, } from "@vietor/easy-agent-core";
|
|
5
6
|
import { builtinCommands } from "./cmds/builtin.js";
|
|
6
7
|
import { startApp } from "./tui/App.js";
|
|
8
|
+
import { FileSessionPersistence } from "./util/sessionStore.js";
|
|
7
9
|
const SYSTEM_PROMPT_BASE = [
|
|
8
10
|
"You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
|
|
9
11
|
`Output:
|
|
@@ -16,18 +18,71 @@ const SYSTEM_PROMPT_BASE = [
|
|
|
16
18
|
`Decision making:
|
|
17
19
|
- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous; when you have enough to proceed, act without asking.`,
|
|
18
20
|
].join("\n\n");
|
|
19
|
-
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
let mode = "new";
|
|
23
|
+
let id;
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a === "-c" || a === "--continue") {
|
|
27
|
+
mode = "continue";
|
|
28
|
+
}
|
|
29
|
+
else if (a === "-r" || a === "--resume") {
|
|
30
|
+
mode = "resume";
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next && !next.startsWith("-")) {
|
|
33
|
+
id = next;
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { mode, id };
|
|
39
|
+
}
|
|
40
|
+
async function listSessions(store) {
|
|
41
|
+
const sessions = await store.listSessions();
|
|
42
|
+
if (!sessions.length) {
|
|
43
|
+
console.log("No previous sessions found in this directory.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log("Previous sessions (most recent first):");
|
|
47
|
+
for (const s of sessions) {
|
|
48
|
+
const title = s.title ? ` ${s.title}` : "";
|
|
49
|
+
console.log(` ${s.id} ${new Date(s.updatedAt).toLocaleString()}${title}`);
|
|
50
|
+
}
|
|
51
|
+
console.log("\nResume with: easy-agent --resume <id>");
|
|
52
|
+
}
|
|
53
|
+
export async function main(argv = []) {
|
|
54
|
+
const { mode, id } = parseArgs(argv);
|
|
55
|
+
const store = new FileSessionPersistence(process.cwd());
|
|
56
|
+
if (mode === "resume" && !id) {
|
|
57
|
+
await listSessions(store);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
20
60
|
const config = loadConfig();
|
|
21
|
-
|
|
61
|
+
let sessionId;
|
|
62
|
+
let resume = false;
|
|
63
|
+
if (mode === "continue") {
|
|
64
|
+
const sessions = await store.listSessions();
|
|
65
|
+
if (sessions.length) {
|
|
66
|
+
sessionId = sessions[0].id;
|
|
67
|
+
resume = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (mode === "resume" && id) {
|
|
71
|
+
sessionId = id;
|
|
72
|
+
resume = true;
|
|
73
|
+
}
|
|
74
|
+
if (!sessionId)
|
|
75
|
+
sessionId = randomUUID();
|
|
76
|
+
const globalSkills = tryLoadSkills(join(homedir(), ".easy-agent", "skills"))
|
|
22
77
|
?? tryLoadSkills(join(homedir(), ".claude", "skills"));
|
|
23
|
-
const globalPrompt = tryReadFileText(join(homedir(), ".
|
|
78
|
+
const globalPrompt = tryReadFileText(join(homedir(), ".easy-agent", "AGENTS.md"))
|
|
24
79
|
?? tryReadFileText(join(homedir(), ".claude", "CLAUDE.md"));
|
|
25
80
|
const projectPrompt = tryReadFileText(join(process.cwd(), "AGENTS.md"))
|
|
26
81
|
?? tryReadFileText(join(process.cwd(), "CLAUDE.md"));
|
|
27
82
|
const systemPrompt = [SYSTEM_PROMPT_BASE, globalPrompt, projectPrompt]
|
|
28
83
|
.filter(Boolean)
|
|
29
84
|
.join("\n\n=================\n\n");
|
|
30
|
-
const session = await
|
|
85
|
+
const session = await createSession({
|
|
31
86
|
systemPrompt,
|
|
32
87
|
llmConfig: config.llm,
|
|
33
88
|
mcpServers: config.mcpServers,
|
|
@@ -36,8 +91,12 @@ export async function main() {
|
|
|
36
91
|
builtinTools: {
|
|
37
92
|
askUser: true,
|
|
38
93
|
todoWrite: true
|
|
39
|
-
}
|
|
94
|
+
},
|
|
95
|
+
sessionId,
|
|
96
|
+
persistence: store,
|
|
40
97
|
});
|
|
98
|
+
if (resume)
|
|
99
|
+
await session.restore();
|
|
41
100
|
const app = startApp(session);
|
|
42
101
|
await app.waitUntilExit().finally(() => session.dispose());
|
|
43
102
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -1,32 +1,42 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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
|
-
import { Box, render,
|
|
3
|
+
import { Box, render, useApp, useInput, useWindowSize } from "ink";
|
|
4
4
|
import { Markdown } from "./components/Markdown.js";
|
|
5
|
-
import {
|
|
5
|
+
import { TimelineList } from "./TimelineList.js";
|
|
6
6
|
import { TodoView } from "./TodoView.js";
|
|
7
7
|
import { AppHeader } from "./AppHeader.js";
|
|
8
8
|
import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
|
|
9
9
|
import { QuestionView } from "./QuestionView.js";
|
|
10
10
|
import { Spinner } from "./Spinner.js";
|
|
11
|
-
import {
|
|
11
|
+
import { StatusBar } from "./StatusBar.js";
|
|
12
12
|
const STREAM_FRAME_MS = 120;
|
|
13
13
|
export function App({ session }) {
|
|
14
14
|
const { exit } = useApp();
|
|
15
|
-
const
|
|
15
|
+
const { columns } = useWindowSize();
|
|
16
|
+
const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
16
17
|
const [runState, setRunState] = useState({ running: false, elapsed: 0, promptTokens: 0, completionTokens: 0 });
|
|
17
18
|
const [streamingText, setStreamingText] = useState("");
|
|
18
19
|
const streamingRef = useRef("");
|
|
19
20
|
const renderTimerRef = useRef(undefined);
|
|
20
21
|
const allCmds = useMemo(() => session.commandSchemas, [session]);
|
|
21
|
-
const pendingQuestion =
|
|
22
|
+
const pendingQuestion = session.getPendingQuestion();
|
|
22
23
|
useEffect(() => {
|
|
23
|
-
session.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const unsub = session.subscribeEvents((e) => {
|
|
25
|
+
switch (e.type) {
|
|
26
|
+
case "assistant_delta":
|
|
27
|
+
streamingRef.current += e.text;
|
|
28
|
+
scheduleStreamingRender();
|
|
29
|
+
break;
|
|
30
|
+
case "assistant":
|
|
31
|
+
streamingRef.current = "";
|
|
32
|
+
setStreamingText("");
|
|
33
|
+
break;
|
|
34
|
+
case "state":
|
|
35
|
+
setRunState(e);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
29
38
|
});
|
|
39
|
+
return unsub;
|
|
30
40
|
}, []);
|
|
31
41
|
const scheduleStreamingRender = () => {
|
|
32
42
|
if (renderTimerRef.current)
|
|
@@ -54,12 +64,14 @@ export function App({ session }) {
|
|
|
54
64
|
});
|
|
55
65
|
async function handleCommand(name, args) {
|
|
56
66
|
await session.executeCommand(name, args);
|
|
57
|
-
if (session.
|
|
67
|
+
if (session.localStore.get("exitRequested") != null)
|
|
58
68
|
exit();
|
|
59
69
|
}
|
|
60
70
|
async function handlePrompt(text) {
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
const [first, ...rest] = text.split(/\s+/);
|
|
72
|
+
if (first.startsWith("/") || session.isCommand(first)) {
|
|
73
|
+
const name = first.startsWith("/") ? first.slice(1) : first;
|
|
74
|
+
await handleCommand(name, rest.join(" "));
|
|
63
75
|
}
|
|
64
76
|
else {
|
|
65
77
|
await session.startPrompt(text);
|
|
@@ -71,13 +83,13 @@ export function App({ session }) {
|
|
|
71
83
|
runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
|
|
72
84
|
}
|
|
73
85
|
else if (streamingText) {
|
|
74
|
-
runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingText }) }));
|
|
86
|
+
runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: "gray", children: _jsx(Markdown, { color: "green", children: streamingText }) }));
|
|
75
87
|
}
|
|
76
88
|
else {
|
|
77
89
|
runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: runState.elapsed, promptTokens: runState.promptTokens, completionTokens: runState.completionTokens }) }));
|
|
78
90
|
}
|
|
79
91
|
}
|
|
80
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
92
|
+
return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, {}), _jsx(TimelineList, { session: session }), view.todos.length > 0 ? _jsx(TodoView, { todos: view.todos }) : null, runningView, !runState.running ? (_jsxs(_Fragment, { children: [_jsx(StatusBar, { contextTokens: session.contextTokens }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
|
|
81
93
|
}
|
|
82
94
|
export function startApp(session) {
|
|
83
95
|
process.stdout.write("[2J[H");
|
package/dist/tui/AppHeader.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo } from "react";
|
|
3
|
-
import { Box, Text } from "ink";
|
|
3
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
4
4
|
import { getPackageInfo } from "../util/package.js";
|
|
5
5
|
export const AppHeader = memo(function AppHeader() {
|
|
6
|
+
const { columns } = useWindowSize();
|
|
6
7
|
const pkginfo = getPackageInfo();
|
|
7
|
-
return (_jsxs(Box, {
|
|
8
|
+
return (_jsxs(Box, { width: columns, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }));
|
|
8
9
|
});
|
|
@@ -61,5 +61,5 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
|
|
|
61
61
|
return (_jsxs(Box, { flexDirection: "column", children: [showMenu && filtered.length > 0 ? (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: visibleItems.items.map((cmd, i) => {
|
|
62
62
|
const realIdx = visibleItems.start + i;
|
|
63
63
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: realIdx === selectedIndex ? "cyan" : undefined, children: [realIdx === selectedIndex ? "▸ " : " ", "/", cmd.name] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name));
|
|
64
|
-
}) })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "
|
|
64
|
+
}) })) : null, _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "> " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit })] })] }));
|
|
65
65
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { memo } from "react";
|
|
3
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
4
|
+
import { compactDisplay } from "../util/format.js";
|
|
5
|
+
export const StatusBar = memo(function StatusBar({ contextTokens }) {
|
|
6
|
+
const { columns } = useWindowSize();
|
|
7
|
+
return (_jsx(Box, { width: columns, paddingX: 1, flexDirection: "row", children: _jsxs(Text, { dimColor: true, children: ["Context: ", compactDisplay(contextTokens), " tokens \u00B7 ESC to stop \u00B7 / for commands"] }) }));
|
|
8
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useSyncExternalStore } from "react";
|
|
3
|
+
import { Box } from "ink";
|
|
4
|
+
import { TimelineView } from "./TimelineView.js";
|
|
5
|
+
export const TimelineList = memo(function TimelineList({ session }) {
|
|
6
|
+
const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
7
|
+
const entries = view.timeline;
|
|
8
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(TimelineView, { entry: entry }, i))) }));
|
|
9
|
+
});
|
|
@@ -11,7 +11,7 @@ function preview(isError, text) {
|
|
|
11
11
|
const byteCount = Buffer.byteLength(text, "utf-8");
|
|
12
12
|
return `Result: ${byteCount} bytes, ${lineCount} lines`;
|
|
13
13
|
}
|
|
14
|
-
export const
|
|
14
|
+
export const TimelineView = memo(function Entry({ entry }) {
|
|
15
15
|
switch (entry.kind) {
|
|
16
16
|
case "user":
|
|
17
17
|
return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "\u276F " }), entry.text] }) }));
|
|
@@ -20,11 +20,11 @@ export const LogView = memo(function Entry({ entry }) {
|
|
|
20
20
|
case "assistant":
|
|
21
21
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Markdown, { color: "green", children: entry.text }) }));
|
|
22
22
|
case "tool":
|
|
23
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CF " }), _jsx(Text, { color: "yellow", bold: true, children: entry.name }), entry.summary ? _jsxs(Text, { dimColor: true, children: [" ", entry.summary] }) : null] }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CF " }), _jsx(Text, { color: "yellow", bold: true, children: entry.name }), entry.summary ? _jsxs(Text, { dimColor: true, children: [" ", entry.summary] }) : null] }), entry.result !== null ? (_jsx(Text, { color: entry.isError ? "red" : "gray", children: ` ${preview(entry.isError ?? false, entry.result)}` })) : null] }));
|
|
24
24
|
case "retry":
|
|
25
25
|
return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u21BB " }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.attempt, "/", entry.max] })] }) }));
|
|
26
26
|
case "error":
|
|
27
|
-
return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: [_jsx(Text, { bold: true, children: "\u2717 " }), entry.text] }) }));
|
|
27
|
+
return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: [_jsx(Text, { bold: true, children: "\u2717 " }), entry.text] }) }));
|
|
28
28
|
case "interrupted":
|
|
29
29
|
return (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25FC " }), _jsx(Text, { dimColor: true, children: "interrupted" })] }) }));
|
|
30
30
|
case "question":
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function encodeCwd(cwd) {
|
|
5
|
+
return cwd.replace(/[\/\\:]/g, "-");
|
|
6
|
+
}
|
|
7
|
+
export class FileSessionPersistence {
|
|
8
|
+
cwd;
|
|
9
|
+
dir;
|
|
10
|
+
constructor(cwd) {
|
|
11
|
+
this.cwd = cwd;
|
|
12
|
+
this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
|
|
13
|
+
}
|
|
14
|
+
file(sessionId) {
|
|
15
|
+
return join(this.dir, `${sessionId}.jsonl`);
|
|
16
|
+
}
|
|
17
|
+
ensureDir() {
|
|
18
|
+
if (!existsSync(this.dir))
|
|
19
|
+
mkdirSync(this.dir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
async load(sessionId) {
|
|
22
|
+
const path = this.file(sessionId);
|
|
23
|
+
if (!existsSync(path))
|
|
24
|
+
return null;
|
|
25
|
+
const messages = [];
|
|
26
|
+
let todos = [];
|
|
27
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
28
|
+
if (!line.trim())
|
|
29
|
+
continue;
|
|
30
|
+
let rec;
|
|
31
|
+
try {
|
|
32
|
+
rec = JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const r = rec;
|
|
38
|
+
if (r.t === "m" && r.m)
|
|
39
|
+
messages.push(r.m);
|
|
40
|
+
else if (r.t === "todo" && r.todos)
|
|
41
|
+
todos = r.todos;
|
|
42
|
+
}
|
|
43
|
+
return { messages, todos };
|
|
44
|
+
}
|
|
45
|
+
async saveAll(sessionId, state) {
|
|
46
|
+
this.ensureDir();
|
|
47
|
+
const lines = state.messages
|
|
48
|
+
.map((m) => JSON.stringify({ t: "m", m }))
|
|
49
|
+
.concat([JSON.stringify({ t: "todo", todos: state.todos })]);
|
|
50
|
+
writeFileSync(this.file(sessionId), lines.join("\n") + "\n", "utf-8");
|
|
51
|
+
}
|
|
52
|
+
async listSessions() {
|
|
53
|
+
if (!existsSync(this.dir))
|
|
54
|
+
return [];
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const name of readdirSync(this.dir)) {
|
|
57
|
+
if (!name.endsWith(".jsonl"))
|
|
58
|
+
continue;
|
|
59
|
+
const id = name.slice(0, -6);
|
|
60
|
+
const path = join(this.dir, name);
|
|
61
|
+
try {
|
|
62
|
+
const stat = statSync(path);
|
|
63
|
+
out.push({
|
|
64
|
+
id,
|
|
65
|
+
title: this.readTitle(path),
|
|
66
|
+
createdAt: stat.birthtimeMs || stat.mtimeMs,
|
|
67
|
+
updatedAt: stat.mtimeMs,
|
|
68
|
+
cwd: this.cwd,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
74
|
+
}
|
|
75
|
+
async delete(sessionId) {
|
|
76
|
+
const path = this.file(sessionId);
|
|
77
|
+
if (existsSync(path))
|
|
78
|
+
unlinkSync(path);
|
|
79
|
+
}
|
|
80
|
+
readTitle(path) {
|
|
81
|
+
const first = this.readFirstUser(path);
|
|
82
|
+
if (!first)
|
|
83
|
+
return undefined;
|
|
84
|
+
const oneline = first.replace(/\s+/g, " ").trim();
|
|
85
|
+
return oneline.length > 60 ? oneline.slice(0, 60) + "…" : oneline;
|
|
86
|
+
}
|
|
87
|
+
readFirstUser(path) {
|
|
88
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
89
|
+
if (!line.trim())
|
|
90
|
+
continue;
|
|
91
|
+
let rec;
|
|
92
|
+
try {
|
|
93
|
+
rec = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const r = rec;
|
|
99
|
+
if (r.t === "m" && r.m && r.m.role === "user" && typeof r.m.content === "string")
|
|
100
|
+
return r.m.content;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vietor/easy-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"react": "^19.2.7",
|
|
23
23
|
"string-width": "^8.2.1",
|
|
24
24
|
"zod": "^4.4.3",
|
|
25
|
-
"@vietor/easy-agent-core": "0.4.
|
|
25
|
+
"@vietor/easy-agent-core": "0.4.4"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.0.0",
|
package/dist/tui/LogList.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useSyncExternalStore } from "react";
|
|
3
|
-
import { Box } from "ink";
|
|
4
|
-
import { LogView } from "./LogView.js";
|
|
5
|
-
export const LogList = memo(function LogList({ session }) {
|
|
6
|
-
useSyncExternalStore(session.subscribe, session.getSnapshot);
|
|
7
|
-
const entries = session.logEntries;
|
|
8
|
-
return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }));
|
|
9
|
-
});
|