@vietor/easy-agent 0.4.12 → 0.5.1

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 CHANGED
@@ -155,6 +155,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
155
155
  - **WebFetch** — fetch a URL as markdown or text.
156
156
  - **AskUser** — ask the user a question and wait for their answer.
157
157
  - **TodoWrite** — track multi-step work as a task list (pending / in_progress / completed), shown live as a panel in the TUI.
158
+ - **SubAgent** — delegate investigation (`explore`) or implementation-planning (`plan`) subtasks to a nested read-only sub-agent.
158
159
 
159
160
  ### Slash commands
160
161
 
@@ -163,6 +164,7 @@ A status bar at the bottom shows the context token usage with a progress bar and
163
164
  | `/mcp` | List linked MCP servers, their status, and exposed tools |
164
165
  | `/clear` | Reset the conversation |
165
166
  | `/compact` | Compress the conversation into a summary to free context |
167
+ | `/skill` | List available skills |
166
168
  | `/export` | Save the current conversation to `conversation-{timestamp}.jsonl` |
167
169
  | `/quit` or `/exit` | Leave the app |
168
170
 
@@ -0,0 +1,85 @@
1
+ import { writeFileSync } from "node:fs";
2
+ export const clearCommand = {
3
+ name: "clear",
4
+ description: "Clear the conversation and log",
5
+ async execute(ctx) {
6
+ ctx.session.clear();
7
+ },
8
+ };
9
+ export const mcpCommand = {
10
+ name: "mcp",
11
+ description: "List linked MCP servers",
12
+ async execute(ctx) {
13
+ const servers = ctx.session.mcpServers;
14
+ const text = servers.length
15
+ ? [
16
+ "MCP servers:",
17
+ ...servers.map((s) => {
18
+ const base = `❯ ${s.name} ⋅ ${s.type} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`;
19
+ return s.error ? `${base}\n ${s.error}` : base;
20
+ }),
21
+ ].join("\n")
22
+ : "No MCP servers linked.";
23
+ ctx.message(text);
24
+ },
25
+ };
26
+ export const compactCommand = {
27
+ name: "compact",
28
+ description: "Compact the agent context",
29
+ async execute(ctx) {
30
+ await ctx.session.compact();
31
+ },
32
+ };
33
+ export const skillCommand = {
34
+ name: "skill",
35
+ description: "List available skills",
36
+ async execute(ctx) {
37
+ const skills = ctx.session.skills;
38
+ const text = skills.length
39
+ ? [
40
+ "Skills:",
41
+ ...skills.map((s) => `❯ ${s.name} ∶ ${s.description || "no description"}`),
42
+ ].join("\n")
43
+ : "No skills available.";
44
+ ctx.message(text);
45
+ },
46
+ };
47
+ export const exitCommand = {
48
+ name: "exit",
49
+ description: "Exit the conversation",
50
+ async execute(ctx) {
51
+ ctx.session.localStore.set("exitRequested", true);
52
+ },
53
+ };
54
+ export const exportCommand = {
55
+ name: "export",
56
+ description: "Export the conversation to a JSONL file",
57
+ async execute(ctx) {
58
+ const d = new Date();
59
+ const pad = (n) => String(n).padStart(2, "0");
60
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
61
+ const file = `conversation-${ts}.jsonl`;
62
+ const lines = ctx.session
63
+ .export()
64
+ .map((m) => JSON.stringify(m))
65
+ .join("\n");
66
+ writeFileSync(file, lines + "\n", "utf-8");
67
+ ctx.message(`exported to ${file}`);
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);
75
+ },
76
+ };
77
+ export const builtinCommands = [
78
+ clearCommand,
79
+ mcpCommand,
80
+ compactCommand,
81
+ skillCommand,
82
+ exitCommand,
83
+ quitCommand,
84
+ exportCommand,
85
+ ];
@@ -0,0 +1,33 @@
1
+ import { builtinCommands } from "./builtin.js";
2
+ const commands = new Map(builtinCommands.map((c) => [c.name, c]));
3
+ export async function executeCommand(name, session) {
4
+ const cmd = commands.get(name);
5
+ if (cmd) {
6
+ const ctx = { session, message: session.timelineNotice, error: session.timelineError };
7
+ try {
8
+ await cmd.execute(ctx);
9
+ }
10
+ catch (e) {
11
+ ctx.error(e.message);
12
+ }
13
+ return;
14
+ }
15
+ try {
16
+ if (await session.runSkill(name))
17
+ return;
18
+ }
19
+ catch (e) {
20
+ session.timelineError(e.message);
21
+ return;
22
+ }
23
+ session.timelineError(`unknown command: /${name}`);
24
+ }
25
+ export function commandSchemas(session) {
26
+ const map = new Map();
27
+ for (const c of builtinCommands)
28
+ map.set(c.name, { name: c.name, description: c.description });
29
+ for (const s of session.skills)
30
+ if (!map.has(s.name))
31
+ map.set(s.name, { name: s.name, description: s.description ?? s.name });
32
+ return [...map.values()];
33
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/config.js CHANGED
@@ -3,30 +3,32 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  const CONFIG_FILE = ".easy-agent.json";
6
- const LLMConfig = z.object({
6
+ const LLMConfigSchema = z.object({
7
7
  baseUrl: z.string(),
8
8
  apiKey: z.string(),
9
9
  model: z.string(),
10
10
  reasoningEffort: z.enum(["high", "max"]).default("high"),
11
11
  wireApi: z.enum(["completions", "anthropic"]).default("completions"),
12
+ contextWindow: z.number().int().positive().default(1_000_000),
12
13
  });
13
- const StdioServerConfig = z.object({
14
- type: z.literal("stdio").optional(),
15
- command: z.string(),
16
- args: z.array(z.string()).optional(),
17
- env: z.record(z.string(), z.string()).optional(),
18
- enabled: z.boolean().optional(),
19
- });
20
- const RemoteServerConfig = z.object({
21
- type: z.enum(["http"]),
22
- url: z.string().url(),
23
- headers: z.record(z.string(), z.string()).optional(),
24
- enabled: z.boolean().optional(),
25
- });
26
- const MCPServerConfig = z.union([StdioServerConfig, RemoteServerConfig]);
27
- const Config = z.object({
28
- llm: LLMConfig,
29
- mcpServers: z.record(z.string(), MCPServerConfig).optional(),
14
+ const MCPServerConfigSchema = z.union([
15
+ z.object({
16
+ type: z.literal("stdio").optional(),
17
+ command: z.string(),
18
+ args: z.array(z.string()).optional(),
19
+ env: z.record(z.string(), z.string()).optional(),
20
+ enabled: z.boolean().optional(),
21
+ }),
22
+ z.object({
23
+ type: z.enum(["http"]),
24
+ url: z.string().url(),
25
+ headers: z.record(z.string(), z.string()).optional(),
26
+ enabled: z.boolean().optional(),
27
+ }),
28
+ ]);
29
+ const ConfigSchema = z.object({
30
+ llm: LLMConfigSchema,
31
+ mcpServers: z.record(z.string(), MCPServerConfigSchema).optional(),
30
32
  });
31
33
  export function loadConfig() {
32
34
  const path = join(homedir(), CONFIG_FILE);
@@ -44,7 +46,7 @@ export function loadConfig() {
44
46
  catch {
45
47
  throw new Error(`Invalid JSON in ~/${CONFIG_FILE}.`);
46
48
  }
47
- const result = Config.safeParse(parsed);
49
+ const result = ConfigSchema.safeParse(parsed);
48
50
  if (!result.success) {
49
51
  const issues = result.error.issues
50
52
  .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
package/dist/main.js CHANGED
@@ -1,13 +1,13 @@
1
+ #!/usr/bin/env node
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { Command } from "commander";
5
6
  import { loadConfig } from "./config.js";
6
7
  import { tryLoadSkills, tryReadFileText, createSession, SYSTEM_PROMPT_BOUNDARY } from "@vietor/easy-agent-core";
7
- import { builtinCommands } from "./cmds/builtin.js";
8
- import { startApp } from "./tui/App.js";
8
+ import { startApp } from "./tui/app.js";
9
9
  import { getPackageInfo } from "./util/package.js";
10
- import { FileSessionPersistence } from "./util/sessionStore.js";
10
+ import { FileSessionPersistence } from "./util/session-store.js";
11
11
  function buildSystemPromptBase(cwd) {
12
12
  return [
13
13
  "You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
@@ -18,8 +18,6 @@ function buildSystemPromptBase(cwd) {
18
18
  `Environment:
19
19
  - Platform: ${process.platform}
20
20
  - Working directory: ${cwd}`,
21
- `Decision making:
22
- - 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.`,
23
21
  `Working style:
24
22
  - Read relevant code/config before acting; do not guess implementation details or restate files from memory.
25
23
  - Make surgical changes and match existing style; do not refactor unrelated code.
@@ -84,20 +82,41 @@ export async function main(argv = []) {
84
82
  llmConfig: config.llm,
85
83
  mcpServers: config.mcpServers,
86
84
  skills: globalSkills,
87
- commands: builtinCommands,
88
85
  builtinTools: {
89
86
  askUser: true,
90
87
  todoWrite: true,
88
+ skill: true,
89
+ subAgent: true,
91
90
  },
92
91
  cwd: cwd,
93
92
  sessionId,
94
93
  persistence: store,
95
94
  });
96
- if (resume)
97
- await session.restore();
95
+ if (resume) {
96
+ const restored = await session.restore();
97
+ if (!restored) {
98
+ console.error(`Session not found: ${sessionId}`);
99
+ process.exit(1);
100
+ }
101
+ }
102
+ let shuttingDown = false;
103
+ const shutdown = () => {
104
+ if (shuttingDown)
105
+ process.exit(1);
106
+ shuttingDown = true;
107
+ session.dispose();
108
+ session.flush().catch(() => { }).finally(() => process.exit(0));
109
+ };
110
+ process.once("SIGINT", shutdown);
111
+ process.once("SIGTERM", shutdown);
98
112
  const app = startApp(session);
99
- await app.waitUntilExit().finally(() => {
113
+ await app.waitUntilExit().finally(async () => {
100
114
  session.dispose();
115
+ await session.flush();
101
116
  console.log(["Resume this session with:", `easy-agent --resume ${sessionId}`].join("\n"));
102
117
  });
103
118
  }
119
+ main(process.argv.slice(2)).catch((e) => {
120
+ console.error(e);
121
+ process.exit(1);
122
+ });
@@ -4,7 +4,7 @@ import { Box, Text, useWindowSize } from "ink";
4
4
  import { getPackageInfo } from "../util/package.js";
5
5
  export const AppHeader = memo(function AppHeader({ cwd, model, reasoningEffort }) {
6
6
  const { columns } = useWindowSize();
7
- const pkginfo = getPackageInfo();
7
+ const pkg = getPackageInfo();
8
8
  const reasoning = ` · reasoning ${reasoningEffort}`;
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", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: `${model}${reasoning}` })] }), _jsx(Text, { dimColor: true, children: cwd })] }));
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}${reasoning}` })] }), _jsx(Text, { dimColor: true, children: cwd })] }));
10
10
  });
@@ -1,47 +1,62 @@
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 { Markdown } from "./components/Markdown.js";
5
- import { TimelineView } from "./TimelineView.js";
6
- import { TodoView } from "./TodoView.js";
7
- import { AppHeader } from "./AppHeader.js";
8
- import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
9
- import { QuestionView } from "./QuestionView.js";
10
- import { Spinner } from "./Spinner.js";
11
- import { StatusBar } from "./StatusBar.js";
4
+ import { executeCommand, commandSchemas } from "../commands/dispatch.js";
5
+ import { Markdown } from "./components/markdown.js";
6
+ import { TimelineView } from "./timeline-view.js";
7
+ import { TodoView } from "./todo-view.js";
8
+ import { AppHeader } from "./app-header.js";
9
+ import { PromptOrCommandInput } from "./prompt-or-command-input.js";
10
+ import { QuestionView } from "./question-view.js";
11
+ import { Spinner } from "./spinner.js";
12
+ import { StatusBar } from "./status-bar.js";
12
13
  const STREAM_FRAME_MS = 120;
14
+ function useThrottledText(frameMs) {
15
+ const [text, setText] = useState("");
16
+ const bufRef = useRef("");
17
+ const timerRef = useRef(undefined);
18
+ const append = (t) => {
19
+ bufRef.current += t;
20
+ if (timerRef.current === undefined) {
21
+ timerRef.current = setTimeout(() => {
22
+ timerRef.current = undefined;
23
+ setText(bufRef.current);
24
+ }, frameMs);
25
+ }
26
+ };
27
+ const reset = () => {
28
+ bufRef.current = "";
29
+ setText("");
30
+ };
31
+ return { text, append, reset };
32
+ }
13
33
  export function App({ session }) {
14
34
  const { exit } = useApp();
15
35
  const { columns } = useWindowSize();
16
36
  const view = useSyncExternalStore(session.subscribe, session.getSnapshot);
17
- const [runState, setRunState] = useState({ running: false, elapsed: 0, thinkingElapsed: 0, replyElapsed: 0, inputTokens: 0, outputTokens: 0 });
18
- const [streamingText, setStreamingText] = useState("");
19
- const streamingRef = useRef("");
20
- const renderTimerRef = useRef(undefined);
21
- const [reasoningText, setReasoningText] = useState("");
22
- const reasoningRef = useRef("");
23
- const reasoningRenderTimerRef = useRef(undefined);
37
+ const [runState, setRunState] = useState(() => ({ running: false, elapsed: 0, thinkingElapsed: 0, replyElapsed: 0, inputTokens: 0, outputTokens: 0 }));
38
+ const streaming = useThrottledText(STREAM_FRAME_MS);
39
+ const reasoning = useThrottledText(STREAM_FRAME_MS);
24
40
  const [showReasoning, setShowReasoning] = useState(false);
25
- const allCmds = useMemo(() => session.commandSchemas, [session]);
41
+ const allCmds = useMemo(() => commandSchemas(session), [session]);
26
42
  const pendingQuestion = session.getPendingQuestion();
27
43
  useEffect(() => {
28
44
  const unsub = session.subscribeEvents((e) => {
29
45
  switch (e.type) {
30
46
  case "assistant_delta":
31
- streamingRef.current += e.text;
32
- scheduleStreamingRender();
47
+ streaming.append(e.text);
33
48
  break;
34
49
  case "reasoning_delta":
35
- reasoningRef.current += e.text;
36
- scheduleReasoningRender();
50
+ reasoning.append(e.text);
37
51
  break;
38
52
  case "reasoning_clear":
39
- reasoningRef.current = "";
40
- setReasoningText("");
53
+ reasoning.reset();
41
54
  break;
42
55
  case "assistant":
43
- streamingRef.current = "";
44
- setStreamingText("");
56
+ streaming.reset();
57
+ break;
58
+ case "retry":
59
+ streaming.reset();
45
60
  break;
46
61
  case "state":
47
62
  setRunState(e);
@@ -50,29 +65,13 @@ export function App({ session }) {
50
65
  });
51
66
  return unsub;
52
67
  }, []);
53
- const scheduleStreamingRender = () => {
54
- if (renderTimerRef.current)
55
- return;
56
- renderTimerRef.current = setTimeout(() => {
57
- renderTimerRef.current = undefined;
58
- setStreamingText(streamingRef.current);
59
- }, STREAM_FRAME_MS);
60
- };
61
- const scheduleReasoningRender = () => {
62
- if (reasoningRenderTimerRef.current)
63
- return;
64
- reasoningRenderTimerRef.current = setTimeout(() => {
65
- reasoningRenderTimerRef.current = undefined;
66
- setReasoningText(reasoningRef.current);
67
- }, STREAM_FRAME_MS);
68
- };
69
68
  useInput((_input, key) => {
70
69
  if (pendingQuestion) {
71
70
  if (key.ctrl && _input === "c")
72
71
  session.abort();
73
72
  return;
74
73
  }
75
- if (_input === "t" && runState.running && reasoningText) {
74
+ if (_input === "t" && runState.running && reasoning.text) {
76
75
  setShowReasoning((v) => !v);
77
76
  return;
78
77
  }
@@ -86,13 +85,18 @@ export function App({ session }) {
86
85
  exit();
87
86
  }
88
87
  });
89
- async function handleCommand(name, args) {
90
- await session.executeCommand(name, args);
88
+ async function handleCommand(name) {
89
+ await executeCommand(name, session);
91
90
  if (session.localStore.get("exitRequested") != null)
92
91
  exit();
93
92
  }
94
93
  async function handlePrompt(text) {
95
- await session.startPrompt(text);
94
+ try {
95
+ await session.startPrompt(text);
96
+ }
97
+ catch (e) {
98
+ session.timelineError(e.message);
99
+ }
96
100
  }
97
101
  let runningView = null;
98
102
  if (runState.running) {
@@ -100,11 +104,11 @@ export function App({ session }) {
100
104
  runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
101
105
  }
102
106
  else {
103
- const spinnerLabel = streamingText ? "replying" : "working";
104
- runningView = (_jsxs(_Fragment, { children: [reasoningText ? renderReasoning(reasoningText, showReasoning) : null, streamingText ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: "gray", children: _jsx(Markdown, { children: streamingText }) })) : null, _jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: spinnerLabel, thinkingElapsed: runState.thinkingElapsed, replyElapsed: runState.replyElapsed, inputTokens: runState.inputTokens, outputTokens: runState.outputTokens }) })] }));
107
+ const spinnerLabel = streaming.text ? "replying" : "working";
108
+ runningView = (_jsxs(_Fragment, { children: [reasoning.text ? renderReasoning(reasoning.text, showReasoning) : null, streaming.text ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, borderStyle: "single", borderTop: false, borderRight: false, borderBottom: false, borderColor: "gray", children: _jsx(Markdown, { children: streaming.text }) })) : null, _jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: spinnerLabel, thinkingElapsed: runState.thinkingElapsed, replyElapsed: runState.replyElapsed, inputTokens: runState.inputTokens, outputTokens: runState.outputTokens }) })] }));
105
109
  }
106
110
  }
107
- return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model, reasoningEffort: session.reasoningEffort }), 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, !runState.running ? (_jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })) : null, _jsx(StatusBar, { contextTokens: session.contextTokens, contextLimit: session.compactThreshold, running: runState.running, questionPending: !!pendingQuestion, reasoningAvailable: !!reasoningText })] }));
111
+ return (_jsxs(Box, { width: columns, flexDirection: "column", children: [_jsx(AppHeader, { cwd: session.cwd, model: session.model, reasoningEffort: session.reasoningEffort }), 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, !runState.running ? (_jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })) : null, _jsx(StatusBar, { contextTokens: session.contextTokens, contextLimit: session.compactThreshold, running: runState.running, questionPending: !!pendingQuestion, reasoningAvailable: !!reasoning.text })] }));
108
112
  }
109
113
  function renderReasoning(text, expanded) {
110
114
  const lines = text.split("\n");
@@ -37,36 +37,52 @@ function renderBlock(token) {
37
37
  return null;
38
38
  }
39
39
  }
40
- function renderInline(tokens) {
40
+ function renderInline(tokens, mode = "react") {
41
41
  if (!tokens || tokens.length === 0)
42
- return null;
43
- return tokens.map((token, i) => {
42
+ return mode === "react" ? null : "";
43
+ const inner = (t) => renderInline(t, mode);
44
+ const out = tokens.map((token, i) => {
44
45
  const tok = token;
45
46
  switch (tok.type) {
46
- case "text":
47
- return _jsx(Text, { children: tok.tokens ? renderInline(tok.tokens) : tok.text }, i);
48
- case "strong":
49
- return _jsx(Text, { bold: true, children: renderInline(tok.tokens) }, i);
50
- case "em":
51
- return _jsx(Text, { italic: true, children: renderInline(tok.tokens) }, i);
47
+ case "text": {
48
+ const content = tok.tokens ? inner(tok.tokens) : tok.text;
49
+ return mode === "react" ? _jsx(Text, { children: content }, i) : content;
50
+ }
51
+ case "strong": {
52
+ const content = inner(tok.tokens);
53
+ return mode === "react" ? _jsx(Text, { bold: true, children: content }, i) : content;
54
+ }
55
+ case "em": {
56
+ const content = inner(tok.tokens);
57
+ return mode === "react" ? _jsx(Text, { italic: true, children: content }, i) : content;
58
+ }
52
59
  case "codespan":
53
- return _jsx(Text, { color: "cyan", children: tok.text }, i);
54
- case "del":
55
- return _jsx(Text, { strikethrough: true, children: renderInline(tok.tokens) }, i);
56
- case "link":
57
- return _jsx(Text, { color: "blue", underline: true, children: renderInline(tok.tokens) }, i);
58
- case "image":
59
- return _jsx(Text, { color: "magenta", children: tok.text || tok.href }, i);
60
+ return mode === "react" ? _jsx(Text, { color: "cyan", children: tok.text }, i) : tok.text;
61
+ case "del": {
62
+ const content = inner(tok.tokens);
63
+ return mode === "react" ? _jsx(Text, { strikethrough: true, children: content }, i) : content;
64
+ }
65
+ case "link": {
66
+ const content = inner(tok.tokens);
67
+ return mode === "react" ? _jsx(Text, { color: "blue", underline: true, children: content }, i) : content;
68
+ }
69
+ case "image": {
70
+ const content = tok.text || tok.href;
71
+ return mode === "react" ? _jsx(Text, { color: "magenta", children: content }, i) : content;
72
+ }
60
73
  case "br":
61
- return _jsx(Text, { children: "\n" }, i);
74
+ return mode === "react" ? _jsx(Text, { children: "\n" }, i) : "";
62
75
  case "escape":
63
- return _jsx(Text, { children: tok.text }, i);
76
+ return mode === "react" ? _jsx(Text, { children: tok.text }, i) : tok.text;
64
77
  case "html":
65
- return _jsx(Text, { dimColor: true, children: tok.text }, i);
78
+ return mode === "react" ? _jsx(Text, { dimColor: true, children: tok.text }, i) : tok.text;
66
79
  default:
67
- return _jsx(Text, { children: tok.text ?? "" }, i);
80
+ return mode === "react"
81
+ ? _jsx(Text, { children: tok.text ?? "" }, i)
82
+ : tok.text ?? "";
68
83
  }
69
84
  });
85
+ return mode === "react" ? out : out.join("");
70
86
  }
71
87
  function renderList(token) {
72
88
  const markers = token.items.map((item, i) => {
@@ -87,14 +103,14 @@ function renderTable(token) {
87
103
  const natural = new Array(cols).fill(0);
88
104
  for (const row of [token.header, ...token.rows]) {
89
105
  for (let c = 0; c < cols; c++) {
90
- const w = stringWidth(tokensToText(row[c].tokens));
106
+ const w = stringWidth(renderInline(row[c].tokens, "text"));
91
107
  if (w > natural[c])
92
108
  natural[c] = w;
93
109
  }
94
110
  }
95
111
  const widths = fitWidths(natural, available);
96
112
  const renderRow = (row, bold = false) => {
97
- const wrapped = row.map((tc, c) => wrapText(tokensToText(tc.tokens), widths[c]));
113
+ const wrapped = row.map((tc, c) => wrapText(renderInline(tc.tokens, "text"), widths[c]));
98
114
  const height = Math.max(1, ...wrapped.map(lines => lines.length));
99
115
  return Array.from({ length: height }, (_, r) => (_jsx(Text, { children: [
100
116
  ...row.flatMap((_, c) => [
@@ -107,36 +123,6 @@ function renderTable(token) {
107
123
  const border = (l, m, r) => l + widths.map(w => "─".repeat(w + 2)).join(m) + r;
108
124
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: border("┌", "┬", "┐") }), _jsx(Fragment, { children: renderRow(token.header, true) }), _jsx(Text, { dimColor: true, children: border("├", "┼", "┤") }), token.rows.map((row, i) => (_jsx(Fragment, { children: renderRow(row) }, i))), _jsx(Text, { dimColor: true, children: border("└", "┴", "┘") })] }));
109
125
  }
110
- function tokensToText(tokens) {
111
- if (!tokens)
112
- return "";
113
- return tokens.map(tokenToText).join("");
114
- }
115
- function tokenToText(token) {
116
- const t = token;
117
- switch (t.type) {
118
- case "text":
119
- return t.tokens ? tokensToText(t.tokens) : t.text;
120
- case "strong":
121
- case "em":
122
- case "del":
123
- return tokensToText(t.tokens);
124
- case "codespan":
125
- return t.text;
126
- case "link":
127
- return tokensToText(t.tokens);
128
- case "image":
129
- return t.text || t.href;
130
- case "br":
131
- return "";
132
- case "escape":
133
- return t.text;
134
- case "html":
135
- return t.text;
136
- default:
137
- return t.text ?? "";
138
- }
139
- }
140
126
  function padAlign(text, width, align) {
141
127
  const pad = Math.max(0, width - stringWidth(text));
142
128
  if (align === "right")
@@ -45,20 +45,19 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
45
45
  if (!text)
46
46
  return;
47
47
  if (text.startsWith("/")) {
48
- const [name, ...rest] = text.slice(1).split(/\s+/);
49
- const args = rest.join(" ");
48
+ const name = text.slice(1).split(/\s+/)[0];
50
49
  if (filtered.length > 0) {
51
50
  const cmd = filtered[selectedIndex];
52
- onCommand(cmd ? cmd.name : name, args);
51
+ onCommand(cmd ? cmd.name : name);
53
52
  }
54
53
  else {
55
- onCommand(name, args);
54
+ onCommand(name);
56
55
  }
57
56
  return;
58
57
  }
59
- const [first, ...rest] = text.split(/\s+/);
58
+ const first = text.split(/\s+/)[0];
60
59
  if (commands.some((c) => c.name === first)) {
61
- onCommand(first, rest.join(" "));
60
+ onCommand(first);
62
61
  return;
63
62
  }
64
63
  onPrompt(text);
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
- import { Markdown } from "./components/Markdown.js";
4
+ import { Markdown } from "./components/markdown.js";
5
5
  export const TimelineView = memo(function TimelineView({ entry }) {
6
6
  switch (entry.kind) {
7
7
  case "user":
@@ -22,7 +22,7 @@ export const TimelineView = memo(function TimelineView({ entry }) {
22
22
  if (entry.answer === null)
23
23
  return null;
24
24
  return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: `? ${entry.text}` }), _jsx(Text, { dimColor: true, children: ` ⎿ ${entry.answer || "(skipped)"}` })] }));
25
- case "system":
25
+ case "notice":
26
26
  return (_jsx(Box, { children: _jsx(Text, { color: "blue", children: entry.text }) }));
27
27
  }
28
28
  });
@@ -1,7 +1,7 @@
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
- const ICONS = {
4
+ const TODO_STATUS_GLYPHS = {
5
5
  pending: "○",
6
6
  in_progress: "◐",
7
7
  completed: "✓",
@@ -14,5 +14,5 @@ const COLORS = {
14
14
  export const TodoView = memo(function TodoView({ todos }) {
15
15
  const done = todos.filter((t) => t.status === "completed").length;
16
16
  const headerColor = done === todos.length ? "green" : "cyan";
17
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [_jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" }), _jsx(Text, { color: headerColor, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${ICONS[t.status]} ${t.content}` }, i)))] }));
17
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [_jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" }), _jsx(Text, { color: headerColor, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${TODO_STATUS_GLYPHS[t.status]} ${t.content}` }, i)))] }));
18
18
  });
@@ -1,12 +1,28 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { appendFile, writeFile } from "node:fs/promises";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
5
+ import { ellipsisText } from "@vietor/easy-agent-core";
6
+ const MAX_PREVIEW_LEN = 75;
4
7
  function encodeCwd(cwd) {
5
8
  return cwd.replace(/[\/\\:]/g, "-");
6
9
  }
10
+ function parseJsonLines(text) {
11
+ const out = [];
12
+ for (const line of text.split("\n")) {
13
+ if (!line.trim())
14
+ continue;
15
+ try {
16
+ out.push(JSON.parse(line));
17
+ }
18
+ catch { /* skip malformed lines */ }
19
+ }
20
+ return out;
21
+ }
7
22
  export class FileSessionPersistence {
8
23
  cwd;
9
24
  dir;
25
+ writtenCounts = new Map();
10
26
  constructor(cwd) {
11
27
  this.cwd = cwd;
12
28
  this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
@@ -24,30 +40,31 @@ export class FileSessionPersistence {
24
40
  return null;
25
41
  const messages = [];
26
42
  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)
43
+ for (const r of parseJsonLines(readFileSync(path, "utf-8"))) {
44
+ if (r.t === "message" && r.m)
39
45
  messages.push(r.m);
40
46
  else if (r.t === "todo" && r.todos)
41
47
  todos = r.todos;
42
48
  }
49
+ this.writtenCounts.set(sessionId, messages.length);
43
50
  return { messages, todos };
44
51
  }
45
52
  async saveAll(sessionId, state) {
46
53
  this.ensureDir();
54
+ const written = this.writtenCounts.get(sessionId) ?? 0;
55
+ const shrink = state.messages.length < written;
47
56
  const lines = state.messages
48
- .map((m) => JSON.stringify({ t: "m", m }))
57
+ .slice(shrink ? 0 : written)
58
+ .map((m) => JSON.stringify({ t: "message", m }))
49
59
  .concat([JSON.stringify({ t: "todo", todos: state.todos })]);
50
- writeFileSync(this.file(sessionId), lines.join("\n") + "\n", "utf-8");
60
+ const path = this.file(sessionId);
61
+ if (shrink) {
62
+ await writeFile(path, lines.join("\n") + "\n", "utf-8");
63
+ }
64
+ else {
65
+ await appendFile(path, lines.join("\n") + "\n", "utf-8");
66
+ }
67
+ this.writtenCounts.set(sessionId, state.messages.length);
51
68
  }
52
69
  async listSessions() {
53
70
  if (!existsSync(this.dir))
@@ -72,31 +89,24 @@ export class FileSessionPersistence {
72
89
  }
73
90
  return out.sort((a, b) => b.updatedAt - a.updatedAt);
74
91
  }
75
- async delete(sessionId) {
76
- const path = this.file(sessionId);
77
- if (existsSync(path))
78
- unlinkSync(path);
79
- }
80
92
  readTitle(path) {
81
93
  const first = this.readFirstUser(path);
82
94
  if (!first)
83
95
  return undefined;
84
- const oneline = first.replace(/\s+/g, " ").trim();
85
- return oneline.length > 60 ? oneline.slice(0, 60) + "…" : oneline;
96
+ return ellipsisText(first, MAX_PREVIEW_LEN);
86
97
  }
87
98
  readFirstUser(path) {
88
99
  for (const line of readFileSync(path, "utf-8").split("\n")) {
89
100
  if (!line.trim())
90
101
  continue;
91
- let rec;
102
+ let r;
92
103
  try {
93
- rec = JSON.parse(line);
104
+ r = JSON.parse(line);
94
105
  }
95
106
  catch {
96
107
  continue;
97
108
  }
98
- const r = rec;
99
- if (r.t === "m" && r.m && r.m.role === "user" && typeof r.m.content === "string")
109
+ if (r.t === "message" && r.m && r.m.role === "user" && typeof r.m.content === "string")
100
110
  return r.m.content;
101
111
  }
102
112
  return undefined;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.4.12",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
- "main": "dist/cli.js",
5
+ "main": "dist/main.js",
6
6
  "bin": {
7
- "easy-agent": "dist/cli.js"
7
+ "easy-agent": "dist/main.js"
8
8
  },
9
9
  "files": [
10
10
  "dist"
@@ -16,19 +16,19 @@
16
16
  "url": "https://github.com/vietor/easy-agent.git"
17
17
  },
18
18
  "dependencies": {
19
- "commander": "^13.0.0",
20
- "ink": "^7.1.0",
19
+ "commander": "^13.1.0",
20
+ "ink": "^7.1.1",
21
21
  "ink-text-input": "^6.0.0",
22
- "marked": "^18.0.5",
23
- "react": "^19.2.7",
24
- "string-width": "^8.2.1",
22
+ "marked": "^18.0.7",
23
+ "react": "^19.2.8",
24
+ "string-width": "^8.2.2",
25
25
  "zod": "^4.4.3",
26
- "@vietor/easy-agent-core": "0.4.12"
26
+ "@vietor/easy-agent-core": "0.5.1"
27
27
  },
28
28
  "devDependencies": {
29
- "@types/node": "^22.0.0",
29
+ "@types/node": "^22.20.1",
30
30
  "@types/react": "^19.2.17",
31
- "tsx": "^4.23.0",
31
+ "tsx": "^4.23.1",
32
32
  "typescript": "^6.0.3"
33
33
  },
34
34
  "engines": {
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "build": "tsc",
45
- "dev": "tsx src/cli.ts",
46
- "start": "node dist/cli.js"
45
+ "dev": "tsx src/main.ts",
46
+ "start": "node dist/main.js"
47
47
  }
48
48
  }
package/dist/cli.js DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env node
2
- import { main } from "./main.js";
3
- main(process.argv.slice(2)).catch((e) => {
4
- console.error(e);
5
- process.exit(1);
6
- });
@@ -1,29 +0,0 @@
1
- import { writeFileSync } from "node:fs";
2
- export const exitCommand = {
3
- name: "exit",
4
- description: "Exit the conversation",
5
- async execute(ctx) {
6
- ctx.session.localStore.set("exitRequested", true);
7
- },
8
- };
9
- export const exportCommand = {
10
- name: "export",
11
- description: "Export the conversation to a JSONL file",
12
- async execute(ctx) {
13
- const d = new Date();
14
- const pad = (n) => String(n).padStart(2, "0");
15
- const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
16
- const file = `conversation-${ts}.jsonl`;
17
- const lines = ctx.session
18
- .export()
19
- .map((m) => JSON.stringify(m))
20
- .join("\n");
21
- writeFileSync(file, lines + "\n", "utf-8");
22
- ctx.message(`exported to ${file}`);
23
- },
24
- };
25
- export const builtinCommands = [
26
- exitCommand,
27
- { ...exitCommand, name: "quit" },
28
- exportCommand,
29
- ];
File without changes
File without changes
File without changes