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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isContextOverflowFailure } from '../../llm/provider-error.js';
|
|
2
|
+
import { buildFallbackChain } from '../../llm/routing.js';
|
|
2
3
|
import { isLLMError } from '../../llm/schema/errors.js';
|
|
3
4
|
import { recoverFromOverflow } from './compaction.js';
|
|
4
5
|
/** 入参是否为协议层/loop 标记的解析失败(携带 _parseError / _raw 容错标记)。
|
|
@@ -60,10 +61,17 @@ export async function* collectStreamChunks(state, deps, streamFn, request, overf
|
|
|
60
61
|
...over,
|
|
61
62
|
});
|
|
62
63
|
try {
|
|
64
|
+
// config.fallback 接线:enabled 时按配置重试参数 + 声明同模型的其他 provider 回退。
|
|
65
|
+
// 未启用时保持内置默认(3 次重试、无跨 provider 回退),行为与以往一致。
|
|
66
|
+
const fallback = buildFallbackChain(deps.config, state.config.provider, state.config.model);
|
|
63
67
|
for await (const chunk of streamFn({
|
|
64
68
|
registry: deps.llmRegistry,
|
|
65
69
|
signal: state.abortController.signal,
|
|
66
|
-
}, request, {
|
|
70
|
+
}, request, {
|
|
71
|
+
provider: state.config.provider,
|
|
72
|
+
model: state.config.model,
|
|
73
|
+
...(fallback ? { fallback } : {}),
|
|
74
|
+
})) {
|
|
67
75
|
if (firstTokenTime === null && chunk._tag !== 'done') {
|
|
68
76
|
firstTokenTime = Date.now();
|
|
69
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendMessage } from '../../session/message.js';
|
|
2
|
-
import { createSession } from '../../session/session.js';
|
|
2
|
+
import { createSession, updateSessionLastRun } from '../../session/session.js';
|
|
3
3
|
import { generateId } from '../../shared/index.js';
|
|
4
4
|
import { createAgent, runAgent } from '../agent.js';
|
|
5
5
|
import { applyPatchToParent, captureBaseline, captureDeltaPatch, createWorktree, removeWorktree, } from '../worktree.js';
|
|
@@ -35,10 +35,11 @@ export async function runSubAgent(deps, parent, request) {
|
|
|
35
35
|
description: request.description ?? '',
|
|
36
36
|
background: request.background ?? false,
|
|
37
37
|
});
|
|
38
|
-
// 2. 创建子 session(记录 agentType
|
|
38
|
+
// 2. 创建子 session(记录 agentType;parentId 挂到父会话——树内嵌套、
|
|
39
|
+
// 删除父会话时级联,不再是游离根节点)
|
|
39
40
|
let childSession;
|
|
40
41
|
try {
|
|
41
|
-
childSession = await createSession(deps.db, title, parent.session.projectId ?? undefined, request.agentType);
|
|
42
|
+
childSession = await createSession(deps.db, title, parent.session.projectId ?? undefined, request.agentType, parent.session.source ?? undefined, parent.session.id);
|
|
42
43
|
}
|
|
43
44
|
catch (e) {
|
|
44
45
|
return { _tag: 'error', error: e instanceof Error ? e.message : String(e) };
|
|
@@ -147,13 +148,30 @@ export async function runSubAgent(deps, parent, request) {
|
|
|
147
148
|
// 6. background 模式:fork 异步运行,立即返回 running;完成时向父 session 注入合成通知
|
|
148
149
|
if (request.background) {
|
|
149
150
|
const jobId = childSession.id;
|
|
151
|
+
// P2:落库 running 标记——进程崩溃后 markDeadBackgroundJobs 可识别悬空任务
|
|
152
|
+
// 并给父会话发失败通知(此前仅内存 jobId,重启即静默丢失)。
|
|
153
|
+
// 此写 await 完成再返回:保证「返回 running」时 DB 已记录;同时避免与
|
|
154
|
+
// 子 loop 的并发写入叠加。
|
|
155
|
+
await updateSessionLastRun(deps.db, childSession.id, {
|
|
156
|
+
status: 'running',
|
|
157
|
+
agentName: request.agentType,
|
|
158
|
+
startedAt: Date.now(),
|
|
159
|
+
}).catch(() => { });
|
|
150
160
|
void runBody()
|
|
151
|
-
.then((result) => {
|
|
161
|
+
.then(async (result) => {
|
|
162
|
+
// 无论成败都置 completed:任务已终结,状态体现在合成通知里。
|
|
163
|
+
// 顺序 await(不并发发起):PGLite WASM 对同实例并发查询会忙等,
|
|
164
|
+
// 与 appendMessage 并发叠加曾导致 100% CPU 自旋(loop.test.ts 复现)。
|
|
165
|
+
await updateSessionLastRun(deps.db, childSession.id, {
|
|
166
|
+
status: 'completed',
|
|
167
|
+
agentName: request.agentType,
|
|
168
|
+
startedAt: Date.now(),
|
|
169
|
+
}).catch(() => { });
|
|
152
170
|
const success = result._tag === 'success';
|
|
153
171
|
const output = success ? result.output : result.error;
|
|
154
172
|
const tag = success ? 'task_result' : 'task_error';
|
|
155
173
|
const synthetic = `<task id="${childSession.id}" state="${success ? 'completed' : 'failed'}">\n<${tag}>\n${output}\n</${tag}>\n</task>`;
|
|
156
|
-
|
|
174
|
+
await appendMessage(deps.db, parent.session.id, {
|
|
157
175
|
role: 'user',
|
|
158
176
|
content: [{ _tag: 'text', text: synthetic }],
|
|
159
177
|
}).catch((e) => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏感字段脱敏工具:/config 斜杠命令与 `c0de config get` 展示配置时使用,
|
|
3
|
+
* 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
|
|
4
|
+
*/
|
|
5
|
+
export declare function isSecretKey(key: string): boolean;
|
|
6
|
+
/** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
|
|
7
|
+
export declare function maskSecret(value: unknown): unknown;
|
|
8
|
+
/** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
|
|
9
|
+
export declare function redactSecrets(value: unknown, key?: string): unknown;
|
|
10
|
+
/** 判断对象树中是否含非空敏感值(用于「配置文件含密钥」类警告)。 */
|
|
11
|
+
export declare function containsSecrets(value: unknown): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏感字段脱敏工具:/config 斜杠命令与 `c0de config get` 展示配置时使用,
|
|
3
|
+
* 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
|
|
4
|
+
*/
|
|
5
|
+
/** 敏感字段名模式(大小写不敏感,匹配完整键名)。 */
|
|
6
|
+
const SECRET_KEY_RE = /(api[_-]?key|token|password|passwd|secret|authorization|auth[_-]?token)$/i;
|
|
7
|
+
export function isSecretKey(key) {
|
|
8
|
+
return SECRET_KEY_RE.test(key);
|
|
9
|
+
}
|
|
10
|
+
/** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
|
|
11
|
+
export function maskSecret(value) {
|
|
12
|
+
if (typeof value !== 'string')
|
|
13
|
+
return '****';
|
|
14
|
+
if (value.length <= 8)
|
|
15
|
+
return '****';
|
|
16
|
+
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
|
17
|
+
}
|
|
18
|
+
/** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
|
|
19
|
+
export function redactSecrets(value, key) {
|
|
20
|
+
if (typeof key === 'string' && isSecretKey(key))
|
|
21
|
+
return maskSecret(value);
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map((v) => redactSecrets(v));
|
|
24
|
+
if (value !== null && typeof value === 'object') {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [k, v] of Object.entries(value)) {
|
|
27
|
+
out[k] = redactSecrets(v, k);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** 判断对象树中是否含非空敏感值(用于「配置文件含密钥」类警告)。 */
|
|
34
|
+
export function containsSecrets(value) {
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value.some((v) => containsSecrets(v));
|
|
37
|
+
if (value !== null && typeof value === 'object') {
|
|
38
|
+
for (const [k, v] of Object.entries(value)) {
|
|
39
|
+
if (isSecretKey(k)) {
|
|
40
|
+
if (typeof v === 'string' ? v.length > 0 : v != null)
|
|
41
|
+
return true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (containsSecrets(v))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
package/dist/core/slash.js
CHANGED
|
@@ -25,23 +25,28 @@ function createSlashRegistry() {
|
|
|
25
25
|
}
|
|
26
26
|
const helpCommand = {
|
|
27
27
|
name: 'help',
|
|
28
|
-
description: '
|
|
29
|
-
execute: async () => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
];
|
|
28
|
+
description: '列出可用斜杠命令',
|
|
29
|
+
execute: async (_args, ctx) => {
|
|
30
|
+
// 从 registry 动态生成,避免静态文案随命令增删漂移;
|
|
31
|
+
// 尊重 slashCommands.enabled 过滤(空 = 全部启用,与消费方语义一致)。
|
|
32
|
+
const enabledList = ctx.config.slashCommands?.enabled ?? [];
|
|
33
|
+
const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
|
|
34
|
+
const commands = createSlashRegistry()
|
|
35
|
+
.list()
|
|
36
|
+
.filter((c) => enabledSet.size === 0 || enabledSet.has(c.name));
|
|
37
|
+
const width = Math.max(...commands.map((c) => `/${c.name}`.length));
|
|
38
|
+
const lines = ['可用命令:'];
|
|
39
|
+
for (const cmd of commands) {
|
|
40
|
+
const hint = cmd.argsHint ? ` ${cmd.argsHint}` : '';
|
|
41
|
+
const pad = ' '.repeat(Math.max(1, width - `/${cmd.name}`.length));
|
|
42
|
+
lines.push(` /${cmd.name}${pad}${hint} ${cmd.description}`);
|
|
43
|
+
}
|
|
39
44
|
return { _tag: 'text', text: lines.join('\n') };
|
|
40
45
|
},
|
|
41
46
|
};
|
|
42
47
|
const compactCommand = {
|
|
43
48
|
name: 'compact',
|
|
44
|
-
description: '
|
|
49
|
+
description: '手动触发上下文压缩',
|
|
45
50
|
execute: async () => {
|
|
46
51
|
// 仅声明意图:真正的压缩由消费方(loop.compactContext / chat 路由)执行,
|
|
47
52
|
// 复用 createSummarizer + runCompaction,且不把 /compact 当作 user 消息发给 LLM。
|
|
@@ -50,20 +55,47 @@ const compactCommand = {
|
|
|
50
55
|
};
|
|
51
56
|
const modelCommand = {
|
|
52
57
|
name: 'model',
|
|
53
|
-
description: '
|
|
58
|
+
description: '查看当前会话使用的模型',
|
|
54
59
|
argsHint: '',
|
|
55
|
-
execute: async () => {
|
|
56
|
-
//
|
|
57
|
-
//
|
|
60
|
+
execute: async (_args, ctx) => {
|
|
61
|
+
// P2:诚实化——此前返回「Model set to X」但什么都不生效,后改为引导文案。
|
|
62
|
+
// 现在提供真实信息:优先读本会话活跃段的 provider/model(若存在),
|
|
63
|
+
// 否则回退默认配置。会话模型切换由底部 ModelSelector 完成(Web);
|
|
64
|
+
// CLI 按渠道给出可操作指引(P3:此前 CLI 输出指向 Web-only 控件,是死路)。
|
|
65
|
+
const model = async () => {
|
|
66
|
+
if (!ctx.sessionId)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const { getLLMSegments } = await import('../session/session.js');
|
|
70
|
+
const segs = await getLLMSegments(ctx.deps.db, ctx.sessionId);
|
|
71
|
+
const last = segs[segs.length - 1];
|
|
72
|
+
if (last)
|
|
73
|
+
return `${last.model}(provider: ${last.provider})`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// 段查询失败回退默认值展示
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const sessionModel = await model();
|
|
81
|
+
if (ctx.channel === 'cli') {
|
|
82
|
+
return {
|
|
83
|
+
_tag: 'text',
|
|
84
|
+
text: `${sessionModel ? `当前会话模型:${sessionModel}` : `默认模型:${ctx.config.defaultModel}(provider: ${ctx.config.defaultProvider})`}\n` +
|
|
85
|
+
'CLI 模式不支持会话内切换模型:新会话用 c0de chat --model <model>,' +
|
|
86
|
+
'或 c0de config set defaultModel <model> 修改默认模型。',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
58
89
|
return {
|
|
59
90
|
_tag: 'text',
|
|
60
|
-
text:
|
|
91
|
+
text: `${sessionModel ? `当前会话模型:${sessionModel}` : `默认模型:${ctx.config.defaultModel}(provider: ${ctx.config.defaultProvider})`}\n` +
|
|
92
|
+
'切换模型请使用聊天页底部的模型选择器。',
|
|
61
93
|
};
|
|
62
94
|
},
|
|
63
95
|
};
|
|
64
96
|
const clearCommand = {
|
|
65
97
|
name: 'clear',
|
|
66
|
-
description: '
|
|
98
|
+
description: '清空会话消息(先归档原始内容)',
|
|
67
99
|
argsHint: '[session-id] [--yes]',
|
|
68
100
|
execute: async (args, ctx) => {
|
|
69
101
|
const parts = args.split(/\s+/).filter(Boolean);
|
|
@@ -75,7 +107,7 @@ const clearCommand = {
|
|
|
75
107
|
if (!yes) {
|
|
76
108
|
return {
|
|
77
109
|
_tag: 'error',
|
|
78
|
-
message: '
|
|
110
|
+
message: '清空消息不可逆。原始消息将归档到「会话归档」面板(会话页顶部「归档」按钮可查看/搜索),确认请执行 /clear --yes(或 /clear <session-id> --yes)。',
|
|
79
111
|
};
|
|
80
112
|
}
|
|
81
113
|
const { getEntries, deleteEntriesByIds } = await import('../session/message.js');
|
|
@@ -88,12 +120,15 @@ const clearCommand = {
|
|
|
88
120
|
await archiveOriginalEntries(ctx.deps.db, sessionId, entries, 'clear', `Cleared ${ids.length} entries`, generateId());
|
|
89
121
|
await deleteEntriesByIds(ctx.deps.db, ids);
|
|
90
122
|
}
|
|
91
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
_tag: 'success',
|
|
125
|
+
message: `已清空 ${ids.length} 条消息(原内容已归档,可在会话页「归档」面板查看)`,
|
|
126
|
+
};
|
|
92
127
|
},
|
|
93
128
|
};
|
|
94
129
|
const forkCommand = {
|
|
95
130
|
name: 'fork',
|
|
96
|
-
description: '
|
|
131
|
+
description: '从指定消息处分支会话',
|
|
97
132
|
argsHint: '[session-id] [message-index]',
|
|
98
133
|
execute: async (args, ctx) => {
|
|
99
134
|
const parts = args.split(/\s+/).filter(Boolean);
|
|
@@ -126,7 +161,7 @@ const forkCommand = {
|
|
|
126
161
|
const { forkSession } = await import('../session/branch.js');
|
|
127
162
|
try {
|
|
128
163
|
const forked = await forkSession(ctx.deps.db, sessionId, messageIndex);
|
|
129
|
-
return { _tag: 'success', message:
|
|
164
|
+
return { _tag: 'success', message: `已分支到新会话:${forked.id}` };
|
|
130
165
|
}
|
|
131
166
|
catch (error) {
|
|
132
167
|
return {
|
|
@@ -138,19 +173,29 @@ const forkCommand = {
|
|
|
138
173
|
};
|
|
139
174
|
const configCommand = {
|
|
140
175
|
name: 'config',
|
|
141
|
-
description: '
|
|
176
|
+
description: '查看或设置配置(支持点路径)',
|
|
142
177
|
argsHint: '[key] [value]',
|
|
143
178
|
execute: async (args, ctx) => {
|
|
144
179
|
const { getByPath, setPathPatch, coerce } = await import('./config-path.js');
|
|
180
|
+
// 展示前脱敏:apiKey/token 等敏感字段绝不原样出现在聊天里(P0 密钥暴露)。
|
|
181
|
+
const { redactSecrets } = await import('./redact.js');
|
|
145
182
|
if (!args) {
|
|
146
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
_tag: 'text',
|
|
185
|
+
text: `(合并视图:global + project 作用域;修改请用 /config <key> <value>,写入 project 作用域)\n${JSON.stringify(redactSecrets(ctx.config), null, 2)}`,
|
|
186
|
+
};
|
|
147
187
|
}
|
|
148
188
|
const parts = args.split(/\s+/);
|
|
149
189
|
const key = parts[0] ?? '';
|
|
150
190
|
if (parts.length === 1) {
|
|
151
191
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
192
|
+
// 用点路径末段作为键名参与脱敏判定:/config security.token 等单键读取同样掩码。
|
|
193
|
+
const leaf = key.split('.').pop();
|
|
194
|
+
const value = redactSecrets(getByPath(ctx.config, key), leaf);
|
|
195
|
+
return {
|
|
196
|
+
_tag: 'text',
|
|
197
|
+
text: `${key}: ${JSON.stringify(value)}(合并视图:global + project 作用域)`,
|
|
198
|
+
};
|
|
154
199
|
}
|
|
155
200
|
catch (error) {
|
|
156
201
|
return { _tag: 'error', message: error instanceof Error ? error.message : String(error) };
|
|
@@ -164,13 +209,13 @@ const configCommand = {
|
|
|
164
209
|
await saveConfigScoped('project', ctx.cwd, next);
|
|
165
210
|
return {
|
|
166
211
|
_tag: 'success',
|
|
167
|
-
message: `${value === null ? '
|
|
212
|
+
message: `${value === null ? '已取消设置' : '已设置'} ${key} (scope: project)`,
|
|
168
213
|
};
|
|
169
214
|
},
|
|
170
215
|
};
|
|
171
216
|
const workflowCommand = {
|
|
172
217
|
name: 'workflow',
|
|
173
|
-
description: '
|
|
218
|
+
description: '管理并运行工作流',
|
|
174
219
|
argsHint: '[list|run|show|create|edit] [name] [args]',
|
|
175
220
|
subcommands: [
|
|
176
221
|
{ name: 'list', description: 'List available workflows' },
|
|
@@ -201,13 +246,13 @@ const workflowCommand = {
|
|
|
201
246
|
for (const wf of projectWorkflows)
|
|
202
247
|
byName.set(wf.meta.name, wf);
|
|
203
248
|
const workflows = Array.from(byName.values());
|
|
204
|
-
const lines = ['
|
|
249
|
+
const lines = ['可用工作流:'];
|
|
205
250
|
for (const wf of workflows) {
|
|
206
251
|
const phases = wf.meta.phases ? ` [${wf.meta.phases.join('→')}]` : '';
|
|
207
252
|
lines.push(` /${wf.meta.name}${phases} — ${wf.meta.description} (${wf.source})`);
|
|
208
253
|
}
|
|
209
254
|
lines.push('');
|
|
210
|
-
lines.push('
|
|
255
|
+
lines.push('用法:');
|
|
211
256
|
lines.push(' /workflow run <name> [args] — 执行工作流');
|
|
212
257
|
lines.push(' /workflow create <name> --file <path> — 从文件创建工作流');
|
|
213
258
|
lines.push(' /workflow edit <name> — 编辑工作流源码');
|
|
@@ -220,7 +265,7 @@ const workflowCommand = {
|
|
|
220
265
|
return { _tag: 'error', message: 'Usage: /workflow show <name>' };
|
|
221
266
|
const wf = resolveEntry(name);
|
|
222
267
|
if (!wf) {
|
|
223
|
-
return { _tag: 'error', message:
|
|
268
|
+
return { _tag: 'error', message: `未知工作流:${name}` };
|
|
224
269
|
}
|
|
225
270
|
const code = wf.sourceCode ?? '// source not available';
|
|
226
271
|
return {
|
|
@@ -246,7 +291,7 @@ const workflowCommand = {
|
|
|
246
291
|
source = await import('node:fs/promises').then((fs) => fs.readFile(filePath, 'utf-8'));
|
|
247
292
|
}
|
|
248
293
|
catch {
|
|
249
|
-
return { _tag: 'error', message:
|
|
294
|
+
return { _tag: 'error', message: `无法读取文件:${filePath}` };
|
|
250
295
|
}
|
|
251
296
|
const result = await saveWorkflow(name, source, 'project', ctx.cwd);
|
|
252
297
|
if (!result.ok) {
|
|
@@ -258,7 +303,7 @@ const workflowCommand = {
|
|
|
258
303
|
}
|
|
259
304
|
return {
|
|
260
305
|
_tag: 'success',
|
|
261
|
-
message:
|
|
306
|
+
message: `工作流 "${name}" 已保存到 ${result.filePath}\n现在可以用 /workflow run ${name} 执行,或在对话中输入 /${name} 调用。`,
|
|
262
307
|
};
|
|
263
308
|
}
|
|
264
309
|
if (subcommand === 'edit') {
|
|
@@ -267,30 +312,27 @@ const workflowCommand = {
|
|
|
267
312
|
return { _tag: 'error', message: 'Usage: /workflow edit <name>' };
|
|
268
313
|
const wf = resolveEntry(name);
|
|
269
314
|
if (!wf) {
|
|
270
|
-
return { _tag: 'error', message:
|
|
315
|
+
return { _tag: 'error', message: `未知工作流:${name}` };
|
|
271
316
|
}
|
|
272
317
|
if (wf.source === 'builtin') {
|
|
273
318
|
return {
|
|
274
319
|
_tag: 'error',
|
|
275
|
-
message: '
|
|
320
|
+
message: '内置工作流不可编辑。请先复制:/workflow create <新名称> --file <路径>',
|
|
276
321
|
};
|
|
277
322
|
}
|
|
278
323
|
if (!wf.filePath) {
|
|
279
|
-
return { _tag: 'error', message:
|
|
280
|
-
}
|
|
281
|
-
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
|
282
|
-
try {
|
|
283
|
-
const { spawnSync } = await import('node:child_process');
|
|
284
|
-
spawnSync(editor, [wf.filePath], { stdio: 'inherit' });
|
|
324
|
+
return { _tag: 'error', message: `工作流 "${name}" 的文件路径不可用` };
|
|
285
325
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
326
|
+
// 斜杠命令经 Web SSE 执行,无法在浏览器里交互式打开终端编辑器——
|
|
327
|
+
// 在 serve 进程 spawn vi 会让用户既看不到也无法输入,SSE 流还会阻塞。
|
|
328
|
+
// 改为给出文件路径引导用户在本地编辑器/文件面板中编辑。
|
|
329
|
+
return {
|
|
330
|
+
_tag: 'error',
|
|
331
|
+
message: `Web 端不支持交互式编辑器。请用本地编辑器打开工作流文件后保存:\n` +
|
|
332
|
+
` ${wf.filePath}\n` +
|
|
333
|
+
`保存后下次 /workflow run ${name} 会读取最新内容(项目工作流按需从磁盘发现)。` +
|
|
334
|
+
`或使用 /workflow create <name> --file <path> 覆盖。`,
|
|
335
|
+
};
|
|
294
336
|
}
|
|
295
337
|
if (subcommand === 'run') {
|
|
296
338
|
const name = parts[1];
|
|
@@ -305,7 +347,7 @@ const workflowCommand = {
|
|
|
305
347
|
].join(', ');
|
|
306
348
|
return {
|
|
307
349
|
_tag: 'error',
|
|
308
|
-
message:
|
|
350
|
+
message: `未知工作流:"${name}"。可用:${available || '(无)'}`,
|
|
309
351
|
};
|
|
310
352
|
}
|
|
311
353
|
const agentConfig = {
|
|
@@ -328,7 +370,7 @@ const workflowCommand = {
|
|
|
328
370
|
}
|
|
329
371
|
return {
|
|
330
372
|
_tag: 'error',
|
|
331
|
-
message:
|
|
373
|
+
message: `未知子命令:${subcommand}。可用:list, run, show, create, edit`,
|
|
332
374
|
};
|
|
333
375
|
},
|
|
334
376
|
};
|
package/dist/core/title.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chat as defaultChat } from '../llm/provider.js';
|
|
2
|
+
import { buildFallbackChain } from '../llm/routing.js';
|
|
2
3
|
import { updateSessionTitle } from '../session/session.js';
|
|
3
4
|
/** 会话默认占位标题;仍是该值时才有资格自动生成。 */
|
|
4
5
|
const DEFAULT_SESSION_TITLE = 'New Session';
|
|
@@ -102,7 +103,12 @@ async function generateSessionTitle(deps, sessionId, firstMessage, chatProvider,
|
|
|
102
103
|
stream: true,
|
|
103
104
|
system: TITLE_PROMPT,
|
|
104
105
|
};
|
|
105
|
-
const
|
|
106
|
+
const fallback = buildFallbackChain(config, provider, model);
|
|
107
|
+
const text = await chatFn({ registry: llmRegistry }, request, {
|
|
108
|
+
provider,
|
|
109
|
+
model,
|
|
110
|
+
...(fallback ? { fallback } : {}),
|
|
111
|
+
});
|
|
106
112
|
const title = cleanTitle(text);
|
|
107
113
|
if (title.length === 0)
|
|
108
114
|
return;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -90,6 +90,8 @@ type CommandContext = {
|
|
|
90
90
|
/** 当前会话 id(Web chat 路由注入):/clear /fork 等命令默认作用于当前会话。 */
|
|
91
91
|
sessionId?: string;
|
|
92
92
|
workflowRegistry?: import('./workflows/registry.js').WorkflowRegistry;
|
|
93
|
+
/** 消费渠道(P3):/model 等命令按渠道给可操作指引(CLI 无底部模型选择器)。 */
|
|
94
|
+
channel?: 'web' | 'cli';
|
|
93
95
|
};
|
|
94
96
|
/** Declarative subcommand definition for commands like /workflow. */
|
|
95
97
|
type SubcommandDef = {
|
package/dist/db/schema.d.ts
CHANGED
|
@@ -307,6 +307,23 @@ export declare const sessions: import("drizzle-orm/pg-core").PgTableWithColumns<
|
|
|
307
307
|
identity: undefined;
|
|
308
308
|
generated: undefined;
|
|
309
309
|
}, {}, {}>;
|
|
310
|
+
deletedBatchId: import("drizzle-orm/pg-core").PgColumn<{
|
|
311
|
+
name: "deleted_batch_id";
|
|
312
|
+
tableName: "sessions";
|
|
313
|
+
dataType: "string";
|
|
314
|
+
columnType: "PgUUID";
|
|
315
|
+
data: string;
|
|
316
|
+
driverParam: string;
|
|
317
|
+
notNull: false;
|
|
318
|
+
hasDefault: false;
|
|
319
|
+
isPrimaryKey: false;
|
|
320
|
+
isAutoincrement: false;
|
|
321
|
+
hasRuntimeDefault: false;
|
|
322
|
+
enumValues: undefined;
|
|
323
|
+
baseColumn: never;
|
|
324
|
+
identity: undefined;
|
|
325
|
+
generated: undefined;
|
|
326
|
+
}, {}, {}>;
|
|
310
327
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
311
328
|
name: "created_at";
|
|
312
329
|
tableName: "sessions";
|
package/dist/db/schema.js
CHANGED
|
@@ -31,8 +31,10 @@ export const sessions = pgTable('sessions', {
|
|
|
31
31
|
worktreePath: text('worktree_path'),
|
|
32
32
|
/** 会话来源:'web'(Web UI)/ 'cli'(CLI print 模式);null=旧数据视为 web。 */
|
|
33
33
|
source: text('source').default(sql `null`),
|
|
34
|
-
/** 软删除时间戳;null=未删除。回收站保留
|
|
34
|
+
/** 软删除时间戳;null=未删除。回收站保留 60 天后物理清除。 */
|
|
35
35
|
deletedAt: timestamp('deleted_at', { withTimezone: true }).default(sql `null`),
|
|
36
|
+
/** 删除级联批次号:同一次 softDeleteSession 的父+后代共享;恢复时仅还原同批次后代。 */
|
|
37
|
+
deletedBatchId: uuid('deleted_batch_id'),
|
|
36
38
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
37
39
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
38
40
|
}, (table) => [
|
package/dist/kanban/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createKanbanStore } from './store.js';
|
|
1
|
+
export { createKanbanStore, KanbanColumnInUseError } from './store.js';
|
package/dist/kanban/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createKanbanStore } from './store.js';
|
|
1
|
+
export { createKanbanStore, KanbanColumnInUseError } from './store.js';
|
package/dist/kanban/store.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { KanbanStore } from '../shared/types/kanban.js';
|
|
3
|
+
/** 列内仍有卡片时禁止删除该列(否则卡片静默不可见)。 */
|
|
4
|
+
declare class KanbanColumnInUseError extends Error {
|
|
5
|
+
constructor(columnNames: string[], cardCount: number);
|
|
6
|
+
}
|
|
3
7
|
/**
|
|
4
8
|
* Create a project-scoped KanbanStore backed by the given db handle.
|
|
5
9
|
* The board is created on first access (lazy) with the default 5 columns.
|
|
6
10
|
*/
|
|
7
11
|
declare function createKanbanStore(handle: DB, projectId: string): KanbanStore;
|
|
8
|
-
export { createKanbanStore };
|
|
12
|
+
export { createKanbanStore, KanbanColumnInUseError };
|
package/dist/kanban/store.js
CHANGED
|
@@ -5,6 +5,13 @@ import { DEFAULT_KANBAN_COLUMNS } from '../shared/types/kanban.js';
|
|
|
5
5
|
const DEFAULT_COLUMN_ID = 'todo';
|
|
6
6
|
/** Position increment — large gap avoids frequent re-indexing on reorder. */
|
|
7
7
|
const POSITION_GAP = 1000;
|
|
8
|
+
/** 列内仍有卡片时禁止删除该列(否则卡片静默不可见)。 */
|
|
9
|
+
class KanbanColumnInUseError extends Error {
|
|
10
|
+
constructor(columnNames, cardCount) {
|
|
11
|
+
super(`列 [${columnNames.join(', ')}] 中仍有 ${cardCount} 张卡片,无法删除。` +
|
|
12
|
+
'请先移动或删除这些卡片。');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
8
15
|
// ── Row → API mappers ──────────────────────────────────────
|
|
9
16
|
function rowToBoard(row) {
|
|
10
17
|
return {
|
|
@@ -140,7 +147,39 @@ function createKanbanStore(handle, projectId) {
|
|
|
140
147
|
await db.delete(kanbanCards).where(eq(kanbanCards.id, id));
|
|
141
148
|
},
|
|
142
149
|
async updateBoard(patch) {
|
|
143
|
-
await getOrCreateBoardId();
|
|
150
|
+
const boardId = await getOrCreateBoardId();
|
|
151
|
+
// P1-4:删除列前校验该列内是否有卡片;删除标签前把悬空 labelId 从卡片上清掉。
|
|
152
|
+
if (patch.columns !== undefined) {
|
|
153
|
+
const cards = await db
|
|
154
|
+
.select({ columnId: kanbanCards.columnId })
|
|
155
|
+
.from(kanbanCards)
|
|
156
|
+
.where(eq(kanbanCards.boardId, boardId));
|
|
157
|
+
const newColumnIds = new Set(patch.columns.map((c) => c.id));
|
|
158
|
+
const removedWithCards = new Map();
|
|
159
|
+
for (const card of cards) {
|
|
160
|
+
if (!newColumnIds.has(card.columnId)) {
|
|
161
|
+
removedWithCards.set(card.columnId, (removedWithCards.get(card.columnId) ?? 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (removedWithCards.size > 0) {
|
|
165
|
+
const total = Array.from(removedWithCards.values()).reduce((a, b) => a + b, 0);
|
|
166
|
+
throw new KanbanColumnInUseError(Array.from(removedWithCards.keys()), total);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (patch.labels !== undefined) {
|
|
170
|
+
const labelIds = new Set(patch.labels.map((l) => l.id));
|
|
171
|
+
const cards = await db
|
|
172
|
+
.select({ id: kanbanCards.id, labels: kanbanCards.labels })
|
|
173
|
+
.from(kanbanCards)
|
|
174
|
+
.where(eq(kanbanCards.boardId, boardId));
|
|
175
|
+
for (const card of cards) {
|
|
176
|
+
const current = (card.labels ?? []);
|
|
177
|
+
const kept = current.filter((lid) => labelIds.has(lid));
|
|
178
|
+
if (kept.length !== current.length) {
|
|
179
|
+
await db.update(kanbanCards).set({ labels: kept }).where(eq(kanbanCards.id, card.id));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
144
183
|
const [boardRow] = await db
|
|
145
184
|
.update(kanbanBoards)
|
|
146
185
|
.set({
|
|
@@ -153,6 +192,41 @@ function createKanbanStore(handle, projectId) {
|
|
|
153
192
|
const row = boardRow;
|
|
154
193
|
return rowToBoard(row);
|
|
155
194
|
},
|
|
195
|
+
/** 整板替换(导入):board 配置 + 卡片在单事务内重建;中途失败整体回滚,
|
|
196
|
+
* 不残留「配置已换、卡片半新半旧」的混合状态。 */
|
|
197
|
+
async replaceBoard(input) {
|
|
198
|
+
const boardId = await getOrCreateBoardId();
|
|
199
|
+
await db.transaction(async (tx) => {
|
|
200
|
+
await tx.delete(kanbanCards).where(eq(kanbanCards.boardId, boardId));
|
|
201
|
+
await tx
|
|
202
|
+
.update(kanbanBoards)
|
|
203
|
+
.set({ columns: input.columns, labels: input.labels, updatedAt: new Date() })
|
|
204
|
+
.where(eq(kanbanBoards.id, boardId));
|
|
205
|
+
if (input.cards.length > 0) {
|
|
206
|
+
await tx.insert(kanbanCards).values(input.cards.map((c) => ({
|
|
207
|
+
boardId,
|
|
208
|
+
title: c.title,
|
|
209
|
+
description: c.description ?? null,
|
|
210
|
+
columnId: c.columnId,
|
|
211
|
+
priority: c.priority,
|
|
212
|
+
position: c.position,
|
|
213
|
+
labels: c.labels,
|
|
214
|
+
})));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
const boardRow = await db
|
|
218
|
+
.select()
|
|
219
|
+
.from(kanbanBoards)
|
|
220
|
+
.where(eq(kanbanBoards.id, boardId))
|
|
221
|
+
.limit(1);
|
|
222
|
+
const board = boardRow[0];
|
|
223
|
+
const cards = await db
|
|
224
|
+
.select()
|
|
225
|
+
.from(kanbanCards)
|
|
226
|
+
.where(eq(kanbanCards.boardId, boardId))
|
|
227
|
+
.orderBy(asc(kanbanCards.columnId), asc(kanbanCards.position));
|
|
228
|
+
return { ...rowToBoard(board), cards: cards.map(rowToCard) };
|
|
229
|
+
},
|
|
156
230
|
};
|
|
157
231
|
}
|
|
158
|
-
export { createKanbanStore };
|
|
232
|
+
export { createKanbanStore, KanbanColumnInUseError };
|
package/dist/llm/index.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overri
|
|
|
8
8
|
export type { Retryable, RetryOptions } from './retry.js';
|
|
9
9
|
export { delay, retryable, withRetry } from './retry.js';
|
|
10
10
|
export type { FallbackChain } from './routing.js';
|
|
11
|
-
export { runWithFallback, shouldFallOver } from './routing.js';
|
|
11
|
+
export { buildFallbackChain, runWithFallback, shouldFallOver } from './routing.js';
|
|
12
12
|
export * from './schema/index.js';
|
|
13
13
|
export { estimateTokens } from './token.js';
|
package/dist/llm/index.js
CHANGED
|
@@ -3,6 +3,6 @@ export { buildInternalRequest, chat, chatStream, toInternalMessage, toStreamChun
|
|
|
3
3
|
export { isContextOverflow, isContextOverflowFailure } from './provider-error.js';
|
|
4
4
|
export { builtinCapabilities, createRegistry, DEFAULT_MODEL_CAPABILITIES, overrideToCapabilities, registerProvider, resolveModelByRole, resolveRoute, setRole, } from './registry.js';
|
|
5
5
|
export { delay, retryable, withRetry } from './retry.js';
|
|
6
|
-
export { runWithFallback, shouldFallOver } from './routing.js';
|
|
6
|
+
export { buildFallbackChain, runWithFallback, shouldFallOver } from './routing.js';
|
|
7
7
|
export * from './schema/index.js';
|
|
8
8
|
export { estimateTokens } from './token.js';
|
package/dist/llm/provider.js
CHANGED
|
@@ -135,7 +135,7 @@ const streamTarget = async function* (ctx, request, target, chain) {
|
|
|
135
135
|
headers: { authorization: `Bearer ${authHeader}`, ...resolved.route.headers() },
|
|
136
136
|
signal: ctx.signal,
|
|
137
137
|
fetchImpl: ctx.fetchImpl,
|
|
138
|
-
}), { maxRetries: chain.maxRetries, sleep: chain.sleep });
|
|
138
|
+
}), { maxRetries: chain.maxRetries, sleep: chain.sleep, initialDelayMs: chain.retryDelay });
|
|
139
139
|
let state = initialStepState();
|
|
140
140
|
for await (const frame of sseFraming(stream)) {
|
|
141
141
|
const chunk = parseChunk(resolved.route.id, frame);
|