feat: 拆分输入解析功能;拓展输入命令;更新文档规划

This commit is contained in:
李岩岩 2026-08-04 15:43:55 +08:00 committed by liyy
parent 8c7a9098ef
commit 8fdf29b4e9
12 changed files with 395 additions and 156 deletions

View File

@ -15,10 +15,12 @@ pnpm dev
其中设置 `DEEPSEEK_API_KEY`
也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
文档:
- [架构总览](docs/architecture.md)
- [源码目录](docs/source-layout.md)
- [数据目录](docs/data-layout.md)
- [Extension 目录](docs/extensions.md)
- [Extension 规划](docs/extensions.md)
- [路线图](docs/roadmap.md)

View File

@ -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 机制必须有多个具体使用者。

View File

@ -8,7 +8,7 @@
~/.llm-to-agent/
```
开发环境可以指向另一个用户目录,测试使用临时目录。
开发环境可以指定其他数据根,测试使用临时目录。
## 物理目录
@ -17,40 +17,51 @@
├── config/
├── workspaces/
│ └── <workspace-id>/
│ ├── workspace.json
│ ├── files/
│ ├── conversations/
│ ├── runs/
│ ├── artifacts/
│ └── extensions/
│ └── <extension-id>/
├── extensions/
│ └── <extension-id>/
├── 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/<workspace-id>/extensions/<extension-id>/`,全局私有数据写入 `extensions/<extension-id>/`
凭据明文不写入数据根,由 `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
<LLM_TO_AGENT_HOME>/workspaces/<workspace-id>/
```
项目中不创建 `.llm-to-agent/` 或其他专属元数据。项目移动时更新绑定关系;删除 linked Workspace 绝不能删除外部项目源码。
绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在数据根中。项目中不创建 `.llm-to-agent/` 或其他专属元数据。
## 数据规则
- Kernel 只提供安全路径、隔离和基础写入机制,不理解业务数据。
- 每个 Extension 管理自己的数据,不能直接修改其他 Extension 的私有内容。
- 缓存和临时数据必须可以清理重建。
- 删除重要数据默认先进入 `trash/`,迁移和修复前先保留可恢复副本。
- Runtime 公共库只提供安全路径、原子写入和基础存储工具,不理解业务数据;
- 缓存、索引和临时数据必须可以清理重建;
- 删除重要数据默认先进入 `trash/`
- 迁移、修复和恢复前保留可恢复副本;
- 全局备份、离线恢复和版本回退由 Supervisor 协调。

View File

@ -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 ProviderGitHub、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 内部文件划分不作统一限制;
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。

View File

@ -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
- 优先跑通最短闭环,再补充实现细节;
- 安全、取消、日志和数据保护随能力一起实现;
- 路线图只记录阶段和完成标志,不维护小功能状态表。

View File

@ -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
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。

View File

@ -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<WorkspaceService>(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<WorkspaceService>(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();
});

View File

@ -1,3 +1,3 @@
# CLI extensions
这里保存 REPL、终端渲染、斜杠命令和 TUI 等 CLI 私有能力。CLI 扩展把终端输入提交给 Kernel并把 Run 事件转换成终端输出。
这里保存终端输入解析、斜杠命令、展示和 TUI 等 CLI 私有能力。CLI Extension 通过公开抓手调用共享能力,并把运行结果转换成终端输出。

View File

@ -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<AgentService>(Hook.Agent);
const workspace = context.get<WorkspaceService>(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 <id>、/history、/exitCtrl+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 <conversation-id>\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");

View File

@ -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",
});
});

View File

@ -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 };
}

View File

@ -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<ChatMessage[]>;
append(role: MessageRole, content: string): Promise<ChatMessage>;
newConversation(): Promise<string>;
switchConversation(conversationId: string): Promise<boolean>;
}
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);
},