@starwish-ai/xingyuan-dsh 0.6.3 → 0.6.4

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
@@ -5,6 +5,34 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.4] - 2026-09-11
9
+
10
+ ### Changed
11
+ - **dsh 0.1.5-rc.2 compatibility**: peer and dev dependencies moved from
12
+ `0.1.2-rc.1` to `^0.1.5-rc.2`. Every host/client API this plugin consumes was
13
+ re-verified against the installed 0.1.5-rc.2 build — tool registration,
14
+ settings namespaces, storage backend/domain, user questions, system prompt,
15
+ conversation cards and session tabs are unchanged; the client half needs no
16
+ code changes. XingYuan's prompt sections (orders 5–115) now naturally precede
17
+ the host's relocated sections (10000+).
18
+
19
+ ### Fixed
20
+ - **Session log self-heal now covers format v3**: dsh 0.1.5 names session
21
+ artifacts `session.v{N}.jsonl[.zstd]`; the repair module now selects the
22
+ highest stored generation per session instead of the fixed `session.jsonl`
23
+ names, so `xingyuan/*` events in new sessions keep receiving the `ignorable`
24
+ marker before a cold load.
25
+ - **Legacy sessions with XingYuan cards open again**: dsh 0.1.5's v0/v1/v2 → v3
26
+ migration chain rejects every unknown historical event, `ignorable` included,
27
+ which previously made a session containing `xingyuan/*` events fail to load as
28
+ a whole. Legacy artifacts are now de-poisoned ahead of migration: those event
29
+ rows are replaced with the official inert `hook/invoked` event (seq/time
30
+ preserved, payload valid across every migration edge), so the session migrates
31
+ and opens; other rows stay byte-identical and files without XingYuan events
32
+ are never written. Trade-off: historical cards in those old sessions no longer
33
+ replay (business data lives in SQLite; the pre-repair file is backed up under
34
+ `~/.dsh/xingyuan/session-backups/`).
35
+
8
36
  ## [0.6.3] - 2026-09-06
9
37
 
10
38
  ### Changed
package/README.en-US.md CHANGED
@@ -25,6 +25,9 @@ Tools are only mounted on sessions using the XingYuan preset; other sessions are
25
25
 
26
26
  ## Install
27
27
 
28
+ > Requires DeepSeek Harness `0.1.5-rc.2` (peer dependencies are pinned to that
29
+ > release; check the upgrade notes in [AGENTS.md](./AGENTS.md) before bumping dsh).
30
+
28
31
  ```sh
29
32
  dsh plugin --profile web add @starwish-ai/xingyuan-dsh
30
33
  ```
package/README.md CHANGED
@@ -27,6 +27,9 @@
27
27
 
28
28
  ## 安装
29
29
 
30
+ > 需要 DeepSeek Harness `0.1.5-rc.2`(peer 依赖按该版本锁定;升级 dsh 前先核对
31
+ > [AGENTS.md](./AGENTS.md) 的升级要点)。
32
+
30
33
  ```sh
31
34
  dsh plugin --profile web add @starwish-ai/xingyuan-dsh
32
35
  ```
package/lib/index.js CHANGED
@@ -65,7 +65,12 @@ export async function apply(ctx, config) {
65
65
  try {
66
66
  const report = await repairSessionLogs({ listLiveSessionIds: () => liveSessionIds(sessions) });
67
67
  if (report.patched > 0) {
68
- console.log(`[xingyuan] 会话日志自愈:补标 ${report.eventsMarked} 条卡片事件(${report.patched} 个会话,扫描 ${report.scanned})`);
68
+ const parts = [];
69
+ if (report.eventsMarked > 0)
70
+ parts.push(`补标 ${report.eventsMarked} 条卡片事件`);
71
+ if (report.neutralized > 0)
72
+ parts.push(`旧格式会话迁移前替换 ${report.neutralized} 条卡片事件(历史卡片不再回放)`);
73
+ console.log(`[xingyuan] 会话日志自愈:${parts.join(',')}(${report.patched} 个会话,扫描 ${report.scanned})`);
69
74
  }
70
75
  for (const warning of report.warnings)
71
76
  console.warn(`[xingyuan] 会话日志自愈跳过:${warning}`);
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * 会话日志自愈(bundle 常驻层)。
3
3
  *
4
- * 背景:dsh(0.1.1-rc.2)会话持久化读取端按「本仓库生成的官方事件类型白名单」
5
- * 拒绝未知事件;仓库外插件事件按构造就在名单之外,而写入端(Session.append)
6
- * 当前没有 ignorable 标记通道——因此任何包含 xingyuan/* 卡片事件的会话,
7
- * 进程重启后冷加载会被 SessionFormatUnsupportedError 整体拒绝,连对话文本都
8
- * 打不开(详见 docs/handoff-session-format-unsupported-event.md)。
4
+ * 背景:dsh 会话持久化读取端按「本仓库生成的官方事件类型白名单」拒绝未知事件;
5
+ * 仓库外插件事件按构造不在名单内,而写入端(Session.append)至今没有 ignorable
6
+ * 标记通道。0.1.5-rc.2 的规则分两档:
7
+ * - 当前格式(v3,工件名 session.v3.jsonl[.zstd]):读取端认 `ignorable: true`,
8
+ * 不认识的带标事件被安全跳过且数据原样保留;
9
+ * - 历史格式迁移(v0/v1/v2 → v3):迁移链**拒绝一切未知历史事件,ignorable
10
+ * 也不例外**(官方 alpha-historical-unknown-event-refusal 决策)——旧日志里的
11
+ * xingyuan/* 事件会让整个会话冷加载失败,且不产出 v3 后继、源文件原样保留。
9
12
  *
10
- * 本模块以「事后补标」弥合「写时打标」的缺位:激活期扫描 $DSH_HOME/sessions
11
- * 下的会话工件,把 type 以 xingyuan/ 开头且未标记的事件行补上 `"ignorable": true`
12
- * 后原子替换原文件。读取端本来就支持该字段:不认识的带标事件被安全跳过且
13
- * **不会被过滤**——事件数据原封保留在日志中,卡片回放能力随之保留。
13
+ * 本模块在激活期扫描 $DSH_HOME/sessions,按工件版本分两种处理:
14
+ * - 当前版本工件:补标模式——xingyuan/* 未标记事件行补 `"ignorable": true`;
15
+ * - 历史版本工件:去毒模式——xingyuan/* 事件行替换为官方惰性事件 hook/invoked
16
+ * (seq/time 不变),迁移链即可安全穿过、会话照常打开;代价是该会话历史卡片
17
+ * 事件不再回放(业务数据在 sqlite,不丢失)。仅当目录内无当前版本工件时才动
18
+ * 历史工件(dsh 只按最新代读取,旧代已不被消费)。
14
19
  *
15
20
  * 安全约束(改前必读,勿删):
16
21
  * - 不含星愿事件的会话文件零写入(字节与 mtime 均不变);
17
- * - 只有星愿事件行被重序列化(仅追加一个字段),其余行原样保留字节;
18
- * 行序与 seq 由构造保证不变(本模块从不增删移动任何行);
22
+ * - 只有星愿事件行被重序列化(补标仅追加字段;去毒仅换 type/data),其余行原样
23
+ * 保留字节;行序与 seq 由构造保证不变(本模块从不增删移动任何行);
19
24
  * - 首次改写前把原件备份到 <home>/xingyuan/session-backups/<项目目录>/,
20
25
  * 每会话保留最近 maxBackupPerSession 份;
21
26
  * - 任何看不懂的情况一律整文件跳过不动:撕裂尾、坏帧、header 版本不符、
@@ -28,7 +33,8 @@
28
33
  * (历史上一次实现曾把整个文件重压成单帧导致宿主启动崩溃,已由本模块的
29
34
  * relayout-only 路径兜底自愈),即使无需补标也会重写为合法布局。
30
35
  * - SESSION_FORMAT_VERSION 取自 @deepseek-ai/dsh-session。升级 dsh 时必须核对
31
- * 本模块的前提与布局契约仍然成立。
36
+ * 本模块的前提与布局契约仍然成立(尤其迁移链是否改为接受 ignorable 历史事件——
37
+ * 若接受,去毒模式可回归补标)。
32
38
  */
33
39
  import { constants as zlibConstants, zstdCompress, zstdDecompressSync } from 'node:zlib';
34
40
  import { promisify } from 'node:util';
@@ -41,6 +47,21 @@ const zstdCompressAsync = promisify(zstdCompress);
41
47
  const XINGYUAN_EVENT_PREFIX = 'xingyuan/';
42
48
  /** 工件内明文的防御性上限(远超个人部署可能的会话体积)。 */
43
49
  const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
50
+ /**
51
+ * 去毒模式下替换星愿事件的官方惰性事件类型与载荷:hook/invoked 只做日志记录,
52
+ * 跨 v0/v1/v2 迁移边无任何关系约束,是最安全的中性载体(payload 语义校验要求
53
+ * turn≥0、point/handlerId 非空、dialect ∈ {claude-code, codex})。
54
+ * handlerId 保留可追溯的替换来源,便于人工审计。
55
+ */
56
+ const NEUTRAL_EVENT_TYPE = 'hook/invoked';
57
+ function neutralEvent(seq, time, headerId) {
58
+ return JSON.stringify({
59
+ type: NEUTRAL_EVENT_TYPE,
60
+ seq,
61
+ time,
62
+ data: { turn: 1, point: 'PreToolUse', dialect: 'codex', handlerId: `xingyuan-repair:${headerId}:${String(seq)}` },
63
+ });
64
+ }
44
65
  /**
45
66
  * 每会话自愈标记文件名(放在会话目录内,dsh 不扫描它)。用于把启动期自愈从
46
67
  * 「全量解压解析」降为「stat + 读 40 字节标记」:已修复的会话除非工件字节数
@@ -161,11 +182,12 @@ async function selfVerify(container, expectedPlaintext) {
161
182
  return undefined;
162
183
  }
163
184
  /**
164
- * 对工件明文做行级补标。首行必须是本构建可读的 session header;之后每个完整行
165
- * 必须能解析为对象(任何一行解析失败都整体放弃)。只有 xingyuan/* 且未标记的行
166
- * 会被重序列化,其余行保持原字节。撕裂尾(无换行的最后一段)按原样保留。
185
+ * 对工件明文做行级处理。首行必须是 session header 且版本与工件名一致
186
+ * (mark 要求当前版本;neutralize 要求历史版本)。之后每个完整行必须能解析为
187
+ * 对象(任何一行解析失败都整体放弃)。只有星愿事件行会被重序列化,其余行保持
188
+ * 原字节。撕裂尾(无换行的最后一段)按原样保留。
167
189
  */
168
- function patchPlaintext(plaintext) {
190
+ function patchPlaintext(plaintext, mode, artifactVersion) {
169
191
  if (plaintext.length > MAX_PLAINTEXT_BYTES)
170
192
  return skip('oversized', `${plaintext.length} bytes`);
171
193
  const headerEnd = plaintext.indexOf(10);
@@ -179,20 +201,24 @@ function patchPlaintext(plaintext) {
179
201
  return skip('format', 'header line is not valid JSON');
180
202
  }
181
203
  const headerRecord = header;
204
+ const expectedVersion = mode === 'mark' ? SESSION_FORMAT_VERSION : artifactVersion;
182
205
  if (headerRecord === null || typeof headerRecord !== 'object' ||
183
206
  headerRecord['type'] !== 'session' ||
184
- headerRecord['version'] !== SESSION_FORMAT_VERSION ||
207
+ headerRecord['version'] !== expectedVersion ||
208
+ (mode === 'neutralize' && expectedVersion >= SESSION_FORMAT_VERSION) ||
185
209
  typeof headerRecord['id'] !== 'string' || headerRecord['id'] === '') {
186
- return skip('format', `header version=${String(headerRecord?.['version'])} expect=${SESSION_FORMAT_VERSION}`);
210
+ return skip('format', `header version=${String(headerRecord?.['version'])} expect=${expectedVersion}`);
187
211
  }
212
+ const headerId = headerRecord['id'];
188
213
  const chunks = [plaintext.subarray(0, headerEnd + 1)];
189
214
  let eventsMarked = 0;
215
+ let eventsNeutralized = 0;
190
216
  let totalMarked = 0;
191
217
  let cursor = headerEnd + 1;
192
218
  let lineNumber = 1;
193
219
  for (;;) {
194
220
  const newline = plaintext.indexOf(10, cursor);
195
- // 无换行的尾段是撕裂尾或尚未落盘的部分记录:交给 dsh 自己的截断修复,不在补标范围
221
+ // 无换行的尾段是撕裂尾或尚未落盘的部分记录:交给 dsh 自己的截断修复,不在处理范围
196
222
  if (newline === -1) {
197
223
  chunks.push(plaintext.subarray(cursor));
198
224
  break;
@@ -211,8 +237,12 @@ function patchPlaintext(plaintext) {
211
237
  if (record === null || typeof record !== 'object' || typeof record['type'] !== 'string') {
212
238
  return skip('unparsable', 'event line is not an object with a type field');
213
239
  }
214
- if (record['type'].startsWith(XINGYUAN_EVENT_PREFIX)) {
215
- totalMarked += 1;
240
+ if (!record['type'].startsWith(XINGYUAN_EVENT_PREFIX)) {
241
+ chunks.push(rawLine, Buffer.from('\n', 'utf8'));
242
+ continue;
243
+ }
244
+ totalMarked += 1;
245
+ if (mode === 'mark') {
216
246
  if (record['ignorable'] !== true) {
217
247
  record['ignorable'] = true;
218
248
  eventsMarked += 1;
@@ -222,14 +252,21 @@ function patchPlaintext(plaintext) {
222
252
  // 已是 ignorable:不触发补标(patched 判定只看 eventsMarked),只累计总数供回填
223
253
  chunks.push(rawLine, Buffer.from('\n', 'utf8'));
224
254
  }
255
+ continue;
225
256
  }
226
- else {
227
- chunks.push(rawLine, Buffer.from('\n', 'utf8'));
257
+ // neutralize:seq/time 必须可原样承载,否则整文件放弃(宁可跳过不可伪造坐标)
258
+ const seq = record['seq'];
259
+ const time = record['time'];
260
+ if (!Number.isSafeInteger(seq) || !Number.isSafeInteger(time)) {
261
+ return skip('unparsable', `xingyuan event line ${lineNumber} lacks safe integer seq/time`);
228
262
  }
263
+ eventsNeutralized += 1;
264
+ chunks.push(Buffer.from(neutralEvent(seq, time, headerId), 'utf8'), Buffer.from('\n', 'utf8'));
265
+ }
266
+ if (eventsMarked === 0 && eventsNeutralized === 0) {
267
+ return { kind: 'clean', plaintext, eventsMarked: 0, eventsNeutralized: 0, totalMarked };
229
268
  }
230
- if (eventsMarked === 0)
231
- return { kind: 'clean', plaintext, eventsMarked: 0, totalMarked };
232
- return { kind: 'patched', plaintext: Buffer.concat(chunks), eventsMarked, totalMarked };
269
+ return { kind: 'patched', plaintext: Buffer.concat(chunks), eventsMarked, eventsNeutralized, totalMarked };
233
270
  }
234
271
  function skip(reason, detail) {
235
272
  return { kind: 'skip', reason, detail };
@@ -267,14 +304,10 @@ async function subdirectories(dir) {
267
304
  return [];
268
305
  }
269
306
  }
270
- const SESSION_ARTIFACTS = [
271
- { filename: 'session.jsonl.zstd', compressed: true },
272
- { filename: 'session.jsonl', compressed: false },
273
- ];
274
307
  /** 激活期自愈入口:永不抛出,异常折算进 report(warnings/skipped.error)。 */
275
308
  export async function repairSessionLogs(options = {}) {
276
309
  const log = options.log ?? (() => { });
277
- const report = { scanned: 0, patched: 0, eventsMarked: 0, skipped: {}, warnings: [] };
310
+ const report = { scanned: 0, patched: 0, eventsMarked: 0, neutralized: 0, skipped: {}, warnings: [] };
278
311
  const sessionsRoot = join(options.dshHome ?? resolveDshHome(), 'sessions');
279
312
  const liveIds = safeLiveIds(options.listLiveSessionIds);
280
313
  if (!existsSync(sessionsRoot))
@@ -312,9 +345,12 @@ export async function repairSessionLogs(options = {}) {
312
345
  if (outcome.kind === 'patched') {
313
346
  report.patched += 1;
314
347
  report.eventsMarked += outcome.eventsMarked;
348
+ report.neutralized += outcome.eventsNeutralized;
315
349
  log(outcome.relayoutOnly
316
350
  ? `[xingyuan] 已修复会话 ${outcome.sessionId} 的工件帧布局(首帧须为单行 header)`
317
- : `[xingyuan] 已为会话 ${outcome.sessionId} 补标 ${outcome.eventsMarked} 条卡片事件(ignorable)`);
351
+ : outcome.eventsNeutralized > 0
352
+ ? `[xingyuan] 会话 ${outcome.sessionId} 为旧格式:${outcome.eventsNeutralized} 条卡片事件已替换为惰性事件以便 dsh 迁移(卡片不再回放)`
353
+ : `[xingyuan] 已为会话 ${outcome.sessionId} 补标 ${outcome.eventsMarked} 条卡片事件(ignorable)`);
318
354
  }
319
355
  else if (outcome.kind === 'clean') {
320
356
  report.eventsMarked += outcome.eventsMarked;
@@ -332,13 +368,47 @@ export async function repairSessionLogs(options = {}) {
332
368
  }
333
369
  return report;
334
370
  }
371
+ /**
372
+ * 解析官方工件名:`session.jsonl[.zstd]`(v0)或 `session.v<N>.jsonl[.zstd]`(N>0)。
373
+ * 非规范名(大写 v、前导零、v0 带版本段、迁移临时文件等)与高于当前版本的代
374
+ * 一律不认(口径同 dsh 的 parseGenerationLogFilename)。
375
+ */
376
+ function parseArtifactName(name) {
377
+ const match = /^session(?:\.v([1-9]\d*))?\.jsonl(\.zstd)?$/.exec(name);
378
+ if (match === null)
379
+ return undefined;
380
+ const version = match[1] === undefined ? 0 : Number(match[1]);
381
+ if (!Number.isSafeInteger(version) || version > SESSION_FORMAT_VERSION)
382
+ return undefined;
383
+ return { version, compressed: match[2] !== undefined };
384
+ }
385
+ /**
386
+ * 选目录内最高代的合法工件(dsh 只按最新代读取/迁移):当前版本走补标,
387
+ * 更低版本走去毒,旧代在被更高代遮蔽时不处理(零写入原则)。同代并存时
388
+ * 优先压缩工件(与旧版选择顺序一致)。
389
+ */
335
390
  async function locateArtifact(sessionDir) {
336
- for (const candidate of SESSION_ARTIFACTS) {
337
- const path = join(sessionDir, candidate.filename);
338
- if (existsSync(path))
339
- return { path, compressed: candidate.compressed };
391
+ let entries;
392
+ try {
393
+ entries = await readdir(sessionDir);
340
394
  }
341
- return undefined;
395
+ catch {
396
+ return undefined;
397
+ }
398
+ let best;
399
+ for (const name of entries) {
400
+ const parsed = parseArtifactName(name);
401
+ if (parsed === undefined)
402
+ continue;
403
+ if (best !== undefined) {
404
+ if (parsed.version < best.version)
405
+ continue;
406
+ if (parsed.version === best.version && (best.compressed || !parsed.compressed))
407
+ continue;
408
+ }
409
+ best = { path: join(sessionDir, name), version: parsed.version, compressed: parsed.compressed };
410
+ }
411
+ return best;
342
412
  }
343
413
  async function readMarker(sessionDir) {
344
414
  try {
@@ -388,7 +458,7 @@ async function repairOneFile(artifact, sessionId, projectDirName, backupRoot, ma
388
458
  else {
389
459
  plaintext = raw;
390
460
  }
391
- const outcome = patchPlaintext(plaintext);
461
+ const outcome = patchPlaintext(plaintext, artifact.version === SESSION_FORMAT_VERSION ? 'mark' : 'neutralize', artifact.version);
392
462
  if (outcome.kind === 'skip')
393
463
  return { kind: 'skipped', reason: outcome.reason, detail: outcome.detail };
394
464
  // 布局非法时即使无需补标也要重写为合法容器(其余情况才允许 clean 跳过)
@@ -433,8 +503,15 @@ async function repairOneFile(artifact, sessionId, projectDirName, backupRoot, ma
433
503
  return { kind: 'skipped', reason: 'busy', detail: error instanceof Error ? error.message : String(error) };
434
504
  }
435
505
  await pruneBackups(dirnameOf(backupPath), `${sessionId}-`, maxBackupPerSession);
436
- await writeMarker(dirnameOf(artifact.path), { artifactBytes: nextBytes.length, eventsMarked: outcome.totalMarked });
437
- return { kind: 'patched', sessionId, eventsMarked: outcome.eventsMarked, totalMarked: outcome.totalMarked, relayoutOnly: outcome.kind === 'clean' };
506
+ // neutralize 后文件内不再有星愿事件:标记计数归零,避免下次启动虚报「已补标」
507
+ await writeMarker(dirnameOf(artifact.path), {
508
+ artifactBytes: nextBytes.length,
509
+ eventsMarked: outcome.eventsNeutralized > 0 ? 0 : outcome.totalMarked,
510
+ });
511
+ if (outcome.eventsNeutralized > 0) {
512
+ return { kind: 'patched', sessionId, eventsMarked: 0, eventsNeutralized: outcome.eventsNeutralized, totalMarked: outcome.totalMarked };
513
+ }
514
+ return { kind: 'patched', sessionId, eventsMarked: outcome.eventsMarked, eventsNeutralized: 0, totalMarked: outcome.totalMarked, relayoutOnly: outcome.kind === 'clean' };
438
515
  }
439
516
  function dirnameOf(path) {
440
517
  const index = path.lastIndexOf('/');
@@ -3,7 +3,7 @@
3
3
  * 1) 激活期把包内 preset 发布到用户根(preset-root.ts);
4
4
  * 2) 打开 xingyuan 领域并发布同名服务;
5
5
  * 3) 注册 /xingyuan/* 数据 API 与页面路由;
6
- * 4) 激活期对会话日志做 ignorable 补标自愈(session-log-repair.ts)。
6
+ * 4) 激活期对会话日志做自愈(当前格式补 ignorable、旧格式去毒,见 session-log-repair.ts)。
7
7
  * (sqlite 后端在独立行 '@starwish-ai/xingyuan-dsh/sqlite',见 cordis.patch.yml。)
8
8
  */
9
9
  import type { Context } from '@deepseek-ai/cordis';
@@ -21,9 +21,10 @@ export interface Config {
21
21
  /** 记忆列表单页条数(分页端点缺省 limit)。 */
22
22
  memoryListLimit: number;
23
23
  /**
24
- * 激活期会话日志自愈(默认开):为历史日志里的 xingyuan/* 卡片事件补
25
- * `"ignorable": true` 标记,修掉「重启后旧会话冷加载被整体拒绝」。
26
- * 不含星愿事件的文件零写入;详见 session-log-repair.ts 头注。
24
+ * 激活期会话日志自愈(默认开):当前格式日志补 `"ignorable": true` 标记,
25
+ * 旧格式日志的 xingyuan/* 事件替换为官方便可迁移的惰性事件(详见
26
+ * session-log-repair.ts 头注;dsh 0.1.5 起历史迁移链拒绝未知事件)。
27
+ * 不含星愿事件的文件零写入。
27
28
  */
28
29
  repairSessionLogs: boolean;
29
30
  }
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * 会话日志自愈(bundle 常驻层)。
3
3
  *
4
- * 背景:dsh(0.1.1-rc.2)会话持久化读取端按「本仓库生成的官方事件类型白名单」
5
- * 拒绝未知事件;仓库外插件事件按构造就在名单之外,而写入端(Session.append)
6
- * 当前没有 ignorable 标记通道——因此任何包含 xingyuan/* 卡片事件的会话,
7
- * 进程重启后冷加载会被 SessionFormatUnsupportedError 整体拒绝,连对话文本都
8
- * 打不开(详见 docs/handoff-session-format-unsupported-event.md)。
4
+ * 背景:dsh 会话持久化读取端按「本仓库生成的官方事件类型白名单」拒绝未知事件;
5
+ * 仓库外插件事件按构造不在名单内,而写入端(Session.append)至今没有 ignorable
6
+ * 标记通道。0.1.5-rc.2 的规则分两档:
7
+ * - 当前格式(v3,工件名 session.v3.jsonl[.zstd]):读取端认 `ignorable: true`,
8
+ * 不认识的带标事件被安全跳过且数据原样保留;
9
+ * - 历史格式迁移(v0/v1/v2 → v3):迁移链**拒绝一切未知历史事件,ignorable
10
+ * 也不例外**(官方 alpha-historical-unknown-event-refusal 决策)——旧日志里的
11
+ * xingyuan/* 事件会让整个会话冷加载失败,且不产出 v3 后继、源文件原样保留。
9
12
  *
10
- * 本模块以「事后补标」弥合「写时打标」的缺位:激活期扫描 $DSH_HOME/sessions
11
- * 下的会话工件,把 type 以 xingyuan/ 开头且未标记的事件行补上 `"ignorable": true`
12
- * 后原子替换原文件。读取端本来就支持该字段:不认识的带标事件被安全跳过且
13
- * **不会被过滤**——事件数据原封保留在日志中,卡片回放能力随之保留。
13
+ * 本模块在激活期扫描 $DSH_HOME/sessions,按工件版本分两种处理:
14
+ * - 当前版本工件:补标模式——xingyuan/* 未标记事件行补 `"ignorable": true`;
15
+ * - 历史版本工件:去毒模式——xingyuan/* 事件行替换为官方惰性事件 hook/invoked
16
+ * (seq/time 不变),迁移链即可安全穿过、会话照常打开;代价是该会话历史卡片
17
+ * 事件不再回放(业务数据在 sqlite,不丢失)。仅当目录内无当前版本工件时才动
18
+ * 历史工件(dsh 只按最新代读取,旧代已不被消费)。
14
19
  *
15
20
  * 安全约束(改前必读,勿删):
16
21
  * - 不含星愿事件的会话文件零写入(字节与 mtime 均不变);
17
- * - 只有星愿事件行被重序列化(仅追加一个字段),其余行原样保留字节;
18
- * 行序与 seq 由构造保证不变(本模块从不增删移动任何行);
22
+ * - 只有星愿事件行被重序列化(补标仅追加字段;去毒仅换 type/data),其余行原样
23
+ * 保留字节;行序与 seq 由构造保证不变(本模块从不增删移动任何行);
19
24
  * - 首次改写前把原件备份到 <home>/xingyuan/session-backups/<项目目录>/,
20
25
  * 每会话保留最近 maxBackupPerSession 份;
21
26
  * - 任何看不懂的情况一律整文件跳过不动:撕裂尾、坏帧、header 版本不符、
@@ -28,7 +33,8 @@
28
33
  * (历史上一次实现曾把整个文件重压成单帧导致宿主启动崩溃,已由本模块的
29
34
  * relayout-only 路径兜底自愈),即使无需补标也会重写为合法布局。
30
35
  * - SESSION_FORMAT_VERSION 取自 @deepseek-ai/dsh-session。升级 dsh 时必须核对
31
- * 本模块的前提与布局契约仍然成立。
36
+ * 本模块的前提与布局契约仍然成立(尤其迁移链是否改为接受 ignorable 历史事件——
37
+ * 若接受,去毒模式可回归补标)。
32
38
  */
33
39
  /** $DSH_HOME 解析:环境变量优先,否则 ~/.dsh。 */
34
40
  export declare function resolveDshHome(): string;
@@ -54,8 +60,10 @@ export interface RepairReport {
54
60
  scanned: number;
55
61
  /** 实际改写的会话文件数。 */
56
62
  patched: number;
57
- /** 补上 ignorable 标记的事件总数。 */
63
+ /** 补上 ignorable 标记的事件总数(当前格式工件)。 */
58
64
  eventsMarked: number;
65
+ /** 旧格式工件中替换为惰性事件的星愿事件总数(迁移链拒绝未知历史事件)。 */
66
+ neutralized: number;
59
67
  skipped: Partial<Record<SkipReason, number>>;
60
68
  warnings: string[];
61
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starwish-ai/xingyuan-dsh",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "星愿 XingYuan for DeepSeek Harness: 对话创建愿望/任务、机会日打卡、微行动、记忆与图表的 Agent Preset 组合包",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,14 +93,14 @@
93
93
  },
94
94
  "peerDependencies": {
95
95
  "@deepseek-ai/cordis": "^4.0.2",
96
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
97
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
98
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
99
- "@deepseek-ai/dsh-storage": "^0.1.2-rc.1",
100
- "@deepseek-ai/dsh-storage-domain": "^0.1.2-rc.1",
101
- "@deepseek-ai/dsh-system-prompt": "^0.1.2-rc.1",
102
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
103
- "@deepseek-ai/dsh-user-questions": "^0.1.2-rc.1",
96
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
97
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
98
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
99
+ "@deepseek-ai/dsh-storage": "^0.1.5-rc.2",
100
+ "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
101
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
102
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
103
+ "@deepseek-ai/dsh-user-questions": "^0.1.5-rc.2",
104
104
  "@deepseek-ai/schemastery": "*",
105
105
  "react": "^18.2.0",
106
106
  "zod": "^4.4.3"
@@ -113,27 +113,28 @@
113
113
  "devDependencies": {
114
114
  "@deepseek-ai/cordis": "4.0.2",
115
115
  "@deepseek-ai/cordis-plugin-loader": "1.0.2",
116
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
117
- "@deepseek-ai/dsh-api-remotes": "0.1.2-rc.1",
118
- "@deepseek-ai/dsh-api-session-controller": "0.1.2-rc.1",
119
- "@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
120
- "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
121
- "@deepseek-ai/dsh-client-store": "0.1.2-rc.1",
122
- "@deepseek-ai/dsh-client-ui-chat": "0.1.2-rc.1",
123
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
124
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
125
- "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
126
- "@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.2-rc.1",
127
- "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
128
- "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
129
- "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
130
- "@deepseek-ai/dsh-session": "0.1.2-rc.1",
131
- "@deepseek-ai/dsh-settings": "0.1.2-rc.1",
132
- "@deepseek-ai/dsh-storage": "0.1.2-rc.1",
133
- "@deepseek-ai/dsh-storage-domain": "0.1.2-rc.1",
134
- "@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
135
- "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
136
- "@deepseek-ai/dsh-user-questions": "0.1.2-rc.1",
116
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
117
+ "@deepseek-ai/dsh-api-remotes": "0.1.5-rc.2",
118
+ "@deepseek-ai/dsh-api-session-controller": "0.1.5-rc.2",
119
+ "@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
120
+ "@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
121
+ "@deepseek-ai/dsh-client-store": "0.1.5-rc.2",
122
+ "@deepseek-ai/dsh-client-ui-chat": "0.1.5-rc.2",
123
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
124
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.2",
125
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
126
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.5-rc.2",
127
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
128
+ "@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
129
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
130
+ "@deepseek-ai/dsh-session": "0.1.5-rc.2",
131
+ "@deepseek-ai/dsh-session-format-catalog": "0.1.5-rc.2",
132
+ "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
133
+ "@deepseek-ai/dsh-storage": "0.1.5-rc.2",
134
+ "@deepseek-ai/dsh-storage-domain": "0.1.5-rc.2",
135
+ "@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
136
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
137
+ "@deepseek-ai/dsh-user-questions": "0.1.5-rc.2",
137
138
  "@deepseek-ai/schemastery": "^3.18.0",
138
139
  "@types/node": "^24.0.0",
139
140
  "@types/react": "~18.3.1",