thincoder 0.12.60 → 0.12.62
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/CHANGELOG.md +49 -2
- package/README.md +8 -6
- package/bin/thincoder.mjs +38 -124
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- package/src/acp.mjs +19 -6
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +36 -134
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +124 -329
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +3 -2
- package/src/agent/completion.mjs +1 -1
- package/src/agent/dispatch.mjs +47 -12
- package/src/agent/helpers.mjs +71 -13
- package/src/agent/record-results.mjs +13 -5
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +24 -7
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +43 -11
- package/src/agent-tools/advisor-async.mjs +70 -180
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +69 -20
- package/src/agent-tools/async-settle.mjs +13 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +28 -10
- package/src/agent-tools/design-token.mjs +14 -1
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +3 -3
- package/src/agent-tools/escalate-async.mjs +22 -13
- package/src/agent-tools/read-history.mjs +31 -6
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +18 -6
- package/src/agent-tools/subagent-async.mjs +66 -14
- package/src/agent-tools/subagent-panel.mjs +22 -15
- package/src/agent-tools/subagent-run.mjs +10 -7
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +69 -16
- package/src/agent-tools/subagent.mjs +175 -49
- package/src/agent-tools/verify.mjs +13 -34
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +42 -21
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +23 -7
- package/src/cli/memory-command.mjs +2 -2
- package/src/cli/setup-wizard.mjs +29 -9
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +132 -63
- package/src/context.mjs +12 -1
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +35 -8
- package/src/expand-home.mjs +16 -0
- package/src/generate-title.mjs +9 -4
- package/src/heap-watch.mjs +88 -0
- package/src/hooks.mjs +7 -3
- package/src/ledger.mjs +227 -0
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +77 -36
- package/src/memory/core.mjs +8 -9
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +17 -11
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/scan.mjs +95 -0
- package/src/memory/schema.mjs +15 -3
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/prompt-overlays.mjs +73 -16
- package/src/prompts/advisor-design.md +19 -9
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +14 -3
- package/src/prompts/advisor-round3.md +14 -3
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- package/src/prompts/discipline-engineering.md +258 -0
- package/src/prompts/discipline-normal.md +185 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +60 -0
- package/src/prompts/persona-engineering.md +55 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +13 -32
- package/src/provider/errors.mjs +26 -1
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +9 -2
- package/src/session-guard.mjs +12 -0
- package/src/session-segments.mjs +100 -0
- package/src/session-slots.mjs +10 -2
- package/src/session-store.mjs +441 -0
- package/src/session.mjs +137 -99
- package/src/text-budget.mjs +46 -0
- package/src/token-ttl.mjs +2 -1
- package/src/tools/{system.mjs → bash.mjs} +6 -243
- package/src/tools/file.mjs +30 -10
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +15 -34
- package/src/tools/index.mjs +4 -2
- package/src/tools/ops.mjs +20 -7
- package/src/tools/question.md +1 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -1
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/traces/trace-store.mjs +195 -64
- package/src/tui/agent-turn.mjs +32 -13
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-clear.mjs +2 -0
- package/src/tui/cmd-config.mjs +108 -37
- package/src/tui/cmd-eng.mjs +11 -27
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +5 -1
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +8 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/display-budget.mjs +184 -0
- package/src/tui/index.mjs +69 -40
- package/src/tui/key-handler-search.mjs +9 -1
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/ledger-surface.mjs +69 -0
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +47 -10
- package/src/tui/pickers.mjs +28 -410
- package/src/tui/render-frame.mjs +38 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +5 -19
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +45 -13
- package/src/tui/subagent-blocks.mjs +70 -90
- package/src/tui/subagent-children.mjs +125 -67
- package/src/tui/subagent-freeze.mjs +48 -45
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +31 -83
- package/src/tui/tool-args.mjs +9 -4
- package/src/tui/tool-display.mjs +20 -5
- package/src/tui/tool-events.mjs +76 -30
- package/src/tui/tui-lifecycle.mjs +27 -7
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +54 -0
- package/src/prompts/coder.md +0 -13
- package/src/prompts/discipline.md +0 -84
- package/src/prompts/eng-coder.md +0 -19
- package/src/prompts/engineering-sub.md +0 -14
- package/src/prompts/engineering.md +0 -87
- package/src/prompts/explore.md +0 -12
- package/src/prompts/main.md +0 -34
- package/src/prompts/methodology-template.md +0 -38
- package/src/prompts/plan.md +0 -9
- package/src/prompts/system.md +0 -44
package/src/provider/errors.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* provider/errors.mjs — 错误分类与流规则编译族(2026-09-05 module-split:core.mjs
|
|
3
3
|
* 557 > 500 硬限——parseRetryAfter/isNonRetryableError/betaBaseURL/compileStreamRules
|
|
4
|
-
* verbatim 迁入,语义零变;core.mjs import 回(chat
|
|
4
|
+
* verbatim 迁入,语义零变;core.mjs import 回(chat 调用点零改)。
|
|
5
5
|
* 注:retry.mjs(anthropic/google/responses 通道)自 2026-09-08 起导入本文件的
|
|
6
6
|
* parseRetryAfter/isNonRetryableError(ENG-SESSION-PROVIDER-CLEANUP D2.2/D2.4 去重——
|
|
7
7
|
* 单实现;早先的"循环依赖回避"复制已随依赖方向实测消解)。
|
|
@@ -74,3 +74,28 @@ export function compileStreamRules(rules) {
|
|
|
74
74
|
}
|
|
75
75
|
}).filter(Boolean)
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Provider-level pre-flight error (MODEL-400-FIX F-1 — 请求体组装前断言): carries the
|
|
80
|
+
* provider identity so a fail-fast throw is readable ("which provider + what to fix")
|
|
81
|
+
* instead of a wire-time serde 400 or a bare message without context.
|
|
82
|
+
*/
|
|
83
|
+
export class ProviderError extends Error {
|
|
84
|
+
constructor(provider, message) {
|
|
85
|
+
super(`provider "${provider?.name ?? provider?.model ?? "unknown"}": ${message}`)
|
|
86
|
+
this.name = "ProviderError"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* F-1 (MODEL-400-FIX) 根因兜底——请求体组装前断言:渠道裸克隆(`{...渠道}`——渠道只带
|
|
92
|
+
* 默认单值 `model`,克隆链未重派生 `.model` 时)provider.model 为 undefined/null——
|
|
93
|
+
* JSON.stringify 会丢 undefined 键 → 无 model 请求 → serde 400。
|
|
94
|
+
* fail-fast 报可读错误(带 provider 名 + 修复线索),不发病体。core.mjs chatImpl openai body 组装
|
|
95
|
+
* 前调用(单行——core.mjs 500 行硬限)。
|
|
96
|
+
*/
|
|
97
|
+
export function assertProviderModel(provider) {
|
|
98
|
+
if (!provider.model) {
|
|
99
|
+
throw new ProviderError(provider, "model is undefined — provider cloned without model re-derivation (set providers[].model — the channel default model)")
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/provider/google.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { proxyFetch } from "../proxy.mjs"
|
|
8
8
|
import { requestWithRetry } from "./retry.mjs"
|
|
9
9
|
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
10
|
+
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
10
11
|
|
|
11
12
|
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
12
13
|
function mapFunctionCallingConfig(choice) {
|
|
@@ -104,7 +105,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
104
105
|
// Gemini uses API key as query parameter
|
|
105
106
|
const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
|
|
106
107
|
|
|
107
|
-
if (signal?.aborted) throw
|
|
108
|
+
if (signal?.aborted) throw abortError(signal, "provider", "transport-google")
|
|
108
109
|
|
|
109
110
|
// 会诊 #6:TPM/RPM 闸门 + 记账
|
|
110
111
|
const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
|
|
@@ -116,7 +117,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
116
117
|
const response = await requestWithRetry(
|
|
117
118
|
() => proxyFetch(url, {
|
|
118
119
|
method: "POST",
|
|
119
|
-
headers: { "Content-Type": "application/json" },
|
|
120
|
+
headers: { ...(provider.headers ?? {}), "Content-Type": "application/json" }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
|
|
120
121
|
body: JSON.stringify(body),
|
|
121
122
|
// 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
|
|
122
123
|
signal,
|
|
@@ -199,7 +200,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
199
200
|
const armIdle = () => {
|
|
200
201
|
if (idleTimer) clearTimeout(idleTimer)
|
|
201
202
|
idleTimer = setTimeout(() => {
|
|
202
|
-
try { response.body?.destroy(
|
|
203
|
+
try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")) } catch { /* already gone */ }
|
|
203
204
|
}, READ_IDLE_MS)
|
|
204
205
|
idleTimer.unref?.()
|
|
205
206
|
}
|
|
@@ -208,9 +209,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
208
209
|
for await (const chunk of response.body) {
|
|
209
210
|
armIdle()
|
|
210
211
|
if (signal?.aborted) {
|
|
211
|
-
|
|
212
|
-
e.reason = signal.reason
|
|
213
|
-
throw e
|
|
212
|
+
throw abortError(signal, "provider", "transport-google")
|
|
214
213
|
}
|
|
215
214
|
buffer += decoder.decode(chunk, { stream: true })
|
|
216
215
|
// BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
|
package/src/provider/index.mjs
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
* provider/index.mjs — backward-compatible re-export
|
|
3
3
|
* import { chat } from "./provider" → resolves to this file
|
|
4
4
|
*/
|
|
5
|
-
export { chat, createProvider,
|
|
5
|
+
export { chat, createProvider, stripImagesForTextModel } from "./core.mjs"
|
|
6
|
+
export { listModels } from "./list-models.mjs"
|
|
6
7
|
export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-models.mjs — provider 模型清单拉取(GET /models,按 provider.format 分派——PROVIDER.md §16 M1)。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-10 自 core.mjs 迁出(原实现仅 OpenAI 形状)+ 扩 anthropic / google 两分支:
|
|
5
|
+
* - openai(缺省/未知 format——与 chat 分派缺省一致):`GET {baseURL}/models` + Bearer;解析 `data[].id`
|
|
6
|
+
* - anthropic:`GET {baseURL}/models?limit=1000` + `x-api-key` / `anthropic-version`;`has_more` 时以
|
|
7
|
+
* `last_id` 作 `after_id` 翻页跟随(≤10 页——防死循环)
|
|
8
|
+
* - google:`GET {baseURL}/models?key=…&pageSize=1000`;剥 `models/` 前缀;`nextPageToken` 翻页跟随(≤10 页)
|
|
9
|
+
*
|
|
10
|
+
* URL 组合 = `{baseURL}` + 相对路径,与 chat 各 transport 同构——baseURL 自带版本段
|
|
11
|
+
* (claude 预设 `…/v1` → `…/v1/models`;gemini 预设 `…/v1beta` → `…/v1beta/models`)。
|
|
12
|
+
* 超时制度沿用原实现(整体 15s + header 15s + body idle 15s;翻页时逐页各自计时;调用方可传
|
|
13
|
+
* `signal` 短路)。HTTP 非 2xx / 网络失败**抛出**(调用方决定降级——与现实现同);
|
|
14
|
+
* 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
|
|
15
|
+
*/
|
|
16
|
+
import { proxyFetch } from "../proxy.mjs"
|
|
17
|
+
|
|
18
|
+
const LIST_TIMEOUT_MS = 15_000
|
|
19
|
+
/** 翻页上限(cursor loop 防死循环——任一分页失败即整体抛出,不部分返回)。 */
|
|
20
|
+
const MAX_PAGES = 10
|
|
21
|
+
const ANTHROPIC_VERSION = "2023-06-01"
|
|
22
|
+
|
|
23
|
+
/** 单页 GET + JSON 解析(非 2xx → throw;畸形 JSON → null——解析侧各自防御)。 */
|
|
24
|
+
async function fetchJson(provider, url, headers, signal) {
|
|
25
|
+
const opts = {
|
|
26
|
+
headers,
|
|
27
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(LIST_TIMEOUT_MS)]) : AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
28
|
+
_headerTimeoutMs: LIST_TIMEOUT_MS,
|
|
29
|
+
_bodyIdleMs: LIST_TIMEOUT_MS,
|
|
30
|
+
}
|
|
31
|
+
const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const text = await response.text().catch(() => "")
|
|
34
|
+
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
35
|
+
}
|
|
36
|
+
return response.json().catch(() => null)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 防御性收集:字段缺失/非字符串项跳过。 */
|
|
40
|
+
function collect(rows, pick) {
|
|
41
|
+
if (!Array.isArray(rows)) return []
|
|
42
|
+
const out = []
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
const v = pick(row)
|
|
45
|
+
if (typeof v === "string" && v) out.push(v)
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function listOpenai(provider, signal) {
|
|
51
|
+
const url = `${provider.baseURL}/models`
|
|
52
|
+
const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
|
|
53
|
+
return collect(data?.data, (m) => m?.id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function listAnthropic(provider, signal) {
|
|
57
|
+
const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
|
|
58
|
+
const base = `${provider.baseURL}/models?limit=1000`
|
|
59
|
+
const out = []
|
|
60
|
+
let afterId = null
|
|
61
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
62
|
+
const url = afterId ? `${base}&after_id=${encodeURIComponent(afterId)}` : base
|
|
63
|
+
const data = await fetchJson(provider, url, headers, signal)
|
|
64
|
+
out.push(...collect(data?.data, (m) => m?.id))
|
|
65
|
+
if (!data?.has_more) return out
|
|
66
|
+
afterId = typeof data?.last_id === "string" && data.last_id ? data.last_id : null
|
|
67
|
+
if (!afterId) return out // has_more 但无游标——无法继续,避免死循环
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function listGoogle(provider, signal) {
|
|
73
|
+
const headers = { ...(provider.headers ?? {}) }
|
|
74
|
+
const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
|
|
75
|
+
const out = []
|
|
76
|
+
let pageToken = null
|
|
77
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
78
|
+
const url = pageToken ? `${base}&pageToken=${encodeURIComponent(pageToken)}` : base
|
|
79
|
+
const data = await fetchJson(provider, url, headers, signal)
|
|
80
|
+
out.push(...collect(data?.models, (m) => (typeof m?.name === "string" ? m.name.replace(/^models\//, "") : null)))
|
|
81
|
+
if (typeof data?.nextPageToken !== "string" || !data.nextPageToken) return out
|
|
82
|
+
pageToken = data.nextPageToken
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** List available model IDs from the provider's /models endpoint (format 分派——M1)。 */
|
|
88
|
+
export async function listModels(provider, { signal } = {}) {
|
|
89
|
+
const format = provider?.format
|
|
90
|
+
if (format === "anthropic") return listAnthropic(provider, signal)
|
|
91
|
+
if (format === "google") return listGoogle(provider, signal)
|
|
92
|
+
return listOpenai(provider, signal)
|
|
93
|
+
}
|
package/src/provider/rate.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* provider/rate.mjs — TPM/RPM proactive throttling gate
|
|
3
3
|
* Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
|
|
4
4
|
*/
|
|
5
|
+
import { abortError } from "../abort-provenance.mjs"
|
|
5
6
|
|
|
6
7
|
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
7
8
|
export const MAX_RETRIES = 3
|
|
@@ -86,7 +87,7 @@ export async function rateGate(provider, estimated, onWait, signal) {
|
|
|
86
87
|
waitMs = Math.max(waitMs, 50)
|
|
87
88
|
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
88
89
|
await _rateHooks.sleep(waitMs)
|
|
89
|
-
if (signal?.aborted) throw
|
|
90
|
+
if (signal?.aborted) throw abortError(signal, "provider", "rate-gate")
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -420,6 +420,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
420
420
|
// rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
|
|
421
421
|
const estimated = estimateRequestTokens({ messages })
|
|
422
422
|
await rateGate(provider, estimated, onWait, signal)
|
|
423
|
+
// 定制头展开(PROVIDER.md §21):provider.headers 在前、内置头在后——同名内置头胜出(三处 fetch 共用)
|
|
424
|
+
const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
|
|
423
425
|
|
|
424
426
|
// round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
|
|
425
427
|
// requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
|
|
@@ -429,7 +431,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
429
431
|
response = await requestWithRetry(
|
|
430
432
|
() => proxyFetch(`${provider.baseURL}/responses`, {
|
|
431
433
|
method: "POST",
|
|
432
|
-
headers
|
|
434
|
+
headers,
|
|
433
435
|
body: JSON.stringify(body),
|
|
434
436
|
signal,
|
|
435
437
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
@@ -448,7 +450,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
448
450
|
response = await requestWithRetry(
|
|
449
451
|
() => proxyFetch(`${provider.baseURL}/responses`, {
|
|
450
452
|
method: "POST",
|
|
451
|
-
headers
|
|
453
|
+
headers,
|
|
452
454
|
body: JSON.stringify(fullBody),
|
|
453
455
|
signal,
|
|
454
456
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
@@ -466,7 +468,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
466
468
|
response = await requestWithRetry(
|
|
467
469
|
() => proxyFetch(`${provider.baseURL}/responses`, {
|
|
468
470
|
method: "POST",
|
|
469
|
-
headers
|
|
471
|
+
headers,
|
|
470
472
|
body: JSON.stringify(fresh2.body),
|
|
471
473
|
signal,
|
|
472
474
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
package/src/provider/sse.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* provider/sse.mjs — SSE stream reader
|
|
3
3
|
* Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
|
|
4
4
|
*/
|
|
5
|
+
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
|
|
@@ -174,7 +175,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
174
175
|
const armIdle = () => {
|
|
175
176
|
if (idleTimer) clearTimeout(idleTimer)
|
|
176
177
|
idleTimer = setTimeout(() => {
|
|
177
|
-
try { response.body?.destroy(
|
|
178
|
+
try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")) } catch { /* already gone */ }
|
|
178
179
|
}, READ_IDLE_MS)
|
|
179
180
|
idleTimer.unref?.()
|
|
180
181
|
}
|
|
@@ -183,9 +184,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
183
184
|
for await (const chunk of response.body) {
|
|
184
185
|
armIdle()
|
|
185
186
|
if (signal?.aborted) {
|
|
186
|
-
|
|
187
|
-
e.reason = signal.reason
|
|
188
|
-
throw e
|
|
187
|
+
throw abortError(signal, "provider", "stream-read")
|
|
189
188
|
}
|
|
190
189
|
buffer += decoder.decode(chunk, { stream: true })
|
|
191
190
|
// UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
|
package/src/proxy.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { connect } from "node:net";
|
|
|
6
6
|
import { connect as tlsConnect } from "node:tls";
|
|
7
7
|
import { PassThrough } from "node:stream";
|
|
8
8
|
import { URL } from "node:url";
|
|
9
|
+
import { abortError, timeoutError } from "./abort-provenance.mjs";
|
|
9
10
|
|
|
10
11
|
const FETCH_TIMEOUT = 15_000
|
|
11
12
|
|
|
@@ -52,12 +53,6 @@ export function injectProxy(providers, config) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function abortError(signal) {
|
|
56
|
-
const e = new DOMException("The operation was aborted", "AbortError")
|
|
57
|
-
e.reason = signal?.reason
|
|
58
|
-
return e
|
|
59
|
-
}
|
|
60
|
-
|
|
61
56
|
/**
|
|
62
57
|
* 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
|
|
63
58
|
* 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
|
|
@@ -76,15 +71,15 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
76
71
|
const headers = opts.headers ?? {}
|
|
77
72
|
const signal = opts.signal
|
|
78
73
|
|
|
79
|
-
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
|
|
74
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
80
75
|
|
|
81
76
|
const body = new PassThrough()
|
|
82
77
|
let settled = false
|
|
83
78
|
let headerBuf = ""
|
|
84
79
|
let idleTimer = null
|
|
85
80
|
|
|
86
|
-
const timer = setTimeout(() => fail(
|
|
87
|
-
const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
|
|
81
|
+
const timer = setTimeout(() => fail(timeoutError("Response timeout", "provider", "proxy-header")), timeout)
|
|
82
|
+
const onAbort = () => { sock.destroy(); fail(abortError(signal, "provider", "proxy")) }
|
|
88
83
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
89
84
|
|
|
90
85
|
function cleanup() {
|
|
@@ -101,7 +96,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
101
96
|
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
102
97
|
function armIdle() {
|
|
103
98
|
clearTimeout(idleTimer)
|
|
104
|
-
if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(
|
|
99
|
+
if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
|
|
105
100
|
}
|
|
106
101
|
|
|
107
102
|
sock.on("data", (d) => {
|
|
@@ -190,11 +185,11 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
|
190
185
|
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
191
186
|
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
192
187
|
|
|
193
|
-
if (signal?.aborted) return reject(abortError(signal))
|
|
188
|
+
if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
|
|
194
189
|
|
|
195
190
|
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
196
191
|
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
197
|
-
const onAbort = () => { sock.destroy(); reject(abortError(signal)) }
|
|
192
|
+
const onAbort = () => { sock.destroy(); reject(abortError(signal, "provider", "proxy")) }
|
|
198
193
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
199
194
|
sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
|
|
200
195
|
|
|
@@ -229,14 +224,14 @@ function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
|
229
224
|
return new Promise((resolve, reject) => {
|
|
230
225
|
const proxy = new URL(proxyUri)
|
|
231
226
|
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
232
|
-
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
|
|
227
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
|
|
233
228
|
const onAbort = () => sock.destroy()
|
|
234
229
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
235
230
|
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
236
231
|
const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
|
|
237
232
|
const onClose = () => {
|
|
238
233
|
clearTimeout(timer)
|
|
239
|
-
reject(signal?.aborted ? abortError(signal) : new Error("Proxy connection closed before tunnel established"))
|
|
234
|
+
reject(signal?.aborted ? abortError(signal, "provider", "proxy") : new Error("Proxy connection closed before tunnel established"))
|
|
240
235
|
}
|
|
241
236
|
sock.once("connect", () => {
|
|
242
237
|
clearTimeout(timer)
|
package/src/session-gc.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* import,启动钩子(session.mjs resumeSlot 包装)只一行调用。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"
|
|
22
|
+
import { readdirSync, readFileSync, statSync, unlinkSync, existsSync, rmSync } from "node:fs"
|
|
23
23
|
import { join, dirname, basename } from "node:path"
|
|
24
24
|
import { configDir } from "./config.mjs"
|
|
25
25
|
import { sessionPath, isProcessAlive } from "./session-slots.mjs"
|
|
@@ -153,7 +153,14 @@ export function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), ali
|
|
|
153
153
|
if (!target) return { ok: false, reason: "not-cold", deleted: [] }
|
|
154
154
|
const deleted = []
|
|
155
155
|
for (const name of target.files) {
|
|
156
|
-
|
|
156
|
+
const p = join(dir, name)
|
|
157
|
+
try {
|
|
158
|
+
// §14.3.8(TUI-OOM-ROOTCAUSE):目录项(记录存储 sidecar `{prefix}.N.d`)递归删——
|
|
159
|
+
// 现 unlinkSync 对目录静默跳过(旧实现漏删 sidecar);数据文件维持 unlink。
|
|
160
|
+
if (statSync(p).isDirectory()) rmSync(p, { recursive: true, force: true })
|
|
161
|
+
else unlinkSync(p)
|
|
162
|
+
deleted.push(name)
|
|
163
|
+
} catch { /* 占用/竞态——跳过 */ }
|
|
157
164
|
}
|
|
158
165
|
return { ok: true, deleted }
|
|
159
166
|
}
|
package/src/session-guard.mjs
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
|
|
13
13
|
import { basename } from "node:path"
|
|
14
|
+
// §14.3.8①(修正轮 #1):.bak 轮转对**非本会话活动绑定面**联动改名 sidecar(`{p}.d → {bak}.d`)。
|
|
15
|
+
import { RECORD_DIR_SUFFIX } from "./session-store.mjs"
|
|
16
|
+
|
|
17
|
+
/** 轮转时随迁记录存储(.bak 路径联动)——本会话活动绑定面跳过(防拔掉活动存储)。 */
|
|
18
|
+
function moveSidecar(src, dst, agent) {
|
|
19
|
+
const dir = src + RECORD_DIR_SUFFIX
|
|
20
|
+
if (agent?._recordStore?.dir === dir) return // 本会话活动绑定面——原地保留(§14.3.8①)
|
|
21
|
+
try {
|
|
22
|
+
if (existsSync(dir)) renameSync(dir, dst + RECORD_DIR_SUFFIX)
|
|
23
|
+
} catch { /* 随迁失败不阻断轮转(现场原地保留——读侧身份核验兜底) */ }
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export function guardForeignSlotFile(agent, p, slot) {
|
|
16
27
|
try {
|
|
@@ -29,6 +40,7 @@ export function guardForeignSlotFile(agent, p, slot) {
|
|
|
29
40
|
if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
|
|
30
41
|
const bak = `${p}.bak-${Date.now()}`
|
|
31
42
|
renameSync(p, bak)
|
|
43
|
+
moveSidecar(p, bak, agent)
|
|
32
44
|
console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
|
|
33
45
|
agent._slotMtime = st.mtimeMs
|
|
34
46
|
return bak
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-segments.mjs — 记录存储的段文件原语 + 人读线条目形态(TUI-OOM-ROOTCAUSE 批)。
|
|
3
|
+
*
|
|
4
|
+
* 机制契约全文 = docs/design/SESSION.md §14.3(D2 单一权威源);本文件 = 「段 IO + 条目
|
|
5
|
+
* 形态」叶子层(`session-store.mjs` 拆分产物——503+ 行越 500 硬限,§14.5 表列模块按职责
|
|
6
|
+
* 拆分;store 继续 re-export 全部公开名,调用面零改)。零项目内依赖(仅 `node:`)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
10
|
+
|
|
11
|
+
/** 常量单源(§14.3.2):每段消息数 / sidecar 目录后缀。 */
|
|
12
|
+
export const RECORD_SEG_MESSAGES = 100
|
|
13
|
+
export const RECORD_DIR_SUFFIX = ".d"
|
|
14
|
+
|
|
15
|
+
/** 观测计数(测试缝——AC-RS9「恢复只读末段」以段读次数断言)。 */
|
|
16
|
+
export const _storeStats = { segmentReads: 0, segmentWrites: 0, appendedEntries: 0, catchUpRetries: 0 }
|
|
17
|
+
|
|
18
|
+
const LEGACY_TRANSIENT_PREFIXES = [
|
|
19
|
+
"[System reminder: working directory snapshot:",
|
|
20
|
+
"[Relevant memories from previous sessions",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** legacy transient 判定(旧进程写进人读线的机器注入——落盘/展示均跳过)。 */
|
|
24
|
+
export function isLegacyTransient(m) {
|
|
25
|
+
return (
|
|
26
|
+
m?.role === "user" &&
|
|
27
|
+
typeof m.content === "string" &&
|
|
28
|
+
LEGACY_TRANSIENT_PREFIXES.some((p) => m.content.startsWith(p))
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Slim the HUMAN line for storage(自 session.mjs 迁出——§14.5):机器线(contextHistory)
|
|
33
|
+
* 逐字节保持。Copy-on-write ONLY(两线经 pushReal 共享对象引用):tool args → 300 字符头
|
|
34
|
+
* 截断 / tool content → 500 / 多模态 user → 保留 text、丢 image_url。 */
|
|
35
|
+
export function slimForDisplay(m) {
|
|
36
|
+
if (m && Array.isArray(m.content)) {
|
|
37
|
+
const textParts = m.content.filter((p) => p?.type !== "image_url")
|
|
38
|
+
if (textParts.length === m.content.length) return m
|
|
39
|
+
return { ...m, content: textParts }
|
|
40
|
+
}
|
|
41
|
+
if (m && m.role === "assistant" && Array.isArray(m.tool_calls)) {
|
|
42
|
+
let changed = false
|
|
43
|
+
const tool_calls = m.tool_calls.map((tc) => {
|
|
44
|
+
const args = tc.function?.arguments
|
|
45
|
+
if (typeof args === "string" && args.length > 300) {
|
|
46
|
+
changed = true
|
|
47
|
+
return { ...tc, function: { ...tc.function, arguments: args.slice(0, 300) + "…" } }
|
|
48
|
+
}
|
|
49
|
+
return tc
|
|
50
|
+
})
|
|
51
|
+
return changed ? { ...m, tool_calls } : m
|
|
52
|
+
}
|
|
53
|
+
if (m && m.role === "tool" && typeof m.content === "string" && m.content.length > 500) {
|
|
54
|
+
return { ...m, content: m.content.slice(0, 500) + "\n… (truncated for storage)" }
|
|
55
|
+
}
|
|
56
|
+
return m
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 记录条目过滤(与 saveSession 同源):transient 机器注入与 legacy-transient 前缀不进记录。 */
|
|
60
|
+
export function shouldAppend(m) {
|
|
61
|
+
return !!m && typeof m === "object" && !m.transient && !isLegacyTransient(m)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 真实 user 消息(turnCount/firstMessage 口径——同 session-slots.extractSlotMeta)。 */
|
|
65
|
+
export function isRealUserMsg(m) {
|
|
66
|
+
return m?.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** sidecar 目录路径(`{slot 文件路径}.d`——同目录同前缀)。 */
|
|
70
|
+
export function recordDirOf(slotFile) { return slotFile + RECORD_DIR_SUFFIX }
|
|
71
|
+
|
|
72
|
+
/** 段文件名(六位零填充)。 */
|
|
73
|
+
export function segName(n) { return `seg-${String(n).padStart(6, "0")}.jsonl` }
|
|
74
|
+
|
|
75
|
+
/** 段号升序表(readdir——不读内容)。 */
|
|
76
|
+
export function listSegments(dir) {
|
|
77
|
+
let names
|
|
78
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
79
|
+
const out = []
|
|
80
|
+
for (const n of names) {
|
|
81
|
+
const m = /^seg-(\d+)\.jsonl$/.exec(n)
|
|
82
|
+
if (m) out.push(Number(m[1]))
|
|
83
|
+
}
|
|
84
|
+
return out.sort((a, b) => a - b)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 读段(原始行——去尾空行);读失败按空段容忍(读路径尽力面)。 */
|
|
88
|
+
export function readSegmentLines(path) {
|
|
89
|
+
_storeStats.segmentReads++
|
|
90
|
+
let text
|
|
91
|
+
try { text = readFileSync(path, "utf8") } catch { return [] }
|
|
92
|
+
const lines = text.split("\n")
|
|
93
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
|
94
|
+
return lines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 逐行容错解析:半行/损坏行 → undefined(调用方跳过——不抛、不自动修复)。 */
|
|
98
|
+
export function tryParse(line) {
|
|
99
|
+
try { return JSON.parse(line) } catch { return undefined }
|
|
100
|
+
}
|
package/src/session-slots.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { migrateHashLength } from "./session-migrate.mjs"
|
|
|
18
18
|
// legacy 过滤)属 session.mjs(500 行内不迁移)——静态环仅此一处:函数声明实例化期已初始化、
|
|
19
19
|
// 只函数体内运行时使用(环安全);VS Code 端 session-slots ↔ session-io 同构镜像。
|
|
20
20
|
import { loadSlotFile, isLegacyTransient } from "./session.mjs"
|
|
21
|
+
// TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.8):删槽联动记录存储(store 零项目内依赖——无环)。
|
|
22
|
+
import { unlinkRecordStore } from "./session-store.mjs"
|
|
21
23
|
|
|
22
24
|
let currentSessionId = null
|
|
23
25
|
|
|
@@ -135,9 +137,11 @@ function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
/** Extract preview summary from session data for manifest storage (with current timestamp)
|
|
140
|
+
/** Extract preview summary from session data for manifest storage (with current timestamp)
|
|
141
|
+
* MODEL-MERGE-SESSION:摘要升级带 activeModel——listSlots 合成显 "p:m"(双端同规则)。 */
|
|
139
142
|
export function slotDigest(data) {
|
|
140
143
|
const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
|
|
144
|
+
if (data.activeModel) meta.activeModel = data.activeModel
|
|
141
145
|
return { ts: Date.now(), ...meta }
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -356,6 +360,7 @@ function loadSlotMeta(cwd, slot, v) {
|
|
|
356
360
|
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
357
361
|
const history = data.history ?? []
|
|
358
362
|
const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
|
|
363
|
+
if (data.activeModel) meta.activeModel = data.activeModel
|
|
359
364
|
return { ts, ...meta }
|
|
360
365
|
} catch {
|
|
361
366
|
return { ts }
|
|
@@ -381,7 +386,9 @@ export function listSlots(cwd) {
|
|
|
381
386
|
messageCount: meta.messageCount ?? 0,
|
|
382
387
|
turnCount: meta.turnCount ?? 0,
|
|
383
388
|
firstMessage: meta.firstMessage ?? "",
|
|
384
|
-
|
|
389
|
+
// MODEL-MERGE-SESSION 摘要 "p:m":activeProvider 保持裸渠道名——列表消费面显复合
|
|
390
|
+
// (旧摘要无 activeModel → 回退裸渠道名——新老兼容;cmd-session/VSC sessions 行免改)
|
|
391
|
+
activeProvider: meta.activeProvider ? (meta.activeModel ? `${meta.activeProvider}:${meta.activeModel}` : meta.activeProvider) : "",
|
|
385
392
|
updatedAt: meta.updatedAt ?? meta.ts,
|
|
386
393
|
updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
|
|
387
394
|
title: meta.title ?? "",
|
|
@@ -400,6 +407,7 @@ export function deleteSlot(cwd, slot) {
|
|
|
400
407
|
delete m.slots[n]
|
|
401
408
|
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
402
409
|
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
410
|
+
unlinkRecordStore(slotPath(cwd, n)) // §14.3.8:删槽连带删除记录存储(sidecar)
|
|
403
411
|
if (m.active === n) delete m.active
|
|
404
412
|
// setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
|
|
405
413
|
// 保留 fresh.active,2026-09-01 会诊三家 🟡)
|