@vellumai/assistant 0.8.2 → 0.8.3
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/ARCHITECTURE.md +11 -12
- package/docker-entrypoint.sh +13 -1
- package/docker-init-apt-root.sh +79 -6
- package/openapi.yaml +336 -21
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
- package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
- package/src/__tests__/config-get-vision-flag.test.ts +136 -0
- package/src/__tests__/config-loader-backfill.test.ts +115 -18
- package/src/__tests__/context-token-estimator.test.ts +30 -65
- package/src/__tests__/conversation-agent-loop.test.ts +57 -1
- package/src/__tests__/conversation-media-retry.test.ts +19 -8
- package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
- package/src/__tests__/date-context.test.ts +45 -0
- package/src/__tests__/external-plugin-loader.test.ts +91 -19
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
- package/src/__tests__/guardian-dispatch.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +24 -164
- package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
- package/src/__tests__/host-app-control-proxy.test.ts +241 -0
- package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
- package/src/__tests__/injector-background-turn.test.ts +153 -0
- package/src/__tests__/injector-chain.test.ts +5 -0
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
- package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
- package/src/__tests__/llm-catalog-parity.test.ts +3 -0
- package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
- package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
- package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
- package/src/__tests__/llm-resolver.test.ts +255 -2
- package/src/__tests__/managed-profile-guard.test.ts +10 -0
- package/src/__tests__/notification-decision-fallback.test.ts +0 -91
- package/src/__tests__/notification-decision-strategy.test.ts +14 -31
- package/src/__tests__/notification-deep-link.test.ts +15 -0
- package/src/__tests__/notification-guardian-path.test.ts +1 -2
- package/src/__tests__/notification-platform-adapter.test.ts +5 -4
- package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
- package/src/__tests__/openai-provider.test.ts +218 -3
- package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
- package/src/__tests__/openrouter-provider-only.test.ts +51 -3
- package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
- package/src/__tests__/platform-proxy-context.test.ts +6 -1
- package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
- package/src/__tests__/plugin-types.test.ts +2 -2
- package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +6 -73
- package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
- package/src/a2a/__tests__/agent-card.test.ts +98 -0
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
- package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
- package/src/a2a/__tests__/task-store.test.ts +246 -0
- package/src/a2a/agent-card.ts +58 -0
- package/src/a2a/feature-gate.ts +8 -0
- package/src/a2a/protocol-constants.ts +21 -0
- package/src/a2a/protocol-errors.ts +50 -0
- package/src/a2a/protocol-types.ts +162 -0
- package/src/a2a/task-store.ts +168 -0
- package/src/agent/loop.ts +167 -18
- package/src/channels/config.ts +9 -0
- package/src/channels/types.ts +14 -0
- package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
- package/src/cli/commands/__tests__/schedules.test.ts +469 -0
- package/src/cli/commands/notifications.ts +65 -35
- package/src/cli/commands/plugins.ts +67 -0
- package/src/cli/commands/schedules.ts +297 -5
- package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
- package/src/cli/lib/install-from-github.ts +8 -9
- package/src/cli/lib/search-plugins.ts +163 -0
- package/src/cli/program.ts +14 -0
- package/src/config/assistant-feature-flags.ts +24 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/call-site-defaults.ts +105 -0
- package/src/config/feature-flag-registry.json +21 -29
- package/src/config/llm-resolver.ts +52 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
- package/src/config/schemas/channels.ts +9 -0
- package/src/config/schemas/conversations.ts +10 -0
- package/src/config/schemas/heartbeat.ts +14 -0
- package/src/config/schemas/llm.ts +1 -3
- package/src/config/schemas/memory-retrospective.ts +1 -1
- package/src/config/schemas/memory-v2.ts +4 -4
- package/src/config/schemas/memory.ts +3 -1
- package/src/config/seed-inference-profiles.ts +99 -29
- package/src/context/compactor.ts +72 -12
- package/src/context/token-estimator.ts +32 -34
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
- package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
- package/src/daemon/conversation-agent-loop.ts +29 -2
- package/src/daemon/conversation-runtime-assembly.ts +9 -0
- package/src/daemon/conversation.ts +0 -7
- package/src/daemon/date-context.ts +40 -0
- package/src/daemon/guardian-action-generators.ts +1 -125
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
- package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
- package/src/daemon/handlers/config-a2a.ts +289 -0
- package/src/daemon/handlers/conversations.ts +1 -0
- package/src/daemon/host-app-control-proxy.ts +69 -18
- package/src/daemon/host-proxy-preactivation.ts +85 -18
- package/src/daemon/lifecycle.ts +49 -61
- package/src/daemon/memory-v2-startup.ts +49 -13
- package/src/daemon/message-types/notifications.ts +21 -0
- package/src/daemon/pkb-reminder-builder.test.ts +10 -53
- package/src/daemon/pkb-reminder-builder.ts +4 -19
- package/src/daemon/process-message.ts +3 -0
- package/src/daemon/skill-memory-refresh.ts +5 -1
- package/src/daemon/wake-target-adapter.ts +2 -0
- package/src/export/__tests__/transcript-formatter.test.ts +121 -0
- package/src/export/transcript-formatter.ts +54 -20
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
- package/src/heartbeat/heartbeat-service.ts +34 -191
- package/src/home/__tests__/feed-types.test.ts +40 -0
- package/src/home/feed-types.ts +14 -2
- package/src/ipc/cli-client.ts +147 -45
- package/src/memory/__tests__/conversation-queries.test.ts +220 -0
- package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
- package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
- package/src/memory/conversation-queries.ts +87 -1
- package/src/memory/conversation-title-service.ts +26 -4
- package/src/memory/db-init.ts +6 -0
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
- package/src/memory/graph/conversation-graph-memory.ts +18 -6
- package/src/memory/graph/tools.ts +6 -37
- package/src/memory/invite-store.ts +53 -0
- package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
- package/src/memory/llm-request-log-store.ts +92 -1
- package/src/memory/memory-retrospective-enqueue.ts +1 -20
- package/src/memory/memory-retrospective-job.ts +33 -6
- package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
- package/src/memory/migrations/251-a2a-tasks.ts +49 -0
- package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/a2a.ts +15 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/inference.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
- package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
- package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
- package/src/memory/v2/__tests__/injection.test.ts +190 -3
- package/src/memory/v2/__tests__/static-context.test.ts +12 -1
- package/src/memory/v2/activation-store.ts +14 -16
- package/src/memory/v2/cli-command-content.ts +19 -0
- package/src/memory/v2/cli-command-store.ts +304 -0
- package/src/memory/v2/frontmatter-sweep.ts +7 -1
- package/src/memory/v2/injection.ts +49 -20
- package/src/memory/v2/page-index.ts +38 -13
- package/src/memory/v2/static-context.ts +4 -4
- package/src/memory/v2/types.ts +23 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
- package/src/messaging/providers/a2a/deliver.ts +156 -0
- package/src/messaging/providers/gmail/client.ts +9 -2
- package/src/messaging/providers/index.ts +11 -2
- package/src/notifications/__tests__/broadcaster.test.ts +203 -0
- package/src/notifications/__tests__/decision-engine.test.ts +283 -0
- package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
- package/src/notifications/adapters/macos.ts +12 -2
- package/src/notifications/broadcaster.ts +29 -4
- package/src/notifications/copy-composer.ts +17 -64
- package/src/notifications/decision-engine.ts +111 -44
- package/src/notifications/deterministic-checks.ts +96 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/home-feed-side-effect.ts +85 -6
- package/src/notifications/signal.ts +0 -4
- package/src/notifications/types.ts +8 -0
- package/src/oauth/platform-connection.test.ts +43 -3
- package/src/oauth/platform-connection.ts +13 -4
- package/src/plugins/defaults/injectors.ts +38 -19
- package/src/plugins/external-plugin-loader.ts +82 -10
- package/src/plugins/types.ts +16 -7
- package/src/prompts/__tests__/system-prompt.test.ts +6 -51
- package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
- package/src/prompts/system-prompt.ts +0 -8
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/system-sections.ts +0 -9
- package/src/providers/__tests__/inference.test.ts +2 -0
- package/src/providers/call-site-routing.ts +24 -6
- package/src/providers/connection-resolution.ts +63 -13
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
- package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
- package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
- package/src/providers/inference/adapter-factory.ts +9 -20
- package/src/providers/inference/auth.ts +12 -0
- package/src/providers/inference/backfill.ts +14 -1
- package/src/providers/inference/connections.ts +85 -5
- package/src/providers/inference/resolve-auth.ts +2 -0
- package/src/providers/model-catalog.ts +199 -244
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
- package/src/providers/openai/chat-completions-provider.ts +159 -6
- package/src/providers/openrouter/client.ts +42 -4
- package/src/providers/platform-proxy/constants.ts +3 -4
- package/src/providers/provider-catalog-visibility.ts +3 -1
- package/src/providers/provider-send-message.ts +27 -12
- package/src/providers/registry.ts +30 -1
- package/src/runtime/agent-wake.ts +61 -1
- package/src/runtime/auth/route-policy.ts +13 -0
- package/src/runtime/http-server.ts +7 -16
- package/src/runtime/http-types.ts +0 -47
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
- package/src/runtime/routes/channel-availability-routes.ts +5 -0
- package/src/runtime/routes/consolidation-routes.ts +100 -0
- package/src/runtime/routes/conversation-query-routes.ts +70 -11
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
- package/src/runtime/routes/integrations/a2a.ts +235 -0
- package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
- package/src/runtime/routes/subagents-routes.ts +41 -0
- package/src/subagent/manager.ts +2 -0
- package/src/tools/memory/register.ts +1 -9
- package/src/tools/registry.ts +2 -2
- package/src/tools/types.ts +37 -2
- package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
- package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
- package/src/runtime/guardian-action-conversation-turn.ts +0 -99
package/src/context/compactor.ts
CHANGED
|
@@ -439,6 +439,44 @@ function resolveTailStartIndex(
|
|
|
439
439
|
return null;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Walk a model-chosen tail index backward until it lands on a user message
|
|
444
|
+
* that does not contain client-side `tool_result` blocks. Prevents the
|
|
445
|
+
* orphan-`tool_result` failure where the matching assistant `tool_use` sits
|
|
446
|
+
* in the discarded prefix and Anthropic rejects the next call with
|
|
447
|
+
* `unexpected tool_use_id found in tool_result blocks`.
|
|
448
|
+
*
|
|
449
|
+
* Walking back (rather than forward) preserves the recent context the model
|
|
450
|
+
* deliberately chose to keep; the tail just expands by the few messages
|
|
451
|
+
* needed to re-anchor the orphaned `tool_result` against its `tool_use`.
|
|
452
|
+
*
|
|
453
|
+
* Returns 0 when the walk falls off the front — the caller treats this as
|
|
454
|
+
* "nothing to compact" via the existing `tailIndex === 0` branch.
|
|
455
|
+
*
|
|
456
|
+
* Only `type === "tool_result"` blocks count. Server-side tools
|
|
457
|
+
* (`server_tool_use` / `web_search_tool_result`) are self-paired inside an
|
|
458
|
+
* assistant message and never trigger an adjustment.
|
|
459
|
+
*/
|
|
460
|
+
export function adjustTailIndexForToolPairing(
|
|
461
|
+
messages: Message[],
|
|
462
|
+
tailIndex: number,
|
|
463
|
+
): number {
|
|
464
|
+
let k = tailIndex;
|
|
465
|
+
while (k > 0) {
|
|
466
|
+
const m = messages[k];
|
|
467
|
+
if (
|
|
468
|
+
m.role === "user" &&
|
|
469
|
+
// guard:allow-tool-result-only — server-side web_search_tool_result is
|
|
470
|
+
// self-paired inside its assistant message and never spans user turns.
|
|
471
|
+
!m.content.some((block) => block.type === "tool_result")
|
|
472
|
+
) {
|
|
473
|
+
return k;
|
|
474
|
+
}
|
|
475
|
+
k--;
|
|
476
|
+
}
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
442
480
|
// ---------------------------------------------------------------------------
|
|
443
481
|
// Retained-image hydration
|
|
444
482
|
// ---------------------------------------------------------------------------
|
|
@@ -655,8 +693,12 @@ export async function runAssistantDrivenCompaction(
|
|
|
655
693
|
}
|
|
656
694
|
|
|
657
695
|
const timestamps = buildTimestampIndex(args.messages);
|
|
658
|
-
const
|
|
659
|
-
|
|
696
|
+
const resolvedTailIndex = resolveTailStartIndex(
|
|
697
|
+
args.messages,
|
|
698
|
+
timestamps,
|
|
699
|
+
parsed,
|
|
700
|
+
);
|
|
701
|
+
if (resolvedTailIndex == null) {
|
|
660
702
|
log.warn(
|
|
661
703
|
{
|
|
662
704
|
timestamp: parsed.tailStartTimestamp,
|
|
@@ -680,6 +722,22 @@ export async function runAssistantDrivenCompaction(
|
|
|
680
722
|
};
|
|
681
723
|
}
|
|
682
724
|
|
|
725
|
+
const tailIndex = adjustTailIndexForToolPairing(
|
|
726
|
+
args.messages,
|
|
727
|
+
resolvedTailIndex,
|
|
728
|
+
);
|
|
729
|
+
if (tailIndex !== resolvedTailIndex) {
|
|
730
|
+
log.info(
|
|
731
|
+
{
|
|
732
|
+
conversationId: args.conversationId,
|
|
733
|
+
originalTailIndex: resolvedTailIndex,
|
|
734
|
+
tailIndex,
|
|
735
|
+
walkedBy: resolvedTailIndex - tailIndex,
|
|
736
|
+
},
|
|
737
|
+
"Adjusted compaction tail backward to preserve tool_use/tool_result pairing",
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
683
741
|
if (tailIndex === 0) {
|
|
684
742
|
return {
|
|
685
743
|
...emptyResult(
|
|
@@ -762,6 +820,9 @@ export async function runAssistantDrivenCompaction(
|
|
|
762
820
|
compactedMessages: compactableMessages.length,
|
|
763
821
|
compactedPersistedMessages,
|
|
764
822
|
tailIndex,
|
|
823
|
+
...(tailIndex !== resolvedTailIndex
|
|
824
|
+
? { originalTailIndex: resolvedTailIndex }
|
|
825
|
+
: {}),
|
|
765
826
|
retainedImages: resolved.length,
|
|
766
827
|
summaryChars: summaryText.length,
|
|
767
828
|
},
|
|
@@ -885,10 +946,12 @@ export async function runEmergencyCompaction(
|
|
|
885
946
|
|
|
886
947
|
const splitIndex = findLastToolPairStart(args.messages);
|
|
887
948
|
if (splitIndex == null || splitIndex === 0) {
|
|
888
|
-
log.info(
|
|
889
|
-
|
|
949
|
+
log.info("Emergency compaction: no tool pair found — falling through");
|
|
950
|
+
return emptyResult(
|
|
951
|
+
args,
|
|
952
|
+
thresholdTokens,
|
|
953
|
+
"no tool pair for emergency split",
|
|
890
954
|
);
|
|
891
|
-
return emptyResult(args, thresholdTokens, "no tool pair for emergency split");
|
|
892
955
|
}
|
|
893
956
|
|
|
894
957
|
const keptTail = stripInjectionsForCompaction(
|
|
@@ -904,8 +967,7 @@ export async function runEmergencyCompaction(
|
|
|
904
967
|
const prefixBudget = args.maxInputTokens - instructionBudget - outputBudget;
|
|
905
968
|
|
|
906
969
|
let prefixEstimate = estimatePromptTokens(prefix, args.systemPrompt, {
|
|
907
|
-
providerName:
|
|
908
|
-
args.provider.tokenEstimationProvider ?? args.provider.name,
|
|
970
|
+
providerName: args.provider.tokenEstimationProvider ?? args.provider.name,
|
|
909
971
|
});
|
|
910
972
|
|
|
911
973
|
if (prefixEstimate > prefixBudget && prefix.length > 1) {
|
|
@@ -920,10 +982,7 @@ export async function runEmergencyCompaction(
|
|
|
920
982
|
// Drop messages from the front until we fit. Keep at least the first
|
|
921
983
|
// message (may be an existing summary) and try to preserve recent context.
|
|
922
984
|
let dropCount = 0;
|
|
923
|
-
while (
|
|
924
|
-
prefixEstimate > prefixBudget &&
|
|
925
|
-
dropCount < prefix.length - 1
|
|
926
|
-
) {
|
|
985
|
+
while (prefixEstimate > prefixBudget && dropCount < prefix.length - 1) {
|
|
927
986
|
dropCount++;
|
|
928
987
|
const truncated = prefix.slice(dropCount);
|
|
929
988
|
prefixEstimate = estimatePromptTokens(truncated, args.systemPrompt, {
|
|
@@ -1015,7 +1074,8 @@ export async function runEmergencyCompaction(
|
|
|
1015
1074
|
compactedMessages: compactedCount,
|
|
1016
1075
|
keptTailMessages: keptTail.length,
|
|
1017
1076
|
summaryChars: summaryText.length,
|
|
1018
|
-
prefixTruncated:
|
|
1077
|
+
prefixTruncated:
|
|
1078
|
+
prefix[0]?.content?.[0]?.type === "text" &&
|
|
1019
1079
|
(prefix[0].content[0] as { text: string }).text.includes("truncated"),
|
|
1020
1080
|
},
|
|
1021
1081
|
"Applied emergency mid-turn compaction",
|
|
@@ -33,18 +33,24 @@ const OTHER_BLOCK_TOKENS = 16;
|
|
|
33
33
|
const SYSTEM_PROMPT_OVERHEAD_TOKENS = 8;
|
|
34
34
|
const GEMINI_INLINE_FILE_MIME_TYPES = new Set(["application/pdf"]);
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// are resized. 1,200,000 / 750 = 1,600 tokens, matching the documented threshold.
|
|
42
|
-
// Reference table (max sizes that won't be resized):
|
|
36
|
+
// Dimension-based image token estimate, used as a universal default for every
|
|
37
|
+
// provider. The formula and constants below come from Anthropic's published
|
|
38
|
+
// vision spec — scale to a 1568x1568 bounding box, then charge
|
|
39
|
+
// ~(width * height) / 750 tokens, with a ~1.2-megapixel cap that lands at
|
|
40
|
+
// ~1,600 tokens per image. Reference table (max sizes that won't be resized):
|
|
43
41
|
// 1:1 → 1092x1092 (~1,590 tokens) 1:2 → 784x1568 (~1,639 tokens)
|
|
44
42
|
// See: https://platform.claude.com/docs/en/build-with-claude/vision#evaluate-image-size
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
//
|
|
44
|
+
// Other multimodal providers (OpenAI/GPT-4V tile pricing, Moonshot/Kimi,
|
|
45
|
+
// Gemini fixed-cost, OpenRouter pass-through) price differently in detail,
|
|
46
|
+
// but every published rate lands in the same hundreds-to-low-thousands range
|
|
47
|
+
// per image. Using this formula as the default gets compaction within ~2-3x
|
|
48
|
+
// of reality instead of the ~30-100x over-counting produced by treating the
|
|
49
|
+
// raw base64 payload as if it were text.
|
|
50
|
+
const IMAGE_MAX_DIMENSION = 1568;
|
|
51
|
+
const IMAGE_MAX_PIXELS = 1_200_000;
|
|
52
|
+
const IMAGE_TOKENS_PER_PIXEL = 1 / 750;
|
|
53
|
+
const IMAGE_MAX_TOKENS = 1_600;
|
|
48
54
|
|
|
49
55
|
// Anthropic renders each PDF page as an image (~1,568 tokens at standard
|
|
50
56
|
// resolution) plus any extracted text. Typical PDF pages are 50-150 KB.
|
|
@@ -103,45 +109,37 @@ function estimateFileDataTokens(
|
|
|
103
109
|
return 0;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
function
|
|
112
|
+
function estimateImageTokensByDimensions(
|
|
113
|
+
width: number,
|
|
114
|
+
height: number,
|
|
115
|
+
): number {
|
|
107
116
|
// Step 1: Scale to fit within 1568px bounding box
|
|
108
|
-
const dimScale = Math.min(
|
|
109
|
-
1,
|
|
110
|
-
ANTHROPIC_IMAGE_MAX_DIMENSION / Math.max(width, height),
|
|
111
|
-
);
|
|
117
|
+
const dimScale = Math.min(1, IMAGE_MAX_DIMENSION / Math.max(width, height));
|
|
112
118
|
let scaledWidth = Math.round(width * dimScale);
|
|
113
119
|
let scaledHeight = Math.round(height * dimScale);
|
|
114
120
|
|
|
115
121
|
// Step 2: Scale further if exceeds megapixel budget
|
|
116
122
|
const pixels = scaledWidth * scaledHeight;
|
|
117
|
-
if (pixels >
|
|
118
|
-
const mpScale = Math.sqrt(
|
|
123
|
+
if (pixels > IMAGE_MAX_PIXELS) {
|
|
124
|
+
const mpScale = Math.sqrt(IMAGE_MAX_PIXELS / pixels);
|
|
119
125
|
scaledWidth = Math.round(scaledWidth * mpScale);
|
|
120
126
|
scaledHeight = Math.round(scaledHeight * mpScale);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
return Math.ceil(
|
|
124
|
-
scaledWidth * scaledHeight * ANTHROPIC_IMAGE_TOKENS_PER_PIXEL,
|
|
125
|
-
);
|
|
129
|
+
return Math.ceil(scaledWidth * scaledHeight * IMAGE_TOKENS_PER_PIXEL);
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
function estimateImageTokens(
|
|
129
133
|
block: Extract<ContentBlock, { type: "image" }>,
|
|
130
|
-
options?: TokenEstimatorOptions,
|
|
131
134
|
): number {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
block.source.media_type,
|
|
136
|
-
);
|
|
137
|
-
if (dims) {
|
|
138
|
-
return estimateAnthropicImageTokens(dims.width, dims.height);
|
|
139
|
-
}
|
|
140
|
-
// Fallback: if dimensions can't be parsed, use Anthropic's max
|
|
141
|
-
return ANTHROPIC_IMAGE_MAX_TOKENS;
|
|
135
|
+
const dims = parseImageDimensions(block.source.data, block.source.media_type);
|
|
136
|
+
if (dims) {
|
|
137
|
+
return estimateImageTokensByDimensions(dims.width, dims.height);
|
|
142
138
|
}
|
|
143
|
-
//
|
|
144
|
-
|
|
139
|
+
// Dimensions unparseable (corrupt header, exotic format): use the per-image
|
|
140
|
+
// cap rather than the raw base64 length, which over-counts by 30-100x for
|
|
141
|
+
// non-Anthropic providers and trips spurious compaction.
|
|
142
|
+
return IMAGE_MAX_TOKENS;
|
|
145
143
|
}
|
|
146
144
|
|
|
147
145
|
export function estimateContentBlockTokens(
|
|
@@ -188,7 +186,7 @@ export function estimateContentBlockTokens(
|
|
|
188
186
|
return (
|
|
189
187
|
IMAGE_BLOCK_OVERHEAD_TOKENS +
|
|
190
188
|
estimateTextTokens(block.source.media_type) +
|
|
191
|
-
estimateImageTokens(block
|
|
189
|
+
estimateImageTokens(block)
|
|
192
190
|
);
|
|
193
191
|
case "file":
|
|
194
192
|
return (
|
|
@@ -97,7 +97,6 @@ mock.module("../../memory/auto-analysis-enqueue.js", () => ({
|
|
|
97
97
|
},
|
|
98
98
|
}));
|
|
99
99
|
|
|
100
|
-
let memoryRetroEnabled = false;
|
|
101
100
|
const memoryRetroCalls: Array<{
|
|
102
101
|
conversationId: string;
|
|
103
102
|
trigger: string;
|
|
@@ -108,7 +107,6 @@ mock.module("../../memory/memory-retrospective-enqueue.js", () => ({
|
|
|
108
107
|
conversationId: string;
|
|
109
108
|
trigger: string;
|
|
110
109
|
}) => {
|
|
111
|
-
if (!memoryRetroEnabled) return;
|
|
112
110
|
memoryRetroCalls.push(args);
|
|
113
111
|
},
|
|
114
112
|
// Also export sibling functions other modules import from this file, so
|
|
@@ -205,7 +203,6 @@ describe("disposeConversation — auto-analysis enqueue", () => {
|
|
|
205
203
|
autoAnalyzeCalls.length = 0;
|
|
206
204
|
memoryRetroCalls.length = 0;
|
|
207
205
|
autoAnalyzeEnabled = true;
|
|
208
|
-
memoryRetroEnabled = false;
|
|
209
206
|
autoAnalysisConversations.clear();
|
|
210
207
|
v2Enabled = false;
|
|
211
208
|
});
|
|
@@ -390,13 +387,11 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
|
|
|
390
387
|
autoAnalyzeCalls.length = 0;
|
|
391
388
|
memoryRetroCalls.length = 0;
|
|
392
389
|
autoAnalyzeEnabled = false;
|
|
393
|
-
memoryRetroEnabled = false;
|
|
394
390
|
autoAnalysisConversations.clear();
|
|
395
391
|
v2Enabled = false;
|
|
396
392
|
});
|
|
397
393
|
|
|
398
|
-
test("guardian conversation
|
|
399
|
-
memoryRetroEnabled = true;
|
|
394
|
+
test("guardian conversation — enqueues memory-retrospective with trigger 'lifecycle'", () => {
|
|
400
395
|
const ctx = makeDisposeContext({
|
|
401
396
|
conversationId: "conv-retro",
|
|
402
397
|
trustClass: "guardian",
|
|
@@ -411,20 +406,7 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
|
|
|
411
406
|
});
|
|
412
407
|
});
|
|
413
408
|
|
|
414
|
-
test("
|
|
415
|
-
memoryRetroEnabled = false;
|
|
416
|
-
const ctx = makeDisposeContext({
|
|
417
|
-
conversationId: "conv-retro-off",
|
|
418
|
-
trustClass: "guardian",
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
disposeConversation(ctx);
|
|
422
|
-
|
|
423
|
-
expect(memoryRetroCalls).toHaveLength(0);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
test("untrusted actor — no memory-retrospective enqueue even when flag is on", () => {
|
|
427
|
-
memoryRetroEnabled = true;
|
|
409
|
+
test("untrusted actor — no memory-retrospective enqueue", () => {
|
|
428
410
|
const ctx = makeDisposeContext({
|
|
429
411
|
conversationId: "conv-retro-untrusted",
|
|
430
412
|
trustClass: "unknown",
|
|
@@ -443,8 +425,7 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
|
|
|
443
425
|
// outside the `!isAutoAnalysis` guard, so it fired even for auto-analysis
|
|
444
426
|
// conversations. Mirrors the indexer-time gate in `indexer.ts` and
|
|
445
427
|
// matches the existing graph_extract recursion-guard semantics.
|
|
446
|
-
test("auto-analysis conversation — does NOT enqueue memory-retrospective
|
|
447
|
-
memoryRetroEnabled = true;
|
|
428
|
+
test("auto-analysis conversation — does NOT enqueue memory-retrospective", () => {
|
|
448
429
|
autoAnalysisConversations.add("conv-auto-retro");
|
|
449
430
|
const ctx = makeDisposeContext({
|
|
450
431
|
conversationId: "conv-auto-retro",
|
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
} from "../memory/conversation-crud.js";
|
|
24
24
|
import {
|
|
25
25
|
backfillMessageIdOnLogs,
|
|
26
|
+
buildProviderErrorResponsePayload,
|
|
26
27
|
recordRequestLog,
|
|
28
|
+
setAgentLoopExitReasonOnLatestLog,
|
|
27
29
|
} from "../memory/llm-request-log-store.js";
|
|
28
30
|
import { backfillMemoryRecallLogMessageId } from "../memory/memory-recall-log-store.js";
|
|
29
31
|
import { backfillMemoryV2ActivationMessageId } from "../memory/memory-v2-activation-log-store.js";
|
|
@@ -1166,6 +1168,52 @@ function handleUsage(
|
|
|
1166
1168
|
state.llmCallStartedEmitted = false;
|
|
1167
1169
|
}
|
|
1168
1170
|
|
|
1171
|
+
/**
|
|
1172
|
+
* Persist a provider-rejected LLM call as an `llm_request_logs` row.
|
|
1173
|
+
*
|
|
1174
|
+
* Mirrors `handleUsage`'s recording side-effect for the failure path: the
|
|
1175
|
+
* loop only reaches the success branch (and emits `usage`) when the
|
|
1176
|
+
* provider returns a response, so without this handler a rejected call
|
|
1177
|
+
* leaves nothing in the inspector — only a pino line saying "The AI
|
|
1178
|
+
* provider rejected the request." The row's `messageId` is left null
|
|
1179
|
+
* here and linked via one of two backfill paths, depending on how the
|
|
1180
|
+
* turn unwinds:
|
|
1181
|
+
*
|
|
1182
|
+
* - Multi-call turn where a later call also produces a real assistant
|
|
1183
|
+
* response: `handleMessageComplete` -> `backfillMessageIdOnLogs`
|
|
1184
|
+
* sweeps this row with the rest, same as a successful-call row.
|
|
1185
|
+
* - Pure provider-failure turn (no real assistant response): the
|
|
1186
|
+
* synthetic error-message branch in `conversation-agent-loop.ts`
|
|
1187
|
+
* persists a stand-in assistant message and calls
|
|
1188
|
+
* `backfillMessageIdOnLogs` itself, since `message_complete` is
|
|
1189
|
+
* never emitted on that path. Closing the orphan window inside the
|
|
1190
|
+
* same synchronous turn prevents a later turn's sweep from wrong-
|
|
1191
|
+
* attaching this row to an unrelated assistant message.
|
|
1192
|
+
*
|
|
1193
|
+
* Failures inside the recording itself are logged and swallowed — this
|
|
1194
|
+
* mirrors `handleUsage`'s non-fatal stance so a DB hiccup never escalates
|
|
1195
|
+
* a provider rejection into a dispatcher-level throw.
|
|
1196
|
+
*/
|
|
1197
|
+
function handleProviderError(
|
|
1198
|
+
deps: EventHandlerDeps,
|
|
1199
|
+
event: Extract<AgentEvent, { type: "provider_error" }>,
|
|
1200
|
+
): void {
|
|
1201
|
+
try {
|
|
1202
|
+
recordRequestLog(
|
|
1203
|
+
deps.ctx.conversationId,
|
|
1204
|
+
JSON.stringify(event.rawRequest),
|
|
1205
|
+
JSON.stringify(buildProviderErrorResponsePayload(event.error)),
|
|
1206
|
+
undefined,
|
|
1207
|
+
event.actualProvider,
|
|
1208
|
+
);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
deps.rlog.warn(
|
|
1211
|
+
{ err },
|
|
1212
|
+
"Failed to persist provider-error LLM request log (non-fatal)",
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1169
1217
|
// ── Dispatcher ───────────────────────────────────────────────────────
|
|
1170
1218
|
|
|
1171
1219
|
/** Routes an AgentEvent to the appropriate handler. */
|
|
@@ -1254,12 +1302,42 @@ export async function dispatchAgentEvent(
|
|
|
1254
1302
|
case "error":
|
|
1255
1303
|
handleError(state, deps, event);
|
|
1256
1304
|
break;
|
|
1305
|
+
case "provider_error":
|
|
1306
|
+
handleProviderError(deps, event);
|
|
1307
|
+
break;
|
|
1257
1308
|
case "message_complete":
|
|
1258
1309
|
await handleMessageComplete(state, deps, event);
|
|
1259
1310
|
break;
|
|
1260
1311
|
case "usage":
|
|
1261
1312
|
handleUsage(state, deps, event);
|
|
1262
1313
|
break;
|
|
1314
|
+
case "agent_loop_exit":
|
|
1315
|
+
// Stamp the exit reason onto the most-recent llm_request_logs
|
|
1316
|
+
// row for this conversation. The final `usage` event of the run
|
|
1317
|
+
// lands its row immediately before this event arrives (in the
|
|
1318
|
+
// normal-dispatch path; the wake path handles ordering
|
|
1319
|
+
// explicitly via `pendingExitReason`).
|
|
1320
|
+
//
|
|
1321
|
+
// Wrapped in try/catch so a DB hiccup here can't tear down the
|
|
1322
|
+
// surrounding dispatch — the outer try/catch already swallows
|
|
1323
|
+
// errors, but logging here gives the diagnosis hook a clear
|
|
1324
|
+
// attribution to the exit handler specifically.
|
|
1325
|
+
try {
|
|
1326
|
+
setAgentLoopExitReasonOnLatestLog(
|
|
1327
|
+
deps.ctx.conversationId,
|
|
1328
|
+
event.reason,
|
|
1329
|
+
);
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
log.warn(
|
|
1332
|
+
{
|
|
1333
|
+
err,
|
|
1334
|
+
conversationId: deps.ctx.conversationId,
|
|
1335
|
+
reason: event.reason,
|
|
1336
|
+
},
|
|
1337
|
+
"Failed to persist agent_loop_exit_reason (non-fatal)",
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
break;
|
|
1263
1341
|
}
|
|
1264
1342
|
} catch (err) {
|
|
1265
1343
|
log.error(
|
|
@@ -71,7 +71,9 @@ import {
|
|
|
71
71
|
isReplaceableTitle,
|
|
72
72
|
queueRegenerateConversationTitle,
|
|
73
73
|
} from "../memory/conversation-title-service.js";
|
|
74
|
+
import { isBackgroundConversationType } from "../memory/conversation-types.js";
|
|
74
75
|
import type { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
|
|
76
|
+
import { backfillMessageIdOnLogs } from "../memory/llm-request-log-store.js";
|
|
75
77
|
import { recordMemoryRecallLog } from "../memory/memory-recall-log-store.js";
|
|
76
78
|
import { enqueueMemoryRetrospectiveOnCompaction } from "../memory/memory-retrospective-enqueue.js";
|
|
77
79
|
import { PKB_WORKSPACE_SCOPE } from "../memory/pkb/types.js";
|
|
@@ -109,6 +111,7 @@ import type {
|
|
|
109
111
|
MemoryResult,
|
|
110
112
|
OverflowReduceArgs,
|
|
111
113
|
OverflowReduceResult,
|
|
114
|
+
PersistAddResult,
|
|
112
115
|
PersistArgs,
|
|
113
116
|
PersistResult,
|
|
114
117
|
TurnContext as PluginTurnContext,
|
|
@@ -1616,6 +1619,9 @@ export async function runAgentLoopImpl(
|
|
|
1616
1619
|
transportHints: ctx.transportHints ?? null,
|
|
1617
1620
|
slackRuntimeContextNotice: ctx.slackRuntimeContextNotice ?? null,
|
|
1618
1621
|
isNonInteractive: !isInteractiveResolved,
|
|
1622
|
+
isBackgroundConversation: isBackgroundConversationType(
|
|
1623
|
+
turnStartConversation?.conversationType,
|
|
1624
|
+
),
|
|
1619
1625
|
subagentStatusBlock,
|
|
1620
1626
|
slackChronologicalMessages,
|
|
1621
1627
|
slackActiveThreadFocusBlock,
|
|
@@ -2942,7 +2948,7 @@ export async function runAgentLoopImpl(
|
|
|
2942
2948
|
const errorAssistantMessage = createAssistantMessage(
|
|
2943
2949
|
state.providerErrorUserMessage,
|
|
2944
2950
|
);
|
|
2945
|
-
await runPipeline<PersistArgs, PersistResult>(
|
|
2951
|
+
const errorPersistResult = (await runPipeline<PersistArgs, PersistResult>(
|
|
2946
2952
|
"persistence",
|
|
2947
2953
|
getMiddlewaresFor("persistence"),
|
|
2948
2954
|
defaultPersistenceTerminal,
|
|
@@ -2955,9 +2961,30 @@ export async function runAgentLoopImpl(
|
|
|
2955
2961
|
},
|
|
2956
2962
|
buildPluginTurnContext(ctx, reqId),
|
|
2957
2963
|
DEFAULT_TIMEOUTS.persistence,
|
|
2958
|
-
);
|
|
2964
|
+
)) as PersistAddResult;
|
|
2959
2965
|
persistedErrorAssistantMessage = true;
|
|
2960
2966
|
newMessages.push(errorAssistantMessage);
|
|
2967
|
+
// Pipe the just-assigned message id into any orphaned LLM request log
|
|
2968
|
+
// row(s) for this turn. The success path links rows via
|
|
2969
|
+
// `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
|
|
2970
|
+
// failure turns never fire `message_complete` (the synthetic assistant
|
|
2971
|
+
// message is persisted directly above), so without this call the rows
|
|
2972
|
+
// from `handleProviderError` stay with `message_id IS NULL` and a
|
|
2973
|
+
// later turn's backfill sweep would wrong-attach them to that turn's
|
|
2974
|
+
// assistant message. Scope is per-conversation, so concurrent runs on
|
|
2975
|
+
// other conversations cannot collide. Non-fatal — a DB hiccup must
|
|
2976
|
+
// not escalate a provider rejection into a turn-level throw.
|
|
2977
|
+
try {
|
|
2978
|
+
backfillMessageIdOnLogs(
|
|
2979
|
+
ctx.conversationId,
|
|
2980
|
+
errorPersistResult.message.id,
|
|
2981
|
+
);
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
rlog.warn(
|
|
2984
|
+
{ err },
|
|
2985
|
+
"Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2961
2988
|
// Do NOT send assistant_text_delta here — handleProviderError already
|
|
2962
2989
|
// emitted a conversation_error event for this same error text, and the
|
|
2963
2990
|
// client renders it as an InlineChatErrorAlert. Sending a text delta
|
|
@@ -1652,6 +1652,7 @@ const RUNTIME_INJECTION_PREFIXES = [
|
|
|
1652
1652
|
"<interface_turn_context>", // backward-compat: strip legacy separate interface blocks
|
|
1653
1653
|
// NOTE: <turn_context> is intentionally NOT stripped — unified turn context
|
|
1654
1654
|
// blocks persist in history so the assistant retains temporal/actor grounding.
|
|
1655
|
+
"<background_turn>",
|
|
1655
1656
|
"<memory_context __injected>",
|
|
1656
1657
|
"<memory_context>", // backward-compat: strip legacy blocks from pre-__injected history
|
|
1657
1658
|
// The static `memory-v2-static` block (opens `<memory>\n…`) IS stripped
|
|
@@ -1966,6 +1967,13 @@ export interface RuntimeInjectionOptions {
|
|
|
1966
1967
|
nowScratchpad?: string | null;
|
|
1967
1968
|
subagentStatusBlock?: string | null;
|
|
1968
1969
|
isNonInteractive?: boolean;
|
|
1970
|
+
/**
|
|
1971
|
+
* True when the active conversation's type is "background" or "scheduled".
|
|
1972
|
+
* Forwarded to {@link TurnInjectionInputs.isBackgroundConversation} so the
|
|
1973
|
+
* `background-turn` injector can wrap the tail user message with the
|
|
1974
|
+
* configured reminder.
|
|
1975
|
+
*/
|
|
1976
|
+
isBackgroundConversation?: boolean;
|
|
1969
1977
|
transportHints?: string[] | null;
|
|
1970
1978
|
slackRuntimeContextNotice?: string | null;
|
|
1971
1979
|
/**
|
|
@@ -2056,6 +2064,7 @@ function buildTurnInjectionInputs(
|
|
|
2056
2064
|
voiceCallControlPrompt: options.voiceCallControlPrompt,
|
|
2057
2065
|
transportHints: options.transportHints,
|
|
2058
2066
|
isNonInteractive: options.isNonInteractive,
|
|
2067
|
+
isBackgroundConversation: options.isBackgroundConversation,
|
|
2059
2068
|
activeDocuments: options.activeDocuments,
|
|
2060
2069
|
};
|
|
2061
2070
|
}
|
|
@@ -54,7 +54,6 @@ import {
|
|
|
54
54
|
getConversationOriginChannel,
|
|
55
55
|
getConversationOverrideProfileFromRow,
|
|
56
56
|
} from "../memory/conversation-crud.js";
|
|
57
|
-
import { isBackgroundConversationType } from "../memory/conversation-types.js";
|
|
58
57
|
import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
|
|
59
58
|
import { shouldExposePersonalMemory } from "../memory/v2/static-context.js";
|
|
60
59
|
import { PermissionPrompter } from "../permissions/prompter.js";
|
|
@@ -489,9 +488,6 @@ export class Conversation {
|
|
|
489
488
|
channelPersona: persona.channelPersona,
|
|
490
489
|
userSlug: persona.userSlug,
|
|
491
490
|
onboardingContext: this.getOnboardingContext(),
|
|
492
|
-
isBackgroundConversation: isBackgroundConversationType(
|
|
493
|
-
getConversation(this.conversationId)?.conversationType,
|
|
494
|
-
),
|
|
495
491
|
});
|
|
496
492
|
})(),
|
|
497
493
|
};
|
|
@@ -583,9 +579,6 @@ export class Conversation {
|
|
|
583
579
|
channelPersona: persona.channelPersona,
|
|
584
580
|
userSlug: persona.userSlug,
|
|
585
581
|
onboardingContext: this.getOnboardingContext(),
|
|
586
|
-
isBackgroundConversation: isBackgroundConversationType(
|
|
587
|
-
getConversation(this.conversationId)?.conversationType,
|
|
588
|
-
),
|
|
589
582
|
});
|
|
590
583
|
})();
|
|
591
584
|
const tools = buildToolDefinitions();
|
|
@@ -326,6 +326,46 @@ function formatLocalDate(date: Date, timeZone: string): string {
|
|
|
326
326
|
).padStart(2, "0")}`;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
const localTimestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
330
|
+
|
|
331
|
+
function getLocalTimestampFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
332
|
+
let fmt = localTimestampFormatterCache.get(timeZone);
|
|
333
|
+
if (!fmt) {
|
|
334
|
+
fmt = new Intl.DateTimeFormat("en-CA", {
|
|
335
|
+
timeZone,
|
|
336
|
+
year: "numeric",
|
|
337
|
+
month: "2-digit",
|
|
338
|
+
day: "2-digit",
|
|
339
|
+
hour: "2-digit",
|
|
340
|
+
minute: "2-digit",
|
|
341
|
+
second: "2-digit",
|
|
342
|
+
hourCycle: "h23",
|
|
343
|
+
});
|
|
344
|
+
localTimestampFormatterCache.set(timeZone, fmt);
|
|
345
|
+
}
|
|
346
|
+
return fmt;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Format an epoch-millis instant as `YYYY-MM-DD HH:MM:SS` in the given
|
|
351
|
+
* IANA timezone. When `timeZone` is omitted or `"UTC"`, falls back to a
|
|
352
|
+
* pure-UTC `toISOString` slice so callers can opt in incrementally.
|
|
353
|
+
*
|
|
354
|
+
* The internal `Intl.DateTimeFormat` is memoized by `timeZone` because
|
|
355
|
+
* constructing it is ~1ms in V8 — material when rendering long transcripts.
|
|
356
|
+
*/
|
|
357
|
+
export function formatLocalTimestamp(ms: number, timeZone?: string): string {
|
|
358
|
+
if (!timeZone || timeZone === "UTC") {
|
|
359
|
+
return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
|
|
360
|
+
}
|
|
361
|
+
const parts = getLocalTimestampFormatter(timeZone).formatToParts(
|
|
362
|
+
new Date(ms),
|
|
363
|
+
);
|
|
364
|
+
const v: Record<string, string> = {};
|
|
365
|
+
for (const p of parts) v[p.type] = p.value;
|
|
366
|
+
return `${v.year}-${v.month}-${v.day} ${v.hour}:${v.minute}:${v.second}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
329
369
|
export function resolveTurnTimezoneContext(
|
|
330
370
|
options: TemporalContextOptions = {},
|
|
331
371
|
): TurnTimezoneContext {
|