thincoder 0.7.1 → 0.7.2

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
@@ -101,6 +101,10 @@ TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全
101
101
  "baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
102
102
  "apiKey": "sk-...", // 或留空走环境变量
103
103
  "model": "deepseek-chat",
104
+ // 可选:主动节流预算(按账户限速等级自配,不配则关闭闸门,429 退避仍生效)。
105
+ // 限速是账户级独立计数器(RPM/TPM 按 60s 窗口),等级查各厂商控制台
106
+ // "tpm": 200000, // tokens/分钟(输入+输出总量)
107
+ // "rpm": 50, // 请求数/分钟
104
108
  },
105
109
  ],
106
110
  "activeProvider": "deepseek", // 当前激活的 provider 名
@@ -189,6 +193,13 @@ node scripts/verify-team.mjs # 团队记忆 A->git->B 全链路验证(本
189
193
 
190
194
  ## 更新日志
191
195
 
196
+ ### 0.7.2(2026-07)
197
+ - **TPM/RPM 主动节流闸门**:provider 配置 `tpm`/`rpm` 预算后,发请求前本地滑动窗口记账(60s,输入+输出),超预算先睡到窗口腾出空间而不是打 429 碰运气;主循环/压缩摘要/子 agent/截断续写全覆盖。等待时状态栏显示 `TPM 节流等待 ~Ns`,不配的 provider 闸门关闭
198
+ - **429 专项退避**:尊重 `Retry-After` 响应头,无则按 15s/30s/60s(60s 窗口,秒级退避无意义);配额/余额错误(`exceeded_current_quota_error`)与限速区分,不再无效重试
199
+ - **依赖注入改为紧凑摘要**:`buildSummary`(目录级依赖 + 枢纽文件 + 入口,天然 ~1-2k 字符)替代全量大纲注入,详细 import/export 用 `repo_outline` 按需查
200
+ - **TUI 菜单化**:`/model` `/config` `/provider` `/think` `/mcp` `/goal` `/session` `/rewind` 统一改为选择器菜单
201
+ - **会话健壮性**:归档/切换时文件损坏或磁盘异常不再崩,静默放弃
202
+
192
203
  ### 0.7.1(2026-07)
193
204
  - **修复上下文爆炸(紧急)**:依赖大纲开局注入不再无界——多仓库父目录(索引数千文件)的全量大纲实测达 140 万字符 ≈ 35 万 token,且每轮对话重复注入累积,几轮即打爆上下文并触发 TPM 限流。现截断到 6000 字符(超出指引用 `repo_outline` 聚焦查询)且每会话只注一次
194
205
  - **压缩逃逸口**:历史太短(≤13 条)切不出中间段时压缩永远不发生,一条巨型消息(大段粘贴/超大注入)即可卡死。现走确定性瘦身:超长 user/tool 正文截断换桩,不动 reasoning_content 与 tool_calls 配对
package/bin/thincoder.mjs CHANGED
@@ -179,6 +179,9 @@ switch (command) {
179
179
  try {
180
180
  await runAgent(agent, prompt, {
181
181
  onToken: (text) => process.stdout.write(text),
182
+ onWait: ({ phase, seconds }) => {
183
+ console.error(phase === "gate" ? `[rate-limit] TPM 节流等待 ~${seconds}s` : `[rate-limit] 429,${seconds}s 后重试`)
184
+ },
182
185
  onToolCall: (name, toolArgs) => {
183
186
  console.error(`\n[tool] ${name} ${summarize(toolArgs)}`)
184
187
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/agent.mjs CHANGED
@@ -133,9 +133,10 @@ function escapeXml(s) {
133
133
  const TOOL_RESULT_OFFLOAD_LIMIT = 16_000 // 工具结果超过此长度即落盘(防单次输出灌爆上下文)
134
134
  const TOOL_RESULT_PREVIEW = 2_000
135
135
 
136
- /** 依赖大纲注入:前缀(历史查重去重用)与长度硬上限(多仓库父目录的全量大纲可达百万字符) */
136
+ /** 依赖摘要注入:前缀(历史查重去重用)。
137
+ * v0.7 从全量大纲改为紧凑摘要(buildSummary)——目录级依赖 + 枢纽文件 + 入口,
138
+ * 天然有界 ~1-2k 字符,不再需要 OUTLINE_INJECT_MAX 硬截断。 */
137
139
  const OUTLINE_INJECT_PREFIX = "[System reminder: project dependency outline:"
138
- const OUTLINE_INJECT_MAX = 6_000
139
140
 
140
141
  /** 会改文件的写工具(文件触碰追踪 + 增量索引用) */
141
142
  const FILE_MUTATORS = new Set(["write", "edit", "insert_after", "apply_patch", "delete"])
@@ -739,21 +740,16 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
739
740
  if (tree) {
740
741
  agent.history.push({ role: "user", content: `[System reminder: working directory snapshot:\n<untrusted_cwd_listing>\n${escapeXml(tree)}\n</untrusted_cwd_listing>]`, transient: true })
741
742
  }
742
- // 依赖大纲:模型开局就能看见谁 import 谁,不用盲调 repo_outline。
743
- // 两道保险(多仓库父目录的全量大纲实测可达 140 万字符 35 token,曾直接打爆上下文 + TPM):
744
- // 1) 硬截断到 OUTLINE_INJECT_MAX,超了让模型用 repo_outline 工具按需查聚焦视图;
745
- // 2) 每会话只注一次(历史已有则跳过)——runAgent 每轮都跑,重复注入会让大纲按轮数累积
743
+ // 依赖摘要(紧凑版,替代旧的全量大纲注入):
744
+ // 目录级依赖 + 枢纽文件 + 入口文件,天然 ~1-2k 字符;
745
+ // 详细 import/export repo_outline 工具按需查。
746
+ // 每会话只注一次(历史已有则跳过)
746
747
  if (agent.memory && !agent.history.some((m) => typeof m.content === "string" && m.content.startsWith(OUTLINE_INJECT_PREFIX))) {
747
748
  try {
748
- const { buildOutline } = await import("./repomap.mjs")
749
- let outline = buildOutline(agent.memory.db, agent.cwd, null)
750
- if (outline && !outline.startsWith("(no indexed")) {
751
- if (outline.length > OUTLINE_INJECT_MAX) {
752
- outline =
753
- outline.slice(0, OUTLINE_INJECT_MAX).replace(/\n[^\n]*$/, "") +
754
- "\n... (outline truncated — call repo_outline with a file path for a focused view)"
755
- }
756
- agent.history.push({ role: "user", content: `${OUTLINE_INJECT_PREFIX}\n${outline}]`, transient: true })
749
+ const { buildSummary } = await import("./repomap.mjs")
750
+ const summary = buildSummary(agent.memory.db, agent.cwd)
751
+ if (summary && !summary.startsWith("(no indexed")) {
752
+ agent.history.push({ role: "user", content: `${OUTLINE_INJECT_PREFIX}\n${summary}]`, transient: true })
757
753
  }
758
754
  } catch { /* 索引未就绪不报错 */ }
759
755
  }
@@ -878,6 +874,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
878
874
  tools: toolSchemas,
879
875
  onToken: callbacks.onToken,
880
876
  onReasoning: callbacks.onReasoning,
877
+ onWait: callbacks.onWait,
881
878
  signal,
882
879
  })
883
880
  // token 用量(含 DeepSeek 缓存命中/未命中)透传给 UI 层展示
package/src/provider.mjs CHANGED
@@ -10,11 +10,109 @@ import { specForModel } from "./config.mjs"
10
10
  export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
11
11
  const MAX_RETRIES = 3
12
12
  const MAX_CONTINUATIONS = 3 // Partial Mode 截断续写上限(防异常的无限 length 循环)
13
+ // 429 的专项退避:RPM/TPM 按 60s 窗口计(账户级独立计数器),通用退避的 1s/2s/4s 等不出窗口
14
+ const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
13
15
 
14
16
  /**
15
- * 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort? }
17
+ * 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
18
+ * 生产代码不要直接调 setTimeout/sleep,统一走这里。
19
+ */
20
+ export const _rateHooks = {
21
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
22
+ now: () => Date.now(),
23
+ windowMs: 60_000,
24
+ }
25
+
26
+ // ---------------------------------------------------------------- TPM/RPM 主动节流闸门
27
+
28
+ /**
29
+ * 滑动窗口闸门:发请求前若 窗口内已耗 + 本次估算 超预算,就睡到最早的记录滑出窗口。
30
+ * 按 baseURL+apiKey 记账(限速是账户级);预算来自 provider.tpm / provider.rpm,
31
+ * 不配则闸门关闭(429 反应式退避仍然生效)。主循环/压缩摘要/子 agent/截断续写全走这里。
32
+ */
33
+ const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
34
+
35
+ function rateKey(provider) {
36
+ return `${provider.baseURL}|${provider.apiKey ?? ""}`
37
+ }
38
+
39
+ /** 粗估文本 token 数(与 context.mjs 同口径:ASCII/4 + 非 ASCII/1;context 依赖本模块,不能反向 import) */
40
+ function estimateText(s) {
41
+ let nonAscii = 0
42
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
43
+ return Math.ceil((s.length - nonAscii) / 4) + nonAscii
44
+ }
45
+
46
+ /** 本次请求的 prompt 估算:messages 正文 + 思考链 + tool_calls 参数 + tools schema */
47
+ function estimateRequestTokens(body) {
48
+ let tokens = 0
49
+ for (const m of body.messages ?? []) {
50
+ if (typeof m.content === "string") tokens += estimateText(m.content)
51
+ if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
52
+ for (const tc of m.tool_calls ?? []) {
53
+ tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
54
+ }
55
+ }
56
+ if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
57
+ return tokens
58
+ }
59
+
60
+ /** 闸门:超预算则睡到窗口腾出空间;onWait({phase:"gate", seconds}) 供 UI 展示等待 */
61
+ async function rateGate(provider, estimated, onWait, signal) {
62
+ // 单次请求估算已超过 TPM 预算时闸门无意义(等到天荒地老也塞不下),放行由服务端裁决
63
+ const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
64
+ const rpm = provider.rpm ?? null
65
+ if (tpm == null && rpm == null) return
66
+ const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
67
+ rateWindows.set(rateKey(provider), w)
68
+ for (;;) {
69
+ const now = _rateHooks.now()
70
+ const cutoff = now - _rateHooks.windowMs
71
+ w.tokens = w.tokens.filter((e) => e.ts > cutoff)
72
+ w.requests = w.requests.filter((ts) => ts > cutoff)
73
+ const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
74
+ const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
75
+ const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
76
+ if (overTokens <= 0 && overRequests <= 0) break
77
+ let waitMs = _rateHooks.windowMs
78
+ if (overTokens > 0) {
79
+ // tokens 按时间升序:累加最早若干条,过期量足够腾出空间时的过期时刻
80
+ let freed = 0
81
+ for (const e of w.tokens) {
82
+ freed += e.n
83
+ if (freed >= overTokens) {
84
+ waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
85
+ break
86
+ }
87
+ }
88
+ }
89
+ if (overRequests > 0) {
90
+ // 等第 overRequests-1 条(升序)滑出,窗口内请求数即降回 rpm-1
91
+ waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
92
+ }
93
+ waitMs = Math.max(waitMs, 50)
94
+ onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
95
+ await _rateHooks.sleep(waitMs)
96
+ if (signal?.aborted) return
97
+ }
98
+ }
99
+
100
+ /** 记账:响应回来后按实测 usage 记(无 usage 用发送前的估算兜底);被拒的请求不记(服务端未处理) */
101
+ function recordRate(provider, estimated, usage) {
102
+ if (provider.tpm == null && provider.rpm == null) return
103
+ const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
104
+ rateWindows.set(rateKey(provider), w)
105
+ const now = _rateHooks.now()
106
+ w.requests.push(now)
107
+ // TPM 按输入+输出总量计(Moonshot 口径)
108
+ w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
109
+ }
110
+
111
+ /**
112
+ * 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort?, tpm?, rpm? }
16
113
  * thinking: { type: "enabled"|"disabled" } 思维模式开关
17
114
  * reasoningEffort: "low"|"high"|"max" 推理强度(DeepSeek/Kimi/GLM 通用)
115
+ * tpm / rpm: 主动节流预算(tokens/分钟、请求数/分钟,按账户限速等级自配),不配则闸门关闭
18
116
  */
19
117
  export function createProvider(config) {
20
118
  if (!config?.baseURL) throw new Error("provider config: baseURL is required")
@@ -28,6 +126,8 @@ export function createProvider(config) {
28
126
  temperature: config.temperature,
29
127
  thinking: config.thinking,
30
128
  reasoningEffort: config.reasoningEffort,
129
+ tpm: config.tpm,
130
+ rpm: config.rpm,
31
131
  }
32
132
  }
33
133
 
@@ -35,6 +135,7 @@ export function createProvider(config) {
35
135
  * 流式对话。
36
136
  * messages: OpenAI 格式数组; tools: OpenAI tools schema(可选)
37
137
  * onToken(text): 正文流式回调; onReasoning(text): 思考流回调(DeepSeek-R1 类模型)
138
+ * onWait({phase, seconds}): 节流等待回调(phase: "gate"=主动节流 / "retry"=429 退避)
38
139
  * signal: AbortSignal(可选)
39
140
  * 返回 { content, reasoning, toolCalls: [{id, name, arguments}], usage, finishReason }
40
141
  * 注意:toolCalls[i].arguments 是 JSON 字符串,调用方负责 parse
@@ -46,7 +147,7 @@ export function createProvider(config) {
46
147
  * - prefixMode(DeepSeek):assistant 消息带 prefix:true,且须走 /beta 端点;
47
148
  * 思考模式不支持前缀续写,已产出 reasoning 时放弃续写
48
149
  */
49
- export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
150
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
50
151
  const spec = specForModel(provider.model)
51
152
  const body = {
52
153
  model: provider.model,
@@ -77,8 +178,13 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
77
178
  }
78
179
  if (tools?.length) body.tools = tools
79
180
 
80
- const response = await requestWithRetry(provider, body, signal)
181
+ // TPM/RPM 主动节流:超预算先在本地睡到窗口腾出空间,不打 429 碰运气
182
+ const estimated = estimateRequestTokens(body)
183
+ await rateGate(provider, estimated, onWait, signal)
184
+
185
+ const response = await requestWithRetry(provider, body, signal, onWait)
81
186
  const result = await readSSE(response, { onToken, onReasoning })
187
+ recordRate(provider, estimated, result.usage)
82
188
 
83
189
  // 截断续写:仅规格表声明续写协议的模型(其他端点不认识 partial/prefix 字段,可能 400)
84
190
  if (!spec.partialMode && !spec.prefixMode) return result
@@ -101,6 +207,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
101
207
  tools,
102
208
  onToken,
103
209
  onReasoning,
210
+ onWait,
104
211
  signal,
105
212
  })
106
213
  result.content += continued.content
@@ -145,11 +252,18 @@ export async function listModels(provider, { signal } = {}) {
145
252
  return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
146
253
  }
147
254
 
148
- /** 带重试的请求:网络错误与 429/5xx 指数退避(1s/2s/4s),其余 4xx 直接抛 */
149
- async function requestWithRetry(provider, body, signal) {
255
+ /**
256
+ * 带重试的请求:网络错误与 5xx 指数退避(1s/2s/4s);429 专项处理——
257
+ * 有 Retry-After 以它为准,没有按 15s/30s/60s 退避(RPM/TPM 是 60s 窗口,秒级退避等不出去)。
258
+ * 配额/余额错误(如 exceeded_current_quota_error)与限速同状态码但语义不同:重试无用,直接抛。
259
+ */
260
+ async function requestWithRetry(provider, body, signal, onWait) {
150
261
  let lastError
262
+ let lastWas429 = false
263
+ let rateLimitHits = 0 // 连续 429 计数(退避档位用,与 attempt 解耦)
151
264
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
152
- if (attempt > 0) await sleep(2 ** (attempt - 1) * 1000)
265
+ if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
266
+ lastWas429 = false
153
267
 
154
268
  let response
155
269
  try {
@@ -172,6 +286,21 @@ async function requestWithRetry(provider, body, signal) {
172
286
 
173
287
  const text = await response.text().catch(() => "")
174
288
  const message = `LLM API error ${response.status}: ${text}`
289
+ if (isQuotaError(text)) throw new Error(message)
290
+ if (response.status === 429) {
291
+ const retryAfter = Number(response.headers.get("retry-after"))
292
+ const waitMs =
293
+ Number.isFinite(retryAfter) && retryAfter > 0
294
+ ? retryAfter * 1000
295
+ : RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
296
+ lastError = new Error(message)
297
+ lastWas429 = true
298
+ if (attempt < MAX_RETRIES) {
299
+ onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
300
+ await _rateHooks.sleep(waitMs)
301
+ }
302
+ continue
303
+ }
175
304
  if (RETRYABLE_STATUS.has(response.status)) {
176
305
  lastError = new Error(message)
177
306
  continue
@@ -181,6 +310,16 @@ async function requestWithRetry(provider, body, signal) {
181
310
  throw lastError
182
311
  }
183
312
 
313
+ /** 配额/余额错误(重试无意义):错误体 type 含 quota,如 Moonshot exceeded_current_quota_error */
314
+ function isQuotaError(text) {
315
+ try {
316
+ const type = JSON.parse(text)?.error?.type
317
+ return typeof type === "string" && type.includes("quota")
318
+ } catch {
319
+ return false
320
+ }
321
+ }
322
+
184
323
  /** 解析 SSE 流,累积正文/思考/tool_calls */
185
324
  async function readSSE(response, { onToken, onReasoning }) {
186
325
  const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
@@ -237,10 +376,6 @@ async function readSSE(response, { onToken, onReasoning }) {
237
376
  return result
238
377
  }
239
378
 
240
- function sleep(ms) {
241
- return new Promise((resolve) => setTimeout(resolve, ms))
242
- }
243
-
244
379
  /** DeepSeek Prefix Completion 只在 /beta 端点开放:.../v1 → .../beta */
245
380
  function betaBaseURL(baseURL) {
246
381
  return baseURL.replace(/\/v1$/, "/beta")
package/src/repomap.mjs CHANGED
@@ -109,15 +109,15 @@ function normalizeExt(p) {
109
109
  return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
110
110
  }
111
111
 
112
- /** 从 code_chunks 取已知文件列表(复用索引),按路径解析生成大纲文本 */
113
- export function buildOutline(db, cwd, focusPath) {
112
+ /**
113
+ * 内部:扫描全量文件,构建正向依赖图 + 反向引用图。
114
+ * 返回 { deps, importers, fileCount } 供 buildOutline / buildSummary 共用。
115
+ */
116
+ function _buildDepGraph(db, cwd) {
114
117
  const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
118
+ if (allFiles.length === 0) return null
115
119
 
116
- if (allFiles.length === 0) return "(no indexed source files; run codeSync or /reindex first)"
117
-
118
- // 构建正向(谁 import 谁)+ 反向(被谁 import)图——总是全量扫描,
119
- // 因为聚焦一个文件也需要知道别的文件是否 import 了它
120
- const deps = new Map() // path → { imports: Set, exports: Set, size: number }
120
+ const deps = new Map() // path { imports: Set, exports: Set, size: number, dir: string }
121
121
  const importers = new Map() // importee → Set<importer>
122
122
 
123
123
  for (const rel of allFiles) {
@@ -140,7 +140,6 @@ export function buildOutline(db, cwd, focusPath) {
140
140
  // 把 import 路径解析成相对路径(处理 ./ ../)
141
141
  const resolved = []
142
142
  for (let imp of imports) {
143
- // 去掉 ./ 前缀
144
143
  if (imp.startsWith("./")) imp = imp.slice(2)
145
144
  const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
146
145
  const parts = imp.split("/")
@@ -154,7 +153,8 @@ export function buildOutline(db, cwd, focusPath) {
154
153
  }
155
154
  }
156
155
 
157
- deps.set(rel, { imports: new Set(resolved), exports: new Set(exports), size: Math.floor(text.length / 1024) })
156
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "."
157
+ deps.set(rel, { imports: new Set(resolved), exports: new Set(exports), size: Math.floor(text.length / 1024), dir })
158
158
 
159
159
  for (const r of resolved) {
160
160
  if (!importers.has(r)) importers.set(r, new Set())
@@ -162,7 +162,97 @@ export function buildOutline(db, cwd, focusPath) {
162
162
  }
163
163
  }
164
164
 
165
- // 生成文本
165
+ return { deps, importers, fileCount: allFiles.length }
166
+ }
167
+
168
+ /**
169
+ * 生成紧凑架构摘要(替换旧的全量注入)。
170
+ * 三层信息,每层信息密度递减:
171
+ * 1. 目录级依赖(多目录项目才有意义,单目录跳过)
172
+ * 2. 枢纽文件 Top-12(被 import 最多的文件——架构骨架)
173
+ * 3. 入口文件(无人 import 的文件——启动/顶层入口)
174
+ * 输出天然有界(~1000-2000 字符),不再需要 OUTLINE_INJECT_MAX 硬截断。
175
+ */
176
+ export function buildSummary(db, cwd) {
177
+ const graph = _buildDepGraph(db, cwd)
178
+ if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
179
+ const { deps, importers, fileCount } = graph
180
+
181
+ const out = []
182
+ out.push(`${fileCount} source files indexed.`)
183
+
184
+ // 1) 目录级依赖
185
+ const dirDeps = new Map() // dir → Set<imported-dir>
186
+ const dirSet = new Set()
187
+ for (const [rel, d] of deps) {
188
+ dirSet.add(d.dir)
189
+ if (!dirDeps.has(d.dir)) dirDeps.set(d.dir, new Set())
190
+ for (const imp of d.imports) {
191
+ const targetDir = imp.includes("/") ? imp.slice(0, imp.lastIndexOf("/")) : "."
192
+ if (targetDir !== d.dir) dirDeps.get(d.dir).add(targetDir)
193
+ }
194
+ }
195
+ if (dirSet.size > 1) {
196
+ out.push("Directory dependencies:")
197
+ for (const dir of [...dirSet].sort()) {
198
+ const targets = dirDeps.get(dir)
199
+ if (targets?.size) {
200
+ out.push(` ${dir}/ → ${[...targets].sort().join(", ")}/`)
201
+ } else {
202
+ out.push(` ${dir}/ (leaf)`)
203
+ }
204
+ }
205
+ }
206
+
207
+ // 2) 枢纽文件 Top-12:按被 import 次数降序
208
+ const HUB_LIMIT = 12
209
+ const hubScores = []
210
+ for (const [rel] of deps) {
211
+ const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
212
+ const rev = importers.get(key)
213
+ if (rev?.size) hubScores.push({ path: rel, count: rev.size })
214
+ }
215
+ hubScores.sort((a, b) => b.count - a.count)
216
+ if (hubScores.length > 0) {
217
+ out.push(`Hub files (by inbound dependencies, top ${Math.min(hubScores.length, HUB_LIMIT)}):`)
218
+ for (const h of hubScores.slice(0, HUB_LIMIT)) {
219
+ const d = deps.get(h.path)
220
+ const kb = d?.size ? ` (${d.size} KB)` : ""
221
+ const key = h.path.replace(/\.(m?js|jsx|tsx?)$/i, "")
222
+ const rev = importers.get(key)
223
+ const shortRefs = rev.size <= 5
224
+ ? [...rev].join(", ")
225
+ : [...rev].slice(0, 4).join(", ") + ` +${rev.size - 4} more`
226
+ out.push(` ${h.path}${kb} — imported by: ${shortRefs}`)
227
+ }
228
+ }
229
+
230
+ // 3) 入口文件:无人 import 的(叶子/入口)
231
+ const entries = []
232
+ for (const [rel] of deps) {
233
+ const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
234
+ if (!importers.has(key) || importers.get(key).size === 0) {
235
+ entries.push(rel)
236
+ }
237
+ }
238
+ if (entries.length > 0 && entries.length < fileCount) {
239
+ const limit = 8
240
+ const shown = entries.slice(0, limit)
241
+ out.push(`Entry points (not imported by others):`)
242
+ for (const e of shown) out.push(` ${e}`)
243
+ if (entries.length > limit) out.push(` ... +${entries.length - limit} more`)
244
+ }
245
+
246
+ out.push("For detailed per-file relationships, call repo_outline with a file path.")
247
+ return out.join("\n")
248
+ }
249
+
250
+ /** 从 code_chunks 取已知文件列表(复用索引),按路径解析生成大纲文本 */
251
+ export function buildOutline(db, cwd, focusPath) {
252
+ const graph = _buildDepGraph(db, cwd)
253
+ if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
254
+ const { deps, importers } = graph
255
+
166
256
  const files = focusPath ? [focusPath] : [...deps.keys()]
167
257
  const out = []
168
258
  const sorted = files.sort()
package/src/session.mjs CHANGED
@@ -63,7 +63,14 @@ export function archiveCurrent(cwd, { exclude } = {}) {
63
63
 
64
64
  const dst = slotPath(cwd, slot)
65
65
  // 复制(rename 会丢当前);走原子写,防中途崩溃留下截断的 JSON 丢归档
66
- writeSessionFile(dst, JSON.parse(readFileSync(src, "utf8")))
66
+ let data
67
+ try {
68
+ data = JSON.parse(readFileSync(src, "utf8"))
69
+ } catch {
70
+ // 会话文件损坏,放弃归档,下次保存会覆盖
71
+ return
72
+ }
73
+ writeSessionFile(dst, data)
67
74
  m.slots[slot] = Date.now()
68
75
  delete m.slots._currentName
69
76
  saveManifest(cwd, m)
@@ -91,10 +98,14 @@ export function switchToSlot(cwd, slot) {
91
98
  const src = slotPath(cwd, slot)
92
99
  const dst = sessionPath(cwd)
93
100
  if (!existsSync(src)) return null
94
- // 先删当前文件(archiveCurrent 是复制不是移动,所以它还在)
95
- try { unlinkSync(dst) } catch { /* 不存在就算了 */ }
96
- copyFileSync(src, dst)
97
- unlinkSync(src)
101
+ try {
102
+ try { unlinkSync(dst) } catch { /* 不存在就算了 */ }
103
+ copyFileSync(src, dst)
104
+ unlinkSync(src)
105
+ } catch {
106
+ // 文件操作失败(磁盘满/权限不足/锁文件),放弃切换
107
+ return null
108
+ }
98
109
 
99
110
  // 重读 manifest(archiveCurrent 改了它)
100
111
  const m2 = loadManifest(cwd)