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,275 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { cacheHistoryKey } from "../shared/cache.ts"
4
+ import { forEachScannedEntry } from "../shared/scan.ts"
5
+ import { clearActiveSummary } from "./active-summary-cache.ts"
6
+ import { recordCacheHistory } from "./cache-history.ts"
7
+ import { deleteSessionTempFiles } from "./decompress.ts"
8
+ import { markSessionDeleted } from "./deleted-sessions.ts"
9
+ import { blockMarker } from "./render.ts"
10
+ import { deleteTranscriptsForSession } from "./transcript-store.ts"
11
+ import {
12
+ STATE_PREFIX,
13
+ commitPending,
14
+ dropPending,
15
+ loadState,
16
+ parseState,
17
+ retryPending,
18
+ saveState,
19
+ stateKey,
20
+ type State,
21
+ } from "./state.ts"
22
+
23
+ /** 输入:服务端上下文 + sessionID + 块 id;输出:最新 compaction 是否包含该块。 */
24
+ export async function hasBlockInLatestCompaction(
25
+ ctx: Plugin.Context,
26
+ sessionID: string,
27
+ blockId: string,
28
+ ): Promise<boolean> {
29
+ try {
30
+ const messages = await ctx.session.context({ sessionID })
31
+ const latest = messages.find((message) => message.type === "compaction" && message.status === "completed")
32
+ if (latest === undefined) return false
33
+ return latest.summary.includes(blockMarker(blockId))
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /** 输入:状态;输出:pending 对账后的状态。 */
40
+ async function reconcile(ctx: Plugin.Context, sessionID: string, state: State): Promise<State> {
41
+ if (!state.pending) return state
42
+ const found = await hasBlockInLatestCompaction(ctx, sessionID, state.pending.id)
43
+ return found ? commitPending(state) : dropPending(state, "pending compaction not found after restart")
44
+ }
45
+
46
+ export type CompactionEventType = "ended" | "failed"
47
+
48
+ /** 摘要链仍超预算且没有任何合法合并候选时的终止提示。 */
49
+ export const TERMINAL_MESSAGE =
50
+ "compression cannot reduce this context further: start a new session or use native compaction"
51
+
52
+ /**
53
+ * 输入:服务端上下文 + 事件类型 + sessionID + 事件自带错误(仅 failed 有)。
54
+ * 输出:无。
55
+ * 预期:只有最新 checkpoint 包含 pending marker 才转正;失败时保留 phase 让下一次请求重试,
56
+ * 达到 MAX_COMPRESSION_ATTEMPTS 后清除 phase,记录真实原因。
57
+ * 事件 error 优先于 state.lastError——钩子没跑过的失败(如 OpenCode 在钩子前判定
58
+ * 「无可压缩内容」)state 里没有任何错误记录,只能从事件拿真实原因。
59
+ */
60
+ export async function handleCompactionEvent(
61
+ ctx: Plugin.Context,
62
+ type: CompactionEventType,
63
+ sessionID: string,
64
+ eventError?: string,
65
+ ): Promise<void> {
66
+ const state = await loadState(ctx.storage, sessionID)
67
+ if (type === "ended") {
68
+ if (!state.pending) {
69
+ await saveState(ctx.storage, sessionID, { ...state, attempts: 0 })
70
+ return
71
+ }
72
+ // 事件可能晚到:只有最新 checkpoint 确实包含这个 pending 才转正,
73
+ // 否则它属于下一层 compaction,留给下一次 ended。
74
+ if (await hasBlockInLatestCompaction(ctx, sessionID, state.pending.id)) {
75
+ await saveState(ctx.storage, sessionID, commitPending(state))
76
+ }
77
+ return
78
+ }
79
+
80
+ const detail = eventError ?? state.lastError ?? "compaction failed"
81
+ // 钩子从未运行的失败(state 里没有任何本层错误记录)且已排下一层 phase:
82
+ // 典型是历史里只剩 checkpoint,OpenCode 在钩子前就判定无可压缩内容。
83
+ // 这不是本层失败,保留 phase 等下一次带真实消息的压缩,不计 attempts。
84
+ if (eventError !== undefined && state.lastError === undefined && state.phase !== undefined) {
85
+ await saveState(ctx.storage, sessionID, { ...state, attempts: 0 })
86
+ return
87
+ }
88
+ // 本层失败:保留 phase,下一次请求由 auto 重新入队;达到上限后 retryPending 会清掉 phase。
89
+ if (state.phase !== undefined) {
90
+ await saveState(ctx.storage, sessionID, retryPending(state, detail))
91
+ return
92
+ }
93
+ // T1 没有下一层可重试:丢弃 pending,记录真实原因。
94
+ await saveState(ctx.storage, sessionID, dropPending(state, detail))
95
+ }
96
+
97
+ export interface SessionEventOptions {
98
+ readonly onSessionIdle?: (sessionID: string) => void
99
+ readonly onSessionDeleted?: (sessionID: string) => void
100
+ readonly onStepEnded?: (input: {
101
+ readonly sessionID: string
102
+ readonly messageID: string
103
+ readonly tokens: {
104
+ readonly input: number
105
+ readonly output: number
106
+ readonly reasoning: number
107
+ readonly cache: { readonly read: number; readonly write: number }
108
+ }
109
+ }) => void | Promise<void>
110
+ }
111
+
112
+ /**
113
+ * 输入:插件上下文 + sessionID。
114
+ * 输出:无。
115
+ * 预期:先打墓碑阻止迟到写入,再删 state、cache history、transcript 与临时文件。
116
+ */
117
+ export async function forgetSession(ctx: Plugin.Context, sessionID: string): Promise<void> {
118
+ markSessionDeleted(sessionID)
119
+ clearActiveSummary(sessionID)
120
+ deleteSessionTempFiles(sessionID)
121
+ try {
122
+ await Promise.all([
123
+ ctx.storage.remove(stateKey(sessionID)),
124
+ ctx.storage.remove(cacheHistoryKey(sessionID)),
125
+ deleteTranscriptsForSession(ctx.storage, sessionID),
126
+ ])
127
+ } catch {
128
+ // best effort:删不掉也不影响当前进程
129
+ }
130
+ }
131
+
132
+ /** 输入:事件里未校验的 tokens 字段;输出:规范化后的 usage,形状不对时 undefined。 */
133
+ function readStepTokens(raw: unknown): {
134
+ input: number
135
+ output: number
136
+ reasoning: number
137
+ cache: { read: number; write: number }
138
+ } | undefined {
139
+ if (typeof raw !== "object" || raw === null) return undefined
140
+ const record = raw as Record<string, unknown>
141
+ const input = record["input"]
142
+ if (typeof input !== "number" || !Number.isFinite(input)) return undefined
143
+ const finite = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0)
144
+ const cache =
145
+ typeof record["cache"] === "object" && record["cache"] !== null
146
+ ? (record["cache"] as Record<string, unknown>)
147
+ : {}
148
+ return {
149
+ input,
150
+ output: finite(record["output"]),
151
+ reasoning: finite(record["reasoning"]),
152
+ cache: { read: finite(cache["read"]), write: finite(cache["write"]) },
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 输入:插件上下文 + 可选清理回调。
158
+ * 输出:订阅会话事件并返回取消函数。
159
+ * 预期:compaction 成功/失败对账 pending;usage.updated/idle 补写缓存历史;
160
+ * idle 清理内存;deleted 清理内存与 storage。
161
+ */
162
+ export function startSessionEvents(
163
+ ctx: Plugin.Context,
164
+ options: SessionEventOptions = {},
165
+ ): () => void {
166
+ const controller = new AbortController()
167
+ void (async () => {
168
+ try {
169
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
170
+ switch (event.type) {
171
+ case "session.step.ended": {
172
+ const data = event.data as {
173
+ sessionID: string
174
+ assistantMessageID?: string
175
+ tokens?: unknown
176
+ }
177
+ const tokens = readStepTokens(data.tokens)
178
+ if (tokens !== undefined && options.onStepEnded !== undefined) {
179
+ await options.onStepEnded({
180
+ sessionID: data.sessionID,
181
+ messageID: data.assistantMessageID ?? "",
182
+ tokens,
183
+ })
184
+ }
185
+ break
186
+ }
187
+ case "session.compaction.ended":
188
+ case "session.compaction.failed": {
189
+ const type: CompactionEventType =
190
+ event.type === "session.compaction.ended" ? "ended" : "failed"
191
+ const failure =
192
+ event.type === "session.compaction.failed"
193
+ ? ((event.data as { error?: { message?: string } }).error?.message ?? "compaction failed")
194
+ : undefined
195
+ await handleCompactionEvent(ctx, type, event.data.sessionID, failure)
196
+ break
197
+ }
198
+ case "session.usage.updated":
199
+ case "session.idle":
200
+ // session.idle 在新版本里已标记 deprecated,usage.updated 每个 step 都会发;
201
+ // 两个都接,谁先到都能补写(按 messageID 去重)。
202
+ await recordCacheHistory(ctx, event.data.sessionID)
203
+ if (event.type === "session.idle") options.onSessionIdle?.(event.data.sessionID)
204
+ break
205
+ case "session.deleted":
206
+ await forgetSession(ctx, event.data.sessionID)
207
+ options.onSessionDeleted?.(event.data.sessionID)
208
+ break
209
+ }
210
+ }
211
+ } catch {
212
+ // aborted or disconnected;下一次事件或启动对账会补上
213
+ }
214
+ })()
215
+ return () => {
216
+ controller.abort()
217
+ }
218
+ }
219
+
220
+ /** 输入:任意错误;输出:是否明确的“会话不存在”。 */
221
+ function isSessionMissing(error: unknown): boolean {
222
+ if (typeof error !== "object" || error === null) {
223
+ return /not found|404/i.test(String(error))
224
+ }
225
+ const record = error as Record<string, unknown>
226
+ const status = record["status"] ?? record["statusCode"] ?? record["code"]
227
+ if (status === 404 || status === "404" || status === "not_found") return true
228
+ const message = typeof record["message"] === "string" ? record["message"] : ""
229
+ return /not found|404/i.test(message)
230
+ }
231
+
232
+ /**
233
+ * 输入:服务端上下文 + 每个 state 条目的处理函数。
234
+ * 输出:无。
235
+ * 预期:按 100 条一页顺序遍历所有 state key;空 sessionID 跳过,处理函数串行 await。
236
+ */
237
+ async function forEachStateEntry(
238
+ ctx: Plugin.Context,
239
+ visit: (sessionID: string, value: unknown) => Promise<void>,
240
+ ): Promise<void> {
241
+ await forEachScannedEntry(ctx.storage, STATE_PREFIX, async (entry) => {
242
+ const sessionID = entry.key.slice(STATE_PREFIX.length)
243
+ if (sessionID.length === 0) return
244
+ await visit(sessionID, entry.value)
245
+ })
246
+ }
247
+
248
+ /**
249
+ * 输入:服务端上下文。
250
+ * 输出:无。
251
+ * 预期:启动时只清理“明确不存在”会话的孤儿 state;瞬时错误不删数据。
252
+ */
253
+ async function cleanupOrphanStates(ctx: Plugin.Context): Promise<void> {
254
+ await forEachStateEntry(ctx, async (sessionID) => {
255
+ try {
256
+ await ctx.session.get({ sessionID })
257
+ } catch (error) {
258
+ if (isSessionMissing(error)) await forgetSession(ctx, sessionID)
259
+ }
260
+ })
261
+ }
262
+
263
+ /** 输入:服务端上下文;输出:无;预期:启动时对账所有 pending 状态并清理孤儿。 */
264
+ export async function reconcileAll(ctx: Plugin.Context): Promise<void> {
265
+ try {
266
+ await cleanupOrphanStates(ctx)
267
+ await forEachStateEntry(ctx, async (sessionID, value) => {
268
+ const state = parseState(value)
269
+ if (!state.pending) return
270
+ await saveState(ctx.storage, sessionID, await reconcile(ctx, sessionID, state))
271
+ })
272
+ } catch {
273
+ // best effort
274
+ }
275
+ }
@@ -0,0 +1,53 @@
1
+ import type { MarkdownSummary } from "./schema.ts"
2
+
3
+ const HEADING_RE = /^#{1,6}\s+(.+)$/m
4
+ const BULLET_RE = /^[\u2022\-*]\s+/
5
+
6
+ /** 输入:文本 + 上限;输出:截断后的文本。 */
7
+ function clamp(text: string, max: number): string {
8
+ return text.length <= max ? text : text.slice(0, max).trimEnd()
9
+ }
10
+
11
+ /**
12
+ * 输入:Markdown 正文。
13
+ * 输出:块标题;预期:第一个标题 > 第一个非空行 > untitled,去 bullet,最多 60 字符。
14
+ */
15
+ export function deriveMarkdownTitle(text: string): string {
16
+ const heading = HEADING_RE.exec(text)
17
+ const source = heading !== null ? heading[1] ?? "" : (text.split("\n").find((line) => line.trim().length > 0) ?? "")
18
+ const cleaned = source.trim().replace(BULLET_RE, "")
19
+ return cleaned.length === 0 ? "untitled" : clamp(cleaned, 60)
20
+ }
21
+
22
+ /**
23
+ * 输入:Markdown 正文。
24
+ * 输出:用于候选描述的短目标;预期:标题后的第一个非空非标题行,最多 160 字符。
25
+ */
26
+ export function markdownGoal(text: string): string {
27
+ const lines = text.split("\n").map((line) => line.trim())
28
+ const headingIndex = lines.findIndex((line) => HEADING_RE.test(line))
29
+ const start = headingIndex >= 0 ? headingIndex + 1 : 0
30
+ const first = lines.slice(start).find((line) => line.length > 0 && !HEADING_RE.test(line))
31
+ const cleaned = (first ?? deriveMarkdownTitle(text)).replace(BULLET_RE, "")
32
+ return clamp(cleaned, 160)
33
+ }
34
+
35
+ /** 输入:文本;输出:去掉 markdown 代码围栏后的正文。 */
36
+ function cleanMarkdown(text: string): string {
37
+ return text
38
+ .trim()
39
+ .replace(/^```(?:markdown|md)?\s*/i, "")
40
+ .replace(/\s*```$/, "")
41
+ .trim()
42
+ }
43
+
44
+ /** 输入:Markdown 正文 + 可选标题;输出:规范 Markdown 摘要。 */
45
+ export function toMarkdownSummary(text: string, title?: string): MarkdownSummary {
46
+ const body = cleanMarkdown(text)
47
+ const explicit = title?.trim() ?? ""
48
+ return {
49
+ format: "markdown",
50
+ title: explicit.length > 0 ? clamp(explicit, 80) : deriveMarkdownTitle(body),
51
+ text: body,
52
+ }
53
+ }
@@ -0,0 +1,114 @@
1
+ import { Schema } from "effect"
2
+
3
+ import type { SummaryFormat } from "../shared/settings.ts"
4
+ import { toBabelSummary } from "./babel.ts"
5
+ import { toMarkdownSummary } from "./markdown.ts"
6
+ import { SummaryFields, type Summary } from "./schema.ts"
7
+
8
+ /** 输入:任意模型字段;输出:字符串数组;预期:字符串包成单元素数组,其他丢弃。 */
9
+ export function stringArray(value: unknown): string[] {
10
+ if (Array.isArray(value)) {
11
+ return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
12
+ }
13
+ if (typeof value === "string" && value.trim().length > 0) return [value]
14
+ return []
15
+ }
16
+
17
+ /**
18
+ * 输入:模型返回的原始 JSON 对象。
19
+ * 输出:补齐缺失可选字段后的对象。
20
+ * 预期:真实模型常漏 progress/files 或把数组写成字符串;只补空值,不编造内容。
21
+ */
22
+ export function normalizeSummary(value: unknown): unknown {
23
+ if (typeof value !== "object" || value === null) return value
24
+ const record = value as Record<string, unknown>
25
+ const goal = typeof record["goal"] === "string" ? record["goal"] : ""
26
+ const title = (
27
+ typeof record["title"] === "string" && record["title"].trim().length > 0
28
+ ? record["title"]
29
+ : goal.trim() || "untitled"
30
+ ).slice(0, 80)
31
+ const progress =
32
+ typeof record["progress"] === "object" && record["progress"] !== null
33
+ ? (record["progress"] as Record<string, unknown>)
34
+ : {}
35
+ return {
36
+ ...record,
37
+ title,
38
+ goal,
39
+ constraints: stringArray(record["constraints"]),
40
+ decisions: stringArray(record["decisions"]),
41
+ progress: {
42
+ completed: stringArray(progress["completed"]),
43
+ active: stringArray(progress["active"]),
44
+ blocked: stringArray(progress["blocked"]),
45
+ },
46
+ open_items: stringArray(record["open_items"]),
47
+ files: stringArray(record["files"]),
48
+ facts: stringArray(record["facts"]),
49
+ }
50
+ }
51
+
52
+ /** 输入:模型返回的 Markdown 载体;输出:正文;预期:支持 string / markdown / text / summary。 */
53
+ function markdownText(value: unknown): string | undefined {
54
+ if (typeof value === "string") return value
55
+ if (typeof value !== "object" || value === null) return undefined
56
+ const record = value as Record<string, unknown>
57
+ for (const key of ["markdown", "text", "summary", "content"]) {
58
+ const text = record[key]
59
+ if (typeof text === "string") return text
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /** 输入:模型返回的 Markdown 载体;输出:可选标题。 */
65
+ function markdownTitle(value: unknown): string | undefined {
66
+ if (typeof value !== "object" || value === null) return undefined
67
+ const record = value as Record<string, unknown>
68
+ const title = record["title"] ?? record["topic"]
69
+ return typeof title === "string" ? title : undefined
70
+ }
71
+
72
+ /** 输入:Babel 载体;输出:正文文本和可选 title/goal。 */
73
+ function babelParts(
74
+ value: unknown,
75
+ ): { readonly text: string; readonly title?: string; readonly goal?: string } | undefined {
76
+ if (typeof value === "string") return { text: value }
77
+ if (typeof value !== "object" || value === null) return undefined
78
+ const record = value as Record<string, unknown>
79
+ for (const key of ["babel", "markdown", "text", "summary", "content"]) {
80
+ const text = record[key]
81
+ if (typeof text !== "string") continue
82
+ const title = typeof record["title"] === "string" ? record["title"] : undefined
83
+ const goal = typeof record["goal"] === "string" ? record["goal"] : undefined
84
+ return {
85
+ text,
86
+ ...(title === undefined ? {} : { title }),
87
+ ...(goal === undefined ? {} : { goal }),
88
+ }
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ /**
94
+ * 输入:模型返回的原始值 + 摘要格式。
95
+ * 输出:规范摘要;形状不合法时返回 undefined。
96
+ * 预期:Markdown 直接保留原文;JSON 先补齐可选字段再由 SummaryFields 解码。
97
+ */
98
+ export function toSummary(value: unknown, format: SummaryFormat = "markdown"): Summary | undefined {
99
+ if (format === "babel") {
100
+ const parts = babelParts(value)
101
+ if (parts === undefined) return undefined
102
+ return toBabelSummary(parts.text, parts.title, parts.goal)
103
+ }
104
+ if (format === "markdown") {
105
+ const text = markdownText(value)
106
+ if (text !== undefined) return toMarkdownSummary(text, markdownTitle(value))
107
+ // Markdown 模式下仍收到 JSON 字段时回退到结构化解析,避免工具调用直接失败。
108
+ }
109
+ try {
110
+ return Schema.decodeUnknownSync(SummaryFields)(normalizeSummary(value))
111
+ } catch {
112
+ return undefined
113
+ }
114
+ }
@@ -0,0 +1,70 @@
1
+ import type { Block } from "./state.ts"
2
+ import type { Summary } from "./schema.ts"
3
+ import { summaryBody, summaryTitle } from "./summary.ts"
4
+ import { formatTimestamp } from "../shared/format.ts"
5
+ import { countText } from "../shared/tokens.ts"
6
+
7
+ /** 输入:块 id;输出:块头标记;预期:用于渲染和对账。 */
8
+ export function blockMarker(id: string): string {
9
+ return `[Compressed conversation section #${id}]`
10
+ }
11
+
12
+ /** 块头附加信息开关;默认都不显示,避免多占 token。 */
13
+ export interface RenderOptions {
14
+ readonly showTimeline?: boolean
15
+ readonly showBlockTokens?: boolean
16
+ }
17
+
18
+ /** 输入:结构化字段;输出:Markdown 摘要正文。 */
19
+ export function renderSummary(summary: Summary): string {
20
+ return summaryBody(summary)
21
+ }
22
+
23
+ /**
24
+ * 输入:一个块 + 可选渲染开关。
25
+ * 输出:带块头、层级/合并关系、可选时间与 token 估算的完整文本。
26
+ * 预期:marker 前缀保持原样,对账逻辑只依赖 `blockMarker(id)`。
27
+ */
28
+ export function renderBlock(block: Block, options: RenderOptions = {}): string {
29
+ const body = renderSummary(block.summary)
30
+ const meta: string[] = [`T${block.tier.toString()}`]
31
+ if (block.sourceBlockIds !== undefined && block.sourceBlockIds.length > 0) {
32
+ meta.push(`merged ${block.sourceBlockIds.map((id) => `#${id}`).join(" + ")}`)
33
+ }
34
+ if (options.showBlockTokens === true) {
35
+ meta.push(`≈${countText(body).toString()} tok`)
36
+ }
37
+ if (options.showTimeline === true) {
38
+ meta.push(formatTimestamp(block.at))
39
+ }
40
+ const title = summaryTitle(block.summary).trim()
41
+ const header = `${blockMarker(block.id)} [${meta.join(" · ")}]${title ? ` ${title}` : ""}`
42
+ return `${header}\n\n${body}`
43
+ }
44
+
45
+ /**
46
+ * 输入:已落盘块 + 可选 pending 块 + 渲染开关。
47
+ * 输出:active 顶层块,按 order 排序。
48
+ * 预期:被任何块 sourceBlockIds 引用的块不显示;合并块占据第一个源块的位置。
49
+ */
50
+ export function activeBlocks(blocks: readonly Block[], pending?: Block): readonly Block[] {
51
+ const all = pending === undefined ? blocks : [...blocks, pending]
52
+ const consumed = new Set<string>()
53
+ for (const block of all) {
54
+ for (const sourceId of block.sourceBlockIds ?? []) consumed.add(sourceId)
55
+ }
56
+ return all
57
+ .filter((block) => !consumed.has(block.id))
58
+ .sort((a, b) => a.order - b.order || a.at - b.at || a.id.localeCompare(b.id))
59
+ }
60
+
61
+ /** 输入:块 + 可选 pending + 渲染开关;输出:拼接后的 checkpoint 文本。 */
62
+ export function renderAll(
63
+ blocks: readonly Block[],
64
+ pending?: Block,
65
+ options: RenderOptions = {},
66
+ ): string {
67
+ return activeBlocks(blocks, pending)
68
+ .map((block) => renderBlock(block, options))
69
+ .join("\n\n")
70
+ }
@@ -0,0 +1,84 @@
1
+ import { Schema } from "effect"
2
+
3
+ /**
4
+ * 摘要的规范 JSON 形状:写入存储、渲染、校验都用这一份,所有字段必填。
5
+ *
6
+ * 模型侧输入用 SummaryInput:JSON 模式只要求 goal,Markdown 模式要求 markdown;
7
+ * 进入内部前由 normalize 补齐,避免逼模型编造内容。
8
+ */
9
+ export const SummaryFields = Schema.Struct({
10
+ title: Schema.String,
11
+ goal: Schema.String,
12
+ constraints: Schema.Array(Schema.String),
13
+ decisions: Schema.Array(Schema.String),
14
+ progress: Schema.Struct({
15
+ completed: Schema.Array(Schema.String),
16
+ active: Schema.Array(Schema.String),
17
+ blocked: Schema.Array(Schema.String),
18
+ }),
19
+ open_items: Schema.Array(Schema.String),
20
+ files: Schema.Array(Schema.String),
21
+ facts: Schema.Array(Schema.String),
22
+ })
23
+
24
+ export type SummaryFields = Schema.Schema.Type<typeof SummaryFields>
25
+
26
+ /** Markdown 摘要:正文是唯一权威内容,title 只是派生元数据。 */
27
+ export interface MarkdownSummary {
28
+ readonly format: "markdown"
29
+ readonly title: string
30
+ readonly text: string
31
+ }
32
+
33
+ /**
34
+ * Babel 摘要:正文是模型间高密度文本,title/goal 从首行 `T=...|G=...` 解析。
35
+ * text 保留首行,方便 T2/T3 合并时继续看到目标。
36
+ */
37
+ export interface BabelSummary {
38
+ readonly format: "babel"
39
+ readonly title: string
40
+ readonly goal: string
41
+ readonly text: string
42
+ }
43
+
44
+ /** 块摘要:JSON 保持原形状,Markdown/Babel 用 format 标记;旧 JSON 块天然兼容。 */
45
+ export type Summary = SummaryFields | MarkdownSummary | BabelSummary
46
+
47
+ /** 输入:任意摘要;输出:是否 Markdown 摘要。 */
48
+ export function isMarkdownSummary(summary: Summary): summary is MarkdownSummary {
49
+ return "format" in summary && summary.format === "markdown"
50
+ }
51
+
52
+ /** 输入:任意摘要;输出:是否 Babel 摘要。 */
53
+ export function isBabelSummary(summary: Summary): summary is BabelSummary {
54
+ return "format" in summary && summary.format === "babel"
55
+ }
56
+
57
+ /**
58
+ * 模型侧摘要输入:JSON 字段都可选,Markdown 用 markdown 字段。
59
+ *
60
+ * 预期:JSON 模式在归一化时要求 goal;Markdown 模式要求 markdown。
61
+ */
62
+ export const SummaryInput = Schema.Struct({
63
+ format: Schema.optional(
64
+ Schema.Union([Schema.Literal("json"), Schema.Literal("markdown"), Schema.Literal("babel")]),
65
+ ),
66
+ markdown: Schema.optional(Schema.String),
67
+ babel: Schema.optional(Schema.String),
68
+ title: Schema.optional(Schema.String),
69
+ goal: Schema.optional(Schema.String),
70
+ constraints: Schema.optional(Schema.Array(Schema.String)),
71
+ decisions: Schema.optional(Schema.Array(Schema.String)),
72
+ progress: Schema.optional(
73
+ Schema.Struct({
74
+ completed: Schema.optional(Schema.Array(Schema.String)),
75
+ active: Schema.optional(Schema.Array(Schema.String)),
76
+ blocked: Schema.optional(Schema.Array(Schema.String)),
77
+ }),
78
+ ),
79
+ open_items: Schema.optional(Schema.Array(Schema.String)),
80
+ files: Schema.optional(Schema.Array(Schema.String)),
81
+ facts: Schema.optional(Schema.Array(Schema.String)),
82
+ })
83
+
84
+ export type SummaryInput = Schema.Schema.Type<typeof SummaryInput>