@pellux/goodvibes-agent 1.1.7 → 1.2.0

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 (255) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +46 -30
  3. package/dist/package/{ast-grep-napi.linux-x64-gnu-mkk8xwww.node → ast-grep-napi.linux-x64-gnu-swtppvy9.node} +0 -0
  4. package/dist/package/{ast-grep-napi.linux-x64-musl-ryqtgdv6.node → ast-grep-napi.linux-x64-musl-ttfcdtap.node} +0 -0
  5. package/dist/package/main.js +132577 -91483
  6. package/docs/README.md +14 -7
  7. package/docs/channels-remote-and-api.md +8 -5
  8. package/docs/connected-host.md +14 -12
  9. package/docs/getting-started.md +56 -30
  10. package/docs/knowledge-artifacts-and-multimodal.md +7 -5
  11. package/docs/project-planning.md +2 -2
  12. package/docs/providers-and-routing.md +11 -3
  13. package/docs/tools-and-commands.md +114 -45
  14. package/docs/voice-and-live-tts.md +3 -1
  15. package/package.json +3 -2
  16. package/release/release-notes.md +130 -5
  17. package/release/release-readiness.json +120 -90
  18. package/src/agent/assistant-cockpit.ts +247 -0
  19. package/src/agent/autonomy-schedule-format.ts +96 -0
  20. package/src/agent/autonomy-schedule.ts +514 -0
  21. package/src/agent/channel-delivery-receipts.ts +292 -0
  22. package/src/agent/competitive-feature-inventory.ts +296 -0
  23. package/src/agent/document-registry-types.ts +106 -0
  24. package/src/agent/document-registry.ts +734 -0
  25. package/src/agent/harness-control.ts +2 -2
  26. package/src/agent/memory-prompt.ts +2 -2
  27. package/src/agent/operator-actions.ts +50 -6
  28. package/src/agent/persona-registry.ts +8 -0
  29. package/src/agent/project-context-files.ts +273 -0
  30. package/src/agent/prompt-context-receipts.ts +502 -0
  31. package/src/agent/reminder-schedule-format.ts +16 -2
  32. package/src/agent/research-run-registry.ts +582 -0
  33. package/src/agent/research-source-registry.ts +441 -0
  34. package/src/agent/routine-registry.ts +19 -3
  35. package/src/agent/routine-schedule-format.ts +27 -2
  36. package/src/agent/runtime-profile-starters.ts +7 -0
  37. package/src/agent/runtime-profile.ts +106 -4
  38. package/src/agent/schedule-edit-format.ts +129 -0
  39. package/src/agent/schedule-edit.ts +496 -0
  40. package/src/agent/schedule-next-routes.ts +42 -0
  41. package/src/agent/setup-wizard-checkpoint.ts +140 -0
  42. package/src/agent/setup-wizard.ts +447 -0
  43. package/src/agent/skill-registry-types.ts +102 -0
  44. package/src/agent/skill-registry.ts +81 -107
  45. package/src/agent/vibe-confirmation-routes.ts +62 -0
  46. package/src/agent/vibe-file.ts +285 -0
  47. package/src/cli/agent-knowledge-command.ts +45 -2
  48. package/src/cli/help.ts +3 -0
  49. package/src/cli/profiles-command.ts +25 -11
  50. package/src/config/agent-settings-policy.ts +1 -18
  51. package/src/input/agent-workspace-activation.ts +53 -0
  52. package/src/input/agent-workspace-artifact-browser-editor.ts +494 -0
  53. package/src/input/agent-workspace-artifact-metadata.ts +17 -0
  54. package/src/input/agent-workspace-basic-command-editors.ts +5 -1
  55. package/src/input/agent-workspace-categories.ts +153 -155
  56. package/src/input/agent-workspace-channel-triage.ts +481 -0
  57. package/src/input/agent-workspace-channels.ts +263 -0
  58. package/src/input/agent-workspace-command-editor.ts +152 -0
  59. package/src/input/agent-workspace-context-snapshot.ts +349 -0
  60. package/src/input/agent-workspace-delegation-editor-submission.ts +8 -0
  61. package/src/input/agent-workspace-document-editor.ts +502 -0
  62. package/src/input/agent-workspace-document-ops-editor.ts +523 -0
  63. package/src/input/agent-workspace-host-category.ts +1 -1
  64. package/src/input/agent-workspace-local-library-snapshot.ts +167 -0
  65. package/src/input/agent-workspace-model-compare-editor.ts +376 -0
  66. package/src/input/agent-workspace-model-compare-prompt-submission.ts +552 -0
  67. package/src/input/agent-workspace-model-compare-types.ts +82 -0
  68. package/src/input/agent-workspace-model-compare-utils.ts +35 -0
  69. package/src/input/agent-workspace-onboarding-categories.ts +141 -0
  70. package/src/input/agent-workspace-operations-command-editor-submission.ts +53 -0
  71. package/src/input/agent-workspace-operations-command-editors.ts +22 -0
  72. package/src/input/agent-workspace-research-report-editor.ts +212 -0
  73. package/src/input/agent-workspace-research-run-editor.ts +144 -0
  74. package/src/input/agent-workspace-research-source-editor.ts +167 -0
  75. package/src/input/agent-workspace-review-packet-snapshot.ts +593 -0
  76. package/src/input/agent-workspace-review-packet-utils.ts +265 -0
  77. package/src/input/agent-workspace-settings.ts +262 -30
  78. package/src/input/agent-workspace-setup-checkpoint-action.ts +127 -0
  79. package/src/input/agent-workspace-setup-snapshot.ts +198 -0
  80. package/src/input/agent-workspace-setup.ts +34 -1
  81. package/src/input/agent-workspace-snapshot.ts +162 -147
  82. package/src/input/agent-workspace-types.ts +287 -2
  83. package/src/input/agent-workspace.ts +18 -0
  84. package/src/input/command-registry.ts +9 -2
  85. package/src/input/commands/agent-runtime-profile-runtime.ts +28 -13
  86. package/src/input/commands/channels-runtime.ts +102 -114
  87. package/src/input/commands/delegation-runtime.ts +87 -24
  88. package/src/input/commands/knowledge-browser-flags.ts +12 -0
  89. package/src/input/commands/knowledge.ts +28 -14
  90. package/src/input/commands/operator-actions-runtime.ts +6 -3
  91. package/src/input/commands/schedule-runtime.ts +71 -14
  92. package/src/input/commands/session-content.ts +2 -2
  93. package/src/input/commands/shell-core.ts +2 -0
  94. package/src/input/commands/vibe-runtime.ts +140 -0
  95. package/src/input/commands.ts +2 -0
  96. package/src/input/connected-host-routes.ts +123 -0
  97. package/src/input/settings-modal-types.ts +14 -0
  98. package/src/main.ts +15 -15
  99. package/src/panels/qr-panel.ts +2 -2
  100. package/src/renderer/agent-workspace-context-lines.ts +686 -0
  101. package/src/renderer/agent-workspace.ts +30 -419
  102. package/src/renderer/settings-modal-helpers.ts +12 -0
  103. package/src/renderer/settings-modal.ts +12 -0
  104. package/src/runtime/bootstrap-command-context.ts +11 -1
  105. package/src/runtime/bootstrap-command-parts.ts +14 -3
  106. package/src/runtime/bootstrap-core.ts +39 -1
  107. package/src/runtime/bootstrap-shell.ts +6 -0
  108. package/src/runtime/bootstrap.ts +113 -30
  109. package/src/runtime/connected-host-auth.ts +3 -2
  110. package/src/runtime/execution-ledger.ts +231 -0
  111. package/src/runtime/services.ts +88 -10
  112. package/src/runtime/tool-permission-safety.ts +199 -5
  113. package/src/shell/session-continuity-hints.ts +39 -0
  114. package/src/tools/agent-artifacts-tool.ts +606 -0
  115. package/src/tools/agent-audit-tool.ts +155 -0
  116. package/src/tools/agent-autonomy-schedule-tool.ts +238 -0
  117. package/src/tools/agent-autonomy-tool.ts +140 -0
  118. package/src/tools/agent-channel-send-tool.ts +24 -2
  119. package/src/tools/agent-channels-tool.ts +140 -0
  120. package/src/tools/agent-computer-tool.ts +168 -0
  121. package/src/tools/agent-context-policy.ts +137 -7
  122. package/src/tools/agent-context-tool.ts +143 -0
  123. package/src/tools/agent-delegation-tool.ts +128 -0
  124. package/src/tools/agent-device-tool.ts +240 -0
  125. package/src/tools/agent-documents-tool.ts +684 -0
  126. package/src/tools/agent-execution-tool.ts +230 -0
  127. package/src/tools/agent-harness-agent-orchestration.ts +703 -0
  128. package/src/tools/agent-harness-autonomy-intake.ts +785 -0
  129. package/src/tools/agent-harness-autonomy-live-records.ts +588 -0
  130. package/src/tools/agent-harness-autonomy-queue-types.ts +88 -0
  131. package/src/tools/agent-harness-autonomy-queue.ts +560 -0
  132. package/src/tools/agent-harness-background-processes-types.ts +35 -0
  133. package/src/tools/agent-harness-background-processes.ts +794 -0
  134. package/src/tools/agent-harness-browser-cockpit-route.ts +219 -0
  135. package/src/tools/agent-harness-browser-control.ts +338 -0
  136. package/src/tools/agent-harness-channel-metadata.ts +168 -10
  137. package/src/tools/agent-harness-cli-command-policy.ts +110 -0
  138. package/src/tools/agent-harness-command-catalog.ts +4 -2
  139. package/src/tools/agent-harness-command-runner.ts +42 -0
  140. package/src/tools/agent-harness-connected-host-status.ts +7 -4
  141. package/src/tools/agent-harness-delegation-posture.ts +234 -19
  142. package/src/tools/agent-harness-document-ops-reviewer-readiness.ts +234 -0
  143. package/src/tools/agent-harness-document-ops-types.ts +72 -0
  144. package/src/tools/agent-harness-document-ops.ts +671 -0
  145. package/src/tools/agent-harness-execution-history.ts +489 -0
  146. package/src/tools/agent-harness-execution-posture.ts +382 -0
  147. package/src/tools/agent-harness-file-recovery.ts +135 -0
  148. package/src/tools/agent-harness-keybinding-metadata.ts +16 -12
  149. package/src/tools/agent-harness-learning-curator-common.ts +102 -0
  150. package/src/tools/agent-harness-learning-curator-consolidation.ts +295 -0
  151. package/src/tools/agent-harness-learning-curator-proposals.ts +606 -0
  152. package/src/tools/agent-harness-learning-curator-types.ts +151 -0
  153. package/src/tools/agent-harness-learning-curator.ts +417 -0
  154. package/src/tools/agent-harness-local-model-benchmarks.ts +199 -0
  155. package/src/tools/agent-harness-local-model-cookbook.ts +238 -0
  156. package/src/tools/agent-harness-local-model-endpoints.ts +673 -0
  157. package/src/tools/agent-harness-media-posture.ts +208 -2
  158. package/src/tools/agent-harness-memory-posture.ts +556 -0
  159. package/src/tools/agent-harness-metadata.ts +100 -168
  160. package/src/tools/agent-harness-mode-catalog.ts +84 -33
  161. package/src/tools/agent-harness-model-catalog.ts +162 -0
  162. package/src/tools/agent-harness-model-provider-health.ts +186 -0
  163. package/src/tools/agent-harness-model-readiness.ts +406 -0
  164. package/src/tools/agent-harness-model-routing-types.ts +266 -0
  165. package/src/tools/agent-harness-model-routing-utils.ts +30 -0
  166. package/src/tools/agent-harness-model-routing.ts +137 -190
  167. package/src/tools/agent-harness-operator-methods.ts +115 -133
  168. package/src/tools/agent-harness-pairing-posture.ts +431 -18
  169. package/src/tools/agent-harness-personal-ops-discovery.ts +533 -0
  170. package/src/tools/agent-harness-personal-ops-intake.ts +385 -0
  171. package/src/tools/agent-harness-personal-ops-lanes.ts +204 -0
  172. package/src/tools/agent-harness-personal-ops-records.ts +732 -0
  173. package/src/tools/agent-harness-personal-ops-runner.ts +637 -0
  174. package/src/tools/agent-harness-personal-ops-types.ts +222 -0
  175. package/src/tools/agent-harness-personal-ops.ts +606 -0
  176. package/src/tools/agent-harness-project-context.ts +144 -0
  177. package/src/tools/agent-harness-prompt-context.ts +589 -0
  178. package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
  179. package/src/tools/agent-harness-release-evidence.ts +9 -7
  180. package/src/tools/agent-harness-release-readiness.ts +9 -7
  181. package/src/tools/agent-harness-research-briefing.ts +427 -0
  182. package/src/tools/agent-harness-research-queue.ts +250 -0
  183. package/src/tools/agent-harness-research-runs.ts +295 -0
  184. package/src/tools/agent-harness-research-workflow.ts +322 -0
  185. package/src/tools/agent-harness-security-posture.ts +2 -2
  186. package/src/tools/agent-harness-service-posture.ts +9 -9
  187. package/src/tools/agent-harness-session-metadata.ts +3 -3
  188. package/src/tools/agent-harness-setup-connected-host.ts +501 -0
  189. package/src/tools/agent-harness-setup-describe.ts +557 -0
  190. package/src/tools/agent-harness-setup-handoffs.ts +502 -0
  191. package/src/tools/agent-harness-setup-model-helpers.ts +101 -0
  192. package/src/tools/agent-harness-setup-plan.ts +253 -0
  193. package/src/tools/agent-harness-setup-posture-types.ts +218 -0
  194. package/src/tools/agent-harness-setup-posture-utils.ts +173 -0
  195. package/src/tools/agent-harness-setup-posture.ts +449 -221
  196. package/src/tools/agent-harness-setup-smoke.ts +508 -0
  197. package/src/tools/agent-harness-sudo-posture.ts +114 -0
  198. package/src/tools/agent-harness-tool-schema.ts +120 -7
  199. package/src/tools/agent-harness-tool-types.ts +78 -0
  200. package/src/tools/agent-harness-tool-utils.ts +43 -0
  201. package/src/tools/agent-harness-tool.ts +311 -370
  202. package/src/tools/agent-harness-ui-surface-metadata.ts +76 -69
  203. package/src/tools/agent-harness-ui-surface-types.ts +46 -0
  204. package/src/tools/agent-harness-vibe-health.ts +105 -0
  205. package/src/tools/agent-harness-workspace-action-runner.ts +149 -0
  206. package/src/tools/agent-harness-workspace-actions.ts +114 -8
  207. package/src/tools/agent-harness-workspace-editor-execution.ts +214 -0
  208. package/src/tools/agent-harness-workspace-editor-runner.ts +755 -0
  209. package/src/tools/agent-host-tool.ts +159 -0
  210. package/src/tools/agent-knowledge-ingest-tool.ts +34 -1
  211. package/src/tools/agent-learning-consolidation-core.ts +327 -0
  212. package/src/tools/agent-learning-consolidation-tool.ts +758 -0
  213. package/src/tools/agent-memory-tool.ts +214 -0
  214. package/src/tools/agent-model-compare-export.ts +315 -0
  215. package/src/tools/agent-model-compare-handoff.ts +592 -0
  216. package/src/tools/agent-model-compare-judgment.ts +633 -0
  217. package/src/tools/agent-model-compare-run.ts +722 -0
  218. package/src/tools/agent-model-compare-tool.ts +698 -0
  219. package/src/tools/agent-model-compare-types.ts +191 -0
  220. package/src/tools/agent-model-compare-utils.ts +93 -0
  221. package/src/tools/agent-models-tool.ts +208 -0
  222. package/src/tools/agent-operator-action-tool.ts +1 -1
  223. package/src/tools/agent-operator-method-tool.ts +643 -0
  224. package/src/tools/agent-personal-ops-tool.ts +197 -0
  225. package/src/tools/agent-policy-explanation.ts +415 -0
  226. package/src/tools/agent-research-report-tool.ts +608 -0
  227. package/src/tools/agent-research-runs-tool.ts +434 -0
  228. package/src/tools/agent-research-sources-tool.ts +443 -0
  229. package/src/tools/agent-research-tool.ts +687 -0
  230. package/src/tools/agent-review-packet-presets-core.ts +757 -0
  231. package/src/tools/agent-review-packet-presets-tool.ts +466 -0
  232. package/src/tools/agent-review-packet-share-tool.ts +305 -0
  233. package/src/tools/agent-route-planner-candidates-setup.ts +411 -0
  234. package/src/tools/agent-route-planner-candidates-surfaces.ts +432 -0
  235. package/src/tools/agent-route-planner-candidates-work.ts +251 -0
  236. package/src/tools/agent-route-planner-helpers.ts +667 -0
  237. package/src/tools/agent-route-planner.ts +185 -0
  238. package/src/tools/agent-route-tool.ts +105 -0
  239. package/src/tools/agent-schedule-edit-tool.ts +210 -0
  240. package/src/tools/agent-schedule-tool.ts +282 -0
  241. package/src/tools/agent-security-tool.ts +145 -0
  242. package/src/tools/agent-sessions-tool.ts +122 -0
  243. package/src/tools/agent-settings-import-tool.ts +104 -0
  244. package/src/tools/agent-settings-tool.ts +176 -0
  245. package/src/tools/agent-setup-tool.ts +226 -0
  246. package/src/tools/agent-support-tool.ts +122 -0
  247. package/src/tools/agent-terminal-process-tools.ts +167 -0
  248. package/src/tools/agent-tool-policy-guard-types.ts +77 -0
  249. package/src/tools/agent-tool-policy-guard.ts +106 -85
  250. package/src/tools/agent-vibe-tool.ts +297 -0
  251. package/src/tools/agent-work-plan-tool.ts +265 -6
  252. package/src/tools/agent-workspace-tool.ts +305 -0
  253. package/src/tools/artifact-archive.ts +169 -0
  254. package/src/tools/tool-definition-compaction.ts +36 -0
  255. package/src/version.ts +1 -1
@@ -0,0 +1,722 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { ArtifactDescriptor } from '@pellux/goodvibes-sdk/platform/artifacts';
3
+ import type { ChatRequest, ChatResponse } from '@pellux/goodvibes-sdk/platform/providers';
4
+ import type { AgentModelCompareArtifactStore, AgentModelCompareCatalogModel, AgentModelCompareModelCatalog, AgentModelCompareToolArgs, AgentModelCompareToolDeps, CompareCandidateResult, ComparisonArtifactStatus, ResolvedCompareModel, SavedComparisonArtifact, StoredComparison } from './agent-model-compare-types.ts';
5
+ import { BLIND_LABELS, COMPARISON_STORE_LIMIT, DEFAULT_CANDIDATE_COUNT, DEFAULT_CANDIDATE_OUTPUT_CHARS, DEFAULT_MAX_TOKENS, DEFAULT_SIDE_BY_SIDE_PREVIEW_BYTES, MAX_CANDIDATES, MAX_COMPLETION_TOKENS, MAX_PROMPT_CHARS, MAX_SIDE_BY_SIDE_PREVIEW_BYTES, MAX_SOURCE_ARTIFACT_BYTES, MIN_CANDIDATES, MODE_ANALYTICS, MODE_APPLY, MODE_EXPORT, MODE_HANDOFF, MODE_HANDOFF_ARCHIVE, MODE_HANDOFF_DIFF, MODE_JUDGE, MODE_REVEAL, MODE_REVIEW, MODE_ROUTE_DECISION, MODE_RUN, MODE_SIDE_BY_SIDE, MODE_SYNTHESIS } from './agent-model-compare-types.ts';
6
+ import { clamp, isTextLike, readBenchmarkKind, readBoolean, readComparisonTag, readModelRefs, readNumber, readOptionalBoolean, readRecord, readString, readStringList, previewText } from './agent-model-compare-utils.ts';
7
+
8
+ export function normalizeCatalogModel(model: AgentModelCompareCatalogModel): ResolvedCompareModel | null {
9
+ const modelId = readString(model.modelId ?? model.id);
10
+ const providerId = readString(model.providerId ?? model.provider);
11
+ if (!modelId || !providerId) return null;
12
+ return {
13
+ modelId,
14
+ providerId,
15
+ registryKey: readString(model.registryKey) || `${providerId}:${modelId}`,
16
+ displayName: readString(model.displayName) || modelId,
17
+ current: model.current === true,
18
+ };
19
+ }
20
+
21
+ export function modelSearchText(model: ResolvedCompareModel): string {
22
+ return [
23
+ model.registryKey,
24
+ model.modelId,
25
+ model.providerId,
26
+ model.displayName,
27
+ ].join('\n').toLowerCase();
28
+ }
29
+
30
+ export function dedupeModels(models: readonly ResolvedCompareModel[]): readonly ResolvedCompareModel[] {
31
+ const seen = new Set<string>();
32
+ const result: ResolvedCompareModel[] = [];
33
+ for (const model of models) {
34
+ if (seen.has(model.registryKey)) continue;
35
+ seen.add(model.registryKey);
36
+ result.push(model);
37
+ }
38
+ return result;
39
+ }
40
+
41
+ export async function listSelectableModels(catalog: AgentModelCompareModelCatalog): Promise<readonly ResolvedCompareModel[]> {
42
+ const rawModels = await catalog.listModels({ selectableOnly: true });
43
+ return dedupeModels(rawModels
44
+ .filter((model) => model.selectable !== false)
45
+ .map(normalizeCatalogModel)
46
+ .filter((model): model is ResolvedCompareModel => model !== null));
47
+ }
48
+
49
+ export async function resolveCurrentModel(catalog: AgentModelCompareModelCatalog): Promise<ResolvedCompareModel | null> {
50
+ if (!catalog.getCurrentModel) return null;
51
+ try {
52
+ return normalizeCatalogModel(await catalog.getCurrentModel());
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function resolveRequestedModels(
59
+ refs: readonly string[],
60
+ selectableModels: readonly ResolvedCompareModel[],
61
+ ): readonly ResolvedCompareModel[] {
62
+ const resolved: ResolvedCompareModel[] = [];
63
+ for (const ref of refs) {
64
+ const normalizedRef = ref.toLowerCase();
65
+ const exact = selectableModels.find((model) => model.registryKey.toLowerCase() === normalizedRef)
66
+ ?? selectableModels.find((model) => `${model.providerId}:${model.modelId}`.toLowerCase() === normalizedRef);
67
+ if (exact) {
68
+ resolved.push(exact);
69
+ continue;
70
+ }
71
+ const modelIdMatches = selectableModels.filter((model) => model.modelId.toLowerCase() === normalizedRef);
72
+ if (modelIdMatches.length === 1) {
73
+ resolved.push(modelIdMatches[0]!);
74
+ continue;
75
+ }
76
+ const displayMatches = selectableModels.filter((model) => model.displayName.toLowerCase() === normalizedRef);
77
+ if (displayMatches.length === 1) {
78
+ resolved.push(displayMatches[0]!);
79
+ continue;
80
+ }
81
+ const fuzzy = selectableModels.filter((model) => modelSearchText(model).includes(normalizedRef));
82
+ if (fuzzy.length === 1) resolved.push(fuzzy[0]!);
83
+ }
84
+ return dedupeModels(resolved);
85
+ }
86
+
87
+ export async function selectComparisonModels(
88
+ catalog: AgentModelCompareModelCatalog,
89
+ refs: readonly string[],
90
+ candidateCount: number,
91
+ ): Promise<readonly ResolvedCompareModel[]> {
92
+ const selectableModels = await listSelectableModels(catalog);
93
+ if (selectableModels.length < MIN_CANDIDATES) {
94
+ throw new Error(`At least ${MIN_CANDIDATES} selectable models are required for blind comparison.`);
95
+ }
96
+
97
+ if (refs.length > 0) {
98
+ const requested = resolveRequestedModels(refs, selectableModels);
99
+ const missing = refs.length - requested.length;
100
+ if (missing > 0) {
101
+ throw new Error(`Could not resolve ${missing} requested model reference(s). Use registry keys from models action:"status".`);
102
+ }
103
+ if (requested.length < MIN_CANDIDATES) throw new Error(`Select at least ${MIN_CANDIDATES} different models.`);
104
+ if (requested.length > MAX_CANDIDATES) throw new Error(`Select at most ${MAX_CANDIDATES} models.`);
105
+ return requested;
106
+ }
107
+
108
+ const current = await resolveCurrentModel(catalog);
109
+ const count = clamp(candidateCount, MIN_CANDIDATES, MAX_CANDIDATES);
110
+ const ordered = dedupeModels([
111
+ ...(current ? [current] : []),
112
+ ...selectableModels,
113
+ ]).filter((model) => selectableModels.some((selectable) => selectable.registryKey === model.registryKey));
114
+ return ordered.slice(0, count);
115
+ }
116
+
117
+ export function createPromptRequest(
118
+ model: ResolvedCompareModel,
119
+ prompt: string,
120
+ systemPrompt: string,
121
+ maxTokens: number,
122
+ ): ChatRequest {
123
+ return {
124
+ model: model.modelId,
125
+ messages: [{ role: 'user', content: prompt }],
126
+ ...(systemPrompt ? { systemPrompt } : {}),
127
+ ...(maxTokens > 0 ? { maxTokens } : {}),
128
+ };
129
+ }
130
+
131
+ export async function runCandidate(
132
+ deps: AgentModelCompareToolDeps,
133
+ model: ResolvedCompareModel,
134
+ blindId: string,
135
+ prompt: string,
136
+ systemPrompt: string,
137
+ maxTokens: number,
138
+ ): Promise<CompareCandidateResult> {
139
+ const startedAt = Date.now();
140
+ try {
141
+ const provider = deps.providerRegistry.getForModel(model.modelId, model.providerId);
142
+ const response = await provider.chat(createPromptRequest(model, prompt, systemPrompt, maxTokens));
143
+ return {
144
+ blindId,
145
+ model,
146
+ status: 'completed',
147
+ content: response.content,
148
+ stopReason: response.stopReason,
149
+ usage: response.usage,
150
+ latencyMs: Date.now() - startedAt,
151
+ toolCallCount: response.toolCalls.length,
152
+ };
153
+ } catch (error) {
154
+ return {
155
+ blindId,
156
+ model,
157
+ status: 'failed',
158
+ content: '',
159
+ latencyMs: Date.now() - startedAt,
160
+ error: error instanceof Error ? error.message : String(error),
161
+ };
162
+ }
163
+ }
164
+
165
+ export function formatUsage(usage: ChatResponse['usage'] | undefined): string {
166
+ if (!usage) return 'unknown';
167
+ const cache = [
168
+ usage.cacheReadTokens ? `cache read ${usage.cacheReadTokens}` : '',
169
+ usage.cacheWriteTokens ? `cache write ${usage.cacheWriteTokens}` : '',
170
+ ].filter(Boolean).join(', ');
171
+ return `${usage.inputTokens} in / ${usage.outputTokens} out${cache ? `, ${cache}` : ''}`;
172
+ }
173
+
174
+ export function candidateContent(content: string): string {
175
+ if (content.length <= DEFAULT_CANDIDATE_OUTPUT_CHARS) return content || '(empty)';
176
+ return `${content.slice(0, DEFAULT_CANDIDATE_OUTPUT_CHARS).trimEnd()}\n\n[Candidate output truncated at ${DEFAULT_CANDIDATE_OUTPUT_CHARS} characters.]`;
177
+ }
178
+
179
+ export function formatCandidate(candidate: CompareCandidateResult, reveal: boolean): string {
180
+ const lines = [
181
+ `Candidate ${candidate.blindId}`,
182
+ ` status ${candidate.status}`,
183
+ ` latency ${candidate.latencyMs}ms`,
184
+ ];
185
+ if (reveal) {
186
+ lines.push(` model ${candidate.model.registryKey} (${candidate.model.displayName})`);
187
+ }
188
+ if (candidate.status === 'failed') {
189
+ lines.push(` error ${reveal ? candidate.error ?? 'unknown' : 'Provider-specific error hidden until reveal.'}`);
190
+ return lines.join('\n');
191
+ }
192
+ lines.push(` stop ${candidate.stopReason ?? 'unknown'}`);
193
+ lines.push(` usage ${formatUsage(candidate.usage)}`);
194
+ if ((candidate.toolCallCount ?? 0) > 0) lines.push(` tool calls ${candidate.toolCallCount}`);
195
+ lines.push('');
196
+ lines.push(candidateContent(candidate.content));
197
+ return lines.join('\n');
198
+ }
199
+
200
+ export function formatReveal(comparison: StoredComparison): string {
201
+ return [
202
+ `Blind model comparison reveal ${comparison.comparisonId}`,
203
+ `created ${comparison.createdAt}`,
204
+ `prompt ${comparison.promptPreview}`,
205
+ ...(comparison.sourceArtifact ? [`source artifact ${comparison.sourceArtifact.artifactId} (${comparison.sourceArtifact.mimeType}, ${comparison.sourceArtifact.sizeBytes} bytes)`] : []),
206
+ ...formatComparisonDimensionLines(comparison),
207
+ ...(comparison.artifact ? [`artifact ${comparison.artifact.artifactId} (${comparison.artifact.mimeType}, ${comparison.artifact.sizeBytes} bytes)`] : []),
208
+ '',
209
+ ...comparison.candidates.map((candidate) => `${candidate.blindId}: ${candidate.model.registryKey} (${candidate.model.displayName})`),
210
+ ].join('\n');
211
+ }
212
+
213
+ export function formatSavedComparisonArtifacts(artifactStore?: AgentModelCompareArtifactStore): string {
214
+ if (!artifactStore?.list) {
215
+ return 'Saved blind comparison artifacts are unavailable because the artifact store does not expose listing in this runtime.';
216
+ }
217
+ const artifacts = artifactStore.list(50)
218
+ .filter(isModelCompareArtifact)
219
+ .slice(0, 10);
220
+ if (artifacts.length === 0) {
221
+ return 'No saved blind comparison artifacts found. Run agent_model_compare mode:"run" first.';
222
+ }
223
+ return [
224
+ 'Saved blind comparison artifacts',
225
+ ...artifacts.map((artifact) => {
226
+ const comparisonId = readString(artifact.metadata.comparisonId) || 'unknown-comparison';
227
+ const promptPreview = readString(artifact.metadata.promptPreview) || '(prompt unavailable)';
228
+ const completed = typeof artifact.metadata.completedCandidates === 'number' ? artifact.metadata.completedCandidates : '?';
229
+ const count = typeof artifact.metadata.candidateCount === 'number' ? artifact.metadata.candidateCount : '?';
230
+ const dimensions = [
231
+ readComparisonTag(artifact.metadata.benchmarkKind) ? `benchmark ${readComparisonTag(artifact.metadata.benchmarkKind)}` : null,
232
+ readComparisonTag(artifact.metadata.taskType) ? `task ${readComparisonTag(artifact.metadata.taskType)}` : null,
233
+ readComparisonTag(artifact.metadata.documentId) ? `document ${readComparisonTag(artifact.metadata.documentId)}` : null,
234
+ ].filter(Boolean).join('; ');
235
+ return ` ${artifact.id} ${comparisonId} candidates ${completed}/${count}${dimensions ? ` ${dimensions}` : ''} prompt ${promptPreview}`;
236
+ }),
237
+ '',
238
+ 'Review one with mode:"review" and artifactId, render related evidence with mode:"sideBySide", or reveal with mode:"reveal" after judging.',
239
+ ].join('\n');
240
+ }
241
+
242
+ export function isModelCompareJudgmentArtifact(artifact: ArtifactDescriptor): boolean {
243
+ const purpose = readString(artifact.metadata.purpose);
244
+ if (purpose === 'agent-model-compare-judgment') return true;
245
+ if (purpose) return false;
246
+ return readString(artifact.filename).startsWith('blind-model-comparison-judgment-');
247
+ }
248
+
249
+ export function isModelCompareHandoffArtifact(artifact: ArtifactDescriptor): boolean {
250
+ const purpose = readString(artifact.metadata.purpose);
251
+ if (purpose === 'agent-model-compare-handoff') return true;
252
+ if (purpose) return false;
253
+ return readString(artifact.filename).startsWith('blind-model-comparison-handoff-');
254
+ }
255
+
256
+ export function isModelCompareRouteDecisionArtifact(artifact: ArtifactDescriptor): boolean {
257
+ const purpose = readString(artifact.metadata.purpose);
258
+ if (purpose === 'agent-model-compare-route-decision') return true;
259
+ if (purpose) return false;
260
+ return readString(artifact.filename).startsWith('blind-model-comparison-route-decision-');
261
+ }
262
+
263
+ export function formatSavedHandoffArtifacts(artifactStore?: AgentModelCompareArtifactStore): string {
264
+ if (!artifactStore?.list) {
265
+ return 'Saved blind comparison reviewer handoffs are unavailable because the artifact store does not expose listing in this runtime.';
266
+ }
267
+ const artifacts = artifactStore.list(50)
268
+ .filter(isModelCompareHandoffArtifact)
269
+ .slice(0, 10);
270
+ if (artifacts.length === 0) {
271
+ return 'No saved blind comparison reviewer handoffs found. Create one with mode:"handoff" first.';
272
+ }
273
+ return [
274
+ 'Saved blind comparison reviewer handoffs',
275
+ ...artifacts.map((artifact) => {
276
+ const handoffId = readString(artifact.metadata.handoffId) || 'unknown-handoff';
277
+ const comparisonId = readString(artifact.metadata.comparisonId) || 'unknown-comparison';
278
+ const sourceArtifactId = readString(artifact.metadata.sourceArtifactId) || '(missing source)';
279
+ const sourceKind = readString(artifact.metadata.sourceKind) || 'unknown';
280
+ const relatedArtifactIds = readStringList(artifact.metadata.relatedArtifactIds);
281
+ return ` ${artifact.id} ${handoffId} comparison ${comparisonId} source ${sourceArtifactId} (${sourceKind}) related ${relatedArtifactIds.length}`;
282
+ }),
283
+ '',
284
+ 'Archive one with mode:"handoffArchive" and artifactId, compare two with mode:"handoffDiff" plus leftArtifactId/rightArtifactId.',
285
+ ].join('\n');
286
+ }
287
+
288
+ export function formatArtifactStatus(comparison: StoredComparison): string {
289
+ if (comparison.artifact) {
290
+ const filename = comparison.artifact.filename ? ` ${comparison.artifact.filename}` : '';
291
+ return `artifact ${comparison.artifact.artifactId}${filename} (${comparison.artifact.mimeType}, ${comparison.artifact.sizeBytes} bytes; includes full prompt, blinded outputs, and reveal map)`;
292
+ }
293
+ if (comparison.artifactStatus) return `artifact ${comparison.artifactStatus.message}`;
294
+ return 'artifact not saved';
295
+ }
296
+
297
+ export function formatComparisonDimensionLines(comparison: StoredComparison): readonly string[] {
298
+ const lines: string[] = [];
299
+ if (comparison.benchmarkKind) lines.push(`benchmark ${comparison.benchmarkKind}`);
300
+ if (comparison.taskType) lines.push(`task type ${comparison.taskType}`);
301
+ if (comparison.documentId) lines.push(`document ${comparison.documentId}`);
302
+ return lines;
303
+ }
304
+
305
+ export function formatIndentedContent(content: string): string {
306
+ return candidateContent(content)
307
+ .split('\n')
308
+ .map((line) => ` ${line}`)
309
+ .join('\n');
310
+ }
311
+
312
+ export function formatReviewCandidate(candidate: CompareCandidateResult, reveal: boolean): string {
313
+ const lines = [
314
+ `Candidate ${candidate.blindId}`,
315
+ ` status ${candidate.status}`,
316
+ ` latency ${candidate.latencyMs}ms`,
317
+ ];
318
+ if (reveal) lines.push(` model ${candidate.model.registryKey} (${candidate.model.displayName})`);
319
+ if (candidate.status === 'failed') {
320
+ lines.push(` error ${reveal ? candidate.error ?? 'unknown' : 'Provider-specific error hidden until reveal.'}`);
321
+ return lines.join('\n');
322
+ }
323
+ lines.push(` stop ${candidate.stopReason ?? 'unknown'}`);
324
+ lines.push(` usage ${formatUsage(candidate.usage)}`);
325
+ lines.push(' output');
326
+ lines.push(formatIndentedContent(candidate.content));
327
+ return lines.join('\n');
328
+ }
329
+
330
+ export function formatReview(comparison: StoredComparison, reveal: boolean): string {
331
+ const lines = [
332
+ `Blind model comparison review ${comparison.comparisonId}`,
333
+ `created ${comparison.createdAt}`,
334
+ `prompt ${comparison.promptPreview}`,
335
+ `rubric ${comparison.rubric || '(none)'}`,
336
+ ...(comparison.sourceArtifact ? [`source artifact ${comparison.sourceArtifact.artifactId} (${comparison.sourceArtifact.mimeType}, ${comparison.sourceArtifact.sizeBytes} bytes)`] : []),
337
+ ...formatComparisonDimensionLines(comparison),
338
+ formatArtifactStatus(comparison),
339
+ '',
340
+ 'Review board',
341
+ ...comparison.candidates.flatMap((candidate, index) => [
342
+ formatReviewCandidate(candidate, reveal),
343
+ ...(index < comparison.candidates.length - 1 ? [''] : []),
344
+ ]),
345
+ '',
346
+ 'Decision worksheet',
347
+ ' winner: (user chooses after reading the blinded outputs)',
348
+ ' reasons: capture strengths, weaknesses, factuality, tone, and fit to rubric',
349
+ ' route update: separate confirmed model-routing action only',
350
+ ];
351
+ if (reveal) {
352
+ lines.push('');
353
+ lines.push('Reveal');
354
+ lines.push(...comparison.candidates.map((candidate) => ` ${candidate.blindId}: ${candidate.model.registryKey} (${candidate.model.displayName})`));
355
+ } else {
356
+ lines.push('');
357
+ lines.push(`Identities hidden. Reveal after judging with mode:"reveal" and comparisonId:"${comparison.comparisonId}".`);
358
+ }
359
+ lines.push('No selected model was changed.');
360
+ return lines.join('\n');
361
+ }
362
+
363
+ export function formatRunResult(comparison: StoredComparison, reveal: boolean): string {
364
+ const completed = comparison.candidates.filter((candidate) => candidate.status === 'completed').length;
365
+ const lines = [
366
+ `Blind model comparison ${comparison.comparisonId}`,
367
+ `created ${comparison.createdAt}`,
368
+ `prompt ${comparison.promptPreview}`,
369
+ `rubric ${comparison.rubric || '(none)'}`,
370
+ ...(comparison.sourceArtifact ? [`source artifact ${comparison.sourceArtifact.artifactId} (${comparison.sourceArtifact.mimeType}, ${comparison.sourceArtifact.sizeBytes} bytes)`] : []),
371
+ ...formatComparisonDimensionLines(comparison),
372
+ `candidates ${comparison.candidates.length}; completed ${completed}; reveal ${reveal ? 'included' : 'hidden'}`,
373
+ formatArtifactStatus(comparison),
374
+ '',
375
+ ...comparison.candidates.flatMap((candidate, index) => [
376
+ formatCandidate(candidate, reveal),
377
+ ...(index < comparison.candidates.length - 1 ? [''] : []),
378
+ ]),
379
+ '',
380
+ ];
381
+ if (reveal) {
382
+ lines.push('Reveal');
383
+ lines.push(...comparison.candidates.map((candidate) => ` ${candidate.blindId}: ${candidate.model.registryKey} (${candidate.model.displayName})`));
384
+ } else {
385
+ lines.push(`Identities hidden. Judge the outputs first, then call agent_model_compare with mode:"reveal" and comparisonId:"${comparison.comparisonId}".`);
386
+ }
387
+ lines.push('No selected model was changed.');
388
+ return lines.join('\n');
389
+ }
390
+
391
+ export function formatPreview(
392
+ args: AgentModelCompareToolArgs,
393
+ refs: readonly string[],
394
+ candidateCount: number,
395
+ ): string {
396
+ const benchmarkKind = readBenchmarkKind(args.benchmarkKind);
397
+ const taskType = readComparisonTag(args.taskType);
398
+ const documentId = readComparisonTag(args.documentId);
399
+ return [
400
+ 'Agent blind model comparison preview',
401
+ ` prompt ${previewText(readString(args.prompt) || '(missing)')}`,
402
+ ...(readString(args.artifactId) ? [` source artifact ${readString(args.artifactId)}`] : []),
403
+ ` candidates ${refs.length > 0 ? refs.length : candidateCount}`,
404
+ ` selection ${refs.length > 0 ? 'user supplied model refs' : 'auto-select from selectable models'}`,
405
+ ` rubric ${previewText(readString(args.rubric) || '(none)')}`,
406
+ ...(benchmarkKind ? [` benchmark ${benchmarkKind}`] : []),
407
+ ...(taskType ? [` task type ${taskType}`] : []),
408
+ ...(documentId ? [` document ${documentId}`] : []),
409
+ ` reveal ${readBoolean(args.reveal) ? 'immediate' : 'delayed'}`,
410
+ ` artifact ${readOptionalBoolean(args.saveArtifact, true) ? 'save local JSON review' : 'do not save'}`,
411
+ ' policy model comparison sends the same prompt to each candidate and requires confirm:true plus explicitUserRequest',
412
+ ].join('\n');
413
+ }
414
+
415
+ export function parseMode(value: unknown): 'run' | 'reveal' | 'review' | 'sideBySide' | 'judge' | 'apply' | 'routeDecision' | 'export' | 'handoff' | 'handoffArchive' | 'handoffDiff' | 'analytics' | 'synthesis' {
416
+ const mode = readString(value) || MODE_RUN;
417
+ if (
418
+ mode === MODE_RUN
419
+ || mode === MODE_REVEAL
420
+ || mode === MODE_REVIEW
421
+ || mode === MODE_SIDE_BY_SIDE
422
+ || mode === MODE_JUDGE
423
+ || mode === MODE_APPLY
424
+ || mode === MODE_ROUTE_DECISION
425
+ || mode === MODE_EXPORT
426
+ || mode === MODE_HANDOFF
427
+ || mode === MODE_HANDOFF_ARCHIVE
428
+ || mode === MODE_HANDOFF_DIFF
429
+ || mode === MODE_ANALYTICS
430
+ || mode === MODE_SYNTHESIS
431
+ ) return mode;
432
+ throw new Error('mode must be run, reveal, review, sideBySide, judge, apply, routeDecision, export, handoff, handoffArchive, handoffDiff, analytics, or synthesis.');
433
+ }
434
+
435
+ export function rememberComparison(store: Map<string, StoredComparison>, comparison: StoredComparison): void {
436
+ store.set(comparison.comparisonId, comparison);
437
+ while (store.size > COMPARISON_STORE_LIMIT) {
438
+ const oldest = store.keys().next().value as string | undefined;
439
+ if (!oldest) break;
440
+ store.delete(oldest);
441
+ }
442
+ }
443
+
444
+ export function toArtifactCandidate(candidate: CompareCandidateResult): Record<string, unknown> {
445
+ return {
446
+ blindId: candidate.blindId,
447
+ status: candidate.status,
448
+ content: candidate.content,
449
+ latencyMs: candidate.latencyMs,
450
+ ...(candidate.stopReason ? { stopReason: candidate.stopReason } : {}),
451
+ ...(candidate.usage ? { usage: candidate.usage } : {}),
452
+ ...(typeof candidate.toolCallCount === 'number' ? { toolCallCount: candidate.toolCallCount } : {}),
453
+ ...(candidate.error ? { error: candidate.error } : {}),
454
+ model: {
455
+ registryKey: candidate.model.registryKey,
456
+ providerId: candidate.model.providerId,
457
+ modelId: candidate.model.modelId,
458
+ displayName: candidate.model.displayName,
459
+ current: candidate.model.current,
460
+ },
461
+ };
462
+ }
463
+
464
+ export function comparisonArtifactText(input: {
465
+ readonly comparison: StoredComparison;
466
+ readonly prompt: string;
467
+ readonly systemPrompt: string;
468
+ readonly maxTokens: number;
469
+ readonly revealIncludedInTranscript: boolean;
470
+ readonly benchmarkKind?: string;
471
+ }): string {
472
+ return `${JSON.stringify({
473
+ schema: 'goodvibes.agent.model_compare.v1',
474
+ comparisonId: input.comparison.comparisonId,
475
+ createdAt: input.comparison.createdAt,
476
+ prompt: input.prompt,
477
+ promptPreview: input.comparison.promptPreview,
478
+ ...(input.comparison.sourceArtifact ? { sourceArtifact: input.comparison.sourceArtifact } : {}),
479
+ ...(input.comparison.documentId ? { documentId: input.comparison.documentId } : {}),
480
+ ...(input.systemPrompt ? { systemPrompt: input.systemPrompt } : {}),
481
+ maxTokens: input.maxTokens,
482
+ rubric: input.comparison.rubric,
483
+ ...(input.benchmarkKind ? { benchmarkKind: input.benchmarkKind } : {}),
484
+ ...(input.comparison.taskType ? { taskType: input.comparison.taskType } : {}),
485
+ revealIncludedInTranscript: input.revealIncludedInTranscript,
486
+ reviewFlow: {
487
+ blindFirst: true,
488
+ revealInstruction: `Call agent_model_compare with mode:"reveal" and comparisonId:"${input.comparison.comparisonId}".`,
489
+ routeMutation: 'none',
490
+ },
491
+ candidates: input.comparison.candidates.map(toArtifactCandidate),
492
+ }, null, 2)}\n`;
493
+ }
494
+
495
+ export function toSavedComparisonArtifact(descriptor: ArtifactDescriptor): SavedComparisonArtifact {
496
+ const documentId = readString(descriptor.metadata.documentId);
497
+ return {
498
+ artifactId: descriptor.id,
499
+ ...(descriptor.filename ? { filename: descriptor.filename } : {}),
500
+ mimeType: descriptor.mimeType,
501
+ sizeBytes: descriptor.sizeBytes,
502
+ ...(documentId ? { documentId } : {}),
503
+ };
504
+ }
505
+
506
+ export async function loadRunSourceArtifact(
507
+ artifactStore: AgentModelCompareArtifactStore | undefined,
508
+ artifactId: string,
509
+ ): Promise<{ readonly artifact: SavedComparisonArtifact; readonly promptBlock: string }> {
510
+ if (!artifactStore?.readContent) {
511
+ throw new Error('Saved artifact comparison requires an artifact store with readContent support.');
512
+ }
513
+ const { record, buffer } = await artifactStore.readContent(artifactId);
514
+ if (!isTextLike(record.mimeType)) {
515
+ throw new Error(`Saved artifact ${record.id} is ${record.mimeType}; blind comparison can only inline text-like artifacts.`);
516
+ }
517
+ const sliced = buffer.subarray(0, Math.min(buffer.byteLength, MAX_SOURCE_ARTIFACT_BYTES));
518
+ const text = sliced.toString('utf-8').replace(/\0/g, '').trim();
519
+ const lines = [
520
+ 'Saved artifact context',
521
+ `Artifact ID: ${record.id}`,
522
+ `Filename: ${record.filename ?? '(none)'}`,
523
+ `MIME: ${record.mimeType}`,
524
+ '',
525
+ text || '(empty text artifact)',
526
+ ];
527
+ if (buffer.byteLength > sliced.byteLength) {
528
+ lines.push('', `[Artifact content truncated at ${MAX_SOURCE_ARTIFACT_BYTES} bytes.]`);
529
+ }
530
+ return {
531
+ artifact: toSavedComparisonArtifact(record),
532
+ promptBlock: lines.join('\n'),
533
+ };
534
+ }
535
+
536
+ export async function buildRunPromptFromArtifact(input: {
537
+ readonly artifactStore?: AgentModelCompareArtifactStore;
538
+ readonly prompt: string;
539
+ readonly artifactId: string;
540
+ }): Promise<{ readonly prompt: string; readonly sourceArtifact?: SavedComparisonArtifact }> {
541
+ if (!input.artifactId) return { prompt: input.prompt };
542
+ const loaded = await loadRunSourceArtifact(input.artifactStore, input.artifactId);
543
+ const instruction = input.prompt || 'Compare model responses using the saved artifact context below.';
544
+ return {
545
+ prompt: [instruction, '', loaded.promptBlock].join('\n'),
546
+ sourceArtifact: loaded.artifact,
547
+ };
548
+ }
549
+
550
+ export async function saveComparisonArtifact(input: {
551
+ readonly artifactStore?: AgentModelCompareArtifactStore;
552
+ readonly comparison: StoredComparison;
553
+ readonly prompt: string;
554
+ readonly systemPrompt: string;
555
+ readonly maxTokens: number;
556
+ readonly revealIncludedInTranscript: boolean;
557
+ readonly enabled: boolean;
558
+ readonly benchmarkKind?: string;
559
+ }): Promise<{
560
+ readonly artifact?: SavedComparisonArtifact;
561
+ readonly status: ComparisonArtifactStatus;
562
+ }> {
563
+ if (!input.enabled) {
564
+ return { status: { state: 'disabled', message: 'not saved (saveArtifact false)' } };
565
+ }
566
+ if (!input.artifactStore) {
567
+ return { status: { state: 'unavailable', message: 'not saved (artifact store unavailable)' } };
568
+ }
569
+ try {
570
+ const descriptor = await input.artifactStore.create({
571
+ kind: 'data',
572
+ mimeType: 'application/json',
573
+ filename: `blind-model-comparison-${input.comparison.comparisonId}.json`,
574
+ text: comparisonArtifactText(input),
575
+ metadata: {
576
+ purpose: 'agent-model-compare',
577
+ comparisonId: input.comparison.comparisonId,
578
+ promptPreview: input.comparison.promptPreview,
579
+ ...(input.comparison.sourceArtifact ? { sourceArtifactId: input.comparison.sourceArtifact.artifactId } : {}),
580
+ ...(input.comparison.documentId ? { documentId: input.comparison.documentId } : {}),
581
+ candidateCount: input.comparison.candidates.length,
582
+ completedCandidates: input.comparison.candidates.filter((candidate) => candidate.status === 'completed').length,
583
+ ...(input.benchmarkKind ? { benchmarkKind: input.benchmarkKind } : {}),
584
+ ...(input.comparison.taskType ? { taskType: input.comparison.taskType } : {}),
585
+ revealStored: true,
586
+ revealIncludedInTranscript: input.revealIncludedInTranscript,
587
+ },
588
+ });
589
+ return {
590
+ artifact: toSavedComparisonArtifact(descriptor),
591
+ status: { state: 'saved', message: `saved as ${descriptor.id}` },
592
+ };
593
+ } catch (error) {
594
+ return {
595
+ status: {
596
+ state: 'failed',
597
+ message: `not saved (${error instanceof Error ? error.message : String(error)})`,
598
+ },
599
+ };
600
+ }
601
+ }
602
+
603
+ export function isModelCompareArtifact(artifact: ArtifactDescriptor): boolean {
604
+ const purpose = readString(artifact.metadata.purpose);
605
+ if (purpose === 'agent-model-compare') return true;
606
+ if (purpose) return false;
607
+ return readString(artifact.filename).startsWith('blind-model-comparison-cmp_');
608
+ }
609
+
610
+ export function parseArtifactUsage(value: unknown): ChatResponse['usage'] | undefined {
611
+ const record = readRecord(value);
612
+ if (!record) return undefined;
613
+ const inputTokens = readNumber(record.inputTokens, Number.NaN);
614
+ const outputTokens = readNumber(record.outputTokens, Number.NaN);
615
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined;
616
+ return {
617
+ inputTokens,
618
+ outputTokens,
619
+ ...(typeof record.cacheReadTokens === 'number' ? { cacheReadTokens: record.cacheReadTokens } : {}),
620
+ ...(typeof record.cacheWriteTokens === 'number' ? { cacheWriteTokens: record.cacheWriteTokens } : {}),
621
+ };
622
+ }
623
+
624
+ export function parseArtifactCandidate(value: unknown, index: number): CompareCandidateResult | null {
625
+ const candidate = readRecord(value);
626
+ if (!candidate) return null;
627
+ const model = readRecord(candidate.model) ?? {};
628
+ const blindId = readString(candidate.blindId) || BLIND_LABELS[index] || String(index + 1);
629
+ const status = readString(candidate.status) === 'failed' ? 'failed' : 'completed';
630
+ return {
631
+ blindId,
632
+ status,
633
+ content: readString(candidate.content),
634
+ latencyMs: Math.max(0, readNumber(candidate.latencyMs, 0)),
635
+ ...(readString(candidate.stopReason) ? { stopReason: readString(candidate.stopReason) } : {}),
636
+ ...(parseArtifactUsage(candidate.usage) ? { usage: parseArtifactUsage(candidate.usage) } : {}),
637
+ ...(typeof candidate.toolCallCount === 'number' ? { toolCallCount: Math.max(0, Math.trunc(candidate.toolCallCount)) } : {}),
638
+ ...(readString(candidate.error) ? { error: readString(candidate.error) } : {}),
639
+ model: {
640
+ registryKey: readString(model.registryKey) || `artifact:${blindId}`,
641
+ providerId: readString(model.providerId) || 'artifact',
642
+ modelId: readString(model.modelId) || blindId,
643
+ displayName: readString(model.displayName) || readString(model.modelId) || blindId,
644
+ current: model.current === true,
645
+ },
646
+ };
647
+ }
648
+
649
+ export function parseComparisonArtifactPayload(
650
+ value: unknown,
651
+ artifact: SavedComparisonArtifact,
652
+ ): StoredComparison | null {
653
+ const payload = readRecord(value);
654
+ if (!payload || readString(payload.schema) !== 'goodvibes.agent.model_compare.v1') return null;
655
+ const rawCandidates = Array.isArray(payload.candidates) ? payload.candidates : [];
656
+ const candidates = rawCandidates
657
+ .map(parseArtifactCandidate)
658
+ .filter((candidate): candidate is CompareCandidateResult => candidate !== null);
659
+ if (candidates.length === 0) return null;
660
+ const prompt = readString(payload.prompt);
661
+ const sourceArtifact = readRecord(payload.sourceArtifact);
662
+ const sourceArtifactId = readString(sourceArtifact?.artifactId);
663
+ const sourceDocumentId = readString(sourceArtifact?.documentId);
664
+ const documentId = readString(payload.documentId) || sourceDocumentId;
665
+ const benchmarkKind = readComparisonTag(payload.benchmarkKind);
666
+ const taskType = readComparisonTag(payload.taskType);
667
+ return {
668
+ comparisonId: readString(payload.comparisonId) || `cmp_from_${artifact.artifactId}`,
669
+ createdAt: readString(payload.createdAt) || new Date().toISOString(),
670
+ promptPreview: readString(payload.promptPreview) || previewText(prompt || '(prompt unavailable)', 160),
671
+ rubric: readString(payload.rubric),
672
+ ...(sourceArtifactId ? {
673
+ sourceArtifact: {
674
+ artifactId: sourceArtifactId,
675
+ ...(readString(sourceArtifact?.filename) ? { filename: readString(sourceArtifact?.filename) } : {}),
676
+ mimeType: readString(sourceArtifact?.mimeType) || 'application/octet-stream',
677
+ sizeBytes: Math.max(0, readNumber(sourceArtifact?.sizeBytes, 0)),
678
+ ...(sourceDocumentId ? { documentId: sourceDocumentId } : {}),
679
+ },
680
+ } : {}),
681
+ ...(benchmarkKind ? { benchmarkKind } : {}),
682
+ ...(taskType ? { taskType } : {}),
683
+ ...(documentId ? { documentId } : {}),
684
+ candidates,
685
+ artifact,
686
+ artifactStatus: { state: 'saved', message: `loaded from ${artifact.artifactId}` },
687
+ };
688
+ }
689
+
690
+ export async function loadComparisonFromArtifact(
691
+ artifactStore: AgentModelCompareArtifactStore | undefined,
692
+ artifactId: string,
693
+ ): Promise<StoredComparison | null> {
694
+ if (!artifactStore?.readContent) return null;
695
+ const { record, buffer } = await artifactStore.readContent(artifactId);
696
+ const artifact = toSavedComparisonArtifact(record);
697
+ let parsed: unknown;
698
+ try {
699
+ parsed = JSON.parse(buffer.toString('utf-8')) as unknown;
700
+ } catch {
701
+ return null;
702
+ }
703
+ return parseComparisonArtifactPayload(parsed, artifact);
704
+ }
705
+
706
+ export async function resolveComparisonForRead(input: {
707
+ readonly artifactStore?: AgentModelCompareArtifactStore;
708
+ readonly comparisons: Map<string, StoredComparison>;
709
+ readonly comparisonId: string;
710
+ readonly artifactId: string;
711
+ }): Promise<StoredComparison | null> {
712
+ if (input.comparisonId) {
713
+ const inMemory = input.comparisons.get(input.comparisonId);
714
+ if (inMemory) return inMemory;
715
+ const artifact = input.artifactStore?.list?.(100)
716
+ .filter(isModelCompareArtifact)
717
+ .find((entry) => readString(entry.metadata.comparisonId) === input.comparisonId);
718
+ if (artifact) return loadComparisonFromArtifact(input.artifactStore, artifact.id);
719
+ }
720
+ if (input.artifactId) return loadComparisonFromArtifact(input.artifactStore, input.artifactId);
721
+ return null;
722
+ }