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 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.13",
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.0'
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.13'
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.13'
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.18, sha256 3a2e3a4008b8c061)
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.18'
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.18, sha256 5e1e8cce1d168f6c)
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.0, sha256 3aa1fdcbb120ab06)
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.0, sha256 5ebed01988b5bfcf)
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.0, sha256 49dcb38f01b7c751)
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.0'
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.0, sha256 5ca2460b42a3a776)
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.0, sha256 8b5c75ec49d48981)
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({ staging, backup }, logFile)
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({ staging, backup }, logFile)
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({ staging, backup }, logFile)
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: clock, data: next, surfaceOp: 'append' })
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: clock, data: { ...message, turn }, surfaceOp: 'append' })
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: clock, data: payload, surfaceOp: 'append' })
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: clock, data: { turn, step, callId: block.id, name: block.name, arguments: block.arguments } })
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 rebuilt = out.map((event, index) => ({ ...event, seq: index }))
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.0, sha256 028d9c12a395aaab)
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.0, sha256 e3782b79b4db0062)
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.0, sha256 7cf835c053942961)
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.0, sha256 675a759a767b7e38)
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
@@ -1,7 +1,7 @@
1
1
  {
2
- "dsh-edit-turn": "0.5.18",
2
+ "dsh-edit-turn": "0.5.19",
3
3
  "dsh-rerun-turn": "0.2.11",
4
4
  "dsh-delete-turn": "0.2.4",
5
5
  "dsh-markdown-bubble": "0.1.5",
6
- "dsh-log-trim": "0.7.0"
6
+ "dsh-log-trim": "0.7.1"
7
7
  }