@deepseek-ai/dsh-time-context 0.0.1-rc.1 → 0.0.1-rc.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
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/context/time-context/README.md
5
- README.md: 9956918c63b49de8ec5e739bc3d9887e269930a8
6
- README.zh.md: 3a9bb1012fc0639d9c3f6b104cea5a64d4b187d6
5
+ README.md: 0bdb0d463362427d6a7050c2d7d6d55f96779f9c
6
+ README.zh.md: 92eb0b3f43162279ac7e0f728e685863d75a28b6
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
5
+ Opt-in durable context with the current zoned time, the browser zone attached to the open request, and elapsed time sampled during model-request preparation. Default compositions leave it disabled; the Schedule Web overlay mounts it so the model can interpret otherwise-unqualified dates and times in the user's browser zone. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
6
6
 
7
7
  ## Config
8
8
 
@@ -10,27 +10,31 @@ Opt-in durable context with the current zoned time and elapsed time sampled duri
10
10
  - id: time-context
11
11
  name: '@deepseek-ai/dsh-time-context'
12
12
  config:
13
- timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
13
+ timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone
14
14
  refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
15
15
  ```
16
16
 
17
- When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load.
17
+ When the open turn contains one Host-validated browser zone, that request-local zone formats the timestamp. With missing or mixed browser provenance, `timeZone` supplies the display fallback; omitting it resolves the Node process zone once at plugin load. Node honors `TZ`, and every explicit fallback is validated through `Intl.DateTimeFormat`.
18
18
 
19
- `refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
19
+ `refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the Session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds elapsed since the latest injection.
20
20
 
21
- ## Timing semantics
21
+ ## Request-zone ownership
22
+
23
+ The browser samples `Intl.DateTimeFormat().resolvedOptions().timeZone` for each prompt. The Host validates and canonicalizes that value before binding it to the exact durable `user-rpc` message source. Time-context examines only those sources in the open turn: one unique zone resolves the request, multiple zones are `mixed`, and none are `unavailable`. It does not read or mutate Session headers, connection state, or Schedule records.
22
24
 
23
- The plugin prepends an `agent/pre-step` listener. When an injection is due and the downstream decision enters the proposed step, it adds one sourced `UserMessage` to the returned batch. AgentLoop records that context after `step/start` and before ordinary automatic compaction with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed, rejected, or failed pre-step records nothing.
25
+ The resolved instruction tells the model to interpret otherwise-unqualified dates and times in that browser zone. Mixed or unavailable provenance tells the model to ask the user to clarify. This is natural-language context, not an input default at another package boundary: a tool that accepts local calendar fields still owns its explicit zone requirement.
26
+
27
+ ## Timing semantics
24
28
 
25
- Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
29
+ The plugin prepends an `agent/pre-step` listener and delegates first. When an injection is due and the downstream decision enters, it appends one sourced `UserMessage` to the returned batch. AgentLoop records the final batch after `step/start` and before request derivation. Rejection, listener failure, or an already-aborted signal records nothing.
26
30
 
27
- Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
31
+ Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same text> }] }`. The `./invariant` companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline.
28
32
 
29
- A time reading records an entered pre-step batch, not a completed step or transmitted request. A later request-preparation failure can therefore leave the reading in history, but a downstream pre-step listener that rejects or fails prevents it from being recorded.
33
+ Positive-interval scheduling scans raw durable Session events for the latest plugin-attributed message, including a reading shadowed by compaction. It therefore survives resume without a process-local cache. A positive interval can intentionally let a later request reuse existing history without a fresh reading; the Schedule Web overlay omits the interval.
30
34
 
31
- The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
35
+ Step 1 measures from the latest preceding durable user, assistant, or tool-result message. The prompt proposed for that step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Missing baselines report `unavailable`, and backward wall-clock movement clamps elapsed time to zero.
32
36
 
33
- The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one.
37
+ A reading records an entered step, not a completed or transmitted request. A later preparation failure can leave it in history. The message remains in derived conversation history until compaction shadows it; `request/header` contains no time-context state, and request reconstruction uses the complete durable surface prefix after each `step/start`.
34
38
 
35
39
  ## Model Experience
36
40
 
@@ -38,12 +42,13 @@ The time reading stays in derived conversation history until a later compaction
38
42
 
39
43
  #### What the model sees
40
44
 
41
- On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
45
+ Each injected message contains three lines. `<timestamp>` is an ISO-shaped timestamp with numeric offset and IANA zone; durations use compact whole-second units.
42
46
 
43
47
  ##### First step
44
48
 
45
49
  ```markdown
46
50
  Time sampled while preparing turn <turn>, step 1: <timestamp>
51
+ Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
47
52
  Elapsed since the preceding model-visible message: <duration-or-unavailable>.
48
53
  ```
49
54
 
@@ -51,12 +56,13 @@ Elapsed since the preceding model-visible message: <duration-or-unavailable>.
51
56
 
52
57
  ```markdown
53
58
  Time sampled while preparing turn <turn>, step <step>: <timestamp>
59
+ Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
54
60
  Elapsed since the preceding step context: <duration-or-unavailable>.
55
61
  ```
56
62
 
57
63
  #### Token effect
58
64
 
59
- Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
65
+ Each reading accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one at every eligible preparation attempt.
60
66
 
61
67
  #### KV Cache effect
62
68
 
@@ -64,7 +70,8 @@ Append-only; newly visible content follows the reusable request prefix and does
64
70
 
65
71
  ## Known Limitations and Deferred Work
66
72
 
73
+ - **Prompt provenance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field.
74
+ - **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time.
75
+ - **Fallback is not user authority** — the configured or process zone formats the clock when browser provenance is missing or mixed, but the model-facing policy still says to clarify.
67
76
  - **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
68
- - **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp.
69
- - **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ.
70
- - **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost.
77
+ - **History cost between compactions** — omission or `0` retains one reading for every eligible attempt; a positive interval reduces but does not eliminate this cost and may leave a later request without fresh browser-zone guidance.
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
5
+ 可选的持久上下文,包含当前带时区时间、附加到当前开放请求的浏览器时区,以及在模型请求准备期间采样的经过时长。默认组合不启用它;Schedule Web overlay 会挂载它,使模型可以按用户的浏览器时区解释未明确限定时区的日期和时间。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
6
6
 
7
7
  ## 配置
8
8
 
@@ -10,27 +10,31 @@
10
10
  - id: time-context
11
11
  name: '@deepseek-ai/dsh-time-context'
12
12
  config:
13
- timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
13
+ timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone
14
14
  refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
15
15
  ```
16
16
 
17
- 省略 `timeZone` 时,插件会在加载时解析一次 Node 进程的系统时区。Node 遵循 `TZ`;如果没有该覆盖,时区由宿主或容器提供。显式 `timeZone` 必须是 IANA 标识符,并在插件加载时验证。
17
+ 当当前开放轮次只包含一个经 Host 校验的浏览器时区时,使用该请求本地时区格式化时间戳。浏览器来源信息缺失或混杂时,`timeZone` 提供显示回退;省略它则会在插件加载时解析一次 Node 进程时区。Node 遵循 `TZ`,每个显式回退值都经 `Intl.DateTimeFormat` 校验。
18
18
 
19
- `refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每次信号尚未中止且会进入步骤的合格步骤前处理添加上下文。正数值只会在会话没有早先 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。
19
+ `refreshIntervalMs` 必须是非负安全整数。省略或设为 `0` 时,会为每个信号尚未中止且将进入步骤的合格 pre-step 添加上下文。正数值只会在会话没有更早的 time-context 注入、挂钟时间倒退,或自最新注入起已经过至少相应毫秒数时添加上下文。
20
20
 
21
- ## 时序语义
21
+ ## 请求时区归属
22
+
23
+ 浏览器会为每条提示词采样 `Intl.DateTimeFormat().resolvedOptions().timeZone`。Host 校验并规范化该值,再将其绑定到确切的持久 `user-rpc` 消息来源。Time-context 只检查当前开放轮次中的这些来源:唯一一个时区可解析请求,多个时区记为 `mixed`,没有时区则记为 `unavailable`。它不会读取或修改会话标头、连接状态或 Schedule 记录。
22
24
 
23
- 该插件会前置一个 `agent/pre-step` 监听器。需要注入且下游决策进入拟议步骤时,它会在返回批次中添加一条带来源的 `UserMessage`。AgentLoop 会在 `step/start` 之后、普通自动压缩(compaction)之前记录该上下文,其来源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制、拒绝或失败的步骤前处理不会记录任何内容。
25
+ 解析后的指令告诉模型,把未明确限定时区的日期和时间解释为该浏览器时区。来源信息为 mixed unavailable 时,模型会收到要求用户澄清的指令。这是自然语言上下文,并非另一个包边界上的输入默认值:接受本地日历字段的工具仍自行负责其显式时区要求。
26
+
27
+ ## 时序语义
24
28
 
25
- 正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的时间读数。因此,调度可以跨轮次以及进程恢复持续生效,不需要进程本地缓存状态。它会降低追加频率与历史增长,但绝不移除现有时间读数,且每个会话独立调度。
29
+ 该插件会前置一个 `agent/pre-step` 监听器,并先行委托下游。需要注入且下游决策进入步骤时,它会向返回批次追加一条带来源的 `UserMessage`。AgentLoop 在 `step/start` 之后、请求派生之前记录最终批次。决策被拒绝、监听器失败或信号已经中止时,不会记录任何内容。
26
30
 
27
- 1 步从前一条模型可见消息起测量,包括开启轮次的提示词。后续步骤从同一轮次中前一个 time-context 事件起测量。两种基线都使用持久会话事件时间戳;挂钟时间倒退时,经过时长限制为零。如果第一步缺少基线,或者后续步骤因间隔抑制而没有较早的同轮次时间读数,则报告 `unavailable`。
31
+ 每个读数都使用确切的快照来源 `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same text> }] }`。`./invariant` 配套模块会校验该形状,根据原始 `user-rpc` 消息重新派生当前轮次的浏览器策略,并检查时间戳时区与经过时长基线。
28
32
 
29
- 时间读数记录的是一个已进入步骤的步骤前批次,不是已完成步骤或已传输请求。后续请求准备失败时,该读数可能已留在历史中;但下游步骤前监听器拒绝或失败时,该读数不会被记录。
33
+ 正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息,其中包括已被压缩(compaction)遮蔽的读数。因此,它无需进程本地缓存也能在恢复后继续生效。正数间隔可以有意让后续请求复用现有历史,而不添加新读数;Schedule Web overlay 会省略该间隔。
30
34
 
31
- 单独发布的 `./invariant` 配套模块会根据当前未结束的轮次、下一个步骤前位置、经过时长基线与持久事件时间检查每个归因于插件的时间读数。其渲染时间戳必须可解析,且不能晚于该事件;采样与追加之间的进程挂起不会使时间读数失效。
35
+ 1 步从最新一条在其之前持久化的用户、助手或工具结果消息起测量。为该步骤拟议的提示词尚未追加。后续步骤从同一轮次中前一个 time-context 事件起测量。缺少基线时报告 `unavailable`,挂钟时间倒退时将经过时长限制为零。
32
36
 
33
- 时间读数会保留在派生会话历史中,直到后续压缩遮蔽它。请求标头不含 time-context 状态。请求重建会在每个 `step/start` 之后使用完整持久表层前缀,因此已传输请求无需与时间读数一一对应:请求准备可能在进入步骤后失败,而间隔抑制可让请求复用现有历史,无需添加时间读数。
37
+ 读数记录的是已进入的步骤,不是已完成或已传输的请求。后续准备失败时,该读数可能留在历史中。消息会保留在派生会话历史中,直到压缩将其遮蔽;`request/header` 不含 time-context 状态,请求重建会使用每个 `step/start` 之后的完整持久表层前缀。
34
38
 
35
39
  ## 模型体验
36
40
 
@@ -38,12 +42,13 @@
38
42
 
39
43
  #### 模型看到的内容
40
44
 
41
- 每次执行注入的准备尝试都会生成一条带源标记的上下文消息,包含下方两行。`<timestamp>` 是带数字偏移与 IANA 时区、形如 ISO 的本地时间戳;持续时间使用紧凑的整秒单位。正间隔可能使某次步骤尝试没有新时间读数。
45
+ 每条注入消息包含三行。`<timestamp>` 是带数字偏移和 IANA 时区、形如 ISO 的时间戳;持续时间使用紧凑的整秒单位。
42
46
 
43
47
  ##### 第一步
44
48
 
45
49
  ```markdown
46
50
  Time sampled while preparing turn <turn>, step 1: <timestamp>
51
+ Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
47
52
  Elapsed since the preceding model-visible message: <duration-or-unavailable>.
48
53
  ```
49
54
 
@@ -51,12 +56,13 @@ Elapsed since the preceding model-visible message: <duration-or-unavailable>.
51
56
 
52
57
  ```markdown
53
58
  Time sampled while preparing turn <turn>, step <step>: <timestamp>
59
+ Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
54
60
  Elapsed since the preceding step context: <duration-or-unavailable>.
55
61
  ```
56
62
 
57
63
  #### Token 影响
58
64
 
59
- 每条注入的两行消息都会累积,直到压缩遮蔽它。正间隔会减少添加;省略或设为 `0` 则会为每次合格准备尝试添加一条。
65
+ 每个读数都会累积,直到压缩将其遮蔽。正数间隔会减少新增读数;省略或设为 `0` 时,每次合格的准备尝试都会添加一条。
60
66
 
61
67
  #### KV Cache 影响
62
68
 
@@ -64,7 +70,8 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
64
70
 
65
71
  ## 已知限制与暂缓事项
66
72
 
73
+ - **仅限提示词来源信息**:浏览器时区上下文用于指导自然语言解释,但不会悄然填入另一工具所要求的时区字段。
74
+ - **混合轮次会询问**:如果同一个开放轮次包含来自不同浏览器时区的提示词,模型会收到要求澄清的指令,而不会猜测哪个时区拥有未限定的时间。
75
+ - **回退值不代表用户权威**:浏览器来源信息缺失或混杂时,配置或进程时区用于格式化时钟,但面向模型的策略仍要求澄清。
67
76
  - **整秒显示**:时间戳与持续时间省略亚秒精度,尽管持久事件时间保留毫秒。
68
- - **会话事件基线**:经过时长从持久追加时间戳起计算,而非客户端传输的原始发送时间戳。
69
- - **进程本地默认时区**:省略设置时,使用插件加载时捕获的 Node 进程 `TZ`、宿主或容器时区,而非远程用户的时区;两者不同时,请配置显式 IANA 时区。
70
- - **压缩之间的历史成本**:省略设置或设为 `0` 会为每次合格准备尝试保留一条时间读数,包括后续取消或失败的尝试;正间隔可以降低但无法消除该成本。
77
+ - **压缩之间的历史成本**:省略或设为 `0` 时,每次合格尝试都会保留一条读数;正数间隔可以降低但无法消除该成本,也可能使后续请求缺少新鲜的浏览器时区指导。
package/lib/index.js CHANGED
@@ -158,6 +158,114 @@ z.union([normalPolicySchema, alwaysPolicySchema]);
158
158
  */
159
159
  const { version } = createRequire(import.meta.url)("../package.json");
160
160
  //#endregion
161
+ //#region ../../llm/llm/src/never.ts
162
+ /**
163
+ * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
164
+ * new variant fails compilation at every required handler. Do not use it for declaration-merged
165
+ * unions such as session events or content blocks: handle known variants and explicitly fall
166
+ * through because plugins may add valid unknown cases.
167
+ * @module @deepseek-ai/dsh-llm/never
168
+ */
169
+ /**
170
+ * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
171
+ * a value that escaped its type throws with diagnostics at runtime.
172
+ * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
173
+ * @param context - optional label (e.g. the switch site) prefixed into the throw message.
174
+ * @returns never — it always throws, with the offending value JSON-rendered in the message.
175
+ */
176
+ function assertNever(value, context) {
177
+ const rendered = JSON.stringify(value) ?? String(value);
178
+ throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
179
+ }
180
+ //#endregion
181
+ //#region lib/types/request-zone.js
182
+ /** Browser-zone derivation and model-facing policy text for one open request turn. */
183
+ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
184
+ /** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */
185
+ function browserTimeZone(message) {
186
+ const source = message.source;
187
+ const value = source.kind === "user" && "rpcId" in source && typeof source.rpcId === "string" && "clientTimeZone" in source && typeof source.clientTimeZone === "string" ? source.clientTimeZone : void 0;
188
+ if (value === void 0) return void 0;
189
+ if (value !== "UTC" && !IANA_TIME_ZONE.test(value)) throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`);
190
+ let canonical;
191
+ try {
192
+ canonical = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
193
+ } catch (error) {
194
+ throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error });
195
+ }
196
+ if (canonical !== value) throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`);
197
+ return value;
198
+ }
199
+ /**
200
+ * Derive the unique, mixed, or missing browser zone for one open turn.
201
+ * @param messages - Entered and proposed user messages belonging to the turn.
202
+ * @returns Sorted, duplicate-free browser-zone facts.
203
+ * @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
204
+ */
205
+ function deriveBrowserTimeZoneContext(messages) {
206
+ const timeZones = [...new Set(messages.flatMap((message) => {
207
+ const timeZone = browserTimeZone(message);
208
+ return timeZone === void 0 ? [] : [timeZone];
209
+ }))].sort();
210
+ const [timeZone, ...remaining] = timeZones;
211
+ if (timeZone === void 0) return { kind: "missing" };
212
+ if (remaining.length === 0) return {
213
+ kind: "resolved",
214
+ timeZone
215
+ };
216
+ return {
217
+ kind: "mixed",
218
+ timeZones
219
+ };
220
+ }
221
+ /**
222
+ * Render the model instruction for one browser-zone context.
223
+ * @param context - Browser-zone facts for the open turn.
224
+ * @returns One durable policy line.
225
+ */
226
+ function renderBrowserTimeZoneContext(context) {
227
+ switch (context.kind) {
228
+ case "resolved": return `Browser time zone for this request: ${context.timeZone}. Interpret otherwise-unqualified dates and times in this zone.`;
229
+ case "mixed": return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. Ask the user to clarify otherwise-unqualified dates and times.`;
230
+ case "missing": return "Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.";
231
+ /* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
232
+ default: return assertNever(context, "BrowserTimeZoneContext");
233
+ }
234
+ }
235
+ //#endregion
236
+ //#region lib/types/timestamp.js
237
+ /** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
238
+ /**
239
+ * Create the exact formatter used by durable time-context readings.
240
+ * @param timeZone - Explicit display zone, or `undefined` for the process fallback.
241
+ * @returns A formatter with stable numeric local fields and long numeric offset.
242
+ */
243
+ function createTimestampFormatter(timeZone) {
244
+ return new Intl.DateTimeFormat("en-US", {
245
+ ...timeZone === void 0 ? {} : { timeZone },
246
+ year: "numeric",
247
+ month: "2-digit",
248
+ day: "2-digit",
249
+ hour: "2-digit",
250
+ minute: "2-digit",
251
+ second: "2-digit",
252
+ hourCycle: "h23",
253
+ timeZoneName: "longOffset"
254
+ });
255
+ }
256
+ /**
257
+ * Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
258
+ * @param now - Epoch milliseconds to display.
259
+ * @param formatter - Formatter created for `timeZone`.
260
+ * @param timeZone - Canonical zone label carried in brackets.
261
+ * @returns The durable timestamp text.
262
+ */
263
+ function formatTimestamp(now, formatter, timeZone) {
264
+ const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
265
+ const offset = parts.timeZoneName.replace(/^GMT$/, "GMT+00:00").slice(3);
266
+ return `${parts["year"]}-${parts["month"]}-${parts["day"]}T${parts["hour"]}:${parts["minute"]}:${parts["second"]}${offset}[${timeZone}]`;
267
+ }
268
+ //#endregion
161
269
  //#region lib/types/index.js
162
270
  /**
163
271
  * Opt-in request clock context. Eligible steps add durable,
@@ -174,12 +282,6 @@ const Config = z.object({
174
282
  timeZone: z.string(),
175
283
  refreshIntervalMs: z.number()
176
284
  });
177
- /** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
178
- function formatTimestamp(now, formatter, timeZone) {
179
- const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
180
- const offset = parts.timeZoneName.replace(/^GMT$/, "GMT+00:00").slice(3);
181
- return `${parts["year"]}-${parts["month"]}-${parts["day"]}T${parts["hour"]}:${parts["minute"]}:${parts["second"]}${offset}[${timeZone}]`;
182
- }
183
285
  /** Format a non-negative elapsed millisecond count as compact whole-second units. */
184
286
  function formatDuration(elapsedMs) {
185
287
  let seconds = Math.floor(Math.max(0, elapsedMs) / 1e3);
@@ -216,10 +318,16 @@ function precedingStepContextTime(agent, turn) {
216
318
  function latestInjectionTime(agent) {
217
319
  for (const event of [...agent.session.events].reverse()) if (event.type === "user/message" && event.data.source.kind === "plugin" && event.data.source.plugin === "time-context") return event.time;
218
320
  }
219
- function renderText(now, turn, step, previous, formatter, timeZone) {
321
+ /** Collect already-entered and proposed user messages belonging to one open turn. */
322
+ function requestMessages(agent, turn, proposed) {
323
+ const start = agent.session.events.findLastIndex((event) => event.type === "turn/start" && event.data.turn === turn);
324
+ return [...start < 0 ? [] : agent.session.events.slice(start + 1).flatMap((event) => event.type === "user/message" ? [event.data] : []), ...proposed];
325
+ }
326
+ function renderText(now, turn, step, previous, formatter, timeZone, browserContext) {
220
327
  const elapsed = previous === void 0 ? "unavailable" : formatDuration(now - previous);
221
328
  const baseline = step === 1 ? "model-visible message" : "step context";
222
- return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\nElapsed since the preceding ${baseline}: ${elapsed}.`;
329
+ const browserText = renderBrowserTimeZoneContext(browserContext);
330
+ return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n${browserText}\nElapsed since the preceding ${baseline}: ${elapsed}.`;
223
331
  }
224
332
  /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
225
333
  function validateRefreshInterval(refreshIntervalMs) {
@@ -235,24 +343,23 @@ function apply(ctx, config) {
235
343
  const timeZone = config.timeZone;
236
344
  const refreshIntervalMs = config.refreshIntervalMs;
237
345
  validateRefreshInterval(refreshIntervalMs);
238
- let formatter;
346
+ let fallbackFormatter;
239
347
  try {
240
- formatter = new Intl.DateTimeFormat("en-US", {
241
- ...timeZone === void 0 ? {} : { timeZone },
242
- year: "numeric",
243
- month: "2-digit",
244
- day: "2-digit",
245
- hour: "2-digit",
246
- minute: "2-digit",
247
- second: "2-digit",
248
- hourCycle: "h23",
249
- timeZoneName: "longOffset"
250
- });
348
+ fallbackFormatter = createTimestampFormatter(timeZone);
251
349
  } catch (error) {
252
350
  const message = timeZone === void 0 ? "time-context: failed to resolve the system time zone" : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`;
253
351
  throw new Error(message, { cause: error });
254
352
  }
255
- const resolvedTimeZone = formatter.resolvedOptions().timeZone;
353
+ const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone;
354
+ const formatters = new Map([[fallbackTimeZone, fallbackFormatter]]);
355
+ /** Resolve and cache one request-local timestamp formatter. */
356
+ const formatterFor = (selectedTimeZone) => {
357
+ const existing = formatters.get(selectedTimeZone);
358
+ if (existing !== void 0) return existing;
359
+ const created = createTimestampFormatter(selectedTimeZone);
360
+ formatters.set(selectedTimeZone, created);
361
+ return created;
362
+ };
256
363
  ctx.on("agent/pre-step", async ({ agent, turn, step, signal }, next) => {
257
364
  const decision = await next();
258
365
  if (decision.kind === "reject" || signal.aborted) return decision;
@@ -261,7 +368,10 @@ function apply(ctx, config) {
261
368
  const lastInjection = latestInjectionTime(agent);
262
369
  if (lastInjection !== void 0 && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision;
263
370
  }
264
- const text = renderText(now, turn, step, step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn), formatter, resolvedTimeZone);
371
+ const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn);
372
+ const browser = deriveBrowserTimeZoneContext(requestMessages(agent, turn, decision.messages));
373
+ const selectedTimeZone = browser.kind === "resolved" ? browser.timeZone : fallbackTimeZone;
374
+ const text = renderText(now, turn, step, previous, formatterFor(selectedTimeZone), selectedTimeZone, browser);
265
375
  return {
266
376
  kind: "enter",
267
377
  messages: [...decision.messages, createUserMessage({
package/lib/invariant.js CHANGED
@@ -1,51 +1,271 @@
1
+ import { createRequire } from "node:module";
2
+ import "@deepseek-ai/cordis";
3
+ import z from "@deepseek-ai/schemastery";
4
+ //#region ../../util/timeout/src/index.ts
5
+ /** Largest delay Node schedules without clamping it to one millisecond. */
6
+ const MAX_TIMER_DELAY_MS = 2147483647;
7
+ //#endregion
8
+ //#region ../../llm/llm/src/error.ts
9
+ /**
10
+ * Canonical provider-neutral code for a response that completed normally but
11
+ * carried no content blocks at all. Providers occasionally emit a degenerate
12
+ * completion (a terminal stop with zero output); adapters classify it as this
13
+ * failure instead of yielding an empty assistant message, because an empty
14
+ * message silently ends the turn with nothing for the user or the loop to act
15
+ * on. The attempt produced nothing durable, so retry policy treats it as safe
16
+ * to repeat.
17
+ */
18
+ const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
19
+ new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
20
+ new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
21
+ new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
22
+ //#endregion
23
+ //#region ../../llm/llm/src/retry-policy.ts
24
+ /**
25
+ * Provider-owned request-retry policy configuration and resolution.
26
+ *
27
+ * Adapters expose one resolved policy per registered provider route; the
28
+ * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
29
+ *
30
+ * @module @deepseek-ai/dsh-llm/retry-policy
31
+ */
32
+ const DEFAULT_MAX_RETRIES = 2;
33
+ const DEFAULT_INITIAL_DELAY_MS = 500;
34
+ const DEFAULT_MAX_DELAY_MS = 1e4;
35
+ const DEFAULT_JITTER_RATIO = .1;
36
+ const DEFAULT_RETRYABLE_CODES = Object.freeze([
37
+ EMPTY_RESPONSE_CODE,
38
+ "RATE_LIMIT",
39
+ "SERVER",
40
+ "TIMEOUT",
41
+ "TRANSPORT"
42
+ ]);
43
+ const backoffSchema = z.object({
44
+ initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
45
+ maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
46
+ jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
47
+ });
48
+ const normalPolicySchema = z.object({
49
+ mode: z.const("normal").required(),
50
+ maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
51
+ retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
52
+ backoff: backoffSchema
53
+ });
54
+ const alwaysPolicySchema = z.object({
55
+ mode: z.const("always").required(),
56
+ backoff: backoffSchema
57
+ });
58
+ z.union([normalPolicySchema, alwaysPolicySchema]);
59
+ //#endregion
60
+ //#region ../../llm/llm/src/attribution.ts
61
+ /**
62
+ * Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
63
+ * adapters from drifting. See
64
+ * `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
65
+ *
66
+ * App-attribution vocabulary for provider requests.
67
+ * @module @deepseek-ai/dsh-llm/attribution
68
+ */
69
+ const { version } = createRequire(import.meta.url)("../package.json");
70
+ //#endregion
71
+ //#region ../../llm/llm/src/never.ts
72
+ /**
73
+ * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
74
+ * new variant fails compilation at every required handler. Do not use it for declaration-merged
75
+ * unions such as session events or content blocks: handle known variants and explicitly fall
76
+ * through because plugins may add valid unknown cases.
77
+ * @module @deepseek-ai/dsh-llm/never
78
+ */
79
+ /**
80
+ * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
81
+ * a value that escaped its type throws with diagnostics at runtime.
82
+ * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
83
+ * @param context - optional label (e.g. the switch site) prefixed into the throw message.
84
+ * @returns never — it always throws, with the offending value JSON-rendered in the message.
85
+ */
86
+ function assertNever(value, context) {
87
+ const rendered = JSON.stringify(value) ?? String(value);
88
+ throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
89
+ }
90
+ //#endregion
91
+ //#region lib/types/request-zone.js
92
+ /** Browser-zone derivation and model-facing policy text for one open request turn. */
93
+ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
94
+ /** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */
95
+ function browserTimeZone(message) {
96
+ const source = message.source;
97
+ const value = source.kind === "user" && "rpcId" in source && typeof source.rpcId === "string" && "clientTimeZone" in source && typeof source.clientTimeZone === "string" ? source.clientTimeZone : void 0;
98
+ if (value === void 0) return void 0;
99
+ if (value !== "UTC" && !IANA_TIME_ZONE.test(value)) throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`);
100
+ let canonical;
101
+ try {
102
+ canonical = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
103
+ } catch (error) {
104
+ throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error });
105
+ }
106
+ if (canonical !== value) throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`);
107
+ return value;
108
+ }
109
+ /**
110
+ * Derive the unique, mixed, or missing browser zone for one open turn.
111
+ * @param messages - Entered and proposed user messages belonging to the turn.
112
+ * @returns Sorted, duplicate-free browser-zone facts.
113
+ * @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
114
+ */
115
+ function deriveBrowserTimeZoneContext(messages) {
116
+ const timeZones = [...new Set(messages.flatMap((message) => {
117
+ const timeZone = browserTimeZone(message);
118
+ return timeZone === void 0 ? [] : [timeZone];
119
+ }))].sort();
120
+ const [timeZone, ...remaining] = timeZones;
121
+ if (timeZone === void 0) return { kind: "missing" };
122
+ if (remaining.length === 0) return {
123
+ kind: "resolved",
124
+ timeZone
125
+ };
126
+ return {
127
+ kind: "mixed",
128
+ timeZones
129
+ };
130
+ }
131
+ /**
132
+ * Render the model instruction for one browser-zone context.
133
+ * @param context - Browser-zone facts for the open turn.
134
+ * @returns One durable policy line.
135
+ */
136
+ function renderBrowserTimeZoneContext(context) {
137
+ switch (context.kind) {
138
+ case "resolved": return `Browser time zone for this request: ${context.timeZone}. Interpret otherwise-unqualified dates and times in this zone.`;
139
+ case "mixed": return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. Ask the user to clarify otherwise-unqualified dates and times.`;
140
+ case "missing": return "Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.";
141
+ /* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
142
+ default: return assertNever(context, "BrowserTimeZoneContext");
143
+ }
144
+ }
145
+ //#endregion
146
+ //#region lib/types/timestamp.js
147
+ /** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
148
+ /**
149
+ * Create the exact formatter used by durable time-context readings.
150
+ * @param timeZone - Explicit display zone, or `undefined` for the process fallback.
151
+ * @returns A formatter with stable numeric local fields and long numeric offset.
152
+ */
153
+ function createTimestampFormatter(timeZone) {
154
+ return new Intl.DateTimeFormat("en-US", {
155
+ ...timeZone === void 0 ? {} : { timeZone },
156
+ year: "numeric",
157
+ month: "2-digit",
158
+ day: "2-digit",
159
+ hour: "2-digit",
160
+ minute: "2-digit",
161
+ second: "2-digit",
162
+ hourCycle: "h23",
163
+ timeZoneName: "longOffset"
164
+ });
165
+ }
166
+ /**
167
+ * Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
168
+ * @param now - Epoch milliseconds to display.
169
+ * @param formatter - Formatter created for `timeZone`.
170
+ * @param timeZone - Canonical zone label carried in brackets.
171
+ * @returns The durable timestamp text.
172
+ */
173
+ function formatTimestamp(now, formatter, timeZone) {
174
+ const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
175
+ const offset = parts.timeZoneName.replace(/^GMT$/, "GMT+00:00").slice(3);
176
+ return `${parts["year"]}-${parts["month"]}-${parts["day"]}T${parts["hour"]}:${parts["minute"]}:${parts["second"]}${offset}[${timeZone}]`;
177
+ }
178
+ //#endregion
1
179
  //#region lib/types/invariant.js
2
180
  /** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */
3
181
  const PACKAGE_NAME = "@deepseek-ai/dsh-time-context";
4
182
  const SOURCE_NAME = "time-context";
5
- const READING = /* @__PURE__ */ new RegExp("^Time sampled while preparing turn (\\d+), step (\\d+): (\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\nElapsed since the preceding (model-visible message|step context): (?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$");
183
+ const READING = /* @__PURE__ */ new RegExp("^Time sampled while preparing turn (\\d+), step (\\d+): (\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n(Browser time zone for this request: .+)\\nElapsed since the preceding (model-visible message|step context): (?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$");
6
184
  /** Cordis companion plugin name. */
7
185
  const name = "time-context-invariant";
8
186
  /** Service required before the companion can reserve package ownership. */
9
187
  const inject = ["invariants"];
10
- /** Derive the entered step boundary at which a time-context reading may append. */
188
+ /** Derive the open step boundary at which a time-context reading may append. */
11
189
  function preparationPosition(history, fail) {
12
- for (const event of history.slice().reverse()) switch (event.type) {
13
- case "step/start": return {
14
- turn: event.data.turn,
15
- step: event.data.step
16
- };
190
+ let openTurn;
191
+ let openStep;
192
+ let requestStarted = false;
193
+ for (const event of history) switch (event.type) {
17
194
  case "turn/start":
195
+ openTurn = event.data.turn;
196
+ openStep = void 0;
197
+ requestStarted = false;
198
+ break;
199
+ case "step/start":
200
+ openStep = event.data.step;
201
+ requestStarted = false;
202
+ break;
203
+ case "request/header":
204
+ requestStarted = true;
205
+ break;
18
206
  case "step/end":
207
+ openStep = void 0;
208
+ requestStarted = false;
209
+ break;
19
210
  case "turn/end":
20
- case "request/header":
21
- case "assistant/chunk":
22
- case "assistant/message":
23
- case "tool/call":
24
- case "tool/result":
25
- fail("time-context reading must be appended during prompt assembly");
211
+ openTurn = void 0;
212
+ openStep = void 0;
213
+ requestStarted = false;
26
214
  break;
27
215
  default: break;
28
216
  }
29
- fail("time-context reading must be appended during prompt assembly");
217
+ if (openTurn === void 0) fail("time-context reading must be appended inside an open turn");
218
+ if (openStep === void 0) fail("time-context reading must follow step/start");
219
+ if (requestStarted) fail("time-context reading must precede request/header");
220
+ return {
221
+ turn: openTurn,
222
+ step: openStep
223
+ };
224
+ }
225
+ /** Collect the entered user messages belonging to one open turn. */
226
+ function requestMessages(history, turn) {
227
+ const start = history.findLastIndex((event) => event.type === "turn/start" && event.data.turn === turn);
228
+ return history.slice(start + 1).flatMap((event) => event.type === "user/message" ? [event.data] : []);
30
229
  }
31
230
  /** Validate one plugin-attributed time reading against its session position and timestamp. */
32
231
  function validateReading(history, event, fail) {
33
- const [block] = event.data.content;
34
- if (event.data.content.length !== 1 || block?.type !== "text") fail("time-context messages must contain exactly one text block");
35
- const match = READING.exec(block.text);
232
+ const blockValue = event.data.content[0];
233
+ const block = typeof blockValue === "object" && blockValue !== null ? blockValue : void 0;
234
+ const blockText = block?.text;
235
+ if (event.data.content.length !== 1 || block === void 0 || Object.keys(block).length !== 2 || block.type !== "text" || typeof blockText !== "string") fail("time-context messages must contain exactly one text block");
236
+ const match = READING.exec(blockText);
36
237
  if (match === null) fail("time-context message does not match the durable reading format");
37
238
  const turn = Number(match[1]);
38
239
  const step = Number(match[2]);
39
240
  if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) fail("time-context turn and step must be positive safe integers");
40
241
  const expected = preparationPosition(history, fail);
41
242
  if (turn !== expected.turn || step !== expected.step) fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`);
42
- const baseline = match[4];
243
+ const source = event.data.source;
244
+ /* v8 ignore next 2 -- replay and dispatch callers select this exact package-owned source before validation. */
245
+ if (source.kind !== "plugin" || source.plugin !== SOURCE_NAME) fail("time-context source must retain package ownership");
246
+ const sections = "sections" in source ? source.sections : void 0;
247
+ const sectionValue = Array.isArray(sections) ? sections[0] : void 0;
248
+ const section = typeof sectionValue === "object" && sectionValue !== null ? sectionValue : void 0;
249
+ if (Object.keys(source).length !== 4 || source.form !== "snapshot" || !Array.isArray(sections) || sections.length !== 1 || section === void 0 || Object.keys(section).length !== 2 || section.name !== SOURCE_NAME || section.text !== blockText) fail("time-context source must carry only the exact snapshot text, not request authority");
250
+ const renderedBrowserContext = match[4];
251
+ const browserContext = deriveBrowserTimeZoneContext(requestMessages(history, turn));
252
+ if (renderedBrowserContext !== renderBrowserTimeZoneContext(browserContext)) fail("time-context browser-zone text does not match current-turn user messages");
253
+ const baseline = match[5];
43
254
  if (step === 1 !== (baseline === "model-visible message")) fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`);
44
255
  const rendered = match[3];
45
256
  /* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */
46
257
  if (rendered === void 0) fail("time-context reading omitted its rendered timestamp");
47
258
  const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ""));
48
259
  if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) || event.time < renderedTime) fail("time-context rendered timestamp must parse and not postdate its durable event");
260
+ if (browserContext.kind === "resolved") {
261
+ let expectedTimestamp;
262
+ try {
263
+ expectedTimestamp = formatTimestamp(renderedTime, createTimestampFormatter(browserContext.timeZone), browserContext.timeZone);
264
+ } catch (error) {
265
+ fail(`time-context browser zone cannot format its durable timestamp: ${String(error)}`);
266
+ }
267
+ if (rendered !== expectedTimestamp) fail("time-context rendered timestamp does not match the unique browser zone");
268
+ }
49
269
  }
50
270
  /** Validate all package-owned readings already present in one session. */
51
271
  function validateSession(session, fail) {
@@ -57,6 +277,9 @@ function validateSession(session, fail) {
57
277
  /** Install validation for loaded and newly appended context readings. */
58
278
  const install = Object.assign((ctx, fail) => {
59
279
  for (const session of ctx.sessions.list()) validateSession(session, fail);
280
+ ctx.on("session/created", (session) => {
281
+ validateSession(session, fail);
282
+ }, { global: true });
60
283
  ctx.on("internal/dispatch", (_mode, eventName, args) => {
61
284
  if (eventName !== "session/event") return;
62
285
  const [session, event] = args;
@@ -12,7 +12,7 @@ export declare const name = "time-context";
12
12
  export declare const inject: string[];
13
13
  /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
14
14
  export interface Config {
15
- /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
15
+ /** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
16
16
  timeZone?: string;
17
17
  /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
18
18
  refreshIntervalMs?: number;
@@ -0,0 +1,26 @@
1
+ /** Browser-zone derivation and model-facing policy text for one open request turn. */
2
+ import type { UserMessage } from '@deepseek-ai/dsh-llm';
3
+ /** Browser-zone facts derived from user-rpc messages in one open turn. */
4
+ export type BrowserTimeZoneContext = {
5
+ readonly kind: 'resolved';
6
+ readonly timeZone: string;
7
+ } | {
8
+ readonly kind: 'mixed';
9
+ readonly timeZones: readonly string[];
10
+ } | {
11
+ readonly kind: 'missing';
12
+ };
13
+ /**
14
+ * Derive the unique, mixed, or missing browser zone for one open turn.
15
+ * @param messages - Entered and proposed user messages belonging to the turn.
16
+ * @returns Sorted, duplicate-free browser-zone facts.
17
+ * @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
18
+ */
19
+ export declare function deriveBrowserTimeZoneContext(messages: readonly UserMessage[]): BrowserTimeZoneContext;
20
+ /**
21
+ * Render the model instruction for one browser-zone context.
22
+ * @param context - Browser-zone facts for the open turn.
23
+ * @returns One durable policy line.
24
+ */
25
+ export declare function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string;
26
+ //# sourceMappingURL=request-zone.d.ts.map
@@ -0,0 +1,16 @@
1
+ /** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
2
+ /**
3
+ * Create the exact formatter used by durable time-context readings.
4
+ * @param timeZone - Explicit display zone, or `undefined` for the process fallback.
5
+ * @returns A formatter with stable numeric local fields and long numeric offset.
6
+ */
7
+ export declare function createTimestampFormatter(timeZone?: string): Intl.DateTimeFormat;
8
+ /**
9
+ * Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
10
+ * @param now - Epoch milliseconds to display.
11
+ * @param formatter - Formatter created for `timeZone`.
12
+ * @param timeZone - Canonical zone label carried in brackets.
13
+ * @returns The durable timestamp text.
14
+ */
15
+ export declare function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string;
16
+ //# sourceMappingURL=timestamp.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-time-context",
3
3
  "description": "Opt-in durable per-step context with the current time and elapsed time",
4
- "version": "0.0.1-rc.1",
4
+ "version": "0.0.1-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },
@@ -35,21 +35,21 @@
35
35
  "@deepseek-ai/schemastery": "^3.18.1-rc.1"
36
36
  },
37
37
  "peerDependencies": {
38
- "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
39
- "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
40
- "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
41
- "@deepseek-ai/cordis": "^4.0.1-rc.1"
38
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.2",
39
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.2",
40
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
41
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@deepseek-ai/dsh-agent-loop": "^0.0.1-rc.1",
45
- "@deepseek-ai/dsh-agent-loop-testkit": "^0.0.1-rc.1",
46
- "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
47
- "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
48
- "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
49
- "@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.1",
50
- "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
51
- "@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.1",
52
- "@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
44
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.2",
45
+ "@deepseek-ai/dsh-agent-loop": "^0.0.1-rc.2",
46
+ "@deepseek-ai/dsh-agent-loop-testkit": "^0.0.1-rc.2",
47
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.2",
48
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.2",
49
+ "@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.2",
50
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.2",
51
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.2",
52
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.2",
53
53
  "@deepseek-ai/cordis": "^4.0.1-rc.1"
54
54
  }
55
55
  }