dsh-as-aistudio 0.5.13 → 0.5.14
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/client.js +2 -2
- package/src/components.js +1 -1
- package/src/vendor/host/dsh-edit-turn.js +2 -2
- package/src/vendor/host/dsh-edit-turn.patch.js +1 -1
- package/src/vendor/host/dsh-log-trim.flatten.js +1 -1
- package/src/vendor/host/dsh-log-trim.goal-repair.js +187 -0
- package/src/vendor/host/dsh-log-trim.host-libs.mjs +1 -1
- package/src/vendor/host/dsh-log-trim.js +149 -4
- package/src/vendor/host/dsh-log-trim.ledger.js +1 -1
- package/src/vendor/host/dsh-log-trim.lossless.js +137 -9
- package/src/vendor/host/dsh-log-trim.roots.js +1 -1
- package/src/vendor/host/dsh-log-trim.vendor.compact-session.mjs +44 -2
- package/src/vendor/host/dsh-log-trim.verify-session-log.mjs +1 -1
- package/src/vendor/host/dsh-log-trim.worker.mjs +1 -1
- package/src/vendor/versions.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
## 中文
|
|
6
6
|
|
|
7
|
+
**0.5.14** —— 跟进 dsh-log-trim 0.7.1(目标事件不再被折叠/重建丢掉)。
|
|
8
|
+
|
|
9
|
+
- 内嵌 log-trim 升到 0.7.1:折叠与重建保留 `goal/change` 等状态事件,四条重写路径加守卫,
|
|
10
|
+
启动扫描修复孤儿目标轮次并清投影缓存。
|
|
11
|
+
- 重新 vendor(新增 `dsh-log-trim.goal-repair.js`)。
|
|
12
|
+
|
|
7
13
|
**0.5.13** —— 跟进 dsh-rerun-turn 0.2.11(`/state` 走内存快照 + 派生缓存)。
|
|
8
14
|
|
|
9
15
|
**0.5.12** —— 跟进 dsh-rerun-turn 0.2.10(行增长节流 + 传输层重试)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-as-aistudio",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"description": "Google-AI-Studio-style turn controls for DeepSeek Harness, in one install: edit a prompt and re-run it, re-run a reply in place, delete a turn from the model context, render sent prompts as Markdown, and trim idle session logs on boot. Self-contained: the plugin list shows this one row, and the five components (four actions + one maintenance) are vendored inside it - installing the studio installs nothing else.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/client.js
CHANGED
|
@@ -9642,7 +9642,7 @@ window.__ModuleLoader__.load({
|
|
|
9642
9642
|
const react = require('react')
|
|
9643
9643
|
const { jsx, jsxs } = require('react/jsx-runtime')
|
|
9644
9644
|
|
|
9645
|
-
const PLUGIN_VERSION = '0.7.
|
|
9645
|
+
const PLUGIN_VERSION = '0.7.1'
|
|
9646
9646
|
const ROUTE_PREFIX = '/api/dsh-log-trim'
|
|
9647
9647
|
|
|
9648
9648
|
/** The knobs this card edits, in the order they are drawn. */
|
|
@@ -10168,7 +10168,7 @@ window.__ModuleLoader__.load({
|
|
|
10168
10168
|
const NS = 'dsh-as-aistudio'
|
|
10169
10169
|
|
|
10170
10170
|
/** Keep in sync with package.json and src/components.js. */
|
|
10171
|
-
const PLUGIN_VERSION = '0.5.
|
|
10171
|
+
const PLUGIN_VERSION = '0.5.14'
|
|
10172
10172
|
|
|
10173
10173
|
/** Presence marker the live verifier reads. */
|
|
10174
10174
|
const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
|
package/src/components.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
export const STUDIO_ID = 'dsh-as-aistudio'
|
|
27
27
|
|
|
28
28
|
/** Keep in sync with package.json and src/client.js. */
|
|
29
|
-
export const STUDIO_VERSION = '0.5.
|
|
29
|
+
export const STUDIO_VERSION = '0.5.14'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The AI Studio action strip this studio composes, in the order the user meets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-edit-turn host half (dsh-edit-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-edit-turn/lib/index.js (v0.5.
|
|
3
|
+
// Source: ../dsh-edit-turn/lib/index.js (v0.5.19, sha256 5a6e1fed0c1e2b64)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -85,7 +85,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
85
85
|
export const name = PLUGIN_ID
|
|
86
86
|
|
|
87
87
|
/** Keep in sync with package.json and lib/client.js. */
|
|
88
|
-
export const PLUGIN_VERSION = '0.5.
|
|
88
|
+
export const PLUGIN_VERSION = '0.5.19'
|
|
89
89
|
|
|
90
90
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
91
91
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-edit-turn host half (dsh-edit-turn.patch.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-edit-turn/lib/patch.js (v0.5.
|
|
3
|
+
// Source: ../dsh-edit-turn/lib/patch.js (v0.5.19, sha256 5e1e8cce1d168f6c)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.flatten.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/flatten.js (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/flatten.js (v0.7.1, sha256 3aa1fdcbb120ab06)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// dsh-log-trim host half (dsh-log-trim.goal-repair.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Source: ../dsh-log-trim/lib/goal-repair.js (v0.7.1, sha256 0801a28f35667ee7)
|
|
4
|
+
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
|
+
//
|
|
6
|
+
// src/shell.js imports the copy named after the component, which is why the
|
|
7
|
+
// studio installs with no runtime dependency on the component package.
|
|
8
|
+
/**
|
|
9
|
+
* Repair sessions whose goal bookkeeping was lost by a log rewrite.
|
|
10
|
+
*
|
|
11
|
+
* `goal/change` is not a surface event: it carries the durable goal snapshot
|
|
12
|
+
* (create / edit / pause / resume / complete / block / clear), while the goal's
|
|
13
|
+
* rounds arrive as ordinary `user/message` events whose `data.source` says
|
|
14
|
+
* `{kind: 'goal', goalId, revision, round}`. The goal projection folds the two
|
|
15
|
+
* together and demands that every round be *the next admitted round* of the
|
|
16
|
+
* currently active goal.
|
|
17
|
+
*
|
|
18
|
+
* A rewrite that rebuilds the log from its visible surface (`rebuildSurface` in
|
|
19
|
+
* `lossless.js`, exposed by `applyFlatten` / `applyRebuild`) used to keep the
|
|
20
|
+
* rounds - they are surface nodes - and drop every `goal/change`. The log still
|
|
21
|
+
* passes every host format validator, because the format layer does not know
|
|
22
|
+
* goal semantics, but on the next load the goal projection refuses the whole
|
|
23
|
+
* log:
|
|
24
|
+
*
|
|
25
|
+
* goal replay failed at session event N: goal round at session event N is not
|
|
26
|
+
* the next admitted round of the active goal
|
|
27
|
+
*
|
|
28
|
+
* and `/goal` is unusable in that session forever (`command.execute failed`).
|
|
29
|
+
*
|
|
30
|
+
* The durable goal is already gone at that point - the snapshots were deleted -
|
|
31
|
+
* so the honest repair is to drop the now-dangling goal attribution from those
|
|
32
|
+
* rounds: they stay in the model's history as what they are, user messages, and
|
|
33
|
+
* `/goal` can create a fresh goal. Nothing is invented and no sequence number
|
|
34
|
+
* moves, so the repair cannot itself invalidate a log.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Goal operations that keep (or replace) a current goal snapshot. */
|
|
38
|
+
const SNAPSHOT_OPERATIONS = new Set(['create', 'edit', 'pause', 'resume', 'complete', 'block'])
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{current?: {id?: string, revision?: number, phase?: string, maxGoalRounds?: number}, roundsStarted: number}} GoalFold
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply one `goal/change` payload to the local fold.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately tolerant: a payload this function does not understand leaves the
|
|
48
|
+
* fold alone instead of failing the whole repair.
|
|
49
|
+
*
|
|
50
|
+
* @param {GoalFold} state - fold accumulator.
|
|
51
|
+
* @param {unknown} data - the event payload.
|
|
52
|
+
* @returns {GoalFold} the next fold state.
|
|
53
|
+
*/
|
|
54
|
+
function applyChange(state, data) {
|
|
55
|
+
if (data === null || typeof data !== 'object' || data.kind !== 'goal/change') return state
|
|
56
|
+
if (data.operation === 'clear') return { current: undefined, roundsStarted: 0 }
|
|
57
|
+
if (!SNAPSHOT_OPERATIONS.has(data.operation)) return state
|
|
58
|
+
const goal = data.goal
|
|
59
|
+
if (goal === null || typeof goal !== 'object' || typeof goal.id !== 'string') return state
|
|
60
|
+
return {
|
|
61
|
+
current: {
|
|
62
|
+
id: goal.id,
|
|
63
|
+
revision: goal.revision,
|
|
64
|
+
phase: goal.phase,
|
|
65
|
+
maxGoalRounds: goal.maxGoalRounds,
|
|
66
|
+
},
|
|
67
|
+
roundsStarted: Number.isSafeInteger(data.roundsStarted) ? data.roundsStarted : state.roundsStarted,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether one goal-source round is admitted by the fold (same rule as the goal
|
|
73
|
+
* projection's `applyGoalEvent`).
|
|
74
|
+
*
|
|
75
|
+
* @param {GoalFold} state - fold state before this round.
|
|
76
|
+
* @param {object} source - the message's `data.source`.
|
|
77
|
+
* @returns {boolean} true when the projection would admit this round.
|
|
78
|
+
*/
|
|
79
|
+
function admitsRound(state, source) {
|
|
80
|
+
const current = state.current
|
|
81
|
+
if (current === undefined) return false
|
|
82
|
+
if (current.phase !== 'active') return false
|
|
83
|
+
if (source.goalId !== current.id || source.revision !== current.revision) return false
|
|
84
|
+
if (source.round !== state.roundsStarted + 1) return false
|
|
85
|
+
if (Number.isSafeInteger(current.maxGoalRounds) && source.round > current.maxGoalRounds) return false
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Find the goal rounds a replay would reject as orphans.
|
|
91
|
+
*
|
|
92
|
+
* @param {object[]} events - the session's events, in seq order.
|
|
93
|
+
* @returns {number[]} the seqs of goal-source user messages no goal admits.
|
|
94
|
+
*/
|
|
95
|
+
export function orphanGoalRounds(events) {
|
|
96
|
+
/** @type {GoalFold} */
|
|
97
|
+
let state = { current: undefined, roundsStarted: 0 }
|
|
98
|
+
const orphans = []
|
|
99
|
+
for (const event of events) {
|
|
100
|
+
if (event.type === 'goal/change') {
|
|
101
|
+
state = applyChange(state, event.data)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (event.type !== 'user/message') continue
|
|
105
|
+
const source = event.data?.source
|
|
106
|
+
if (source === null || typeof source !== 'object' || source.kind !== 'goal') continue
|
|
107
|
+
if (admitsRound(state, source)) {
|
|
108
|
+
state = { ...state, roundsStarted: source.round }
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
orphans.push(event.seq)
|
|
112
|
+
}
|
|
113
|
+
return orphans
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether one log event belongs to the goal domain.
|
|
118
|
+
*
|
|
119
|
+
* Both halves matter: `goal/change` carries the snapshots, and every admitted
|
|
120
|
+
* `user/message` with a goal source advances `roundsStarted`. Losing either one
|
|
121
|
+
* makes the goal projection reject the whole log on replay.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} event - a session event.
|
|
124
|
+
* @returns {boolean} true for goal bookkeeping events.
|
|
125
|
+
*/
|
|
126
|
+
export function isGoalEvent(event) {
|
|
127
|
+
if (event === null || typeof event !== 'object') return false
|
|
128
|
+
if (event.type === 'goal/change') return true
|
|
129
|
+
return event.type === 'user/message' && event.data?.source?.kind === 'goal'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How many goal-domain events a log carries.
|
|
134
|
+
*
|
|
135
|
+
* @param {object[]} events - session events.
|
|
136
|
+
* @returns {number} the count.
|
|
137
|
+
*/
|
|
138
|
+
export function goalEventCount(events) {
|
|
139
|
+
let count = 0
|
|
140
|
+
for (const event of events) if (isGoalEvent(event)) count += 1
|
|
141
|
+
return count
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Refuse a rewrite that would drop a goal event.
|
|
146
|
+
*
|
|
147
|
+
* The goal projection folds the **whole** log, shadowed history included, so it
|
|
148
|
+
* is not enough to keep what the model can currently read: a compaction may have
|
|
149
|
+
* replaced a round, and trimming that round away turns every later round into an
|
|
150
|
+
* orphan. `goal/change` is preserved by the current writers; goal rounds are
|
|
151
|
+
* surface events and cannot be re-added as invisible bookkeeping, so a rewrite
|
|
152
|
+
* that would lose one has to stop rather than produce a log `/goal` cannot open.
|
|
153
|
+
*
|
|
154
|
+
* @param {object[]} original - the log before the rewrite.
|
|
155
|
+
* @param {object[]} kept - the events the rewrite would write.
|
|
156
|
+
* @throws {Error} when a goal event would disappear.
|
|
157
|
+
*/
|
|
158
|
+
export function assertGoalEventsKept(original, kept) {
|
|
159
|
+
const before = goalEventCount(original)
|
|
160
|
+
if (before === 0) return
|
|
161
|
+
const after = goalEventCount(kept)
|
|
162
|
+
if (after >= before) return
|
|
163
|
+
throw new Error(
|
|
164
|
+
`压缩会删掉 ${before - after} 条目标事件(goal/change 或目标轮次)。目标投影从整份日志折叠,` +
|
|
165
|
+
'删掉任何一条都会让该会话的 /goal 永久报 "goal replay failed";已放弃这次写入',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewrite the orphan rounds' attribution into plain user messages.
|
|
171
|
+
*
|
|
172
|
+
* Copy-on-write: untouched events keep their identity, so a caller that finds
|
|
173
|
+
* no orphans can skip the write entirely.
|
|
174
|
+
*
|
|
175
|
+
* @param {object[]} events - the session's events, in seq order.
|
|
176
|
+
* @param {number[]} orphans - seqs from {@link orphanGoalRounds}.
|
|
177
|
+
* @returns {object[]} the repaired events (same seq numbering).
|
|
178
|
+
*/
|
|
179
|
+
export function stripOrphanGoalSources(events, orphans) {
|
|
180
|
+
const doomed = new Set(orphans)
|
|
181
|
+
if (doomed.size === 0) return events
|
|
182
|
+
return events.map((event) => {
|
|
183
|
+
if (!doomed.has(event.seq) || event.type !== 'user/message') return event
|
|
184
|
+
const data = event.data ?? {}
|
|
185
|
+
return { ...event, data: { ...data, source: { kind: 'user' } } }
|
|
186
|
+
})
|
|
187
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.host-libs.mjs), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/host-libs.mjs (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/host-libs.mjs (v0.7.1, sha256 5ebed01988b5bfcf)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/index.js (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/index.js (v0.7.1, sha256 906bd2b437e569b4)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -37,13 +37,14 @@ import { discoverRoots, isCurrentLog, listLegacy } from './dsh-log-trim.roots.js
|
|
|
37
37
|
import fs from 'node:fs'
|
|
38
38
|
import os from 'node:os'
|
|
39
39
|
import path from 'node:path'
|
|
40
|
-
import { applyTrim, foldSurface, isSessionLocked, keepLastWindows, readLogAsync, renumber, windowsOf } from './dsh-log-trim.vendor.compact-session.mjs'
|
|
40
|
+
import { applyTrim, commitTrim, encodeLogAsync, foldSurface, isSessionLocked, keepLastWindows, readLogAsync, renumber, windowsOf } from './dsh-log-trim.vendor.compact-session.mjs'
|
|
41
41
|
import { applyFlatten, applyLossless, applyStreamOnly, planLossless, applyRebuild } from './dsh-log-trim.lossless.js'
|
|
42
|
+
import { orphanGoalRounds, stripOrphanGoalSources } from './dsh-log-trim.goal-repair.js'
|
|
42
43
|
import { readLedger, recordFor } from './dsh-log-trim.ledger.js'
|
|
43
44
|
|
|
44
45
|
export const name = 'dsh-log-trim'
|
|
45
46
|
export const PLUGIN_ID = name
|
|
46
|
-
export const PLUGIN_VERSION = '0.7.
|
|
47
|
+
export const PLUGIN_VERSION = '0.7.1'
|
|
47
48
|
export const ROUTE_PREFIX = '/api/dsh-log-trim'
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -345,6 +346,125 @@ export async function repairIfCorrupt(logFile, config) {
|
|
|
345
346
|
return { sessionId: path.basename(path.dirname(logFile)), reason: detail, failed: true }
|
|
346
347
|
}
|
|
347
348
|
|
|
349
|
+
/**
|
|
350
|
+
* `<DSH_HOME>` for one session log, so the projection cache can be addressed.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} logFile - the session's log.
|
|
353
|
+
* @param {object} config - resolved config (`sessionsRoot`).
|
|
354
|
+
* @returns {string} the DSH home directory.
|
|
355
|
+
*/
|
|
356
|
+
function dshHomeOf(logFile, config) {
|
|
357
|
+
const root = typeof config?.sessionsRoot === 'string' ? path.resolve(config.sessionsRoot) : null
|
|
358
|
+
const file = path.resolve(logFile)
|
|
359
|
+
if (root !== null && file.startsWith(root + path.sep)) return path.dirname(root)
|
|
360
|
+
// <home>/sessions/<workspace>/<session>/<log>
|
|
361
|
+
return path.dirname(path.dirname(path.dirname(path.dirname(file))))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Forget one session's durable projection cache record.
|
|
366
|
+
*
|
|
367
|
+
* The record's identity is the session header, not the log bytes, and its rows
|
|
368
|
+
* keep a checkpoint seq plus the values folded from it. A cached `goal` row that
|
|
369
|
+
* already carries the replay failure would therefore be restored verbatim after
|
|
370
|
+
* the log was repaired — the failure would outlive the fix. Deleting the record
|
|
371
|
+
* makes the next load fold from seq 0.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} logFile - the session's log.
|
|
374
|
+
* @param {object} config - resolved config.
|
|
375
|
+
* @returns {string[]|null} removed cache file names, or null when there was none.
|
|
376
|
+
*/
|
|
377
|
+
export function dropProjectionCache(logFile, config) {
|
|
378
|
+
const sessionId = path.basename(path.dirname(path.resolve(logFile)))
|
|
379
|
+
const dir = path.join(dshHomeOf(logFile, config), 'storages', 'session_projcache', 'sessions')
|
|
380
|
+
let names
|
|
381
|
+
try {
|
|
382
|
+
names = fs.readdirSync(dir)
|
|
383
|
+
} catch {
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
const bare = sessionId.replace(/^session-/, '')
|
|
387
|
+
const doomed = names.filter(
|
|
388
|
+
(name) => name.endsWith('.json') && (name === `${sessionId}.json` || name === `${bare}.json`),
|
|
389
|
+
)
|
|
390
|
+
const removed = []
|
|
391
|
+
for (const name of doomed) {
|
|
392
|
+
try {
|
|
393
|
+
fs.rmSync(path.join(dir, name), { force: true })
|
|
394
|
+
removed.push(name)
|
|
395
|
+
} catch {
|
|
396
|
+
// 删不掉就留着:下次启动还会再试一次。
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return removed.length === 0 ? null : removed
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 修好被重写路径弄丢目标的会话。
|
|
404
|
+
*
|
|
405
|
+
* `goal/change` 不是表面事件:它没有 surfaceOp,只是恰好落在某次 replace 的
|
|
406
|
+
* 区间里(或整份日志按表面重建时),就会被裁剪/重写顺手删掉;而目标轮次是普通
|
|
407
|
+
* 的 `user/message`,作为表面节点留了下来。这种日志能通过宿主的格式校验(格式层
|
|
408
|
+
* 不懂目标语义),但目标投影回放时会整份报错:
|
|
409
|
+
*
|
|
410
|
+
* goal replay failed at session event N: goal round at session event N is not
|
|
411
|
+
* the next admitted round of the active goal
|
|
412
|
+
*
|
|
413
|
+
* 之后 `/goal` 在该会话里永久失败(`command.execute failed`)。删掉的快照找不
|
|
414
|
+
* 回来,所以修复只做一件不发明任何东西的事:把这些已经指向不存在目标的轮次改回
|
|
415
|
+
* 普通用户消息(内容、seq 都不动),目标投影随之清空,`/goal` 可以重新开。顺带
|
|
416
|
+
* 删掉该会话的投影缓存,否则缓存里的 failure 行会被当成检查点继续复用。
|
|
417
|
+
*
|
|
418
|
+
* @param {string} logFile - 会话日志。
|
|
419
|
+
* @param {object} config - 生效配置。
|
|
420
|
+
* @returns {Promise<object|null>} 修了什么;没有孤儿轮次时返回 null。
|
|
421
|
+
*/
|
|
422
|
+
export async function repairOrphanGoalRounds(logFile, config) {
|
|
423
|
+
if (isSessionLocked(logFile)) return null
|
|
424
|
+
const original = await readLogAsync(logFile)
|
|
425
|
+
const orphans = orphanGoalRounds(original.events)
|
|
426
|
+
if (orphans.length === 0) return null
|
|
427
|
+
const repaired = stripOrphanGoalSources(original.events, orphans)
|
|
428
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
429
|
+
const backup = `${logFile}.precompact-${stamp}`
|
|
430
|
+
const staging = `${logFile}.staging-${stamp}`
|
|
431
|
+
await fs.promises.writeFile(staging, await encodeLogAsync(original.header, repaired))
|
|
432
|
+
const { verifyLog } = await import('./dsh-log-trim.verify-session-log.mjs')
|
|
433
|
+
const verified = await verifyLog(staging)
|
|
434
|
+
if (!verified.ok) {
|
|
435
|
+
await fs.promises.rm(staging, { force: true })
|
|
436
|
+
const failed = (verified.checks || []).filter((check) => !check.ok).slice(0, 3)
|
|
437
|
+
throw new Error(
|
|
438
|
+
'目标轮次修复结果没通过宿主校验,未写入:' +
|
|
439
|
+
failed.map((check) => check.name + ':' + check.detail).join(';'),
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
const afterBytes = fs.statSync(staging).size
|
|
443
|
+
const summary = await commitTrim({
|
|
444
|
+
staging,
|
|
445
|
+
backup,
|
|
446
|
+
summary: {
|
|
447
|
+
backup,
|
|
448
|
+
before: { events: original.events.length, bytes: original.compressedBytes },
|
|
449
|
+
after: { events: repaired.length, bytes: afterBytes },
|
|
450
|
+
dropped: 0,
|
|
451
|
+
},
|
|
452
|
+
}, logFile)
|
|
453
|
+
const cache = dropProjectionCache(logFile, config)
|
|
454
|
+
recordFor(logFile, 'goal-repair', original.events, repaired, {
|
|
455
|
+
bytesBefore: original.compressedBytes,
|
|
456
|
+
bytesAfter: summary.after.bytes,
|
|
457
|
+
record: config?.recordLedger !== false,
|
|
458
|
+
})
|
|
459
|
+
return {
|
|
460
|
+
sessionId: path.basename(path.dirname(logFile)),
|
|
461
|
+
reason: `丢掉目标快照的轮次改回普通消息(${orphans.length} 条)`,
|
|
462
|
+
orphans: orphans.length,
|
|
463
|
+
backup: backup.slice(backup.lastIndexOf('.precompact-') + 1),
|
|
464
|
+
cache,
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
348
468
|
export function listBackups(root, limit = 40) {
|
|
349
469
|
const found = []
|
|
350
470
|
let total = 0
|
|
@@ -851,8 +971,22 @@ export async function runScan(config, logger = {}) {
|
|
|
851
971
|
known.size === bytes &&
|
|
852
972
|
known.mtimeMs === mtimeMs
|
|
853
973
|
) {
|
|
854
|
-
// Same bytes, same mtime: the answer cannot have changed
|
|
974
|
+
// Same bytes, same mtime: the answer cannot have changed — except the goal
|
|
975
|
+
// repair, which older verdicts never ran. Do it once per file revision.
|
|
855
976
|
remember[logFile] = known
|
|
977
|
+
if (known.goalChecked !== true && config.repairOnBoot !== false && bytes >= 64 * 1024) {
|
|
978
|
+
try {
|
|
979
|
+
const goalFixed = await repairOrphanGoalRounds(logFile, config)
|
|
980
|
+
if (goalFixed !== null) {
|
|
981
|
+
summary.repaired.push(goalFixed)
|
|
982
|
+
// 日志变了,旧判定作废;下一轮扫描按新文件重来。
|
|
983
|
+
continue
|
|
984
|
+
}
|
|
985
|
+
} catch (error) {
|
|
986
|
+
summary.failed.push({ sessionId, reason: `目标轮次修复失败:${error instanceof Error ? error.message : String(error)}` })
|
|
987
|
+
}
|
|
988
|
+
known.goalChecked = true
|
|
989
|
+
}
|
|
856
990
|
if (known.verdict === 'skip') {
|
|
857
991
|
summary.skipped.push({ sessionId, reason: known.reason })
|
|
858
992
|
} else if (known.verdict === 'fail') {
|
|
@@ -860,6 +994,17 @@ export async function runScan(config, logger = {}) {
|
|
|
860
994
|
}
|
|
861
995
|
continue
|
|
862
996
|
}
|
|
997
|
+
// 宿主的格式校验管不到目标语义:被重写过的日志能正常打开,却可能只剩目标轮次
|
|
998
|
+
// 而没有 goal/change,于是 /goal 永远报 "goal replay failed ... not the next
|
|
999
|
+
// admitted round"。这一步先把它修好;小日志不可能积累出一整段目标轮次,跳过。
|
|
1000
|
+
if (config.repairOnBoot !== false && bytes >= 64 * 1024) {
|
|
1001
|
+
try {
|
|
1002
|
+
const goalFixed = await repairOrphanGoalRounds(logFile, config)
|
|
1003
|
+
if (goalFixed !== null) summary.repaired.push(goalFixed)
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
summary.failed.push({ sessionId, reason: `目标轮次修复失败:${error instanceof Error ? error.message : String(error)}` })
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
863
1008
|
// 先折叠:edit/rerun/delete 的载体是“注释行”,落成正文后日志才是“本来如此”。
|
|
864
1009
|
// 折叠完继续往下走,于是同一份日志还能顺带被裁窗口(两步都各自验过才写)。
|
|
865
1010
|
// 这个计数跨两个分支用(折叠要不要跑、清理已遮蔽要不要跳过),必须在外层声明。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.ledger.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/ledger.js (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/ledger.js (v0.7.1, sha256 5ca2460b42a3a776)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.lossless.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/lossless.js (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/lossless.js (v0.7.1, sha256 1b98a61bbad8ef87)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import fs from 'node:fs'
|
|
27
|
+
import { assertGoalEventsKept } from './dsh-log-trim.goal-repair.js'
|
|
27
28
|
import { recordFor } from './dsh-log-trim.ledger.js'
|
|
28
29
|
import { commitTrim, derivedHistory, dropOrphanCompactions, encodeLogAsync, foldSurface, readLogAsync, renumber, seededHeaderFor, textLengthOf } from './dsh-log-trim.vendor.compact-session.mjs'
|
|
29
30
|
|
|
@@ -123,6 +124,11 @@ export async function planLossless(logFile, config) {
|
|
|
123
124
|
const original = await readLogAsync(logFile)
|
|
124
125
|
const before = modelView(original.events)
|
|
125
126
|
const cut = selectLossless(original.events)
|
|
127
|
+
try {
|
|
128
|
+
assertGoalEventsKept(original.events, cut.events)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { should: false, reason: error instanceof Error ? error.message : String(error), report: { title: '', events: original.events.length, windows: 0, droppable: 0, modelBefore: 0, modelAfter: 0, modelSame: false, bytes: original.compressedBytes, afterBytes: original.compressedBytes }, cut }
|
|
131
|
+
}
|
|
126
132
|
const after = modelView(cut.events)
|
|
127
133
|
const bytes = (await encodeLogAsync(original.header, cut.events)).length
|
|
128
134
|
const same = sameView(before, after)
|
|
@@ -189,6 +195,7 @@ export async function planLossless(logFile, config) {
|
|
|
189
195
|
*/
|
|
190
196
|
export async function applyLossless(logFile, config, plan, options = {}) {
|
|
191
197
|
const original = await readLogAsync(logFile)
|
|
198
|
+
assertGoalEventsKept(original.events, plan.cut.events)
|
|
192
199
|
const renamed = renumber(plan.cut.events)
|
|
193
200
|
const seqs = new Set(renamed.events.map((event) => event.seq))
|
|
194
201
|
for (let index = 0; index < renamed.events.length; index += 1) {
|
|
@@ -206,6 +213,7 @@ export async function applyLossless(logFile, config, plan, options = {}) {
|
|
|
206
213
|
const backup = `${logFile}.precompact-${stamp}`
|
|
207
214
|
const staging = `${logFile}.staging-${stamp}`
|
|
208
215
|
await fs.promises.writeFile(staging, await encodeLogAsync(original.header, renamed.events))
|
|
216
|
+
const afterBytes = fs.statSync(staging).size
|
|
209
217
|
|
|
210
218
|
if (options.verify !== false) {
|
|
211
219
|
let verified
|
|
@@ -224,7 +232,16 @@ export async function applyLossless(logFile, config, plan, options = {}) {
|
|
|
224
232
|
}
|
|
225
233
|
}
|
|
226
234
|
|
|
227
|
-
const summary = await commitTrim({
|
|
235
|
+
const summary = await commitTrim({
|
|
236
|
+
staging,
|
|
237
|
+
backup,
|
|
238
|
+
summary: {
|
|
239
|
+
backup,
|
|
240
|
+
before: { events: original.events.length, bytes: original.compressedBytes },
|
|
241
|
+
after: { events: renamed.events.length, bytes: afterBytes },
|
|
242
|
+
dropped: original.events.length - renamed.events.length,
|
|
243
|
+
},
|
|
244
|
+
}, logFile)
|
|
228
245
|
recordFor(logFile, 'lossless', original.events, renamed.events, { bytesBefore: original.compressedBytes, bytesAfter: summary.after.bytes, record: config?.recordLedger !== false })
|
|
229
246
|
return {
|
|
230
247
|
...summary,
|
|
@@ -243,6 +260,7 @@ export async function applyLossless(logFile, config, plan, options = {}) {
|
|
|
243
260
|
export async function applyRebuild(logFile, config) {
|
|
244
261
|
const original = await readLogAsync(logFile)
|
|
245
262
|
const rebuilt = rebuildSurface(original.events)
|
|
263
|
+
assertGoalEventsKept(original.events, rebuilt)
|
|
246
264
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
247
265
|
const backup = `${logFile}.precompact-${stamp}`
|
|
248
266
|
const staging = `${logFile}.staging-${stamp}`
|
|
@@ -255,7 +273,16 @@ export async function applyRebuild(logFile, config) {
|
|
|
255
273
|
throw new Error('重写结果没通过宿主校验,未写入:' + failed.map((check) => check.name + ':' + check.detail).join(';'))
|
|
256
274
|
}
|
|
257
275
|
const beforeBytes = fs.statSync(staging).size
|
|
258
|
-
await commitTrim({
|
|
276
|
+
await commitTrim({
|
|
277
|
+
staging,
|
|
278
|
+
backup,
|
|
279
|
+
summary: {
|
|
280
|
+
backup,
|
|
281
|
+
before: { events: original.events.length, bytes: original.compressedBytes },
|
|
282
|
+
after: { events: rebuilt.length, bytes: beforeBytes },
|
|
283
|
+
dropped: original.events.length - rebuilt.length,
|
|
284
|
+
},
|
|
285
|
+
}, logFile)
|
|
259
286
|
recordFor(logFile, 'rebuild', original.events, rebuilt, { bytesBefore: original.compressedBytes, bytesAfter: beforeBytes, record: config?.recordLedger !== false })
|
|
260
287
|
return {
|
|
261
288
|
mode: 'rebuild',
|
|
@@ -319,6 +346,7 @@ export async function applyFlatten(logFile, config) {
|
|
|
319
346
|
// 与窗口裁剪同一条规则:只留最近几轮的流式回放记录,更早的删掉。
|
|
320
347
|
const stripped = stripOldStreams(flattened.events, 0)
|
|
321
348
|
const folded = stripped.events
|
|
349
|
+
assertGoalEventsKept(original.events, folded)
|
|
322
350
|
if (stats.edits + stats.reruns + stats.deletes === 0) {
|
|
323
351
|
throw new Error('没有需要折叠的编辑/重跑/删除记录')
|
|
324
352
|
}
|
|
@@ -334,7 +362,16 @@ export async function applyFlatten(logFile, config) {
|
|
|
334
362
|
throw new Error('折叠结果没通过宿主校验,未写入:' + failed.map((check) => check.name + ':' + check.detail).join(';'))
|
|
335
363
|
}
|
|
336
364
|
const beforeBytes = fs.statSync(staging).size
|
|
337
|
-
await commitTrim({
|
|
365
|
+
await commitTrim({
|
|
366
|
+
staging,
|
|
367
|
+
backup,
|
|
368
|
+
summary: {
|
|
369
|
+
backup,
|
|
370
|
+
before: { events: original.events.length, bytes: original.compressedBytes },
|
|
371
|
+
after: { events: folded.length, bytes: beforeBytes },
|
|
372
|
+
dropped: original.events.length - folded.length,
|
|
373
|
+
},
|
|
374
|
+
}, logFile)
|
|
338
375
|
recordFor(logFile, 'flatten', original.events, folded, { bytesBefore: original.compressedBytes, bytesAfter: beforeBytes, record: config?.recordLedger !== false })
|
|
339
376
|
return {
|
|
340
377
|
mode: 'flatten',
|
|
@@ -346,6 +383,86 @@ export async function applyFlatten(logFile, config) {
|
|
|
346
383
|
}
|
|
347
384
|
}
|
|
348
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Log events that are not part of the dialogue surface but that a host
|
|
388
|
+
* projection still folds from the log.
|
|
389
|
+
*
|
|
390
|
+
* The rebuild replays the visible messages and used to discard everything else,
|
|
391
|
+
* which silently deleted durable state:
|
|
392
|
+
*
|
|
393
|
+
* - `goal/change` carries the goal snapshot. Losing it while the goal's rounds
|
|
394
|
+
* (ordinary surface `user/message` events) survive makes the goal projection
|
|
395
|
+
* refuse the whole log: "goal round at session event N is not the next
|
|
396
|
+
* admitted round of the active goal", and `/goal` stays broken forever.
|
|
397
|
+
* - `todo/write`, `plan/mode`, `schedule/change`, `team/*`, `feedback/*` and
|
|
398
|
+
* `working-directory/change` are the same kind of snapshot state: the UI and
|
|
399
|
+
* the projections read the last one, and dropping them looks like the user's
|
|
400
|
+
* todos / plan mode / team board / schedules vanished.
|
|
401
|
+
*
|
|
402
|
+
* The six settings event types already re-emitted at the head of the rebuild
|
|
403
|
+
* (`agent-preset/selected`, `permission/preset`, `sandbox/mode`,
|
|
404
|
+
* `approval/policy`, `session/title`, `model/selection`) are deliberately not
|
|
405
|
+
* listed here: the rebuild regenerates each from its own last event.
|
|
406
|
+
*/
|
|
407
|
+
const PRESERVED_STATE_TYPES = new Set([
|
|
408
|
+
'goal/change',
|
|
409
|
+
'plan/mode',
|
|
410
|
+
'todo/write',
|
|
411
|
+
'schedule/change',
|
|
412
|
+
'working-directory/change',
|
|
413
|
+
'team/member',
|
|
414
|
+
'team/task',
|
|
415
|
+
'team/message/queued',
|
|
416
|
+
'team/message/delivered',
|
|
417
|
+
'feedback/record',
|
|
418
|
+
'feedback/message-put',
|
|
419
|
+
'feedback/message-delete',
|
|
420
|
+
])
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Re-insert preserved state events just before the first surface block whose
|
|
424
|
+
* source event originally came after them.
|
|
425
|
+
*
|
|
426
|
+
* `out` is already the rebuilt surface; `marks` maps each copied source seq to
|
|
427
|
+
* the index at which its block starts. A state event that originally sat
|
|
428
|
+
* between two messages therefore lands back between them, which is what keeps a
|
|
429
|
+
* goal's create/edit snapshot ahead of the rounds it admits.
|
|
430
|
+
*
|
|
431
|
+
* @param {object[]} out - the rebuilt surface entries.
|
|
432
|
+
* @param {Map<number, number>} marks - source seq → output index.
|
|
433
|
+
* @param {object[]} preserved - state events to keep, in original seq order.
|
|
434
|
+
* @returns {object[]} the surface with state events merged back in.
|
|
435
|
+
*/
|
|
436
|
+
function mergeStateEvents(out, marks, preserved) {
|
|
437
|
+
if (preserved.length === 0) return out
|
|
438
|
+
const boundaries = [...marks.entries()].sort((left, right) => left[0] - right[0])
|
|
439
|
+
const buckets = new Map()
|
|
440
|
+
for (const event of preserved) {
|
|
441
|
+
let at = out.length
|
|
442
|
+
for (const [seq, index] of boundaries) {
|
|
443
|
+
if (seq > event.seq) {
|
|
444
|
+
at = index
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const bucket = buckets.get(at)
|
|
449
|
+
if (bucket === undefined) buckets.set(at, [event])
|
|
450
|
+
else bucket.push(event)
|
|
451
|
+
}
|
|
452
|
+
const merged = []
|
|
453
|
+
const pushBucket = (index) => {
|
|
454
|
+
const bucket = buckets.get(index)
|
|
455
|
+
if (bucket === undefined) return
|
|
456
|
+
for (const event of bucket) merged.push({ seq: 0, type: event.type, time: event.time, data: event.data })
|
|
457
|
+
}
|
|
458
|
+
for (let index = 0; index < out.length; index += 1) {
|
|
459
|
+
pushBucket(index)
|
|
460
|
+
merged.push(out[index])
|
|
461
|
+
}
|
|
462
|
+
pushBucket(out.length)
|
|
463
|
+
return merged
|
|
464
|
+
}
|
|
465
|
+
|
|
349
466
|
/**
|
|
350
467
|
* Rebuild a log from the surface the model reads.
|
|
351
468
|
*
|
|
@@ -356,12 +473,21 @@ export async function applyFlatten(logFile, config) {
|
|
|
356
473
|
* every tool result sits in the turn of its call, and no replace or compaction
|
|
357
474
|
* record survives.
|
|
358
475
|
*
|
|
476
|
+
* Events in {@link PRESERVED_STATE_TYPES} are re-inserted at their original
|
|
477
|
+
* position relative to the surface, so a goal snapshot still precedes the round
|
|
478
|
+
* it admits (and a later edit/clear still follows the rounds it follows today).
|
|
479
|
+
*
|
|
359
480
|
* @param {object[]} events - the session's events.
|
|
360
481
|
* @returns {object[]} the rebuilt events, seqs assigned from 0.
|
|
361
482
|
*/
|
|
362
483
|
export function rebuildSurface(events) {
|
|
363
484
|
const visible = foldSurface(events).visible ?? []
|
|
364
485
|
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
486
|
+
const preserved = events.filter((event) => PRESERVED_STATE_TYPES.has(String(event.type ?? '')))
|
|
487
|
+
// Output index at which each visible source event starts; state events are
|
|
488
|
+
// merged back in front of the first visible event that originally followed
|
|
489
|
+
// them.
|
|
490
|
+
const marks = new Map()
|
|
365
491
|
const headers = events.filter((event) => event.type === 'request/header')
|
|
366
492
|
const headerBefore = (seq) => {
|
|
367
493
|
let found = null
|
|
@@ -466,6 +592,7 @@ export function rebuildSurface(events) {
|
|
|
466
592
|
if (consumed.has(seq)) continue
|
|
467
593
|
const event = bySeq.get(seq)
|
|
468
594
|
if (event === undefined) continue
|
|
595
|
+
marks.set(seq, out.length)
|
|
469
596
|
advance(event)
|
|
470
597
|
const data = event.data ?? {}
|
|
471
598
|
const message = data.message ?? data
|
|
@@ -484,12 +611,12 @@ export function rebuildSurface(events) {
|
|
|
484
611
|
out.push({ seq: 0, type: 'request/header', time: clock, data: header.data })
|
|
485
612
|
next.headerSeq = headerIndex
|
|
486
613
|
}
|
|
487
|
-
out.push({ seq: 0, type: event.type, time:
|
|
614
|
+
out.push({ seq: 0, type: event.type, time: ownTime(event), data: next, surfaceOp: 'append' })
|
|
488
615
|
continue
|
|
489
616
|
}
|
|
490
617
|
if (role === 'user' || event.type === 'user/message') {
|
|
491
618
|
openTurn(header)
|
|
492
|
-
out.push({ seq: 0, type: event.type, time:
|
|
619
|
+
out.push({ seq: 0, type: event.type, time: ownTime(event), data: { ...message, turn }, surfaceOp: 'append' })
|
|
493
620
|
continue
|
|
494
621
|
}
|
|
495
622
|
if (!open) openTurn(header)
|
|
@@ -517,14 +644,14 @@ export function rebuildSurface(events) {
|
|
|
517
644
|
// without it the format layer reads the row as a fork result and refuses it.
|
|
518
645
|
if (data.error !== undefined) payload.error = data.error
|
|
519
646
|
if (event.type === 'tool/call') payload.callId = data.callId
|
|
520
|
-
out.push({ seq: 0, type: event.type, time:
|
|
647
|
+
out.push({ seq: 0, type: event.type, time: ownTime(event), data: payload, surfaceOp: 'append' })
|
|
521
648
|
// A tool call is announced by its assistant message and "started" by a
|
|
522
649
|
// tool/call event; without that event the result looks like a tool that
|
|
523
650
|
// never ran, and the format layer demands the TOOL_NOT_STARTED repair.
|
|
524
651
|
if (event.type === 'assistant/message' && Array.isArray(message?.content)) {
|
|
525
652
|
for (const block of message.content) {
|
|
526
653
|
if (block === null || block === undefined || block.type !== 'tool-call' || !answered.has(block.id)) continue
|
|
527
|
-
out.push({ seq: 0, type: 'tool/call', time:
|
|
654
|
+
out.push({ seq: 0, type: 'tool/call', time: ownTime(event), data: { turn, step, callId: block.id, name: block.name, arguments: block.arguments } })
|
|
528
655
|
// The model reads a call and its result together; a result that arrived
|
|
529
656
|
// many steps later would otherwise leave the step that advertised the
|
|
530
657
|
// call unresolved, which the format layer refuses.
|
|
@@ -540,7 +667,8 @@ export function rebuildSurface(events) {
|
|
|
540
667
|
}
|
|
541
668
|
}
|
|
542
669
|
closeTurn()
|
|
543
|
-
const
|
|
670
|
+
const merged = mergeStateEvents(out, marks, preserved)
|
|
671
|
+
const rebuilt = merged.map((event, index) => ({ ...event, seq: index }))
|
|
544
672
|
// A fork result encodes the sequence it was appended at inside its identity
|
|
545
673
|
// (`forked-tool-result-<callId>-<seq>`); renumbering has to follow, or the host
|
|
546
674
|
// reads the row as a replacement that names a sequence it does not have.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.roots.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/roots.js (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/roots.js (v0.7.1, sha256 028d9c12a395aaab)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.vendor.compact-session.mjs), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/vendor.compact-session.mjs (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/vendor.compact-session.mjs (v0.7.1, sha256 0f27c0d30c2c8215)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -452,7 +452,44 @@ function resolveLog(arg) {
|
|
|
452
452
|
* 无论被谁覆盖都保留的事件:这些是界面渲染「压缩标记」和会话元信息要用的,
|
|
453
453
|
* 体量可以忽略(这个会话里 64 条),丢了会让界面少一行标记或丢标题。
|
|
454
454
|
*/
|
|
455
|
-
const KEEP_ALWAYS = ['compaction/', 'session/']
|
|
455
|
+
const KEEP_ALWAYS = ['compaction/', 'session/', 'goal/']
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 目标事件:`goal/change` 与带 goal source 的轮次。
|
|
459
|
+
*
|
|
460
|
+
* 目标投影折叠的是**整份日志**(包括已被 replace 遮蔽的历史),所以"模型看不见的
|
|
461
|
+
* 事件就能删"这条规则对它不成立:少一条 goal/change 或少一轮,后面的轮次就全成了
|
|
462
|
+
* 孤儿,该会话的 /goal 从此永久报 "goal replay failed"。
|
|
463
|
+
*
|
|
464
|
+
* @param {object} event - 一条日志事件。
|
|
465
|
+
* @returns {boolean} 是否属于目标。
|
|
466
|
+
*/
|
|
467
|
+
function isGoalEvent(event) {
|
|
468
|
+
if (event === null || typeof event !== 'object') return false
|
|
469
|
+
if (event.type === 'goal/change') return true
|
|
470
|
+
return event.type === 'user/message' && event.data?.source?.kind === 'goal'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 任何会让目标事件变少的裁剪都必须放弃:目标语义不在宿主的格式校验里,
|
|
475
|
+
* 写下去就是一整份"能装得上、但 /goal 永久失败"的日志。
|
|
476
|
+
*
|
|
477
|
+
* @param {object[]} original - 裁剪前的日志事件。
|
|
478
|
+
* @param {object[]} kept - 准备写入的事件。
|
|
479
|
+
* @throws {Error} 会删掉目标事件时抛出。
|
|
480
|
+
*/
|
|
481
|
+
export function assertNoGoalEventsDropped(original, kept) {
|
|
482
|
+
let before = 0
|
|
483
|
+
for (const event of original) if (isGoalEvent(event)) before += 1
|
|
484
|
+
if (before === 0) return
|
|
485
|
+
let after = 0
|
|
486
|
+
for (const event of kept) if (isGoalEvent(event)) after += 1
|
|
487
|
+
if (after >= before) return
|
|
488
|
+
throw new Error(
|
|
489
|
+
`压缩会删掉 ${before - after} 条目标事件(goal/change 或目标轮次);` +
|
|
490
|
+
'目标投影从整份日志折叠,删掉任何一条都会让该会话的 /goal 永久失败,已放弃这次写入',
|
|
491
|
+
)
|
|
492
|
+
}
|
|
456
493
|
|
|
457
494
|
/**
|
|
458
495
|
* 生成压缩后的日志事件流。
|
|
@@ -1572,6 +1609,10 @@ export function renumber(events) {
|
|
|
1572
1609
|
'llm/retry', 'llm/retry-started', 'session/title', 'session/title-llm-request',
|
|
1573
1610
|
'command/run', 'command/done', 'compaction/start', 'compaction/summary', 'compaction/end',
|
|
1574
1611
|
'compaction/prune', 'session/end-seed',
|
|
1612
|
+
// `goal/change` 没有 turn/step 字段,格式层不管它;但它的顺序对目标投影有意义
|
|
1613
|
+
// (create/edit 必须排在被它放行的那一轮之前)。归到 head 会被搬到整份日志最前,
|
|
1614
|
+
// 那一轮就成了孤儿,所以必须留在 body 原位。
|
|
1615
|
+
'goal/change',
|
|
1575
1616
|
])
|
|
1576
1617
|
const head = events.filter((e) => !RELATIONSHIP_TYPES.has(e.type))
|
|
1577
1618
|
const body = events.filter((e) => RELATIONSHIP_TYPES.has(e.type))
|
|
@@ -2022,6 +2063,7 @@ export async function stageTrim(logFile, keep, options = {}) {
|
|
|
2022
2063
|
// 旧回合的流式回放记录:与 data.message.content 逐块重复,留最近几轮就够。
|
|
2023
2064
|
const streamKeepTurns = options.streamKeepTurns === undefined ? Infinity : options.streamKeepTurns
|
|
2024
2065
|
const stripped = stripOldStreams(trimmed.events, streamKeepTurns)
|
|
2066
|
+
assertNoGoalEventsDropped(original.events, stripped.events)
|
|
2025
2067
|
const renamed = renumber(stripped.events)
|
|
2026
2068
|
|
|
2027
2069
|
const seqs = new Set(renamed.events.map((e) => e.seq))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.verify-session-log.mjs), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/verify-session-log.mjs (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/verify-session-log.mjs (v0.7.1, sha256 7cf835c053942961)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-log-trim host half (dsh-log-trim.worker.mjs), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-log-trim/lib/worker.mjs (v0.7.
|
|
3
|
+
// Source: ../dsh-log-trim/lib/worker.mjs (v0.7.1, sha256 675a759a767b7e38)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|