c0de-agent 1.11.0 → 1.12.0
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 +24 -5
- package/dist/cli/commands/chat.js +87 -1
- package/dist/cli/commands/config.js +17 -3
- package/dist/cli/commands/sessions.js +62 -3
- package/dist/cli/index.js +26 -7
- package/dist/cli/modes/print.js +9 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +11 -2
- package/dist/core/config.js +73 -6
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +93 -51
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +10 -1
- package/dist/server/agent-manager.js +14 -1
- package/dist/server/auth-manager.d.ts +11 -5
- package/dist/server/auth-manager.js +39 -20
- package/dist/server/context.js +3 -0
- package/dist/server/permission/interactive.d.ts +6 -3
- package/dist/server/permission/interactive.js +20 -4
- package/dist/server/permission/store.d.ts +12 -2
- package/dist/server/permission/store.js +31 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +32 -2
- package/dist/server/routes/chat.d.ts +12 -0
- package/dist/server/routes/chat.js +216 -38
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +103 -23
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +133 -7
- package/dist/server/routes/project.js +59 -10
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +298 -22
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +88 -28
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +198 -102
- package/dist/server/types.d.ts +7 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.js +20 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +128 -11
- package/dist/session/session.js +461 -30
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +11 -1
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +5 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +9 -0
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/snapshot.d.ts +1 -0
- package/dist/update/snapshot.js +2 -0
- package/dist/update/updater.d.ts +15 -1
- package/dist/update/updater.js +45 -17
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +41 -42
package/README.md
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
- **Browser-Server 架构**:Hono 后端通过 SSE 推送 LLM 流式响应;React 前端单页应用。
|
|
8
8
|
- **本地优先**:会话与索引存储在 PGLite(浏览器/进程内 PostgreSQL),数据随用随取。
|
|
9
|
-
-
|
|
9
|
+
- **多项目**:工作区隔离的多项目管理,每个项目独立会话历史与配置。项目目录被**移动/重命名**后,顶栏「目录已失效」提示提供「重新定位」——输入新路径即可把会话与看板整体迁移过去(无需删除项目)。
|
|
10
10
|
- **工具系统**:内置文件读写、bash、grep、glob、编辑等工具,可按需启用/禁用。
|
|
11
|
-
- **权限模式**:`default` / `auto`
|
|
11
|
+
- **权限模式**:`default` / `auto` 等授权模式,控制工具执行前是否需用户确认。权限确认双层超时:5 分钟未确认仅提示(可在弹窗中重新确认,不重发消息);再过 25 分钟仍未响应则自动拒绝(run 继续执行,会话不会无限挂起)。
|
|
12
12
|
- **会话可恢复**:会话快照序列化/反序列化,支持进程重启后无缝恢复。
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- **无感知热更新**:后台定期检查新版本,安装成功后才暂停活跃会话 → 序列化会话快照 → npm 自更新 → 端口 handoff 接管。空闲会话零感知;进行中的对话会中断(界面提示后可一键重发继续),安装失败时不触碰任何会话状态。更新完成后请**刷新页面**加载新版界面(后续请求已自动落到新实例,不刷新则页面仍运行旧版前端)。
|
|
14
|
+
- **追加指令**:对话进行中可注入「追加指令」(steering),立即影响下一轮模型输出,并作为会话历史的一部分持久化——刷新后不丢失。
|
|
15
|
+
- **故障回退**:主 provider 失败时自动重试;启用后按配置的重试参数执行,并回退到声明了同一模型的其他 provider(设置 → 故障回退)。
|
|
15
16
|
- **斜杠命令**:`/compact`、`/model`、`/clear` 等可配置的斜杠命令。
|
|
16
|
-
-
|
|
17
|
+
- **会话回收站**:删除会话软删除入回收站,保留 60 天(自**首次在回收站看到该条目**起算——只标记一次,不随每次打开重置,倒计时稳定可预期)可恢复(按项目隔离,恢复时自动重新归属)。到期后先进入 **7 天宽限期**(回收站内明示「即将清除」,仍可恢复),宽限期满才物理清除,绝不静默清空。删除项目会将其会话移入回收站的「未归属项目」分组(跨项目可见,恢复时归属到某项目);该分组默认折叠,展开时才启动保留期倒计时。恢复会话时仅还原同一次删除操作中一并删除的分支,漏掉的分支会在提示中显式告知数量。
|
|
18
|
+
- **会话导出/导入**:会话可导出为 JSON 备份,并在任意项目重新导入(会话列表「导入」按钮),跨机器迁移或本地备份均可用。导出含权限态(自动授权/始终允许白名单)时,导入前会明示风险并让你选择是否随迁——默认剥离,避免导入他人导出的会话时静默继承对危险工具的免确认放行。导出仅含本会话与归档;有派生分支时会先提示分支内容不会随迁。
|
|
19
|
+
- **看板**:项目级任务看板(与会话内 agent 待办相互独立,可把待办导出为看板卡片),支持导出/导入整板 JSON 备份——注意:删除项目会**永久**级联删除看板(会话有回收站,看板没有),删除前请先导出备份;目录只是被移动/重命名时请用「重新定位」整体迁移,不要用删除。
|
|
20
|
+
- **会话用量**:会话列表每行显示累计 token 徽标;会话页「会话信息」面板提供 token/成本/消息数等完整统计(token 为服务端上报用量,成本按配置价目估算)。
|
|
17
21
|
- **主题**:亮/暗/跟随系统主题。
|
|
18
22
|
|
|
19
23
|
## 安装
|
|
@@ -30,7 +34,10 @@ pnpm add -g c0de-agent
|
|
|
30
34
|
|
|
31
35
|
## 安全
|
|
32
36
|
|
|
37
|
+
- **密钥存储**:`config.json` 写入时强制 `chmod 600`;`providers[].apiKey` 与 `websearch.tavilyApiKey/braveApiKey` 落盘前自动加密(AES-256-GCM,机器绑定,`enc:` 前缀),无论经 Web 设置、`c0de config set` 还是 `/config` 斜杠命令写入均不落明文。例外:`security.token`(静态模式专用)以明文存储,仅建议 CI/脚本场景使用,交互使用请走设备配对。`/config` 与 `c0de config get` 展示配置时对 apiKey/token 等字段自动脱敏。
|
|
38
|
+
- 项目级配置(`<项目>/.c0de/config.json`)含密钥且目录在 git 仓库内时,设置页会提示将 `.c0de/` 加入 `.gitignore`,防止误提交。
|
|
33
39
|
- **认证**:`security.authEnabled` 默认开启。首次启动自动生成 bootstrap token(持久化于数据目录 `auth-token` 文件),浏览器首访凭启动打印的 URL `?token=` 注册为**首台设备**,服务端随即**轮换 bootstrap token**(旧 token 立即失效,杜绝 URL/shell 历史泄漏)并下发设备 token;后续 API 请求与终端 WebSocket 均携带设备 token。新增设备无 token 时进入**配对流程**:新设备生成 6 位配对码,由已授权设备在「设备配对」弹窗中核对并批准后下发新设备 token。显式配置 `security.token` 时为静态模式(不轮换、不配对,适合 CI/脚本)。
|
|
40
|
+
- **首设备先到先得**:任何能看见启动打印 URL 的人(同机其他用户、终端输出被截图/共享)都能抢先注册为首台设备,随后真实用户只能经其配对审批。请勿在共享主机或会被录屏的场景让 URL 暴露。默认防护:bootstrap token 生成后 **5 分钟**内有效(`security.firstDeviceTtlMs` 默认值),超时需重启 `c0de serve`(无已注册设备时重启会重新生成 bootstrap,窗口随之刷新);共享主机/CI 等场景可显式配置更短的值。
|
|
34
41
|
- token 解析优先级:`security.token` 配置 > 环境变量 `C0DE_AUTH_TOKEN` > 数据目录 token 文件 > 自动生成并持久化。
|
|
35
42
|
- 显式关闭:`security.authEnabled: false`(本机单用户且端口仅本机可达时)。
|
|
36
43
|
- **设备管理**:`c0de auth list` 列出已授权设备;`c0de auth revoke <id>` 撤销设备(运行中的服务立即生效);`c0de auth reset` 清除全部设备与 token。运行中的服务会热加载 `devices.json` 的变更。
|
|
@@ -54,12 +61,24 @@ c0de serve --restore snapshot.json
|
|
|
54
61
|
# 打印模式(单次问答,不启动服务)
|
|
55
62
|
c0de chat "解释这段代码"
|
|
56
63
|
|
|
64
|
+
# serve 运行时打印模式默认拒绝(对话无法持久化),显式接受临时模式:
|
|
65
|
+
c0de chat --temp "解释这段代码"
|
|
66
|
+
|
|
57
67
|
# 更新检查
|
|
58
68
|
c0de update --check
|
|
59
69
|
```
|
|
60
70
|
|
|
61
71
|
启动后访问 `http://localhost:3000`。
|
|
62
72
|
|
|
73
|
+
> **CLI 与 Web 的会话隔离**:`c0de chat` 产生的会话标记为 `cli` 来源,不会出现在
|
|
74
|
+
> Web 界面的会话树中(Web 会话树仅展示 web 会话)。CLI 侧 `c0de sessions list` 会列出
|
|
75
|
+
> 全部会话(`[cli]`/`[web]` 标注),并可删除/恢复任意会话——这是 Web 会话的 CLI 恢复途径。
|
|
76
|
+
> 注意:数据库为单写者,`c0de sessions` / `c0de chat`(非 `--temp`)在 serve 运行期间会报错拒绝
|
|
77
|
+
> (CLI 管理需先停止 serve;运行中的服务请用 Web 界面管理)。
|
|
78
|
+
> 恢复的会话若原项目目录已不存在(孤儿会话),可用 `c0de sessions restore <id> --project <路径>`
|
|
79
|
+
> 显式归属到项目。两者存于同一数据库。`c0de chat --continue` 续接过的 CLI 会话不会被自动清理;
|
|
80
|
+
> 未续接的一次性问答会话 30 天后自动清除。
|
|
81
|
+
|
|
63
82
|
## 开发
|
|
64
83
|
|
|
65
84
|
```bash
|
|
@@ -1,4 +1,70 @@
|
|
|
1
1
|
import { runPrintMode } from '../modes/print.js';
|
|
2
|
+
/**
|
|
3
|
+
* CLI 斜杠命令拦截:与 Web chat 路由同语义(P1 跨端一致性)。
|
|
4
|
+
* 此前 print 模式把 /clear、/config 等当普通文本发给 LLM,模型会虚构执行结果;
|
|
5
|
+
* 现在 parseSlashInput + registry 拦截,未启用/未知命令回退与 Web 端一致。
|
|
6
|
+
* /compact 需要会话上下文,由消费方在此执行压缩并输出摘要。
|
|
7
|
+
*/
|
|
8
|
+
async function runSlashCommand(message, continueId, ctx) {
|
|
9
|
+
const { createSlashRegistry, parseSlashInput } = await import('../../core/slash.js');
|
|
10
|
+
const parsed = parseSlashInput(message);
|
|
11
|
+
if (!parsed)
|
|
12
|
+
return false;
|
|
13
|
+
const registry = createSlashRegistry();
|
|
14
|
+
const cmd = registry.get(parsed.name);
|
|
15
|
+
if (!cmd)
|
|
16
|
+
return false; // 未知斜杠命令:回退为正常消息发给 agent(与 Web 端一致)
|
|
17
|
+
const out = ctx.stdout ?? process.stdout.write.bind(process.stdout);
|
|
18
|
+
const err = ctx.stderr ?? process.stderr.write.bind(process.stderr);
|
|
19
|
+
const enabledList = ctx.config.slashCommands?.enabled ?? [];
|
|
20
|
+
const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
|
|
21
|
+
if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
|
|
22
|
+
err(`斜杠命令 /${parsed.name} 未启用(config.slashCommands.enabled)\n`);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const { compactContext } = await import('../../core/loop.js');
|
|
26
|
+
const { createAgent } = await import('../../core/agent.js');
|
|
27
|
+
const { getSession } = await import('../../session/session.js');
|
|
28
|
+
const result = await cmd.execute(parsed.args, {
|
|
29
|
+
cwd: ctx.deps.cwd,
|
|
30
|
+
config: ctx.config,
|
|
31
|
+
deps: ctx.deps,
|
|
32
|
+
sessionId: continueId,
|
|
33
|
+
// 消费渠道:/model 等命令按渠道给指引(CLI 无底部模型选择器)。
|
|
34
|
+
channel: 'cli',
|
|
35
|
+
});
|
|
36
|
+
if (result._tag === 'compact') {
|
|
37
|
+
// /compact:消费方执行压缩(Web 端由 chat 路由消费)。需要会话上下文。
|
|
38
|
+
if (!continueId) {
|
|
39
|
+
err('/compact 需要会话上下文:请加 --continue <session-id> 指定会话\n');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const session = await getSession(ctx.deps.db, continueId);
|
|
43
|
+
if (!session) {
|
|
44
|
+
throw new Error(`session not found: ${continueId}`);
|
|
45
|
+
}
|
|
46
|
+
const agentConfig = {
|
|
47
|
+
provider: ctx.config.defaultProvider,
|
|
48
|
+
model: ctx.config.defaultModel,
|
|
49
|
+
tools: [],
|
|
50
|
+
plugins: ctx.config.plugins.enabled,
|
|
51
|
+
agentName: 'default',
|
|
52
|
+
};
|
|
53
|
+
const state = await createAgent(session, agentConfig, ctx.deps);
|
|
54
|
+
for await (const event of compactContext(state, ctx.deps)) {
|
|
55
|
+
if (event._tag === 'text_delta')
|
|
56
|
+
out(`${event.text}\n`);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (result._tag === 'error') {
|
|
61
|
+
err(`${result.message}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const text = result._tag === 'success' ? result.message : result.text;
|
|
65
|
+
out(`${text}\n`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
2
68
|
async function runChatCommand(ctx) {
|
|
3
69
|
const message = ctx.args.positionals.join(' ').trim();
|
|
4
70
|
if (!message)
|
|
@@ -7,7 +73,22 @@ async function runChatCommand(ctx) {
|
|
|
7
73
|
const err = ctx.stderr ?? process.stderr.write.bind(process.stderr);
|
|
8
74
|
const format = ctx.args.options.format ?? 'text';
|
|
9
75
|
const model = ctx.args.options.model;
|
|
10
|
-
|
|
76
|
+
let continueId = ctx.args.options.continue;
|
|
77
|
+
// --continue last:续接最近一次会话,免手抄 UUID(P3 产品发现性)。
|
|
78
|
+
if (continueId === 'last') {
|
|
79
|
+
const { listAllSessions } = await import('../../session/session.js');
|
|
80
|
+
const sessions = await listAllSessions(ctx.deps.db);
|
|
81
|
+
if (sessions.length === 0) {
|
|
82
|
+
throw new Error('chat: no sessions to continue (run c0de chat once first)');
|
|
83
|
+
}
|
|
84
|
+
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
85
|
+
continueId = sessions[0]?.id;
|
|
86
|
+
if (!continueId)
|
|
87
|
+
throw new Error('chat: no sessions to continue');
|
|
88
|
+
}
|
|
89
|
+
// 斜杠命令拦截:命中则执行本地语义,不把命令文本发给 LLM。
|
|
90
|
+
if (await runSlashCommand(message, continueId, ctx))
|
|
91
|
+
return;
|
|
11
92
|
const text = await runPrintMode(ctx.config, message, ctx.deps, {
|
|
12
93
|
...(model ? { model } : {}),
|
|
13
94
|
...(continueId ? { sessionId: continueId } : {}),
|
|
@@ -32,5 +113,10 @@ async function runChatCommand(ctx) {
|
|
|
32
113
|
else {
|
|
33
114
|
out(`${text}\n`);
|
|
34
115
|
}
|
|
116
|
+
// P2-5:一次性 CLI 问答不进 Web 会话树,明确告知查看/续接途径,
|
|
117
|
+
// 避免「我的会话去哪了」(--continue 续接时用户已知会话存在,不提示)。
|
|
118
|
+
if (!continueId) {
|
|
119
|
+
err('此问答会话不会出现在 Web 界面;可用 `c0de sessions list` 查看,或 `c0de chat --continue` 续接。\n');
|
|
120
|
+
}
|
|
35
121
|
}
|
|
36
122
|
export { runChatCommand };
|
|
@@ -6,13 +6,21 @@ async function runConfigCommand(ctx) {
|
|
|
6
6
|
const scopes = loadConfigScopes(ctx.cwd);
|
|
7
7
|
// get 展示用合并视图(global ← project,含默认值),与 loadConfig 语义一致。
|
|
8
8
|
const config = mergeConfig(scopes.global, scopes.project);
|
|
9
|
+
// 未知顶层键告警(loadConfig 只在 serve/chat 启动路径调用;config 命令走 loadConfigScopes)。
|
|
10
|
+
const { warnUnknownConfigKeys } = await import('../../core/config.js');
|
|
11
|
+
warnUnknownConfigKeys('global', scopes.global);
|
|
12
|
+
warnUnknownConfigKeys('project', scopes.project);
|
|
9
13
|
if (sub === 'get') {
|
|
10
14
|
const key = ctx.args.positionals[1];
|
|
15
|
+
const { redactSecrets } = await import('../../core/redact.js');
|
|
11
16
|
if (!key) {
|
|
12
|
-
|
|
17
|
+
// 展示前脱敏:apiKey/token 明文绝不输出到终端(P0 密钥暴露)。
|
|
18
|
+
write(`${JSON.stringify(redactSecrets(config), null, 2)}\n`);
|
|
13
19
|
return;
|
|
14
20
|
}
|
|
15
|
-
|
|
21
|
+
// 用点路径末段作为键名参与脱敏判定:config get security.token 等单键读取同样掩码。
|
|
22
|
+
const leaf = key.split('.').pop();
|
|
23
|
+
const val = redactSecrets(getByPath(config, key), leaf);
|
|
16
24
|
write(`${typeof val === 'object' ? JSON.stringify(val) : String(val)}\n`);
|
|
17
25
|
return;
|
|
18
26
|
}
|
|
@@ -23,6 +31,12 @@ async function runConfigCommand(ctx) {
|
|
|
23
31
|
throw new Error('config set: a key is required');
|
|
24
32
|
if (rawArg === undefined)
|
|
25
33
|
throw new Error('config set: a value is required');
|
|
34
|
+
// 顶层键校验:拒绝拼写错误/过时键,避免写入永不生效的配置(此前静默接受)。
|
|
35
|
+
const { KNOWN_CONFIG_KEYS } = await import('../../core/config.js');
|
|
36
|
+
const topKey = key.split('.')[0] ?? '';
|
|
37
|
+
if (!KNOWN_CONFIG_KEYS.has(topKey)) {
|
|
38
|
+
throw new Error(`config set: 未知的顶层配置键 "${topKey}"。有效键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
|
|
39
|
+
}
|
|
26
40
|
const scope = ctx.args.options.global ? 'global' : 'project';
|
|
27
41
|
// 只把 patch 合并进目标作用域的原始文件,不写入默认值与其他作用域的配置(P2-3)。
|
|
28
42
|
// 值为 null → 删除该键(作用域内取消覆盖,回落另一作用域/默认值)。
|
|
@@ -30,7 +44,7 @@ async function runConfigCommand(ctx) {
|
|
|
30
44
|
const value = coerce(rawArg);
|
|
31
45
|
const next = applyScopedPatch(scopeCfg ?? {}, setPathPatch(key, value));
|
|
32
46
|
await saveConfigScoped(scope, ctx.cwd, next);
|
|
33
|
-
write(`${value === null ? '
|
|
47
|
+
write(`${value === null ? '已取消设置' : '已设置'} ${key} (scope: ${scope})\n`);
|
|
34
48
|
return;
|
|
35
49
|
}
|
|
36
50
|
throw new Error(`config: unknown subcommand "${sub ?? ''}" (expected get|set)`);
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// c0de chat 每次运行都创建 source='cli' 会话且被 Web 会话树排除,
|
|
4
4
|
// 此前无任何 CLI 途径列出/清理,垃圾数据只增不减。
|
|
5
5
|
// 复用 withAgentDeps 的持久库生命周期(serve 占用时退化为内存库则明确报错)。
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// P1 闭环修复:delete 移入回收站后必须有 CLI 恢复途径(此前文案承诺
|
|
8
|
+
// 「60 天内可恢复」但 CLI 无 restore 子命令,恢复只能绕道 Web 界面)。
|
|
9
|
+
import { fromDirectory } from '../../project/project.js';
|
|
10
|
+
import { getSession, listAllSessions, listDeletedSessions, rebindSession, restoreSession, softDeleteSession, } from '../../session/session.js';
|
|
7
11
|
async function runSessionsCommand(ctx) {
|
|
8
12
|
const write = ctx.write ?? ((s) => process.stdout.write(s));
|
|
9
13
|
const sub = ctx.args.positionals[0] ?? 'list';
|
|
@@ -27,9 +31,64 @@ async function runSessionsCommand(ctx) {
|
|
|
27
31
|
const ok = await softDeleteSession(ctx.db, id);
|
|
28
32
|
if (!ok)
|
|
29
33
|
throw new Error(`sessions delete: session not found or already deleted: ${id}`);
|
|
30
|
-
write(`已删除会话 ${id}(移入回收站,
|
|
34
|
+
write(`已删除会话 ${id}(移入回收站,60 天内可用 \`c0de sessions restore ${id}\` 恢复)。\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (sub === 'restore') {
|
|
38
|
+
const id = ctx.args.positionals[1];
|
|
39
|
+
if (!id)
|
|
40
|
+
throw new Error('sessions restore: a session id is required (use `c0de sessions list`)');
|
|
41
|
+
const ok = await restoreSession(ctx.db, id);
|
|
42
|
+
if (!ok)
|
|
43
|
+
throw new Error(`sessions restore: session not found or not deleted: ${id}`);
|
|
44
|
+
// P2-2:与 Web restore 对齐——会话项目已删除(FK set null)时按 worktreePath
|
|
45
|
+
// 重建归属,避免恢复成功但在 Web 各项目视图不可达。
|
|
46
|
+
// P2 修复:--project <path> 提供显式归属出口(Web 有「归属到当前项目」,
|
|
47
|
+
// CLI 此前只有警告、用户自己修不了)。
|
|
48
|
+
const projectPath = ctx.args.options.project;
|
|
49
|
+
const session = await getSession(ctx.db, id);
|
|
50
|
+
if (session && !session.projectId && (session.worktreePath || projectPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const { existsSync } = await import('node:fs');
|
|
53
|
+
const target = projectPath ?? session.worktreePath;
|
|
54
|
+
if (target && existsSync(target)) {
|
|
55
|
+
const project = await fromDirectory(ctx.db, target);
|
|
56
|
+
await rebindSession(ctx.db, id, project);
|
|
57
|
+
write(`已恢复会话 ${id}(已重新归属到项目 ${project.id})。\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (projectPath) {
|
|
61
|
+
throw new Error(`项目目录不存在:${projectPath}`);
|
|
62
|
+
}
|
|
63
|
+
write(`已恢复会话 ${id}(警告:原项目目录不存在,会话未归属任何项目,Web 不可见)。\n`);
|
|
64
|
+
write(`提示:c0de sessions restore ${id} --project <项目路径> 可显式归属。\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
write(`已恢复会话 ${id}(警告:项目归属失败——${error instanceof Error ? error.message : String(error)},会话可能不可见)。\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (session?.projectId && projectPath) {
|
|
73
|
+
write(`已恢复会话 ${id}(会话已有项目归属,忽略 --project)。\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
write(`已恢复会话 ${id}。\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// 回收站列表:软删除会话 + 剩余保留信息(辅助 restore)
|
|
80
|
+
if (sub === 'deleted') {
|
|
81
|
+
const sessions = await listDeletedSessions(ctx.db);
|
|
82
|
+
if (sessions.length === 0) {
|
|
83
|
+
write('回收站为空。\n');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const s of sessions) {
|
|
87
|
+
const when = s.deletedAt ? new Date(s.deletedAt).toISOString() : '-';
|
|
88
|
+
write(`- ${s.title} (id: ${s.id}, deleted ${when})\n`);
|
|
89
|
+
}
|
|
31
90
|
return;
|
|
32
91
|
}
|
|
33
|
-
throw new Error(`sessions: unknown subcommand "${sub}" (expected list|delete)`);
|
|
92
|
+
throw new Error(`sessions: unknown subcommand "${sub}" (expected list|delete|restore|deleted)`);
|
|
34
93
|
}
|
|
35
94
|
export { runSessionsCommand };
|
package/dist/cli/index.js
CHANGED
|
@@ -25,6 +25,9 @@ const COMMANDS = [
|
|
|
25
25
|
{ name: 'format', type: 'string' },
|
|
26
26
|
{ name: 'yes', type: 'boolean', short: 'y' },
|
|
27
27
|
{ name: 'continue', type: 'string' },
|
|
28
|
+
// P2 修复:serve 运行时持久库被占用,此前静默退化为内存库(会话不保存,
|
|
29
|
+
// 仅 stderr 一行提示极易错过)。现须显式 --temp 才允许临时模式。
|
|
30
|
+
{ name: 'temp', type: 'boolean' },
|
|
28
31
|
],
|
|
29
32
|
},
|
|
30
33
|
{
|
|
@@ -59,13 +62,16 @@ const COMMANDS = [
|
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
name: 'sessions',
|
|
62
|
-
description: 'Manage CLI/web sessions (list / delete).',
|
|
63
|
-
options: [],
|
|
65
|
+
description: 'Manage CLI/web sessions (list / delete / restore / deleted).',
|
|
66
|
+
options: [{ name: 'project', type: 'string' }],
|
|
64
67
|
},
|
|
65
68
|
{
|
|
66
69
|
name: 'acp',
|
|
67
70
|
description: 'Run in Agent Client Protocol mode (editor integration).',
|
|
68
|
-
options: [
|
|
71
|
+
options: [
|
|
72
|
+
// serve 占用持久库时的显式临时模式开关(与 chat --temp 同语义)。
|
|
73
|
+
{ name: 'temp', type: 'boolean' },
|
|
74
|
+
],
|
|
69
75
|
},
|
|
70
76
|
{
|
|
71
77
|
name: 'update',
|
|
@@ -109,14 +115,22 @@ async function withAgentDeps(cwd, opts, fn) {
|
|
|
109
115
|
if (!isDbLockConflict(err))
|
|
110
116
|
throw err;
|
|
111
117
|
if (opts.requirePersistent) {
|
|
112
|
-
throw new Error('c0de serve
|
|
118
|
+
throw new Error('c0de serve 正在运行并占用会话库,CLI 会话管理暂不可用(单写者限制)。\n' +
|
|
119
|
+
' 1) 在 Web 界面管理会话;\n' +
|
|
120
|
+
' 2) 或停止 serve(Ctrl+C)后重试本命令。');
|
|
113
121
|
}
|
|
114
122
|
if (opts.continueSessionId) {
|
|
115
123
|
throw new Error(`无法续聊会话 ${opts.continueSessionId}:持久库被 c0de serve 占用,内存模式无法续聊。\n` +
|
|
116
124
|
`请停止 serve 后重试,或去掉 --continue 开启新会话。`);
|
|
117
125
|
}
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
if (!opts.allowTemp) {
|
|
127
|
+
// P2 修复:锁冲突不再静默降级为不保存会话的内存模式(用户极易错过 stderr
|
|
128
|
+
// 提示导致对话丢失)。必须显式 --temp 确认放弃持久化。
|
|
129
|
+
throw new Error('c0de serve 正在运行并占用会话库,本次对话将无法保存。\n' +
|
|
130
|
+
' 1) 停止 serve 后重试(对话可持久化,推荐);\n' +
|
|
131
|
+
' 2) 或加 --temp 显式接受临时模式(消息与工具调用不会保存)。');
|
|
132
|
+
}
|
|
133
|
+
process.stderr.write('[c0de] ⚠ 临时模式:消息与工具调用不会保存。停止 serve 后重试可持久化,或直接使用浏览器界面。\n');
|
|
120
134
|
db = await createDB({ driver: 'pglite' });
|
|
121
135
|
await migrateDB(db);
|
|
122
136
|
}
|
|
@@ -155,9 +169,11 @@ async function dispatch(argv, overrides = {}) {
|
|
|
155
169
|
// --yes / -y 显式放行写操作;否则按 config.permission.defaultMode 决定(默认 safe)。
|
|
156
170
|
const strategy = args.options.yes ? 'full-auto' : undefined;
|
|
157
171
|
const continueId = args.options.continue;
|
|
172
|
+
const allowTemp = args.options.temp === true;
|
|
158
173
|
await withAgentDeps(cwd, {
|
|
159
174
|
...(strategy ? { strategy } : {}),
|
|
160
175
|
...(continueId ? { continueSessionId: continueId } : {}),
|
|
176
|
+
...(allowTemp ? { allowTemp } : {}),
|
|
161
177
|
}, (config, deps) => runChatCommand({ args, config, deps }));
|
|
162
178
|
return;
|
|
163
179
|
}
|
|
@@ -184,7 +200,10 @@ async function dispatch(argv, overrides = {}) {
|
|
|
184
200
|
}
|
|
185
201
|
case 'acp': {
|
|
186
202
|
// ACP 非交互:所有工具放行(编辑器侧自行控制执行授权)。
|
|
187
|
-
await withAgentDeps(cwd, {
|
|
203
|
+
await withAgentDeps(cwd, {
|
|
204
|
+
strategy: 'full-auto',
|
|
205
|
+
...(args.options.temp === true ? { allowTemp: true } : {}),
|
|
206
|
+
}, (config, deps) => runAcpCommand({ config, deps }));
|
|
188
207
|
return;
|
|
189
208
|
}
|
|
190
209
|
case 'update': {
|
package/dist/cli/modes/print.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createAgent, runAgent } from '../../core/agent.js';
|
|
2
2
|
import { resolveRoute } from '../../llm/registry.js';
|
|
3
|
-
import { createSession, getSession } from '../../session/session.js';
|
|
3
|
+
import { createSession, getSession, upgradeTemporarySession } from '../../session/session.js';
|
|
4
4
|
import { resolveEnabledToolNames } from '../../tools/index.js';
|
|
5
5
|
/** 从事件流累积 assistant 文本。纯函数。 */
|
|
6
6
|
function collectAssistantText(events) {
|
|
@@ -24,10 +24,17 @@ async function runPrintMode(config, message, deps, opts = {}) {
|
|
|
24
24
|
const existing = await getSession(deps.db, opts.sessionId);
|
|
25
25
|
if (!existing)
|
|
26
26
|
throw new Error(`session not found: ${opts.sessionId}`);
|
|
27
|
+
// 续接即升级为持久会话:30 天临时清理不再触及(P1:此前 --continue 的
|
|
28
|
+
// 会话同样会在 30 天不活动后被物理删除,用户显式续接的历史静默丢失)。
|
|
29
|
+
if (existing.agentType === 'print') {
|
|
30
|
+
await upgradeTemporarySession(deps.db, opts.sessionId);
|
|
31
|
+
}
|
|
27
32
|
session = existing;
|
|
28
33
|
}
|
|
29
34
|
else {
|
|
30
|
-
|
|
35
|
+
// agentType='print':一次性 print 会话标记。purgeTemporarySessions 仅清理
|
|
36
|
+
// print/workflow 会话;普通 CLI 会话(ACP 等)与 --continue 续接的历史永不误删。
|
|
37
|
+
session = await createSession(deps.db, 'cli-print', undefined, 'print', 'cli');
|
|
31
38
|
}
|
|
32
39
|
const agentConfig = {
|
|
33
40
|
provider: config.defaultProvider,
|
package/dist/core/compact.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { Registry } from '../llm/registry.js';
|
|
3
|
+
import type { FallbackChain } from '../llm/routing.js';
|
|
3
4
|
import type { CompactionConfig, CompactionResult, Summarizer } from '../session/types.js';
|
|
4
5
|
/** Create a Summarizer backed by an LLM streaming call. */
|
|
5
6
|
declare function createSummarizer(registry: Registry, provider: string, model: string, opts?: {
|
|
6
7
|
maxTokens?: number;
|
|
7
8
|
signal?: AbortSignal;
|
|
9
|
+
fallback?: FallbackChain;
|
|
8
10
|
}): Summarizer;
|
|
9
11
|
/** Run compaction on a session using the provided summarizer. */
|
|
10
12
|
declare function runCompaction(db: DB, sessionId: string, summarizer: Summarizer, config?: Partial<CompactionConfig>): Promise<CompactionResult>;
|
package/dist/core/compact.js
CHANGED
|
@@ -13,6 +13,7 @@ function createSummarizer(registry, provider, model, opts) {
|
|
|
13
13
|
for await (const chunk of chatStream({ registry, signal: opts?.signal }, request, {
|
|
14
14
|
provider,
|
|
15
15
|
model,
|
|
16
|
+
...(opts?.fallback ? { fallback: opts.fallback } : {}),
|
|
16
17
|
})) {
|
|
17
18
|
if (chunk._tag === 'text') {
|
|
18
19
|
chunks.push(chunk);
|
package/dist/core/config.d.ts
CHANGED
|
@@ -25,8 +25,17 @@ declare function applyScopedPatch(base: Record<string, unknown> | undefined, pat
|
|
|
25
25
|
* 用于「patch 合并进某个作用域的原始文件」后再写盘。
|
|
26
26
|
*/
|
|
27
27
|
declare function mergeRaw(...cfgs: (Record<string, unknown> | undefined)[]): Record<string, unknown>;
|
|
28
|
-
/** 把 raw JSON 写回指定作用域配置文件(不含默认值)。
|
|
28
|
+
/** 把 raw JSON 写回指定作用域配置文件(不含默认值)。
|
|
29
|
+
* - 落盘前对 providers[].apiKey 加密(已带 enc: 前缀/空值透传)——
|
|
30
|
+
* CLI config set 与 /config 斜杠命令共用此路径,保证「apiKey 不明文落盘」全链路成立。
|
|
31
|
+
* - 写入后 chmod 600:配置文件可能含 token/加密密钥,默认 0644 同机可读。 */
|
|
29
32
|
declare function saveConfigScoped(scope: 'global' | 'project', projectDir: string | undefined, data: Record<string, unknown>): Promise<void>;
|
|
30
33
|
declare function loadConfig(projectDir?: string): Promise<Config>;
|
|
34
|
+
/** Config 全部顶层键(DEFAULT_CONFIG + 可选键)。未知顶层键校验与告警共用。 */
|
|
35
|
+
declare const KNOWN_CONFIG_KEYS: Set<string>;
|
|
36
|
+
/** 收集对象中不属于 Config 顶层键的未知键。 */
|
|
37
|
+
declare function collectUnknownConfigKeys(data: Record<string, unknown> | undefined): string[];
|
|
38
|
+
/** 加载配置时对未知顶层键告警(拼写错误/旧版键静默失效的唯一提示)。 */
|
|
39
|
+
declare function warnUnknownConfigKeys(scope: 'global' | 'project', data: Record<string, unknown> | undefined): void;
|
|
31
40
|
export type { AgentsConfig, CompactionConfig, Config, MCPServerConfig, PermissionConfig, SecurityConfig, ToolMetricsConfig, UpdateConfig, WebSearchConfig, };
|
|
32
|
-
export { applyScopedPatch, DEFAULT_CONFIG, loadConfig, loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped, };
|
|
41
|
+
export { applyScopedPatch, collectUnknownConfigKeys, DEFAULT_CONFIG, KNOWN_CONFIG_KEYS, loadConfig, loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped, warnUnknownConfigKeys, };
|
package/dist/core/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { encryptSecret, isEncryptedSecret } from './secret.js';
|
|
4
5
|
const GLOBAL_CONFIG_DIR = '.c0de';
|
|
5
6
|
const CONFIG_FILENAME = 'config.json';
|
|
6
7
|
const DEFAULT_CONFIG = {
|
|
@@ -21,7 +22,9 @@ const DEFAULT_CONFIG = {
|
|
|
21
22
|
tools: { enabled: [], disabled: [] },
|
|
22
23
|
plugins: { enabled: [] },
|
|
23
24
|
mcpServers: [],
|
|
24
|
-
slashCommands: {
|
|
25
|
+
slashCommands: {
|
|
26
|
+
enabled: ['/compact', '/model', '/clear', '/help', '/fork', '/config', '/workflow'],
|
|
27
|
+
},
|
|
25
28
|
toolMetrics: { enabled: true, threshold: 0.8, minSamples: 5 },
|
|
26
29
|
security: { authEnabled: true, allowedOrigins: [] },
|
|
27
30
|
websearch: { provider: 'auto' },
|
|
@@ -29,7 +32,6 @@ const DEFAULT_CONFIG = {
|
|
|
29
32
|
permission: { defaultMode: 'default' },
|
|
30
33
|
update: { enabled: true, intervalMs: 60 * 60 * 1000, initialDelayMs: 10_000 },
|
|
31
34
|
theme: 'system',
|
|
32
|
-
locale: 'en',
|
|
33
35
|
};
|
|
34
36
|
function mergeConfig(...configs) {
|
|
35
37
|
const result = structuredClone(DEFAULT_CONFIG);
|
|
@@ -138,7 +140,10 @@ function mergeRaw(...cfgs) {
|
|
|
138
140
|
}
|
|
139
141
|
return result;
|
|
140
142
|
}
|
|
141
|
-
/** 把 raw JSON 写回指定作用域配置文件(不含默认值)。
|
|
143
|
+
/** 把 raw JSON 写回指定作用域配置文件(不含默认值)。
|
|
144
|
+
* - 落盘前对 providers[].apiKey 加密(已带 enc: 前缀/空值透传)——
|
|
145
|
+
* CLI config set 与 /config 斜杠命令共用此路径,保证「apiKey 不明文落盘」全链路成立。
|
|
146
|
+
* - 写入后 chmod 600:配置文件可能含 token/加密密钥,默认 0644 同机可读。 */
|
|
142
147
|
async function saveConfigScoped(scope, projectDir, data) {
|
|
143
148
|
const dir = scope === 'global'
|
|
144
149
|
? join(homedir(), GLOBAL_CONFIG_DIR)
|
|
@@ -146,13 +151,75 @@ async function saveConfigScoped(scope, projectDir, data) {
|
|
|
146
151
|
if (!existsSync(dir))
|
|
147
152
|
mkdirSync(dir, { recursive: true });
|
|
148
153
|
const path = join(dir, CONFIG_FILENAME);
|
|
149
|
-
|
|
154
|
+
const hardened = redactSensitiveOnSave(data);
|
|
155
|
+
writeFileSync(path, JSON.stringify(hardened, null, 2), 'utf-8');
|
|
156
|
+
try {
|
|
157
|
+
chmodSync(path, 0o600);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// 平台不支持(Windows 等)时忽略;内容仍已加密。
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** 落盘前的敏感值处理:providers[].apiKey 与 websearch 后端 key 明文 → enc: 加密
|
|
164
|
+
* (spec §24.2;此前 websearch.tavilyApiKey/braveApiKey 明文落盘,与 provider
|
|
165
|
+
* apiKey 安全叙事不一致——git 误提交警告会命中它们,加密却不会)。 */
|
|
166
|
+
function redactSensitiveOnSave(data) {
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [key, val] of Object.entries(data)) {
|
|
169
|
+
if (key === 'providers' && Array.isArray(val)) {
|
|
170
|
+
out[key] = val.map((p) => {
|
|
171
|
+
if (p === null || typeof p !== 'object')
|
|
172
|
+
return p;
|
|
173
|
+
const apiKey = p.apiKey;
|
|
174
|
+
if (typeof apiKey === 'string' && apiKey.length > 0 && !isEncryptedSecret(apiKey)) {
|
|
175
|
+
return { ...p, apiKey: encryptSecret(apiKey) };
|
|
176
|
+
}
|
|
177
|
+
return p;
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (key === 'websearch' && val !== null && typeof val === 'object') {
|
|
182
|
+
const ws = { ...val };
|
|
183
|
+
for (const k of ['tavilyApiKey', 'braveApiKey']) {
|
|
184
|
+
const v = ws[k];
|
|
185
|
+
if (typeof v === 'string' && v.length > 0 && !isEncryptedSecret(v)) {
|
|
186
|
+
ws[k] = encryptSecret(v);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
out[key] = ws;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
out[key] = val;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
150
195
|
}
|
|
151
196
|
async function loadConfig(projectDir) {
|
|
152
197
|
const globalPath = join(homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILENAME);
|
|
153
198
|
const projectPath = join(projectDir ?? process.cwd(), '.c0de', CONFIG_FILENAME);
|
|
154
199
|
const global = readJsonIfExists(globalPath);
|
|
155
200
|
const project = readJsonIfExists(projectPath);
|
|
201
|
+
warnUnknownConfigKeys('global', global);
|
|
202
|
+
warnUnknownConfigKeys('project', project);
|
|
156
203
|
return mergeConfig(global, project);
|
|
157
204
|
}
|
|
158
|
-
|
|
205
|
+
/** Config 全部顶层键(DEFAULT_CONFIG + 可选键)。未知顶层键校验与告警共用。 */
|
|
206
|
+
const KNOWN_CONFIG_KEYS = new Set([
|
|
207
|
+
...Object.keys(DEFAULT_CONFIG),
|
|
208
|
+
// 可选键不在 DEFAULT_CONFIG 中(缺失=未配置),但属于合法键。
|
|
209
|
+
'commitModel',
|
|
210
|
+
]);
|
|
211
|
+
/** 收集对象中不属于 Config 顶层键的未知键。 */
|
|
212
|
+
function collectUnknownConfigKeys(data) {
|
|
213
|
+
if (!data)
|
|
214
|
+
return [];
|
|
215
|
+
return Object.keys(data).filter((k) => !KNOWN_CONFIG_KEYS.has(k));
|
|
216
|
+
}
|
|
217
|
+
/** 加载配置时对未知顶层键告警(拼写错误/旧版键静默失效的唯一提示)。 */
|
|
218
|
+
function warnUnknownConfigKeys(scope, data) {
|
|
219
|
+
const unknown = collectUnknownConfigKeys(data);
|
|
220
|
+
if (unknown.length === 0)
|
|
221
|
+
return;
|
|
222
|
+
console.warn(`[config] ${scope} 配置包含未知键:${unknown.join(', ')}。` +
|
|
223
|
+
`这些键不会生效——请检查拼写,或移除过时配置。有效顶层键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
|
|
224
|
+
}
|
|
225
|
+
export { applyScopedPatch, collectUnknownConfigKeys, DEFAULT_CONFIG, KNOWN_CONFIG_KEYS, loadConfig, loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped, warnUnknownConfigKeys, };
|
|
@@ -27,5 +27,6 @@ export declare function recoverFromOverflow(state: AgentState, deps: LoopDeps):
|
|
|
27
27
|
* 死锁在下一轮用户输入(agentLoop 重入)时重置。
|
|
28
28
|
* - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
|
|
29
29
|
* 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
|
|
30
|
-
* 两者都静默消费 compactContext 的 text_delta
|
|
30
|
+
* 两者都静默消费 compactContext 的 text_delta 通知,但把 compaction_done 透传给前端,
|
|
31
|
+
* 让用户在上下文被改写时得到提示(可在归档面板查看原始内容)。 */
|
|
31
32
|
export declare function runCompactionIfNeeded(state: AgentState, deps: LoopDeps, latestMessages: Message[]): AsyncGenerator<AgentEvent>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildFallbackChain } from '../../llm/routing.js';
|
|
1
2
|
import { getMessages } from '../../session/message.js';
|
|
2
3
|
import { createSummarizer, runCompaction } from '../compact.js';
|
|
3
4
|
import { estimateBudget, shouldCompact } from '../context.js';
|
|
@@ -13,7 +14,13 @@ import { estimateBudget, shouldCompact } from '../context.js';
|
|
|
13
14
|
export async function* compactContext(state, deps) {
|
|
14
15
|
// summarizer 优先用 compactionModel 覆盖,否则回退当前会话 provider/model。
|
|
15
16
|
const cm = state.compactionModel;
|
|
16
|
-
const
|
|
17
|
+
const provider = cm ? cm.provider : state.config.provider;
|
|
18
|
+
const model = cm ? cm.model : state.config.model;
|
|
19
|
+
const fallback = buildFallbackChain(deps.config, provider, model);
|
|
20
|
+
const summarizer = createSummarizer(deps.llmRegistry, provider, model, {
|
|
21
|
+
signal: state.abortController.signal,
|
|
22
|
+
...(fallback ? { fallback } : {}),
|
|
23
|
+
});
|
|
17
24
|
const result = await runCompaction(deps.db, state.session.id, summarizer, {
|
|
18
25
|
keepRecentTokens: deps.config.compaction.keepRecentTokens,
|
|
19
26
|
});
|
|
@@ -77,15 +84,19 @@ export async function recoverFromOverflow(state, deps) {
|
|
|
77
84
|
* 死锁在下一轮用户输入(agentLoop 重入)时重置。
|
|
78
85
|
* - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
|
|
79
86
|
* 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
|
|
80
|
-
* 两者都静默消费 compactContext 的 text_delta
|
|
87
|
+
* 两者都静默消费 compactContext 的 text_delta 通知,但把 compaction_done 透传给前端,
|
|
88
|
+
* 让用户在上下文被改写时得到提示(可在归档面板查看原始内容)。 */
|
|
81
89
|
export async function* runCompactionIfNeeded(state, deps, latestMessages) {
|
|
82
90
|
// 死锁时跳过自动压缩:上一轮压缩已证明无法释放足够空间,重试只会无限循环。
|
|
83
91
|
if (!state.compactionDeadEnd &&
|
|
84
92
|
shouldCompact(latestMessages, state.tokenBudget, deps.config.compaction)) {
|
|
85
93
|
// 自动压缩:复用 compactContext;失败非致命,仅记录警告不中断主循环。
|
|
86
94
|
try {
|
|
87
|
-
for await (const
|
|
88
|
-
//
|
|
95
|
+
for await (const ev of compactContext(state, deps)) {
|
|
96
|
+
// 静默消费 text_delta 通知,但把 compaction_done 透传给前端——
|
|
97
|
+
// 上下文被改写,用户应被告知(可在归档面板查看原始内容),而非悄然失忆。
|
|
98
|
+
if (ev._tag === 'compaction_done')
|
|
99
|
+
yield ev;
|
|
89
100
|
}
|
|
90
101
|
// 进度保护:compactContext 已重算 state.tokenBudget.used。若压缩后仍超阈值
|
|
91
102
|
// (典型成因:keepRecentTokens 本身已超 historyBudget),压缩无法再释放足够
|
|
@@ -124,8 +135,10 @@ export async function* runCompactionIfNeeded(state, deps, latestMessages) {
|
|
|
124
135
|
// 中轮压缩静默执行:compactContext 内部已发 text_delta 通知,
|
|
125
136
|
// 不再 yield error/warning(与 turn-end 自动压缩的进度保护语义不同)。
|
|
126
137
|
try {
|
|
127
|
-
for await (const
|
|
128
|
-
//
|
|
138
|
+
for await (const ev of compactContext(state, deps)) {
|
|
139
|
+
// 静默消费 text_delta 通知,透传 compaction_done(与 turn-end 一致)。
|
|
140
|
+
if (ev._tag === 'compaction_done')
|
|
141
|
+
yield ev;
|
|
129
142
|
}
|
|
130
143
|
// 压缩改写了消息历史 → 刷新内存视图,供下一轮上下文重建使用
|
|
131
144
|
state.messages = await getMessages(deps.db, state.session.id);
|