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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SummaryFormat } from "../shared/settings.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 本模块只放「摘要长什么样」的文案。
|
|
5
|
+
*
|
|
6
|
+
* 压缩提示词(summarize.ts)和 compress 工具描述(tool.ts)都从这里取,
|
|
7
|
+
* 避免两处各写一份、改一处忘另一处。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const JSON_SHAPE = `{
|
|
11
|
+
"title": "optional short label for this block",
|
|
12
|
+
"goal": "required: the objective and any pivots it went through",
|
|
13
|
+
"constraints": ["optional: hard limits and user preferences"],
|
|
14
|
+
"decisions": ["optional: what was decided — why"],
|
|
15
|
+
"progress": {
|
|
16
|
+
"completed": ["optional: work actually finished"],
|
|
17
|
+
"active": ["optional: work in progress, including what the user still expects"],
|
|
18
|
+
"blocked": ["optional: what is blocked — why"]
|
|
19
|
+
},
|
|
20
|
+
"open_items": ["optional: unanswered questions, TODOs, next actions"],
|
|
21
|
+
"files": ["optional: real file paths only, full/path.ext:line — role in the work"],
|
|
22
|
+
"facts": ["optional: exact errors, signatures, versions, config keys, thresholds, measured values"]
|
|
23
|
+
}`
|
|
24
|
+
|
|
25
|
+
const MARKDOWN_SHAPE = `# Short title
|
|
26
|
+
## Goal
|
|
27
|
+
...
|
|
28
|
+
## Constraints
|
|
29
|
+
- ...
|
|
30
|
+
## Decisions
|
|
31
|
+
- ...
|
|
32
|
+
## Progress
|
|
33
|
+
### Completed
|
|
34
|
+
- ...
|
|
35
|
+
### Active
|
|
36
|
+
- ...
|
|
37
|
+
### Blocked
|
|
38
|
+
- ...
|
|
39
|
+
## Open Items
|
|
40
|
+
- ...
|
|
41
|
+
## Files
|
|
42
|
+
- ...
|
|
43
|
+
## Facts
|
|
44
|
+
- ...`
|
|
45
|
+
|
|
46
|
+
/** 输入:格式 + 是否要 candidate 行;输出:格式说明行。 */
|
|
47
|
+
export function shapeLines(format: SummaryFormat | undefined, withCandidate: boolean): string[] {
|
|
48
|
+
if (format === "babel") {
|
|
49
|
+
return [
|
|
50
|
+
withCandidate
|
|
51
|
+
? "Return ONLY Babel text. First line must be `c=N`, where N is the chosen candidate; second line must be `T=<short title>|G=<one-line goal>`."
|
|
52
|
+
: "Return ONLY Babel text. First line must be `T=<short title>|G=<one-line goal>`.",
|
|
53
|
+
"After the header, use adaptive Babel: only T/G is fixed; invent the shortest anchors/delimiters for this source. No fixed key schema, no Markdown headings, no JSON, no code fences, no commentary.",
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
if (format === "markdown") {
|
|
57
|
+
return [
|
|
58
|
+
withCandidate
|
|
59
|
+
? "Return ONLY markdown. First line must be `candidate: N`, then a self-contained markdown checkpoint."
|
|
60
|
+
: "Return ONLY markdown. Do not return JSON. Do not wrap in code fences.",
|
|
61
|
+
MARKDOWN_SHAPE,
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
withCandidate
|
|
66
|
+
? "Return ONLY a JSON object with the normal summary shape PLUS a top-level `candidate` number:"
|
|
67
|
+
: "Return ONLY a JSON object with this exact shape (no markdown fences, no commentary):",
|
|
68
|
+
JSON_SHAPE,
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 输入:摘要格式;输出:该格式的写法说明,供压缩提示词和工具描述共用。 */
|
|
73
|
+
export function summaryShape(format: SummaryFormat): string {
|
|
74
|
+
return shapeLines(format, false).join("\n")
|
|
75
|
+
}
|
package/src/compression/state.ts
CHANGED
|
@@ -1,248 +1,248 @@
|
|
|
1
|
-
import type { Schema } from "effect"
|
|
2
|
-
|
|
3
|
-
import { toBabelSummary } from "./babel.ts"
|
|
4
|
-
import { isSessionDeleted } from "./deleted-sessions.ts"
|
|
5
|
-
import { type Summary, type SummaryFields } from "./schema.ts"
|
|
6
|
-
|
|
7
|
-
/** 压缩层级:1 = 原文摘要,2/3 = 摘要再蒸馏。 */
|
|
8
|
-
export type Tier = 1 | 2 | 3
|
|
9
|
-
|
|
10
|
-
/** 下一次 compaction 的目的;缺省表示 T1。 */
|
|
11
|
-
export type CompactionPhase = "t2" | "t3"
|
|
12
|
-
|
|
13
|
-
/** 一个已落盘或待落盘的压缩块。 */
|
|
14
|
-
export interface Block {
|
|
15
|
-
readonly id: string
|
|
16
|
-
readonly tier: Tier
|
|
17
|
-
readonly summary: Summary
|
|
18
|
-
readonly fromId: string
|
|
19
|
-
readonly toId: string
|
|
20
|
-
readonly tokensBefore: number
|
|
21
|
-
readonly tokensAfter: number
|
|
22
|
-
readonly at: number
|
|
23
|
-
/** active 链中的顺序;合并块占第一个源块的位置。 */
|
|
24
|
-
readonly order: number
|
|
25
|
-
/** 被本块合并的源块;空表示原始 T1 块。 */
|
|
26
|
-
readonly sourceBlockIds?: readonly string[]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 每个会话一份的压缩状态。 */
|
|
30
|
-
export interface State {
|
|
31
|
-
readonly blocks: readonly Block[]
|
|
32
|
-
readonly pending?: Block
|
|
33
|
-
/** 下一次 compaction 是二级压缩;T1 时缺省。 */
|
|
34
|
-
readonly phase?: CompactionPhase
|
|
35
|
-
readonly attempts: number
|
|
36
|
-
readonly lastError?: string
|
|
37
|
-
/** 上一次主动压缩提醒时的上下文 token 数;缺省表示还没建立基线。 */
|
|
38
|
-
readonly nudgeBaselineTokens?: number
|
|
39
|
-
/** 已发出的主动压缩提醒次数。 */
|
|
40
|
-
readonly nudgeCount?: number
|
|
41
|
-
/** 全 T3 且无法继续压缩时的终止原因;存在时停止自动压缩。 */
|
|
42
|
-
readonly terminal?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 服务端 storage 的最小接口,便于测试替身。 */
|
|
46
|
-
export interface StorageLike {
|
|
47
|
-
get(key: string): Promise<Schema.Json | undefined>
|
|
48
|
-
set(key: string, value: Schema.Json): Promise<void>
|
|
49
|
-
remove(key: string): Promise<void>
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const STATE_PREFIX = "compression:state:"
|
|
53
|
-
|
|
54
|
-
/** 连续压缩失败达到这个次数后暂停自动压缩;同一层内还会做摘要重试。 */
|
|
55
|
-
export const MAX_COMPRESSION_ATTEMPTS = 3
|
|
56
|
-
|
|
57
|
-
export const EMPTY_STATE: State = { blocks: [], attempts: 0 }
|
|
58
|
-
|
|
59
|
-
/** 输入:sessionID;输出:storage key。 */
|
|
60
|
-
export function stateKey(sessionID: string): string {
|
|
61
|
-
return STATE_PREFIX + sessionID
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** 输入:块列表;输出:下一个可用的 order。 */
|
|
65
|
-
export function nextOrder(blocks: readonly Block[]): number {
|
|
66
|
-
return blocks.reduce((max, block) => Math.max(max, block.order), 0) + 1
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 输入:已落盘块 + 可选 pending;输出:下一个可用的 bN id。 */
|
|
70
|
-
export function nextBlockId(blocks: readonly Block[], pending?: Block): string {
|
|
71
|
-
let max = 0
|
|
72
|
-
const all = pending === undefined ? blocks : [...blocks, pending]
|
|
73
|
-
for (const block of all) {
|
|
74
|
-
const numeric = Number(block.id.startsWith("b") ? block.id.slice(1) : "")
|
|
75
|
-
if (Number.isInteger(numeric) && numeric > max) max = numeric
|
|
76
|
-
}
|
|
77
|
-
return `b${(max + 1).toString()}`
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isStringArray(value: unknown): value is readonly string[] {
|
|
81
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** 输入:存储里的摘要;输出:JSON 摘要字段;形状不合法时 undefined。 */
|
|
85
|
-
function parseFields(value: unknown): SummaryFields | undefined {
|
|
86
|
-
if (typeof value !== "object" || value === null) return undefined
|
|
87
|
-
const fields = value as Record<string, unknown>
|
|
88
|
-
if (typeof fields["title"] !== "string" || typeof fields["goal"] !== "string") return undefined
|
|
89
|
-
if (!isStringArray(fields["constraints"])) return undefined
|
|
90
|
-
if (!isStringArray(fields["decisions"])) return undefined
|
|
91
|
-
if (!isStringArray(fields["open_items"])) return undefined
|
|
92
|
-
if (!isStringArray(fields["files"])) return undefined
|
|
93
|
-
if (!isStringArray(fields["facts"])) return undefined
|
|
94
|
-
const progress = fields["progress"]
|
|
95
|
-
if (typeof progress !== "object" || progress === null) return undefined
|
|
96
|
-
const p = progress as Record<string, unknown>
|
|
97
|
-
if (!isStringArray(p["completed"]) || !isStringArray(p["active"]) || !isStringArray(p["blocked"])) {
|
|
98
|
-
return undefined
|
|
99
|
-
}
|
|
100
|
-
return {
|
|
101
|
-
title: fields["title"],
|
|
102
|
-
goal: fields["goal"],
|
|
103
|
-
constraints: fields["constraints"],
|
|
104
|
-
decisions: fields["decisions"],
|
|
105
|
-
progress: { completed: p["completed"], active: p["active"], blocked: p["blocked"] },
|
|
106
|
-
open_items: fields["open_items"],
|
|
107
|
-
files: fields["files"],
|
|
108
|
-
facts: fields["facts"],
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** 输入:存储里的摘要;输出:规范摘要;形状不合法时 undefined。 */
|
|
113
|
-
function parseSummary(value: unknown): Summary | undefined {
|
|
114
|
-
if (typeof value !== "object" || value === null) return undefined
|
|
115
|
-
const record = value as Record<string, unknown>
|
|
116
|
-
if (record["format"] === "markdown") {
|
|
117
|
-
if (typeof record["title"] !== "string" || typeof record["text"] !== "string") return undefined
|
|
118
|
-
return { format: "markdown", title: record["title"], text: record["text"] }
|
|
119
|
-
}
|
|
120
|
-
if (record["format"] === "babel") {
|
|
121
|
-
if (typeof record["text"] !== "string") return undefined
|
|
122
|
-
return toBabelSummary(
|
|
123
|
-
record["text"],
|
|
124
|
-
typeof record["title"] === "string" ? record["title"] : undefined,
|
|
125
|
-
typeof record["goal"] === "string" ? record["goal"] : undefined,
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
if ("format" in record) return undefined
|
|
129
|
-
return parseFields(value)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** 输入:存储里的块;输出:规范块;形状不合法时 undefined。 */
|
|
133
|
-
function parseBlock(value: unknown): Block | undefined {
|
|
134
|
-
if (typeof value !== "object" || value === null) return undefined
|
|
135
|
-
const block = value as Record<string, unknown>
|
|
136
|
-
if (typeof block["id"] !== "string") return undefined
|
|
137
|
-
const tier = block["tier"]
|
|
138
|
-
if (tier !== 1 && tier !== 2 && tier !== 3) return undefined
|
|
139
|
-
if (typeof block["fromId"] !== "string" || typeof block["toId"] !== "string") return undefined
|
|
140
|
-
if (typeof block["tokensBefore"] !== "number" || typeof block["tokensAfter"] !== "number") return undefined
|
|
141
|
-
if (typeof block["at"] !== "number" || typeof block["order"] !== "number") return undefined
|
|
142
|
-
const sourceBlockIds = block["sourceBlockIds"]
|
|
143
|
-
if (sourceBlockIds !== undefined && !isStringArray(sourceBlockIds)) return undefined
|
|
144
|
-
const summary = parseSummary(block["summary"])
|
|
145
|
-
if (summary === undefined) return undefined
|
|
146
|
-
return {
|
|
147
|
-
id: block["id"],
|
|
148
|
-
tier,
|
|
149
|
-
summary,
|
|
150
|
-
fromId: block["fromId"],
|
|
151
|
-
toId: block["toId"],
|
|
152
|
-
tokensBefore: block["tokensBefore"],
|
|
153
|
-
tokensAfter: block["tokensAfter"],
|
|
154
|
-
at: block["at"],
|
|
155
|
-
order: block["order"],
|
|
156
|
-
...(sourceBlockIds === undefined ? {} : { sourceBlockIds }),
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* 输入:storage 里的原始值。
|
|
162
|
-
* 输出:合法状态;损坏时返回空状态。
|
|
163
|
-
* 预期:不读版本号、不做迁移;坏块直接丢弃。
|
|
164
|
-
*/
|
|
165
|
-
export function parseState(value: unknown): State {
|
|
166
|
-
if (typeof value !== "object" || value === null) return EMPTY_STATE
|
|
167
|
-
const record = value as Record<string, unknown>
|
|
168
|
-
const blocks = Array.isArray(record["blocks"])
|
|
169
|
-
? record["blocks"].map(parseBlock).filter((block): block is Block => block !== undefined)
|
|
170
|
-
: []
|
|
171
|
-
const pending = parseBlock(record["pending"])
|
|
172
|
-
const attempts = typeof record["attempts"] === "number" ? record["attempts"] : 0
|
|
173
|
-
const lastError = typeof record["lastError"] === "string" ? record["lastError"] : undefined
|
|
174
|
-
const nudgeBaselineTokens =
|
|
175
|
-
typeof record["nudgeBaselineTokens"] === "number" ? record["nudgeBaselineTokens"] : undefined
|
|
176
|
-
const nudgeCount = typeof record["nudgeCount"] === "number" ? record["nudgeCount"] : undefined
|
|
177
|
-
const terminal = typeof record["terminal"] === "string" ? record["terminal"] : undefined
|
|
178
|
-
const rawPhase = record["phase"]
|
|
179
|
-
const phase: CompactionPhase | undefined = rawPhase === "t2" || rawPhase === "t3" ? rawPhase : undefined
|
|
180
|
-
return {
|
|
181
|
-
blocks,
|
|
182
|
-
attempts,
|
|
183
|
-
...(pending === undefined ? {} : { pending }),
|
|
184
|
-
...(lastError === undefined ? {} : { lastError }),
|
|
185
|
-
...(nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens }),
|
|
186
|
-
...(nudgeCount === undefined ? {} : { nudgeCount }),
|
|
187
|
-
...(terminal === undefined ? {} : { terminal }),
|
|
188
|
-
...(phase === undefined ? {} : { phase }),
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** 输入:storage + sessionID;输出:状态。 */
|
|
193
|
-
export async function loadState(storage: StorageLike, sessionID: string): Promise<State> {
|
|
194
|
-
return parseState(await storage.get(stateKey(sessionID)))
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** 输入:storage + sessionID + 状态;输出:无;预期:写入 JSON;已删会话直接跳过。 */
|
|
198
|
-
export async function saveState(storage: StorageLike, sessionID: string, state: State): Promise<void> {
|
|
199
|
-
if (isSessionDeleted(sessionID)) return
|
|
200
|
-
await storage.set(stateKey(sessionID), state as unknown as Schema.Json)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** 输入:状态;输出:pending 转正后的新状态;预期:成功时清 lastError/terminal,保留下一层 phase。 */
|
|
204
|
-
export function commitPending(state: State): State {
|
|
205
|
-
if (!state.pending) return state
|
|
206
|
-
return {
|
|
207
|
-
blocks: [...state.blocks, state.pending],
|
|
208
|
-
attempts: 0,
|
|
209
|
-
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
210
|
-
...(state.phase === undefined ? {} : { phase: state.phase }),
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** 输入:状态 + 错误;输出:丢弃 pending 后的新状态;预期:失败时清 pending/phase,保留 terminal。 */
|
|
215
|
-
export function dropPending(state: State, error: string): State {
|
|
216
|
-
return {
|
|
217
|
-
blocks: state.blocks,
|
|
218
|
-
attempts: state.attempts + 1,
|
|
219
|
-
lastError: error,
|
|
220
|
-
...(state.nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens: state.nudgeBaselineTokens }),
|
|
221
|
-
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
222
|
-
...(state.terminal === undefined ? {} : { terminal: state.terminal }),
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* 输入:失败后的状态 + 错误。
|
|
228
|
-
* 输出:清 pending,但保留 phase 以便下一次请求重试。
|
|
229
|
-
* 预期:attempts 达到上限后清掉 phase,避免无限重试。
|
|
230
|
-
*/
|
|
231
|
-
export function retryPending(state: State, error: string): State {
|
|
232
|
-
const attempts = state.attempts + 1
|
|
233
|
-
const keepPhase = state.phase !== undefined && attempts < MAX_COMPRESSION_ATTEMPTS
|
|
234
|
-
return {
|
|
235
|
-
blocks: state.blocks,
|
|
236
|
-
attempts,
|
|
237
|
-
lastError: error,
|
|
238
|
-
...(state.nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens: state.nudgeBaselineTokens }),
|
|
239
|
-
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
240
|
-
...(keepPhase ? { phase: state.phase } : {}),
|
|
241
|
-
...(state.terminal === undefined ? {} : { terminal: state.terminal }),
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** 输入:状态 + 新块;输出:挂上 pending 的新状态。 */
|
|
246
|
-
export function withPending(state: State, block: Block): State {
|
|
247
|
-
return { ...state, pending: block }
|
|
248
|
-
}
|
|
1
|
+
import type { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
import { toBabelSummary } from "./babel.ts"
|
|
4
|
+
import { isSessionDeleted } from "./deleted-sessions.ts"
|
|
5
|
+
import { type Summary, type SummaryFields } from "./schema.ts"
|
|
6
|
+
|
|
7
|
+
/** 压缩层级:1 = 原文摘要,2/3 = 摘要再蒸馏。 */
|
|
8
|
+
export type Tier = 1 | 2 | 3
|
|
9
|
+
|
|
10
|
+
/** 下一次 compaction 的目的;缺省表示 T1。 */
|
|
11
|
+
export type CompactionPhase = "t2" | "t3"
|
|
12
|
+
|
|
13
|
+
/** 一个已落盘或待落盘的压缩块。 */
|
|
14
|
+
export interface Block {
|
|
15
|
+
readonly id: string
|
|
16
|
+
readonly tier: Tier
|
|
17
|
+
readonly summary: Summary
|
|
18
|
+
readonly fromId: string
|
|
19
|
+
readonly toId: string
|
|
20
|
+
readonly tokensBefore: number
|
|
21
|
+
readonly tokensAfter: number
|
|
22
|
+
readonly at: number
|
|
23
|
+
/** active 链中的顺序;合并块占第一个源块的位置。 */
|
|
24
|
+
readonly order: number
|
|
25
|
+
/** 被本块合并的源块;空表示原始 T1 块。 */
|
|
26
|
+
readonly sourceBlockIds?: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 每个会话一份的压缩状态。 */
|
|
30
|
+
export interface State {
|
|
31
|
+
readonly blocks: readonly Block[]
|
|
32
|
+
readonly pending?: Block
|
|
33
|
+
/** 下一次 compaction 是二级压缩;T1 时缺省。 */
|
|
34
|
+
readonly phase?: CompactionPhase
|
|
35
|
+
readonly attempts: number
|
|
36
|
+
readonly lastError?: string
|
|
37
|
+
/** 上一次主动压缩提醒时的上下文 token 数;缺省表示还没建立基线。 */
|
|
38
|
+
readonly nudgeBaselineTokens?: number
|
|
39
|
+
/** 已发出的主动压缩提醒次数。 */
|
|
40
|
+
readonly nudgeCount?: number
|
|
41
|
+
/** 全 T3 且无法继续压缩时的终止原因;存在时停止自动压缩。 */
|
|
42
|
+
readonly terminal?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 服务端 storage 的最小接口,便于测试替身。 */
|
|
46
|
+
export interface StorageLike {
|
|
47
|
+
get(key: string): Promise<Schema.Json | undefined>
|
|
48
|
+
set(key: string, value: Schema.Json): Promise<void>
|
|
49
|
+
remove(key: string): Promise<void>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const STATE_PREFIX = "compression:state:"
|
|
53
|
+
|
|
54
|
+
/** 连续压缩失败达到这个次数后暂停自动压缩;同一层内还会做摘要重试。 */
|
|
55
|
+
export const MAX_COMPRESSION_ATTEMPTS = 3
|
|
56
|
+
|
|
57
|
+
export const EMPTY_STATE: State = { blocks: [], attempts: 0 }
|
|
58
|
+
|
|
59
|
+
/** 输入:sessionID;输出:storage key。 */
|
|
60
|
+
export function stateKey(sessionID: string): string {
|
|
61
|
+
return STATE_PREFIX + sessionID
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 输入:块列表;输出:下一个可用的 order。 */
|
|
65
|
+
export function nextOrder(blocks: readonly Block[]): number {
|
|
66
|
+
return blocks.reduce((max, block) => Math.max(max, block.order), 0) + 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 输入:已落盘块 + 可选 pending;输出:下一个可用的 bN id。 */
|
|
70
|
+
export function nextBlockId(blocks: readonly Block[], pending?: Block): string {
|
|
71
|
+
let max = 0
|
|
72
|
+
const all = pending === undefined ? blocks : [...blocks, pending]
|
|
73
|
+
for (const block of all) {
|
|
74
|
+
const numeric = Number(block.id.startsWith("b") ? block.id.slice(1) : "")
|
|
75
|
+
if (Number.isInteger(numeric) && numeric > max) max = numeric
|
|
76
|
+
}
|
|
77
|
+
return `b${(max + 1).toString()}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
81
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 输入:存储里的摘要;输出:JSON 摘要字段;形状不合法时 undefined。 */
|
|
85
|
+
function parseFields(value: unknown): SummaryFields | undefined {
|
|
86
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
87
|
+
const fields = value as Record<string, unknown>
|
|
88
|
+
if (typeof fields["title"] !== "string" || typeof fields["goal"] !== "string") return undefined
|
|
89
|
+
if (!isStringArray(fields["constraints"])) return undefined
|
|
90
|
+
if (!isStringArray(fields["decisions"])) return undefined
|
|
91
|
+
if (!isStringArray(fields["open_items"])) return undefined
|
|
92
|
+
if (!isStringArray(fields["files"])) return undefined
|
|
93
|
+
if (!isStringArray(fields["facts"])) return undefined
|
|
94
|
+
const progress = fields["progress"]
|
|
95
|
+
if (typeof progress !== "object" || progress === null) return undefined
|
|
96
|
+
const p = progress as Record<string, unknown>
|
|
97
|
+
if (!isStringArray(p["completed"]) || !isStringArray(p["active"]) || !isStringArray(p["blocked"])) {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
title: fields["title"],
|
|
102
|
+
goal: fields["goal"],
|
|
103
|
+
constraints: fields["constraints"],
|
|
104
|
+
decisions: fields["decisions"],
|
|
105
|
+
progress: { completed: p["completed"], active: p["active"], blocked: p["blocked"] },
|
|
106
|
+
open_items: fields["open_items"],
|
|
107
|
+
files: fields["files"],
|
|
108
|
+
facts: fields["facts"],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 输入:存储里的摘要;输出:规范摘要;形状不合法时 undefined。 */
|
|
113
|
+
function parseSummary(value: unknown): Summary | undefined {
|
|
114
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
115
|
+
const record = value as Record<string, unknown>
|
|
116
|
+
if (record["format"] === "markdown") {
|
|
117
|
+
if (typeof record["title"] !== "string" || typeof record["text"] !== "string") return undefined
|
|
118
|
+
return { format: "markdown", title: record["title"], text: record["text"] }
|
|
119
|
+
}
|
|
120
|
+
if (record["format"] === "babel") {
|
|
121
|
+
if (typeof record["text"] !== "string") return undefined
|
|
122
|
+
return toBabelSummary(
|
|
123
|
+
record["text"],
|
|
124
|
+
typeof record["title"] === "string" ? record["title"] : undefined,
|
|
125
|
+
typeof record["goal"] === "string" ? record["goal"] : undefined,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
if ("format" in record) return undefined
|
|
129
|
+
return parseFields(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 输入:存储里的块;输出:规范块;形状不合法时 undefined。 */
|
|
133
|
+
function parseBlock(value: unknown): Block | undefined {
|
|
134
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
135
|
+
const block = value as Record<string, unknown>
|
|
136
|
+
if (typeof block["id"] !== "string") return undefined
|
|
137
|
+
const tier = block["tier"]
|
|
138
|
+
if (tier !== 1 && tier !== 2 && tier !== 3) return undefined
|
|
139
|
+
if (typeof block["fromId"] !== "string" || typeof block["toId"] !== "string") return undefined
|
|
140
|
+
if (typeof block["tokensBefore"] !== "number" || typeof block["tokensAfter"] !== "number") return undefined
|
|
141
|
+
if (typeof block["at"] !== "number" || typeof block["order"] !== "number") return undefined
|
|
142
|
+
const sourceBlockIds = block["sourceBlockIds"]
|
|
143
|
+
if (sourceBlockIds !== undefined && !isStringArray(sourceBlockIds)) return undefined
|
|
144
|
+
const summary = parseSummary(block["summary"])
|
|
145
|
+
if (summary === undefined) return undefined
|
|
146
|
+
return {
|
|
147
|
+
id: block["id"],
|
|
148
|
+
tier,
|
|
149
|
+
summary,
|
|
150
|
+
fromId: block["fromId"],
|
|
151
|
+
toId: block["toId"],
|
|
152
|
+
tokensBefore: block["tokensBefore"],
|
|
153
|
+
tokensAfter: block["tokensAfter"],
|
|
154
|
+
at: block["at"],
|
|
155
|
+
order: block["order"],
|
|
156
|
+
...(sourceBlockIds === undefined ? {} : { sourceBlockIds }),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 输入:storage 里的原始值。
|
|
162
|
+
* 输出:合法状态;损坏时返回空状态。
|
|
163
|
+
* 预期:不读版本号、不做迁移;坏块直接丢弃。
|
|
164
|
+
*/
|
|
165
|
+
export function parseState(value: unknown): State {
|
|
166
|
+
if (typeof value !== "object" || value === null) return EMPTY_STATE
|
|
167
|
+
const record = value as Record<string, unknown>
|
|
168
|
+
const blocks = Array.isArray(record["blocks"])
|
|
169
|
+
? record["blocks"].map(parseBlock).filter((block): block is Block => block !== undefined)
|
|
170
|
+
: []
|
|
171
|
+
const pending = parseBlock(record["pending"])
|
|
172
|
+
const attempts = typeof record["attempts"] === "number" ? record["attempts"] : 0
|
|
173
|
+
const lastError = typeof record["lastError"] === "string" ? record["lastError"] : undefined
|
|
174
|
+
const nudgeBaselineTokens =
|
|
175
|
+
typeof record["nudgeBaselineTokens"] === "number" ? record["nudgeBaselineTokens"] : undefined
|
|
176
|
+
const nudgeCount = typeof record["nudgeCount"] === "number" ? record["nudgeCount"] : undefined
|
|
177
|
+
const terminal = typeof record["terminal"] === "string" ? record["terminal"] : undefined
|
|
178
|
+
const rawPhase = record["phase"]
|
|
179
|
+
const phase: CompactionPhase | undefined = rawPhase === "t2" || rawPhase === "t3" ? rawPhase : undefined
|
|
180
|
+
return {
|
|
181
|
+
blocks,
|
|
182
|
+
attempts,
|
|
183
|
+
...(pending === undefined ? {} : { pending }),
|
|
184
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
185
|
+
...(nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens }),
|
|
186
|
+
...(nudgeCount === undefined ? {} : { nudgeCount }),
|
|
187
|
+
...(terminal === undefined ? {} : { terminal }),
|
|
188
|
+
...(phase === undefined ? {} : { phase }),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 输入:storage + sessionID;输出:状态。 */
|
|
193
|
+
export async function loadState(storage: StorageLike, sessionID: string): Promise<State> {
|
|
194
|
+
return parseState(await storage.get(stateKey(sessionID)))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 输入:storage + sessionID + 状态;输出:无;预期:写入 JSON;已删会话直接跳过。 */
|
|
198
|
+
export async function saveState(storage: StorageLike, sessionID: string, state: State): Promise<void> {
|
|
199
|
+
if (isSessionDeleted(sessionID)) return
|
|
200
|
+
await storage.set(stateKey(sessionID), state as unknown as Schema.Json)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 输入:状态;输出:pending 转正后的新状态;预期:成功时清 lastError/terminal,保留下一层 phase。 */
|
|
204
|
+
export function commitPending(state: State): State {
|
|
205
|
+
if (!state.pending) return state
|
|
206
|
+
return {
|
|
207
|
+
blocks: [...state.blocks, state.pending],
|
|
208
|
+
attempts: 0,
|
|
209
|
+
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
210
|
+
...(state.phase === undefined ? {} : { phase: state.phase }),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 输入:状态 + 错误;输出:丢弃 pending 后的新状态;预期:失败时清 pending/phase,保留 terminal。 */
|
|
215
|
+
export function dropPending(state: State, error: string): State {
|
|
216
|
+
return {
|
|
217
|
+
blocks: state.blocks,
|
|
218
|
+
attempts: state.attempts + 1,
|
|
219
|
+
lastError: error,
|
|
220
|
+
...(state.nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens: state.nudgeBaselineTokens }),
|
|
221
|
+
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
222
|
+
...(state.terminal === undefined ? {} : { terminal: state.terminal }),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 输入:失败后的状态 + 错误。
|
|
228
|
+
* 输出:清 pending,但保留 phase 以便下一次请求重试。
|
|
229
|
+
* 预期:attempts 达到上限后清掉 phase,避免无限重试。
|
|
230
|
+
*/
|
|
231
|
+
export function retryPending(state: State, error: string): State {
|
|
232
|
+
const attempts = state.attempts + 1
|
|
233
|
+
const keepPhase = state.phase !== undefined && attempts < MAX_COMPRESSION_ATTEMPTS
|
|
234
|
+
return {
|
|
235
|
+
blocks: state.blocks,
|
|
236
|
+
attempts,
|
|
237
|
+
lastError: error,
|
|
238
|
+
...(state.nudgeBaselineTokens === undefined ? {} : { nudgeBaselineTokens: state.nudgeBaselineTokens }),
|
|
239
|
+
...(state.nudgeCount === undefined ? {} : { nudgeCount: state.nudgeCount }),
|
|
240
|
+
...(keepPhase ? { phase: state.phase } : {}),
|
|
241
|
+
...(state.terminal === undefined ? {} : { terminal: state.terminal }),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 输入:状态 + 新块;输出:挂上 pending 的新状态。 */
|
|
246
|
+
export function withPending(state: State, block: Block): State {
|
|
247
|
+
return { ...state, pending: block }
|
|
248
|
+
}
|