mixdog 0.9.154 → 0.9.155

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 (38) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-voice-runtime-unix.sh +24 -0
  3. package/scripts/voice-runtime-config.json +1 -1
  4. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +2 -2
  5. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +5 -2
  6. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -2
  7. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +47 -4
  8. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -0
  9. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +7 -0
  10. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +4 -1
  11. package/src/runtime/channels/data/voice-runtime-manifest.json +17 -17
  12. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +59 -0
  13. package/src/runtime/office/assurance-benchmark.mjs +67 -1
  14. package/src/runtime/office/assurance.mjs +24 -17
  15. package/src/runtime/office/capabilities.mjs +17 -17
  16. package/src/runtime/office/composition-system.mjs +451 -0
  17. package/src/runtime/office/design-library.mjs +102 -0
  18. package/src/runtime/office/design-system.mjs +357 -168
  19. package/src/runtime/office/index.mjs +149 -20
  20. package/src/runtime/office/office-com-host.ps1 +132 -46
  21. package/src/runtime/office/pdf-render.mjs +203 -35
  22. package/src/runtime/office/portable-ooxml.mjs +1090 -47
  23. package/src/runtime/office/portable-package.mjs +390 -0
  24. package/src/runtime/office/portable-sheet-styles.mjs +241 -0
  25. package/src/runtime/office/portable-slide-shapes.mjs +283 -0
  26. package/src/runtime/office/quality-live-benchmark.mjs +82 -36
  27. package/src/runtime/office/quality-pipeline.mjs +3 -0
  28. package/src/runtime/office/tool-defs.mjs +2 -2
  29. package/src/session-runtime/goal-reminder.mjs +79 -0
  30. package/src/session-runtime/goal-runtime.mjs +105 -45
  31. package/src/session-runtime/goal-text.mjs +58 -0
  32. package/src/session-runtime/runtime-core.mjs +14 -1
  33. package/src/session-runtime/session-lifecycle.mjs +1 -1
  34. package/src/session-runtime/session-turn-api.mjs +5 -0
  35. package/src/standalone/session-service.mjs +30 -3
  36. package/src/tui/dist/index.mjs +12 -3
  37. package/src/tui/lib/voice-setup.mjs +19 -3
  38. package/src/tui/session/goal-continuation.mjs +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.154",
3
+ "version": "0.9.155",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -72,7 +72,7 @@
72
72
  "test:session-transport": "node --test scripts/session-transport-test.mjs scripts/daemon-bootstrap-test.mjs src/runtime/shared/child-spawn-remote.test.mjs src/standalone/session-runtime-agent-control-client.test.mjs src/standalone/session-runtime-dispatch-cancel.test.mjs src/standalone/session-runtime-host-factory.test.mjs src/standalone/session-runtime-inline-host.test.mjs src/standalone/session-runtime-provider-cooldown.test.mjs src/standalone/session-runtime-shard-host.test.mjs src/standalone/session-runtime-shard-router.test.mjs",
73
73
  "test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-save-fault-store-test.mjs scripts/turn-review-revert-test.mjs src/runtime/shared/tool-surface.test.mjs src/runtime/bridge-clients.test.mjs",
74
74
  "test:quick": "npm run test:session && npm run test:session-transport && npm run test:desktop-main --prefix apps/desktop",
75
- "test:office": "node --test src/runtime/office/office-runtime.test.mjs src/runtime/office/office-assurance.test.mjs && npm run test:office-approval --prefix apps/desktop",
75
+ "test:office": "node --test src/runtime/office/office-runtime.test.mjs src/runtime/office/office-assurance.test.mjs src/runtime/office/portable-authoring.test.mjs && npm run test:office-approval --prefix apps/desktop",
76
76
  "test:office:live": "node scripts/run-office-live-tests.mjs",
77
77
  "test:media": "node --test src/runtime/media/store.test.mjs src/runtime/media/renditions.test.mjs src/runtime/media/adapters/codex-image.test.mjs",
78
78
  "failures": "node scripts/tool-failures.mjs",
@@ -41,17 +41,41 @@ curl --fail --location --retry 3 \
41
41
  --output "${WORK_ROOT}/source.tar.gz"
42
42
  tar -xzf "${WORK_ROOT}/source.tar.gz" -C "${WORK_ROOT}"
43
43
 
44
+ # Portability baseline (GGML_NATIVE=OFF + explicit ISA on x86-64).
45
+ #
46
+ # ggml defaults GGML_NATIVE=ON, which tunes the binary to the BUILD machine.
47
+ # CI runners are wider than consumer hardware, so a native build ships
48
+ # instructions the target CPU may not have and aborts with SIGILL /
49
+ # STATUS_ILLEGAL_INSTRUCTION right after backend init — the `--help` smoke
50
+ # below cannot catch it, because on the build machine it passes.
51
+ #
52
+ # x86-64 pins an AVX2/FMA/F16C baseline (Haswell 2013+); AVX-512, AVX-VNNI and
53
+ # BMI2 stay OFF because they are not universal on current consumer CPUs.
54
+ # arm64 needs no ISA pins — NEON is baseline — but must still disable NATIVE.
44
55
  CMAKE_ARGS=(
45
56
  -S "${SOURCE_ROOT}"
46
57
  -B "${BUILD_ROOT}"
47
58
  -DCMAKE_BUILD_TYPE=Release
48
59
  -DBUILD_SHARED_LIBS=OFF
60
+ -DGGML_NATIVE=OFF
49
61
  -DWHISPER_BUILD_EXAMPLES=ON
50
62
  -DWHISPER_BUILD_SERVER=ON
51
63
  -DWHISPER_BUILD_TESTS=OFF
52
64
  -DWHISPER_SDL2=OFF
53
65
  )
54
66
 
67
+ if [[ "${TARGET_ARCH}" == "x64" ]]; then
68
+ CMAKE_ARGS+=(
69
+ -DGGML_AVX=ON
70
+ -DGGML_AVX2=ON
71
+ -DGGML_FMA=ON
72
+ -DGGML_F16C=ON
73
+ -DGGML_AVX512=OFF
74
+ -DGGML_AVX_VNNI=OFF
75
+ -DGGML_BMI2=OFF
76
+ )
77
+ fi
78
+
55
79
  case "${BACKEND}" in
56
80
  vulkan)
57
81
  CMAKE_ARGS+=(-DGGML_VULKAN=ON)
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "1.9.2",
2
+ "runtimeVersion": "1.9.3",
3
3
  "whisperCommit": "306c88f4d1286aec1bf96e544632897886af5501",
4
4
  "vulkanSdkVersion": "1.4.309.0",
5
5
  "platforms": [
@@ -240,8 +240,8 @@ export function requestAnthropicTools(tools, messages, opts) {
240
240
  // Test-only: expose the lowering so the steering-provenance test can assert
241
241
  // the API-key provider keeps steering-tagged user turns distinct (mirrors
242
242
  // anthropic-oauth._buildRequestBodyForCacheSmoke coverage).
243
- export function _toAnthropicMessagesForTest(messages) {
244
- return toAnthropicMessages(messages);
243
+ export function _toAnthropicMessagesForTest(messages, availableTools) {
244
+ return toAnthropicMessages(messages, availableTools);
245
245
  }
246
246
 
247
247
  // Applies cache_control markers to the FINAL, already-sanitized Anthropic
@@ -296,12 +296,16 @@ function buildRequestBody(messages, model, tools, sendOpts) {
296
296
  // Message-tail cache budget (4-BP layout + ANTHROPIC_MSG_SLOTS) is shared
297
297
  // with anthropic.mjs — see resolveAnthropicMessageCacheSlots.
298
298
  const messageCacheSlots = resolveAnthropicMessageCacheSlots(systemBlocks, ttls);
299
+ // Tools are resolved BEFORE the messages: the lowering needs the final
300
+ // tool list to drop tool_reference blocks whose tool no longer ships in
301
+ // this request (otherwise the API rejects the whole turn).
302
+ const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
299
303
  // Build → sanitize (once, inside toAnthropicMessages) → mark. Markers are
300
304
  // applied to the FINAL sanitized array by invariant, so block drops /
301
305
  // inserts / reorders performed by the sanitizer can never move or delete a
302
306
  // marked block. NEVER sanitize again after this (see send path).
303
307
  const anthropicMessages = applyAnthropicCacheMarkers(
304
- toAnthropicMessages(chatMsgs),
308
+ toAnthropicMessages(chatMsgs, requestTools),
305
309
  messageCacheSlots,
306
310
  );
307
311
 
@@ -317,7 +321,6 @@ function buildRequestBody(messages, model, tools, sendOpts) {
317
321
 
318
322
  if (systemBlocks.length) body.system = systemBlocks;
319
323
 
320
- const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
321
324
  if (requestTools.length) {
322
325
  // No cache_control on tools — the systemBase BP already covers the
323
326
  // tools prefix via Anthropic's prompt cache prefix semantics (order:
@@ -145,12 +145,16 @@ export class AnthropicProvider {
145
145
  // Message-tail cache budget (4-BP layout + ANTHROPIC_MSG_SLOTS) is
146
146
  // shared with anthropic-oauth — see resolveAnthropicMessageCacheSlots.
147
147
  const messageCacheSlots = resolveAnthropicMessageCacheSlots(systemBlocks, ttls);
148
+ // Tools are resolved BEFORE the messages: the lowering needs the final
149
+ // tool list to drop tool_reference blocks whose tool no longer ships
150
+ // in this request (otherwise the API rejects the whole turn).
151
+ const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
148
152
  // Build → sanitize (once, inside toAnthropicMessages) → mark. Markers
149
153
  // are applied to the FINAL sanitized array by invariant, so block
150
154
  // drops / inserts / reorders performed by the sanitizer can never move
151
155
  // or delete a marked block. NEVER sanitize again after this.
152
156
  const anthropicMessages = applyAnthropicCacheMarkers(
153
- toAnthropicMessages(chatMsgs),
157
+ toAnthropicMessages(chatMsgs, requestTools),
154
158
  messageCacheSlots,
155
159
  );
156
160
 
@@ -163,7 +167,6 @@ export class AnthropicProvider {
163
167
  applyAnthropicServerFallback(params, useModel, {
164
168
  enabled: this.config?.disableBetaHeaders !== true && opts.serverFallback !== false,
165
169
  });
166
- const requestTools = requestAnthropicTools(tools, chatMsgs, opts);
167
170
  if (requestTools.length) {
168
171
  // No cache_control on tools — the system BP covers tools via
169
172
  // Anthropic prefix semantics (order: tools → system → messages).
@@ -89,7 +89,39 @@ export function resolveAnthropicMessageCacheSlots(systemBlocks, ttls) {
89
89
  // root cause of the sporadic COLD-turn cache miss: pre-sanitize markers landed
90
90
  // on blocks the sanitizer then rewrote, so the provider-visible breakpoint
91
91
  // diverged from the cached one).
92
- export function toAnthropicMessages(messages) {
92
+ // Optional `availableTools`: the exact tool list this request will ship. A
93
+ // tool_reference whose tool is absent from that list makes the API reject the
94
+ // WHOLE request ("Tool reference '<name>' not found in available tools"), and
95
+ // the dangling block lives in the transcript — so once a loaded tool leaves
96
+ // the deferred catalog (feature toggle, provider/model rebuild, disconnected
97
+ // MCP server) every later turn of that session fails identically. Passing the
98
+ // list lets the lowering drop unresolvable references instead. Omitted =>
99
+ // every reference is kept verbatim.
100
+ function anthropicToolNameSet(availableTools) {
101
+ if (!Array.isArray(availableTools)) return null;
102
+ const names = new Set();
103
+ for (const tool of availableTools) {
104
+ const name = typeof tool?.name === 'string' ? tool.name.trim() : '';
105
+ if (name) names.add(name);
106
+ }
107
+ return names;
108
+ }
109
+
110
+ // Replacement content for a tool_result whose references were all dropped.
111
+ // The stored text result (the deferred-load summary) is the natural fallback;
112
+ // an EMPTY tool_result is itself a 400, so a blank result gets a placeholder.
113
+ function toolResultContentWithoutReferences(message, droppedNames) {
114
+ const normalized = normalizeContentForAnthropic(message.content);
115
+ if (typeof normalized === 'string' && normalized.trim()) return normalized;
116
+ if (Array.isArray(normalized) && normalized.length > 0) return normalized;
117
+ return [{
118
+ type: 'text',
119
+ text: `[tool references dropped - no longer available: ${droppedNames.join(', ')}]`,
120
+ }];
121
+ }
122
+
123
+ export function toAnthropicMessages(messages, availableTools) {
124
+ const availableNames = anthropicToolNameSet(availableTools);
93
125
  const result = [];
94
126
  for (let idx = 0; idx < messages.length; idx++) {
95
127
  const m = messages[idx];
@@ -136,12 +168,23 @@ export function toAnthropicMessages(messages) {
136
168
  && Array.isArray(native?.toolReferences)
137
169
  ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
138
170
  : [];
171
+ // Keep only references this request can actually back with a
172
+ // definition; a reference without one is a hard 400 on every turn
173
+ // that replays it (see anthropicToolNameSet).
174
+ const usableReferences = availableNames
175
+ ? references.filter((name) => availableNames.has(name))
176
+ : references;
177
+ const droppedReferences = usableReferences.length === references.length
178
+ ? []
179
+ : references.filter((name) => !usableReferences.includes(name));
139
180
  const block = {
140
181
  type: 'tool_result',
141
182
  tool_use_id: m.toolCallId || '',
142
- content: references.length
143
- ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
144
- : normalizeContentForAnthropic(m.content),
183
+ content: usableReferences.length
184
+ ? usableReferences.map((tool_name) => ({ type: 'tool_reference', tool_name }))
185
+ : (droppedReferences.length
186
+ ? toolResultContentWithoutReferences(m, droppedReferences)
187
+ : normalizeContentForAnthropic(m.content)),
145
188
  ...((m.toolKind === 'error' || m.isError === true) ? { is_error: true } : {}),
146
189
  };
147
190
  if (last?.role === 'user' && Array.isArray(last.content)) {
@@ -18,6 +18,10 @@ import {
18
18
  acknowledgePendingDeferredToolDelta,
19
19
  snapshotPendingDeferredToolDelta,
20
20
  } from '../../../../../session-runtime/deferred-tool-delta.mjs';
21
+ import {
22
+ acknowledgePendingGoalReminder,
23
+ snapshotPendingGoalReminder,
24
+ } from '../../../../../session-runtime/goal-reminder.mjs';
21
25
  import { recordStandaloneStatusTelemetry } from './status-telemetry.mjs';
22
26
  import { normalizeStaleCompactingStage } from './compaction-runner.mjs';
23
27
  import { resolveSessionContextMeta, positiveContextWindow } from './context-meta.mjs';
@@ -276,6 +280,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
276
280
  let _pwstTurnDrained = null;
277
281
  let _turnPendingEntries = [];
278
282
  let _turnDeferredToolDelta = null;
283
+ let _turnGoalReminder = null;
279
284
  // After the first turn, the next prompt comes from the drained queue.
280
285
  // (On the first iteration _pendingTail is empty and `prompt` is the
281
286
  // caller's original message.)
@@ -589,11 +594,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
589
594
  }
590
595
  const _currentTimeBlock = buildCurrentTimeBlock(prompt);
591
596
  _turnDeferredToolDelta = snapshotPendingDeferredToolDelta(session);
597
+ // Event-driven Goal state: present only after compaction dropped the
598
+ // Goal's own tool results, never on an ordinary turn.
599
+ _turnGoalReminder = snapshotPendingGoalReminder(session);
592
600
  const _turnReminderBlock = [
593
601
  _currentTimeBlock
594
602
  ? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
595
603
  : '',
596
604
  _turnDeferredToolDelta?.content || '',
605
+ _turnGoalReminder?.content || '',
597
606
  ].filter(Boolean).join('\n\n');
598
607
  const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
599
608
  const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnReminderBlock);
@@ -1067,6 +1076,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1067
1076
  if (_turnDeferredToolDelta) {
1068
1077
  acknowledgePendingDeferredToolDelta(session, _turnDeferredToolDelta.revision);
1069
1078
  }
1079
+ if (_turnGoalReminder) {
1080
+ acknowledgePendingGoalReminder(session, _turnGoalReminder.revision);
1081
+ }
1070
1082
  const terminalResultPreview = {
1071
1083
  ...result,
1072
1084
  trimmed: messagesDropped > 0,
@@ -36,6 +36,7 @@ import { traceAgentCompact, messagePrefixHash } from '../agent-trace.mjs';
36
36
  import { invalidateProviderRequestToolsScope } from '../../../../session-runtime/provider-request-tools.mjs';
37
37
  import { bumpUsageMetricsEpoch } from './manager.mjs';
38
38
  import { resetReadStateAfterCompaction } from './read-dedup.mjs';
39
+ import { markPendingGoalReminder } from '../../../../session-runtime/goal-reminder.mjs';
39
40
 
40
41
  const RECOVERED_ERROR_MESSAGE_MAX_CHARS = 300;
41
42
  const ANSI_ESCAPE_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;
@@ -590,6 +591,12 @@ export async function runPreSendCompactPass(state) {
590
591
  catch { /* best-effort: PostCompact hook must never break the loop */ }
591
592
  }
592
593
  }
594
+ // Compaction drops the Goal's own tool results, so the durable task
595
+ // snapshot vanishes mid-objective and the model keeps working
596
+ // without its checklist. Mark one tail-injected state reminder for
597
+ // the next turn; it clears when that turn is accepted.
598
+ try { markPendingGoalReminder(sessionRef, 'compaction'); }
599
+ catch { /* best-effort: a Goal reminder must never break compaction */ }
593
600
  }
594
601
  return {
595
602
  iterations,
@@ -157,7 +157,10 @@ export function _clearLiveSession(id) {
157
157
  // in the persisted JSON) stay resident for this long after their last use so
158
158
  // multi-turn image recognition keeps working across an idle gap. Beyond the
159
159
  // TTL the memory cost wins and the snapshot is reclaimed like any other.
160
- export const LIVE_MEDIA_RETENTION_MS = 60 * 60 * 1000; // 1h
160
+ export const LIVE_MEDIA_RETENTION_MS = Math.max(
161
+ 60_000,
162
+ Number(process.env.MIXDOG_LIVE_MEDIA_RETENTION_MS) || 10 * 60 * 1000,
163
+ ); // 10m default — raw image bytes are the most expensive thing we retain
161
164
 
162
165
  export function _messagesCarryLiveMedia(messages) {
163
166
  if (!Array.isArray(messages)) return false;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.9.2",
2
+ "version": "1.9.3",
3
3
  "source": "ggml-org/whisper.cpp@306c88f4d1286aec1bf96e544632897886af5501",
4
4
  "model": {
5
5
  "id": "large-v3-turbo-q8_0",
@@ -64,9 +64,9 @@
64
64
  {
65
65
  "id": "vulkan",
66
66
  "requires": null,
67
- "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.2/whisper-server-win32-x64-vulkan.zip",
68
- "sha256": "6ee3718696033ddbc156201eb0c097b778d8aca7388233b54fd3a966cbcb9fee",
69
- "size": 16751300,
67
+ "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.3/whisper-server-win32-x64-vulkan.zip",
68
+ "sha256": "a80997f6841decf08421ab261084fa365ba879932221ba53de98bc3710c9dced",
69
+ "size": 16733128,
70
70
  "format": "zip",
71
71
  "executable": "whisper-server.exe"
72
72
  }
@@ -77,9 +77,9 @@
77
77
  {
78
78
  "id": "vulkan",
79
79
  "requires": null,
80
- "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.2/whisper-server-linux-x64-vulkan.zip",
81
- "sha256": "c3cbf695ebc818546a8ef4d3657ef74378781948b8ff62e7f2bf1da70ccdc639",
82
- "size": 14461318,
80
+ "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.3/whisper-server-linux-x64-vulkan.zip",
81
+ "sha256": "b4614d11e13f383be33a278003d66401e6a5a94024e9124ea9ada615177d4897",
82
+ "size": 14333249,
83
83
  "format": "zip",
84
84
  "executable": "whisper-server"
85
85
  }
@@ -90,9 +90,9 @@
90
90
  {
91
91
  "id": "vulkan",
92
92
  "requires": null,
93
- "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.2/whisper-server-linux-arm64-vulkan.zip",
94
- "sha256": "73fc8ac7fdbc1e68ebb4a2c27faaf3c1145fcf7f4b0f04f32fdeb26bc66b20eb",
95
- "size": 14274639,
93
+ "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.3/whisper-server-linux-arm64-vulkan.zip",
94
+ "sha256": "b267e322e66df8d46b2fccd3812d4dcba57de9781baf40ebecb9fe43c477b10b",
95
+ "size": 14229402,
96
96
  "format": "zip",
97
97
  "executable": "whisper-server"
98
98
  }
@@ -103,8 +103,8 @@
103
103
  {
104
104
  "id": "metal",
105
105
  "requires": null,
106
- "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.2/whisper-server-darwin-arm64-metal.zip",
107
- "sha256": "2ebd9eec5b991716c230f9d4dace8db952f99f0faf8ca1ef3b3a5b4a1b106401",
106
+ "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.3/whisper-server-darwin-arm64-metal.zip",
107
+ "sha256": "6523f7acf18924e87ab80e7c712d76e8558cc09215df98da703b9102c0f5650b",
108
108
  "size": 1350283,
109
109
  "format": "zip",
110
110
  "executable": "whisper-server"
@@ -116,15 +116,15 @@
116
116
  {
117
117
  "id": "cpu",
118
118
  "requires": null,
119
- "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.2/whisper-server-darwin-x64-cpu.zip",
120
- "sha256": "4d39a13874c380e28ae25ff922673bf8869e6e756f49e866a67e7a86ec8088a5",
121
- "size": 1346377,
119
+ "url": "https://github.com/tribgames/mixdog/releases/download/voice-runtime-v1.9.3/whisper-server-darwin-x64-cpu.zip",
120
+ "sha256": "3d76426a0c7ec718b33ff54521a1d29ed09e4ed00ac9e8075f81d6bbe77a5d14",
121
+ "size": 1333240,
122
122
  "format": "zip",
123
123
  "executable": "whisper-server"
124
124
  }
125
125
  ]
126
126
  }
127
127
  },
128
- "release_tag": "voice-runtime-v1.9.2",
129
- "build_hash": "3705f8c9636467bb1ff9722b409dcc888bd7271e3774f13b0bc3ec64cc3d2822"
128
+ "release_tag": "voice-runtime-v1.9.3",
129
+ "build_hash": "d9a066477feb39afd5bc51cf50453d3ce1a194be5de907357ed7f9c1e9677044"
130
130
  }
@@ -551,6 +551,33 @@ export async function ensureWhisperRuntime(dataDir, onProgress = null) {
551
551
  })
552
552
  }
553
553
 
554
+ // Version-drift guard. `active-version` holds `whisper-<version>-<variantId>`,
555
+ // so an installed runtime falls BEHIND whenever the bundled manifest moves on
556
+ // (observed: a 754 MB cuBLAS-11 bundle superseded by a 17 MB Vulkan build that
557
+ // keeps GPU acceleration). The legacy executable fallback in
558
+ // resolveManagedWhisperCmd still resolves the old layout, which is exactly
559
+ // what made the drift permanent — every caller saw `installed: true`, so the
560
+ // upgrade path never ran and the superseded bytes stayed on disk forever.
561
+ //
562
+ // Reported separately from `installed` on purpose: the old runtime still WORKS,
563
+ // so the transcribe hot path must keep using it (offline, mid-install), while
564
+ // the install path treats this as "re-fetch".
565
+ export function isManagedWhisperStale(dataDir) {
566
+ const activeFile = join(dataDir, 'voice-runtime', 'active-version')
567
+ if (!existsSync(activeFile)) return false
568
+ const activeName = readFileSync(activeFile, 'utf8').trim()
569
+ if (!activeName) return false
570
+ if (!existsSync(BUNDLED_MANIFEST_PATH)) return false
571
+ try {
572
+ const manifest = JSON.parse(readFileSync(BUNDLED_MANIFEST_PATH, 'utf8'))
573
+ if (!manifest?.version) return false
574
+ return !activeName.startsWith(`whisper-${manifest.version}-`)
575
+ } catch {
576
+ // An unreadable manifest is not evidence of staleness.
577
+ return false
578
+ }
579
+ }
580
+
554
581
  // Read-only resolver: returns the cached binary path when the managed runtime
555
582
  // is fully installed, null otherwise. Used by the transcribe hot path and the
556
583
  // /cli-check endpoint to test installation state without triggering a fetch.
@@ -747,6 +774,9 @@ export function resolveVoiceRuntime(dataDir, { modelId = 'standard' } = {}) {
747
774
  kind: 'managed-whisper.cpp',
748
775
  label: 'whisper.cpp',
749
776
  installed: !!(managedWhisperCmd && serverCmd && managedModelPath && managedFfmpegPath),
777
+ // Installed, but from an older manifest version — usable now, upgradeable
778
+ // by the install path. See isManagedWhisperStale.
779
+ stale: isManagedWhisperStale(dataDir),
750
780
  binary: !!managedWhisperCmd,
751
781
  model: !!managedModelPath,
752
782
  ffmpeg: !!managedFfmpegPath,
@@ -759,6 +789,35 @@ export function resolveVoiceRuntime(dataDir, { modelId = 'standard' } = {}) {
759
789
  }
760
790
  }
761
791
 
792
+ /**
793
+ * Uninstall counterpart to ensureWhisperRuntime + ensureWhisperModel.
794
+ *
795
+ * Voice used to be install-only: turning it OFF flipped a config flag and left
796
+ * the whisper runtime and model weights (1.5 GB+ on a machine that picked a
797
+ * GPU variant) resident forever, so "off" was never actually cold. Removes
798
+ * both; re-enabling re-fetches them through the normal ensure* path.
799
+ *
800
+ * The managed ffmpeg runtime is deliberately KEPT — it is shared with non-voice
801
+ * media paths and is two orders of magnitude smaller.
802
+ *
803
+ * @returns {string[]} removed directory labels (empty when nothing was present)
804
+ */
805
+ export function removeManagedVoiceRuntime(dataDir) {
806
+ const removed = []
807
+ for (const parts of [['voice-runtime'], ['voice', 'models']]) {
808
+ const target = join(dataDir, ...parts)
809
+ if (!existsSync(target)) continue
810
+ try {
811
+ rmSync(target, { recursive: true, force: true })
812
+ removed.push(parts.join('/'))
813
+ } catch (err) {
814
+ // A locked file (transcribe in flight) must not fail the toggle itself.
815
+ process.stderr.write(`[voice-runtime] uninstall skipped ${parts.join('/')}: ${err?.message || err}\n`)
816
+ }
817
+ }
818
+ return removed
819
+ }
820
+
762
821
  // Single managed location for the whisper model weight file. Idempotent: if
763
822
  // the resolved file exists and matches the manifest sha256, return without
764
823
  // re-downloading. Atomic install via stage-then-rename so a partial download
@@ -326,6 +326,71 @@ async function postSaveReleaseGate() {
326
326
  };
327
327
  }
328
328
 
329
+ async function contentAwareCompositionGate() {
330
+ const content = {
331
+ packageId: 'composition-gate',
332
+ audience: 'executive committee',
333
+ objective: 'approve the operating plan',
334
+ decision: 'approve the plan',
335
+ facts: [{ id: 'revenue', label: 'Revenue', value: 120 }],
336
+ claims: [{ id: 'decision', text: 'Approve the plan', factIds: ['revenue'] }],
337
+ };
338
+ const operation = {
339
+ op: 'compose_document',
340
+ title: 'Decision brief',
341
+ summary: 'Approve the plan.',
342
+ sections: [{
343
+ heading: 'Evidence',
344
+ paragraphs: ['Revenue supports the decision.'],
345
+ table: [['Metric', 'Value'], ['Revenue', 120]],
346
+ }],
347
+ };
348
+ const first = expandOfficeDesignOperations({
349
+ format: 'docx',
350
+ backend: 'microsoft-office-com',
351
+ created: true,
352
+ design: { purpose: 'decide', expressionMode: 'strong-fit', content },
353
+ operations: [operation],
354
+ });
355
+ const firstId = first.semantic[0].composition.id;
356
+ const second = expandOfficeDesignOperations({
357
+ format: 'docx',
358
+ backend: 'microsoft-office-com',
359
+ created: true,
360
+ design: { purpose: 'decide', expressionMode: 'strong-fit', content },
361
+ library: {
362
+ source: 'mixdog-starter',
363
+ recentCompositions: [{
364
+ fingerprint: first.composition.fingerprint,
365
+ format: 'docx',
366
+ compositionIds: [firstId],
367
+ }],
368
+ },
369
+ operations: [operation],
370
+ });
371
+ const gate = evaluateOfficeSubmissionGate({
372
+ persisted: true,
373
+ issues: [{
374
+ severity: 'warning',
375
+ code: 'recent_composition_repeat',
376
+ path: '/',
377
+ message: 'same sequence',
378
+ }],
379
+ });
380
+ return {
381
+ category: 'content-aware-composition-gate',
382
+ passed: firstId !== second.semantic[0].composition.id
383
+ && second.semantic[0].composition.historyPenalty === 0
384
+ && !gate.ok
385
+ && gate.blocking[0].severity === 'error',
386
+ evidence: {
387
+ first: firstId,
388
+ second: second.semantic[0].composition.id,
389
+ gate,
390
+ },
391
+ };
392
+ }
393
+
329
394
  export async function runOfficeAssuranceBenchmark() {
330
395
  const cases = await Promise.all([
331
396
  spreadsheetBenchMini(),
@@ -337,10 +402,11 @@ export async function runOfficeAssuranceBenchmark() {
337
402
  contentModelCrossApp(),
338
403
  semanticDeliverableQuality(),
339
404
  postSaveReleaseGate(),
405
+ contentAwareCompositionGate(),
340
406
  ]);
341
407
  const passed = cases.filter((entry) => entry.passed).length;
342
408
  return {
343
- version: 2,
409
+ version: 3,
344
410
  createdAt: new Date().toISOString(),
345
411
  measurementKind: 'deterministic-office-assurance',
346
412
  categories: cases.length,
@@ -354,21 +354,22 @@ function headingLevel(paragraph) {
354
354
  }
355
355
 
356
356
  function wordBlockOrder(document) {
357
- if (Array.isArray(document?.blockOrder) && document.blockOrder.length) return document.blockOrder;
358
- const blocks = [
359
- ...(document?.paragraphs || []).map((entry) => ({
360
- type: 'paragraph',
361
- index: entry.index,
362
- path: entry.path,
363
- start: Number(entry.start),
364
- })),
365
- ...(document?.tables || []).map((entry) => ({
366
- type: 'table',
367
- index: entry.index,
368
- path: entry.path,
369
- start: Number(entry.start),
370
- })),
371
- ];
357
+ const blocks = Array.isArray(document?.blockOrder) && document.blockOrder.length
358
+ ? document.blockOrder.map((entry) => ({ ...entry, start: Number(entry.start) }))
359
+ : [
360
+ ...(document?.paragraphs || []).map((entry) => ({
361
+ type: 'paragraph',
362
+ index: entry.index,
363
+ path: entry.path,
364
+ start: Number(entry.start),
365
+ })),
366
+ ...(document?.tables || []).map((entry) => ({
367
+ type: 'table',
368
+ index: entry.index,
369
+ path: entry.path,
370
+ start: Number(entry.start),
371
+ })),
372
+ ];
372
373
  if (blocks.every((entry) => Number.isFinite(entry.start))) {
373
374
  blocks.sort((left, right) => left.start - right.start);
374
375
  }
@@ -379,7 +380,9 @@ function reviewDocxStructure(document) {
379
380
  const issues = [];
380
381
  const paragraphs = Array.isArray(document?.paragraphs) ? document.paragraphs : [];
381
382
  const tables = Array.isArray(document?.tables) ? document.tables : [];
382
- const content = paragraphs.filter((paragraph) => String(paragraph.text || '').trim());
383
+ const content = paragraphs.filter((paragraph) => (
384
+ paragraph?.inTable !== true && String(paragraph.text || '').trim()
385
+ ));
383
386
  const headings = content
384
387
  .map((paragraph) => ({ paragraph, level: headingLevel(paragraph) }))
385
388
  .filter((entry) => entry.level !== null);
@@ -408,7 +411,11 @@ function reviewDocxStructure(document) {
408
411
  const block = order[index];
409
412
  if (block.type !== 'paragraph') continue;
410
413
  const paragraph = paragraphsByIndex.get(Number(block.index));
411
- if (headingLevel(paragraph) === null || !String(paragraph?.text || '').trim()) continue;
414
+ if (
415
+ paragraph?.inTable === true
416
+ || headingLevel(paragraph) === null
417
+ || !String(paragraph?.text || '').trim()
418
+ ) continue;
412
419
  const nextBlock = order.slice(index + 1).find((entry) => {
413
420
  if (entry.type === 'table') return true;
414
421
  return String(paragraphsByIndex.get(Number(entry.index))?.text || '').trim();