@jsonstudio/llms 0.6.1462 → 0.6.1733

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.
Files changed (148) hide show
  1. package/dist/conversion/codecs/gemini-openai-codec.js +6 -1
  2. package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.d.ts +4 -7
  3. package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.js +140 -21
  4. package/dist/conversion/compat/actions/antigravity-thought-signature-cache.js +68 -10
  5. package/dist/conversion/compat/actions/antigravity-thought-signature-prepare.js +151 -23
  6. package/dist/conversion/compat/actions/gemini-cli-request.js +72 -13
  7. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.d.ts +10 -0
  8. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.js +121 -0
  9. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.d.ts +10 -0
  10. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.js +80 -0
  11. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.d.ts +7 -0
  12. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.js +161 -0
  13. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.d.ts +12 -0
  14. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.js +67 -0
  15. package/dist/conversion/compat/actions/iflow-response-body-unwrap.d.ts +9 -0
  16. package/dist/conversion/compat/actions/iflow-response-body-unwrap.js +140 -0
  17. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.d.ts +10 -0
  18. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.js +59 -0
  19. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.d.ts +14 -0
  20. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.js +125 -0
  21. package/dist/conversion/compat/actions/normalize-tool-call-ids.d.ts +11 -0
  22. package/dist/conversion/compat/actions/normalize-tool-call-ids.js +140 -0
  23. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.d.ts +2 -0
  24. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +152 -0
  25. package/dist/conversion/compat/antigravity-session-signature.d.ts +57 -3
  26. package/dist/conversion/compat/antigravity-session-signature.js +821 -27
  27. package/dist/conversion/compat/profiles/anthropic-claude-code.json +0 -9
  28. package/dist/conversion/compat/profiles/chat-gemini-cli.json +1 -0
  29. package/dist/conversion/compat/profiles/chat-iflow.json +6 -0
  30. package/dist/conversion/compat/profiles/chat-lmstudio.json +7 -1
  31. package/dist/conversion/hub/operation-table/operation-table-runner.js +1 -1
  32. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +52 -10
  33. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +102 -6
  34. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.d.ts +2 -0
  35. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.js +63 -0
  36. package/dist/conversion/hub/pipeline/compat/compat-profile-store.js +12 -3
  37. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +18 -0
  38. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +1 -0
  39. package/dist/conversion/hub/pipeline/hub-pipeline.js +25 -1
  40. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +20 -0
  41. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +8 -5
  42. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage3_compat/index.js +5 -1
  43. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +113 -0
  44. package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +26 -1
  45. package/dist/conversion/hub/pipeline/target-utils.js +3 -0
  46. package/dist/conversion/hub/process/chat-process.js +300 -67
  47. package/dist/conversion/hub/response/provider-response.js +31 -4
  48. package/dist/conversion/responses/responses-openai-bridge.js +32 -6
  49. package/dist/conversion/shared/anthropic-message-utils.js +20 -5
  50. package/dist/conversion/shared/bridge-id-utils.d.ts +2 -0
  51. package/dist/conversion/shared/bridge-id-utils.js +52 -15
  52. package/dist/conversion/shared/gemini-tool-utils.js +134 -9
  53. package/dist/conversion/shared/responses-conversation-store.js +40 -5
  54. package/dist/conversion/shared/responses-output-builder.js +23 -7
  55. package/dist/conversion/shared/responses-tool-utils.d.ts +1 -0
  56. package/dist/conversion/shared/responses-tool-utils.js +30 -13
  57. package/dist/conversion/shared/text-markup-normalizer.d.ts +1 -0
  58. package/dist/conversion/shared/text-markup-normalizer.js +359 -2
  59. package/dist/conversion/shared/thought-signature-validator.d.ts +1 -1
  60. package/dist/conversion/shared/thought-signature-validator.js +2 -1
  61. package/dist/quota/apikey-reset.d.ts +17 -0
  62. package/dist/quota/apikey-reset.js +43 -0
  63. package/dist/quota/index.d.ts +2 -0
  64. package/dist/quota/index.js +1 -0
  65. package/dist/quota/quota-manager.d.ts +44 -0
  66. package/dist/quota/quota-manager.js +491 -0
  67. package/dist/quota/quota-state.d.ts +6 -0
  68. package/dist/quota/quota-state.js +167 -0
  69. package/dist/quota/types.d.ts +61 -0
  70. package/dist/quota/types.js +1 -0
  71. package/dist/router/virtual-router/bootstrap.js +134 -13
  72. package/dist/router/virtual-router/classifier.js +1 -1
  73. package/dist/router/virtual-router/engine/antigravity/alias-lease.d.ts +33 -0
  74. package/dist/router/virtual-router/engine/antigravity/alias-lease.js +247 -0
  75. package/dist/router/virtual-router/engine/health/index.d.ts +23 -0
  76. package/dist/router/virtual-router/engine/health/index.js +720 -0
  77. package/dist/router/virtual-router/engine/provider-key/parse.d.ts +6 -0
  78. package/dist/router/virtual-router/engine/provider-key/parse.js +43 -0
  79. package/dist/router/virtual-router/engine/routing-pools/index.d.ts +13 -0
  80. package/dist/router/virtual-router/engine/routing-pools/index.js +225 -0
  81. package/dist/router/virtual-router/engine/routing-state/keys.d.ts +3 -0
  82. package/dist/router/virtual-router/engine/routing-state/keys.js +30 -0
  83. package/dist/router/virtual-router/engine/routing-state/metadata.d.ts +6 -0
  84. package/dist/router/virtual-router/engine/routing-state/metadata.js +132 -0
  85. package/dist/router/virtual-router/engine/routing-state/store.d.ts +11 -0
  86. package/dist/router/virtual-router/engine/routing-state/store.js +107 -0
  87. package/dist/router/virtual-router/engine-health.d.ts +1 -23
  88. package/dist/router/virtual-router/engine-health.js +1 -616
  89. package/dist/router/virtual-router/engine-selection/route-utils.js +57 -0
  90. package/dist/router/virtual-router/engine-selection/selection-deps.d.ts +18 -0
  91. package/dist/router/virtual-router/engine-selection/tier-priority.d.ts +1 -2
  92. package/dist/router/virtual-router/engine-selection/tier-priority.js +2 -2
  93. package/dist/router/virtual-router/engine-selection/tier-selection-select.js +39 -55
  94. package/dist/router/virtual-router/engine-selection/tier-selection.js +284 -23
  95. package/dist/router/virtual-router/engine-selection.d.ts +1 -13
  96. package/dist/router/virtual-router/engine-selection.js +1 -225
  97. package/dist/router/virtual-router/engine.d.ts +8 -14
  98. package/dist/router/virtual-router/engine.js +187 -382
  99. package/dist/router/virtual-router/features.js +20 -2
  100. package/dist/router/virtual-router/message-utils.js +15 -5
  101. package/dist/router/virtual-router/success-center.d.ts +10 -0
  102. package/dist/router/virtual-router/success-center.js +32 -0
  103. package/dist/router/virtual-router/types.d.ts +48 -0
  104. package/dist/servertool/clock/config.d.ts +2 -0
  105. package/dist/servertool/clock/config.js +10 -2
  106. package/dist/servertool/clock/daemon.js +3 -0
  107. package/dist/servertool/clock/ntp.d.ts +18 -0
  108. package/dist/servertool/clock/ntp.js +318 -0
  109. package/dist/servertool/clock/paths.d.ts +1 -0
  110. package/dist/servertool/clock/paths.js +3 -0
  111. package/dist/servertool/clock/state.d.ts +2 -0
  112. package/dist/servertool/clock/state.js +15 -2
  113. package/dist/servertool/clock/tasks.d.ts +1 -0
  114. package/dist/servertool/clock/tasks.js +24 -1
  115. package/dist/servertool/clock/types.d.ts +21 -0
  116. package/dist/servertool/engine.js +109 -5
  117. package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.d.ts +1 -0
  118. package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.js +201 -0
  119. package/dist/servertool/handlers/clock-auto.js +39 -4
  120. package/dist/servertool/handlers/clock.js +145 -16
  121. package/dist/servertool/handlers/followup-request-builder.js +84 -0
  122. package/dist/servertool/handlers/gemini-empty-reply-continue.js +48 -47
  123. package/dist/servertool/handlers/stop-message-auto.js +3 -3
  124. package/dist/servertool/handlers/vision.js +10 -0
  125. package/dist/servertool/server-side-tools.d.ts +1 -0
  126. package/dist/servertool/server-side-tools.js +1 -0
  127. package/dist/servertool/types.d.ts +2 -0
  128. package/dist/sse/sse-to-json/builders/response-builder.js +6 -0
  129. package/dist/sse/sse-to-json/chat-sse-to-json-converter.js +32 -2
  130. package/dist/sse/sse-to-json/parsers/sse-parser.js +34 -0
  131. package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -0
  132. package/dist/sse/sse-to-json/responses-sse-to-json-converter.js +33 -1
  133. package/dist/tools/apply-patch/args-normalizer/default-actions.d.ts +2 -0
  134. package/dist/tools/apply-patch/args-normalizer/default-actions.js +12 -0
  135. package/dist/tools/apply-patch/args-normalizer/extract-patch.d.ts +2 -0
  136. package/dist/tools/apply-patch/args-normalizer/extract-patch.js +15 -0
  137. package/dist/tools/apply-patch/args-normalizer/index.d.ts +2 -0
  138. package/dist/tools/apply-patch/args-normalizer/index.js +164 -0
  139. package/dist/tools/apply-patch/args-normalizer/structured-builders.d.ts +7 -0
  140. package/dist/tools/apply-patch/args-normalizer/structured-builders.js +85 -0
  141. package/dist/tools/apply-patch/args-normalizer/types.d.ts +54 -0
  142. package/dist/tools/apply-patch/args-normalizer/types.js +1 -0
  143. package/dist/tools/apply-patch/execution-capturer.js +24 -3
  144. package/dist/tools/apply-patch/patch-text/looks-like-patch.js +1 -0
  145. package/dist/tools/apply-patch/patch-text/normalize.js +104 -5
  146. package/dist/tools/apply-patch/structured/coercion.js +28 -4
  147. package/dist/tools/apply-patch/validator.js +7 -146
  148. package/package.json +3 -2
@@ -1,8 +1,23 @@
1
1
  import { detectExtendedThinkingKeyword, detectImageAttachment, detectKeyword, extractMessageText, getLatestMessageRole, getLatestUserMessage } from './message-utils.js';
2
+ import { extractAntigravityGeminiSessionId } from '../../conversion/compat/antigravity-session-signature.js';
2
3
  import { detectCodingTool, detectLastAssistantToolCategory, detectVisionTool, detectWebTool, extractMeaningfulDeclaredToolNames } from './tool-signals.js';
3
4
  import { computeRequestTokens } from './token-estimator.js';
4
5
  const THINKING_KEYWORDS = ['let me think', 'chain of thought', 'cot', 'reason step', 'deliberate'];
5
6
  export function buildRoutingFeatures(request, metadata) {
7
+ const antigravitySessionId = (() => {
8
+ try {
9
+ const messages = Array.isArray(request.messages) ? request.messages : [];
10
+ const contents = messages.map((msg) => {
11
+ const role = msg?.role === 'user' ? 'user' : 'assistant';
12
+ const text = msg ? extractMessageText(msg) : '';
13
+ return { role, parts: [{ text }] };
14
+ });
15
+ return extractAntigravityGeminiSessionId({ contents });
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ })();
6
21
  const latestUserMessage = getLatestUserMessage(request.messages);
7
22
  const latestMessageRole = getLatestMessageRole(request.messages);
8
23
  const assistantMessages = request.messages.filter((msg) => msg.role === 'assistant');
@@ -14,7 +29,9 @@ export function buildRoutingFeatures(request, metadata) {
14
29
  const estimatedTokens = computeRequestTokens(request, latestUserText);
15
30
  const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
16
31
  const hasVisionTool = detectVisionTool(request);
17
- const hasImageAttachment = detectImageAttachment(latestUserMessage);
32
+ // Vision routing must only trigger for the current user turn (latest message),
33
+ // not for historical user messages carrying images during tool/assistant followups.
34
+ const hasImageAttachment = latestMessageRole === 'user' && detectImageAttachment(latestUserMessage);
18
35
  const hasCodingTool = detectCodingTool(request);
19
36
  const hasWebTool = detectWebTool(request);
20
37
  const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
@@ -50,7 +67,8 @@ export function buildRoutingFeatures(request, metadata) {
50
67
  lastAssistantToolLabel,
51
68
  latestMessageFromUser: latestMessageRole === 'user',
52
69
  metadata: {
53
- ...metadata
70
+ ...metadata,
71
+ ...(antigravitySessionId ? { antigravitySessionId } : {})
54
72
  }
55
73
  };
56
74
  }
@@ -59,8 +59,8 @@ export function detectExtendedThinkingKeyword(text) {
59
59
  export function detectImageAttachment(message) {
60
60
  if (!message)
61
61
  return false;
62
- // 仅基于标准 Chat 语义判断是否携带图片:
63
- // - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image', ... } 块;
62
+ // 仅基于标准 Chat 语义判断是否携带视觉媒体(图片/视频):
63
+ // - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image' | 'video' | 'video_url' | 'input_video', ... } 块;
64
64
  // - 不再依赖 metadata.attachments,也不再用纯文本关键字或剪贴板标记作为信号。
65
65
  if (Array.isArray(message.content)) {
66
66
  for (const part of message.content) {
@@ -69,19 +69,29 @@ export function detectImageAttachment(message) {
69
69
  }
70
70
  const record = part;
71
71
  const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
72
- // For chat/standardized content, images may appear as:
72
+ // For chat/standardized content, media may appear as:
73
73
  // - { type: "image_url", image_url: { url } }
74
74
  // - { type: "image", uri: "...", data: "...", url: "..." }
75
75
  // - { type: "input_image", image_url: "data:..." }
76
- // Treat any non-empty URL/URI/data on an image-* block as a signal.
76
+ // - { type: "video_url", video_url: { url } }
77
+ // - { type: "video", uri: "...", data: "...", url: "..." }
78
+ // - { type: "input_video", video_url: "data:..." }
79
+ // Treat any non-empty URL/URI/data on a media-* block as a signal.
77
80
  let imageCandidate = '';
78
81
  if (typeof record.image_url === 'string') {
79
82
  imageCandidate = record.image_url ?? '';
80
83
  }
84
+ else if (typeof record.video_url === 'string') {
85
+ imageCandidate = record.video_url ?? '';
86
+ }
81
87
  else if (record.image_url &&
82
88
  typeof record.image_url?.url === 'string') {
83
89
  imageCandidate = record.image_url?.url ?? '';
84
90
  }
91
+ else if (record.video_url &&
92
+ typeof record.video_url?.url === 'string') {
93
+ imageCandidate = record.video_url?.url ?? '';
94
+ }
85
95
  else if (typeof record.url === 'string') {
86
96
  imageCandidate = record.url ?? '';
87
97
  }
@@ -91,7 +101,7 @@ export function detectImageAttachment(message) {
91
101
  else if (typeof record.data === 'string') {
92
102
  imageCandidate = record.data ?? '';
93
103
  }
94
- if (typeValue.includes('image') && imageCandidate.trim().length > 0) {
104
+ if ((typeValue.includes('image') || typeValue.includes('video')) && imageCandidate.trim().length > 0) {
95
105
  return true;
96
106
  }
97
107
  }
@@ -0,0 +1,10 @@
1
+ import type { ProviderSuccessEvent } from './types.js';
2
+ type ProviderSuccessListener = (event: ProviderSuccessEvent) => void;
3
+ export declare class ProviderSuccessCenter {
4
+ private readonly listeners;
5
+ subscribe(listener: ProviderSuccessListener): () => void;
6
+ emit(event: ProviderSuccessEvent): ProviderSuccessEvent;
7
+ private normalize;
8
+ }
9
+ export declare const providerSuccessCenter: ProviderSuccessCenter;
10
+ export {};
@@ -0,0 +1,32 @@
1
+ export class ProviderSuccessCenter {
2
+ listeners = new Set();
3
+ subscribe(listener) {
4
+ this.listeners.add(listener);
5
+ return () => {
6
+ this.listeners.delete(listener);
7
+ };
8
+ }
9
+ emit(event) {
10
+ const enriched = this.normalize(event);
11
+ for (const listener of this.listeners) {
12
+ try {
13
+ listener(enriched);
14
+ }
15
+ catch {
16
+ // Listener failures should not break propagation
17
+ }
18
+ }
19
+ return enriched;
20
+ }
21
+ normalize(event) {
22
+ const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now();
23
+ const runtime = event.runtime || {};
24
+ return {
25
+ runtime,
26
+ timestamp,
27
+ metadata: event.metadata,
28
+ details: event.details
29
+ };
30
+ }
31
+ }
32
+ export const providerSuccessCenter = new ProviderSuccessCenter();
@@ -166,6 +166,19 @@ export interface AliasSelectionConfig {
166
166
  * Per-provider overrides keyed by providerId (e.g. "antigravity").
167
167
  */
168
168
  providers?: Record<string, AliasSelectionStrategy>;
169
+ /**
170
+ * Antigravity session isolation cooldown window (ms).
171
+ * Within this window, the same Antigravity auth alias must not be reused by a different session.
172
+ * Default: 300000 (5 minutes).
173
+ */
174
+ sessionLeaseCooldownMs?: number;
175
+ /**
176
+ * Antigravity multi-alias session binding policy.
177
+ * - "lease" (default): prefer the session's last used alias, but can rotate to another alias when needed.
178
+ * - "strict": once a session binds to an alias, it will not switch to another alias; on failure it must
179
+ * fall back to other providers/routes rather than trying a different Antigravity alias.
180
+ */
181
+ antigravitySessionBinding?: 'lease' | 'strict';
169
182
  }
170
183
  export interface ContextWeightedLoadBalancingConfig {
171
184
  /**
@@ -238,6 +251,16 @@ export interface VirtualRouterClockConfig {
238
251
  * Daemon tick interval (ms). 0 disables background cleanup tick (still cleans on load).
239
252
  */
240
253
  tickMs?: number;
254
+ /**
255
+ * Allow clock hold flow for non-streaming (JSON) requests.
256
+ * Default: true.
257
+ */
258
+ holdNonStreaming?: boolean;
259
+ /**
260
+ * Maximum time (ms) a request is allowed to hold waiting for due window.
261
+ * Default: 60s.
262
+ */
263
+ holdMaxMs?: number;
241
264
  }
242
265
  export interface VirtualRouterConfig {
243
266
  routing: RoutingPools;
@@ -284,6 +307,11 @@ export interface RouterMetadataInput {
284
307
  providerProtocol?: string;
285
308
  stage?: 'inbound' | 'outbound' | 'response';
286
309
  routeHint?: string;
310
+ /**
311
+ * Antigravity-Manager alignment: stable sessionId derived from the first user message text.
312
+ * Used for Antigravity alias/session binding and thoughtSignature persistence.
313
+ */
314
+ antigravitySessionId?: string;
287
315
  /**
288
316
  * Indicates that current routing decision is for a request which
289
317
  * expects server-side tools orchestration (e.g. web_search).
@@ -481,6 +509,26 @@ export interface ProviderErrorEvent {
481
509
  timestamp: number;
482
510
  details?: Record<string, unknown>;
483
511
  }
512
+ export interface ProviderSuccessRuntimeMetadata {
513
+ requestId: string;
514
+ routeName?: string;
515
+ providerKey?: string;
516
+ providerId?: string;
517
+ providerType?: string;
518
+ providerProtocol?: string;
519
+ pipelineId?: string;
520
+ target?: TargetMetadata | Record<string, unknown>;
521
+ }
522
+ export interface ProviderSuccessEvent {
523
+ runtime: ProviderSuccessRuntimeMetadata;
524
+ timestamp: number;
525
+ /**
526
+ * Optional request metadata snapshot (e.g. sessionId / conversationId).
527
+ * This must not contain provider-specific payload semantics.
528
+ */
529
+ metadata?: Record<string, unknown>;
530
+ details?: Record<string, unknown>;
531
+ }
484
532
  export interface FeatureBuilder {
485
533
  build(request: StandardizedRequest, metadata: RouterMetadataInput): RoutingFeatures;
486
534
  }
@@ -3,6 +3,8 @@ export declare const CLOCK_CONFIG_DEFAULTS: {
3
3
  readonly retentionMs: number;
4
4
  readonly dueWindowMs: 60000;
5
5
  readonly tickMs: 60000;
6
+ readonly holdNonStreaming: true;
7
+ readonly holdMaxMs: 60000;
6
8
  };
7
9
  export declare function normalizeClockConfig(raw: unknown): ClockConfigSnapshot | null;
8
10
  /**
@@ -1,7 +1,9 @@
1
1
  export const CLOCK_CONFIG_DEFAULTS = {
2
2
  retentionMs: 20 * 60_000,
3
3
  dueWindowMs: 60_000,
4
- tickMs: 60_000
4
+ tickMs: 60_000,
5
+ holdNonStreaming: true,
6
+ holdMaxMs: 60_000
5
7
  };
6
8
  export function normalizeClockConfig(raw) {
7
9
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -23,7 +25,13 @@ export function normalizeClockConfig(raw) {
23
25
  const tickMs = typeof record.tickMs === 'number' && Number.isFinite(record.tickMs) && record.tickMs >= 0
24
26
  ? Math.floor(record.tickMs)
25
27
  : CLOCK_CONFIG_DEFAULTS.tickMs;
26
- return { enabled: true, retentionMs, dueWindowMs, tickMs };
28
+ const holdNonStreaming = record.holdNonStreaming === true ||
29
+ (typeof record.holdNonStreaming === 'string' && record.holdNonStreaming.trim().toLowerCase() === 'true') ||
30
+ (typeof record.holdNonStreaming === 'number' && record.holdNonStreaming === 1);
31
+ const holdMaxMs = typeof record.holdMaxMs === 'number' && Number.isFinite(record.holdMaxMs) && record.holdMaxMs >= 0
32
+ ? Math.floor(record.holdMaxMs)
33
+ : CLOCK_CONFIG_DEFAULTS.holdMaxMs;
34
+ return { enabled: true, retentionMs, dueWindowMs, tickMs, holdNonStreaming, holdMaxMs };
27
35
  }
28
36
  /**
29
37
  * Resolve the effective clock config for a request/session.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { readSessionDirEnv, resolveClockDir } from './paths.js';
4
4
  import { cleanExpiredTasks, coerceState, nowMs } from './state.js';
5
5
  import { readJsonFile, writeJsonFileAtomic } from './io.js';
6
+ import { startClockNtpSyncIfNeeded } from './ntp.js';
6
7
  let daemonStarted = false;
7
8
  let daemonTimer;
8
9
  let daemonConfig;
@@ -16,6 +17,8 @@ export async function startClockDaemonIfNeeded(config) {
16
17
  }
17
18
  daemonStarted = true;
18
19
  daemonConfig = config;
20
+ // Best-effort NTP sync (do not block daemon startup).
21
+ void startClockNtpSyncIfNeeded(config);
19
22
  const tickOnce = async () => {
20
23
  const effective = daemonConfig;
21
24
  if (!effective)
@@ -0,0 +1,18 @@
1
+ import type { ClockConfigSnapshot, ClockNtpState } from './types.js';
2
+ export declare function resolveServerTimezone(): string;
3
+ export declare function formatLocalTime(ms: number): string;
4
+ export declare function buildTimeTagLine(snapshot: ClockTimeSnapshot): string;
5
+ export type ClockTimeSnapshot = {
6
+ active: boolean;
7
+ nowMs: number;
8
+ utc: string;
9
+ local: string;
10
+ timezone: string;
11
+ ntp: ClockNtpState;
12
+ };
13
+ export declare function syncClockWithNtpOnce(): Promise<void>;
14
+ export declare function startClockNtpSyncIfNeeded(_config?: ClockConfigSnapshot): Promise<void>;
15
+ export declare function getClockNtpState(): Promise<ClockNtpState>;
16
+ export declare function getClockTimeSnapshot(): Promise<ClockTimeSnapshot>;
17
+ export declare function getCurrentClockOffsetMs(): number;
18
+ export declare function buildStableToolCallId(prefix?: string): string;
@@ -0,0 +1,318 @@
1
+ import dgram from 'node:dgram';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { ensureDir, readSessionDirEnv, resolveClockNtpStateFile } from './paths.js';
6
+ import { readJsonFile, writeJsonFileAtomic } from './io.js';
7
+ import { getClockOffsetMs, setClockOffsetMs, nowMs as correctedNowMs } from './state.js';
8
+ const DEFAULT_NTP_SERVERS = ['time.google.com', 'time.cloudflare.com', 'pool.ntp.org'];
9
+ function isNtpDisabledByEnv() {
10
+ const raw = String(process.env.ROUTECODEX_CLOCK_NTP || '').trim().toLowerCase();
11
+ if (!raw)
12
+ return false;
13
+ return raw === '0' || raw === 'false' || raw === 'off' || raw === 'disable' || raw === 'disabled';
14
+ }
15
+ function clampNumber(value, min, max) {
16
+ if (!Number.isFinite(value))
17
+ return min;
18
+ return Math.max(min, Math.min(max, value));
19
+ }
20
+ function safeErrorMessage(err) {
21
+ try {
22
+ if (err instanceof Error)
23
+ return err.message || err.name;
24
+ return String(err ?? 'unknown');
25
+ }
26
+ catch {
27
+ return 'unknown';
28
+ }
29
+ }
30
+ function pad2(n) {
31
+ return n < 10 ? `0${n}` : String(n);
32
+ }
33
+ function pad3(n) {
34
+ if (n < 10)
35
+ return `00${n}`;
36
+ if (n < 100)
37
+ return `0${n}`;
38
+ return String(n);
39
+ }
40
+ function formatOffset(minutesEast) {
41
+ const sign = minutesEast >= 0 ? '+' : '-';
42
+ const abs = Math.abs(minutesEast);
43
+ const hh = Math.floor(abs / 60);
44
+ const mm = abs % 60;
45
+ return `${sign}${pad2(hh)}:${pad2(mm)}`;
46
+ }
47
+ export function resolveServerTimezone() {
48
+ try {
49
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
50
+ return typeof tz === 'string' && tz.trim().length ? tz.trim() : 'unknown';
51
+ }
52
+ catch {
53
+ return 'unknown';
54
+ }
55
+ }
56
+ export function formatLocalTime(ms) {
57
+ const d = new Date(ms);
58
+ const y = d.getFullYear();
59
+ const mo = pad2(d.getMonth() + 1);
60
+ const da = pad2(d.getDate());
61
+ const hh = pad2(d.getHours());
62
+ const mi = pad2(d.getMinutes());
63
+ const ss = pad2(d.getSeconds());
64
+ const mmm = pad3(d.getMilliseconds());
65
+ const minutesEast = -d.getTimezoneOffset();
66
+ return `${y}-${mo}-${da} ${hh}:${mi}:${ss}.${mmm} ${formatOffset(minutesEast)}`;
67
+ }
68
+ export function buildTimeTagLine(snapshot) {
69
+ // Markdown inline code blocks to reduce the chance of models "roleplaying" XML-like tags.
70
+ return `[Time/Date]: utc=\`${snapshot.utc}\` local=\`${snapshot.local}\` tz=\`${snapshot.timezone}\` nowMs=\`${snapshot.nowMs}\` ntpOffsetMs=\`${snapshot.ntp.offsetMs}\``;
71
+ }
72
+ const EMPTY_NTP_STATE = {
73
+ version: 1,
74
+ offsetMs: 0,
75
+ updatedAtMs: 0,
76
+ status: 'stale'
77
+ };
78
+ let loaded = false;
79
+ let state = { ...EMPTY_NTP_STATE };
80
+ let syncing = null;
81
+ function coerceNtpState(raw) {
82
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
83
+ return { ...EMPTY_NTP_STATE };
84
+ }
85
+ const r = raw;
86
+ const offsetMs = typeof r.offsetMs === 'number' && Number.isFinite(r.offsetMs) ? Math.floor(r.offsetMs) : 0;
87
+ const updatedAtMs = typeof r.updatedAtMs === 'number' && Number.isFinite(r.updatedAtMs) ? Math.floor(r.updatedAtMs) : 0;
88
+ const statusRaw = typeof r.status === 'string' ? r.status.trim() : '';
89
+ const status = statusRaw === 'synced' || statusRaw === 'stale' || statusRaw === 'error' || statusRaw === 'disabled'
90
+ ? statusRaw
91
+ : updatedAtMs > 0
92
+ ? 'stale'
93
+ : 'stale';
94
+ const source = typeof r.source === 'string' && r.source.trim().length ? r.source.trim() : undefined;
95
+ const rttMs = typeof r.rttMs === 'number' && Number.isFinite(r.rttMs) ? Math.max(0, Math.floor(r.rttMs)) : undefined;
96
+ const lastError = typeof r.lastError === 'string' && r.lastError.trim().length ? r.lastError.trim() : undefined;
97
+ return { version: 1, offsetMs, updatedAtMs, status, ...(source ? { source } : {}), ...(rttMs !== undefined ? { rttMs } : {}), ...(lastError ? { lastError } : {}) };
98
+ }
99
+ async function loadStateOnce() {
100
+ if (loaded)
101
+ return;
102
+ loaded = true;
103
+ if (isNtpDisabledByEnv()) {
104
+ state = { ...EMPTY_NTP_STATE, status: 'disabled' };
105
+ setClockOffsetMs(0);
106
+ return;
107
+ }
108
+ const sessionDir = readSessionDirEnv();
109
+ if (!sessionDir) {
110
+ state = { ...EMPTY_NTP_STATE, status: 'stale' };
111
+ setClockOffsetMs(0);
112
+ return;
113
+ }
114
+ const filePath = resolveClockNtpStateFile(sessionDir);
115
+ try {
116
+ const raw = await readJsonFile(filePath);
117
+ state = coerceNtpState(raw);
118
+ setClockOffsetMs(state.offsetMs);
119
+ }
120
+ catch {
121
+ // missing/unreadable file: keep defaults
122
+ state = { ...EMPTY_NTP_STATE, status: 'stale' };
123
+ setClockOffsetMs(0);
124
+ }
125
+ }
126
+ async function persistState(next) {
127
+ const sessionDir = readSessionDirEnv();
128
+ if (!sessionDir)
129
+ return;
130
+ const filePath = resolveClockNtpStateFile(sessionDir);
131
+ await ensureDir(path.dirname(filePath));
132
+ try {
133
+ await fs.chmod(path.dirname(filePath), 0o700);
134
+ }
135
+ catch {
136
+ // best-effort
137
+ }
138
+ await writeJsonFileAtomic(filePath, next);
139
+ }
140
+ const NTP_EPOCH_OFFSET_SECONDS = 2208988800; // 1900-01-01 to 1970-01-01
141
+ function msToNtpTimestamp(ms) {
142
+ const seconds = Math.floor(ms / 1000) + NTP_EPOCH_OFFSET_SECONDS;
143
+ const msRemainder = ms % 1000;
144
+ const fraction = Math.floor((msRemainder / 1000) * 2 ** 32);
145
+ return { seconds, fraction };
146
+ }
147
+ function ntpTimestampToMs(seconds, fraction) {
148
+ const unixSeconds = seconds - NTP_EPOCH_OFFSET_SECONDS;
149
+ const fracMs = Math.round((fraction / 2 ** 32) * 1000);
150
+ return unixSeconds * 1000 + fracMs;
151
+ }
152
+ async function querySntpOnce(server, timeoutMs) {
153
+ const socket = dgram.createSocket('udp4');
154
+ const req = Buffer.alloc(48);
155
+ req[0] = 0x23; // LI=0, VN=4, Mode=3 (client)
156
+ const t1SystemMs = Date.now();
157
+ const t1 = msToNtpTimestamp(t1SystemMs);
158
+ req.writeUInt32BE(t1.seconds >>> 0, 40);
159
+ req.writeUInt32BE(t1.fraction >>> 0, 44);
160
+ const res = await new Promise((resolve, reject) => {
161
+ const timer = setTimeout(() => {
162
+ reject(new Error('ntp timeout'));
163
+ }, timeoutMs);
164
+ socket.once('error', (err) => {
165
+ clearTimeout(timer);
166
+ reject(err);
167
+ });
168
+ socket.once('message', (msg) => {
169
+ clearTimeout(timer);
170
+ resolve(msg);
171
+ });
172
+ socket.send(req, 123, server, (err) => {
173
+ if (err) {
174
+ clearTimeout(timer);
175
+ reject(err);
176
+ }
177
+ });
178
+ }).finally(() => {
179
+ try {
180
+ socket.close();
181
+ }
182
+ catch {
183
+ // ignore
184
+ }
185
+ });
186
+ if (!Buffer.isBuffer(res) || res.length < 48) {
187
+ throw new Error('invalid ntp response');
188
+ }
189
+ const t4SystemMs = Date.now();
190
+ const t2Seconds = res.readUInt32BE(32);
191
+ const t2Fraction = res.readUInt32BE(36);
192
+ const t3Seconds = res.readUInt32BE(40);
193
+ const t3Fraction = res.readUInt32BE(44);
194
+ const t2Ms = ntpTimestampToMs(t2Seconds, t2Fraction);
195
+ const t3Ms = ntpTimestampToMs(t3Seconds, t3Fraction);
196
+ const offsetMs = ((t2Ms - t1SystemMs) + (t3Ms - t4SystemMs)) / 2;
197
+ const rttMs = (t4SystemMs - t1SystemMs) - (t3Ms - t2Ms);
198
+ return {
199
+ offsetMs: Math.floor(clampNumber(offsetMs, -24 * 60 * 60_000, 24 * 60 * 60_000)),
200
+ rttMs: Math.max(0, Math.floor(rttMs))
201
+ };
202
+ }
203
+ function resolveNtpServers() {
204
+ const raw = String(process.env.ROUTECODEX_CLOCK_NTP_SERVERS || '').trim();
205
+ if (!raw)
206
+ return [...DEFAULT_NTP_SERVERS];
207
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
208
+ return list.length ? list : [...DEFAULT_NTP_SERVERS];
209
+ }
210
+ export async function syncClockWithNtpOnce() {
211
+ await loadStateOnce();
212
+ if (isNtpDisabledByEnv()) {
213
+ state = { ...state, status: 'disabled', offsetMs: 0 };
214
+ setClockOffsetMs(0);
215
+ return;
216
+ }
217
+ const servers = resolveNtpServers();
218
+ const timeoutMs = (() => {
219
+ const raw = Number(process.env.ROUTECODEX_CLOCK_NTP_TIMEOUT_MS ?? 800);
220
+ return Number.isFinite(raw) ? Math.max(100, Math.floor(raw)) : 800;
221
+ })();
222
+ let lastErr;
223
+ for (const server of servers.slice(0, 5)) {
224
+ try {
225
+ const result = await querySntpOnce(server, timeoutMs);
226
+ const updatedAtMs = Date.now();
227
+ const next = {
228
+ version: 1,
229
+ offsetMs: result.offsetMs,
230
+ updatedAtMs,
231
+ source: server,
232
+ rttMs: result.rttMs,
233
+ status: 'synced'
234
+ };
235
+ state = next;
236
+ setClockOffsetMs(result.offsetMs);
237
+ await persistState(next);
238
+ return;
239
+ }
240
+ catch (err) {
241
+ lastErr = safeErrorMessage(err);
242
+ }
243
+ }
244
+ state = {
245
+ ...state,
246
+ status: 'error',
247
+ lastError: lastErr || 'ntp failed',
248
+ updatedAtMs: state.updatedAtMs || Date.now()
249
+ };
250
+ }
251
+ export async function startClockNtpSyncIfNeeded(_config) {
252
+ await loadStateOnce();
253
+ if (isNtpDisabledByEnv())
254
+ return;
255
+ if (syncing)
256
+ return syncing;
257
+ // Best-effort background sync; do not block the request pipeline.
258
+ syncing = (async () => {
259
+ try {
260
+ await syncClockWithNtpOnce();
261
+ }
262
+ catch {
263
+ // best-effort
264
+ }
265
+ finally {
266
+ syncing = null;
267
+ }
268
+ })();
269
+ return syncing;
270
+ }
271
+ export async function getClockNtpState() {
272
+ await loadStateOnce();
273
+ const now = Date.now();
274
+ const staleAfterMs = (() => {
275
+ const raw = Number(process.env.ROUTECODEX_CLOCK_NTP_STALE_AFTER_MS ?? 6 * 60 * 60_000);
276
+ return Number.isFinite(raw) ? Math.max(60_000, Math.floor(raw)) : 6 * 60 * 60_000;
277
+ })();
278
+ const age = state.updatedAtMs > 0 ? Math.max(0, now - state.updatedAtMs) : Number.POSITIVE_INFINITY;
279
+ if (state.status === 'synced' && age > staleAfterMs) {
280
+ return { ...state, status: 'stale' };
281
+ }
282
+ return { ...state };
283
+ }
284
+ export async function getClockTimeSnapshot() {
285
+ await loadStateOnce();
286
+ const now = correctedNowMs();
287
+ const d = new Date(now);
288
+ const utc = (() => {
289
+ try {
290
+ return d.toISOString();
291
+ }
292
+ catch {
293
+ return new Date(0).toISOString();
294
+ }
295
+ })();
296
+ const timezone = resolveServerTimezone();
297
+ const local = formatLocalTime(now);
298
+ const ntp = await getClockNtpState();
299
+ return {
300
+ active: true,
301
+ nowMs: now,
302
+ utc,
303
+ local,
304
+ timezone,
305
+ ntp
306
+ };
307
+ }
308
+ export function getCurrentClockOffsetMs() {
309
+ return getClockOffsetMs();
310
+ }
311
+ export function buildStableToolCallId(prefix = 'call_clock') {
312
+ try {
313
+ return `${prefix}_${crypto.randomUUID()}`;
314
+ }
315
+ catch {
316
+ return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
317
+ }
318
+ }
@@ -1,4 +1,5 @@
1
1
  export declare function readSessionDirEnv(): string;
2
2
  export declare function resolveClockDir(sessionDir: string): string;
3
+ export declare function resolveClockNtpStateFile(sessionDir: string): string;
3
4
  export declare function resolveClockStateFile(sessionDir: string, sessionId: string): string | null;
4
5
  export declare function ensureDir(dir: string): Promise<void>;
@@ -13,6 +13,9 @@ function sanitizeSegment(value) {
13
13
  export function resolveClockDir(sessionDir) {
14
14
  return path.join(sessionDir, 'clock');
15
15
  }
16
+ export function resolveClockNtpStateFile(sessionDir) {
17
+ return path.join(resolveClockDir(sessionDir), 'ntp-state.json');
18
+ }
16
19
  export function resolveClockStateFile(sessionDir, sessionId) {
17
20
  const safe = sanitizeSegment(sessionId);
18
21
  if (!safe) {
@@ -1,4 +1,6 @@
1
1
  import type { ClockConfigSnapshot, ClockSessionState, ClockTask } from './types.js';
2
+ export declare function setClockOffsetMs(value: number): void;
3
+ export declare function getClockOffsetMs(): number;
2
4
  export declare function nowMs(): number;
3
5
  export declare function buildEmptyState(sessionId: string): ClockSessionState;
4
6
  export declare function coerceState(raw: unknown, sessionId: string): ClockSessionState;
@@ -1,5 +1,14 @@
1
+ let clockOffsetMs = 0;
2
+ export function setClockOffsetMs(value) {
3
+ if (typeof value !== 'number' || !Number.isFinite(value))
4
+ return;
5
+ clockOffsetMs = Math.max(-24 * 60 * 60_000, Math.min(24 * 60 * 60_000, Math.floor(value)));
6
+ }
7
+ export function getClockOffsetMs() {
8
+ return clockOffsetMs;
9
+ }
1
10
  export function nowMs() {
2
- return Date.now();
11
+ return Date.now() + clockOffsetMs;
3
12
  }
4
13
  export function buildEmptyState(sessionId) {
5
14
  const t = nowMs();
@@ -29,6 +38,9 @@ export function coerceState(raw, sessionId) {
29
38
  ? e.arguments
30
39
  : undefined;
31
40
  const deliveredAtMs = typeof e.deliveredAtMs === 'number' && Number.isFinite(e.deliveredAtMs) ? Math.floor(e.deliveredAtMs) : undefined;
41
+ const notBeforeRequestId = typeof e.notBeforeRequestId === 'string' && e.notBeforeRequestId.trim().length
42
+ ? e.notBeforeRequestId.trim()
43
+ : undefined;
32
44
  const deliveryCount = typeof e.deliveryCount === 'number' && Number.isFinite(e.deliveryCount) ? Math.max(0, Math.floor(e.deliveryCount)) : 0;
33
45
  tasks.push({
34
46
  taskId,
@@ -40,7 +52,8 @@ export function coerceState(raw, sessionId) {
40
52
  ...(tool ? { tool } : {}),
41
53
  ...(args ? { arguments: args } : {}),
42
54
  ...(deliveredAtMs !== undefined ? { deliveredAtMs } : {}),
43
- deliveryCount
55
+ deliveryCount,
56
+ ...(notBeforeRequestId ? { notBeforeRequestId } : {})
44
57
  });
45
58
  }
46
59
  const updatedAtMs = typeof record.updatedAtMs === 'number' && Number.isFinite(record.updatedAtMs) ? Math.floor(record.updatedAtMs) : nowMs();
@@ -10,6 +10,7 @@ export declare function reserveDueTasksForRequest(args: {
10
10
  reservationId: string;
11
11
  sessionId: string;
12
12
  config: ClockConfigSnapshot;
13
+ requestId?: string;
13
14
  }): Promise<{
14
15
  reservation: ClockReservation | null;
15
16
  injectText?: string;