@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 +2 -2
- package/README.md +23 -16
- package/README.zh.md +23 -16
- package/lib/index.js +132 -22
- package/lib/invariant.js +241 -18
- package/lib/types/index.d.ts +1 -1
- package/lib/types/request-zone.d.ts +26 -0
- package/lib/types/timestamp.d.ts +16 -0
- package/package.json +14 -14
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:
|
|
6
|
-
README.zh.md:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
+
当当前开放轮次只包含一个经 Host 校验的浏览器时区时,使用该请求本地时区格式化时间戳。浏览器来源信息缺失或混杂时,`timeZone` 提供显示回退;省略它则会在插件加载时解析一次 Node 进程时区。Node 遵循 `TZ`,每个显式回退值都经 `Intl.DateTimeFormat` 校验。
|
|
18
18
|
|
|
19
|
-
`refreshIntervalMs` 必须是非负安全整数。省略或设为 `0`
|
|
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
|
-
|
|
25
|
+
解析后的指令告诉模型,把未明确限定时区的日期和时间解释为该浏览器时区。来源信息为 mixed 或 unavailable 时,模型会收到要求用户澄清的指令。这是自然语言上下文,并非另一个包边界上的输入默认值:接受本地日历字段的工具仍自行负责其显式时区要求。
|
|
26
|
+
|
|
27
|
+
## 时序语义
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
该插件会前置一个 `agent/pre-step` 监听器,并先行委托下游。需要注入且下游决策进入步骤时,它会向返回批次追加一条带来源的 `UserMessage`。AgentLoop 在 `step/start` 之后、请求派生之前记录最终批次。决策被拒绝、监听器失败或信号已经中止时,不会记录任何内容。
|
|
26
30
|
|
|
27
|
-
|
|
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
|
-
|
|
35
|
+
第 1 步从最新一条在其之前持久化的用户、助手或工具结果消息起测量。为该步骤拟议的提示词尚未追加。后续步骤从同一轮次中前一个 time-context 事件起测量。缺少基线时报告 `unavailable`,挂钟时间倒退时将经过时长限制为零。
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
读数记录的是已进入的步骤,不是已完成或已传输的请求。后续准备失败时,该读数可能留在历史中。消息会保留在派生会话历史中,直到压缩将其遮蔽;`request/header` 不含 time-context 状态,请求重建会使用每个 `step/start` 之后的完整持久表层前缀。
|
|
34
38
|
|
|
35
39
|
## 模型体验
|
|
36
40
|
|
|
@@ -38,12 +42,13 @@
|
|
|
38
42
|
|
|
39
43
|
#### 模型看到的内容
|
|
40
44
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
346
|
+
let fallbackFormatter;
|
|
239
347
|
try {
|
|
240
|
-
|
|
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
|
|
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
|
|
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
|
|
188
|
+
/** Derive the open step boundary at which a time-context reading may append. */
|
|
11
189
|
function preparationPosition(history, fail) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
34
|
-
|
|
35
|
-
const
|
|
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
|
|
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;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
|
39
|
-
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.
|
|
40
|
-
"@deepseek-ai/
|
|
41
|
-
"@deepseek-ai/
|
|
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
|
|
45
|
-
"@deepseek-ai/dsh-agent-loop
|
|
46
|
-
"@deepseek-ai/dsh-
|
|
47
|
-
"@deepseek-ai/dsh-
|
|
48
|
-
"@deepseek-ai/dsh-
|
|
49
|
-
"@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.
|
|
50
|
-
"@deepseek-ai/dsh-session": "^0.0.1-rc.
|
|
51
|
-
"@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.
|
|
52
|
-
"@deepseek-ai/dsh-tools": "^0.0.1-rc.
|
|
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
|
}
|