c0de-agent 1.0.0 → 1.2.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/dist/cli/deps.d.ts +16 -5
- package/dist/cli/deps.js +18 -5
- package/dist/cli/index.js +8 -2
- package/dist/core/agent.js +5 -0
- package/dist/core/config.js +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/loop.d.ts +10 -0
- package/dist/core/loop.js +559 -295
- package/dist/core/slash.js +3 -4
- package/dist/core/title.js +3 -2
- package/dist/core/types.d.ts +2 -0
- package/dist/core/workflow.d.ts +25 -0
- package/dist/core/workflow.js +98 -0
- package/dist/core/worktree.js +6 -4
- package/dist/dap/session.js +3 -3
- package/dist/llm/provider.js +5 -1
- package/dist/plugins/loader.js +3 -2
- package/dist/server/agent-manager.d.ts +2 -0
- package/dist/server/agent-manager.js +8 -0
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -0
- package/dist/server/dev.d.ts +4 -1
- package/dist/server/dev.js +92 -27
- package/dist/server/permission/store.d.ts +2 -0
- package/dist/server/permission/store.js +9 -0
- package/dist/server/routes/chat.js +44 -1
- package/dist/server/routes/session.js +75 -1
- package/dist/server/routes/terminal.d.ts +5 -0
- package/dist/server/routes/terminal.js +66 -0
- package/dist/server/server.d.ts +14 -1
- package/dist/server/server.js +100 -28
- package/dist/server/terminal/pty-manager.d.ts +53 -0
- package/dist/server/terminal/pty-manager.js +160 -0
- package/dist/server/types.d.ts +3 -0
- package/dist/session/archive.d.ts +1 -1
- package/dist/session/compaction.d.ts +8 -2
- package/dist/session/compaction.js +85 -16
- package/dist/session/shake.d.ts +66 -0
- package/dist/session/shake.js +304 -0
- package/dist/session/types.d.ts +1 -1
- package/dist/shared/types/agent.d.ts +27 -0
- package/dist/shared/types/config.d.ts +10 -0
- package/dist/shared/types/llm.d.ts +1 -0
- package/dist/shared/types/tool.d.ts +3 -0
- package/package.json +11 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateId } from '../shared/index.js';
|
|
2
2
|
import { archiveOriginalEntries } from './archive.js';
|
|
3
|
-
import { deleteEntriesByIds, getMessages, insertEntry } from './message.js';
|
|
3
|
+
import { deleteEntriesByIds, getEntries, getMessages, insertEntry } from './message.js';
|
|
4
4
|
import { upsertFileSnapshot } from './snapshot.js';
|
|
5
5
|
import { estimateMessageTokens, estimateTokens } from './token.js';
|
|
6
6
|
/**
|
|
@@ -51,30 +51,80 @@ function extractHotFiles(messages) {
|
|
|
51
51
|
}
|
|
52
52
|
return hot.sort((a, b) => b.accessCount - a.accessCount).slice(0, 10);
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** 工具输出在压缩 prompt 中保留的最大字符数(参考 opencode)。 */
|
|
55
|
+
const TOOL_OUTPUT_MAX_CHARS = 2000;
|
|
56
|
+
/** 截断超长字符串:head 60% + "[truncated]" + tail 40%,而非硬切。 */
|
|
57
|
+
function truncateToolOutput(s) {
|
|
58
|
+
if (s.length <= TOOL_OUTPUT_MAX_CHARS)
|
|
59
|
+
return s;
|
|
60
|
+
const head = Math.floor(TOOL_OUTPUT_MAX_CHARS * 0.6);
|
|
61
|
+
const tail = TOOL_OUTPUT_MAX_CHARS - head;
|
|
62
|
+
return `${s.slice(0, head)}[truncated]${s.slice(-tail)}`;
|
|
63
|
+
}
|
|
64
|
+
/** 序列化单个 content part:text/thinking 保持原样,tool 输出超长时截断。 */
|
|
65
|
+
function serializePart(p) {
|
|
66
|
+
if (p._tag === 'text' || p._tag === 'thinking')
|
|
67
|
+
return p.text;
|
|
68
|
+
if (p._tag === 'tool_call') {
|
|
69
|
+
const input = truncateToolOutput(JSON.stringify(p.input));
|
|
70
|
+
return JSON.stringify({ _tag: p._tag, id: p.id, tool: p.tool, input });
|
|
71
|
+
}
|
|
72
|
+
if (p._tag === 'tool_result') {
|
|
73
|
+
const output = truncateToolOutput(JSON.stringify(p.output));
|
|
74
|
+
return JSON.stringify({ _tag: p._tag, id: p.id, tool: p.tool, output });
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(p);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the LLM summarization prompt for a set of messages.
|
|
80
|
+
*
|
|
81
|
+
* 当存在 previousSummary(上一次压缩生成的摘要)时,prompt 头部改为增量更新指令,
|
|
82
|
+
* 引导模型在已有摘要上叠加新事实、剔除过时信息,从而避免连续多次压缩导致的
|
|
83
|
+
* 信息逐次丢失。无 previousSummary 时退回原始的从零压缩指令。
|
|
84
|
+
*/
|
|
85
|
+
function buildCompactionPrompt(messages, previousSummary) {
|
|
56
86
|
const history = messages
|
|
57
|
-
.map((m) => `[${m.role}] ${m.content.map((p) => (p
|
|
87
|
+
.map((m) => `[${m.role}] ${m.content.map((p) => serializePart(p)).join(' ')}`)
|
|
58
88
|
.join('\n');
|
|
59
|
-
|
|
89
|
+
const sections = `## Agenda
|
|
90
|
+
逐条列出对话中出现的议题/任务,按处理顺序排列。每条格式:
|
|
91
|
+
- **[议题标题]** — ✅已解决 / ⏳进行中 / 🔒阻塞 / 📋待办
|
|
92
|
+
- ✅/🔒 → 一行:最终结论或卡点
|
|
93
|
+
- ⏳/📋 → 完整保留:目标、约束、已尝试方向、相关文件路径、关键决策、待确认问题
|
|
60
94
|
|
|
61
95
|
## Goal
|
|
62
|
-
|
|
96
|
+
用户此次会话的总体目标(若 Agenda 已涵盖,写"见 Agenda")
|
|
63
97
|
|
|
64
|
-
##
|
|
65
|
-
|
|
98
|
+
## Constraints & Preferences
|
|
99
|
+
用户约束、偏好、规范要求(或"(none)")
|
|
66
100
|
|
|
67
|
-
## Decisions
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
## Next Steps
|
|
71
|
-
接下来要做什么
|
|
101
|
+
## Key Decisions
|
|
102
|
+
做出的关键决策及原因
|
|
72
103
|
|
|
73
104
|
## Critical Context
|
|
74
|
-
|
|
105
|
+
必须记住的技术事实(文件路径、变量名、命令、错误信息、未解决问题)
|
|
75
106
|
|
|
76
107
|
## Modified Files
|
|
77
|
-
|
|
108
|
+
修改过的文件路径及变更摘要
|
|
109
|
+
|
|
110
|
+
## Relevant Files
|
|
111
|
+
对任务重要的文件/目录路径及原因`;
|
|
112
|
+
const header = previousSummary
|
|
113
|
+
? `更新以下已有【议题驱动】摘要。重点:
|
|
114
|
+
- 已解决的议题:状态更新为✅并压缩为一行结论;
|
|
115
|
+
- 新增议题:补入 Agenda 并完整保留其描述与约束;
|
|
116
|
+
- 尚未解决的议题:保持其原有描述与约束不变,只叠加本轮新进展。
|
|
117
|
+
|
|
118
|
+
<previous-summary>
|
|
119
|
+
${previousSummary}
|
|
120
|
+
</previous-summary>`
|
|
121
|
+
: `将以下对话历史压缩为一份【议题驱动】的结构化摘要。
|
|
122
|
+
核心原则——非对称保留:已解决的议题只留一行结论;尚未解决/待办的议题
|
|
123
|
+
必须完整保留其描述、约束、已尝试方向、相关文件与待确认问题,它们是后续
|
|
124
|
+
工作的蓝图,绝不可被稀释。`;
|
|
125
|
+
return `${header}
|
|
126
|
+
|
|
127
|
+
${sections}
|
|
78
128
|
|
|
79
129
|
---
|
|
80
130
|
对话历史:
|
|
@@ -115,6 +165,22 @@ function findKeepRecentStart(messages, keepRecentTokens) {
|
|
|
115
165
|
// Always retain at least the most recent message.
|
|
116
166
|
return Math.min(start, messages.length - 1);
|
|
117
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* 查找 session 最新的 compaction 摘要,用于增量摘要(P0-2)。
|
|
170
|
+
*
|
|
171
|
+
* 从全部 entries 中筛选 tag='compaction' 的记录,取最后一条(时间序最晚)的
|
|
172
|
+
* summary。无历史摘要时返回 undefined,buildCompactionPrompt 据此退回从零压缩模式。
|
|
173
|
+
*/
|
|
174
|
+
async function findPreviousSummary(handle, sessionId) {
|
|
175
|
+
const entries = await getEntries(handle, sessionId);
|
|
176
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
177
|
+
const entry = entries[i];
|
|
178
|
+
if (entry && '_tag' in entry && entry._tag === 'compaction') {
|
|
179
|
+
return entry.summary;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
118
184
|
/**
|
|
119
185
|
* Compact a session: summarize old messages, archive them, keep recent ones.
|
|
120
186
|
*
|
|
@@ -144,7 +210,10 @@ async function compactSession(handle, sessionId, summarizer, config) {
|
|
|
144
210
|
if (compactMessages.length === 0) {
|
|
145
211
|
return { compacted: false, reason: 'nothing_to_compact' };
|
|
146
212
|
}
|
|
147
|
-
|
|
213
|
+
// 查询当前 session 最新的 compaction 摘要,作为增量更新的基线(P0-2)。
|
|
214
|
+
// 这样连续多次压缩时,新摘要会基于已有摘要叠加而非从零重建,避免早期信息逐次丢失。
|
|
215
|
+
const previousSummary = await findPreviousSummary(handle, sessionId);
|
|
216
|
+
const prompt = buildCompactionPrompt(compactMessages, previousSummary);
|
|
148
217
|
// Run the (slow, network) summarizer OUTSIDE the transaction so we don't
|
|
149
218
|
// hold a DB transaction open across an LLM call.
|
|
150
219
|
const summary = await summarizer(prompt);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Message } from '../shared/types/message.js';
|
|
2
|
+
export interface ShakeConfig {
|
|
3
|
+
/** 保护最近 N token 的上下文不被 shake。 */
|
|
4
|
+
protectTokens: number;
|
|
5
|
+
/** 总节省 token < minSavings 时不 shake(preview 路径用)。 */
|
|
6
|
+
minSavings: number;
|
|
7
|
+
/** fenced/XML block 的最小 token 阈值。 */
|
|
8
|
+
fenceMinTokens: number;
|
|
9
|
+
/** 受保护的工具名列表(其 tool_result 不被 shake)。 */
|
|
10
|
+
protectedTools: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Auto-shake 默认配置:保护活跃尾部,保守阈值。 */
|
|
13
|
+
export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
|
|
14
|
+
export type ShakeRegion = {
|
|
15
|
+
kind: 'toolResult';
|
|
16
|
+
id: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
messageIndex: number;
|
|
19
|
+
partIndex: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
originalText: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** tool_call_id(tool_result part.id),前端用于跨消息合并后匹配渲染块。 */
|
|
24
|
+
toolCallId: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'block';
|
|
27
|
+
id: string;
|
|
28
|
+
messageId: string;
|
|
29
|
+
messageIndex: number;
|
|
30
|
+
partIndex: number;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
tokens: number;
|
|
34
|
+
originalText: string;
|
|
35
|
+
label: string;
|
|
36
|
+
};
|
|
37
|
+
/** API 返回给前端的区域视图。 */
|
|
38
|
+
export type ShakeRegionView = {
|
|
39
|
+
id: string;
|
|
40
|
+
kind: 'toolResult' | 'block';
|
|
41
|
+
messageId: string;
|
|
42
|
+
messageIndex: number;
|
|
43
|
+
partIndex: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
label: string;
|
|
46
|
+
preview: string;
|
|
47
|
+
placeholder: string;
|
|
48
|
+
isAfterProtectWindow: boolean;
|
|
49
|
+
/** tool_result 的 tool_call_id(仅 toolResult 类别),前端跨消息合并后匹配渲染块。 */
|
|
50
|
+
toolCallId?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* 定位 fenced 代码块和顶层 XML 元素 span。返回字符偏移 [start, end) 数组,
|
|
54
|
+
* 覆盖完整块(含围栏/标签行,不含尾换行)。围栏内抑制 XML 检测。
|
|
55
|
+
* 未闭合围栏/标签不产生 range(保守策略)。
|
|
56
|
+
*/
|
|
57
|
+
export declare function scanTextForBlockRanges(text: string): Array<{
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
}>;
|
|
61
|
+
/** 收集可 shake 的区域。纯函数,不修改输入。 */
|
|
62
|
+
export declare function collectShakeRegions(messages: Message[], config: ShakeConfig): ShakeRegion[];
|
|
63
|
+
/** 原位替换选中区域。返回新数组,不修改原数组。 */
|
|
64
|
+
export declare function applyShakeRegions(messages: Message[], regions: ShakeRegion[]): Message[];
|
|
65
|
+
/** 区域转 API 视图。protectWindow 窗口内的标记 isAfterProtectWindow=false。 */
|
|
66
|
+
export declare function toRegionViews(regions: ShakeRegion[], config: ShakeConfig, messages: Message[]): ShakeRegionView[];
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { estimateTokens } from './token.js';
|
|
2
|
+
/** Rough token cost of a placeholder line; used only for the savings gate. */
|
|
3
|
+
const PLACEHOLDER_TOKEN_ESTIMATE = 16;
|
|
4
|
+
/** 识别顶层 XML 元素(小写 tag,保守策略)。 */
|
|
5
|
+
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
6
|
+
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
7
|
+
/** Auto-shake 默认配置:保护活跃尾部,保守阈值。 */
|
|
8
|
+
export const DEFAULT_SHAKE_CONFIG = {
|
|
9
|
+
protectTokens: 16_000,
|
|
10
|
+
minSavings: 4_000,
|
|
11
|
+
fenceMinTokens: 400,
|
|
12
|
+
protectedTools: [],
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* 定位 fenced 代码块和顶层 XML 元素 span。返回字符偏移 [start, end) 数组,
|
|
16
|
+
* 覆盖完整块(含围栏/标签行,不含尾换行)。围栏内抑制 XML 检测。
|
|
17
|
+
* 未闭合围栏/标签不产生 range(保守策略)。
|
|
18
|
+
*/
|
|
19
|
+
export function scanTextForBlockRanges(text) {
|
|
20
|
+
const ranges = [];
|
|
21
|
+
let inFence = false;
|
|
22
|
+
let fenceStart = -1;
|
|
23
|
+
const tagStack = [];
|
|
24
|
+
let xmlStart = -1;
|
|
25
|
+
let lineStart = 0;
|
|
26
|
+
for (let i = 0; i <= text.length; i++) {
|
|
27
|
+
if (i !== text.length && text[i] !== '\n')
|
|
28
|
+
continue;
|
|
29
|
+
const line = text.slice(lineStart, i);
|
|
30
|
+
const lineEnd = i;
|
|
31
|
+
const trimmedStart = line.trimStart();
|
|
32
|
+
const isFenceLine = trimmedStart.startsWith('```') || trimmedStart.startsWith('~~~');
|
|
33
|
+
if (isFenceLine) {
|
|
34
|
+
if (!inFence) {
|
|
35
|
+
inFence = true;
|
|
36
|
+
fenceStart = lineStart;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
inFence = false;
|
|
40
|
+
ranges.push({ start: fenceStart, end: lineEnd });
|
|
41
|
+
fenceStart = -1;
|
|
42
|
+
}
|
|
43
|
+
lineStart = i + 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!inFence) {
|
|
47
|
+
// Only recognize top-level XML (no leading whitespace)
|
|
48
|
+
if (line.length === trimmedStart.length) {
|
|
49
|
+
const openingMatch = OPENING_XML.exec(trimmedStart);
|
|
50
|
+
if (openingMatch) {
|
|
51
|
+
const tagName = openingMatch[1];
|
|
52
|
+
if (tagName) {
|
|
53
|
+
if (tagStack.length === 0)
|
|
54
|
+
xmlStart = lineStart;
|
|
55
|
+
tagStack.push(tagName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const closingMatch = CLOSING_XML.exec(trimmedStart);
|
|
60
|
+
if (closingMatch &&
|
|
61
|
+
tagStack.length > 0 &&
|
|
62
|
+
tagStack[tagStack.length - 1] === closingMatch[1]) {
|
|
63
|
+
tagStack.pop();
|
|
64
|
+
if (tagStack.length === 0 && xmlStart >= 0) {
|
|
65
|
+
ranges.push({ start: xmlStart, end: lineEnd });
|
|
66
|
+
xmlStart = -1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
lineStart = i + 1;
|
|
73
|
+
}
|
|
74
|
+
return mergeRanges(ranges);
|
|
75
|
+
}
|
|
76
|
+
/** 按 start 升序,丢弃与已保留范围重叠的(嵌套取最外层)。 */
|
|
77
|
+
function mergeRanges(ranges) {
|
|
78
|
+
if (ranges.length <= 1)
|
|
79
|
+
return ranges;
|
|
80
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
81
|
+
const kept = [];
|
|
82
|
+
let lastEnd = -1;
|
|
83
|
+
for (const range of sorted) {
|
|
84
|
+
if (range.start < lastEnd)
|
|
85
|
+
continue;
|
|
86
|
+
kept.push(range);
|
|
87
|
+
lastEnd = range.end;
|
|
88
|
+
}
|
|
89
|
+
return kept;
|
|
90
|
+
}
|
|
91
|
+
/** 单条消息的 token 估算(优先用缓存的 tokenCount)。 */
|
|
92
|
+
function messageTokens(m) {
|
|
93
|
+
if (m.tokenCount > 0)
|
|
94
|
+
return m.tokenCount;
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const part of m.content) {
|
|
97
|
+
switch (part._tag) {
|
|
98
|
+
case 'text':
|
|
99
|
+
case 'thinking':
|
|
100
|
+
case 'steering':
|
|
101
|
+
total += estimateTokens(part.text);
|
|
102
|
+
break;
|
|
103
|
+
case 'tool_call':
|
|
104
|
+
total += estimateTokens(JSON.stringify(part.input));
|
|
105
|
+
break;
|
|
106
|
+
case 'tool_result':
|
|
107
|
+
total += estimateTokens(JSON.stringify(part.output));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return total;
|
|
112
|
+
}
|
|
113
|
+
/** tool_result part 的输出文本。 */
|
|
114
|
+
function toolResultText(output) {
|
|
115
|
+
if (output._tag === 'success')
|
|
116
|
+
return output.output;
|
|
117
|
+
if (output._tag === 'error')
|
|
118
|
+
return output.error;
|
|
119
|
+
if (output._tag === 'truncated')
|
|
120
|
+
return output.output;
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
/** 收集可 shake 的区域。纯函数,不修改输入。 */
|
|
124
|
+
export function collectShakeRegions(messages, config) {
|
|
125
|
+
const n = messages.length;
|
|
126
|
+
if (n === 0)
|
|
127
|
+
return [];
|
|
128
|
+
// accumulatedAfter[i] = i 之后所有 message 的 token 总和
|
|
129
|
+
const accumulatedAfter = new Array(n);
|
|
130
|
+
let acc = 0;
|
|
131
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
132
|
+
accumulatedAfter[i] = acc;
|
|
133
|
+
const m = messages[i];
|
|
134
|
+
if (m)
|
|
135
|
+
acc += messageTokens(m);
|
|
136
|
+
}
|
|
137
|
+
const regions = [];
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
const msg = messages[i];
|
|
140
|
+
if (!msg)
|
|
141
|
+
continue;
|
|
142
|
+
const afterTokens = accumulatedAfter[i] ?? 0;
|
|
143
|
+
const isAfterProtectWindow = afterTokens >= config.protectTokens;
|
|
144
|
+
if (!isAfterProtectWindow)
|
|
145
|
+
continue;
|
|
146
|
+
for (let partIndex = 0; partIndex < msg.content.length; partIndex++) {
|
|
147
|
+
const part = msg.content[partIndex];
|
|
148
|
+
if (!part)
|
|
149
|
+
continue;
|
|
150
|
+
// tool_result 区域
|
|
151
|
+
if (part._tag === 'tool_result') {
|
|
152
|
+
// 已 shaken 跳过
|
|
153
|
+
if ('shakenAt' in part.output && part.output.shakenAt)
|
|
154
|
+
continue;
|
|
155
|
+
// protectedTools 跳过
|
|
156
|
+
if (config.protectedTools.includes(part.tool))
|
|
157
|
+
continue;
|
|
158
|
+
const text = toolResultText(part.output);
|
|
159
|
+
if (text.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const tokens = estimateTokens(text);
|
|
162
|
+
if (tokens < config.fenceMinTokens)
|
|
163
|
+
continue;
|
|
164
|
+
regions.push({
|
|
165
|
+
kind: 'toolResult',
|
|
166
|
+
id: `${msg.id}:toolResult:${partIndex}`,
|
|
167
|
+
messageId: msg.id,
|
|
168
|
+
messageIndex: i,
|
|
169
|
+
partIndex,
|
|
170
|
+
tokens,
|
|
171
|
+
originalText: text,
|
|
172
|
+
label: part.tool,
|
|
173
|
+
toolCallId: part.id,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// text/thinking block 区域
|
|
178
|
+
if (part._tag === 'text' || part._tag === 'thinking') {
|
|
179
|
+
for (const range of scanTextForBlockRanges(part.text)) {
|
|
180
|
+
const slice = part.text.slice(range.start, range.end);
|
|
181
|
+
if (slice.length === 0)
|
|
182
|
+
continue;
|
|
183
|
+
const tokens = estimateTokens(slice);
|
|
184
|
+
if (tokens < config.fenceMinTokens)
|
|
185
|
+
continue;
|
|
186
|
+
regions.push({
|
|
187
|
+
kind: 'block',
|
|
188
|
+
id: `${msg.id}:block:${partIndex}:${range.start}`,
|
|
189
|
+
messageId: msg.id,
|
|
190
|
+
messageIndex: i,
|
|
191
|
+
partIndex,
|
|
192
|
+
start: range.start,
|
|
193
|
+
end: range.end,
|
|
194
|
+
tokens,
|
|
195
|
+
originalText: slice,
|
|
196
|
+
label: msg.role,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// minSavings 门控
|
|
203
|
+
let savings = 0;
|
|
204
|
+
for (const region of regions)
|
|
205
|
+
savings += Math.max(0, region.tokens - PLACEHOLDER_TOKEN_ESTIMATE);
|
|
206
|
+
if (savings < config.minSavings)
|
|
207
|
+
return [];
|
|
208
|
+
return regions;
|
|
209
|
+
}
|
|
210
|
+
/** 为区域生成占位符文本。 */
|
|
211
|
+
function placeholderFor(region) {
|
|
212
|
+
if (region.kind === 'toolResult') {
|
|
213
|
+
return `[shaken: ${region.label}, ${region.tokens} tokens]`;
|
|
214
|
+
}
|
|
215
|
+
return '[shaken]';
|
|
216
|
+
}
|
|
217
|
+
/** 原位替换选中区域。返回新数组,不修改原数组。 */
|
|
218
|
+
export function applyShakeRegions(messages, regions) {
|
|
219
|
+
if (regions.length === 0)
|
|
220
|
+
return messages;
|
|
221
|
+
// 按 messageId 分组
|
|
222
|
+
const byMessage = new Map();
|
|
223
|
+
for (const region of regions) {
|
|
224
|
+
const list = byMessage.get(region.messageId) ?? [];
|
|
225
|
+
list.push(region);
|
|
226
|
+
byMessage.set(region.messageId, list);
|
|
227
|
+
}
|
|
228
|
+
return messages.map((msg) => {
|
|
229
|
+
const msgRegions = byMessage.get(msg.id);
|
|
230
|
+
if (!msgRegions)
|
|
231
|
+
return msg;
|
|
232
|
+
// 深拷贝 content(保证不修改原对象)
|
|
233
|
+
const newContent = structuredClone(msg.content);
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
// toolResult 区域:替换 output 文本
|
|
236
|
+
for (const region of msgRegions) {
|
|
237
|
+
if (region.kind !== 'toolResult')
|
|
238
|
+
continue;
|
|
239
|
+
const part = newContent[region.partIndex];
|
|
240
|
+
if (!part)
|
|
241
|
+
continue;
|
|
242
|
+
if (part._tag !== 'tool_result')
|
|
243
|
+
continue;
|
|
244
|
+
const placeholder = placeholderFor(region);
|
|
245
|
+
if (part.output._tag === 'success') {
|
|
246
|
+
part.output = { ...part.output, output: placeholder, shakenAt: now };
|
|
247
|
+
}
|
|
248
|
+
else if (part.output._tag === 'error') {
|
|
249
|
+
part.output = { ...part.output, error: placeholder, shakenAt: now };
|
|
250
|
+
}
|
|
251
|
+
else if (part.output._tag === 'truncated') {
|
|
252
|
+
part.output = { ...part.output, output: placeholder, shakenAt: now };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// block 区域:按 partIndex 分组,同一 text 内按 start 降序 splice
|
|
256
|
+
const blockByPart = new Map();
|
|
257
|
+
for (const region of msgRegions) {
|
|
258
|
+
if (region.kind !== 'block')
|
|
259
|
+
continue;
|
|
260
|
+
const list = blockByPart.get(region.partIndex) ?? [];
|
|
261
|
+
list.push(region);
|
|
262
|
+
blockByPart.set(region.partIndex, list);
|
|
263
|
+
}
|
|
264
|
+
for (const [partIndex, blockRegions] of blockByPart) {
|
|
265
|
+
const part = newContent[partIndex];
|
|
266
|
+
if (!part || (part._tag !== 'text' && part._tag !== 'thinking'))
|
|
267
|
+
continue;
|
|
268
|
+
const sorted = [...blockRegions].sort((a, b) => b.start - a.start);
|
|
269
|
+
let text = part.text;
|
|
270
|
+
for (const br of sorted) {
|
|
271
|
+
text = text.slice(0, br.start) + placeholderFor(br) + text.slice(br.end);
|
|
272
|
+
}
|
|
273
|
+
part.text = text;
|
|
274
|
+
}
|
|
275
|
+
return { ...msg, content: newContent };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** 区域转 API 视图。protectWindow 窗口内的标记 isAfterProtectWindow=false。 */
|
|
279
|
+
export function toRegionViews(regions, config, messages) {
|
|
280
|
+
const n = messages.length;
|
|
281
|
+
const accumulatedAfter = new Array(n);
|
|
282
|
+
let acc = 0;
|
|
283
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
284
|
+
accumulatedAfter[i] = acc;
|
|
285
|
+
const m = messages[i];
|
|
286
|
+
if (m)
|
|
287
|
+
acc += messageTokens(m);
|
|
288
|
+
}
|
|
289
|
+
return regions.map((region) => ({
|
|
290
|
+
id: region.id,
|
|
291
|
+
kind: region.kind,
|
|
292
|
+
messageId: region.messageId,
|
|
293
|
+
messageIndex: region.messageIndex,
|
|
294
|
+
partIndex: region.partIndex,
|
|
295
|
+
tokens: region.tokens,
|
|
296
|
+
label: region.label,
|
|
297
|
+
preview: region.originalText.slice(0, 200),
|
|
298
|
+
placeholder: region.kind === 'toolResult'
|
|
299
|
+
? `[shaken: ${region.label}, ${region.tokens} tokens]`
|
|
300
|
+
: '[shaken]',
|
|
301
|
+
isAfterProtectWindow: (accumulatedAfter[region.messageIndex] ?? 0) >= config.protectTokens,
|
|
302
|
+
...(region.kind === 'toolResult' ? { toolCallId: region.toolCallId } : {}),
|
|
303
|
+
}));
|
|
304
|
+
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -101,7 +101,7 @@ type CompactionArchive = {
|
|
|
101
101
|
id: string;
|
|
102
102
|
sessionId: string;
|
|
103
103
|
compactionId: string;
|
|
104
|
-
archiveType: 'compaction' | 'squash';
|
|
104
|
+
archiveType: 'compaction' | 'squash' | 'shake';
|
|
105
105
|
originalEntries: SessionEntry[];
|
|
106
106
|
summary: string;
|
|
107
107
|
tokenCount: number;
|
|
@@ -172,6 +172,18 @@ type AgentEvent = {
|
|
|
172
172
|
agentType: string;
|
|
173
173
|
success: boolean;
|
|
174
174
|
output?: string;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 会话压缩成功后发出(spec: plugin-hooks `session:compact`)。
|
|
178
|
+
* 实际发生压缩(runCompaction 返回 compacted=true)时才 yield;
|
|
179
|
+
* nothing_to_compact 不发。archiveId/summary 仅在真实压缩时存在。
|
|
180
|
+
*/
|
|
181
|
+
| {
|
|
182
|
+
_tag: 'compaction_done';
|
|
183
|
+
summary: string;
|
|
184
|
+
archiveId?: string;
|
|
185
|
+
compactedCount: number;
|
|
186
|
+
keptCount: number;
|
|
175
187
|
} | {
|
|
176
188
|
_tag: 'done';
|
|
177
189
|
};
|
|
@@ -198,5 +210,20 @@ type AgentState = {
|
|
|
198
210
|
provider: string;
|
|
199
211
|
model: string;
|
|
200
212
|
};
|
|
213
|
+
/**
|
|
214
|
+
* 压缩死锁标记:自动压缩成功后仍超阈值(如 keepRecentTokens 本身已超限)时置真,
|
|
215
|
+
* 暂停后续自动压缩以防每轮重复触发(无限循环)。收到新用户消息(agentLoop 重入)时重置。
|
|
216
|
+
*/
|
|
217
|
+
compactionDeadEnd?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* 压缩退化监测器:压缩成功后初始化,监测接下来若干轮 assistant 回复。
|
|
220
|
+
* 若连续产生空回复(无实质文本且无 tool_call),发出非致命警告(不中断循环),
|
|
221
|
+
* 提示 agent 可能在"沉默退化"。remaining 耗尽即清除;新一轮用户输入
|
|
222
|
+
* (agentLoop 重入)时不会自动重置——它由压缩成功单独建立。
|
|
223
|
+
*/
|
|
224
|
+
postCompactionMonitor?: {
|
|
225
|
+
remaining: number;
|
|
226
|
+
noTextStreak: number;
|
|
227
|
+
};
|
|
201
228
|
};
|
|
202
229
|
export type { AgentConfig, AgentError, AgentEvent, AgentState, AgentStatus, LLMCall, LLMSegment, PendingToolCall, SegmentTrigger, TokenBudget, };
|
|
@@ -16,6 +16,16 @@ type CompactionConfig = {
|
|
|
16
16
|
reserveTokens: number;
|
|
17
17
|
/** Token budget for retaining recent messages verbatim. */
|
|
18
18
|
keepRecentTokens: number;
|
|
19
|
+
/** 中轮压缩(mid-run compaction):单个 turn 内工具执行后、下一次 LLM 请求前
|
|
20
|
+
* 按阈值静默压缩。与 turn-end 自动压缩独立——以本开关为闸门复用 shouldCompact
|
|
21
|
+
* 的阈值逻辑(不受 `enabled` 影响),默认关闭(保守开启)。 */
|
|
22
|
+
midTurnEnabled?: boolean;
|
|
23
|
+
/** 压缩摘要使用的模型覆盖。未设置时回退到会话主模型。
|
|
24
|
+
* 摘要任务对推理能力要求低,可指定便宜/快速模型以降低成本。 */
|
|
25
|
+
compactionModel?: {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
};
|
|
19
29
|
};
|
|
20
30
|
/** Tool-mode auto-selection configuration (spec §16.5). */
|
|
21
31
|
type ToolMetricsConfig = {
|
|
@@ -117,6 +117,7 @@ type StreamChunk = {
|
|
|
117
117
|
error: {
|
|
118
118
|
message: string;
|
|
119
119
|
retryable?: boolean;
|
|
120
|
+
classification?: 'context-overflow';
|
|
120
121
|
};
|
|
121
122
|
};
|
|
122
123
|
export type { ChatMessage, ChatRequest, ChatTool, ContentPart, FinishReason, ModelCapabilities, ModelOverride, ModelRole, ProviderConfig, ProviderProtocol, StreamChunk, };
|
|
@@ -6,9 +6,11 @@ type ToolResult = {
|
|
|
6
6
|
_tag: 'success';
|
|
7
7
|
output: string;
|
|
8
8
|
metadata?: Record<string, unknown>;
|
|
9
|
+
shakenAt?: number;
|
|
9
10
|
} | {
|
|
10
11
|
_tag: 'error';
|
|
11
12
|
error: string;
|
|
13
|
+
shakenAt?: number;
|
|
12
14
|
} | {
|
|
13
15
|
_tag: 'permission_required';
|
|
14
16
|
reason: string;
|
|
@@ -17,6 +19,7 @@ type ToolResult = {
|
|
|
17
19
|
output: string;
|
|
18
20
|
truncated: boolean;
|
|
19
21
|
totalLines: number;
|
|
22
|
+
shakenAt?: number;
|
|
20
23
|
};
|
|
21
24
|
/** Context passed to a URL resolver (skill://, agent://, pr://, …). */
|
|
22
25
|
type URLResolveContext = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c0de-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Open-source AI coding assistant with Browser-Server architecture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"pnpm": {
|
|
52
52
|
"onlyBuiltDependencies": [
|
|
53
|
-
"esbuild"
|
|
53
|
+
"esbuild",
|
|
54
|
+
"node-pty"
|
|
54
55
|
]
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
@@ -69,10 +70,12 @@
|
|
|
69
70
|
"@types/pg": "^8.20.0",
|
|
70
71
|
"@types/react": "^19.2.17",
|
|
71
72
|
"@types/react-dom": "^19.2.3",
|
|
73
|
+
"@types/ws": "^8.18.1",
|
|
72
74
|
"@vitejs/plugin-react": "^6.0.3",
|
|
73
75
|
"@wyw-in-js/vite": "^2.1.0",
|
|
74
76
|
"drizzle-kit": "^0.31.10",
|
|
75
77
|
"happy-dom": "^20.10.6",
|
|
78
|
+
"node-gyp": "^13.0.1",
|
|
76
79
|
"pg": "^8.22.0",
|
|
77
80
|
"tsx": "^4.22.4",
|
|
78
81
|
"typescript": "^6.0.3",
|
|
@@ -89,6 +92,9 @@
|
|
|
89
92
|
"@native-router/core": "^1.1.0",
|
|
90
93
|
"@native-router/react": "^1.1.2",
|
|
91
94
|
"@tanstack/react-query": "^5.101.2",
|
|
95
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
96
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
97
|
+
"@xterm/xterm": "^6.0.0",
|
|
92
98
|
"diff": "^9.0.0",
|
|
93
99
|
"drizzle-orm": "^0.45.2",
|
|
94
100
|
"fuzzysort": "^3.1.0",
|
|
@@ -96,10 +102,12 @@
|
|
|
96
102
|
"hono": "^4.12.27",
|
|
97
103
|
"lucide-react": "^1.22.0",
|
|
98
104
|
"marked": "^18.0.5",
|
|
105
|
+
"node-pty": "^1.1.0",
|
|
99
106
|
"react": "^19.2.7",
|
|
100
107
|
"react-dom": "^19.2.7",
|
|
101
108
|
"react-router-dom": "^7.18.0",
|
|
102
109
|
"shiki": "^4.3.0",
|
|
103
|
-
"undici": "^8.5.0"
|
|
110
|
+
"undici": "^8.5.0",
|
|
111
|
+
"ws": "^8.21.0"
|
|
104
112
|
}
|
|
105
113
|
}
|