@deepseek-ai/dsh-session 0.2.1-alpha.1 → 0.2.1-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -24,8 +24,11 @@
24
24
  en: aa5f1951ecf3ac0c
25
25
  zh: 62a599ace8f2bf15
26
26
  /deepseek-ai-dsh-session/use-this-package/read-the-log:
27
- en: 2b29cfeac7a2baa8
28
- zh: b535b04dcbc29d3d
27
+ en: c4bf3103b9762b6c
28
+ zh: 369c734bb20de2ea
29
+ /deepseek-ai-dsh-session/use-this-package/write-experimental-plugin-records:
30
+ en: 6c758f98535b734c
31
+ zh: ac54342937477f37
29
32
  /deepseek-ai-dsh-session/use-this-package/fork-a-session:
30
33
  en: 1c40d17a786059d3
31
34
  zh: 81320ce118977c65
@@ -99,8 +102,8 @@
99
102
  en: 5eb30cfb410d52c0
100
103
  zh: da56365b8ab896da
101
104
  /deepseek-ai-dsh-session/known-limitations-and-deferred-work:
102
- en: daaf56dda2bcaff1
103
- zh: 7d24de2deb1c5dc5
105
+ en: 45afe96a4e252db9
106
+ zh: 920bcc011823817b
104
107
  /deepseek-ai-dsh-session/known-limitations-and-deferred-work/dev-note:
105
108
  en: e67f633dc6fe7773
106
109
  zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -63,6 +63,18 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and
63
63
 
64
64
  Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers.
65
65
 
66
+ <a id="write-experimental-plugin-records"></a>
67
+
68
+ ### Write experimental plugin records
69
+
70
+ `appendPluginRecord(session, type, data)` appends one plugin record for a package under `packages/experimental/`; the `verify-plugin-record-callers` check rejects any other production caller in this repository. Plugins outside this repository must not call it either, although no repository check can inspect them.
71
+
72
+ Declare each record in `PluginRecordMap` through `@deepseek-ai/dsh-session/types`, with a description and an explicit payload type annotation in the owning experimental package's `src/`. Names use `plugin:<owner>/<record>`, where `owner` is the suffix of the package name `@deepseek-ai/dsh-experimental-<owner>`; for example, `plugin:pi-extensions/entry`. TypeScript checks each name and payload against its declaration; the writer's runtime JSON snapshot rejects values that cannot be preserved losslessly. Put extension-defined or other dynamic names inside the payload of a declared record.
73
+
74
+ The [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md) lists current plugin-record declarations with their owners, descriptions, payload type annotations, and source files. These declarations remain separate from `SessionEventMap`, expanded persistence schemas, released type history, and `KNOWN_SESSION_EVENT_TYPES`. Records carry `ignorable: true`: a build that does not recognize one retains and skips it on read. Records never enter the model-visible surface. Resume and fork carry them with the rest of the log; Session format migration keeps them on a best-effort basis.
75
+
76
+ `pluginRecordOf(event)` returns an event as a record, or `undefined` for any other event; a `ctx.sessionProjections` unit that passes each event to it rebuilds plugin state on resume. Its `data` remains `unknown` even when the current map declares the name. The owner validates it before use because earlier builds can write different payloads, and the V3-to-V4 format edge can rename an unknown ignorable V3 event into the same `plugin:` namespace. Removing or renaming a declaration does not discard stored records. The [ignorable events decision](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) owns the compatibility policy.
77
+
66
78
  ### Fork a session
67
79
 
68
80
  `ctx.sessions.fork(source, boundary?, childSessionId?)` copies an exact inclusive event prefix (default: the last event) from a live source. `buildForkSeed` in `dsh-session/fork` places an inherited marker after the copied events, adds missing error tool results only for the open step, and closes that step and turn with a `forked` reason. Closed steps and turns remain unchanged, including historical missing results. The marker and closers belong to the child; `inheritedEventCount` counts only the copied prefix.
@@ -188,7 +200,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
188
200
  These limits define when the session store needs special care. They are current package constraints, not a task backlog.
189
201
 
190
202
  - **Live-source Store API** — persisted-but-unloaded sources use the Host observation path. Fork closes only the open tail and does not repair missing results in previously closed steps.
191
- - **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the [adjacent migration chain](../../session/session-format-catalog/README.md) converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
203
+ - **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the [adjacent migration chain](../../session/session-format-catalog/README.md) converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). [Reader compatibility](../../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md) determines whether a change needs a version increase.
192
204
  - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
193
205
  - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
194
206
 
package/README.zh.md CHANGED
@@ -63,6 +63,18 @@ session.deriveMessages() // the derived model history
63
63
 
64
64
  会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。
65
65
 
66
+ <a id="write-experimental-plugin-records"></a>
67
+
68
+ ### 写入实验性插件记录
69
+
70
+ `appendPluginRecord(session, type, data)` 为 `packages/experimental/` 下的包追加一条插件记录;`verify-plugin-record-callers` 检查拒绝本仓库中任何其他生产调用方。本仓库之外的插件同样不得调用它,尽管仓库检查无法检查它们。
71
+
72
+ 在所属实验性包的 `src/` 中,通过 `@deepseek-ai/dsh-session/types` 的 `PluginRecordMap` 为每条记录声明描述与显式 payload 类型注解。名称使用 `plugin:<owner>/<record>`,其中 `owner` 是包名 `@deepseek-ai/dsh-experimental-<owner>` 的后缀;例如 `plugin:pi-extensions/entry`。TypeScript 按声明检查每个名称与 payload;写入方运行时的 JSON 快照拒绝无法无损保留的值。扩展自定义名称或其他动态名称放在已声明记录的 payload 中。
73
+
74
+ [实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md)列出当前插件记录声明及其所属包、描述、payload 类型注解与源文件。这些声明与 `SessionEventMap`、展开的持久化 schema、已发布类型历史及 `KNOWN_SESSION_EVENT_TYPES` 分开。记录带有 `ignorable: true`:不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它们;Session 格式迁移以尽力而为的方式保留它们。
75
+
76
+ `pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。即使当前映射声明了该名称,其 `data` 仍为 `unknown`。所属包在使用前校验数据,因为早先构建可能写入不同的 payload,V3-to-V4 格式迁移边也可能把未知的 ignorable V3 事件改名放入同一个 `plugin:` 命名空间。删除或重命名声明不会丢弃已存记录。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)定义兼容性策略。
77
+
66
78
  ### 派生会话的 fork
67
79
 
68
80
  `ctx.sessions.fork(source, boundary?, childSessionId?)` 从实时源会话复制包含切点的精确事件前缀(默认:最后一个事件)。`dsh-session/fork` 的 `buildForkSeed` 在复制事件之后放置继承标记,仅为开放步骤补缺失的错误工具结果,并以 `forked` 原因关闭步骤和轮次。已关闭的步骤与轮次保持原样,包括历史缺失结果。标记和结束事件属于子会话;`inheritedEventCount` 只统计复制的前缀。
@@ -188,7 +200,7 @@ session.deriveMessages() // the derived model history
188
200
  这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
189
201
 
190
202
  - **Store API 仅接受活跃源会话** — 已持久化但未加载的源会话通过 Host 观察路径处理。Fork 仅闭合开放尾部,不修复先前已闭合步骤中缺失的结果。
191
- - **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;[相邻迁移链](../../session/session-format-catalog/README.zh.md)在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
203
+ - **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;[相邻迁移链](../../session/session-format-catalog/README.zh.md)在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。[读取器兼容性](../../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md)决定变更是否需要升版本。
192
204
  - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
193
205
  - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
194
206
 
package/lib/index.js CHANGED
@@ -38,20 +38,20 @@ function SessionLogOffset(value) {
38
38
  * event-body read composes the build-static adjacent chain and publishes only
39
39
  * this final generation before constructing a Session.
40
40
  *
41
- * The version is a single monotonic integer with no major/minor split. Whether
42
- * a bump is needed is decided by what the WRITER emits, never by what a newer
43
- * reader can accept: bump exactly when an older runtime could no longer handle
44
- * a new log with full semantic correctness ("parses without error" is not
45
- * correctness — silently skipping content that shapes reconstruction is a
46
- * wrong read). Only structural changes reach that bar: the header shape, the
47
- * {@link SessionEvent} envelope, core event semantics, or the surface
48
- * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
49
- * Adding an ordinary event type does not bump — the per-event
50
- * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
51
- * in doubt, bump: a near-identity upgrade step is almost free, a missed bump
52
- * makes older runtimes read new logs wrong silently. The released migration,
53
- * immutable prior-generation, and current fast-path rules are recorded in
54
- * `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
41
+ * The version is a single monotonic integer with no major/minor split. Retain
42
+ * it when older readers resume correctly, preserve safely ignorable records,
43
+ * or refuse required features before execution, mutation, or tail repair.
44
+ * New readers must preserve historical meanings, including logs mixing old
45
+ * and new record representations. Bump when an existing discriminator cannot
46
+ * prevent unsafe interpretation; a structural difference alone is insufficient.
47
+ * Unknown fields and values require evidence of the released reader's actual
48
+ * admission behavior. The per-event {@link SessionEvent.ignorable} marker
49
+ * governs unknown event types, not arbitrary nested payload changes.
50
+ * Same-version readers retain earlier representations: the adjacent migration
51
+ * chain does not run when stored and current versions match. A subsequent
52
+ * migration must account for all supported representations of its source.
53
+ * Compatibility review and the retained migration obligations are recorded in
54
+ * `.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md`.
55
55
  */
56
56
  const SESSION_FORMAT_VERSION = 4;
57
57
  //#endregion
@@ -135,6 +135,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
135
135
  "turn/start",
136
136
  "user/message",
137
137
  "web/deepseek-search-llm-request",
138
+ "working-directory/change",
138
139
  "workspace/changes"
139
140
  ]);
140
141
  /** Event types whose model-visible effects require an explicit pure interpreter. */
@@ -1238,6 +1239,79 @@ function invokeContainedSessionObservers(ctx, name, id, args, callbacks) {
1238
1239
  /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
1239
1240
  const attachments = /* @__PURE__ */ new WeakMap();
1240
1241
  /**
1242
+ * Grammar of a {@link PluginRecordType}: `plugin:` followed by
1243
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
1244
+ * each segment starting with a letter or digit.
1245
+ */
1246
+ const PLUGIN_RECORD_TYPE = /^plugin:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/u;
1247
+ /**
1248
+ * Whether one event type name follows the plugin record grammar.
1249
+ * @param type - an event type name.
1250
+ * @returns whether the name is a {@link PluginRecordType}.
1251
+ */
1252
+ function isPluginRecordType(type) {
1253
+ return PLUGIN_RECORD_TYPE.test(type);
1254
+ }
1255
+ /**
1256
+ * Type one constructed plugin record as a log event after checking its type
1257
+ * against the plugin record grammar. A restored Session already holds events
1258
+ * whose types are outside `SessionEventMap` when they are marked ignorable;
1259
+ * a plugin record is such an event.
1260
+ * @param value - the frozen record envelope built by {@link Session}.
1261
+ * @throws when the record type is outside the plugin record grammar.
1262
+ */
1263
+ function assertPluginRecordEvent(value) {
1264
+ const { type } = value;
1265
+ if (!isPluginRecordType(type)) throw new Error(`plugin record type "${type}" must be "plugin:" followed by lowercase slash-separated segments`);
1266
+ }
1267
+ /** Module-private access to {@link Session}'s record commit, assigned once by its static block. */
1268
+ let commitPluginRecord;
1269
+ /**
1270
+ * Append one experimental plugin record: an event marked `ignorable` whose
1271
+ * type is declared in {@link PluginRecordMap}, outside {@link SessionEventMap}.
1272
+ * The plugin record catalog lists it separately from released event schemas.
1273
+ * A reader that does not recognize the type retains and skips the record, which never
1274
+ * enters the model-visible surface, and fork and resume carry it with the rest
1275
+ * of the log. A Session format migration retains records on a best-effort
1276
+ * basis, so a record holds plugin-owned state that its owner can lose without
1277
+ * changing how the rest of the log is interpreted.
1278
+ *
1279
+ * Only production source under `packages/experimental/` may call this
1280
+ * function; the `verify-plugin-record-callers` gate rejects any other
1281
+ * production caller in this repository. Read records back with
1282
+ * {@link pluginRecordOf}.
1283
+ * @param session - the Session whose log receives the record.
1284
+ * @param type - declared record name: `plugin:` followed by slash-separated
1285
+ * segments of lowercase letters, digits, `.`, `_`, and `-`, each starting
1286
+ * with a letter or digit.
1287
+ * @param data - JSON payload, snapshotted before it enters the log.
1288
+ * @returns the sequence number of the committed record.
1289
+ * @throws when `type` is outside that grammar, when `data` is not losslessly
1290
+ * JSON-serializable, or when the call reenters another append's publication;
1291
+ * a rejected record does not change the log.
1292
+ */
1293
+ function appendPluginRecord(session, type, data) {
1294
+ return commitPluginRecord(session, type, data);
1295
+ }
1296
+ /**
1297
+ * Read one committed event as a plugin record. The V3-to-V4 format edge also
1298
+ * renames each unknown ignorable V3 event into the `plugin:` namespace, so an
1299
+ * owner names its records under its own package name and validates `data`.
1300
+ * @param event - any committed Session event, live or restored.
1301
+ * @returns the record, or undefined when the event is not an ignorable event
1302
+ * whose type follows the plugin record grammar.
1303
+ */
1304
+ function pluginRecordOf(event) {
1305
+ const type = event.type;
1306
+ if (event.ignorable !== true || !isPluginRecordType(type)) return void 0;
1307
+ return {
1308
+ type,
1309
+ seq: event.seq,
1310
+ time: event.time,
1311
+ data: event.data
1312
+ };
1313
+ }
1314
+ /**
1241
1315
  * An event-sourced session: an append-only log of {@link SessionEvent}s.
1242
1316
  *
1243
1317
  * Plain class (not a Service) — create live instances via
@@ -1448,8 +1522,7 @@ var Session = class Session {
1448
1522
  if (dataSnapshot === void 0) throw new Error(`session event "${type}" carries non-JSON-serializable data`);
1449
1523
  const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata);
1450
1524
  if (surfaceMetadataSnapshot === void 0) throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
1451
- const entry = attachments.get(this);
1452
- if (entry?.appending) throw new Error("session append cannot reenter while another append is being published");
1525
+ const entry = this.publicationEntry();
1453
1526
  const event = deepFreeze({
1454
1527
  type,
1455
1528
  seq: SessionSeq(this.log.length),
@@ -1458,6 +1531,50 @@ var Session = class Session {
1458
1531
  ...surfaceMetadataSnapshot
1459
1532
  });
1460
1533
  validateSessionEventData(event, `session event "${type}" at seq ${event.seq}`);
1534
+ this.commit(event, entry);
1535
+ return event;
1536
+ }
1537
+ static {
1538
+ commitPluginRecord = (session, type, data) => session.#appendRecord(type, data);
1539
+ }
1540
+ /**
1541
+ * Commit one plugin record; {@link appendPluginRecord} is the only caller.
1542
+ * @param type - record type, checked against the plugin record grammar.
1543
+ * @param data - JSON payload, snapshotted before it enters the log.
1544
+ * @returns the record's sequence number.
1545
+ */
1546
+ #appendRecord(type, data) {
1547
+ const dataSnapshot = snapshotJsonValue(data);
1548
+ if (dataSnapshot === void 0) throw new Error(`plugin record "${type}" carries non-JSON-serializable data`);
1549
+ const entry = this.publicationEntry();
1550
+ const record = deepFreeze({
1551
+ type,
1552
+ seq: SessionSeq(this.log.length),
1553
+ time: Date.now(),
1554
+ data: dataSnapshot,
1555
+ ignorable: true
1556
+ });
1557
+ assertPluginRecordEvent(record);
1558
+ this.commit(record, entry);
1559
+ return record.seq;
1560
+ }
1561
+ /**
1562
+ * Read the store attachment for one append, refusing an append that
1563
+ * reenters while another append is being published.
1564
+ * @returns the attachment, or undefined for a detached Session.
1565
+ */
1566
+ publicationEntry() {
1567
+ const entry = attachments.get(this);
1568
+ if (entry?.appending) throw new Error("session append cannot reenter while another append is being published");
1569
+ return entry;
1570
+ }
1571
+ /**
1572
+ * Accept one frozen candidate at the next sequence number, enter it into the
1573
+ * log, and notify `session/event` observers with per-listener containment.
1574
+ * @param event - the candidate built at the current log length.
1575
+ * @param entry - the attachment read before the candidate was built.
1576
+ */
1577
+ commit(event, entry) {
1461
1578
  this.surfaceManager.validateNext(event);
1462
1579
  if (entry !== void 0) entry.appending = true;
1463
1580
  try {
@@ -1471,7 +1588,6 @@ var Session = class Session {
1471
1588
  this.log.push(event);
1472
1589
  this.eventsSnapshot = void 0;
1473
1590
  if (callbacks !== void 0 && entry !== void 0) invokeContainedSessionObservers(entry.emitCtx, "session/event", entry.id, callbackArgs, callbacks);
1474
- return event;
1475
1591
  } finally {
1476
1592
  if (entry !== void 0) {
1477
1593
  entry.appending = false;
@@ -1930,4 +2046,4 @@ var SessionStore = class extends Service {
1930
2046
  }
1931
2047
  };
1932
2048
  //#endregion
1933
- export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, ToolCallRecovery, adoptSessionEvent, buildForkSeed, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, snapshotSessionEvent };
2049
+ export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, ToolCallRecovery, adoptSessionEvent, appendPluginRecord, buildForkSeed, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, pluginRecordOf, snapshotSessionEvent };
@@ -10,7 +10,7 @@ import type { Scoped } from '@deepseek-ai/dsh-scope';
10
10
  import type { Message } from '@deepseek-ai/dsh-llm';
11
11
  import { SessionLogOffset, SessionSeq } from './types.ts';
12
12
  import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol';
13
- import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts';
13
+ import type { CreateSessionOptions, EpochHeader, PluginRecord, PluginRecordMap, PluginRecordType, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts';
14
14
  import type { SessionSurface, SessionMessageProjection } from './surface.ts';
15
15
  import type { ToolHistory } from '@deepseek-ai/dsh-llm';
16
16
  export { buildForkSeed } from './fork.ts';
@@ -90,6 +90,40 @@ export declare function adoptSessionEvent<T extends SessionEvent>(event: T): T;
90
90
  * @returns a detached event snapshot with a validated, deeply frozen message.
91
91
  */
92
92
  export declare function snapshotSessionEvent<T extends SessionEvent>(event: T): T;
93
+ /**
94
+ * Append one experimental plugin record: an event marked `ignorable` whose
95
+ * type is declared in {@link PluginRecordMap}, outside {@link SessionEventMap}.
96
+ * The plugin record catalog lists it separately from released event schemas.
97
+ * A reader that does not recognize the type retains and skips the record, which never
98
+ * enters the model-visible surface, and fork and resume carry it with the rest
99
+ * of the log. A Session format migration retains records on a best-effort
100
+ * basis, so a record holds plugin-owned state that its owner can lose without
101
+ * changing how the rest of the log is interpreted.
102
+ *
103
+ * Only production source under `packages/experimental/` may call this
104
+ * function; the `verify-plugin-record-callers` gate rejects any other
105
+ * production caller in this repository. Read records back with
106
+ * {@link pluginRecordOf}.
107
+ * @param session - the Session whose log receives the record.
108
+ * @param type - declared record name: `plugin:` followed by slash-separated
109
+ * segments of lowercase letters, digits, `.`, `_`, and `-`, each starting
110
+ * with a letter or digit.
111
+ * @param data - JSON payload, snapshotted before it enters the log.
112
+ * @returns the sequence number of the committed record.
113
+ * @throws when `type` is outside that grammar, when `data` is not losslessly
114
+ * JSON-serializable, or when the call reenters another append's publication;
115
+ * a rejected record does not change the log.
116
+ */
117
+ export declare function appendPluginRecord<K extends Extract<keyof PluginRecordMap, PluginRecordType>>(session: Session, type: K, data: NoInfer<PluginRecordMap[K]>): SessionSeq;
118
+ /**
119
+ * Read one committed event as a plugin record. The V3-to-V4 format edge also
120
+ * renames each unknown ignorable V3 event into the `plugin:` namespace, so an
121
+ * owner names its records under its own package name and validates `data`.
122
+ * @param event - any committed Session event, live or restored.
123
+ * @returns the record, or undefined when the event is not an ignorable event
124
+ * whose type follows the plugin record grammar.
125
+ */
126
+ export declare function pluginRecordOf(event: SessionEvent): PluginRecord | undefined;
93
127
  /**
94
128
  * An event-sourced session: an append-only log of {@link SessionEvent}s.
95
129
  *
@@ -99,6 +133,7 @@ export declare function snapshotSessionEvent<T extends SessionEvent>(event: T):
99
133
  * @typert object
100
134
  */
101
135
  export declare class Session {
136
+ #private;
102
137
  private log;
103
138
  /** Single incremental owner of surface acceptance and projection state. */
104
139
  private readonly surfaceManager;
@@ -240,6 +275,19 @@ export declare class Session {
240
275
  * rejects before the log changes.
241
276
  */
242
277
  append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []): SessionEvent<T>;
278
+ /**
279
+ * Read the store attachment for one append, refusing an append that
280
+ * reenters while another append is being published.
281
+ * @returns the attachment, or undefined for a detached Session.
282
+ */
283
+ private publicationEntry;
284
+ /**
285
+ * Accept one frozen candidate at the next sequence number, enter it into the
286
+ * log, and notify `session/event` observers with per-listener containment.
287
+ * @param event - the candidate built at the current log length.
288
+ * @param entry - the attachment read before the candidate was built.
289
+ */
290
+ private commit;
243
291
  /** Cached fold of the request-header events — see {@link requestHeader}. */
244
292
  private headerFold;
245
293
  /** Log position (events consumed) the header fold has reached. */
@@ -324,6 +324,77 @@ function invokeContainedSessionObservers(ctx, name, id, args, callbacks) {
324
324
  }
325
325
  /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
326
326
  const attachments = new WeakMap();
327
+ /**
328
+ * Grammar of a {@link PluginRecordType}: `plugin:` followed by
329
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
330
+ * each segment starting with a letter or digit.
331
+ */
332
+ const PLUGIN_RECORD_TYPE = /^plugin:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/u;
333
+ /**
334
+ * Whether one event type name follows the plugin record grammar.
335
+ * @param type - an event type name.
336
+ * @returns whether the name is a {@link PluginRecordType}.
337
+ */
338
+ function isPluginRecordType(type) {
339
+ return PLUGIN_RECORD_TYPE.test(type);
340
+ }
341
+ /**
342
+ * Type one constructed plugin record as a log event after checking its type
343
+ * against the plugin record grammar. A restored Session already holds events
344
+ * whose types are outside `SessionEventMap` when they are marked ignorable;
345
+ * a plugin record is such an event.
346
+ * @param value - the frozen record envelope built by {@link Session}.
347
+ * @throws when the record type is outside the plugin record grammar.
348
+ */
349
+ function assertPluginRecordEvent(value) {
350
+ const { type } = value;
351
+ if (!isPluginRecordType(type)) {
352
+ throw new Error(`plugin record type "${type}" must be "plugin:" followed by lowercase slash-separated segments`);
353
+ }
354
+ }
355
+ /** Module-private access to {@link Session}'s record commit, assigned once by its static block. */
356
+ let commitPluginRecord;
357
+ /**
358
+ * Append one experimental plugin record: an event marked `ignorable` whose
359
+ * type is declared in {@link PluginRecordMap}, outside {@link SessionEventMap}.
360
+ * The plugin record catalog lists it separately from released event schemas.
361
+ * A reader that does not recognize the type retains and skips the record, which never
362
+ * enters the model-visible surface, and fork and resume carry it with the rest
363
+ * of the log. A Session format migration retains records on a best-effort
364
+ * basis, so a record holds plugin-owned state that its owner can lose without
365
+ * changing how the rest of the log is interpreted.
366
+ *
367
+ * Only production source under `packages/experimental/` may call this
368
+ * function; the `verify-plugin-record-callers` gate rejects any other
369
+ * production caller in this repository. Read records back with
370
+ * {@link pluginRecordOf}.
371
+ * @param session - the Session whose log receives the record.
372
+ * @param type - declared record name: `plugin:` followed by slash-separated
373
+ * segments of lowercase letters, digits, `.`, `_`, and `-`, each starting
374
+ * with a letter or digit.
375
+ * @param data - JSON payload, snapshotted before it enters the log.
376
+ * @returns the sequence number of the committed record.
377
+ * @throws when `type` is outside that grammar, when `data` is not losslessly
378
+ * JSON-serializable, or when the call reenters another append's publication;
379
+ * a rejected record does not change the log.
380
+ */
381
+ export function appendPluginRecord(session, type, data) {
382
+ return commitPluginRecord(session, type, data);
383
+ }
384
+ /**
385
+ * Read one committed event as a plugin record. The V3-to-V4 format edge also
386
+ * renames each unknown ignorable V3 event into the `plugin:` namespace, so an
387
+ * owner names its records under its own package name and validates `data`.
388
+ * @param event - any committed Session event, live or restored.
389
+ * @returns the record, or undefined when the event is not an ignorable event
390
+ * whose type follows the plugin record grammar.
391
+ */
392
+ export function pluginRecordOf(event) {
393
+ const type = event.type;
394
+ if (event.ignorable !== true || !isPluginRecordType(type))
395
+ return undefined;
396
+ return { type, seq: event.seq, time: event.time, data: event.data };
397
+ }
327
398
  /**
328
399
  * An event-sourced session: an append-only log of {@link SessionEvent}s.
329
400
  *
@@ -578,10 +649,7 @@ export class Session {
578
649
  if (surfaceMetadataSnapshot === undefined) {
579
650
  throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
580
651
  }
581
- const entry = attachments.get(this);
582
- if (entry?.appending) {
583
- throw new Error('session append cannot reenter while another append is being published');
584
- }
652
+ const entry = this.publicationEntry();
585
653
  const event = deepFreeze({
586
654
  type,
587
655
  seq: SessionSeq(this.log.length),
@@ -590,6 +658,54 @@ export class Session {
590
658
  ...surfaceMetadataSnapshot,
591
659
  });
592
660
  validateSessionEventData(event, `session event "${type}" at seq ${event.seq}`);
661
+ this.commit(event, entry);
662
+ return event;
663
+ }
664
+ static {
665
+ commitPluginRecord = (session, type, data) => session.#appendRecord(type, data);
666
+ }
667
+ /**
668
+ * Commit one plugin record; {@link appendPluginRecord} is the only caller.
669
+ * @param type - record type, checked against the plugin record grammar.
670
+ * @param data - JSON payload, snapshotted before it enters the log.
671
+ * @returns the record's sequence number.
672
+ */
673
+ #appendRecord(type, data) {
674
+ const dataSnapshot = snapshotJsonValue(data);
675
+ if (dataSnapshot === undefined) {
676
+ throw new Error(`plugin record "${type}" carries non-JSON-serializable data`);
677
+ }
678
+ const entry = this.publicationEntry();
679
+ const record = deepFreeze({
680
+ type,
681
+ seq: SessionSeq(this.log.length),
682
+ time: Date.now(),
683
+ data: dataSnapshot,
684
+ ignorable: true,
685
+ });
686
+ assertPluginRecordEvent(record);
687
+ this.commit(record, entry);
688
+ return record.seq;
689
+ }
690
+ /**
691
+ * Read the store attachment for one append, refusing an append that
692
+ * reenters while another append is being published.
693
+ * @returns the attachment, or undefined for a detached Session.
694
+ */
695
+ publicationEntry() {
696
+ const entry = attachments.get(this);
697
+ if (entry?.appending) {
698
+ throw new Error('session append cannot reenter while another append is being published');
699
+ }
700
+ return entry;
701
+ }
702
+ /**
703
+ * Accept one frozen candidate at the next sequence number, enter it into the
704
+ * log, and notify `session/event` observers with per-listener containment.
705
+ * @param event - the candidate built at the current log length.
706
+ * @param entry - the attachment read before the candidate was built.
707
+ */
708
+ commit(event, entry) {
593
709
  this.surfaceManager.validateNext(event);
594
710
  if (entry !== undefined)
595
711
  entry.appending = true;
@@ -604,7 +720,6 @@ export class Session {
604
720
  if (callbacks !== undefined && entry !== undefined) {
605
721
  invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks);
606
722
  }
607
- return event;
608
723
  }
609
724
  finally {
610
725
  if (entry !== undefined) {
@@ -77,6 +77,7 @@ export const KNOWN_SESSION_EVENT_TYPES = new Set([
77
77
  'turn/start',
78
78
  'user/message',
79
79
  'web/deepseek-search-llm-request',
80
+ 'working-directory/change',
80
81
  'workspace/changes',
81
82
  ]);
82
83
  /** Event types whose model-visible effects require an explicit pure interpreter. */
@@ -36,20 +36,20 @@ export type OptionalSessionSeq = SessionSeq | null;
36
36
  * event-body read composes the build-static adjacent chain and publishes only
37
37
  * this final generation before constructing a Session.
38
38
  *
39
- * The version is a single monotonic integer with no major/minor split. Whether
40
- * a bump is needed is decided by what the WRITER emits, never by what a newer
41
- * reader can accept: bump exactly when an older runtime could no longer handle
42
- * a new log with full semantic correctness ("parses without error" is not
43
- * correctness — silently skipping content that shapes reconstruction is a
44
- * wrong read). Only structural changes reach that bar: the header shape, the
45
- * {@link SessionEvent} envelope, core event semantics, or the surface
46
- * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
47
- * Adding an ordinary event type does not bump — the per-event
48
- * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
49
- * in doubt, bump: a near-identity upgrade step is almost free, a missed bump
50
- * makes older runtimes read new logs wrong silently. The released migration,
51
- * immutable prior-generation, and current fast-path rules are recorded in
52
- * `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
39
+ * The version is a single monotonic integer with no major/minor split. Retain
40
+ * it when older readers resume correctly, preserve safely ignorable records,
41
+ * or refuse required features before execution, mutation, or tail repair.
42
+ * New readers must preserve historical meanings, including logs mixing old
43
+ * and new record representations. Bump when an existing discriminator cannot
44
+ * prevent unsafe interpretation; a structural difference alone is insufficient.
45
+ * Unknown fields and values require evidence of the released reader's actual
46
+ * admission behavior. The per-event {@link SessionEvent.ignorable} marker
47
+ * governs unknown event types, not arbitrary nested payload changes.
48
+ * Same-version readers retain earlier representations: the adjacent migration
49
+ * chain does not run when stored and current versions match. A subsequent
50
+ * migration must account for all supported representations of its source.
51
+ * Compatibility review and the retained migration obligations are recorded in
52
+ * `.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md`.
53
53
  */
54
54
  export declare const SESSION_FORMAT_VERSION = 4;
55
55
  /**
@@ -510,6 +510,37 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
510
510
  sourceEventSeqs?: never;
511
511
  });
512
512
  }[T];
513
+ /**
514
+ * Event type of an experimental plugin record: `plugin:` followed by
515
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
516
+ * each starting with a letter or digit. A record type is never a
517
+ * {@link SessionEventMap} member.
518
+ */
519
+ export type PluginRecordType = `plugin:${string}`;
520
+ /**
521
+ * Payloads written by experimental packages, keyed by their `plugin:` record
522
+ * names. Packages augment this map to type writes and enter the current plugin
523
+ * record catalog; these declarations do not enter {@link SessionEventMap} or
524
+ * released persistence schemas. Stored records still require owner validation.
525
+ */
526
+ export interface PluginRecordMap {
527
+ }
528
+ /**
529
+ * One committed experimental plugin record, read from the log by
530
+ * `pluginRecordOf`. The record's owner validates `data` before use, because a
531
+ * restored record carries whatever JSON an earlier build of its owner wrote,
532
+ * or a V3 event that a format migration renamed into the `plugin:` namespace held.
533
+ */
534
+ export interface PluginRecord {
535
+ /** The record type, chosen by the owning plugin. */
536
+ readonly type: PluginRecordType;
537
+ /** The record's position in the Session log. */
538
+ readonly seq: SessionSeq;
539
+ /** Unix epoch milliseconds at append. */
540
+ readonly time: number;
541
+ /** The JSON payload as committed. */
542
+ readonly data: unknown;
543
+ }
513
544
  declare module '@deepseek-ai/dsh-typert-protocol' {
514
545
  interface RemoteErrorDetailsMap {
515
546
  /** The named Session does not exist; produced by every layer that resolves a SessionId. */
@@ -36,20 +36,20 @@ export function SessionLogOffset(value) {
36
36
  * event-body read composes the build-static adjacent chain and publishes only
37
37
  * this final generation before constructing a Session.
38
38
  *
39
- * The version is a single monotonic integer with no major/minor split. Whether
40
- * a bump is needed is decided by what the WRITER emits, never by what a newer
41
- * reader can accept: bump exactly when an older runtime could no longer handle
42
- * a new log with full semantic correctness ("parses without error" is not
43
- * correctness — silently skipping content that shapes reconstruction is a
44
- * wrong read). Only structural changes reach that bar: the header shape, the
45
- * {@link SessionEvent} envelope, core event semantics, or the surface
46
- * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
47
- * Adding an ordinary event type does not bump — the per-event
48
- * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
49
- * in doubt, bump: a near-identity upgrade step is almost free, a missed bump
50
- * makes older runtimes read new logs wrong silently. The released migration,
51
- * immutable prior-generation, and current fast-path rules are recorded in
52
- * `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
39
+ * The version is a single monotonic integer with no major/minor split. Retain
40
+ * it when older readers resume correctly, preserve safely ignorable records,
41
+ * or refuse required features before execution, mutation, or tail repair.
42
+ * New readers must preserve historical meanings, including logs mixing old
43
+ * and new record representations. Bump when an existing discriminator cannot
44
+ * prevent unsafe interpretation; a structural difference alone is insufficient.
45
+ * Unknown fields and values require evidence of the released reader's actual
46
+ * admission behavior. The per-event {@link SessionEvent.ignorable} marker
47
+ * governs unknown event types, not arbitrary nested payload changes.
48
+ * Same-version readers retain earlier representations: the adjacent migration
49
+ * chain does not run when stored and current versions match. A subsequent
50
+ * migration must account for all supported representations of its source.
51
+ * Compatibility review and the retained migration obligations are recorded in
52
+ * `.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md`.
53
53
  */
54
54
  export const SESSION_FORMAT_VERSION = 4;
55
55
  //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session",
3
3
  "description": "Event-sourced session store for the DeepSeek Harness",
4
- "version": "0.2.1-alpha.1",
4
+ "version": "0.2.1-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -40,18 +40,18 @@
40
40
  ],
41
41
  "license": "MIT",
42
42
  "peerDependencies": {
43
- "@deepseek-ai/dsh-scope": "0.2.1-alpha.1",
43
+ "@deepseek-ai/dsh-scope": "0.2.1-alpha.2",
44
44
  "@deepseek-ai/cordis": "~4.0.5-alpha.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@deepseek-ai/dsh-scope": "0.2.1-alpha.1",
48
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.1",
49
- "@deepseek-ai/dsh-typert-registry": "0.2.1-alpha.1",
47
+ "@deepseek-ai/dsh-scope": "0.2.1-alpha.2",
48
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.2",
49
+ "@deepseek-ai/dsh-typert-registry": "0.2.1-alpha.2",
50
50
  "@deepseek-ai/cordis": "~4.0.5-alpha.1"
51
51
  },
52
52
  "dependencies": {
53
- "@deepseek-ai/dsh-llm": "0.2.1-alpha.1",
54
- "@deepseek-ai/dsh-brand": "0.2.1-alpha.1",
55
- "@deepseek-ai/dsh-util-values": "0.2.1-alpha.1"
53
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.2",
54
+ "@deepseek-ai/dsh-brand": "0.2.1-alpha.2",
55
+ "@deepseek-ai/dsh-util-values": "0.2.1-alpha.2"
56
56
  }
57
57
  }