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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +62 -4
  4. package/src/compression/active-summary-cache.ts +50 -0
  5. package/src/compression/adopt.ts +57 -0
  6. package/src/compression/auto.ts +372 -0
  7. package/src/compression/babel.ts +85 -0
  8. package/src/compression/block.ts +25 -0
  9. package/src/compression/cache-history.ts +75 -0
  10. package/src/compression/compaction.ts +189 -0
  11. package/src/compression/decompress-tool.ts +74 -0
  12. package/src/compression/decompress.ts +292 -0
  13. package/src/compression/deleted-sessions.ts +24 -0
  14. package/src/compression/distill.ts +220 -0
  15. package/src/compression/events.ts +275 -0
  16. package/src/compression/markdown.ts +53 -0
  17. package/src/compression/normalize.ts +114 -0
  18. package/src/compression/render.ts +70 -0
  19. package/src/compression/schema.ts +84 -0
  20. package/src/compression/select.ts +123 -0
  21. package/src/compression/session-message.ts +112 -0
  22. package/src/compression/settings-rpc.ts +111 -0
  23. package/src/compression/settings.ts +175 -0
  24. package/src/compression/state.ts +248 -0
  25. package/src/compression/summarize.ts +595 -0
  26. package/src/compression/summary.ts +73 -0
  27. package/src/compression/tool.ts +142 -0
  28. package/src/compression/transcript-store.ts +258 -0
  29. package/src/compression/transcript.ts +63 -0
  30. package/src/compression/validate.ts +81 -0
  31. package/src/i18n/en.ts +93 -0
  32. package/src/i18n/index.ts +28 -0
  33. package/src/i18n/types.ts +89 -0
  34. package/src/i18n/zh.ts +93 -0
  35. package/src/index.ts +108 -0
  36. package/src/shared/blocks.ts +17 -0
  37. package/src/shared/cache.ts +199 -0
  38. package/src/shared/format.ts +23 -0
  39. package/src/shared/media.ts +184 -0
  40. package/src/shared/parse.ts +25 -0
  41. package/src/shared/parts.ts +72 -0
  42. package/src/shared/rpc.ts +145 -0
  43. package/src/shared/scan.ts +32 -0
  44. package/src/shared/settings.ts +471 -0
  45. package/src/shared/stats.ts +142 -0
  46. package/src/shared/tokens.ts +259 -0
  47. package/src/tui/chart/cache-chart.ts +237 -0
  48. package/src/tui/chart/color.ts +58 -0
  49. package/src/tui/index.tsx +111 -0
  50. package/src/tui/panel/cache.tsx +203 -0
  51. package/src/tui/panel/data.ts +105 -0
  52. package/src/tui/panel/index.tsx +176 -0
  53. package/src/tui/panel/summaries.tsx +192 -0
  54. package/src/tui/settings/dialogs.tsx +215 -0
  55. package/src/tui/settings/options.ts +238 -0
  56. package/src/tui/settings.tsx +418 -0
  57. package/src/tui/types.ts +4 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 多模态附件 token 估算。
3
+ *
4
+ * 输入:mime、base64/字节数据、可选时长。
5
+ * 输出:粗略 token 数。
6
+ * 预期:拿不到宽高/页数/时长时使用保守固定值;这里只做估算,不伪装成精确 tokenizer。
7
+ */
8
+
9
+ /** 图片拿不到尺寸时的固定估算。 */
10
+ export const IMAGE_FALLBACK_TOKENS = 1_500
11
+ /** PDF 拿不到页数时的固定估算。 */
12
+ export const PDF_FALLBACK_TOKENS = 2_000
13
+ /** PDF 每页按一张图估算。 */
14
+ export const PDF_PAGE_TOKENS = 1_500
15
+ /** 音频每秒估算。 */
16
+ export const AUDIO_TOKENS_PER_SECOND = 32
17
+ /** 音频拿不到时长时的固定估算。 */
18
+ export const AUDIO_FALLBACK_TOKENS = 1_000
19
+ /** 视频每秒估算。 */
20
+ export const VIDEO_TOKENS_PER_SECOND = 263
21
+ /** 视频拿不到时长时的固定估算。 */
22
+ export const VIDEO_FALLBACK_TOKENS = 10_000
23
+ /** 未知二进制文件固定估算。 */
24
+ export const FILE_FALLBACK_TOKENS = 2_000
25
+
26
+ export interface AttachmentInput {
27
+ readonly mime?: string | undefined
28
+ readonly data?: string | Uint8Array | undefined
29
+ readonly width?: number | undefined
30
+ readonly height?: number | undefined
31
+ readonly durationSeconds?: number | undefined
32
+ }
33
+
34
+ export interface ImageSize {
35
+ readonly width: number
36
+ readonly height: number
37
+ }
38
+
39
+ /** 输入:base64 或字节;输出:字节;失败返回 undefined。 */
40
+ function toBytes(data: string | Uint8Array | undefined): Uint8Array | undefined {
41
+ if (data === undefined) return undefined
42
+ if (data instanceof Uint8Array) return data
43
+ try {
44
+ return Uint8Array.from(Buffer.from(data, "base64"))
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ /** 输入:字节 + 偏移;输出:大端 u32;越界返回 0。 */
51
+ function u32be(bytes: Uint8Array, offset: number): number {
52
+ const a = bytes[offset] ?? 0
53
+ const b = bytes[offset + 1] ?? 0
54
+ const c = bytes[offset + 2] ?? 0
55
+ const d = bytes[offset + 3] ?? 0
56
+ return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0
57
+ }
58
+
59
+ /** 输入:字节 + 偏移;输出:小端 u16;越界返回 0。 */
60
+ function u16le(bytes: Uint8Array, offset: number): number {
61
+ return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8)
62
+ }
63
+
64
+ /** 输入:字节 + 偏移;输出:大端 u16;越界返回 0。 */
65
+ function u16be(bytes: Uint8Array, offset: number): number {
66
+ return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0)
67
+ }
68
+
69
+ /** 输入:字节 + 偏移;输出:小端 u24;越界返回 0。 */
70
+ function u24le(bytes: Uint8Array, offset: number): number {
71
+ return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8) | ((bytes[offset + 2] ?? 0) << 16)
72
+ }
73
+
74
+ /** 输入:PNG 字节;输出:尺寸;不是 PNG 返回 undefined。 */
75
+ function pngSize(bytes: Uint8Array): ImageSize | undefined {
76
+ if (bytes.length < 24) return undefined
77
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10]
78
+ if (!signature.every((value, index) => bytes[index] === value)) return undefined
79
+ const width = u32be(bytes, 16)
80
+ const height = u32be(bytes, 20)
81
+ return width > 0 && height > 0 ? { width, height } : undefined
82
+ }
83
+
84
+ /** 输入:GIF 字节;输出:尺寸;不是 GIF 返回 undefined。 */
85
+ function gifSize(bytes: Uint8Array): ImageSize | undefined {
86
+ if (bytes.length < 10) return undefined
87
+ const header = String.fromCharCode(...bytes.slice(0, 6))
88
+ if (header !== "GIF87a" && header !== "GIF89a") return undefined
89
+ const width = u16le(bytes, 6)
90
+ const height = u16le(bytes, 8)
91
+ return width > 0 && height > 0 ? { width, height } : undefined
92
+ }
93
+
94
+ /** 输入:JPEG 字节;输出:尺寸;找不到 SOF 返回 undefined。 */
95
+ function jpegSize(bytes: Uint8Array): ImageSize | undefined {
96
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined
97
+ let offset = 2
98
+ while (offset + 9 < bytes.length) {
99
+ if (bytes[offset] !== 0xff) {
100
+ offset += 1
101
+ continue
102
+ }
103
+ const marker = bytes[offset + 1] ?? 0
104
+ const length = u16be(bytes, offset + 2)
105
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
106
+ const height = u16be(bytes, offset + 5)
107
+ const width = u16be(bytes, offset + 7)
108
+ return width > 0 && height > 0 ? { width, height } : undefined
109
+ }
110
+ if (length < 2) return undefined
111
+ offset += 2 + length
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ /** 输入:WebP 字节;输出:尺寸;VP8X 之外的编码回退 undefined。 */
117
+ function webpSize(bytes: Uint8Array): ImageSize | undefined {
118
+ if (bytes.length < 30) return undefined
119
+ const riff = String.fromCharCode(...bytes.slice(0, 4))
120
+ const webp = String.fromCharCode(...bytes.slice(8, 12))
121
+ const chunk = String.fromCharCode(...bytes.slice(12, 16))
122
+ if (riff !== "RIFF" || webp !== "WEBP" || chunk !== "VP8X") return undefined
123
+ const width = 1 + u24le(bytes, 24)
124
+ const height = 1 + u24le(bytes, 27)
125
+ return width > 0 && height > 0 ? { width, height } : undefined
126
+ }
127
+
128
+ /** 输入:附件数据;输出:图片尺寸;不是图片或解析失败返回 undefined。 */
129
+ export function imageSize(data: string | Uint8Array | undefined): ImageSize | undefined {
130
+ const bytes = toBytes(data)
131
+ if (bytes === undefined) return undefined
132
+ return pngSize(bytes) ?? gifSize(bytes) ?? jpegSize(bytes) ?? webpSize(bytes)
133
+ }
134
+
135
+ /** 输入:图片宽高;输出:通用经验公式估算。 */
136
+ export function imageTokens(width: number, height: number): number {
137
+ if (width <= 0 || height <= 0) return IMAGE_FALLBACK_TOKENS
138
+ return Math.max(258, Math.ceil((width * height) / 750))
139
+ }
140
+
141
+ /** 输入:PDF 字节;输出:页数;解析失败返回 undefined。 */
142
+ export function pdfPages(data: string | Uint8Array | undefined): number | undefined {
143
+ const bytes = toBytes(data)
144
+ if (bytes === undefined) return undefined
145
+ const text = Buffer.from(bytes).toString("latin1")
146
+ const pages = text.match(/\/Type\s*\/Page\b/g)
147
+ return pages === null ? undefined : pages.length
148
+ }
149
+
150
+ /** 输入:文本类文件字节;输出:粗略 token。 */
151
+ function textFileTokens(bytes: Uint8Array): number {
152
+ return Math.max(1, Math.ceil(bytes.length / 4))
153
+ }
154
+
155
+ /** 输入:附件;输出:通用经验估算。 */
156
+ export function attachmentTokens(input: AttachmentInput): number {
157
+ const mime = (input.mime ?? "").toLowerCase()
158
+ const bytes = toBytes(input.data)
159
+ if (mime.startsWith("image/")) {
160
+ if (input.width !== undefined && input.height !== undefined) {
161
+ return imageTokens(input.width, input.height)
162
+ }
163
+ const size = imageSize(input.data)
164
+ return size === undefined ? IMAGE_FALLBACK_TOKENS : imageTokens(size.width, size.height)
165
+ }
166
+ if (mime === "application/pdf") {
167
+ const pages = pdfPages(input.data)
168
+ return pages === undefined ? PDF_FALLBACK_TOKENS : pages * PDF_PAGE_TOKENS
169
+ }
170
+ if (mime.startsWith("audio/")) {
171
+ return input.durationSeconds === undefined
172
+ ? AUDIO_FALLBACK_TOKENS
173
+ : Math.max(1, Math.ceil(input.durationSeconds * AUDIO_TOKENS_PER_SECOND))
174
+ }
175
+ if (mime.startsWith("video/")) {
176
+ return input.durationSeconds === undefined
177
+ ? VIDEO_FALLBACK_TOKENS
178
+ : Math.max(1, Math.ceil(input.durationSeconds * VIDEO_TOKENS_PER_SECOND))
179
+ }
180
+ if (mime.startsWith("text/") || mime === "application/json") {
181
+ return bytes === undefined ? FILE_FALLBACK_TOKENS : textFileTokens(bytes)
182
+ }
183
+ return FILE_FALLBACK_TOKENS
184
+ }
@@ -0,0 +1,25 @@
1
+ /** 输入:任意值 + 范围;输出:合法整数;非法返回 fallback。 */
2
+ export function readInt(value: unknown, min: number, max: number, fallback: number): number {
3
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback
4
+ const rounded = Math.round(value)
5
+ if (rounded < min || rounded > max) return fallback
6
+ return rounded
7
+ }
8
+
9
+ /** 输入:任意值 + 范围;输出:合法整数;非法返回 undefined。 */
10
+ export function readOptionalInt(value: unknown, min: number, max: number): number | undefined {
11
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined
12
+ const rounded = Math.round(value)
13
+ if (rounded < min || rounded > max) return undefined
14
+ return rounded
15
+ }
16
+
17
+ /**
18
+ * 输入:任意值 + 默认值。
19
+ * 输出:布尔;缺省返回 fallback,类型不对返回 undefined。
20
+ * 预期:服务端设置对错误类型严格拒绝;TUI 迁移用宽松版本。
21
+ */
22
+ export function readStrictBoolean(value: unknown, fallback: boolean): boolean | undefined {
23
+ if (value === undefined) return fallback
24
+ return typeof value === "boolean" ? value : undefined
25
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * OpenCode 消息 part 的兼容层。
3
+ *
4
+ * OpenCode v2 的工具 part 是 `type: "tool"`,输入/输出在 `state.input` /
5
+ * `state.content`;旧测试和部分 provider 仍用 `tool-call` / `tool-result`。
6
+ * 这里只做形状归一,避免 tokens / transcript / select 各自漏一种形状。
7
+ */
8
+
9
+ /** 工具 part 的 state;只保留计数和转录需要的字段。 */
10
+ export interface ToolStateLike {
11
+ readonly input?: unknown
12
+ readonly content?: unknown
13
+ readonly output?: unknown
14
+ }
15
+
16
+ /** 工具 part 的最小形状。 */
17
+ export interface ToolPartLike {
18
+ readonly name?: string | undefined
19
+ readonly input?: unknown
20
+ readonly result?: unknown
21
+ readonly state?: ToolStateLike | undefined
22
+ }
23
+
24
+ /** 输入:任意值;输出:可计数文本;不可序列化时返回空串。 */
25
+ export function stringify(value: unknown): string {
26
+ if (typeof value === "string") return value
27
+ if (value === undefined) return ""
28
+ try {
29
+ const encoded: unknown = JSON.stringify(value)
30
+ return typeof encoded === "string" ? encoded : ""
31
+ } catch {
32
+ return ""
33
+ }
34
+ }
35
+
36
+ /** 输入:工具 part;输出:工具调用参数。 */
37
+ export function toolInput(part: ToolPartLike): unknown {
38
+ return part.input ?? part.state?.input
39
+ }
40
+
41
+ /** 输入:工具 part;输出:工具结果。 */
42
+ export function toolResult(part: ToolPartLike): unknown {
43
+ return part.result ?? part.state?.content ?? part.state?.output
44
+ }
45
+
46
+ /**
47
+ * 输入:工具结果(v2 state.content 数组、字符串、对象或旧 result.value)。
48
+ * 输出:可读文本;数组只拼 text 项,其余项退回 JSON。
49
+ * 预期:不丢工具输出,同时避免把媒体 base64 当普通文本展开。
50
+ */
51
+ export function toolResultText(value: unknown): string {
52
+ if (typeof value === "string") return value
53
+ if (Array.isArray(value)) {
54
+ return value
55
+ .map((item) => {
56
+ if (typeof item === "object" && item !== null) {
57
+ const record = item as { readonly type?: unknown; readonly text?: unknown }
58
+ if (record.type === "text" && typeof record.text === "string") return record.text
59
+ }
60
+ return stringify(item)
61
+ })
62
+ .filter((text) => text.length > 0)
63
+ .join("\n")
64
+ }
65
+ if (typeof value === "object" && value !== null) {
66
+ const record = value as { readonly content?: unknown; readonly output?: unknown; readonly value?: unknown }
67
+ if (record.content !== undefined) return toolResultText(record.content)
68
+ if (record.output !== undefined) return toolResultText(record.output)
69
+ if (record.value !== undefined) return toolResultText(record.value)
70
+ }
71
+ return stringify(value)
72
+ }
@@ -0,0 +1,145 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ import type { SummaryBlock } from "./blocks.ts"
4
+ import type { CacheSample } from "./cache.ts"
5
+ import type { CompressionSettings } from "./settings.ts"
6
+
7
+ const SettingsGetInputJson = {
8
+ type: "object",
9
+ properties: { sessionID: { type: "string" } },
10
+ additionalProperties: false,
11
+ } as const
12
+
13
+ const SettingsProperties = {
14
+ contextLimit: {
15
+ anyOf: [{ type: "string", enum: ["auto"] }, { type: "number" }],
16
+ },
17
+ triggerPercent: { type: "number" },
18
+ nudgeGrowthTokens: { type: "number" },
19
+ summaryBlockPercent: { type: "number" },
20
+ summaryTotalPercent: { type: "number" },
21
+ summaryLanguage: { type: "string", enum: ["auto", "zh", "en"] },
22
+ summaryFormat: { type: "string", enum: ["json", "markdown", "babel"] },
23
+ autoCompress: { type: "boolean" },
24
+ enableCompressTool: { type: "boolean" },
25
+ enableDecompressTool: { type: "boolean" },
26
+ showTimeline: { type: "boolean" },
27
+ showBlockTokens: { type: "boolean" },
28
+ decompressInlineMax: { type: "number" },
29
+ decompressTempCap: { type: "number" },
30
+ decompressShowTokens: { type: "boolean" },
31
+ } as const
32
+
33
+ const SettingsInputJson = {
34
+ type: "object",
35
+ properties: SettingsProperties,
36
+ required: ["contextLimit", "triggerPercent"],
37
+ additionalProperties: false,
38
+ } as const
39
+
40
+ /** 读取结果比写入多一个 resolvedWindow:auto 时从模型原生窗口解析出来的值。 */
41
+ const SettingsOutputJson = {
42
+ type: "object",
43
+ properties: {
44
+ ...SettingsProperties,
45
+ resolvedWindow: { type: "number" },
46
+ },
47
+ required: ["contextLimit", "triggerPercent"],
48
+ additionalProperties: false,
49
+ } as const
50
+
51
+ const OkJson = {
52
+ type: "object",
53
+ properties: { ok: { type: "boolean" } },
54
+ required: ["ok"],
55
+ additionalProperties: false,
56
+ } as const
57
+
58
+ /** 需要明确指定会话的只读查询。 */
59
+ const SessionInputJson = {
60
+ type: "object",
61
+ properties: { sessionID: { type: "string" } },
62
+ required: ["sessionID"],
63
+ additionalProperties: false,
64
+ } as const
65
+
66
+ /** 摘要块:服务端返回 active 链,body 是渲染好的 markdown。 */
67
+ const BlockJson = {
68
+ type: "object",
69
+ properties: {
70
+ id: { type: "string" },
71
+ tier: { type: "number", enum: [1, 2, 3] },
72
+ format: { type: "string", enum: ["json", "markdown", "babel"] },
73
+ title: { type: "string" },
74
+ at: { type: "number" },
75
+ tokensBefore: { type: "number" },
76
+ tokensAfter: { type: "number" },
77
+ sourceBlockIds: { type: "array", items: { type: "string" } },
78
+ body: { type: "string" },
79
+ },
80
+ required: ["id", "tier", "format", "title", "at", "tokensBefore", "tokensAfter", "body"],
81
+ additionalProperties: false,
82
+ } as const
83
+
84
+ const BlocksOutputJson = {
85
+ type: "object",
86
+ properties: { blocks: { type: "array", items: BlockJson } },
87
+ required: ["blocks"],
88
+ additionalProperties: false,
89
+ } as const
90
+
91
+ /** 一条缓存命中样本。 */
92
+ const CacheSampleJson = {
93
+ type: "object",
94
+ properties: {
95
+ messageID: { type: "string" },
96
+ at: { type: "number" },
97
+ input: { type: "number" },
98
+ output: { type: "number" },
99
+ cached: { type: "number" },
100
+ hitRate: { type: "number" },
101
+ },
102
+ required: ["messageID", "at", "input", "output", "cached", "hitRate"],
103
+ additionalProperties: false,
104
+ } as const
105
+
106
+ const CacheHistoryOutputJson = {
107
+ type: "object",
108
+ properties: { samples: { type: "array", items: CacheSampleJson } },
109
+ required: ["samples"],
110
+ additionalProperties: false,
111
+ } as const
112
+
113
+ /** TUI 与服务端共用的设置 / 面板 RPC。 */
114
+ export const ContextRpc: Rpc.PortableDefinition = Rpc.define({
115
+ id: "compressed-context",
116
+ methods: {
117
+ settingsGet: { input: SettingsGetInputJson, output: SettingsOutputJson },
118
+ settingsSet: { input: SettingsInputJson, output: OkJson },
119
+ blocksGet: { input: SessionInputJson, output: BlocksOutputJson },
120
+ cacheHistoryGet: { input: SessionInputJson, output: CacheHistoryOutputJson },
121
+ },
122
+ events: {},
123
+ })
124
+
125
+ /** 服务端设置读取结果。 */
126
+ export interface ContextSettingsResult extends CompressionSettings {
127
+ readonly resolvedWindow?: number
128
+ }
129
+
130
+ /** TUI 侧对 ContextRpc 的类型化视图。 */
131
+ export interface ContextRpcClient {
132
+ settingsGet(input: { readonly sessionID?: string }): Promise<ContextSettingsResult>
133
+ settingsSet(input: CompressionSettings): Promise<{ readonly ok: boolean }>
134
+ blocksGet(input: { readonly sessionID: string }): Promise<{ readonly blocks: readonly SummaryBlock[] }>
135
+ cacheHistoryGet(input: {
136
+ readonly sessionID: string
137
+ }): Promise<{ readonly samples: readonly CacheSample[] }>
138
+ }
139
+
140
+ /** 输入:带 rpc 方法的 TUI client;输出:类型化 RPC 客户端。 */
141
+ export function contextRpc(client: {
142
+ rpc(definition: Rpc.PortableDefinition): unknown
143
+ }): ContextRpcClient {
144
+ return client.rpc(ContextRpc) as ContextRpcClient
145
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 输入:具备 scan 能力的 storage + 前缀 + 每条目的处理函数。
3
+ * 输出:无。
4
+ * 预期:按 next 游标分页遍历全部匹配条目,逐条串行 await,避免一次性把 key 全读进内存。
5
+ */
6
+ export interface ScanStorageLike {
7
+ readonly scan?: (options: { prefix: string; limit: number; after?: string }) => Promise<{
8
+ entries: readonly { key: string; value: unknown }[]
9
+ next?: string
10
+ }>
11
+ }
12
+
13
+ /** 输入:storage + 前缀 + 访问函数 + 每页条数;输出:无;预期:storage 无 scan 时直接返回。 */
14
+ export async function forEachScannedEntry(
15
+ storage: ScanStorageLike,
16
+ prefix: string,
17
+ visit: (entry: { key: string; value: unknown }) => Promise<void>,
18
+ limit = 100,
19
+ ): Promise<void> {
20
+ if (storage.scan === undefined) return
21
+ let after: string | undefined
22
+ for (;;) {
23
+ const page = await storage.scan({
24
+ prefix,
25
+ limit,
26
+ ...(after === undefined ? {} : { after }),
27
+ })
28
+ for (const entry of page.entries) await visit(entry)
29
+ if (page.next === undefined) return
30
+ after = page.next
31
+ }
32
+ }