diff --git a/README.md b/README.md index d173b6d..4c7c210 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ pnpm dev 其中设置 `DEEPSEEK_API_KEY`; 也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。 +CLI 支持 `/new` 新建对话、`/switch ` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。 + 文档: - [架构总览](docs/architecture.md) - [源码目录](docs/source-layout.md) - [数据目录](docs/data-layout.md) -- [Extension 目录](docs/extensions.md) +- [Extension 规划](docs/extensions.md) - [路线图](docs/roadmap.md) diff --git a/docs/architecture.md b/docs/architecture.md index 21115f6..3b29ab1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,53 +2,81 @@ ## 目标 -llm-to-agent 是个人项目,架构优先级是: +llm-to-agent 是个人使用、本地优先的 Agent。架构优先级是: ```text -简单易懂 > 容易修改 > 容易扩展 > 通用性 +简单易懂 > 容易扩展和修改 > 架构完整 > 通用性 ``` -Runtime 内部长期只有: +Runtime 长期保持: ```text Kernel + Extensions ``` -Kernel 管“Extension 怎样连接和运行”,Extension 管“系统具体能做什么”。 +Kernel 管 Extension 怎样连接和运行,Extension 管系统具体能做什么。 ## Kernel -第一版 Kernel 只保留几件直观的事: +Kernel 只提供所有 Extension 共用的运行机制: - 按顺序装入 Extension; - 依次执行 `setup` 和 `start`,停止时倒序执行 `stop`; - 通过命名抓手提供和取得能力; - 通过命名事件发布和监听事实。 -抓手和事件暂时使用带命名空间的字符串,不为它们建立复杂的 TypeScript 类型系统。Setup Context 负责注册,Runtime Context 负责使用;Kernel 信任 Extension 遵守生命周期,不增加 Context 失效检查和不可变包装。 - -Kernel 不认识 Workspace、Conversation、Run、Agent、模型或 Tool。只有所有 Extension 都必须遵守的运行规则才进入 Kernel。 +Kernel 不认识 Workspace、Agent、模型、Tool 或其他业务概念。 ## Extensions -Extension 可以提供能力、调用其他能力、添加同类实现、发布或监听事实,并管理自己的数据与资源。 +Extension 是 Runtime 的独立装配单位,分为三类: -控制流程必须使用明确调用;事件只表示已经发生的事实,不能承担顺序、返回值、审批或回滚。 +- 共享能力:拥有明确状态、资源或业务流程; +- Provider:接入可替换的模型、协议或外部系统; +- 产品入口:处理 CLI、Web、Desktop 的输入、输出和平台资源。 -Extension 之间只依赖公开契约,不引用彼此的实现。 +规划可以列出尚未实现的 Extension,源码、Catalog 和产品装配只包含已经开始实现的部分。Extension 内部文件和目录不设统一结构。 -具体能力归属见 [Extension 目录](extensions.md)。 +最终能力归属见 [Extension 规划](extensions.md)。 + +## 扩展点 + +多个实现向能力所有者登记,不再为扩展点增加新的架构层: + +```text +models.providers +tools.providers +agent.contextContributors +automation.triggers +security.approvalChannels +health.checks +``` + +模型 Provider 由 `models` 选择,具体 Tool 由 `tools` 统一执行,上下文来源由 `agent` 组合。 + +## 协作 + +- 控制流程使用明确调用; +- 事件只表示已经发生的事实; +- Extension 只依赖其他 Extension 的公开契约; +- Provider 向扩展点注册实现,能力所有者不引用 Provider 实现; +- Tool 是能力 Extension 暴露给 Agent 的操作,不是新的 Extension; +- 高风险操作统一经过 `security`,凭据统一通过 `secrets` 的受控引用取得。 + +事件不能承担顺序、返回值、审批、事务或回滚。 ## Runtime 与产品 -Runtime 持有唯一一套 Kernel、Extension 实例和本地数据。CLI、Web、Desktop 是不同产品入口,使用同一套能力和状态。 +Runtime 持有一套 Kernel、Extension 实例和本地数据。CLI、Web、Desktop 是不同产品入口,共用共享能力和数据。 -自进化阶段再增加 Runtime 外的 Supervisor,用于版本切换、健康检查和失败回退。 +版本切换、进程守护、离线恢复和失败回退由 Runtime 外的 Supervisor 负责。 ## 长期约束 -- 默认在现有 Extension 内增加普通代码,出现真实独立边界后再拆。 -- 第一版静态装配,不建设插件平台、热加载或依赖图。 -- 保持单进程、单用户和本地优先,直到真实需求要求改变。 +- 保持单用户、本地优先和单 Runtime; +- 保持一个 TypeScript package,直到出现独立构建、发布或进程边界; +- 使用静态装配,不提前建设插件平台、热加载或依赖图; +- 规划高风险边界,但不创建占位目录、文件、ID 或 Hook; +- 普通函数、页面、命令、Parser、Prompt 和单个 Tool 不拆成 Extension; +- 新增 Kernel 机制必须有多个具体使用者; - 不提前建设 Command Bus、Middleware、事件溯源或分布式状态。 -- 新增 Kernel 机制必须有多个具体使用者。 diff --git a/docs/data-layout.md b/docs/data-layout.md index 67f4861..5b1b869 100644 --- a/docs/data-layout.md +++ b/docs/data-layout.md @@ -8,7 +8,7 @@ ~/.llm-to-agent/ ``` -开发环境可以指向另一个用户目录,测试使用临时目录。 +开发环境可以指定其他数据根,测试使用临时目录。 ## 物理目录 @@ -17,40 +17,51 @@ ├── config/ ├── workspaces/ │ └── / +│ ├── workspace.json │ ├── files/ │ ├── conversations/ -│ ├── runs/ +│ ├── artifacts/ │ └── extensions/ +│ └── / ├── extensions/ +│ └── / ├── releases/ ├── runtime/ └── trash/ ``` -目录按需要创建,不规定尚未实现的数据文件和格式。 +目录按需要创建,不预建尚未使用的层级。 -当前对话链路只会创建 Workspace 绑定信息和 -`conversations/default/messages.jsonl`,后续能力再增加自己的数据。 +## 数据归属 + +- `workspace` 管理 Workspace、Conversation、Message 和 Artifact; +- `agent` 管理 Agent Run; +- `tasks` 管理 Goal、Plan 和任务执行状态; +- `memory` 与 `knowledge` 分别管理自己的长期数据和索引; +- `automation` 管理 Workflow、Schedule 和执行历史; +- `security` 管理审批和安全记录; +- 每个 Extension 只能通过公开契约访问其他 Extension 的数据; +- 每个数据所有者负责自己的迁移、校验、导出和清理。 + +Workspace 范围的私有数据写入 `workspaces//extensions//`,全局私有数据写入 `extensions//`。 + +凭据明文不写入数据根,由 `secrets` 使用系统 Keychain 或受控加密存储管理。 ## Workspace -- `managed` Workspace 的工作文件位于自己的 `files/`。 -- `linked` Workspace 只记录用户已有目录的绑定关系。 -- 两种 Workspace 的 Conversation、Run、记忆和 Extension 数据都位于数据根目录。 +- `managed` Workspace 的工作文件位于自己的 `files/`; +- `linked` Workspace 只记录用户已有目录的绑定关系; +- 产品入口各自管理当前选择的 Workspace 和 Conversation; +- 删除 linked Workspace 绝不能删除外部项目源码。 ## 项目目录零落地 -绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在: - -```text -/workspaces// -``` - -项目中不创建 `.llm-to-agent/` 或其他专属元数据。项目移动时更新绑定关系;删除 linked Workspace 绝不能删除外部项目源码。 +绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在数据根中。项目中不创建 `.llm-to-agent/` 或其他专属元数据。 ## 数据规则 -- Kernel 只提供安全路径、隔离和基础写入机制,不理解业务数据。 -- 每个 Extension 管理自己的数据,不能直接修改其他 Extension 的私有内容。 -- 缓存和临时数据必须可以清理重建。 -- 删除重要数据默认先进入 `trash/`,迁移和修复前先保留可恢复副本。 +- Runtime 公共库只提供安全路径、原子写入和基础存储工具,不理解业务数据; +- 缓存、索引和临时数据必须可以清理重建; +- 删除重要数据默认先进入 `trash/`; +- 迁移、修复和恢复前保留可恢复副本; +- 全局备份、离线恢复和版本回退由 Supervisor 协调。 diff --git a/docs/extensions.md b/docs/extensions.md index 6d82603..669607c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1,66 +1,78 @@ -# Extension 目录 +# Extension 规划 -具体能力全部由 Extension 实现。Tool、页面、命令和事件监听器只是 Extension 内部组成,不是新的架构类型。 +## 共享能力 -## 第一版 +| Extension | 职责 | 公开能力与 Tool | +|---|---|---| +| `workspace` | Workspace、Conversation、Message、Artifact | `WorkspaceService`;默认不提供 Tool | +| `models` | 模型注册、选择、流式调用、Embedding 和 Fallback | `ModelService`;接收 `models.providers` | +| `tools` | Tool 注册、Schema 校验、执行、取消和安全拦截 | `ToolService`;可提供 `tools.search`、`tools.describe` | +| `agent` | 一次 Agent Run、上下文构建、模型与 Tool 循环 | `AgentService`;接收 `agent.contextContributors` | +| `tasks` | Goal、Plan、子 Agent、任务依赖、等待和恢复 | `TaskService`;`tasks.update_plan`、`tasks.delegate`、`tasks.wait` | +| `memory` | 用户偏好、事实、经验和长期记忆 | `MemoryService`;`memory.recall`、`memory.propose`、`memory.forget` | +| `knowledge` | 知识来源、索引、检索和引用 | `KnowledgeService`;`knowledge.search`、`knowledge.open_source` | +| `project` | 项目路径、文件、搜索、Patch、项目识别和验证 | `ProjectService`;`project.read`、`project.search`、`project.apply_patch`、`project.run_checks` | +| `shell` | 命令、PTY、输出流、超时和取消 | `ShellService`;`shell.run`、`shell.read`、`shell.cancel` | +| `git` | Repository、Diff、分支、Commit 和 Worktree | `GitService`;`git.status`、`git.diff`、`git.log`、`git.create_worktree` | +| `automation` | Workflow、Scheduler、提醒、重试和执行历史 | `AutomationService`;`automation.create`、`automation.schedule`、`automation.pause`、`automation.run_now` | +| `security` | 风险规则、授权、审批和安全记录 | `SecurityService`;接收 `security.approvalChannels`,不提供 Tool | +| `secrets` | Keychain、凭据引用、授权范围和轮换 | `SecretsService`;不向模型提供明文凭据 | +| `browser` | 浏览器 Session 和页面操作 | `BrowserService`;`browser.open`、`browser.read`、`browser.click`、`browser.type` | +| `computer` | 屏幕、窗口、应用、键鼠和剪贴板 | `ComputerService`;`computer.screenshot`、`computer.open_app`、`computer.click`、`computer.type` | +| `evolution` | 自身修改候选、验证和发布提案 | `EvolutionService`;`evolution.prepare`、`evolution.validate` | + +`browser`、`computer` 和 `evolution` 在实现对应能力时创建。 + +## Provider + +- 模型 Provider:`deepseek` 以及实际接入的其他模型; +- Tool Provider:GitHub、MCP 和其他实际接入的外部系统; +- Provider 管理自己的协议、配置、资源和故障,向所属扩展点注册实现; +- 一个 Provider 可以提供多个 Tool,一个 Tool 不对应一个 Extension。 + +## 产品入口 + +- `cli`:终端输入、命令、展示和审批通道; +- `web`:HTTP、WebSocket、页面和 Web 会话; +- `desktop`:窗口、快捷键、通知和原生平台集成。 + +产品入口不拥有共享业务能力。 + +## 协作关系 ```text -src/extensions/ -├── catalog.ts -├── shared/ -│ ├── workspace/ -│ ├── agent/ -│ └── deepseek/ -└── cli/ +模型 Provider ────────────────> models + +workspace / memory / knowledge ─> agent.contextContributors + +project / shell / git +browser / computer / 外部 Provider ─> tools.providers + +tasks ────────────────> agent +automation ───────────> tasks / agent / tools +agent ────────────────> workspace / models / tools / memory / knowledge +tools ────────────────> security +外部 Provider ────────> secrets +evolution ────────────> project / shell / git + +cli / web / desktop ──> workspace / agent / tasks / automation / security ``` -- `workspace`:Workspace、Conversation 和消息。 -- `agent`:Run、上下文和 Agent 执行。 -- `deepseek`:DeepSeek 模型接入。 -- `cli`:终端输入、命令和展示。 +## 不单独拆分 -第一版只静态装配这四个 Extension。 +- Conversation、Message、Artifact 归 `workspace`; +- Context、Agent Profile 和声明式 Skill 归 `agent`; +- Workflow、Scheduler 和 Notification 请求归 `automation`; +- Policy、Approval 和 Audit 归 `security`; +- 项目文件归 `project`,进程归 `shell`,仓库归 `git`; +- Run 由实际执行者拥有,不创建通用 Run Extension; +- 页面、命令、Parser、Prompt、模板和单个 Tool 不是 Extension; +- 数据迁移、校验和清理由数据所有者实现。 -`catalog.ts` 集中列出所有跨 Extension 使用的 Extension ID、能力抓手和事实事件;它只保存字符串,不保存类型或运行逻辑。 +## 创建规则 -当前协作链路: - -```text -workspace -> 提供 workspace -deepseek -> 添加 model.providers -agent -> 使用二者并提供 agent -cli -> 调用 agent -``` - -## 协作 - -- 一个明确能力由 Extension 提供,调用方取得后直接调用。 -- 模型、Tool 等多个实现通过同一扩展点汇集,由业务拥有者选择。 -- 状态真正发生后再发布事件,监听器只做展示、日志或派生处理。 -- Extension 只引用其他 Extension 的公开契约。 - -## 后续方向 - -后续公共能力大致包括: - -```text -项目操作与 Git -自身进化 -计划与多 Agent -记忆与知识 -浏览器与本机控制 -自动化 -``` - -CLI、Web、Desktop 各自先保持一个产品 Extension。远程仓库、外部 Tool 协议、Keychain 等在实际接入时再决定是否独立。 - -## 何时拆分 - -只有出现以下真实边界时才创建新 Extension: - -- 需要独立启停; -- 拥有独立的长期资源或数据; -- 存在可替换实现; -- 有明确产品或平台边界。 - -否则继续留在现有 Extension 内。目录只在能力开始实现时创建,不预建占位代码。 +- 规划中的 Extension 只有开始实现时才创建源码; +- `catalog.ts` 只登记已实现并发生跨 Extension 协作的 ID、Hook 和 Event; +- 不创建占位目录、文件、Factory、ID 或 Hook; +- Extension 内部文件划分不作统一限制; +- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 2612787..e69911b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,46 +1,45 @@ # 路线图 -路线图只描述大的实现顺序。安全、日志、取消和数据保护从每个阶段开始就随能力一起实现。 +## P1:持续对话 -## P1:能持续聊天 - -建立最小 Kernel、第一版 Extension 和集中式数据目录。 +建立 Kernel、`workspace`、`models`、`secrets`、`agent`、模型 Provider 和 `cli`。 完成标志:CLI 可以管理 Workspace 与 Conversation,流式聊天,并在重启后继续历史。 -## P2:能操作项目 +## P2:项目闭环 -加入文件、Shell、Tool Calling、Git、Worktree、验证和最小权限确认。 +实现 `tools`、`security`、`project`、`shell` 和 `git`。 -完成标志:Agent 可以在普通代码项目中完成一次可审核的修改与测试闭环。 +完成标志:Agent 可以在受控范围内读取和修改项目、运行验证、检查 Git 变更,并处理审批。 -## P3:能修改自己 +## P3:自身演进 -加入隔离候选、构建验证、发布确认、版本切换和失败回退。 +实现 `evolution` 和 Runtime 外的 Supervisor。 -完成标志:Agent 可以安全完成一次自身小能力的修改与发布。 +完成标志:Agent 可以生成隔离候选、完成验证和发布提案,Supervisor 可以安全切换版本并失败回退。 -## P4:能处理复杂工作 +## P4:复杂任务 -加入多模型、计划、多 Agent、工作流、长期记忆和知识检索。 +实现 `tasks`、`memory`、`knowledge` 和更多需要的模型 Provider。 -完成标志:复杂目标可以持续执行,跨 Conversation 找到相关上下文,并给出可验证结果。 +完成标志:复杂目标可以持续执行、使用子 Agent、恢复任务并检索长期上下文。 -## P5:能管理电脑 +## P5:电脑与自动化 -加入浏览器、本机控制、多模态、提醒、定时任务和自动化。 +实现 `browser`、`computer` 和 `automation`,完善 `secrets` 的 Keychain、轮换和恢复能力。 -完成标志:Agent 可以完成开发之外的高频电脑事务,并可靠重复执行。 +完成标志:Agent 可以操作浏览器和本机应用,管理提醒与自动化,并安全使用外部凭据。 -## P6:适合长期使用 +## P6:长期使用 -实现 Web 和 Desktop,完善远程访问、诊断、备份迁移、资源清理、凭据保护和版本维护。 +实现 `web` 和 `desktop`,完善远程访问、诊断、备份迁移、资源清理和版本维护。 -完成标志:三个产品共用一套稳定 Runtime 和数据,能够长期日常使用。 +完成标志:CLI、Web、Desktop 共用一套稳定 Runtime 和数据,可以长期日常使用。 ## 执行原则 -- 每个阶段开始时再拆提交级任务。 -- 先跑通最短链路,再根据真实压力抽象。 -- 新增普通能力默认修改 Extension;只有通用运行机制才修改 Kernel。 -- 路线图允许随实践调整,不维护小功能状态表。 +- 每个阶段开始时再拆提交级任务; +- 只创建当前阶段实际使用的 Extension; +- 优先跑通最短闭环,再补充实现细节; +- 安全、取消、日志和数据保护随能力一起实现; +- 路线图只记录阶段和完成标志,不维护小功能状态表。 diff --git a/docs/source-layout.md b/docs/source-layout.md index 6d85095..715e7b2 100644 --- a/docs/source-layout.md +++ b/docs/source-layout.md @@ -7,6 +7,7 @@ llm-to-agent/ ├── src/ │ ├── kernel/ │ ├── extensions/ +│ │ ├── catalog.ts │ │ ├── shared/ │ │ ├── cli/ │ │ ├── web/ @@ -22,18 +23,22 @@ llm-to-agent/ ## 目录职责 -- `kernel/`:只保存 Extension 运行机制。 -- `extensions/shared/`:跨产品使用的具体能力。 -- `extensions/cli|web|desktop/`:三条产品线各自的输入、展示和平台集成。 -- `products/`:静态选择每个产品启用哪些 Extension,不写业务逻辑。 -- `tests/`:只保存跨 Extension、跨进程或跨版本测试;普通测试跟随源码。 +- `kernel/`:Extension 生命周期、能力注册和事实事件; +- `extensions/catalog.ts`:已经实现的跨 Extension ID、Hook 和 Event; +- `extensions/shared/`:跨产品使用的能力和 Provider; +- `extensions/cli|web|desktop/`:产品输入、展示和平台集成; +- `products/`:静态选择产品启用的 Extension,不写业务逻辑; +- `tests/`:跨 Extension、跨进程或跨版本测试; - `tooling/`:构建、开发和发布辅助。 -Extension 的具体划分见 [Extension 目录](extensions.md)。 +Extension 的最终职责见 [Extension 规划](extensions.md)。 ## 放置规则 -- 业务概念和流程进入 Extension,不为了复用方便放进 Kernel。 -- 产品私有实现不能互相依赖,真实复用出现后再提升到 `shared/`。 -- 目录和文件只在真实代码出现时创建,不预建占位层级。 -- 当前保持单包;只有构建、平台依赖、独立分发或进程隔离造成实际问题时才拆包。 +- 源码目录只在对应能力开始实现时创建; +- Extension 内部文件和目录不设统一模板; +- 业务概念和流程进入 Extension,不进入 Kernel; +- 产品私有实现不能互相依赖; +- Provider 只通过公开契约和扩展点接入能力所有者; +- 普通复用代码不因此升级为 Extension; +- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。 diff --git a/src/extensions/chat.test.ts b/src/extensions/chat.test.ts index 9de3118..58fc61d 100644 --- a/src/extensions/chat.test.ts +++ b/src/extensions/chat.test.ts @@ -101,3 +101,46 @@ test("streams a reply, saves it, and restores the conversation after restart", a await secondKernel.stop(); }); + +test("creates a conversation and restores it after restart", async (t) => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-")); + const home = join(temporaryDirectory, "home"); + const projectPath = join(temporaryDirectory, "project"); + await mkdir(projectPath); + t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); + + const firstKernel = new Kernel().use( + createWorkspaceExtension({ home, projectPath }), + ); + await firstKernel.start(); + + const firstWorkspace = firstKernel.get(Hook.Workspace); + await firstWorkspace.append("user", "旧对话消息"); + const conversationId = await firstWorkspace.newConversation(); + assert.deepEqual(await firstWorkspace.messages(), []); + await firstWorkspace.append("user", "新对话消息"); + assert.equal(await firstWorkspace.switchConversation("default"), true); + assert.deepEqual( + (await firstWorkspace.messages()).map(({ role, content }) => ({ role, content })), + [{ role: "user", content: "旧对话消息" }], + ); + assert.equal(await firstWorkspace.switchConversation("missing"), false); + assert.equal(firstWorkspace.conversationId, "default"); + assert.equal(await firstWorkspace.switchConversation(conversationId), true); + await firstKernel.stop(); + + const secondKernel = new Kernel().use( + createWorkspaceExtension({ home, projectPath }), + ); + await secondKernel.start(); + + const secondWorkspace = secondKernel.get(Hook.Workspace); + assert.equal(secondWorkspace.conversationId, conversationId); + assert.deepEqual( + (await secondWorkspace.messages()).map(({ role, content }) => ({ role, content })), + [{ role: "user", content: "新对话消息" }], + ); + assert.deepEqual(await readdir(projectPath), []); + + await secondKernel.stop(); +}); diff --git a/src/extensions/cli/README.md b/src/extensions/cli/README.md index 748f59c..f20334a 100644 --- a/src/extensions/cli/README.md +++ b/src/extensions/cli/README.md @@ -1,3 +1,3 @@ # CLI extensions -这里保存 REPL、终端渲染、斜杠命令和 TUI 等 CLI 私有能力。CLI 扩展把终端输入提交给 Kernel,并把 Run 事件转换成终端输出。 +这里保存终端输入解析、斜杠命令、展示和 TUI 等 CLI 私有能力。CLI Extension 通过公开抓手调用共享能力,并把运行结果转换成终端输出。 diff --git a/src/extensions/cli/index.ts b/src/extensions/cli/index.ts index 6fbc4c8..8f748c7 100644 --- a/src/extensions/cli/index.ts +++ b/src/extensions/cli/index.ts @@ -6,6 +6,8 @@ import { import type { Extension } from "../../kernel"; import { Event, ExtensionId, Hook } from "../catalog"; import type { AgentService } from "../shared/agent"; +import type { WorkspaceService } from "../shared/workspace"; +import { parseInput } from "./input"; export function createCliExtension(): Extension { let terminal: ReadlineInterface | undefined; @@ -19,6 +21,12 @@ export function createCliExtension(): Extension { start(context) { const agent = context.get(Hook.Agent); + const workspace = context.get(Hook.Workspace); + const roleNames = { + user: "用户", + assistant: "助手", + system: "系统", + }; terminal = createInterface({ input: process.stdin, output: process.stdout, @@ -35,24 +43,59 @@ export function createCliExtension(): Extension { loop = (async () => { try { process.stdout.write( - `llm-to-agent\nWorkspace: ${process.cwd()}\n输入 /exit 退出,Ctrl+C 取消当前回复。\n\n`, + `llm-to-agent\nWorkspace: ${process.cwd()}\n当前对话: ${workspace.conversationId}\n命令: /new、/switch 、/history、/exit,Ctrl+C 取消当前回复。\n\n`, ); terminal?.setPrompt("> "); terminal?.prompt(); for await (const line of terminal!) { - const input = line.trim(); + const input = parseInput(line); - if (input === "/exit") break; if (!input) { terminal?.prompt(); continue; } + if (input.type === "command") { + if (input.name === "exit") break; + + if (input.name === "new") { + const conversationId = await workspace.newConversation(); + process.stdout.write(`已新建对话: ${conversationId}\n\n`); + } else if (input.name === "switch") { + if (!input.argument) { + process.stdout.write("用法: /switch \n\n"); + } else if (await workspace.switchConversation(input.argument)) { + process.stdout.write(`已切换到对话: ${workspace.conversationId}\n\n`); + } else { + process.stdout.write(`对话不存在: ${input.argument}\n\n`); + } + } else if (input.name === "history") { + const messages = await workspace.messages(); + + if (messages.length === 0) { + process.stdout.write("当前对话暂无消息。\n\n"); + } else { + process.stdout.write(`对话 ${workspace.conversationId}\n`); + for (const message of messages) { + process.stdout.write( + `${roleNames[message.role]}: ${message.content}\n`, + ); + } + process.stdout.write("\n"); + } + } else { + process.stdout.write(`未知命令: /${input.name}\n\n`); + } + + terminal?.prompt(); + continue; + } + currentRequest = new AbortController(); try { - for await (const chunk of agent.chat(input, currentRequest.signal)) { + for await (const chunk of agent.chat(input.content, currentRequest.signal)) { process.stdout.write(chunk); } process.stdout.write("\n\n"); diff --git a/src/extensions/cli/input.test.ts b/src/extensions/cli/input.test.ts new file mode 100644 index 0000000..1f80d16 --- /dev/null +++ b/src/extensions/cli/input.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseInput } from "./input"; + +test("parses CLI messages and commands", () => { + assert.equal(parseInput(" "), undefined); + assert.deepEqual(parseInput(" 你好 "), { + type: "message", + content: "你好", + }); + assert.deepEqual(parseInput("/new\tdemo"), { + type: "command", + name: "new", + argument: "demo", + }); + assert.deepEqual(parseInput("/switch default"), { + type: "command", + name: "switch", + argument: "default", + }); + assert.deepEqual(parseInput("//path"), { + type: "message", + content: "/path", + }); +}); diff --git a/src/extensions/cli/input.ts b/src/extensions/cli/input.ts new file mode 100644 index 0000000..3f46baf --- /dev/null +++ b/src/extensions/cli/input.ts @@ -0,0 +1,19 @@ +type ParsedInput = + | { type: "message"; content: string } + | { type: "command"; name: string; argument: string }; + +export function parseInput(value: string): ParsedInput | undefined { + const input = value.trim(); + + if (!input) return; + if (!input.startsWith("/")) return { type: "message", content: input }; + if (input.startsWith("//")) { + return { type: "message", content: input.slice(1) }; + } + + const separator = input.search(/\s/); + const name = input.slice(1, separator < 0 ? undefined : separator).toLowerCase(); + const argument = separator < 0 ? "" : input.slice(separator + 1).trim(); + + return { type: "command", name, argument }; +} diff --git a/src/extensions/shared/workspace/index.ts b/src/extensions/shared/workspace/index.ts index a21a9fb..c2a7075 100644 --- a/src/extensions/shared/workspace/index.ts +++ b/src/extensions/shared/workspace/index.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; @@ -21,6 +21,8 @@ export interface WorkspaceService { conversationId: string; messages(): Promise; append(role: MessageRole, content: string): Promise; + newConversation(): Promise; + switchConversation(conversationId: string): Promise; } export interface WorkspaceOptions { @@ -35,20 +37,42 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens ); const projectPath = resolve(options.projectPath ?? process.cwd()); const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16); - const conversationId = options.conversationId ?? "default"; const workspaceDirectory = join(home, "workspaces", workspaceId); - const conversationDirectory = join(workspaceDirectory, "conversations", conversationId); - const messagesFile = join(conversationDirectory, "messages.jsonl"); + const conversationsDirectory = join(workspaceDirectory, "conversations"); + const workspaceFile = join(workspaceDirectory, "workspace.json"); + let conversationId = options.conversationId ?? "default"; + + async function saveWorkspace() { + await writeFile( + workspaceFile, + `${JSON.stringify( + { + id: workspaceId, + kind: "linked", + projectPath, + activeConversationId: conversationId, + }, + null, + 2, + )}\n`, + "utf8", + ); + } const workspace: WorkspaceService = { home, projectPath, workspaceId, - conversationId, + get conversationId() { + return conversationId; + }, async messages() { try { - const content = await readFile(messagesFile, "utf8"); + const content = await readFile( + join(conversationsDirectory, conversationId, "messages.jsonl"), + "utf8", + ); return content .split("\n") .filter(Boolean) @@ -66,30 +90,57 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens createdAt: new Date().toISOString(), }; - await appendFile(messagesFile, `${JSON.stringify(message)}\n`, "utf8"); + await appendFile( + join(conversationsDirectory, conversationId, "messages.jsonl"), + `${JSON.stringify(message)}\n`, + "utf8", + ); return message; }, + + async newConversation() { + const newConversationId = randomUUID(); + await mkdir(join(conversationsDirectory, newConversationId), { recursive: true }); + conversationId = newConversationId; + await saveWorkspace(); + return conversationId; + }, + + async switchConversation(nextConversationId) { + const conversations = await readdir(conversationsDirectory, { + withFileTypes: true, + }); + const exists = conversations.some( + (entry) => entry.isDirectory() && entry.name === nextConversationId, + ); + + if (!exists) return false; + + conversationId = nextConversationId; + await saveWorkspace(); + return true; + }, }; return { id: ExtensionId.Workspace, async setup(context) { - await mkdir(conversationDirectory, { recursive: true }); - await writeFile( - join(workspaceDirectory, "workspace.json"), - `${JSON.stringify( - { - id: workspaceId, - kind: "linked", - projectPath, - activeConversationId: conversationId, - }, - null, - 2, - )}\n`, - "utf8", - ); + await mkdir(workspaceDirectory, { recursive: true }); + + if (!options.conversationId) { + try { + const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8")); + if (savedWorkspace.activeConversationId) { + conversationId = savedWorkspace.activeConversationId; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + await mkdir(join(conversationsDirectory, conversationId), { recursive: true }); + await saveWorkspace(); context.add(Hook.Workspace, workspace); },