compressed-context 0.1.0 → 0.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.
Files changed (57) hide show
  1. package/README.md +59 -51
  2. package/package.json +64 -64
  3. package/src/compression/active-summary-cache.ts +50 -50
  4. package/src/compression/adopt.ts +57 -57
  5. package/src/compression/auto.ts +386 -372
  6. package/src/compression/babel.ts +85 -85
  7. package/src/compression/block.ts +25 -25
  8. package/src/compression/cache-history.ts +75 -75
  9. package/src/compression/compaction.ts +189 -189
  10. package/src/compression/decompress-tool.ts +74 -74
  11. package/src/compression/decompress.ts +292 -292
  12. package/src/compression/deleted-sessions.ts +24 -24
  13. package/src/compression/distill.ts +220 -220
  14. package/src/compression/events.ts +275 -275
  15. package/src/compression/markdown.ts +53 -53
  16. package/src/compression/normalize.ts +114 -114
  17. package/src/compression/render.ts +70 -70
  18. package/src/compression/schema.ts +131 -84
  19. package/src/compression/select.ts +123 -123
  20. package/src/compression/session-message.ts +112 -112
  21. package/src/compression/settings-rpc.ts +123 -111
  22. package/src/compression/settings.ts +175 -175
  23. package/src/compression/shape.ts +75 -0
  24. package/src/compression/state.ts +248 -248
  25. package/src/compression/summarize.ts +534 -595
  26. package/src/compression/summary.ts +73 -73
  27. package/src/compression/tool.ts +184 -142
  28. package/src/compression/transcript-store.ts +258 -258
  29. package/src/compression/transcript.ts +63 -63
  30. package/src/compression/validate.ts +81 -81
  31. package/src/i18n/en.ts +93 -93
  32. package/src/i18n/index.ts +28 -28
  33. package/src/i18n/types.ts +89 -89
  34. package/src/i18n/zh.ts +93 -93
  35. package/src/index.ts +116 -108
  36. package/src/shared/blocks.ts +17 -17
  37. package/src/shared/cache.ts +199 -199
  38. package/src/shared/format.ts +23 -23
  39. package/src/shared/media.ts +184 -184
  40. package/src/shared/parse.ts +25 -25
  41. package/src/shared/parts.ts +72 -72
  42. package/src/shared/rpc.ts +145 -145
  43. package/src/shared/scan.ts +32 -32
  44. package/src/shared/settings.ts +466 -471
  45. package/src/shared/stats.ts +142 -142
  46. package/src/shared/tokens.ts +259 -259
  47. package/src/tui/chart/cache-chart.ts +237 -237
  48. package/src/tui/chart/color.ts +58 -58
  49. package/src/tui/index.tsx +109 -111
  50. package/src/tui/panel/cache.tsx +203 -203
  51. package/src/tui/panel/data.ts +105 -105
  52. package/src/tui/panel/index.tsx +176 -176
  53. package/src/tui/panel/summaries.tsx +203 -192
  54. package/src/tui/settings/dialogs.tsx +215 -215
  55. package/src/tui/settings/options.ts +238 -238
  56. package/src/tui/settings.tsx +418 -418
  57. package/src/tui/types.ts +4 -4
@@ -1,292 +1,292 @@
1
- /**
2
- * 解压:把已压缩块的内容还给模型。
3
- *
4
- * - `full:false`(默认)展开一层:合并块返回直接子块的摘要;T1 块返回磁盘原文。
5
- * - `full:true` 递归到 T1,原文从插件自己的 transcript storage 读取。
6
- * - 媒体 bytes/base64 写临时文件;url/ref/缺失内容输出占位符。
7
- * - 返回分级:≤ INLINE_MAX 字符内联进 tool result;超出边读边写 `os.tmpdir()` 文件并回路径,
8
- * 模型用 read 工具打开。临时文件有 reaper(上限 TEMP_CAP,删最老),防无限堆积。
9
- * - copy-paste 语义:不修改任何块状态;块保持折叠,内容只是 tool result 文本。
10
- */
11
-
12
- import { appendFileSync, unlinkSync, writeFileSync } from "node:fs"
13
- import { tmpdir } from "node:os"
14
- import { join } from "node:path"
15
-
16
- import { countText } from "../shared/tokens.ts"
17
- import type { Block, StorageLike } from "./state.ts"
18
- import { loadState } from "./state.ts"
19
- import { renderBlock } from "./render.ts"
20
- import {
21
- loadTranscriptManifest,
22
- loadTranscriptMessage,
23
- type StoredMessage,
24
- type StoredPart,
25
- } from "./transcript-store.ts"
26
-
27
- /** 内联返回的默认字符上限;运行时由设置覆盖。 */
28
- export const INLINE_MAX = 10_000
29
- /** 临时文件数量默认上限;运行时由设置覆盖。 */
30
- export const TEMP_CAP = 50
31
-
32
- /** decompress 的运行时限制。 */
33
- export interface DecompressLimits {
34
- readonly inlineMax: number
35
- readonly tempCap: number
36
- readonly showTokens: boolean
37
- }
38
-
39
- const DEFAULT_LIMITS: DecompressLimits = {
40
- inlineMax: INLINE_MAX,
41
- tempCap: TEMP_CAP,
42
- showTokens: true,
43
- }
44
-
45
- /** 解压工具结果;ok 时 inline 或 path 至少一项存在。 */
46
- export type DecompressResult =
47
- | { readonly status: "ok"; readonly inline: string; readonly path?: string; readonly tokens?: number }
48
- | { readonly status: "error"; readonly message: string }
49
-
50
- const tempFiles: { path: string; mtimeMs: number; sessionID: string }[] = []
51
-
52
- /** 输入:上限;输出:清理过期临时文件。预期:超上限删最老,绝不抛错。 */
53
- function reapTempFiles(cap: number): void {
54
- while (tempFiles.length > cap) {
55
- tempFiles.sort((a, b) => a.mtimeMs - b.mtimeMs)
56
- const oldest = tempFiles.shift()
57
- if (oldest === undefined) break
58
- try {
59
- unlinkSync(oldest.path)
60
- } catch {
61
- // 已被外部清理或权限问题:跳过,不阻塞解压
62
- }
63
- }
64
- }
65
-
66
- /** 输入:会话 + 块 id + 正文 + 上限;输出:临时文件路径。 */
67
- function writeTempFile(sessionID: string, blockId: string, body: string, cap: number): string {
68
- const safeId = blockId.replace(/[^a-zA-Z0-9_-]/g, "-")
69
- const path = join(tmpdir(), `compressed-context-decompress-${safeId}-${String(Date.now())}.txt`)
70
- writeFileSync(path, body, { encoding: "utf8", mode: 0o600 })
71
- tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
72
- reapTempFiles(cap)
73
- return path
74
- }
75
-
76
- /** 输入:会话;输出:无;预期:删除该会话全部解压临时文件。 */
77
- export function deleteSessionTempFiles(sessionID: string): void {
78
- for (let index = tempFiles.length - 1; index >= 0; index -= 1) {
79
- const entry = tempFiles[index]
80
- if (entry?.sessionID !== sessionID) continue
81
- try {
82
- unlinkSync(entry.path)
83
- } catch {
84
- // best effort
85
- }
86
- tempFiles.splice(index, 1)
87
- }
88
- }
89
-
90
- /** 输入:会话 + 块 + 序号 + base64 + mime + 文件名;输出:媒体临时文件路径。 */
91
- function writeMediaTempFile(
92
- sessionID: string,
93
- blockID: string,
94
- index: number,
95
- data: string,
96
- filename: string | undefined,
97
- cap: number,
98
- ): string {
99
- const safeId = `${blockID}-media-${index.toString()}`.replace(/[^a-zA-Z0-9_-]/g, "-")
100
- const safeName = (filename ?? "").replace(/[^a-zA-Z0-9._-]/g, "-")
101
- const suffix = safeName.length > 0 ? `-${safeName}` : ""
102
- const path = join(tmpdir(), `compressed-context-${safeId}${suffix}-${String(Date.now())}.bin`)
103
- writeFileSync(path, Buffer.from(data, "base64"), { mode: 0o600 })
104
- tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
105
- reapTempFiles(cap)
106
- return path
107
- }
108
-
109
- /** 输入:任意值;输出:可读 JSON 文本;不可序列化时返回空串。 */
110
- function stringifyStored(value: unknown): string {
111
- if (typeof value === "string") return value
112
- if (value === undefined) return ""
113
- try {
114
- const encoded: unknown = JSON.stringify(value)
115
- return typeof encoded === "string" ? encoded : ""
116
- } catch {
117
- return ""
118
- }
119
- }
120
-
121
- /** 输入:落盘 part + 上下文;输出:可读文本;不可还原的媒体输出占位符。 */
122
- function renderStoredPart(
123
- part: StoredPart,
124
- sessionID: string,
125
- blockID: string,
126
- index: number,
127
- cap: number,
128
- ): string {
129
- if (part.type === "text") return part.text ?? ""
130
- if (part.type === "tool-call") {
131
- return `[Tool call ${part.name ?? "?"}] ${stringifyStored(part.input)}`
132
- }
133
- if (part.type === "tool-result") {
134
- return `[Tool result ${part.name ?? "?"}] ${stringifyStored(part.result)}`
135
- }
136
- const source = part.media?.source
137
- const filename = part.filename === undefined ? "" : ` "${part.filename}"`
138
- const mime = source?.mediaType ?? "unknown"
139
- if (source?.type === "base64" && source.data !== undefined && source.data.length > 0) {
140
- const path = writeMediaTempFile(sessionID, blockID, index, source.data, part.filename, cap)
141
- return `[media ${mime}${filename} saved to: ${path}]`
142
- }
143
- const kind = source?.type ?? "unknown"
144
- return `[unavailable media: ${mime}${filename} (${kind} not stored)]`
145
- }
146
-
147
- /** 输入:落盘消息 + 上下文;输出:带角色前缀的文本。 */
148
- function renderStoredMessage(
149
- message: StoredMessage,
150
- sessionID: string,
151
- blockID: string,
152
- messageIndex: number,
153
- cap: number,
154
- ): string {
155
- const lines: string[] = []
156
- for (const [partIndex, part] of message.content.entries()) {
157
- const text = renderStoredPart(
158
- part,
159
- sessionID,
160
- blockID,
161
- messageIndex * 1000 + partIndex,
162
- cap,
163
- )
164
- if (text.length === 0) continue
165
- lines.push(message.role === "user" ? `[User]\n${text}` : text)
166
- }
167
- return lines.join("\n")
168
- }
169
-
170
- /**
171
- * 输入:storage + 会话 + 块 + 写入口。
172
- * 输出:无。
173
- * 预期:逐条读取并立即追加,缺 manifest/消息时写占位符,不把整段原文拼成字符串。
174
- */
175
- async function appendStoredTranscript(
176
- storage: StorageLike,
177
- sessionID: string,
178
- block: Block,
179
- cap: number,
180
- append: (text: string) => void,
181
- ): Promise<void> {
182
- const manifest = await loadTranscriptManifest(storage, sessionID, block.id)
183
- if (manifest === undefined) {
184
- append(`[original transcript for ${block.id} is unavailable]`)
185
- return
186
- }
187
- for (let index = 0; index < manifest.count; index += 1) {
188
- const message = await loadTranscriptMessage(storage, sessionID, block.id, index)
189
- if (message === undefined) {
190
- append(`[missing transcript message ${index.toString()} for ${block.id}]`)
191
- continue
192
- }
193
- const text = renderStoredMessage(message, sessionID, block.id, index, cap)
194
- if (text.length > 0) append(text)
195
- }
196
- }
197
-
198
- /**
199
- * 输入:工具参数(block 必填,full 可选)+ 会话上下文。
200
- * 输出:分级结果(内联文本或临时文件路径)。
201
- * 预期:从磁盘 transcript 按需读取;缺原文/媒体时输出占位符,不报错。
202
- * 超过内联上限时边读边写临时文件,避免把全文常驻内存。
203
- */
204
- export async function resolveDecompress(
205
- args: Record<string, unknown>,
206
- ctx: { readonly storage: StorageLike },
207
- sessionID: string,
208
- limits: DecompressLimits = DEFAULT_LIMITS,
209
- ): Promise<DecompressResult> {
210
- const raw = args["block"]
211
- if (typeof raw !== "string" || raw.trim().length === 0) {
212
- return { status: "error", message: "block is required (e.g. b3)" }
213
- }
214
- const blockId = raw.trim()
215
- const full = args["full"] === true
216
-
217
- const state = await loadState(ctx.storage, sessionID)
218
- if (state.blocks.length === 0) {
219
- return { status: "error", message: `block ${blockId} not found (session has no compressed blocks)` }
220
- }
221
-
222
- const byId = new Map(state.blocks.map((block) => [block.id, block]))
223
- const target = byId.get(blockId)
224
- if (target === undefined) {
225
- return { status: "error", message: `block ${blockId} not found` }
226
- }
227
-
228
- const chunks: string[] = []
229
- let filePath: string | undefined
230
- let chars = 0
231
- let tokens = 0
232
- let first = true
233
-
234
- /** 追加一段文本;超过内联上限后转为临时文件,避免全文常驻内存。 */
235
- const append = (text: string): void => {
236
- if (text.length === 0) return
237
- const separator = first ? "" : "\n\n"
238
- first = false
239
- const payload = separator + text
240
- chars += payload.length
241
- tokens += countText(payload)
242
- if (filePath !== undefined) {
243
- appendFileSync(filePath, payload)
244
- return
245
- }
246
- chunks.push(payload)
247
- if (chars > limits.inlineMax) {
248
- filePath = writeTempFile(sessionID, blockId, chunks.join(""), limits.tempCap)
249
- chunks.length = 0
250
- }
251
- }
252
-
253
- const visited = new Set<string>()
254
- const walk = async (block: Block, depth: number): Promise<void> => {
255
- if (visited.has(block.id)) return
256
- visited.add(block.id)
257
- const sources = block.sourceBlockIds ?? []
258
- if (sources.length === 0) {
259
- await appendStoredTranscript(ctx.storage, sessionID, block, limits.tempCap, append)
260
- return
261
- }
262
- if (!full && depth > 0) {
263
- append(renderBlock(block))
264
- return
265
- }
266
- for (const sourceId of sources) {
267
- const source = byId.get(sourceId)
268
- if (source === undefined) continue
269
- if (!full) {
270
- append(renderBlock(source))
271
- continue
272
- }
273
- await walk(source, depth + 1)
274
- }
275
- }
276
-
277
- await walk(target, 0)
278
- if (chars === 0) append("(no restorable content for this block)")
279
-
280
- const tokenLabel = limits.showTokens ? ` · ≈${tokens.toString()} tok` : ""
281
- const header = `[Block ${blockId} content${full ? ", full" : ""}${tokenLabel}]`
282
-
283
- if (filePath !== undefined) {
284
- return {
285
- status: "ok",
286
- inline: `${header}\nContent (${chars.toString()} chars) written to: ${filePath}\nUse the read tool to access it.`,
287
- path: filePath,
288
- tokens,
289
- }
290
- }
291
- return { status: "ok", inline: `${header}\n${chunks.join("")}`, tokens }
292
- }
1
+ /**
2
+ * 解压:把已压缩块的内容还给模型。
3
+ *
4
+ * - `full:false`(默认)展开一层:合并块返回直接子块的摘要;T1 块返回磁盘原文。
5
+ * - `full:true` 递归到 T1,原文从插件自己的 transcript storage 读取。
6
+ * - 媒体 bytes/base64 写临时文件;url/ref/缺失内容输出占位符。
7
+ * - 返回分级:≤ INLINE_MAX 字符内联进 tool result;超出边读边写 `os.tmpdir()` 文件并回路径,
8
+ * 模型用 read 工具打开。临时文件有 reaper(上限 TEMP_CAP,删最老),防无限堆积。
9
+ * - copy-paste 语义:不修改任何块状态;块保持折叠,内容只是 tool result 文本。
10
+ */
11
+
12
+ import { appendFileSync, unlinkSync, writeFileSync } from "node:fs"
13
+ import { tmpdir } from "node:os"
14
+ import { join } from "node:path"
15
+
16
+ import { countText } from "../shared/tokens.ts"
17
+ import type { Block, StorageLike } from "./state.ts"
18
+ import { loadState } from "./state.ts"
19
+ import { renderBlock } from "./render.ts"
20
+ import {
21
+ loadTranscriptManifest,
22
+ loadTranscriptMessage,
23
+ type StoredMessage,
24
+ type StoredPart,
25
+ } from "./transcript-store.ts"
26
+
27
+ /** 内联返回的默认字符上限;运行时由设置覆盖。 */
28
+ export const INLINE_MAX = 10_000
29
+ /** 临时文件数量默认上限;运行时由设置覆盖。 */
30
+ export const TEMP_CAP = 50
31
+
32
+ /** decompress 的运行时限制。 */
33
+ export interface DecompressLimits {
34
+ readonly inlineMax: number
35
+ readonly tempCap: number
36
+ readonly showTokens: boolean
37
+ }
38
+
39
+ const DEFAULT_LIMITS: DecompressLimits = {
40
+ inlineMax: INLINE_MAX,
41
+ tempCap: TEMP_CAP,
42
+ showTokens: true,
43
+ }
44
+
45
+ /** 解压工具结果;ok 时 inline 或 path 至少一项存在。 */
46
+ export type DecompressResult =
47
+ | { readonly status: "ok"; readonly inline: string; readonly path?: string; readonly tokens?: number }
48
+ | { readonly status: "error"; readonly message: string }
49
+
50
+ const tempFiles: { path: string; mtimeMs: number; sessionID: string }[] = []
51
+
52
+ /** 输入:上限;输出:清理过期临时文件。预期:超上限删最老,绝不抛错。 */
53
+ function reapTempFiles(cap: number): void {
54
+ while (tempFiles.length > cap) {
55
+ tempFiles.sort((a, b) => a.mtimeMs - b.mtimeMs)
56
+ const oldest = tempFiles.shift()
57
+ if (oldest === undefined) break
58
+ try {
59
+ unlinkSync(oldest.path)
60
+ } catch {
61
+ // 已被外部清理或权限问题:跳过,不阻塞解压
62
+ }
63
+ }
64
+ }
65
+
66
+ /** 输入:会话 + 块 id + 正文 + 上限;输出:临时文件路径。 */
67
+ function writeTempFile(sessionID: string, blockId: string, body: string, cap: number): string {
68
+ const safeId = blockId.replace(/[^a-zA-Z0-9_-]/g, "-")
69
+ const path = join(tmpdir(), `compressed-context-decompress-${safeId}-${String(Date.now())}.txt`)
70
+ writeFileSync(path, body, { encoding: "utf8", mode: 0o600 })
71
+ tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
72
+ reapTempFiles(cap)
73
+ return path
74
+ }
75
+
76
+ /** 输入:会话;输出:无;预期:删除该会话全部解压临时文件。 */
77
+ export function deleteSessionTempFiles(sessionID: string): void {
78
+ for (let index = tempFiles.length - 1; index >= 0; index -= 1) {
79
+ const entry = tempFiles[index]
80
+ if (entry?.sessionID !== sessionID) continue
81
+ try {
82
+ unlinkSync(entry.path)
83
+ } catch {
84
+ // best effort
85
+ }
86
+ tempFiles.splice(index, 1)
87
+ }
88
+ }
89
+
90
+ /** 输入:会话 + 块 + 序号 + base64 + mime + 文件名;输出:媒体临时文件路径。 */
91
+ function writeMediaTempFile(
92
+ sessionID: string,
93
+ blockID: string,
94
+ index: number,
95
+ data: string,
96
+ filename: string | undefined,
97
+ cap: number,
98
+ ): string {
99
+ const safeId = `${blockID}-media-${index.toString()}`.replace(/[^a-zA-Z0-9_-]/g, "-")
100
+ const safeName = (filename ?? "").replace(/[^a-zA-Z0-9._-]/g, "-")
101
+ const suffix = safeName.length > 0 ? `-${safeName}` : ""
102
+ const path = join(tmpdir(), `compressed-context-${safeId}${suffix}-${String(Date.now())}.bin`)
103
+ writeFileSync(path, Buffer.from(data, "base64"), { mode: 0o600 })
104
+ tempFiles.push({ path, mtimeMs: Date.now(), sessionID })
105
+ reapTempFiles(cap)
106
+ return path
107
+ }
108
+
109
+ /** 输入:任意值;输出:可读 JSON 文本;不可序列化时返回空串。 */
110
+ function stringifyStored(value: unknown): string {
111
+ if (typeof value === "string") return value
112
+ if (value === undefined) return ""
113
+ try {
114
+ const encoded: unknown = JSON.stringify(value)
115
+ return typeof encoded === "string" ? encoded : ""
116
+ } catch {
117
+ return ""
118
+ }
119
+ }
120
+
121
+ /** 输入:落盘 part + 上下文;输出:可读文本;不可还原的媒体输出占位符。 */
122
+ function renderStoredPart(
123
+ part: StoredPart,
124
+ sessionID: string,
125
+ blockID: string,
126
+ index: number,
127
+ cap: number,
128
+ ): string {
129
+ if (part.type === "text") return part.text ?? ""
130
+ if (part.type === "tool-call") {
131
+ return `[Tool call ${part.name ?? "?"}] ${stringifyStored(part.input)}`
132
+ }
133
+ if (part.type === "tool-result") {
134
+ return `[Tool result ${part.name ?? "?"}] ${stringifyStored(part.result)}`
135
+ }
136
+ const source = part.media?.source
137
+ const filename = part.filename === undefined ? "" : ` "${part.filename}"`
138
+ const mime = source?.mediaType ?? "unknown"
139
+ if (source?.type === "base64" && source.data !== undefined && source.data.length > 0) {
140
+ const path = writeMediaTempFile(sessionID, blockID, index, source.data, part.filename, cap)
141
+ return `[media ${mime}${filename} saved to: ${path}]`
142
+ }
143
+ const kind = source?.type ?? "unknown"
144
+ return `[unavailable media: ${mime}${filename} (${kind} not stored)]`
145
+ }
146
+
147
+ /** 输入:落盘消息 + 上下文;输出:带角色前缀的文本。 */
148
+ function renderStoredMessage(
149
+ message: StoredMessage,
150
+ sessionID: string,
151
+ blockID: string,
152
+ messageIndex: number,
153
+ cap: number,
154
+ ): string {
155
+ const lines: string[] = []
156
+ for (const [partIndex, part] of message.content.entries()) {
157
+ const text = renderStoredPart(
158
+ part,
159
+ sessionID,
160
+ blockID,
161
+ messageIndex * 1000 + partIndex,
162
+ cap,
163
+ )
164
+ if (text.length === 0) continue
165
+ lines.push(message.role === "user" ? `[User]\n${text}` : text)
166
+ }
167
+ return lines.join("\n")
168
+ }
169
+
170
+ /**
171
+ * 输入:storage + 会话 + 块 + 写入口。
172
+ * 输出:无。
173
+ * 预期:逐条读取并立即追加,缺 manifest/消息时写占位符,不把整段原文拼成字符串。
174
+ */
175
+ async function appendStoredTranscript(
176
+ storage: StorageLike,
177
+ sessionID: string,
178
+ block: Block,
179
+ cap: number,
180
+ append: (text: string) => void,
181
+ ): Promise<void> {
182
+ const manifest = await loadTranscriptManifest(storage, sessionID, block.id)
183
+ if (manifest === undefined) {
184
+ append(`[original transcript for ${block.id} is unavailable]`)
185
+ return
186
+ }
187
+ for (let index = 0; index < manifest.count; index += 1) {
188
+ const message = await loadTranscriptMessage(storage, sessionID, block.id, index)
189
+ if (message === undefined) {
190
+ append(`[missing transcript message ${index.toString()} for ${block.id}]`)
191
+ continue
192
+ }
193
+ const text = renderStoredMessage(message, sessionID, block.id, index, cap)
194
+ if (text.length > 0) append(text)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 输入:工具参数(block 必填,full 可选)+ 会话上下文。
200
+ * 输出:分级结果(内联文本或临时文件路径)。
201
+ * 预期:从磁盘 transcript 按需读取;缺原文/媒体时输出占位符,不报错。
202
+ * 超过内联上限时边读边写临时文件,避免把全文常驻内存。
203
+ */
204
+ export async function resolveDecompress(
205
+ args: Record<string, unknown>,
206
+ ctx: { readonly storage: StorageLike },
207
+ sessionID: string,
208
+ limits: DecompressLimits = DEFAULT_LIMITS,
209
+ ): Promise<DecompressResult> {
210
+ const raw = args["block"]
211
+ if (typeof raw !== "string" || raw.trim().length === 0) {
212
+ return { status: "error", message: "block is required (e.g. b3)" }
213
+ }
214
+ const blockId = raw.trim()
215
+ const full = args["full"] === true
216
+
217
+ const state = await loadState(ctx.storage, sessionID)
218
+ if (state.blocks.length === 0) {
219
+ return { status: "error", message: `block ${blockId} not found (session has no compressed blocks)` }
220
+ }
221
+
222
+ const byId = new Map(state.blocks.map((block) => [block.id, block]))
223
+ const target = byId.get(blockId)
224
+ if (target === undefined) {
225
+ return { status: "error", message: `block ${blockId} not found` }
226
+ }
227
+
228
+ const chunks: string[] = []
229
+ let filePath: string | undefined
230
+ let chars = 0
231
+ let tokens = 0
232
+ let first = true
233
+
234
+ /** 追加一段文本;超过内联上限后转为临时文件,避免全文常驻内存。 */
235
+ const append = (text: string): void => {
236
+ if (text.length === 0) return
237
+ const separator = first ? "" : "\n\n"
238
+ first = false
239
+ const payload = separator + text
240
+ chars += payload.length
241
+ tokens += countText(payload)
242
+ if (filePath !== undefined) {
243
+ appendFileSync(filePath, payload)
244
+ return
245
+ }
246
+ chunks.push(payload)
247
+ if (chars > limits.inlineMax) {
248
+ filePath = writeTempFile(sessionID, blockId, chunks.join(""), limits.tempCap)
249
+ chunks.length = 0
250
+ }
251
+ }
252
+
253
+ const visited = new Set<string>()
254
+ const walk = async (block: Block, depth: number): Promise<void> => {
255
+ if (visited.has(block.id)) return
256
+ visited.add(block.id)
257
+ const sources = block.sourceBlockIds ?? []
258
+ if (sources.length === 0) {
259
+ await appendStoredTranscript(ctx.storage, sessionID, block, limits.tempCap, append)
260
+ return
261
+ }
262
+ if (!full && depth > 0) {
263
+ append(renderBlock(block))
264
+ return
265
+ }
266
+ for (const sourceId of sources) {
267
+ const source = byId.get(sourceId)
268
+ if (source === undefined) continue
269
+ if (!full) {
270
+ append(renderBlock(source))
271
+ continue
272
+ }
273
+ await walk(source, depth + 1)
274
+ }
275
+ }
276
+
277
+ await walk(target, 0)
278
+ if (chars === 0) append("(no restorable content for this block)")
279
+
280
+ const tokenLabel = limits.showTokens ? ` · ≈${tokens.toString()} tok` : ""
281
+ const header = `[Block ${blockId} content${full ? ", full" : ""}${tokenLabel}]`
282
+
283
+ if (filePath !== undefined) {
284
+ return {
285
+ status: "ok",
286
+ inline: `${header}\nContent (${chars.toString()} chars) written to: ${filePath}\nUse the read tool to access it.`,
287
+ path: filePath,
288
+ tokens,
289
+ }
290
+ }
291
+ return { status: "ok", inline: `${header}\n${chunks.join("")}`, tokens }
292
+ }
@@ -1,24 +1,24 @@
1
- /**
2
- * 已删除会话的内存墓碑。
3
- *
4
- * 输入:会话删除事件。
5
- * 输出:无。
6
- * 预期:删除后到达的 saveState / saveTranscript 直接跳过,避免把状态复活;
7
- * 进程重启后墓碑消失,由启动 GC 清理孤儿数据。
8
- */
9
- const deleted = new Set<string>()
10
-
11
- /** 输入:sessionID;输出:无;预期:标记会话已删除。 */
12
- export function markSessionDeleted(sessionID: string): void {
13
- deleted.add(sessionID)
14
- }
15
-
16
- /** 输入:sessionID;输出:是否已删除。 */
17
- export function isSessionDeleted(sessionID: string): boolean {
18
- return deleted.has(sessionID)
19
- }
20
-
21
- /** 输入:无;输出:无;预期:测试或多租户重置时清空墓碑。 */
22
- export function clearDeletedSessions(): void {
23
- deleted.clear()
24
- }
1
+ /**
2
+ * 已删除会话的内存墓碑。
3
+ *
4
+ * 输入:会话删除事件。
5
+ * 输出:无。
6
+ * 预期:删除后到达的 saveState / saveTranscript 直接跳过,避免把状态复活;
7
+ * 进程重启后墓碑消失,由启动 GC 清理孤儿数据。
8
+ */
9
+ const deleted = new Set<string>()
10
+
11
+ /** 输入:sessionID;输出:无;预期:标记会话已删除。 */
12
+ export function markSessionDeleted(sessionID: string): void {
13
+ deleted.add(sessionID)
14
+ }
15
+
16
+ /** 输入:sessionID;输出:是否已删除。 */
17
+ export function isSessionDeleted(sessionID: string): boolean {
18
+ return deleted.has(sessionID)
19
+ }
20
+
21
+ /** 输入:无;输出:无;预期:测试或多租户重置时清空墓碑。 */
22
+ export function clearDeletedSessions(): void {
23
+ deleted.clear()
24
+ }