deepccc 0.1.7 → 0.1.9

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 CHANGED
@@ -180,7 +180,9 @@ deepccc --dangerously-bypass-permissions
180
180
 
181
181
  ## 终端过程区块
182
182
 
183
- 交互模式下,每轮回复渲染为固定"过程区块":状态行(生成中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。生成中有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
183
+ 交互模式下,每轮回复渲染为固定"过程区块":状态行(压缩上下文中/生成回复中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。活动状态有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
184
+
185
+ 持久化上下文达到 token 阈值时,deepccc 会先按 token 预算保留最近消息,再压缩较早内容;超长历史消息、工具记录和压缩输入会被限长,避免单次摘要请求反复吞入巨量文本。一次压缩最多等待 5 分钟,超时或摘要失败会给出明确错误,不会自动重放用户请求。
184
186
 
185
187
  如果终端渲染出现异常,可以强制回退为纯文本流式输出:
186
188
 
@@ -205,8 +207,11 @@ echo "运行测试并解释失败原因" | deepccc --stream-json
205
207
  输出是逐行 JSON:
206
208
 
207
209
  ```jsonl
208
- {"type":"start","session_id":"session-...","mode":"new","cwd":"/repo","model":"deepseek-v4-pro"}
209
- {"type":"text_delta","text":"...","accumulated":"..."}
210
+ {"type":"start","session_id":"session-...","mode":"new","cwd":"/repo","model":"deepseek-v4-pro"}
211
+ {"type":"status","phase":"compacting"}
212
+ {"type":"compact","compactedMessages":12}
213
+ {"type":"status","phase":"generating"}
214
+ {"type":"text_delta","text":"...","accumulated":"..."}
210
215
  {"type":"tool_call","id":"call_...","name":"read_file","input":{"path":"package.json"}}
211
216
  {"type":"tool_result","tool_call_id":"call_...","name":"read_file","content":{},"is_error":false}
212
217
  {"type":"done","text":"..."}
package/dist/cli.js CHANGED
@@ -192,7 +192,13 @@ function writeJsonLine(event) {
192
192
  process.stdout.write(`${JSON.stringify(event)}\n`);
193
193
  }
194
194
  function streamJsonEvent(event) {
195
- if (event.type === "text") {
195
+ if (event.type === "status") {
196
+ writeJsonLine({
197
+ type: "status",
198
+ phase: event.phase,
199
+ });
200
+ }
201
+ else if (event.type === "text") {
196
202
  writeJsonLine({
197
203
  type: "text_delta",
198
204
  text: event.text,
@@ -446,13 +452,16 @@ async function runRepl(args) {
446
452
  for await (const event of session.chat(input, signal)) {
447
453
  if (renderer && view) {
448
454
  view = reduceProgress(view, event);
449
- if (event.type === "text" || event.type === "compact") {
455
+ if (event.type === "text" || event.type === "compact" || event.type === "status") {
450
456
  renderer.render(view);
451
457
  }
452
458
  else {
453
459
  renderer.flush();
454
460
  }
455
461
  }
462
+ else if (event.type === "status") {
463
+ console.log(`${C.dim}[${event.phase === "compacting" ? "compacting context" : "generating reply"}]${C.reset}`);
464
+ }
456
465
  else if (event.type === "text") {
457
466
  const newText = event.accumulated.slice(lastAccumulated.length);
458
467
  process.stdout.write(newText);
package/dist/context.d.ts CHANGED
@@ -46,6 +46,11 @@ export declare function newBuiltinSessionId(now?: Date, suffix?: string): string
46
46
  export declare function getBuiltinContextSession(sessionId: string, contextDir?: string): BuiltinContextSessionInfo | null;
47
47
  export declare function listBuiltinContextSessions(contextDir?: string): BuiltinContextSessionInfo[];
48
48
  export declare function latestBuiltinSessionForCwd(cwd: string, contextDir?: string): BuiltinContextSessionInfo | null;
49
+ /**
50
+ * 估算上下文的 token 数(用于决定何时压缩)。
51
+ * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
52
+ * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
53
+ */
49
54
  export declare function estimateBuiltinContextTokens(summary: string, messages: readonly BuiltinContextMessage[]): number;
50
55
  export declare function serializeMessagesForSummary(messages: readonly BuiltinContextMessage[]): string;
51
56
  export declare function buildSummaryPrompt(plan: BuiltinCompactionPlan): string;
package/dist/context.js CHANGED
@@ -5,6 +5,10 @@ import { join } from "node:path";
5
5
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
6
6
  export const DEFAULT_COMPACT_AT_TOKENS = 48_000;
7
7
  export const DEFAULT_KEEP_RECENT_MESSAGES = 16;
8
+ const RECENT_CONTEXT_BUDGET_RATIO = 0.6;
9
+ const MAX_COMPACTION_SUMMARY_CHARS = 16_000;
10
+ const MAX_COMPACTION_MESSAGE_CHARS = 24_000;
11
+ const MAX_COMPACTION_SOURCE_CHARS = 64_000;
8
12
  export function normalizeBuiltinSessionId(value) {
9
13
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
10
14
  }
@@ -121,15 +125,52 @@ export function latestBuiltinSessionForCwd(cwd, contextDir = DEFAULT_BUILTIN_CON
121
125
  const legacySessionId = defaultBuiltinSessionId(cwd);
122
126
  return listBuiltinContextSessions(contextDir).find((session) => session.cwd === cwd || session.sessionId === legacySessionId) ?? null;
123
127
  }
128
+ /**
129
+ * 估算上下文的 token 数(用于决定何时压缩)。
130
+ * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
131
+ * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
132
+ */
124
133
  export function estimateBuiltinContextTokens(summary, messages) {
125
- const chars = summary.length + messages.reduce((sum, m) => sum + m.role.length + m.content.length, 0);
126
- return Math.ceil(chars / 3);
134
+ const text = summary + messages.reduce((sum, m) => sum + `${m.role}\n${m.content}\n`, "");
135
+ let cjk = 0;
136
+ for (const ch of text) {
137
+ if (/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch))
138
+ cjk++;
139
+ }
140
+ const other = text.length - cjk;
141
+ return Math.ceil(cjk + other / 3.5);
127
142
  }
128
143
  export function serializeMessagesForSummary(messages) {
129
144
  return messages
130
145
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
131
146
  .join("\n\n");
132
147
  }
148
+ function truncateMiddle(value, maxChars, marker) {
149
+ if (value.length <= maxChars)
150
+ return value;
151
+ const available = Math.max(0, maxChars - marker.length - 2);
152
+ const headChars = Math.ceil(available / 2);
153
+ const tailChars = Math.floor(available / 2);
154
+ return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
155
+ }
156
+ function serializeMessagesForCompaction(messages) {
157
+ const sections = [];
158
+ let remaining = MAX_COMPACTION_SOURCE_CHARS;
159
+ for (let index = 0; index < messages.length && remaining > 0; index += 1) {
160
+ const message = messages[index];
161
+ const header = `### ${index + 1}. ${message.role}\n`;
162
+ if (header.length >= remaining)
163
+ break;
164
+ const maxContentChars = Math.min(MAX_COMPACTION_MESSAGE_CHARS, remaining - header.length);
165
+ const content = truncateMiddle(message.content, maxContentChars, "...[truncated for compaction]...");
166
+ sections.push(`${header}${content}`);
167
+ remaining -= header.length + content.length + 2;
168
+ }
169
+ if (sections.length < messages.length) {
170
+ sections.push(`...[${messages.length - sections.length} additional messages omitted for compaction]...`);
171
+ }
172
+ return sections.join("\n\n");
173
+ }
133
174
  export function buildSummaryPrompt(plan) {
134
175
  const sections = [
135
176
  "Compress the older DeepCCC conversation context.",
@@ -142,9 +183,9 @@ export function buildSummaryPrompt(plan) {
142
183
  "",
143
184
  ];
144
185
  if (plan.previousSummary.trim()) {
145
- sections.push("## Existing Summary", plan.previousSummary.trim(), "");
186
+ sections.push("## Existing Summary", truncateMiddle(plan.previousSummary.trim(), MAX_COMPACTION_SUMMARY_CHARS, "...[existing summary truncated for compaction]..."), "");
146
187
  }
147
- sections.push("## Messages To Compress", serializeMessagesForSummary(plan.oldMessages));
188
+ sections.push("## Messages To Compress", serializeMessagesForCompaction(plan.oldMessages));
148
189
  return sections.join("\n");
149
190
  }
150
191
  export class BuiltinContextManager {
@@ -204,9 +245,23 @@ export class BuiltinContextManager {
204
245
  const estimated = estimateBuiltinContextTokens(this.state.summary, this.state.messages);
205
246
  if (estimated <= this.compactAtTokens)
206
247
  return null;
207
- const splitAt = this.state.messages.length - this.keepRecentMessages;
208
- if (splitAt <= 0)
248
+ const messages = this.state.messages;
249
+ if (messages.length <= 1)
209
250
  return null;
251
+ const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
252
+ const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
253
+ let splitAt = messages.length - 1;
254
+ for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
255
+ const candidate = messages.slice(index);
256
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
257
+ break;
258
+ splitAt = index;
259
+ }
260
+ // A large existing summary can put the total over budget even when every raw
261
+ // message fits. Always leave at least one old message to re-summarize so the
262
+ // compaction pass can still make progress.
263
+ if (splitAt <= 0)
264
+ splitAt = 1;
210
265
  return {
211
266
  previousSummary: this.state.summary,
212
267
  oldMessages: this.state.messages.slice(0, splitAt),
@@ -132,6 +132,12 @@ export interface ApplyPatchFileChange {
132
132
  export interface ApplyPatchOutput {
133
133
  changedFiles: ApplyPatchFileChange[];
134
134
  }
135
+ /**
136
+ * 把 `~` / `~/x`(含反斜杠 `~\\x`)展开为用户主目录绝对路径。
137
+ * `~user/x` 形式不展开(Node 无内置支持),保持原样交给后续解析。
138
+ * 供路径工具共用:技能索引里 `~/...` 路径可直接传给 read_file 等工具。
139
+ */
140
+ export declare function expandHomePath(value: string): string;
135
141
  export declare function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput>;
136
142
  export declare function listDirForTool(cwd: string, input?: ListDirInput): Promise<ListDirOutput>;
137
143
  export declare function searchCodeForTool(cwd: string, input: SearchCodeInput, signal?: AbortSignal, runtimeOptions?: SearchCodeRuntimeOptions): Promise<SearchCodeOutput>;
@@ -3,7 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
6
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
8
  import { createInterface } from "node:readline";
8
9
  import { jsonSchema, tool } from "ai";
9
10
  import { isDangerousCommand } from "./permissions.js";
@@ -22,11 +23,25 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
22
23
  const MAX_COMMAND_TIMEOUT_MS = 900_000;
23
24
  const requireFromHere = createRequire(import.meta.url);
24
25
  const FALLBACK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
26
+ /**
27
+ * 把 `~` / `~/x`(含反斜杠 `~\\x`)展开为用户主目录绝对路径。
28
+ * `~user/x` 形式不展开(Node 无内置支持),保持原样交给后续解析。
29
+ * 供路径工具共用:技能索引里 `~/...` 路径可直接传给 read_file 等工具。
30
+ */
31
+ export function expandHomePath(value) {
32
+ if (value === "~")
33
+ return homedir();
34
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
35
+ return join(homedir(), value.slice(2));
36
+ }
37
+ return value;
38
+ }
25
39
  function resolveToolPath(cwd, value) {
26
40
  const raw = value?.trim();
27
41
  if (!raw)
28
42
  return resolve(cwd);
29
- return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
43
+ const expanded = expandHomePath(raw);
44
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
30
45
  }
31
46
  function toPositiveInt(value) {
32
47
  if (value === undefined)
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块调用。
5
5
  */
6
6
  import { type PermissionMode, type PermissionResolver } from "./permissions.js";
7
+ export declare const DEFAULT_COMPACTION_TIMEOUT_MS: number;
7
8
  export interface ChatSessionConfig {
8
9
  /** OpenAI-compatible service base URL. Defaults to DEEPCCC_BASE_URL/config. */
9
10
  baseURL?: string;
@@ -32,6 +33,8 @@ export interface ChatSessionOptions {
32
33
  compactAtTokens?: number;
33
34
  /** Number of recent raw messages retained after compaction. */
34
35
  keepRecentMessages?: number;
36
+ /** Hard deadline for all context-compaction passes in one turn. */
37
+ compactionTimeoutMs?: number;
35
38
  /** Optional tool-step limit. Leave unset for no step limit. */
36
39
  maxSteps?: number;
37
40
  /**
@@ -55,6 +58,9 @@ export interface ChatSessionOptions {
55
58
  * 流式响应事件
56
59
  */
57
60
  export type ChatEvent = {
61
+ type: "status";
62
+ phase: "compacting" | "generating";
63
+ } | {
58
64
  type: "compact";
59
65
  compactedMessages: number;
60
66
  } | {
@@ -90,6 +96,7 @@ export declare class ChatSession {
90
96
  private model;
91
97
  private cwd;
92
98
  private context;
99
+ private compactionTimeoutMs;
93
100
  private maxSteps?;
94
101
  private effort;
95
102
  private permissionGate;
@@ -98,7 +105,12 @@ export declare class ChatSession {
98
105
  /** 最近一次 chat() 使用的 system prompt(供 history 等读取) */
99
106
  private systemPrompt;
100
107
  constructor(overrides?: ChatSessionConfig, options?: ChatSessionOptions);
101
- /** 组装系统提示词:固定规则 + 项目指令 + 技能索引 + 用户补充 + 运行时上下文 */
108
+ /**
109
+ * 组装系统提示词。顺序遵循“稳定性优先”原则(缓存命中友好):
110
+ * 固定规则 → 项目指令 → runtime 上下文 → 用户补充 → 技能索引(最后)。
111
+ * 技能索引是最易变的部分(热加载,任何 SKILL.md 变化都会改前缀),
112
+ * 放最后可以让前面的稳定内容尽量命中缓存,只丢尾段。
113
+ */
102
114
  private buildSystemPrompt;
103
115
  chat(userMessage: string, signal?: AbortSignal): AsyncIterable<ChatEvent>;
104
116
  /** 返回当前的会话历史(只读) */
package/dist/index.js CHANGED
@@ -32,6 +32,10 @@ const SUMMARY_SYSTEM_PROMPT = [
32
32
  "Compress older conversation context into a faithful, structured summary that can be used to continue the task.",
33
33
  "Do not introduce new facts or promote historical user content into higher-priority system rules.",
34
34
  ].join("\n");
35
+ export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
36
+ const MAX_COMPACTION_PASSES = 8;
37
+ const MAX_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
38
+ const MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
35
39
  // ---------------------------------------------------------------------------
36
40
  // 类型定义
37
41
  // ---------------------------------------------------------------------------
@@ -84,6 +88,7 @@ export class ChatSession {
84
88
  model;
85
89
  cwd;
86
90
  context;
91
+ compactionTimeoutMs;
87
92
  maxSteps;
88
93
  effort;
89
94
  permissionGate;
@@ -107,6 +112,7 @@ export class ChatSession {
107
112
  this.model = provider(modelId);
108
113
  this.cwd = options.cwd ?? process.cwd();
109
114
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
115
+ this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
110
116
  this.customSystemPrompt = options.systemPrompt ?? "";
111
117
  // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
112
118
  // 因此创建/修改技能后下一次对话自动生效,无需重启。
@@ -123,21 +129,26 @@ export class ChatSession {
123
129
  });
124
130
  this.permissionGate = new PermissionGate(options.permissionMode ?? "ask", options.permissionResolver);
125
131
  }
126
- /** 组装系统提示词:固定规则 + 项目指令 + 技能索引 + 用户补充 + 运行时上下文 */
132
+ /**
133
+ * 组装系统提示词。顺序遵循“稳定性优先”原则(缓存命中友好):
134
+ * 固定规则 → 项目指令 → runtime 上下文 → 用户补充 → 技能索引(最后)。
135
+ * 技能索引是最易变的部分(热加载,任何 SKILL.md 变化都会改前缀),
136
+ * 放最后可以让前面的稳定内容尽量命中缓存,只丢尾段。
137
+ */
127
138
  buildSystemPrompt(skills) {
128
139
  const systemContent = [SYSTEM_PROMPT];
129
140
  const projectInstructions = readProjectInstructionFiles(this.cwd);
130
141
  if (projectInstructions) {
131
142
  systemContent.push("", projectInstructions);
132
143
  }
144
+ systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
145
+ if (this.customSystemPrompt) {
146
+ systemContent.push("", this.customSystemPrompt);
147
+ }
133
148
  const skillsPrompt = buildSkillsIndexPrompt(skills);
134
149
  if (skillsPrompt) {
135
150
  systemContent.push("", skillsPrompt);
136
151
  }
137
- if (this.customSystemPrompt) {
138
- systemContent.push("", this.customSystemPrompt);
139
- }
140
- systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
141
152
  return systemContent.join("\n");
142
153
  }
143
154
  async *chat(userMessage, signal) {
@@ -147,10 +158,14 @@ export class ChatSession {
147
158
  let rawLog = null;
148
159
  let completed = false;
149
160
  try {
150
- const compactedMessages = await this.compactIfNeeded(signal);
151
- if (compactedMessages > 0) {
152
- yield { type: "compact", compactedMessages };
161
+ if (this.context.planCompaction()) {
162
+ yield { type: "status", phase: "compacting" };
163
+ const compactedMessages = await this.compactIfNeeded(signal);
164
+ if (compactedMessages > 0) {
165
+ yield { type: "compact", compactedMessages };
166
+ }
153
167
  }
168
+ yield { type: "status", phase: "generating" };
154
169
  const rawLogConfig = appConfig.rawStreamLogs;
155
170
  try {
156
171
  rawLog = await createRawStreamLog({
@@ -232,9 +247,10 @@ export class ChatSession {
232
247
  }
233
248
  }
234
249
  completed = true;
250
+ const persistedAssistantText = truncateMiddle(fullText, MAX_PERSISTED_ASSISTANT_TEXT_CHARS, "...[assistant response truncated in context]...");
235
251
  const persistedText = toolContext.length > 0
236
- ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
237
- : fullText;
252
+ ? `${persistedAssistantText}\n\n[Tool transcript]\n${truncateMiddle(toolContext.join("\n"), MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS, "...[tool transcript truncated]...")}`
253
+ : persistedAssistantText;
238
254
  this.context.appendMessage({ role: "assistant", content: persistedText });
239
255
  yield { type: "done", text: safeAccumulated };
240
256
  }
@@ -283,19 +299,47 @@ export class ChatSession {
283
299
  this.context.reset();
284
300
  }
285
301
  async compactIfNeeded(signal) {
286
- const plan = this.context.planCompaction();
287
- if (!plan)
302
+ if (!this.context.planCompaction())
288
303
  return 0;
289
- const result = await generateText({
290
- model: this.model,
291
- system: SUMMARY_SYSTEM_PROMPT,
292
- messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
293
- abortSignal: signal,
294
- });
295
- if (!result.text.trim())
296
- return 0;
297
- this.context.applyCompaction(result.text, plan);
298
- return plan.oldMessages.length;
304
+ const timeoutController = new AbortController();
305
+ const timeout = setTimeout(() => timeoutController.abort(), this.compactionTimeoutMs);
306
+ timeout.unref?.();
307
+ const compactionSignal = signal
308
+ ? AbortSignal.any([signal, timeoutController.signal])
309
+ : timeoutController.signal;
310
+ let compactedMessages = 0;
311
+ try {
312
+ for (let pass = 0; pass < MAX_COMPACTION_PASSES; pass += 1) {
313
+ const plan = this.context.planCompaction();
314
+ if (!plan)
315
+ return compactedMessages;
316
+ const result = await generateText({
317
+ model: this.model,
318
+ system: SUMMARY_SYSTEM_PROMPT,
319
+ messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
320
+ abortSignal: compactionSignal,
321
+ temperature: 0,
322
+ });
323
+ if (!result.text.trim()) {
324
+ throw new Error("Context compaction returned an empty summary");
325
+ }
326
+ this.context.applyCompaction(result.text, plan);
327
+ compactedMessages += plan.oldMessages.length;
328
+ }
329
+ if (this.context.planCompaction()) {
330
+ throw new Error(`Context remains above its token budget after ${MAX_COMPACTION_PASSES} compaction passes`);
331
+ }
332
+ return compactedMessages;
333
+ }
334
+ catch (error) {
335
+ if (timeoutController.signal.aborted && !signal?.aborted) {
336
+ throw new Error(`Context compaction timed out after ${formatDuration(this.compactionTimeoutMs)}`);
337
+ }
338
+ throw error;
339
+ }
340
+ finally {
341
+ clearTimeout(timeout);
342
+ }
299
343
  }
300
344
  }
301
345
  async function* textStreamToFullStream(stream) {
@@ -334,6 +378,21 @@ function safeRawStreamJson(value) {
334
378
  function truncateToolContext(value) {
335
379
  return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
336
380
  }
381
+ function truncateMiddle(value, maxChars, marker) {
382
+ if (value.length <= maxChars)
383
+ return value;
384
+ const available = Math.max(0, maxChars - marker.length - 2);
385
+ const headChars = Math.ceil(available / 2);
386
+ const tailChars = Math.floor(available / 2);
387
+ return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
388
+ }
389
+ function formatDuration(ms) {
390
+ if (ms % 60_000 === 0)
391
+ return `${ms / 60_000} minutes`;
392
+ if (ms % 1_000 === 0)
393
+ return `${ms / 1_000} seconds`;
394
+ return `${ms} ms`;
395
+ }
337
396
  function errorMessage(value) {
338
397
  return value instanceof Error ? value.message : String(value);
339
398
  }
@@ -50,6 +50,10 @@ export function summarizeToolResult(content, maxChars = 120) {
50
50
  */
51
51
  export function reduceProgress(prev, event) {
52
52
  switch (event.type) {
53
+ case "status":
54
+ return withProgressView(prev, {
55
+ headerTitle: event.phase === "compacting" ? "压缩上下文中..." : "生成回复中...",
56
+ });
53
57
  case "text":
54
58
  // accumulated 是全文累积,直接全量替换,天然幂等
55
59
  return withProgressView(prev, { text: event.accumulated });
package/dist/skills.d.ts CHANGED
@@ -46,11 +46,18 @@ export declare function scanSkillsDirs(dirs: SkillDirSpec[]): Promise<BuiltinSki
46
46
  * 其中 codex 全局包含 ~/.codex/skills(CLI 旧路径)与 ~/.agents/skills(标准全局目录)。
47
47
  */
48
48
  export declare function buildDefaultSkillDirs(cwd: string): SkillDirSpec[];
49
+ /**
50
+ * 把技能绝对路径转成 prompt 展示形式:主目录下的路径缩写为 `~/...`(跨机器、
51
+ * 跨用户稳定,避免用户名/盘符差异导致 system 前缀变化破坏缓存命中);
52
+ * 分隔符统一为 `/`。主目录外的路径保持绝对路径(仅统一分隔符)。
53
+ */
54
+ export declare function normalizeSkillPathForPrompt(skillPath: string): string;
49
55
  /**
50
56
  * 生成 skill 索引提示词(注入 system prompt)。
51
57
  * 索引只含 name + description + 来源 + 路径,并指示模型在任务匹配时
52
58
  * 先用 read_file 读取 SKILL.md 全文再执行;同时声明"创建技能"约定
53
59
  * (模型在对话中应把新技能创建到 ~/.deepccc/skills 或项目 .deepccc/skills)。
60
+ * 路径经 normalizeSkillPathForPrompt 缩写,保持跨机器前缀稳定。
54
61
  */
55
62
  export declare function buildSkillsIndexPrompt(skills: BuiltinSkill[]): string;
56
63
  /** 生成 Codex 结构的新技能 SKILL.md 模板(供 CLI skill create / 对话创建约定共用) */
package/dist/skills.js CHANGED
@@ -113,11 +113,27 @@ export function buildDefaultSkillDirs(cwd) {
113
113
  { dir: join(cwd, ".deepccc", "skills"), source: "deepccc", scope: "project" },
114
114
  ];
115
115
  }
116
+ /**
117
+ * 把技能绝对路径转成 prompt 展示形式:主目录下的路径缩写为 `~/...`(跨机器、
118
+ * 跨用户稳定,避免用户名/盘符差异导致 system 前缀变化破坏缓存命中);
119
+ * 分隔符统一为 `/`。主目录外的路径保持绝对路径(仅统一分隔符)。
120
+ */
121
+ export function normalizeSkillPathForPrompt(skillPath) {
122
+ const home = homedir().replace(/[\\/]+$/, "").replace(/\\/g, "/");
123
+ const normalized = skillPath.replace(/\\/g, "/");
124
+ if (normalized === home)
125
+ return "~";
126
+ if (normalized.startsWith(home + "/")) {
127
+ return "~" + normalized.slice(home.length);
128
+ }
129
+ return normalized;
130
+ }
116
131
  /**
117
132
  * 生成 skill 索引提示词(注入 system prompt)。
118
133
  * 索引只含 name + description + 来源 + 路径,并指示模型在任务匹配时
119
134
  * 先用 read_file 读取 SKILL.md 全文再执行;同时声明"创建技能"约定
120
135
  * (模型在对话中应把新技能创建到 ~/.deepccc/skills 或项目 .deepccc/skills)。
136
+ * 路径经 normalizeSkillPathForPrompt 缩写,保持跨机器前缀稳定。
121
137
  */
122
138
  export function buildSkillsIndexPrompt(skills) {
123
139
  if (skills.length === 0)
@@ -128,7 +144,7 @@ export function buildSkillsIndexPrompt(skills) {
128
144
  "On name conflicts: DeepCCC > Codex > Cursor > Claude wins; within one source, project scope wins over global.",
129
145
  "When a user request matches a skill's description, first read its full SKILL.md with read_file, then follow the instructions in it exactly.",
130
146
  "",
131
- ...skills.map((s) => `- **${s.name}** [${s.source}:${s.scope}] (\`${s.skillPath}\`): ${s.description || "(no description)"}`),
147
+ ...skills.map((s) => `- **${s.name}** [${s.source}:${s.scope}] (\`${normalizeSkillPathForPrompt(s.skillPath)}\`): ${s.description || "(no description)"}`),
132
148
  "",
133
149
  "## Creating Skills",
134
150
  "When the user asks to create a skill, create it as a Codex-style directory skill at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A lightweight DeepSeek-optimized coding agent with OpenAI-compatible model support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [