Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。

Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。
Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。

## 目录放置规则

Expand Down Expand Up @@ -30,7 +30,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字
- 模型由 `pkg/agent` 在 `Stream` / `CompactIfNeeded` 内按 `ModelConfig` 创建:`provider=fake` 走脚本化假模型(测试用),`provider=openai` 走 OpenAI 兼容 HTTP。不由 Runtime 注入模型实例。
- Handler 直接使用 `*sqlite.Queries` 做 CRUD、SSE 回放、Run 的 Start / Continue / Cancel 和审批裁决;领取后的 Loop 才进入 `internal/agent`。
- `internal/agent` 负责把 pkg 的计算结果持久化为 Run、Turn、消息、用量和事件。`AgentEvent` 必须先同事务写入并递增 `sessions.last_event_seq`,提交后再 `events.Bus.Publish`。
- 示例 Tool 为 `ping`,另注册记忆工具;定义都在 `internal/agent/tools`。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。业务 Tool(文件 / Shell / Git)本阶段不实现
- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。写类编码工具声明 `write` 并默认要审批。目录外路径在 Inspect / jail 失败,不能当成功执行。`plan_*` 只碰 `.cursor/*.md`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`
- `internal/agent/memory` 负责 TextMemory 的 Get / Upsert / Delete / List、`SearchMessages` 和 `IndexMessage`;用户侧只看/删目录与专题。不负责 Prompt / Context Packet / 对话压缩,不自动建专题,不定义 Tool。Loop 在新 Session / 对话压缩后装冻结目录;写 message 时 `IndexMessage`。超限目录由 Runtime 后台 `CompactIndex` 改短盖写,不改当前 Session 冻结前缀。
- 不要使用 Store 接口包装 sqlc。
- Agent 契约不得依赖 React、UI 包或路由框架。
Expand Down
12 changes: 6 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

本文档定义 CodeDock 当前的技术骨架。目录按能力拆分;Issue、Task、Review、Workspace 等业务目录不属于本项目的基础结构。

Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping` 与记忆工具
Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`

## 总体架构

Expand Down Expand Up @@ -189,11 +189,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi

工具定义全部在本包。Runtime `New` 接收 `Ports`(Execute 要调用的外部实现),再 `Register`:

- 本包写工具名、入参/出参、schema、权限和编排。`ping`,以及 `memory_read` / `memory_write` / `memory_search`(依赖 `memory` 与 Session)
- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。未注入的字段不注册对应工具
- 本阶段没有外部 Port(不实现文件 / Shell / Git)
- 本包写工具名、入参/出参、schema、权限和编排。`ping`,`memory_read` / `memory_write` / `memory_search`,编码八工具(`read` / `write` / `edit` / `ls` / `grep` / `find` / `bash` / `powershell`),以及 `plan_list` / `plan_read` / `plan_write`
- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。`Ports.WorkspaceRoot` 只是进程回落,会话级根走 `tool.Input.WorkspaceRoot`
- 编码工具经 jail 限制在会话工作目录;目录外路径 Inspect 失败。`plan_*` 只读写 `.cursor/*.md`

每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。审批仍由工具声明 `RequiresApproval`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。
每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。写类编码工具声明 `write`。审批仍由工具声明 `RequiresApproval` 或 `Effect=ask`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。

### `pkg/git`

Expand Down Expand Up @@ -257,7 +257,7 @@ Worker
-> pkg.Build
-> pkg.Stream # 按 ModelConfig 在 pkg 内创建 fake 或 openai
-> Transition:先落库再 Bus
-> pkg.Dispatch # ping、memory_* 及测试用 Tool
-> pkg.Dispatch # ping、memory_*、编码八工具、plan_* 及测试用 Tool
```

## 配置
Expand Down
9 changes: 9 additions & 0 deletions packages/core/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts";
export { decodeText, firstLine, parseDelta } from "./content.ts";
export {
isPlanTool,
latestPlanDocIds,
normalizePlanName,
planPreviewFromTool,
planToolDump,
type PlanDocPreview,
type PlanPreview,
} from "./plan.ts";
export { joinQueuedTexts } from "./queue.ts";
export {
applyApprovalRecord,
Expand Down
138 changes: 138 additions & 0 deletions packages/core/chat/plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { isPlanTool, latestPlanDocIds, normalizePlanName, planPreviewFromTool, planToolDump } from "./plan.ts";

test("isPlanTool recognizes plan_* only", () => {
assert.equal(isPlanTool("plan_list"), true);
assert.equal(isPlanTool("plan_read"), true);
assert.equal(isPlanTool("plan_write"), true);
assert.equal(isPlanTool("read"), false);
});

test("plan_write previews arguments before output arrives", () => {
const preview = planPreviewFromTool({
name: "plan_write",
arguments: { name: "notes", content: "# 目标\n\n写完预览" },
});
assert.deepEqual(preview, {
kind: "doc",
name: "notes.md",
content: "# 目标\n\n写完预览",
source: "write",
});
});

test("plan_write prefers output name and content", () => {
const preview = planPreviewFromTool({
name: "plan_write",
arguments: { name: "draft", content: "old" },
output: { name: "notes.md", content: "new" },
});
assert.deepEqual(preview, {
kind: "doc",
name: "notes.md",
content: "new",
source: "write",
});
});

test("plan_read uses output and ignores empty arguments", () => {
const preview = planPreviewFromTool({
name: "plan_read",
arguments: { name: "notes.md" },
output: { name: "notes.md", content: "## 步骤" },
});
assert.deepEqual(preview, {
kind: "doc",
name: "notes.md",
content: "## 步骤",
source: "read",
});
});

test("plan_read without content still returns a named card", () => {
const preview = planPreviewFromTool({
name: "plan_read",
arguments: { name: "notes.md" },
});
assert.deepEqual(preview, {
kind: "doc",
name: "notes.md",
content: "",
source: "read",
});
});

test("plan_list is not previewed", () => {
assert.equal(
planPreviewFromTool({ name: "plan_list", output: { names: ["a.md", "b.md"] } }),
null,
);
});

test("unknown tools have no preview", () => {
assert.equal(planPreviewFromTool({ name: "read", arguments: { path: "a.ts" } }), null);
});

test("parses JSON string payloads", () => {
const preview = planPreviewFromTool({
name: "plan_write",
arguments: '{"name":"notes.md","content":"hello"}',
});
assert.deepEqual(preview, {
kind: "doc",
name: "notes.md",
content: "hello",
source: "write",
});
});

test("planToolDump strips document content but keeps other tools intact", () => {
assert.deepEqual(
planToolDump({
name: "plan_write",
arguments: { name: "notes.md", content: "# long" },
output: { name: "notes.md", content: "# long" },
}),
{
input: { name: "notes.md" },
output: { name: "notes.md" },
},
);
const ping = { name: "ping", arguments: { value: 1 }, output: { pong: true } };
assert.deepEqual(planToolDump(ping), { input: ping.arguments, output: ping.output });
});

test("normalizePlanName adds .md and keeps basename", () => {
assert.equal(normalizePlanName("notes"), "notes.md");
assert.equal(normalizePlanName(".cursor/notes.md"), "notes.md");
});

test("write to .cursor/*.md is a plan preview; other writes are not", () => {
assert.deepEqual(
planPreviewFromTool({
name: "write",
arguments: { path: ".cursor/preview-smoke.md", content: "# 新稿" },
}),
{
kind: "doc",
name: "preview-smoke.md",
content: "# 新稿",
source: "write",
},
);
assert.equal(
planPreviewFromTool({ name: "write", arguments: { path: "main.go", content: "package main" } }),
null,
);
});

test("latestPlanDocIds keeps only the current plan", () => {
const ids = latestPlanDocIds([
{ id: "t1", name: "plan_write", arguments: { name: "notes.md", content: "v1" } },
{ id: "t2", name: "plan_list", output: { names: ["notes.md", "other.md"] } },
{ id: "t3", name: "plan_write", arguments: { name: "other.md", content: "x" } },
]);
assert.deepEqual([...ids], ["t3"]);
});
131 changes: 131 additions & 0 deletions packages/core/chat/plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
export type PlanDocPreview = {
kind: "doc";
name: string;
content: string;
source: "write" | "read";
};

export type PlanPreview = PlanDocPreview;

const PLAN_DOC_TOOLS = new Set(["plan_read", "plan_write"]);

export function isPlanTool(name: string): boolean {
return name === "plan_list" || PLAN_DOC_TOOLS.has(name);
}

export function normalizePlanName(name: string): string {
const base = name.trim().split(/[\\/]/).pop()?.trim() ?? "";
if (!base) {
return "plan.md";
}
return /\.md$/i.test(base) ? base : `${base}.md`;
}

export function planPreviewFromTool(input: {
name: string;
arguments?: unknown;
output?: unknown;
}): PlanPreview | null {
if (input.name === "write") {
const args = asRecord(input.arguments);
const name = planNameFromWritePath(stringField(args, "path"));
if (!name) {
return null;
}
return {
kind: "doc",
name,
content: stringField(args, "content"),
source: "write",
};
}
if (!PLAN_DOC_TOOLS.has(input.name)) {
return null;
}
const output = asRecord(input.output);
const args = asRecord(input.arguments);
return {
kind: "doc",
name: normalizePlanName(stringField(output, "name") || stringField(args, "name")),
content: stringField(output, "content") || stringField(args, "content"),
source: input.name === "plan_write" ? "write" : "read",
};
}

export function latestPlanDocIds(
tools: Array<{ id: string; name: string; arguments?: unknown; output?: unknown }>,
): Set<string> {
let current: string | undefined;
for (const tool of tools) {
if (planPreviewFromTool(tool)) {
current = tool.id;
}
}
return current ? new Set([current]) : new Set();
}

export function planToolDump(input: {
name: string;
arguments?: unknown;
output?: unknown;
}): { input: unknown; output: unknown } {
if (!planPreviewFromTool(input)) {
return { input: input.arguments, output: input.output };
}
return {
input: omitContent(input.arguments),
output: omitContent(input.output),
};
}

function planNameFromWritePath(path: string): string | null {
const parts = path.trim().replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length < 2) {
return null;
}
const file = parts[parts.length - 1] ?? "";
const dir = parts[parts.length - 2];
if (dir !== ".cursor" || !/\.md$/i.test(file)) {
return null;
}
return file;
}

function asRecord(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith("{")) {
return null;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}

function stringField(record: Record<string, unknown> | null, key: string): string {
if (!record) {
return "";
}
const value = record[key];
return typeof value === "string" ? value : "";
}

function omitContent(value: unknown): unknown {
const record = asRecord(value);
if (!record || !("content" in record)) {
return value;
}
const { content: _content, ...rest } = record;
return rest;
}
7 changes: 7 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ export {
hydrate,
indexMessages,
joinQueuedTexts,
isPlanTool,
latestPlanDocIds,
isRecoverableRun,
isTerminalRun,
isThinkingPhase,
parseDelta,
normalizePlanName,
planPreviewFromTool,
planToolDump,
parseSSEBlock,
parseSSEChunk,
RECOVERABLE_RUN_STATUSES,
Expand Down Expand Up @@ -58,6 +63,8 @@ export type {
EventType,
Message,
PageInfo,
PlanDocPreview,
PlanPreview,
Run,
RunStatus,
Session,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"./git": "./git/index.ts"
},
"scripts": {
"test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts git/client.test.ts"
"test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts git/client.test.ts"
},
"devDependencies": {
"@types/node": "^20",
Expand Down
Loading
Loading