compressed-context 0.0.0-stage → 0.1.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/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +64 -6
  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,123 @@
1
+ import { stringify, toolInput, toolResult, toolResultText } from "../shared/parts.ts"
2
+
3
+ /** 媒体 source 的最小结构;bytes/base64 可落盘,url/ref 只保留引用。 */
4
+ export interface MediaSourceLike {
5
+ readonly type: string
6
+ readonly data?: string | Uint8Array | undefined
7
+ readonly mediaType?: string | undefined
8
+ readonly url?: string | undefined
9
+ readonly provider?: string | undefined
10
+ readonly id?: string | undefined
11
+ readonly expiresAt?: number | undefined
12
+ }
13
+
14
+ /** 单条内容 part 的最小结构,避免绑定 @opencode/ai 的具体类。 */
15
+ export interface ContentPartLike {
16
+ readonly type: string
17
+ readonly text?: string | null | undefined
18
+ readonly name?: string | undefined
19
+ readonly input?: unknown
20
+ readonly result?: unknown
21
+ readonly state?:
22
+ | {
23
+ readonly input?: unknown
24
+ readonly content?: unknown
25
+ readonly output?: unknown
26
+ }
27
+ | undefined
28
+ readonly filename?: string | undefined
29
+ readonly media?:
30
+ | {
31
+ readonly source?: MediaSourceLike | undefined
32
+ readonly mediaType?: string | undefined
33
+ readonly info?:
34
+ | {
35
+ readonly width?: number | undefined
36
+ readonly height?: number | undefined
37
+ readonly durationSeconds?: number | undefined
38
+ }
39
+ | undefined
40
+ readonly providerMetadata?: unknown
41
+ }
42
+ | undefined
43
+ }
44
+
45
+ /** 单条消息的最小结构。 */
46
+ export interface MessageLike {
47
+ readonly id?: string | undefined
48
+ readonly role: string
49
+ readonly content: readonly ContentPartLike[]
50
+ /** v2 用户消息把正文放在 text,而不是 content 数组。 */
51
+ readonly text?: string | null | undefined
52
+ }
53
+
54
+ export const CHECKPOINT_PREFIX = "<conversation-checkpoint>"
55
+
56
+ /** 输入:消息;输出:纯文本(只拼 text part)。 */
57
+ export function messageText(message: MessageLike): string {
58
+ const direct = typeof message.text === "string" ? message.text : ""
59
+ const content: readonly ContentPartLike[] = Array.isArray(message.content)
60
+ ? message.content
61
+ : []
62
+ const parts = content
63
+ .filter((part) => part.type === "text")
64
+ .map((part) => part.text ?? "")
65
+ .join("\n")
66
+ return [direct, parts].filter((text) => text.length > 0).join("\n")
67
+ }
68
+
69
+ /** 输入:消息;输出:是否是 checkpoint 消息。 */
70
+ export function isCheckpoint(message: MessageLike): boolean {
71
+ return messageText(message).trimStart().startsWith(CHECKPOINT_PREFIX)
72
+ }
73
+
74
+ /**
75
+ * 输入:当前请求的消息列表。
76
+ * 输出:可压缩的消息(最后一条 user 之前,排除 checkpoint)。
77
+ * 预期:没有 user 消息时返回空数组。
78
+ */
79
+ export function eligibleMessages(messages: readonly MessageLike[]): readonly MessageLike[] {
80
+ const lastUser = messages.findLastIndex((message) => message.role === "user")
81
+ if (lastUser <= 0) return []
82
+ return messages.slice(0, lastUser).filter((message) => !isCheckpoint(message))
83
+ }
84
+
85
+ /**
86
+ * 输入:compaction 钩子的 older 消息。
87
+ * 输出:可压缩消息(排除 checkpoint)。
88
+ * 预期:older 里没有“当前 user”可排除,不能复用 eligibleMessages。
89
+ */
90
+ export function olderMessages(messages: readonly MessageLike[]): readonly MessageLike[] {
91
+ return messages.filter((message) => !isCheckpoint(message))
92
+ }
93
+
94
+ /** 输入:消息列表;输出:首尾带 id 的消息 id;没有可用 id 时返回 undefined。 */
95
+ export function messageRange(
96
+ messages: readonly MessageLike[],
97
+ ): { readonly fromId: string; readonly toId: string } | undefined {
98
+ const first = messages.find((message) => message.id !== undefined)
99
+ const last = [...messages].reverse().find((message) => message.id !== undefined)
100
+ if (first?.id === undefined || last?.id === undefined) return undefined
101
+ return { fromId: first.id, toId: last.id }
102
+ }
103
+
104
+ /** 输入:消息列表;输出:所有 text part 的总字符数。 */
105
+ export function textChars(messages: readonly MessageLike[]): number {
106
+ let total = 0
107
+ for (const message of messages) {
108
+ if (typeof message.text === "string") total += message.text.length
109
+ const content: readonly ContentPartLike[] = Array.isArray(message.content)
110
+ ? message.content
111
+ : []
112
+ for (const part of content) {
113
+ if (part.type === "text") {
114
+ total += (part.text ?? "").length
115
+ continue
116
+ }
117
+ if (part.type === "tool" || part.type === "tool-call" || part.type === "tool-result") {
118
+ total += `${stringify(toolInput(part))}${toolResultText(toolResult(part))}`.length
119
+ }
120
+ }
121
+ }
122
+ return total
123
+ }
@@ -0,0 +1,112 @@
1
+ import type { ContentPartLike, MediaSourceLike, MessageLike } from "./select.ts"
2
+
3
+ /** 输入:任意值;输出:对象记录或 undefined。 */
4
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
5
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
6
+ }
7
+
8
+ /** 输入:user.files 项;输出:media part;形状不合法时 undefined。 */
9
+ function userFileToPart(value: unknown): ContentPartLike | undefined {
10
+ const file = asRecord(value)
11
+ if (file === undefined || typeof file["mime"] !== "string") return undefined
12
+ const source = asRecord(file["source"])
13
+ const data = typeof file["data"] === "string" ? file["data"] : undefined
14
+ const uri = typeof source?.["uri"] === "string" ? source["uri"] : undefined
15
+ const sourceKind = typeof source?.["type"] === "string" ? source["type"] : "inline"
16
+ const media: MediaSourceLike =
17
+ sourceKind === "uri" && uri !== undefined
18
+ ? { type: "url", url: uri, mediaType: file["mime"] }
19
+ : { type: "base64", ...(data === undefined ? {} : { data }), mediaType: file["mime"] }
20
+ return {
21
+ type: "media",
22
+ ...(typeof file["name"] === "string" ? { filename: file["name"] } : {}),
23
+ media: { source: media },
24
+ }
25
+ }
26
+
27
+ /** 输入:assistant.content 项;输出:可读 parts;reasoning 跳过。 */
28
+ function assistantPartToParts(value: unknown): readonly ContentPartLike[] {
29
+ const part = asRecord(value)
30
+ if (part === undefined) return []
31
+ if (part["type"] === "text" && typeof part["text"] === "string") {
32
+ return [{ type: "text", text: part["text"] }]
33
+ }
34
+ if (part["type"] === "tool" && typeof part["name"] === "string") {
35
+ const state = asRecord(part["state"])
36
+ const parts: ContentPartLike[] = [
37
+ {
38
+ type: "tool-call",
39
+ name: part["name"],
40
+ input: state?.["input"],
41
+ },
42
+ ]
43
+ if (state?.["status"] === "completed") {
44
+ parts.push({
45
+ type: "tool-result",
46
+ name: part["name"],
47
+ result: state["content"] ?? state["input"],
48
+ })
49
+ }
50
+ return parts
51
+ }
52
+ if (part["type"] === "media") {
53
+ const media = asRecord(part["media"])
54
+ const source = asRecord(media?.["source"])
55
+ if (source === undefined || typeof source["type"] !== "string") return []
56
+ return [
57
+ {
58
+ type: "media",
59
+ ...(typeof part["filename"] === "string" ? { filename: part["filename"] } : {}),
60
+ media: {
61
+ source: {
62
+ type: source["type"],
63
+ ...(typeof source["data"] === "string" ? { data: source["data"] } : {}),
64
+ ...(source["data"] instanceof Uint8Array ? { data: source["data"] } : {}),
65
+ ...(typeof source["mediaType"] === "string" ? { mediaType: source["mediaType"] } : {}),
66
+ ...(typeof source["url"] === "string" ? { url: source["url"] } : {}),
67
+ ...(typeof source["provider"] === "string" ? { provider: source["provider"] } : {}),
68
+ ...(typeof source["id"] === "string" ? { id: source["id"] } : {}),
69
+ ...(typeof source["expiresAt"] === "number" ? { expiresAt: source["expiresAt"] } : {}),
70
+ },
71
+ },
72
+ },
73
+ ]
74
+ }
75
+ return []
76
+ }
77
+
78
+ /**
79
+ * 输入:ctx.session.context 返回的持久化 session 消息。
80
+ * 输出:MessageLike 列表;user.text 转 text,user.files 转 media,assistant.content 转 parts。
81
+ * 预期:agent/model/compaction 等非对话项跳过;reasoning 不进入压缩 transcript。
82
+ */
83
+ export function sessionMessagesToMessageLikes(messages: readonly unknown[]): readonly MessageLike[] {
84
+ const out: MessageLike[] = []
85
+ for (const item of messages) {
86
+ const record = asRecord(item)
87
+ if (record === undefined) continue
88
+ const id = typeof record["id"] === "string" ? record["id"] : undefined
89
+ if (record["type"] === "user") {
90
+ const content: ContentPartLike[] = []
91
+ if (typeof record["text"] === "string" && record["text"].length > 0) {
92
+ content.push({ type: "text", text: record["text"] })
93
+ }
94
+ if (Array.isArray(record["files"])) {
95
+ for (const file of record["files"]) {
96
+ const part = userFileToPart(file)
97
+ if (part !== undefined) content.push(part)
98
+ }
99
+ }
100
+ out.push({ ...(id === undefined ? {} : { id }), role: "user", content })
101
+ continue
102
+ }
103
+ if (record["type"] === "assistant" && Array.isArray(record["content"])) {
104
+ const content: ContentPartLike[] = []
105
+ for (const part of record["content"]) {
106
+ content.push(...assistantPartToParts(part))
107
+ }
108
+ out.push({ ...(id === undefined ? {} : { id }), role: "assistant", content })
109
+ }
110
+ }
111
+ return out
112
+ }
@@ -0,0 +1,111 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import type { SummaryBlock } from "../shared/blocks.ts"
4
+ import { ContextRpc } from "../shared/rpc.ts"
5
+ import { loadCacheHistory, recordCacheHistory } from "./cache-history.ts"
6
+ import { activeBlocks, renderSummary } from "./render.ts"
7
+ import { isBabelSummary, isMarkdownSummary } from "./schema.ts"
8
+ import { loadState } from "./state.ts"
9
+ import {
10
+ RESOLVED_WINDOW_KEY,
11
+ loadServerSettings,
12
+ parseServerSettings,
13
+ saveServerSettings,
14
+ windowKey,
15
+ } from "./settings.ts"
16
+
17
+ /** 注册句柄的最小结构。 */
18
+ export interface Disposable {
19
+ dispose(): Promise<void>
20
+ }
21
+
22
+ /**
23
+ * 输入:服务端插件上下文。
24
+ * 输出:注册 settingsGet / settingsSet / blocksGet / cacheHistoryGet,并返回注销句柄。
25
+ * 预期:TUI 推来的设置存进 ctx.storage;非法值返回 ok:false;读取时带上最近解析到的原生窗口;
26
+ * blocksGet 只返回 active 链,cacheHistoryGet 读取前先补写当前上下文。
27
+ */
28
+ export async function registerSettingsRpc(ctx: Plugin.Context): Promise<Disposable> {
29
+ return ctx.rpc.register(ContextRpc, {
30
+ settingsGet: async (input) => {
31
+ const settings = await loadServerSettings(ctx.storage)
32
+ const requested = (input as { readonly sessionID?: unknown }).sessionID
33
+ const sessionID = typeof requested === "string" && requested.length > 0 ? requested : undefined
34
+ // 优先返回当前会话模型自己的窗口;拿不到再退回最近一次解析值。
35
+ let resolvedWindow: number | undefined
36
+ if (sessionID !== undefined) {
37
+ try {
38
+ const session = await ctx.session.get({ sessionID })
39
+ const model = session.model
40
+ if (model !== undefined) {
41
+ const stored = await ctx.storage.get(windowKey(model.providerID, model.id))
42
+ if (typeof stored === "number" && stored > 0) resolvedWindow = stored
43
+ }
44
+ } catch {
45
+ // 会话不存在或模型未知:回退到最近一次解析值
46
+ }
47
+ }
48
+ if (resolvedWindow === undefined) {
49
+ const stored = await ctx.storage.get(RESOLVED_WINDOW_KEY)
50
+ if (typeof stored === "number" && stored > 0) resolvedWindow = stored
51
+ }
52
+ return {
53
+ contextLimit: settings.contextLimit,
54
+ triggerPercent: settings.triggerPercent,
55
+ nudgeGrowthTokens: settings.nudgeGrowthTokens,
56
+ summaryBlockPercent: settings.summaryBlockPercent,
57
+ summaryTotalPercent: settings.summaryTotalPercent,
58
+ summaryLanguage: settings.summaryLanguage,
59
+ summaryFormat: settings.summaryFormat,
60
+ autoCompress: settings.autoCompress,
61
+ enableCompressTool: settings.enableCompressTool,
62
+ enableDecompressTool: settings.enableDecompressTool,
63
+ showTimeline: settings.showTimeline,
64
+ showBlockTokens: settings.showBlockTokens,
65
+ decompressInlineMax: settings.decompressInlineMax,
66
+ decompressTempCap: settings.decompressTempCap,
67
+ decompressShowTokens: settings.decompressShowTokens,
68
+ ...(resolvedWindow === undefined ? {} : { resolvedWindow }),
69
+ }
70
+ },
71
+ settingsSet: async (input) => {
72
+ const current = await loadServerSettings(ctx.storage)
73
+ const parsed = parseServerSettings({ ...current, ...(input as Record<string, unknown>) })
74
+ if (!parsed) return { ok: false }
75
+ await saveServerSettings(ctx.storage, parsed)
76
+ return { ok: true }
77
+ },
78
+ blocksGet: async (input) => {
79
+ const sessionID = (input as { readonly sessionID: string }).sessionID
80
+ const state = await loadState(ctx.storage, sessionID)
81
+ // 只返回模型实际看到的 active 链;被合并掉的源块不展示。
82
+ const blocks: SummaryBlock[] = activeBlocks(state.blocks, state.pending).map((block) => ({
83
+ id: block.id,
84
+ tier: block.tier,
85
+ format: isBabelSummary(block.summary)
86
+ ? "babel"
87
+ : isMarkdownSummary(block.summary)
88
+ ? "markdown"
89
+ : "json",
90
+ title: block.summary.title,
91
+ at: block.at,
92
+ tokensBefore: block.tokensBefore,
93
+ tokensAfter: block.tokensAfter,
94
+ ...(block.sourceBlockIds === undefined ? {} : { sourceBlockIds: [...block.sourceBlockIds] }),
95
+ body: renderSummary(block.summary),
96
+ }))
97
+ return { blocks }
98
+ },
99
+ cacheHistoryGet: async (input) => {
100
+ const sessionID = (input as { readonly sessionID: string }).sessionID
101
+ const history = await loadCacheHistory(ctx.storage, sessionID)
102
+ // 只在历史为空时补写一次;已有历史时只读,避免每次 TUI 刷新都拉全文。
103
+ if (history.samples.length === 0) {
104
+ await recordCacheHistory(ctx, sessionID)
105
+ const bootstrapped = await loadCacheHistory(ctx.storage, sessionID)
106
+ return { samples: bootstrapped.samples }
107
+ }
108
+ return { samples: history.samples }
109
+ },
110
+ })
111
+ }
@@ -0,0 +1,175 @@
1
+ import type { Schema } from "effect"
2
+
3
+ import {
4
+ DEFAULT_AUTO_COMPRESS,
5
+ DEFAULT_DECOMPRESS_INLINE_MAX,
6
+ DEFAULT_DECOMPRESS_SHOW_TOKENS,
7
+ DEFAULT_DECOMPRESS_TEMP_CAP,
8
+ DEFAULT_ENABLE_COMPRESS_TOOL,
9
+ DEFAULT_ENABLE_DECOMPRESS_TOOL,
10
+ DEFAULT_NUDGE_GROWTH_TOKENS,
11
+ DEFAULT_SHOW_BLOCK_TOKENS,
12
+ DEFAULT_SHOW_TIMELINE,
13
+ DEFAULT_SUMMARY_BLOCK_PERCENT,
14
+ DEFAULT_SUMMARY_FORMAT,
15
+ DEFAULT_SUMMARY_TOTAL_PERCENT,
16
+ MAX_DECOMPRESS_INLINE_MAX,
17
+ MAX_DECOMPRESS_TEMP_CAP,
18
+ MAX_CONTEXT_LIMIT,
19
+ MAX_SUMMARY_PERCENT,
20
+ MAX_TRIGGER_PERCENT,
21
+ MIN_CONTEXT_LIMIT,
22
+ MIN_DECOMPRESS_INLINE_MAX,
23
+ MIN_DECOMPRESS_TEMP_CAP,
24
+ MIN_SUMMARY_PERCENT,
25
+ MIN_TRIGGER_PERCENT,
26
+ isSummaryLanguage,
27
+ isSummaryFormat,
28
+ type CompressionSettings,
29
+ } from "../shared/settings.ts"
30
+ import { readOptionalInt, readStrictBoolean } from "../shared/parse.ts"
31
+ import type { StorageLike } from "./state.ts"
32
+
33
+ /** 服务端从 TUI 收到的设置;字段与 shared 的 CompressionSettings 一致。 */
34
+ export type ServerSettings = CompressionSettings
35
+
36
+ export const SETTINGS_KEY = "settings"
37
+ /** 插件最近一次从模型解析到的原生窗口;TUI 用它把绝对值换算成百分比。 */
38
+ export const RESOLVED_WINDOW_KEY = "window:last"
39
+
40
+ /** 最近一次解析到的「生效窗口」= min(settings.contextLimit, 原生窗口);自动压缩用它折算阈值。 */
41
+ export const EFFECTIVE_WINDOW_KEY = "window:effective"
42
+
43
+ /** 输入:模型标识;输出:该模型原生窗口在 storage 里的 key。 */
44
+ export function windowKey(providerID: string, modelID: string): string {
45
+ return `window:${providerID}/${modelID}`
46
+ }
47
+
48
+ export const DEFAULT_SERVER_SETTINGS: ServerSettings = {
49
+ contextLimit: "auto",
50
+ triggerPercent: 80,
51
+ nudgeGrowthTokens: DEFAULT_NUDGE_GROWTH_TOKENS,
52
+ summaryBlockPercent: DEFAULT_SUMMARY_BLOCK_PERCENT,
53
+ summaryTotalPercent: DEFAULT_SUMMARY_TOTAL_PERCENT,
54
+ summaryLanguage: "auto",
55
+ summaryFormat: DEFAULT_SUMMARY_FORMAT,
56
+ autoCompress: DEFAULT_AUTO_COMPRESS,
57
+ enableCompressTool: DEFAULT_ENABLE_COMPRESS_TOOL,
58
+ enableDecompressTool: DEFAULT_ENABLE_DECOMPRESS_TOOL,
59
+ showTimeline: DEFAULT_SHOW_TIMELINE,
60
+ showBlockTokens: DEFAULT_SHOW_BLOCK_TOKENS,
61
+ decompressInlineMax: DEFAULT_DECOMPRESS_INLINE_MAX,
62
+ decompressTempCap: DEFAULT_DECOMPRESS_TEMP_CAP,
63
+ decompressShowTokens: DEFAULT_DECOMPRESS_SHOW_TOKENS,
64
+ }
65
+
66
+ /**
67
+ * 输入:storage 里的原始值。
68
+ * 输出:合法设置;非法返回 undefined。
69
+ * 预期:范围外的值一律拒绝,不静默修正。
70
+ */
71
+ export function parseServerSettings(value: unknown): ServerSettings | undefined {
72
+ if (typeof value !== "object" || value === null) return undefined
73
+ const record = value as Record<string, unknown>
74
+ const limit = record["contextLimit"]
75
+ const percent = record["triggerPercent"]
76
+ const nudge = record["nudgeGrowthTokens"] ?? DEFAULT_NUDGE_GROWTH_TOKENS
77
+ const blockPercent = record["summaryBlockPercent"] ?? DEFAULT_SUMMARY_BLOCK_PERCENT
78
+ const totalPercent = record["summaryTotalPercent"] ?? DEFAULT_SUMMARY_TOTAL_PERCENT
79
+ const language = record["summaryLanguage"] ?? "auto"
80
+ const summaryFormat = record["summaryFormat"] ?? DEFAULT_SUMMARY_FORMAT
81
+ if (
82
+ limit !== "auto" &&
83
+ (typeof limit !== "number" ||
84
+ !Number.isFinite(limit) ||
85
+ limit < MIN_CONTEXT_LIMIT ||
86
+ limit > MAX_CONTEXT_LIMIT)
87
+ ) {
88
+ return undefined
89
+ }
90
+ if (
91
+ typeof percent !== "number" ||
92
+ !Number.isFinite(percent) ||
93
+ percent < MIN_TRIGGER_PERCENT ||
94
+ percent > MAX_TRIGGER_PERCENT
95
+ ) {
96
+ return undefined
97
+ }
98
+ if (
99
+ typeof nudge !== "number" ||
100
+ !Number.isFinite(nudge) ||
101
+ (nudge !== 0 && (nudge < 1_000 || nudge > 200_000))
102
+ ) {
103
+ return undefined
104
+ }
105
+ if (
106
+ typeof blockPercent !== "number" ||
107
+ !Number.isFinite(blockPercent) ||
108
+ blockPercent < MIN_SUMMARY_PERCENT ||
109
+ blockPercent > MAX_SUMMARY_PERCENT
110
+ ) {
111
+ return undefined
112
+ }
113
+ if (
114
+ typeof totalPercent !== "number" ||
115
+ !Number.isFinite(totalPercent) ||
116
+ totalPercent < MIN_SUMMARY_PERCENT ||
117
+ totalPercent > MAX_SUMMARY_PERCENT
118
+ ) {
119
+ return undefined
120
+ }
121
+ if (!isSummaryLanguage(language)) return undefined
122
+ if (!isSummaryFormat(summaryFormat)) return undefined
123
+ const autoCompress = readStrictBoolean(record["autoCompress"], DEFAULT_AUTO_COMPRESS)
124
+ const enableCompressTool = readStrictBoolean(record["enableCompressTool"], DEFAULT_ENABLE_COMPRESS_TOOL)
125
+ const enableDecompressTool = readStrictBoolean(record["enableDecompressTool"], DEFAULT_ENABLE_DECOMPRESS_TOOL)
126
+ const showTimeline = readStrictBoolean(record["showTimeline"], DEFAULT_SHOW_TIMELINE)
127
+ const showBlockTokens = readStrictBoolean(record["showBlockTokens"], DEFAULT_SHOW_BLOCK_TOKENS)
128
+ const decompressShowTokens = readStrictBoolean(record["decompressShowTokens"], DEFAULT_DECOMPRESS_SHOW_TOKENS)
129
+ if (
130
+ autoCompress === undefined ||
131
+ enableCompressTool === undefined ||
132
+ enableDecompressTool === undefined ||
133
+ showTimeline === undefined ||
134
+ showBlockTokens === undefined ||
135
+ decompressShowTokens === undefined
136
+ ) {
137
+ return undefined
138
+ }
139
+ const decompressInlineMax =
140
+ record["decompressInlineMax"] === undefined
141
+ ? DEFAULT_DECOMPRESS_INLINE_MAX
142
+ : readOptionalInt(record["decompressInlineMax"], MIN_DECOMPRESS_INLINE_MAX, MAX_DECOMPRESS_INLINE_MAX)
143
+ const decompressTempCap =
144
+ record["decompressTempCap"] === undefined
145
+ ? DEFAULT_DECOMPRESS_TEMP_CAP
146
+ : readOptionalInt(record["decompressTempCap"], MIN_DECOMPRESS_TEMP_CAP, MAX_DECOMPRESS_TEMP_CAP)
147
+ if (decompressInlineMax === undefined || decompressTempCap === undefined) return undefined
148
+ return {
149
+ contextLimit: limit,
150
+ triggerPercent: Math.round(percent),
151
+ nudgeGrowthTokens: Math.round(nudge),
152
+ summaryBlockPercent: Math.round(blockPercent * 10) / 10,
153
+ summaryTotalPercent: Math.round(totalPercent * 10) / 10,
154
+ summaryLanguage: language,
155
+ summaryFormat,
156
+ autoCompress,
157
+ enableCompressTool,
158
+ enableDecompressTool,
159
+ showTimeline,
160
+ showBlockTokens,
161
+ decompressInlineMax,
162
+ decompressTempCap,
163
+ decompressShowTokens,
164
+ }
165
+ }
166
+
167
+ /** 输入:storage;输出:设置(缺失或损坏时用默认值)。 */
168
+ export async function loadServerSettings(storage: StorageLike): Promise<ServerSettings> {
169
+ return parseServerSettings(await storage.get(SETTINGS_KEY)) ?? DEFAULT_SERVER_SETTINGS
170
+ }
171
+
172
+ /** 输入:storage + 设置;输出:无。 */
173
+ export async function saveServerSettings(storage: StorageLike, settings: ServerSettings): Promise<void> {
174
+ await storage.set(SETTINGS_KEY, settings as unknown as Schema.Json)
175
+ }