Переглянути джерело

refactor(session): share the canonical log basename and JSON snapshots

`session[.vN].jsonl` was assembled independently by the JSONL provider
(twice), the session-log export archive, and the recorded-session fixture
helpers. `dsh-session-format` now owns `sessionFormatLogFilename` and
`parseSessionFormatLogFilename`; the three consumers append only their
compression suffix, and the migration note names the owner.

`snapshotSessionFormatJson` re-implemented the lossless JSON walk that
`dsh-util-values` already publishes as `snapshotJsonValue` + `deepFreeze`,
and the frozen v0 relationship validator carried a third structural JSON
comparison next to `deepEqualJson`. Both now delegate; the format package
reports one `is not lossless JSON` diagnostic per labelled subject instead
of ten member-specific ones, and enumerable accessors whose values survive
a JSON round trip are accepted like `JSON.stringify` accepts them.
Tianyi Cui 3 тижнів тому
батько
коміт
c97df989ff
28 змінених файлів з 143 додано та 121 видалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  4. 1 0
      packages/session-query/session-log-export/package.json
  5. 2 1
      packages/session-query/session-log-export/src/archive.ts
  6. 1 0
      packages/session-query/session-log-export/tsconfig.host.json
  7. 2 1
      packages/session/session-format-v0-to-v1/package.json
  8. 2 18
      packages/session/session-format-v0-to-v1/src/relationships.ts
  9. 3 0
      packages/session/session-format-v0-to-v1/tsconfig.json
  10. 2 2
      packages/session/session-format/README.i18n.yaml
  11. 1 0
      packages/session/session-format/README.md
  12. 1 0
      packages/session/session-format/README.zh.md
  13. 3 0
      packages/session/session-format/package.json
  14. 32 0
      packages/session/session-format/src/filename.ts
  15. 1 0
      packages/session/session-format/src/index.ts
  16. 5 56
      packages/session/session-format/src/json.ts
  17. 31 0
      packages/session/session-format/tests/filename.spec.ts
  18. 4 6
      packages/session/session-format/tests/json.spec.ts
  19. 3 0
      packages/session/session-format/tsconfig.json
  20. 1 0
      packages/session/session-persistence-jsonl/package.json
  21. 10 12
      packages/session/session-persistence-jsonl/src/format.ts
  22. 4 9
      packages/session/session-persistence-jsonl/src/generation.ts
  23. 3 0
      packages/session/session-persistence-jsonl/tsconfig.json
  24. 1 0
      packages/test-support/session-snapshot/package.json
  25. 6 12
      packages/test-support/session-snapshot/src/session-files.ts
  26. 3 0
      packages/test-support/session-snapshot/tsconfig.json
  27. 16 0
      pnpm-lock.yaml
  28. 1 0
      scripts/package-dependency-policy.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: d044350b9c09ec67f7e5ac502e28b21418ae1993
-2026-08-31-released-session-format-migrations.zh.md: 4ac6ddd97e6d6154853cd6cf514844f0f6180252
+2026-08-31-released-session-format-migrations.md: 2c75d0b57a0b513c218b6a67b8c2b31c7cae4d0f
+2026-08-31-released-session-format-migrations.zh.md: d88c643cbfaf7f3d4f52ca6e5fa244a26917eb99

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -22,7 +22,7 @@ Cancellation belongs to the `open`, `stat`, or `list` call that supplied it. Dis
 
 The configured JSONL encoding owns one full suffix, `.jsonl` or `.jsonl.zstd`. Migration reads a stable exact source, decodes the recoverable logical prefix, composes every required edge in memory, validates and syncs a same-directory temporary stage for only the final target, rechecks the source fingerprint, publishes that previously absent target without overwrite, syncs the namespace, and reopens it through current validation before returning a handle. The source never moves or changes; only disposable temporary stages may be moved, linked, or removed. Migration does not synthesize interrupted-turn events: agent-loop appends those repairs through the write handle, while read-only query paths balance them in memory.
 
-Canonical filenames encode the physical format generation: v0 is `session.jsonl` or `session.jsonl.zstd`; every positive generation is lowercase `session.vN.jsonl` or `session.vN.jsonl.zstd`. Publication never renames, replaces, or deletes a committed generation path. If the target already exists, it is accepted only as a regular current-format file with exactly the expected bytes; any other target refuses. Lower generations remain for operator inspection or explicit copying, but normal runtime operations select the numerically highest canonical name and never use retained predecessors as automatic fallback, restore, or downgrade support.
+Canonical filenames encode the physical format generation: v0 is `session.jsonl` or `session.jsonl.zstd`; every positive generation is lowercase `session.vN.jsonl` or `session.vN.jsonl.zstd`. `dsh-session-format` owns the raw basename rule (`sessionFormatLogFilename`, `parseSessionFormatLogFilename`); the JSONL provider, the session-log export archive, and recorded-session fixtures append only the compression suffix. Publication never renames, replaces, or deletes a committed generation path. If the target already exists, it is accepted only as a regular current-format file with exactly the expected bytes; any other target refuses. Lower generations remain for operator inspection or explicit copying, but normal runtime operations select the numerically highest canonical name and never use retained predecessors as automatic fallback, restore, or downgrade support.
 
 The current-format fast path classifies the header from one stable source snapshot, invokes no historical converter or generation write, and passes that snapshot to current decoding without another file read. The decoded log enters the existing bounded revision-keyed memo for an immediate observe-to-resume handoff, while `stat` and `list` deliberately rescan. Multiple edges leave the original generation unchanged and publish only the final target; intermediate versions exist only in memory. A source fingerprint recheck restarts migration when content changes, and exclusive target publication accepts a racing winner only when its bytes match exactly. Cross-process append fencing remains outside this guarantee.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -22,7 +22,7 @@ JSONL provider 在 `open` 为已存储 Session 返回句柄前完成 ensure-curr
 
 配置的 JSONL 编码拥有一个完整后缀:`.jsonl` 或 `.jsonl.zstd`。迁移读取稳定的精确源,解码可恢复逻辑前缀,在内存中组合全部必需迁移边,只为最终目标校验并同步同目录临时 stage,重新检查源 fingerprint,以不覆盖方式发布此前不存在的目标,同步 namespace,并在返回句柄前通过当前格式校验重新打开。源永不移动或改变;只有可丢弃临时 stage 可以被移动、链接或移除。迁移不会合成中断轮次事件:agent-loop 通过写句柄追加这些修复,而只读查询路径在内存中补齐它们。
 
-规范文件名编码物理格式 generation:v0 是 `session.jsonl` 或 `session.jsonl.zstd`;每个正 generation 都是小写 `session.vN.jsonl` 或 `session.vN.jsonl.zstd`。发布绝不重命名、替换或删除已提交 generation 路径。目标已经存在时,只有它是普通当前格式文件且字节与预期完全相同时才接受;其他目标都会拒绝。低 generation 为 operator 检查或显式复制而保留,但普通 runtime 操作选择数值最高的规范名称,绝不把保留的前任当作自动 fallback、restore 或 downgrade 支持。
+规范文件名编码物理格式 generation:v0 是 `session.jsonl` 或 `session.jsonl.zstd`;每个正 generation 都是小写 `session.vN.jsonl` 或 `session.vN.jsonl.zstd`。`dsh-session-format` 拥有原始 basename 规则(`sessionFormatLogFilename`、`parseSessionFormatLogFilename`);JSONL provider、session-log 导出归档与 recorded-session fixture 只追加压缩后缀。发布绝不重命名、替换或删除已提交 generation 路径。目标已经存在时,只有它是普通当前格式文件且字节与预期完全相同时才接受;其他目标都会拒绝。低 generation 为 operator 检查或显式复制而保留,但普通 runtime 操作选择数值最高的规范名称,绝不把保留的前任当作自动 fallback、restore 或 downgrade 支持。
 
 当前格式快速路径从一个稳定源快照分类 header,不调用历史 converter,不写 generation,并把该快照交给当前格式解码,而不再次读取文件。解码日志进入现有按 revision 为键的有界 memo,供紧接的观察到恢复交接复用,而 `stat` 与 `list` 会有意重新扫描。多条迁移边保持原 generation 不变,并只发布最终目标;中间版本只存在于内存。源 fingerprint 重新检查会在内容变化时重启迁移,排他目标发布只在竞争胜者字节完全相同时接受它。跨进程 append 隔离不在此保证内。
 

+ 1 - 0
packages/session-query/session-log-export/package.json

@@ -36,6 +36,7 @@
   },
   "license": "MIT",
   "dependencies": {
+    "@deepseek-ai/dsh-session-format": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "fflate": "^0.8.2",
     "@deepseek-ai/dsh-brand": "workspace:^"

+ 2 - 1
packages/session-query/session-log-export/src/archive.ts

@@ -26,6 +26,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
 import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import { sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
 import type { SessionEvent, SessionHeader, SessionId, SessionStore } from '@deepseek-ai/dsh-session'
 import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
@@ -94,7 +95,7 @@ export type SessionLogZipEntry =
   | { readonly path: string; readonly data: Uint8Array }
 
 /** The current generation's canonical base filename for every exported session log. */
-export const SESSION_LOG_FILENAME = `session.v${SESSION_FORMAT_VERSION}.jsonl`
+export const SESSION_LOG_FILENAME = sessionFormatLogFilename(SESSION_FORMAT_VERSION)
 
 /**
  * Serialize one session's logical log as canonical JSONL text: the header

+ 1 - 0
packages/session-query/session-log-export/tsconfig.host.json

@@ -16,6 +16,7 @@
     { "path": "../../util/brand" },
     { "path": "../../core/session" },
     { "path": "../../interaction/commands" },
+    { "path": "../../session/session-format" },
     { "path": "../../session/session-persistence" },
     { "path": "../session-query" }
   ]

+ 2 - 1
packages/session/session-format-v0-to-v1/package.json

@@ -39,7 +39,8 @@
     }
   },
   "dependencies": {
-    "@deepseek-ai/dsh-session-format": "workspace:^"
+    "@deepseek-ai/dsh-session-format": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"

+ 2 - 18
packages/session/session-format-v0-to-v1/src/relationships.ts

@@ -1,3 +1,4 @@
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import { SessionFormatError } from '@deepseek-ai/dsh-session-format'
 import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
 import { releasedV0Record } from './validation-helpers.ts'
@@ -173,7 +174,7 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
           const start = ptcStarts.get(child)
           if (start === undefined || start.settled) throw new SessionFormatError('tool/code-dispatch has no unique start')
           if (start.root !== root || start.parent !== parent || start.name !== data['name']
-            || !sameJson(start.arguments, data['arguments'] as SessionFormatJsonValue)) {
+            || !deepEqualJson(start.arguments, data['arguments'])) {
             throw new SessionFormatError('tool/code-dispatch does not match its start')
           }
           start.settled = true
@@ -316,23 +317,6 @@ function assertRetryChain(
   }
 }
 
-function sameJson(left: SessionFormatJsonValue, right: SessionFormatJsonValue): boolean {
-  if (left === right) return true
-  if (Array.isArray(left) || Array.isArray(right)) {
-    if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
-    const leftArray = left as readonly SessionFormatJsonValue[]
-    const rightArray = right as readonly SessionFormatJsonValue[]
-    return leftArray.every((value, index) => sameJson(value, rightArray[index] as SessionFormatJsonValue))
-  }
-  if (typeof left !== 'object' || left === null || typeof right !== 'object' || right === null) return false
-  const leftKeys = Object.keys(left)
-  const leftRecord = left as Record<string, SessionFormatJsonValue>
-  const rightRecord = right as Record<string, SessionFormatJsonValue>
-  return leftKeys.length === Object.keys(right).length
-    && leftKeys.every(key => Object.hasOwn(right, key)
-      && sameJson(leftRecord[key] as SessionFormatJsonValue, rightRecord[key] as SessionFormatJsonValue))
-}
-
 function requireOpenStep(
   event: SessionFormatEvent,
   data: Record<string, SessionFormatJsonValue>,

+ 3 - 0
packages/session/session-format-v0-to-v1/tsconfig.json

@@ -19,6 +19,9 @@
     },
     {
       "path": "../session-format"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 2 - 2
packages/session/session-format/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-format/README.md
-README.md: 8fdb0282b3f9a9ac183dfc1703a4dab94004b95a
-README.zh.md: 234df3794175241b2be1f0ab007cd7e8ecdc13d4
+README.md: 81f36bcdaa49a5ba61f8532d3419d46bb66da904
+README.zh.md: 3e6d85d2ee9cae60bc18a4a50e37de11cb5be534

+ 1 - 0
packages/session/session-format/README.md

@@ -55,6 +55,7 @@ The chain validates unique gap-free ordering at construction. A current artifact
 | [`src/chain.ts`](src/chain.ts) | Adjacent plan construction and current bypass |
 | [`src/catalog.ts`](src/catalog.ts) | Physical version dispatch and header classification |
 | [`src/json.ts`](src/json.ts) | Detached lossless JSON snapshots and common coordinate checks |
+| [`src/filename.ts`](src/filename.ts) | Canonical `session[.vN].jsonl` basename shared by persistence, export, and fixtures |
 
 </details>
 

+ 1 - 0
packages/session/session-format/README.zh.md

@@ -55,6 +55,7 @@ const descriptor = catalog.readHeader(physicalHeader)
 | [`src/chain.ts`](src/chain.ts) | 相邻计划构造与当前格式绕过 |
 | [`src/catalog.ts`](src/catalog.ts) | 物理版本分派与标头分类 |
 | [`src/json.ts`](src/json.ts) | 分离的无损 JSON 快照与通用坐标校验 |
+| [`src/filename.ts`](src/filename.ts) | 持久化、导出与 fixture 共用的规范 `session[.vN].jsonl` 文件名 |
 
 </details>
 

+ 3 - 0
packages/session/session-format/package.json

@@ -26,6 +26,9 @@
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-util-values": "workspace:^"
+  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },

+ 32 - 0
packages/session/session-format/src/filename.ts

@@ -0,0 +1,32 @@
+/** Canonical raw log basename shared by every generation-addressed Session artifact. */
+
+import { sessionFormatVersion } from './json.ts'
+
+const CANONICAL_LOG_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u
+
+/**
+ * Name the raw JSONL log of one immutable Session format generation. Version
+ * zero keeps the original `session.jsonl`; every later generation carries a
+ * lowercase numeric `.vN` component before the `.jsonl` suffix.
+ * @param version - non-negative safe integer Session format version.
+ * @returns the canonical basename, without any compression suffix.
+ */
+export function sessionFormatLogFilename(version: number): string {
+  const generation = sessionFormatVersion(version, 'Session log generation version')
+  return generation === 0 ? 'session.jsonl' : `session.v${generation}.jsonl`
+}
+
+/**
+ * Read the generation named by one raw JSONL log basename. Temporary,
+ * uppercase, leading-zero, `.v0`, and compression-suffixed names are not
+ * canonical.
+ * @param filename - one basename from a Session directory or archive.
+ * @returns its Session format version, or `undefined` when the name is not canonical.
+ */
+export function parseSessionFormatLogFilename(filename: string): number | undefined {
+  const match = CANONICAL_LOG_FILENAME.exec(filename)
+  if (match === null) return undefined
+  if (match[1] === undefined) return 0
+  const version = Number(match[1])
+  return Number.isSafeInteger(version) ? version : undefined
+}

+ 1 - 0
packages/session/session-format/src/index.ts

@@ -3,5 +3,6 @@
 export * from './chain.ts'
 export * from './catalog.ts'
 export * from './error.ts'
+export * from './filename.ts'
 export * from './json.ts'
 export * from './types.ts'

+ 5 - 56
packages/session/session-format/src/json.ts

@@ -1,3 +1,4 @@
+import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
 import { SessionFormatError } from './error.ts'
 import type {
   SessionFormatArtifact,
@@ -70,63 +71,11 @@ export function inspectSessionFormatVersion(headerValue: unknown): number {
  * @returns an immutable detached JSON snapshot.
  */
 export function snapshotSessionFormatJson(value: unknown, label = 'Session value'): SessionFormatJsonValue {
-  return snapshotValue(value, label, new Set<object>())
-}
-
-function snapshotValue(value: unknown, label: string, ancestors: Set<object>): SessionFormatJsonValue {
-  if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
-  if (typeof value === 'number') {
-    if (!Number.isFinite(value) || Object.is(value, -0)) {
-      throw new SessionFormatError(`${label} contains a number that JSON cannot preserve`)
-    }
-    return value
-  }
-  if (typeof value !== 'object') throw new SessionFormatError(`${label} contains a non-JSON value`)
-  if (ancestors.has(value)) throw new SessionFormatError(`${label} contains a cycle`)
-  ancestors.add(value)
-  try {
-    if (Array.isArray(value)) {
-      if (Reflect.getPrototypeOf(value) !== Array.prototype) {
-        throw new SessionFormatError(`${label} contains a non-intrinsic JSON array`)
-      }
-      const ownKeys = Reflect.ownKeys(value)
-      const expectedKeys = new Set(['length', ...Array.from({ length: value.length }, (_, index) => String(index))])
-      if (ownKeys.some(key => typeof key !== 'string' || !expectedKeys.has(key))) {
-        throw new SessionFormatError(`${label} contains an array property that JSON cannot preserve`)
-      }
-      for (let index = 0; index < value.length; index += 1) {
-        if (!Object.hasOwn(value, index)) throw new SessionFormatError(`${label} contains a sparse array`)
-        const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index)) as PropertyDescriptor
-        if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
-          throw new SessionFormatError(`${label} contains an array member that JSON cannot preserve`)
-        }
-      }
-      return Object.freeze(value.map(member => snapshotValue(member, label, ancestors)))
-    }
-    const prototype = Reflect.getPrototypeOf(value)
-    if (prototype !== Object.prototype && prototype !== null) {
-      throw new SessionFormatError(`${label} contains a non-plain object`)
-    }
-    const output: Record<string, SessionFormatJsonValue> = {}
-    for (const key of Reflect.ownKeys(value)) {
-      if (typeof key !== 'string') {
-        throw new SessionFormatError(`${label} contains a symbol property that JSON cannot preserve`)
-      }
-      const descriptor = Reflect.getOwnPropertyDescriptor(value, key) as PropertyDescriptor
-      if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
-        throw new SessionFormatError(`${label} contains a property that JSON cannot preserve`)
-      }
-      Object.defineProperty(output, key, {
-        configurable: true,
-        enumerable: true,
-        value: snapshotValue(descriptor.value, label, ancestors),
-        writable: true,
-      })
-    }
-    return Object.freeze(output)
-  } finally {
-    ancestors.delete(value)
+  const snapshot = snapshotJsonValue(value)
+  if (snapshot === undefined) {
+    throw new SessionFormatError(`${label} is not lossless JSON`)
   }
+  return deepFreeze(snapshot) as SessionFormatJsonValue
 }
 
 /**

+ 31 - 0
packages/session/session-format/tests/filename.spec.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { parseSessionFormatLogFilename, sessionFormatLogFilename } from '../src/index.ts'
+
+describe('canonical Session log filenames', () => {
+  it('names version zero without a generation component and later generations with .vN', () => {
+    expect(sessionFormatLogFilename(0)).toBe('session.jsonl')
+    expect(sessionFormatLogFilename(1)).toBe('session.v1.jsonl')
+    expect(sessionFormatLogFilename(27)).toBe('session.v27.jsonl')
+    for (const invalid of [-1, -0, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
+      expect(() => sessionFormatLogFilename(invalid)).toThrow(/non-negative safe integer/)
+    }
+  })
+
+  it('parses only canonical raw generation names', () => {
+    expect(parseSessionFormatLogFilename('session.jsonl')).toBe(0)
+    expect(parseSessionFormatLogFilename('session.v1.jsonl')).toBe(1)
+    expect(parseSessionFormatLogFilename('session.v27.jsonl')).toBe(27)
+    expect(parseSessionFormatLogFilename('session.v9007199254740992.jsonl')).toBeUndefined()
+    for (const name of [
+      'session.v0.jsonl',
+      'session.v01.jsonl',
+      'session.V1.jsonl',
+      'session.v1.backup.jsonl',
+      'session.migration.deadbeef.tmp.jsonl',
+      'session.v1.jsonl.zstd',
+      'session.1.jsonl',
+    ]) {
+      expect(parseSessionFormatLogFilename(name)).toBeUndefined()
+    }
+  })
+})

+ 4 - 6
packages/session/session-format/tests/json.spec.ts

@@ -16,11 +16,9 @@ describe('lossless Session format JSON snapshots', () => {
     ['sparse array', Array(1)],
     ['symbol member', { [Symbol('hidden')]: true }],
     ['non-enumerable member', Object.defineProperty({}, 'hidden', { value: true })],
-    ['accessor member', Object.defineProperty({}, 'value', { enumerable: true, get: () => 1 })],
     ['array property', Object.assign([], { extra: true })],
-    ['array accessor', Object.defineProperty([1], '0', { enumerable: true, get: () => 1 })],
   ])('refuses %s that JSON cannot preserve', (_name, value) => {
-    expect(() => snapshotSessionFormatJson(value)).toThrow(/JSON|sparse|member|property|number/)
+    expect(() => snapshotSessionFormatJson(value, 'payload')).toThrow('payload is not lossless JSON')
   })
 
   it('detaches, freezes, and retains repeated non-cyclic values and __proto__ keys', () => {
@@ -46,9 +44,9 @@ describe('lossless Session format JSON snapshots', () => {
     expect(() => sessionFormatCount(-1, 'count')).toThrow(/non-negative/)
     expect(() => sessionFormatSafeInteger(1.5, 'integer')).toThrow(/safe integer/)
     expect(() => inspectSessionFormatVersion([])).toThrow(/header/)
-    expect(() => snapshotSessionFormatJson(cyclic)).toThrow(/cycle/)
-    expect(() => snapshotSessionFormatJson(new RecordValue())).toThrow(/non-plain/)
-    expect(() => snapshotSessionFormatJson(new ArrayValue(1))).toThrow(/non-intrinsic/)
+    expect(() => snapshotSessionFormatJson(cyclic)).toThrow(/not lossless JSON/)
+    expect(() => snapshotSessionFormatJson(new RecordValue())).toThrow(/not lossless JSON/)
+    expect(() => snapshotSessionFormatJson(new ArrayValue(1))).toThrow(/not lossless JSON/)
   })
 
   it.each([

+ 3 - 0
packages/session/session-format/tsconfig.json

@@ -13,6 +13,9 @@
     },
     {
       "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 1 - 0
packages/session/session-persistence-jsonl/package.json

@@ -32,6 +32,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
+    "@deepseek-ai/dsh-session-format": "workspace:^",
     "@deepseek-ai/dsh-session-format-catalog": "workspace:^",
     "koffi": "^3.1.0",
     "@deepseek-ai/schemastery": "workspace:^"

+ 10 - 12
packages/session/session-persistence-jsonl/src/format.ts

@@ -20,6 +20,7 @@ import type {
   SessionLogOffset as SessionLogOffsetType,
   StorageRecord,
 } from '@deepseek-ai/dsh-session'
+import { parseSessionFormatLogFilename, sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
 import {
   SessionFormatUnsupportedError,
   sessionFormatVersionRefusal,
@@ -35,7 +36,11 @@ export type JsonlCompression = 'zstd' | 'none'
  * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
  */
 export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' {
-  return compression === 'zstd' ? '.jsonl.zstd' : '.jsonl'
+  return `.jsonl${compressionSuffix(compression)}`
+}
+
+function compressionSuffix(compression: JsonlCompression): '.zstd' | '' {
+  return compression === 'zstd' ? '.zstd' : ''
 }
 
 /**
@@ -47,11 +52,7 @@ export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.json
  * @returns the generation filename inside one Session directory.
  */
 export function generationLogFilename(version: number, compression: JsonlCompression): string {
-  if (!Number.isSafeInteger(version) || version < 0 || Object.is(version, -0)) {
-    throw new TypeError('session log generation version must be a non-negative safe integer')
-  }
-  const generation = version === 0 ? '' : `.v${version}`
-  return `session${generation}${logSuffix(compression)}`
+  return `${sessionFormatLogFilename(version)}${compressionSuffix(compression)}`
 }
 
 /**
@@ -66,12 +67,9 @@ export function parseGenerationLogFilename(
   filename: string,
   compression: JsonlCompression,
 ): number | undefined {
-  const suffix = logSuffix(compression)
-  if (filename === `session${suffix}`) return 0
-  const match = new RegExp(`^session\\.v([1-9][0-9]*)${suffix.replaceAll('.', '\\.')}$`).exec(filename)
-  if (match === null) return undefined
-  const version = Number(match[1])
-  return Number.isSafeInteger(version) ? version : undefined
+  const suffix = compressionSuffix(compression)
+  if (!filename.endsWith(suffix)) return undefined
+  return parseSessionFormatLogFilename(filename.slice(0, filename.length - suffix.length))
 }
 
 /**

+ 4 - 9
packages/session/session-persistence-jsonl/src/generation.ts

@@ -20,7 +20,7 @@ import {
 } from 'node:fs/promises'
 import { basename, dirname, join } from 'node:path'
 import type { JsonlCompression } from './format.ts'
-import { logSuffix } from './format.ts'
+import { generationLogFilename, logSuffix } from './format.ts'
 import { publishNewFileWin32 } from './win32.ts'
 import {
   compressZstdFrame,
@@ -445,10 +445,6 @@ function readPhysicalHeader(
   return readRawHeader(bytes)
 }
 
-function generationFilename(version: number, suffix: string): string {
-  return version === 0 ? `session${suffix}` : `session.v${version}${suffix}`
-}
-
 function assertGenerationPaths(
   sourcePath: string,
   sourceVersion: number,
@@ -456,9 +452,8 @@ function assertGenerationPaths(
   currentVersion: number,
   compression: JsonlCompression,
 ): string {
-  const suffix = logSuffix(compression)
-  const expectedSource = generationFilename(sourceVersion, suffix)
-  const expectedCurrent = generationFilename(currentVersion, suffix)
+  const expectedSource = generationLogFilename(sourceVersion, compression)
+  const expectedCurrent = generationLogFilename(currentVersion, compression)
   if (basename(sourcePath) !== expectedSource) {
     throw new Error(`resolved JSONL source path must end with "${expectedSource}": ${sourcePath}`)
   }
@@ -468,7 +463,7 @@ function assertGenerationPaths(
   if (dirname(sourcePath) !== dirname(currentPath)) {
     throw new Error('source and current JSONL generations must share one Session directory')
   }
-  return suffix
+  return logSuffix(compression)
 }
 
 async function syncDirectory(path: string, internals: JsonlGenerationInternals): Promise<void> {

+ 3 - 0
packages/session/session-persistence-jsonl/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../session-persistence"
     },
+    {
+      "path": "../session-format"
+    },
     {
       "path": "../session-format-catalog"
     }

+ 1 - 0
packages/test-support/session-snapshot/package.json

@@ -31,6 +31,7 @@
     "@deepseek-ai/cordis-plugin-include": "workspace:*",
     "@deepseek-ai/dsh-llm-replay": "workspace:^",
     "@deepseek-ai/dsh-loader-smoke": "workspace:*",
+    "@deepseek-ai/dsh-session-format": "workspace:^",
     "js-yaml": "^4.2.0",
     "vitest": "^4.1.8"
   },

+ 6 - 12
packages/test-support/session-snapshot/src/session-files.ts

@@ -1,6 +1,7 @@
 /** Immutable Session-generation filenames used by recorded-session fixtures. */
 
 import { basename, dirname } from 'node:path'
+import { parseSessionFormatLogFilename, sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
 
 /** One canonical recorded-session fixture filename. */
 export interface SessionFixtureFile {
@@ -23,7 +24,6 @@ export interface PersistedSessionFile {
 }
 
 const FIXTURE_FILE = /^session(?:\.([1-9]\d*))?(?:\.v([1-9]\d*))?\.jsonl$/u
-const PERSISTED_FILE = /^session(?:\.v([1-9]\d*))?\.jsonl(\.zstd)?$/u
 
 function nonNegativeSafeInteger(value: number, label: string): void {
   if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {
@@ -182,8 +182,7 @@ export function persistedSessionFilename(
   version: number,
   compression: 'raw' | 'zstd' = 'raw',
 ): string {
-  nonNegativeSafeInteger(version, 'Session format version')
-  return `session${version === 0 ? '' : `.v${version}`}.jsonl${compression === 'zstd' ? '.zstd' : ''}`
+  return `${sessionFormatLogFilename(version)}${compression === 'zstd' ? '.zstd' : ''}`
 }
 
 /**
@@ -193,15 +192,10 @@ export function persistedSessionFilename(
  * @returns Its generation and compression, or `undefined` for noise and noncanonical names.
  */
 export function parsePersistedSessionFilename(name: string): PersistedSessionFile | undefined {
-  const match = PERSISTED_FILE.exec(name)
-  if (match === null) return undefined
-  const version = match[1] === undefined ? 0 : Number(match[1])
-  if (!Number.isSafeInteger(version)) return undefined
-  return {
-    version,
-    compression: match[2] === undefined ? 'raw' : 'zstd',
-    name,
-  }
+  const compression = name.endsWith('.zstd') ? 'zstd' : 'raw'
+  const version = parseSessionFormatLogFilename(compression === 'zstd' ? name.slice(0, -'.zstd'.length) : name)
+  if (version === undefined) return undefined
+  return { version, compression, name }
 }
 
 /**

+ 3 - 0
packages/test-support/session-snapshot/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session/session-format"
+    },
     {
       "path": "../../util/http-proxy"
     }

+ 16 - 0
pnpm-lock.yaml

@@ -6862,6 +6862,9 @@ importers:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
+      '@deepseek-ai/dsh-session-format':
+        specifier: workspace:^
+        version: link:../../session/session-format
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -7080,6 +7083,10 @@ importers:
         version: link:../../core/tools
 
   packages/session/session-format:
+    dependencies:
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -7106,6 +7113,9 @@ importers:
       '@deepseek-ai/dsh-session-format':
         specifier: workspace:^
         version: link:../session-format
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -7163,6 +7173,9 @@ importers:
 
   packages/session/session-persistence-jsonl:
     dependencies:
+      '@deepseek-ai/dsh-session-format':
+        specifier: workspace:^
+        version: link:../session-format
       '@deepseek-ai/dsh-session-format-catalog':
         specifier: workspace:^
         version: link:../session-format-catalog
@@ -9082,6 +9095,9 @@ importers:
       '@deepseek-ai/dsh-loader-smoke':
         specifier: workspace:*
         version: link:../loader-smoke
+      '@deepseek-ai/dsh-session-format':
+        specifier: workspace:^
+        version: link:../../session/session-format
       js-yaml:
         specifier: ^4.2.0
         version: 4.2.0

+ 1 - 0
scripts/package-dependency-policy.ts

@@ -44,6 +44,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
   '@deepseek-ai/dsh-credentials': ['credentialKey'],
   '@deepseek-ai/dsh-deque': ['Deque'],
   '@deepseek-ai/dsh-llm': ['callConfigEquals'],
+  '@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'],
   '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
   '@deepseek-ai/schemastery': ['default'],
 } as const satisfies HostDependencyExports