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
|
@@ -1,275 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,53 +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
|
-
}
|
|
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
|
+
}
|