@vietor/easy-agent 0.4.13 → 0.5.2

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`), implementation-planning (`plan`), or full-tool execution (`generic`) subtasks to a nested 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,34 @@
1
+ import { errorMessage } from "@vietor/easy-agent-core";
2
+ import { builtinCommands } from "./builtin.js";
3
+ const commands = new Map(builtinCommands.map((c) => [c.name, c]));
4
+ export async function executeCommand(name, session) {
5
+ const cmd = commands.get(name);
6
+ if (cmd) {
7
+ const ctx = { session, message: session.timelineNotice, error: session.timelineError };
8
+ try {
9
+ await cmd.execute(ctx);
10
+ }
11
+ catch (e) {
12
+ ctx.error(errorMessage(e));
13
+ }
14
+ return;
15
+ }
16
+ try {
17
+ if (await session.runSkill(name))
18
+ return;
19
+ }
20
+ catch (e) {
21
+ session.timelineError(errorMessage(e));
22
+ return;
23
+ }
24
+ session.timelineError(`unknown command: /${name}`);
25
+ }
26
+ export function commandSchemas(session) {
27
+ const map = new Map();
28
+ for (const c of builtinCommands)
29
+ map.set(c.name, { name: c.name, description: c.description });
30
+ for (const s of session.skills)
31
+ if (!map.has(s.name))
32
+ map.set(s.name, { name: s.name, description: s.description ?? s.name });
33
+ return [...map.values()];
34
+ }
@@ -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,42 @@ 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);
112
+ process.stdout.write("");
98
113
  const app = startApp(session);
99
- await app.waitUntilExit().finally(() => {
114
+ await app.waitUntilExit().finally(async () => {
100
115
  session.dispose();
116
+ await session.flush();
101
117
  console.log(["Resume this session with:", `easy-agent --resume ${sessionId}`].join("\n"));
102
118
  });
103
119
  }
120
+ main(process.argv.slice(2)).catch((e) => {
121
+ console.error(e);
122
+ process.exit(1);
123
+ });
@@ -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,64 @@
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 { createSessionRunState, errorMessage } from "@vietor/easy-agent-core";
5
+ import { executeCommand, commandSchemas } from "../commands/dispatch.js";
6
+ import { Markdown } from "./components/markdown.js";
7
+ import { TimelineView } from "./timeline-view.js";
8
+ import { TodoView } from "./todo-view.js";
9
+ import { AppHeader } from "./app-header.js";
10
+ import { PromptOrCommandInput } from "./prompt-or-command-input.js";
11
+ import { QuestionView } from "./question-view.js";
12
+ import { Spinner } from "./spinner.js";
13
+ import { StatusBar } from "./status-bar.js";
12
14
  const STREAM_FRAME_MS = 120;
15
+ function useThrottledText(frameMs) {
16
+ const [text, setText] = useState("");
17
+ const bufRef = useRef("");
18
+ const timerRef = useRef(undefined);
19
+ const append = (t) => {
20
+ bufRef.current += t;
21
+ if (timerRef.current === undefined) {
22
+ timerRef.current = setTimeout(() => {
23
+ timerRef.current = undefined;
24
+ setText(bufRef.current);
25
+ }, frameMs);
26
+ }
27
+ };
28
+ const reset = () => {
29
+ bufRef.current = "";
30
+ setText("");
31
+ };
32
+ return { text, append, reset };
33
+ }
13
34
  export function App({ session }) {
14
35
  const { exit } = useApp();
15
36
  const { columns } = useWindowSize();
16
37
  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);
38
+ const [runState, setRunState] = useState(createSessionRunState);
39
+ const streaming = useThrottledText(STREAM_FRAME_MS);
40
+ const reasoning = useThrottledText(STREAM_FRAME_MS);
24
41
  const [showReasoning, setShowReasoning] = useState(false);
25
- const allCmds = useMemo(() => session.commandSchemas, [session]);
42
+ const allCmds = useMemo(() => commandSchemas(session), [session]);
26
43
  const pendingQuestion = session.getPendingQuestion();
27
44
  useEffect(() => {
28
45
  const unsub = session.subscribeEvents((e) => {
29
46
  switch (e.type) {
30
47
  case "assistant_delta":
31
- streamingRef.current += e.text;
32
- scheduleStreamingRender();
48
+ streaming.append(e.text);
33
49
  break;
34
50
  case "reasoning_delta":
35
- reasoningRef.current += e.text;
36
- scheduleReasoningRender();
51
+ reasoning.append(e.text);
37
52
  break;
38
53
  case "reasoning_clear":
39
- reasoningRef.current = "";
40
- setReasoningText("");
54
+ reasoning.reset();
41
55
  break;
42
56
  case "assistant":
43
- streamingRef.current = "";
44
- setStreamingText("");
57
+ streaming.reset();
58
+ break;
59
+ case "retry":
60
+ case "interrupted":
61
+ streaming.reset();
45
62
  break;
46
63
  case "state":
47
64
  setRunState(e);
@@ -50,29 +67,13 @@ export function App({ session }) {
50
67
  });
51
68
  return unsub;
52
69
  }, []);
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
70
  useInput((_input, key) => {
70
71
  if (pendingQuestion) {
71
72
  if (key.ctrl && _input === "c")
72
73
  session.abort();
73
74
  return;
74
75
  }
75
- if (_input === "t" && runState.running && reasoningText) {
76
+ if (_input === "t" && runState.running && reasoning.text) {
76
77
  setShowReasoning((v) => !v);
77
78
  return;
78
79
  }
@@ -86,13 +87,18 @@ export function App({ session }) {
86
87
  exit();
87
88
  }
88
89
  });
89
- async function handleCommand(name, args) {
90
- await session.executeCommand(name, args);
90
+ async function handleCommand(name) {
91
+ await executeCommand(name, session);
91
92
  if (session.localStore.get("exitRequested") != null)
92
93
  exit();
93
94
  }
94
95
  async function handlePrompt(text) {
95
- await session.startPrompt(text);
96
+ try {
97
+ await session.startPrompt(text);
98
+ }
99
+ catch (e) {
100
+ session.timelineError(errorMessage(e));
101
+ }
96
102
  }
97
103
  let runningView = null;
98
104
  if (runState.running) {
@@ -100,11 +106,11 @@ export function App({ session }) {
100
106
  runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
101
107
  }
102
108
  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 }) })] }));
109
+ const spinnerLabel = streaming.text ? "replying" : "working";
110
+ runningView = (_jsxs(_Fragment, { children: [reasoning.text ? renderReasoning(reasoning.text, showReasoning) : 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: runState.thinkingElapsed, replyElapsed: runState.replyElapsed, inputTokens: runState.inputTokens, outputTokens: runState.outputTokens }) })] }));
105
111
  }
106
112
  }
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 })] }));
113
+ 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
114
  }
109
115
  function renderReasoning(text, expanded) {
110
116
  const lines = text.split("\n");
@@ -116,6 +122,5 @@ function renderReasoning(text, expanded) {
116
122
  return (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["\u250A ", firstLine, extra, " (t to expand)"] }) }));
117
123
  }
118
124
  export function startApp(session) {
119
- process.stdout.write("");
120
125
  return render(_jsx(App, { session: session }), { exitOnCtrlC: false, incrementalRendering: true });
121
126
  }
@@ -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 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: `${GLYPHS[t.status]} ${t.content}` }, i)))] }));
18
18
  });
@@ -1,12 +1,27 @@
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, MAX_PREVIEW_LEN } from "@vietor/easy-agent-core";
4
6
  function encodeCwd(cwd) {
5
7
  return cwd.replace(/[\/\\:]/g, "-");
6
8
  }
9
+ function parseJsonLines(text) {
10
+ const out = [];
11
+ for (const line of text.split("\n")) {
12
+ if (!line.trim())
13
+ continue;
14
+ try {
15
+ out.push(JSON.parse(line));
16
+ }
17
+ catch { /* skip malformed lines */ }
18
+ }
19
+ return out;
20
+ }
7
21
  export class FileSessionPersistence {
8
22
  cwd;
9
23
  dir;
24
+ writtenCounts = new Map();
10
25
  constructor(cwd) {
11
26
  this.cwd = cwd;
12
27
  this.dir = join(homedir(), ".easy-agent", "projects", encodeCwd(cwd));
@@ -24,30 +39,31 @@ export class FileSessionPersistence {
24
39
  return null;
25
40
  const messages = [];
26
41
  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)
42
+ for (const r of parseJsonLines(readFileSync(path, "utf-8"))) {
43
+ if (r.t === "message" && r.m)
39
44
  messages.push(r.m);
40
45
  else if (r.t === "todo" && r.todos)
41
46
  todos = r.todos;
42
47
  }
48
+ this.writtenCounts.set(sessionId, messages.length);
43
49
  return { messages, todos };
44
50
  }
45
51
  async saveAll(sessionId, state) {
46
52
  this.ensureDir();
53
+ const written = this.writtenCounts.get(sessionId) ?? 0;
54
+ const shrink = state.messages.length < written;
47
55
  const lines = state.messages
48
- .map((m) => JSON.stringify({ t: "m", m }))
56
+ .slice(shrink ? 0 : written)
57
+ .map((m) => JSON.stringify({ t: "message", m }))
49
58
  .concat([JSON.stringify({ t: "todo", todos: state.todos })]);
50
- writeFileSync(this.file(sessionId), lines.join("\n") + "\n", "utf-8");
59
+ const path = this.file(sessionId);
60
+ if (shrink) {
61
+ await writeFile(path, lines.join("\n") + "\n", "utf-8");
62
+ }
63
+ else {
64
+ await appendFile(path, lines.join("\n") + "\n", "utf-8");
65
+ }
66
+ this.writtenCounts.set(sessionId, state.messages.length);
51
67
  }
52
68
  async listSessions() {
53
69
  if (!existsSync(this.dir))
@@ -72,33 +88,15 @@ export class FileSessionPersistence {
72
88
  }
73
89
  return out.sort((a, b) => b.updatedAt - a.updatedAt);
74
90
  }
75
- async delete(sessionId) {
76
- const path = this.file(sessionId);
77
- if (existsSync(path))
78
- unlinkSync(path);
79
- }
80
91
  readTitle(path) {
81
92
  const first = this.readFirstUser(path);
82
93
  if (!first)
83
94
  return undefined;
84
- const oneline = first.replace(/\s+/g, " ").trim();
85
- return oneline.length > 60 ? oneline.slice(0, 60) + "…" : oneline;
95
+ return ellipsisText(first, MAX_PREVIEW_LEN);
86
96
  }
87
97
  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;
98
+ const first = parseJsonLines(readFileSync(path, "utf-8"))
99
+ .find((r) => r.t === "message" && r.m && r.m.role === "user" && typeof r.m.content === "string");
100
+ return first?.m?.content;
103
101
  }
104
102
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.4.13",
3
+ "version": "0.5.2",
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"
@@ -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/easy-agent-core": "0.4.13"
26
+ "@vietor/easy-agent-core": "0.5.2"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.1",
@@ -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