compressed-context 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +88 -2
- package/package.json +62 -4
- package/src/compression/active-summary-cache.ts +50 -0
- package/src/compression/adopt.ts +57 -0
- package/src/compression/auto.ts +372 -0
- package/src/compression/babel.ts +85 -0
- package/src/compression/block.ts +25 -0
- package/src/compression/cache-history.ts +75 -0
- package/src/compression/compaction.ts +189 -0
- package/src/compression/decompress-tool.ts +74 -0
- package/src/compression/decompress.ts +292 -0
- package/src/compression/deleted-sessions.ts +24 -0
- package/src/compression/distill.ts +220 -0
- package/src/compression/events.ts +275 -0
- package/src/compression/markdown.ts +53 -0
- package/src/compression/normalize.ts +114 -0
- package/src/compression/render.ts +70 -0
- package/src/compression/schema.ts +84 -0
- package/src/compression/select.ts +123 -0
- package/src/compression/session-message.ts +112 -0
- package/src/compression/settings-rpc.ts +111 -0
- package/src/compression/settings.ts +175 -0
- package/src/compression/state.ts +248 -0
- package/src/compression/summarize.ts +595 -0
- package/src/compression/summary.ts +73 -0
- package/src/compression/tool.ts +142 -0
- package/src/compression/transcript-store.ts +258 -0
- package/src/compression/transcript.ts +63 -0
- package/src/compression/validate.ts +81 -0
- package/src/i18n/en.ts +93 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +89 -0
- package/src/i18n/zh.ts +93 -0
- package/src/index.ts +108 -0
- package/src/shared/blocks.ts +17 -0
- package/src/shared/cache.ts +199 -0
- package/src/shared/format.ts +23 -0
- package/src/shared/media.ts +184 -0
- package/src/shared/parse.ts +25 -0
- package/src/shared/parts.ts +72 -0
- package/src/shared/rpc.ts +145 -0
- package/src/shared/scan.ts +32 -0
- package/src/shared/settings.ts +471 -0
- package/src/shared/stats.ts +142 -0
- package/src/shared/tokens.ts +259 -0
- package/src/tui/chart/cache-chart.ts +237 -0
- package/src/tui/chart/color.ts +58 -0
- package/src/tui/index.tsx +111 -0
- package/src/tui/panel/cache.tsx +203 -0
- package/src/tui/panel/data.ts +105 -0
- package/src/tui/panel/index.tsx +176 -0
- package/src/tui/panel/summaries.tsx +192 -0
- package/src/tui/settings/dialogs.tsx +215 -0
- package/src/tui/settings/options.ts +238 -0
- package/src/tui/settings.tsx +418 -0
- package/src/tui/types.ts +4 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
import { resolveSummaryLimits } from "../shared/settings.ts"
|
|
5
|
+
import { createT1Block } from "./block.ts"
|
|
6
|
+
import { toSummary } from "./normalize.ts"
|
|
7
|
+
import { SummaryInput } from "./schema.ts"
|
|
8
|
+
import { RESOLVED_WINDOW_KEY, loadServerSettings, windowKey } from "./settings.ts"
|
|
9
|
+
import { eligibleMessages, messageRange, type MessageLike } from "./select.ts"
|
|
10
|
+
import { sessionMessagesToMessageLikes } from "./session-message.ts"
|
|
11
|
+
import { loadState, saveState, withPending } from "./state.ts"
|
|
12
|
+
import type { Disposable } from "./settings-rpc.ts"
|
|
13
|
+
import { buildTranscript } from "./transcript.ts"
|
|
14
|
+
import { saveTranscript } from "./transcript-store.ts"
|
|
15
|
+
import { MIN_SOURCE_CHARS, validateSummary } from "./validate.ts"
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION =
|
|
18
|
+
"Compress the conversation so far into a structured checkpoint. Call this when the context is getting full. " +
|
|
19
|
+
"It covers every message since the last compressed block, up to the current user message, and replaces them with " +
|
|
20
|
+
"one block. In JSON mode provide `goal` plus the optional fields; in Markdown mode provide `markdown`; in Babel " +
|
|
21
|
+
"mode provide `babel` with first line `T=<short title>|G=<one-line goal>` and a dense body. Omit any " +
|
|
22
|
+
"optional field that has no real content instead of inventing entries. Do not call while a compression is already " +
|
|
23
|
+
"pending. " +
|
|
24
|
+
"Add a short title if useful. " +
|
|
25
|
+
"The block is the only remaining record of those messages, so copy exact paths with line numbers, signatures, the " +
|
|
26
|
+
"code lines that carry the logic, error text, versions, config keys, thresholds, and measured values. " +
|
|
27
|
+
"Use `files` only for real file paths; put config keys and function names in `facts`. " +
|
|
28
|
+
"Put work in `progress.completed` only when it was actually finished; plans go in `progress.active` or `open_items`. " +
|
|
29
|
+
"Give the reason for each decision, keep the objective and any pivots, why important actions were taken, unanswered " +
|
|
30
|
+
"questions, and work the user still expects. Describe the past as past, not as a new instruction. " +
|
|
31
|
+
"Leave out logs, repeated reads, exploration that produced nothing, abandoned attempts (one line on why), and " +
|
|
32
|
+
"repeated status checks. If space is tight, keep objective and constraints first, then decisions and reasons, then " +
|
|
33
|
+
"exact technical facts, then conclusions, then lessons. Write short factual entries, not prose."
|
|
34
|
+
|
|
35
|
+
function failure(message: string) {
|
|
36
|
+
return {
|
|
37
|
+
output: { block: "", status: "error" },
|
|
38
|
+
content: `ERROR: ${message}`,
|
|
39
|
+
metadata: { error: true },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 输入:服务端上下文。
|
|
45
|
+
* 输出:注册 compress 工具。
|
|
46
|
+
* 预期:执行时从会话上下文按需取消息,不再把整段历史常驻内存。
|
|
47
|
+
*/
|
|
48
|
+
export async function registerCompressTool(
|
|
49
|
+
ctx: Plugin.Context,
|
|
50
|
+
legacyCache?: ReadonlyMap<string, readonly MessageLike[]>,
|
|
51
|
+
): Promise<Disposable> {
|
|
52
|
+
const registration = await ctx.tool.transform((tools) => {
|
|
53
|
+
tools.add({
|
|
54
|
+
name: "compress",
|
|
55
|
+
options: { codemode: false },
|
|
56
|
+
description: DESCRIPTION,
|
|
57
|
+
input: SummaryInput,
|
|
58
|
+
output: Schema.Struct({ block: Schema.String, status: Schema.String }),
|
|
59
|
+
execute: async (input, toolCtx) => {
|
|
60
|
+
const sessionID = toolCtx.sessionID
|
|
61
|
+
const state = await loadState(ctx.storage, sessionID)
|
|
62
|
+
if (state.terminal !== undefined) return failure(state.terminal)
|
|
63
|
+
if (state.pending || state.phase !== undefined) return failure("compression already in progress")
|
|
64
|
+
|
|
65
|
+
let eligible: readonly MessageLike[] | undefined
|
|
66
|
+
try {
|
|
67
|
+
const raw = await ctx.session.context({ sessionID })
|
|
68
|
+
eligible = eligibleMessages(sessionMessagesToMessageLikes(raw))
|
|
69
|
+
} catch {
|
|
70
|
+
// 兼容旧测试替身;生产路径不传 legacyCache。
|
|
71
|
+
eligible = legacyCache === undefined ? undefined : (legacyCache.get(sessionID) ?? [])
|
|
72
|
+
}
|
|
73
|
+
if (eligible === undefined) return failure("failed to read session context")
|
|
74
|
+
const transcript = buildTranscript(eligible)
|
|
75
|
+
const sourceChars = transcript.length
|
|
76
|
+
if (eligible.length === 0) return failure("nothing to compress")
|
|
77
|
+
if (sourceChars < MIN_SOURCE_CHARS) {
|
|
78
|
+
return failure(
|
|
79
|
+
`not enough content to compress (${sourceChars.toString()} chars, min ${MIN_SOURCE_CHARS.toString()})`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const settings = await loadServerSettings(ctx.storage)
|
|
84
|
+
if (!settings.enableCompressTool) return failure("compress tool is disabled in settings")
|
|
85
|
+
// 优先用当前会话模型自己的窗口;取不到再回退最近一次解析值。
|
|
86
|
+
let windowTokens: number | undefined
|
|
87
|
+
try {
|
|
88
|
+
const session = await ctx.session.get({ sessionID })
|
|
89
|
+
const model = session.model
|
|
90
|
+
const stored =
|
|
91
|
+
model === undefined ? undefined : await ctx.storage.get(windowKey(model.providerID, model.id))
|
|
92
|
+
if (typeof stored === "number" && stored > 0) windowTokens = stored
|
|
93
|
+
} catch {
|
|
94
|
+
// 会话或模型未知:回退到最近一次解析值
|
|
95
|
+
}
|
|
96
|
+
if (windowTokens === undefined) {
|
|
97
|
+
const resolved = await ctx.storage.get(RESOLVED_WINDOW_KEY)
|
|
98
|
+
if (typeof resolved === "number" && resolved > 0) windowTokens = resolved
|
|
99
|
+
}
|
|
100
|
+
const limits = resolveSummaryLimits(settings, windowTokens)
|
|
101
|
+
const summary = toSummary(input, settings.summaryFormat)
|
|
102
|
+
if (summary === undefined) return failure("invalid summary shape")
|
|
103
|
+
const validation = validateSummary(summary, sourceChars, undefined, limits.blockChars)
|
|
104
|
+
if (!validation.ok) return failure(validation.message)
|
|
105
|
+
|
|
106
|
+
const range = messageRange(eligible)
|
|
107
|
+
if (range === undefined) {
|
|
108
|
+
return failure("messages are missing ids")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const block = createT1Block({
|
|
112
|
+
blocks: state.blocks,
|
|
113
|
+
summary,
|
|
114
|
+
fromId: range.fromId,
|
|
115
|
+
toId: range.toId,
|
|
116
|
+
sourceChars,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// transcript 落盘失败不应阻断摘要;解压时会降级为占位符。
|
|
120
|
+
try {
|
|
121
|
+
await saveTranscript(ctx.storage, sessionID, block, eligible)
|
|
122
|
+
} catch {
|
|
123
|
+
// best effort
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await saveState(ctx.storage, sessionID, withPending(state, block))
|
|
127
|
+
try {
|
|
128
|
+
await ctx.session.compact({ sessionID })
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await saveState(ctx.storage, sessionID, { ...state, lastError: String(error) })
|
|
131
|
+
return failure(`failed to start compaction: ${String(error)}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
output: { block: block.id, status: "queued" },
|
|
136
|
+
content: `queued compression ${block.id}`,
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
return registration
|
|
142
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
import { isSessionDeleted } from "./deleted-sessions.ts"
|
|
4
|
+
import { forEachScannedEntry } from "../shared/scan.ts"
|
|
5
|
+
import type { Block, StorageLike } from "./state.ts"
|
|
6
|
+
import type { MediaSourceLike, MessageLike } from "./select.ts"
|
|
7
|
+
|
|
8
|
+
/** 落盘的媒体 source:bytes 统一转 base64;url/ref 只存引用。 */
|
|
9
|
+
export interface StoredMediaSource {
|
|
10
|
+
readonly type: "base64" | "url" | "ref" | "unknown"
|
|
11
|
+
readonly data?: string
|
|
12
|
+
readonly mediaType?: string
|
|
13
|
+
readonly url?: string
|
|
14
|
+
readonly provider?: string
|
|
15
|
+
readonly id?: string
|
|
16
|
+
readonly expiresAt?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 落盘的单条 part;reasoning 不进入这里。 */
|
|
20
|
+
export interface StoredPart {
|
|
21
|
+
readonly type: "text" | "tool-call" | "tool-result" | "media"
|
|
22
|
+
readonly text?: string
|
|
23
|
+
readonly name?: string
|
|
24
|
+
readonly input?: unknown
|
|
25
|
+
readonly result?: unknown
|
|
26
|
+
readonly filename?: string
|
|
27
|
+
readonly media?: {
|
|
28
|
+
readonly source: StoredMediaSource
|
|
29
|
+
readonly info?: unknown
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 落盘的单条消息。 */
|
|
34
|
+
export interface StoredMessage {
|
|
35
|
+
readonly id?: string
|
|
36
|
+
readonly role: string
|
|
37
|
+
readonly content: readonly StoredPart[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 一个 T1 块的 transcript manifest;消息本体按 msg:<index> 分开存。 */
|
|
41
|
+
export interface TranscriptManifest {
|
|
42
|
+
readonly v: 1
|
|
43
|
+
readonly sessionID: string
|
|
44
|
+
readonly blockID: string
|
|
45
|
+
readonly fromId: string
|
|
46
|
+
readonly toId: string
|
|
47
|
+
readonly count: number
|
|
48
|
+
readonly createdAt: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const PREFIX = "compression:transcript:"
|
|
52
|
+
|
|
53
|
+
/** 输入:sessionID;输出:该会话全部 transcript key 的公共前缀。 */
|
|
54
|
+
export function transcriptSessionPrefix(sessionID: string): string {
|
|
55
|
+
return `${PREFIX}${sessionID}:`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 输入:sessionID + blockID;输出:manifest key。 */
|
|
59
|
+
export function transcriptManifestKey(sessionID: string, blockID: string): string {
|
|
60
|
+
return `${transcriptSessionPrefix(sessionID)}${blockID}:manifest`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 输入:sessionID + blockID + 序号;输出:单条消息 key。 */
|
|
64
|
+
export function transcriptMessageKey(sessionID: string, blockID: string, index: number): string {
|
|
65
|
+
return `${transcriptSessionPrefix(sessionID)}${blockID}:msg:${index.toString()}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 输入:任意数据;输出:是否 Uint8Array。 */
|
|
69
|
+
function isBytes(value: unknown): value is Uint8Array {
|
|
70
|
+
return value instanceof Uint8Array
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 输入:media source;输出:可 JSON 化的 source;未知形状保留 unknown。 */
|
|
74
|
+
function storeSource(source: MediaSourceLike | undefined): StoredMediaSource {
|
|
75
|
+
if (source === undefined) return { type: "unknown" }
|
|
76
|
+
if (source.type === "bytes") {
|
|
77
|
+
const data = isBytes(source.data)
|
|
78
|
+
? Buffer.from(source.data).toString("base64")
|
|
79
|
+
: typeof source.data === "string"
|
|
80
|
+
? source.data
|
|
81
|
+
: ""
|
|
82
|
+
return {
|
|
83
|
+
type: "base64",
|
|
84
|
+
...(data.length === 0 ? {} : { data }),
|
|
85
|
+
...(source.mediaType === undefined ? {} : { mediaType: source.mediaType }),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (source.type === "base64") {
|
|
89
|
+
return {
|
|
90
|
+
type: "base64",
|
|
91
|
+
...(typeof source.data === "string" ? { data: source.data } : {}),
|
|
92
|
+
...(source.mediaType === undefined ? {} : { mediaType: source.mediaType }),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (source.type === "url") {
|
|
96
|
+
return {
|
|
97
|
+
type: "url",
|
|
98
|
+
...(source.url === undefined ? {} : { url: source.url }),
|
|
99
|
+
...(source.mediaType === undefined ? {} : { mediaType: source.mediaType }),
|
|
100
|
+
...(source.expiresAt === undefined ? {} : { expiresAt: source.expiresAt }),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (source.type === "ref") {
|
|
104
|
+
return {
|
|
105
|
+
type: "ref",
|
|
106
|
+
...(source.provider === undefined ? {} : { provider: source.provider }),
|
|
107
|
+
...(source.id === undefined ? {} : { id: source.id }),
|
|
108
|
+
...(source.mediaType === undefined ? {} : { mediaType: source.mediaType }),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { type: "unknown", ...(source.mediaType === undefined ? {} : { mediaType: source.mediaType }) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 输入:可压缩消息;输出:落盘消息;预期:跳过 reasoning 和其他未知 part。 */
|
|
115
|
+
export function toStoredMessage(message: MessageLike): StoredMessage {
|
|
116
|
+
const content: StoredPart[] = []
|
|
117
|
+
for (const part of message.content) {
|
|
118
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
119
|
+
content.push({ type: "text", text: part.text })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (part.type === "tool-call") {
|
|
123
|
+
content.push({
|
|
124
|
+
type: "tool-call",
|
|
125
|
+
...(part.name === undefined ? {} : { name: part.name }),
|
|
126
|
+
input: part.input,
|
|
127
|
+
})
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (part.type === "tool-result") {
|
|
131
|
+
content.push({
|
|
132
|
+
type: "tool-result",
|
|
133
|
+
...(part.name === undefined ? {} : { name: part.name }),
|
|
134
|
+
result: part.result,
|
|
135
|
+
})
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (part.type === "media") {
|
|
139
|
+
content.push({
|
|
140
|
+
type: "media",
|
|
141
|
+
...(part.filename === undefined ? {} : { filename: part.filename }),
|
|
142
|
+
media: {
|
|
143
|
+
source: storeSource(part.media?.source),
|
|
144
|
+
...(part.media?.info === undefined ? {} : { info: part.media.info }),
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...(message.id === undefined ? {} : { id: message.id }),
|
|
151
|
+
role: message.role,
|
|
152
|
+
content,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 输入:storage + 会话 + T1 块 + 覆盖的消息。
|
|
158
|
+
* 输出:无。
|
|
159
|
+
* 预期:先写 manifest,再逐条写消息;删除墓碑存在时整体跳过。
|
|
160
|
+
*/
|
|
161
|
+
export async function saveTranscript(
|
|
162
|
+
storage: StorageLike,
|
|
163
|
+
sessionID: string,
|
|
164
|
+
block: Pick<Block, "id" | "fromId" | "toId">,
|
|
165
|
+
messages: readonly MessageLike[],
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (isSessionDeleted(sessionID)) return
|
|
168
|
+
const manifest: TranscriptManifest = {
|
|
169
|
+
v: 1,
|
|
170
|
+
sessionID,
|
|
171
|
+
blockID: block.id,
|
|
172
|
+
fromId: block.fromId,
|
|
173
|
+
toId: block.toId,
|
|
174
|
+
count: messages.length,
|
|
175
|
+
createdAt: Date.now(),
|
|
176
|
+
}
|
|
177
|
+
await storage.set(transcriptManifestKey(sessionID, block.id), manifest as unknown as Schema.Json)
|
|
178
|
+
for (const [index, message] of messages.entries()) {
|
|
179
|
+
await storage.set(
|
|
180
|
+
transcriptMessageKey(sessionID, block.id, index),
|
|
181
|
+
toStoredMessage(message) as unknown as Schema.Json,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 输入:storage + 会话 + 块;输出:合法 manifest 或 undefined。 */
|
|
187
|
+
export async function loadTranscriptManifest(
|
|
188
|
+
storage: StorageLike,
|
|
189
|
+
sessionID: string,
|
|
190
|
+
blockID: string,
|
|
191
|
+
): Promise<TranscriptManifest | undefined> {
|
|
192
|
+
const raw = await storage.get(transcriptManifestKey(sessionID, blockID))
|
|
193
|
+
if (typeof raw !== "object" || raw === null) return undefined
|
|
194
|
+
const record = raw as Record<string, unknown>
|
|
195
|
+
if (
|
|
196
|
+
record["v"] !== 1 ||
|
|
197
|
+
typeof record["sessionID"] !== "string" ||
|
|
198
|
+
typeof record["blockID"] !== "string" ||
|
|
199
|
+
typeof record["fromId"] !== "string" ||
|
|
200
|
+
typeof record["toId"] !== "string" ||
|
|
201
|
+
typeof record["count"] !== "number" ||
|
|
202
|
+
!Number.isInteger(record["count"]) ||
|
|
203
|
+
record["count"] < 0
|
|
204
|
+
) {
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
v: 1,
|
|
209
|
+
sessionID: record["sessionID"],
|
|
210
|
+
blockID: record["blockID"],
|
|
211
|
+
fromId: record["fromId"],
|
|
212
|
+
toId: record["toId"],
|
|
213
|
+
count: record["count"],
|
|
214
|
+
createdAt: typeof record["createdAt"] === "number" ? record["createdAt"] : 0,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 输入:storage + 会话 + 块 + 序号;输出:落盘消息或 undefined。 */
|
|
219
|
+
export async function loadTranscriptMessage(
|
|
220
|
+
storage: StorageLike,
|
|
221
|
+
sessionID: string,
|
|
222
|
+
blockID: string,
|
|
223
|
+
index: number,
|
|
224
|
+
): Promise<StoredMessage | undefined> {
|
|
225
|
+
const raw = await storage.get(transcriptMessageKey(sessionID, blockID, index))
|
|
226
|
+
if (typeof raw !== "object" || raw === null) return undefined
|
|
227
|
+
const record = raw as Record<string, unknown>
|
|
228
|
+
if (typeof record["role"] !== "string" || !Array.isArray(record["content"])) return undefined
|
|
229
|
+
return {
|
|
230
|
+
...(typeof record["id"] === "string" ? { id: record["id"] } : {}),
|
|
231
|
+
role: record["role"],
|
|
232
|
+
content: record["content"] as StoredPart[],
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 输入:storage + 会话;输出:无;预期:删掉该会话所有 manifest/message key。 */
|
|
237
|
+
export async function deleteTranscriptsForSession(
|
|
238
|
+
storage: StorageLike & {
|
|
239
|
+
scan?: (options: { prefix: string; limit: number; after?: string }) => Promise<{
|
|
240
|
+
entries: readonly { key: string; value: unknown }[]
|
|
241
|
+
next?: string
|
|
242
|
+
}>
|
|
243
|
+
},
|
|
244
|
+
sessionID: string,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
await forEachScannedEntry(storage, transcriptSessionPrefix(sessionID), async (entry) => {
|
|
247
|
+
await storage.remove(entry.key)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 输入:storage + 会话 + 块;输出:manifest 是否存在。 */
|
|
252
|
+
export async function hasTranscript(
|
|
253
|
+
storage: StorageLike,
|
|
254
|
+
sessionID: string,
|
|
255
|
+
blockID: string,
|
|
256
|
+
): Promise<boolean> {
|
|
257
|
+
return (await loadTranscriptManifest(storage, sessionID, blockID)) !== undefined
|
|
258
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { stringify, toolInput, toolResult, toolResultText } from "../shared/parts.ts"
|
|
2
|
+
import type { ContentPartLike, MessageLike } from "./select.ts"
|
|
3
|
+
|
|
4
|
+
/** 单条工具输出最多保留的字符数。 */
|
|
5
|
+
const TOOL_RESULT_MAX = 2_000
|
|
6
|
+
/** 工具调用参数最多保留的字符数。 */
|
|
7
|
+
const TOOL_INPUT_MAX = 500
|
|
8
|
+
/** 整份 transcript 的字符上限,防止侧调用自身溢出。 */
|
|
9
|
+
export const TRANSCRIPT_MAX = 200_000
|
|
10
|
+
|
|
11
|
+
function truncate(value: string, max: number): string {
|
|
12
|
+
return value.length <= max ? value : `${value.slice(0, max)}…[truncated]`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderMessage(message: MessageLike): string {
|
|
16
|
+
const lines: string[] = []
|
|
17
|
+
if (typeof message.text === "string" && message.text.length > 0) {
|
|
18
|
+
lines.push(message.role === "user" ? `[User]\n${message.text}` : message.text)
|
|
19
|
+
}
|
|
20
|
+
const content: readonly ContentPartLike[] = Array.isArray(message.content)
|
|
21
|
+
? message.content
|
|
22
|
+
: []
|
|
23
|
+
for (const part of content) {
|
|
24
|
+
if (part.type === "text") {
|
|
25
|
+
const text = part.text ?? ""
|
|
26
|
+
if (text.length > 0) lines.push(message.role === "user" ? `[User]\n${text}` : text)
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (part.type === "tool") {
|
|
30
|
+
lines.push(`[Tool call ${part.name ?? "?"}] ${truncate(stringify(toolInput(part)), TOOL_INPUT_MAX)}`)
|
|
31
|
+
lines.push(`[Tool result ${part.name ?? "?"}] ${truncate(toolResultText(toolResult(part)), TOOL_RESULT_MAX)}`)
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (part.type === "tool-call") {
|
|
35
|
+
lines.push(`[Tool call ${part.name ?? "?"}] ${truncate(stringify(toolInput(part)), TOOL_INPUT_MAX)}`)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (part.type === "tool-result") {
|
|
39
|
+
lines.push(`[Tool result ${part.name ?? "?"}] ${truncate(toolResultText(toolResult(part)), TOOL_RESULT_MAX)}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 输入:可压缩消息。
|
|
47
|
+
* 输出:给侧调用使用的纯文本 transcript。
|
|
48
|
+
* 预期:跳过 reasoning/media;工具输出截断;整体超限时丢弃最老的消息。
|
|
49
|
+
*/
|
|
50
|
+
export function buildTranscript(messages: readonly MessageLike[]): string {
|
|
51
|
+
const rendered = messages.map(renderMessage).filter((text) => text.length > 0)
|
|
52
|
+
let total = rendered.reduce((sum, text) => sum + text.length + 2, 0)
|
|
53
|
+
let start = 0
|
|
54
|
+
while (total > TRANSCRIPT_MAX && start < rendered.length - 1) {
|
|
55
|
+
const item = rendered[start]
|
|
56
|
+
if (item === undefined) break
|
|
57
|
+
total -= item.length + 2
|
|
58
|
+
start += 1
|
|
59
|
+
}
|
|
60
|
+
const kept = rendered.slice(start)
|
|
61
|
+
const prefix = start > 0 ? `[older messages omitted: ${start.toString()}]\n\n` : ""
|
|
62
|
+
return prefix + kept.join("\n\n")
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isBabelSummary, isMarkdownSummary, type Summary } from "./schema.ts"
|
|
2
|
+
import { summaryChars } from "./summary.ts"
|
|
3
|
+
|
|
4
|
+
export const MIN_SUMMARY_CHARS = 200
|
|
5
|
+
export const MIN_MERGE_SUMMARY_CHARS = 80
|
|
6
|
+
export const MIN_ULTRA_SUMMARY_CHARS = 40
|
|
7
|
+
export const MAX_SUMMARY_CHARS = 20_000
|
|
8
|
+
|
|
9
|
+
/** 输入:层级;输出:该层允许的最短摘要字符数。 */
|
|
10
|
+
export function minSummaryChars(tier: 1 | 2 | 3): number {
|
|
11
|
+
if (tier === 3) return MIN_ULTRA_SUMMARY_CHARS
|
|
12
|
+
if (tier === 2) return MIN_MERGE_SUMMARY_CHARS
|
|
13
|
+
return MIN_SUMMARY_CHARS
|
|
14
|
+
}
|
|
15
|
+
/** 可压缩原文的最小字符数,低于它不生成块。 */
|
|
16
|
+
export const MIN_SOURCE_CHARS = 5_000
|
|
17
|
+
|
|
18
|
+
export interface ValidationResult {
|
|
19
|
+
readonly ok: boolean
|
|
20
|
+
readonly fields: readonly string[]
|
|
21
|
+
readonly message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 输入:结构化摘要或 Markdown 摘要 + 原文总字符数。
|
|
26
|
+
* 输出:校验结果。
|
|
27
|
+
* 预期:JSON 检查字段;Markdown 检查标题/正文/长度,且摘要必须比原文小。
|
|
28
|
+
*/
|
|
29
|
+
export function validateSummary(
|
|
30
|
+
summary: Summary,
|
|
31
|
+
sourceChars: number,
|
|
32
|
+
minChars: number = MIN_SUMMARY_CHARS,
|
|
33
|
+
maxChars: number = MAX_SUMMARY_CHARS,
|
|
34
|
+
): ValidationResult {
|
|
35
|
+
const fields: string[] = []
|
|
36
|
+
if (isBabelSummary(summary)) {
|
|
37
|
+
if (summary.title.trim().length === 0 || summary.title.length > 80) fields.push("title")
|
|
38
|
+
if (summary.goal.trim().length === 0) fields.push("goal")
|
|
39
|
+
const body = summary.text.split(/\r?\n/).slice(1).join("\n").trim()
|
|
40
|
+
if (body.length === 0) fields.push("text")
|
|
41
|
+
} else if (isMarkdownSummary(summary)) {
|
|
42
|
+
if (summary.title.trim().length === 0 || summary.title.length > 80) fields.push("title")
|
|
43
|
+
if (summary.text.trim().length === 0) fields.push("text")
|
|
44
|
+
} else {
|
|
45
|
+
if (summary.goal.trim().length === 0) fields.push("goal")
|
|
46
|
+
if (summary.title.trim().length === 0 || summary.title.length > 80) fields.push("title")
|
|
47
|
+
if (summary.constraints.some((item) => item.trim().length === 0)) fields.push("constraints")
|
|
48
|
+
if (summary.decisions.some((item) => item.trim().length === 0)) fields.push("decisions")
|
|
49
|
+
if (summary.open_items.some((item) => item.trim().length === 0)) fields.push("open_items")
|
|
50
|
+
if (summary.files.some((item) => item.trim().length === 0)) fields.push("files")
|
|
51
|
+
if (summary.facts.some((item) => item.trim().length === 0)) fields.push("facts")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fields.length > 0) {
|
|
55
|
+
return { ok: false, fields, message: `empty field: ${fields.join(", ")}` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const chars = summaryChars(summary)
|
|
59
|
+
if (chars < minChars) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
fields: ["summary"],
|
|
63
|
+
message: `summary too short (${chars.toString()} chars, min ${minChars.toString()})`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (chars > maxChars) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
fields: ["summary"],
|
|
70
|
+
message: `summary too long (${chars.toString()} chars, max ${maxChars.toString()})`,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (sourceChars > 0 && chars >= sourceChars) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
fields: ["summary"],
|
|
77
|
+
message: `summary (${chars.toString()} chars) is not smaller than the source (${sourceChars.toString()} chars)`,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, fields: [], message: "ok" }
|
|
81
|
+
}
|
package/src/i18n/en.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Messages } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/** English copy. Labels stay short so they fit the narrow sidebar. */
|
|
4
|
+
export const en: Messages = {
|
|
5
|
+
title: "Context breakdown",
|
|
6
|
+
labels: {
|
|
7
|
+
system: "System",
|
|
8
|
+
tools: "Tool defs",
|
|
9
|
+
tool: "Tool I/O",
|
|
10
|
+
text: "Text",
|
|
11
|
+
recent: "Kept",
|
|
12
|
+
summaries: "Summary",
|
|
13
|
+
media: "Media",
|
|
14
|
+
},
|
|
15
|
+
approximate: "≈",
|
|
16
|
+
terminal: "Context cannot be compressed further; start a new session or use native compaction",
|
|
17
|
+
cacheTitle: "Cache",
|
|
18
|
+
cacheChart: "Chart",
|
|
19
|
+
cacheLog: "Log",
|
|
20
|
+
cacheInput: "In",
|
|
21
|
+
cacheOutput: "Out",
|
|
22
|
+
cacheCached: "Cached",
|
|
23
|
+
cacheHit: "Hit",
|
|
24
|
+
summariesTitle: "Summaries",
|
|
25
|
+
noData: "—",
|
|
26
|
+
command: {
|
|
27
|
+
title: "Compressed context settings",
|
|
28
|
+
description: "Open plugin settings",
|
|
29
|
+
group: "Plugins",
|
|
30
|
+
},
|
|
31
|
+
settings: {
|
|
32
|
+
title: "Compressed context plugin settings",
|
|
33
|
+
show: "Show panel",
|
|
34
|
+
limit: "Context limit",
|
|
35
|
+
limitAuto: "Model native",
|
|
36
|
+
limitCustom: "Custom limit…",
|
|
37
|
+
limitHint: "Upper bound; the effective window is the smaller of this value and the model's native window. Digits or a k/m suffix",
|
|
38
|
+
limitInvalid: "Enter a valid token count",
|
|
39
|
+
limitPlaceholder: "200k",
|
|
40
|
+
limitPreview: (value) => `= ${value} tokens`,
|
|
41
|
+
limitCapped: (requested, actual) => `${requested} (model max ${actual})`,
|
|
42
|
+
threshold: "Compression threshold",
|
|
43
|
+
thresholdHint: "Enter an integer between 60 and 95 (percent)",
|
|
44
|
+
thresholdInvalid: "Threshold must be between 60 and 95",
|
|
45
|
+
thresholdPreview: (value) => `= ${value}%`,
|
|
46
|
+
nudgeGrowth: "Compression reminder",
|
|
47
|
+
nudgeGrowthHint: "Remind the model after this much context growth; 0 disables it",
|
|
48
|
+
nudgeGrowthInvalid: "Enter 0, or 1k to 200k",
|
|
49
|
+
summaryBlockPercent: "Summary block limit",
|
|
50
|
+
summaryBlockPercentHint: "Max share of the window for one summary; enter 10% or 20k",
|
|
51
|
+
summaryTotalPercent: "Total summary limit",
|
|
52
|
+
summaryTotalPercentHint: "Max share of the window for all summaries; enter 50% or 100k",
|
|
53
|
+
summaryPercentInvalid: "Enter 1% to 90%, or a token count that fits the window",
|
|
54
|
+
windowKnown: (value) => `Current window: ${value}`,
|
|
55
|
+
windowUnknown: "Current window unknown (run one model request first)",
|
|
56
|
+
summaryLanguage: "Summary language",
|
|
57
|
+
summaryFormat: "Summary format",
|
|
58
|
+
summaryFormatJson: "JSON (structured)",
|
|
59
|
+
summaryFormatMarkdown: "Markdown (natural language)",
|
|
60
|
+
summaryFormatBabel: "Babel (model-to-model dense)",
|
|
61
|
+
autoCompress: "Auto compression",
|
|
62
|
+
enableCompressTool: "compress tool",
|
|
63
|
+
enableDecompressTool: "decompress tool",
|
|
64
|
+
showTimeline: "Block timeline",
|
|
65
|
+
showBlockTokens: "Block token estimate",
|
|
66
|
+
showCacheChart: "Show cache chart",
|
|
67
|
+
showCacheLog: "Show cache log",
|
|
68
|
+
showSummaries: "Show summary blocks",
|
|
69
|
+
categories: {
|
|
70
|
+
compress: "Compression",
|
|
71
|
+
summary: "Summary",
|
|
72
|
+
panel: "Panel",
|
|
73
|
+
decompress: "Decompression",
|
|
74
|
+
interface: "Interface",
|
|
75
|
+
},
|
|
76
|
+
decompressInlineMax: "Decompress inline limit",
|
|
77
|
+
decompressInlineMaxHint: "Above this many characters the content goes to a temp file; digits or a k/m suffix",
|
|
78
|
+
decompressInlineMaxInvalid: "Enter a character limit between 1k and 1m",
|
|
79
|
+
decompressTempCap: "Decompress temp file cap",
|
|
80
|
+
decompressTempCapHint: "Maximum number of decompress temp files to keep",
|
|
81
|
+
decompressTempCapInvalid: "Enter an integer between 1 and 1000",
|
|
82
|
+
decompressShowTokens: "Decompress token estimate",
|
|
83
|
+
submit: "Submit",
|
|
84
|
+
language: "Language",
|
|
85
|
+
auto: "Auto",
|
|
86
|
+
on: "On",
|
|
87
|
+
off: "Off",
|
|
88
|
+
},
|
|
89
|
+
languages: {
|
|
90
|
+
zh: "中文",
|
|
91
|
+
en: "English",
|
|
92
|
+
},
|
|
93
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { en } from "./en.ts"
|
|
2
|
+
import type { Messages } from "./types.ts"
|
|
3
|
+
import { zh } from "./zh.ts"
|
|
4
|
+
|
|
5
|
+
export type { Messages }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 输入:locale 字符串(可为空)。
|
|
9
|
+
* 输出:对应语言的消息表。
|
|
10
|
+
* 预期:`zh*` 用中文,其余一律英文;空值回退到系统 locale。
|
|
11
|
+
*/
|
|
12
|
+
export function resolveMessages(locale: string | undefined): Messages {
|
|
13
|
+
const value = (locale ?? detectLocale()).toLowerCase()
|
|
14
|
+
return value.startsWith("zh") ? zh : en
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 输入:无。
|
|
19
|
+
* 输出:系统 locale。
|
|
20
|
+
* 预期:运行时拿不到时回退 `en`,保证始终有文案。
|
|
21
|
+
*/
|
|
22
|
+
export function detectLocale(): string {
|
|
23
|
+
try {
|
|
24
|
+
return Intl.DateTimeFormat().resolvedOptions().locale
|
|
25
|
+
} catch {
|
|
26
|
+
return "en"
|
|
27
|
+
}
|
|
28
|
+
}
|