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.
- package/README.md +59 -51
- package/package.json +64 -64
- package/src/compression/active-summary-cache.ts +50 -50
- package/src/compression/adopt.ts +57 -57
- package/src/compression/auto.ts +386 -372
- package/src/compression/babel.ts +85 -85
- package/src/compression/block.ts +25 -25
- package/src/compression/cache-history.ts +75 -75
- package/src/compression/compaction.ts +189 -189
- package/src/compression/decompress-tool.ts +74 -74
- package/src/compression/decompress.ts +292 -292
- package/src/compression/deleted-sessions.ts +24 -24
- package/src/compression/distill.ts +220 -220
- package/src/compression/events.ts +275 -275
- package/src/compression/markdown.ts +53 -53
- package/src/compression/normalize.ts +114 -114
- package/src/compression/render.ts +70 -70
- package/src/compression/schema.ts +131 -84
- package/src/compression/select.ts +123 -123
- package/src/compression/session-message.ts +112 -112
- package/src/compression/settings-rpc.ts +123 -111
- package/src/compression/settings.ts +175 -175
- package/src/compression/shape.ts +75 -0
- package/src/compression/state.ts +248 -248
- package/src/compression/summarize.ts +534 -595
- package/src/compression/summary.ts +73 -73
- package/src/compression/tool.ts +184 -142
- package/src/compression/transcript-store.ts +258 -258
- package/src/compression/transcript.ts +63 -63
- package/src/compression/validate.ts +81 -81
- package/src/i18n/en.ts +93 -93
- package/src/i18n/index.ts +28 -28
- package/src/i18n/types.ts +89 -89
- package/src/i18n/zh.ts +93 -93
- package/src/index.ts +116 -108
- package/src/shared/blocks.ts +17 -17
- package/src/shared/cache.ts +199 -199
- package/src/shared/format.ts +23 -23
- package/src/shared/media.ts +184 -184
- package/src/shared/parse.ts +25 -25
- package/src/shared/parts.ts +72 -72
- package/src/shared/rpc.ts +145 -145
- package/src/shared/scan.ts +32 -32
- package/src/shared/settings.ts +466 -471
- package/src/shared/stats.ts +142 -142
- package/src/shared/tokens.ts +259 -259
- package/src/tui/chart/cache-chart.ts +237 -237
- package/src/tui/chart/color.ts +58 -58
- package/src/tui/index.tsx +109 -111
- package/src/tui/panel/cache.tsx +203 -203
- package/src/tui/panel/data.ts +105 -105
- package/src/tui/panel/index.tsx +176 -176
- package/src/tui/panel/summaries.tsx +203 -192
- package/src/tui/settings/dialogs.tsx +215 -215
- package/src/tui/settings/options.ts +238 -238
- package/src/tui/settings.tsx +418 -418
- 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
|
+
}
|