mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.55",
3
+ "version": "0.9.59",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -70,6 +70,7 @@
70
70
  "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
71
71
  "test:tui-input-render": "node --test scripts/prompt-immediate-render-test.mjs",
72
72
  "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs",
73
+ "test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
73
74
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --test scripts/verify-release-assets-test.mjs",
74
75
  "test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && node --test scripts/tui-transcript-perf-test.mjs",
75
76
  "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
@@ -117,6 +118,7 @@
117
118
  "react": "^19.2.7",
118
119
  "string-width": "^8.2.1",
119
120
  "strip-ansi": "^7.2.0",
121
+ "tiktoken": "^1.0.22",
120
122
  "undici": "^8.5.0",
121
123
  "wrap-ansi": "^10.0.0",
122
124
  "ws": "^8.21.0",
@@ -0,0 +1,7 @@
1
+ const wd = setTimeout(() => {
2
+ console.error('=== WATCHDOG 45s: active resources:', JSON.stringify(process.getActiveResourcesInfo()));
3
+ process.exit(99);
4
+ }, 45000);
5
+ await import('./compact-smoke.mjs');
6
+ clearTimeout(wd);
7
+ console.error('=== script import completed normally');
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import {
@@ -41,7 +41,12 @@ try {
41
41
  writeFileSync(join(root, 'mixdog-config.json'), JSON.stringify({
42
42
  agent: { autoClear: overrideConfig.autoClear },
43
43
  }));
44
- const { sweepStaleSessions } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
44
+ const {
45
+ deleteSession,
46
+ markSessionClosed,
47
+ saveSession,
48
+ sweepStaleSessions,
49
+ } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
45
50
  const old = Date.now() - 181_000;
46
51
  const known = {
47
52
  id: 'sess_known_reap',
@@ -83,6 +88,126 @@ try {
83
88
  assert.ok(defaultSweep.details.some((detail) => detail.id === known.id), 'store reaps a listed provider at its Advanced duration');
84
89
  assert.ok(defaultSweep.details.some((detail) => detail.id === unknown.id), 'store reaps an unlisted provider at the default duration');
85
90
 
91
+ const locallyLive = {
92
+ ...known,
93
+ id: 'sess_locally_live_keep',
94
+ };
95
+ writeFileSync(join(root, 'sessions', `${locallyLive.id}.json`), JSON.stringify(locallyLive));
96
+ utimesSync(join(root, 'sessions', `${locallyLive.id}.json`), old / 1000, old / 1000);
97
+ const protectedSweep = sweepStaleSessions({
98
+ retainOpenSessions: false,
99
+ isSessionLive: (id) => id === locallyLive.id,
100
+ });
101
+ assert.ok(!protectedSweep.details.some((detail) => detail.id === locallyLive.id), 'store does not reap a locally live stale session');
102
+ assert.notEqual(JSON.parse(readFileSync(join(root, 'sessions', `${locallyLive.id}.json`), 'utf8')).closed, true);
103
+ const settledSweep = sweepStaleSessions({ retainOpenSessions: false });
104
+ assert.ok(settledSweep.details.some((detail) => detail.id === locallyLive.id), 'store reaps the session once local work settles');
105
+
106
+ const heartbeatRace = {
107
+ ...known,
108
+ id: 'sess_heartbeat_race_keep',
109
+ };
110
+ const heartbeatRacePath = join(root, 'sessions', `${heartbeatRace.id}.json`);
111
+ writeFileSync(heartbeatRacePath, JSON.stringify(heartbeatRace));
112
+ utimesSync(heartbeatRacePath, old / 1000, old / 1000);
113
+ let livenessChecks = 0;
114
+ const heartbeatRaceSweep = sweepStaleSessions({
115
+ retainOpenSessions: false,
116
+ isSessionLive: (id) => {
117
+ if (id !== heartbeatRace.id) return false;
118
+ livenessChecks++;
119
+ if (livenessChecks === 3) {
120
+ writeFileSync(join(root, 'sessions', `${id}.hb`), '');
121
+ }
122
+ return false;
123
+ },
124
+ });
125
+ assert.ok(livenessChecks >= 3, 'store performs the final runtime probe before taking the close lock');
126
+ assert.ok(!heartbeatRaceSweep.details.some((detail) => detail.id === heartbeatRace.id), 'heartbeat landing before the locked re-stat vetoes close');
127
+ assert.notEqual(JSON.parse(readFileSync(heartbeatRacePath, 'utf8')).closed, true);
128
+
129
+ const retentionHeartbeat = {
130
+ ...known,
131
+ id: 'sess_retention_heartbeat_keep',
132
+ createdAt: Date.now() - 1_000,
133
+ updatedAt: Date.now() - 1_000,
134
+ };
135
+ const retentionPath = join(root, 'sessions', `${retentionHeartbeat.id}.json`);
136
+ writeFileSync(retentionPath, JSON.stringify(retentionHeartbeat));
137
+ let retentionLivenessChecks = 0;
138
+ const retentionSweep = sweepStaleSessions({
139
+ ttlMs: 60_000,
140
+ openMaxAgeMs: 24 * 60 * 60 * 1000,
141
+ openMaxCount: 0,
142
+ isSessionLive: (id) => {
143
+ if (id !== retentionHeartbeat.id) return false;
144
+ retentionLivenessChecks++;
145
+ if (retentionLivenessChecks === 2) {
146
+ writeFileSync(join(root, 'sessions', `${id}.hb`), '');
147
+ }
148
+ return false;
149
+ },
150
+ });
151
+ assert.ok(retentionLivenessChecks >= 2, 'retention performs its runtime probe before taking the delete lock');
152
+ assert.ok(!retentionSweep.openPrunedDetails.some((detail) => detail.id === retentionHeartbeat.id), 'commit-edge heartbeat vetoes retention hard-delete');
153
+ assert.ok(existsSync(retentionPath), 'heartbeating retention candidate survives');
154
+
155
+ const pendingClose = { ...known, id: 'sess_vetoed_close_pending', messages: [] };
156
+ const pendingDelete = { ...known, id: 'sess_vetoed_delete_pending', messages: [] };
157
+ for (const session of [pendingClose, pendingDelete]) {
158
+ writeFileSync(join(root, 'sessions', `${session.id}.json`), JSON.stringify(session));
159
+ saveSession({ ...session, messages: [{ role: 'user', content: 'pending save survived' }] });
160
+ }
161
+ assert.equal(markSessionClosed(pendingClose.id, 'idle-sweep', { isSessionLive: () => true }), null);
162
+ assert.equal(deleteSession(pendingDelete.id, { isSessionLive: () => true }), false);
163
+ await new Promise((resolve) => setTimeout(resolve, 250));
164
+ for (const session of [pendingClose, pendingDelete]) {
165
+ const saved = JSON.parse(readFileSync(join(root, 'sessions', `${session.id}.json`), 'utf8'));
166
+ assert.equal(saved.messages[0]?.content, 'pending save survived', 'veto leaves debounce persistence intact');
167
+ assert.notEqual(saved.closed, true);
168
+ }
169
+
170
+ const {
171
+ _clearSessionRuntime,
172
+ _getRuntimeEntry,
173
+ markSessionAskStart,
174
+ } = await import('../src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs');
175
+ const {
176
+ _finalizeSweptSessionRuntime,
177
+ _runCleanupCycle,
178
+ } = await import('../src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs');
179
+
180
+ const busy = {
181
+ ...known,
182
+ id: 'sess_unrelated_busy_keep',
183
+ status: 'running',
184
+ };
185
+ const terminalDuringBusy = {
186
+ ...known,
187
+ id: 'sess_terminal_during_busy_reap',
188
+ };
189
+ for (const session of [busy, terminalDuringBusy]) {
190
+ const path = join(root, 'sessions', `${session.id}.json`);
191
+ writeFileSync(path, JSON.stringify(session));
192
+ utimesSync(path, old / 1000, old / 1000);
193
+ }
194
+ markSessionAskStart(busy.id);
195
+ const busyEntry = _getRuntimeEntry(busy.id);
196
+ busyEntry.controller = new AbortController();
197
+ _runCleanupCycle();
198
+ assert.notEqual(JSON.parse(readFileSync(join(root, 'sessions', `${busy.id}.json`), 'utf8')).closed, true, 'unrelated busy runtime survives idle cleanup');
199
+ assert.equal(JSON.parse(readFileSync(join(root, 'sessions', `${terminalDuringBusy.id}.json`), 'utf8')).closed, true, 'terminal session is reaped while unrelated runtime is busy');
200
+
201
+ const postScanRaceId = 'sess_post_scan_active_veto';
202
+ markSessionAskStart(postScanRaceId);
203
+ const postScanEntry = _getRuntimeEntry(postScanRaceId);
204
+ postScanEntry.controller = new AbortController();
205
+ assert.equal(_finalizeSweptSessionRuntime({ id: postScanRaceId }), false, 'post-scan activity vetoes runtime cleanup');
206
+ assert.equal(postScanEntry.controller.signal.aborted, false, 'post-scan controller is not aborted');
207
+ assert.equal(_getRuntimeEntry(postScanRaceId), postScanEntry, 'post-scan runtime remains owned');
208
+ _clearSessionRuntime(busy.id);
209
+ _clearSessionRuntime(postScanRaceId);
210
+
86
211
  const dataDir = join(root, 'worker-index');
87
212
  mkdirSync(dataDir, { recursive: true });
88
213
  writeFileSync(join(dataDir, 'agent-workers.json'), JSON.stringify({
@@ -0,0 +1,88 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { recallFastTrackCompactMessages, semanticCompactMessages } from '../src/runtime/agent/orchestrator/session/compact.mjs';
6
+ import {
7
+ buildPostCompactFileAttachment,
8
+ MAX_REATTACH_FILES,
9
+ REATTACH_MAX_TOTAL_TOKENS,
10
+ } from '../src/runtime/agent/orchestrator/session/compact/file-reattach.mjs';
11
+ import { estimateTokens, sanitizeToolPairs } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
12
+
13
+ const dir = mkdtempSync(join(tmpdir(), 'reattach-'));
14
+ const fileA = join(dir, 'a.mjs'); writeFileSync(fileA, 'export const A = 1;\n'.repeat(50));
15
+ const fileB = join(dir, 'b.mjs'); writeFileSync(fileB, 'export const B = 2;\n'.repeat(50));
16
+ const fileHuge = join(dir, 'huge.txt'); writeFileSync(fileHuge, 'h'.repeat(600 * 1024)); // > 512KB cap
17
+ const budgetFiles = Array.from({ length: 5 }, (_, i) => {
18
+ const p = join(dir, `budget-${i}.mjs`);
19
+ writeFileSync(p, `export const budget${i} = "${'token payload '.repeat(4000)}";\n`);
20
+ return p;
21
+ });
22
+
23
+ const readCall = (id, p) => ({ role: 'assistant', content: '', toolCalls: [{ id, name: 'read', arguments: JSON.stringify({ path: p }) }] });
24
+ const toolRes = (id) => ({ role: 'tool', toolCallId: id, content: 'old cached body '.repeat(100) });
25
+
26
+ function transcript() {
27
+ const msgs = [{ role: 'system', content: 'rules' }];
28
+ msgs.push({ role: 'user', content: 'fix bug in a.mjs' });
29
+ msgs.push(readCall('c1', fileA), toolRes('c1'));
30
+ msgs.push(readCall('c2', fileHuge), toolRes('c2'));
31
+ msgs.push(readCall('c3', join(dir, 'missing.mjs')), toolRes('c3'));
32
+ for (let i = 0; i < 12; i++) { msgs.push({ role: 'user', content: `iterate ${i} ` + 'pad '.repeat(300) }, { role: 'assistant', content: `ok ${i}` }); }
33
+ // newest turn reads fileB — must survive in tail and be skipped
34
+ msgs.push({ role: 'user', content: 'now check b.mjs' });
35
+ msgs.push(readCall('c9', fileB), toolRes('c9'));
36
+ msgs.push({ role: 'assistant', content: 'checked' });
37
+ return msgs;
38
+ }
39
+
40
+ // 1) fasttrack: A re-attached, B (in tail) skipped, huge+missing skipped
41
+ {
42
+ const r = recallFastTrackCompactMessages(transcript(), 4000, { force: true, recallText: 'digest', allowEmptyRecall: true, tailTurns: 2, keepTokens: 2000, cwd: dir });
43
+ const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
44
+ assert.ok(ref, 'fasttrack: reference-files message injected');
45
+ assert.ok(ref.content.includes(fileA), 'fileA re-attached');
46
+ assert.ok(ref.content.includes('export const A'), 'fileA fresh content present');
47
+ assert.ok(!ref.content.includes(fileHuge), 'huge file skipped');
48
+ assert.ok(!ref.content.includes('missing.mjs'), 'missing file skipped');
49
+ assert.ok(!ref.content.includes(fileB) || !ref.content.includes('export const B'), 'tail-surviving fileB not re-attached');
50
+ const refIdx = r.messages.indexOf(ref);
51
+ assert.equal(r.messages[refIdx + 1]?.role, 'assistant', 'ack follows reference message');
52
+ assert.equal(JSON.stringify(sanitizeToolPairs(r.messages)), JSON.stringify(r.messages), 'pairing valid');
53
+ assert.equal(r.diagnostics.fileReattached, true, 'diagnostics flag set');
54
+ }
55
+ // 2) semantic path with fake provider
56
+ {
57
+ const provider = { name: 'fake', async send() { return { content: '## Goal\n- g\n\n## Constraints & Preferences\n- (none)\n\n## Progress\n### Done\n- d\n\n### In Progress\n- (none)\n\n### Blocked\n- (none)\n\n## Key Decisions\n- (none)\n\n## Next Steps\n- n\n\n## Critical Context\n- c\n\n## Relevant Files\n- a.mjs' }; } };
58
+ const r = await semanticCompactMessages(provider, transcript(), 'fake-model', 4000, { force: true, tailTurns: 1, cwd: dir });
59
+ const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
60
+ assert.ok(ref, 'semantic: reference-files message injected');
61
+ assert.ok(ref.content.includes('export const A'), 'semantic: fileA fresh content');
62
+ assert.equal(r.diagnostics.fileReattached, true, 'semantic diagnostics flag');
63
+ }
64
+ // 3) no room -> no injection, still valid compact
65
+ {
66
+ const r = recallFastTrackCompactMessages(transcript(), 1500, { force: true, recallText: 'digest', allowEmptyRecall: true, tailTurns: 1, keepTokens: 1200, cwd: dir });
67
+ assert.ok(Array.isArray(r.messages), 'tight budget compact still succeeds');
68
+ const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
69
+ if (ref) assert.ok(r.diagnostics.finalTokens <= r.diagnostics.budgetTokens, 'reattach never exceeds budget');
70
+ }
71
+ // 4) env off-switch
72
+ {
73
+ process.env.MIXDOG_COMPACT_FILE_REATTACH = '0';
74
+ const off = buildPostCompactFileAttachment([readCall('x', fileA)], [], 10000, { cwd: dir });
75
+ assert.equal(off, null, 'env kill-switch disables reattach');
76
+ delete process.env.MIXDOG_COMPACT_FILE_REATTACH;
77
+ }
78
+ // 5) generous context room is still bounded by the compact re-attach envelope
79
+ {
80
+ const calls = budgetFiles.map((p, i) => readCall(`budget-${i}`, p));
81
+ const ref = buildPostCompactFileAttachment(calls, [], 50_000, { cwd: dir });
82
+ assert.ok(ref, 'bounded attachment should include at least one recent file');
83
+ assert.ok((ref.content.match(/^### /gm) || []).length <= MAX_REATTACH_FILES, 'attachment file count must stay capped');
84
+ assert.ok(estimateTokens(ref.content) <= REATTACH_MAX_TOTAL_TOKENS, 'attachment total tokens must stay capped');
85
+ }
86
+ rmSync(dir, { recursive: true, force: true });
87
+ console.log('compact file-reattach test passed \u2713');
88
+
@@ -1,11 +1,13 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { createRequire } from 'node:module';
3
4
  import {
4
5
  estimateMessagesTokens,
5
6
  estimateRequestReserveTokens,
6
7
  estimateToolSchemaTokens,
7
8
  estimateTokens,
8
9
  IMAGE_VISUAL_TOKEN_ALLOWANCE,
10
+ providerTokenCalibration,
9
11
  summarizeContextMessages,
10
12
  } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
11
13
  import { createContextStatus } from '../src/session-runtime/context-status.mjs';
@@ -176,9 +178,10 @@ test('degenerate main budgets use a documented single-shot fallback', () => {
176
178
 
177
179
  const oneToken = { contextWindow: 1, compaction: {}, tools: [] };
178
180
  const oneTokenPolicy = policyFor(oneToken);
179
- assert.ok(oneTokenPolicy.reserveTokens >= oneTokenPolicy.triggerTokens);
180
- assert.equal(oneTokenPolicy.singleShot, true,
181
- 'the request reserve, not the one-token boundary itself, makes this degenerate');
181
+ assert.equal(oneTokenPolicy.reserveTokens, 0,
182
+ 'an empty request has no synthetic fixed reserve');
183
+ assert.equal(oneTokenPolicy.singleShot, false,
184
+ 'the one-token boundary alone does not require a single-shot fallback');
182
185
  assert.equal(oneTokenPolicy.triggerTokens, 1);
183
186
  assert.equal(compactTargetBudget(oneTokenPolicy), 1);
184
187
 
@@ -280,21 +283,32 @@ test('image payload bytes do not inflate live gauge or fallback compaction estim
280
283
  'estimating live context must not sanitize or remove image content');
281
284
  });
282
285
 
283
- test('dense ASCII, encoded, and minified payloads do not retain the prose chars/4 estimate', () => {
284
- for (const text of [
285
- 'A1b2C3d4E5f6G7h8'.repeat(100),
286
- '%7B%22path%22%3A%22very%2Flong%2Fencoded%2Fvalue%22%7D'.repeat(40),
287
- JSON.stringify({ rows: Array.from({ length: 100 }, (_, i) => ({ id: i, value: `item_${i}_abcdef` })) }),
288
- ]) {
289
- assert.ok(estimateTokens(text) >= Math.ceil(text.length * 0.7),
290
- `dense ASCII estimate must be conservative (${estimateTokens(text)}/${text.length})`);
286
+ test('token estimates match the real o200k BPE tokenizer at the default multiplier', () => {
287
+ const requireForTest = createRequire(import.meta.url);
288
+ const { Tiktoken } = requireForTest('tiktoken/lite');
289
+ const o200k = requireForTest('tiktoken/encoders/o200k_base.json');
290
+ const enc = new Tiktoken(o200k.bpe_ranks, o200k.special_tokens, o200k.pat_str);
291
+ try {
292
+ for (const text of [
293
+ 'A1b2C3d4E5f6G7h8'.repeat(100),
294
+ '%7B%22path%22%3A%22very%2Flong%2Fencoded%2Fvalue%22%7D'.repeat(40),
295
+ JSON.stringify({ rows: Array.from({ length: 100 }, (_, i) => ({ id: i, value: `item_${i}_abcdef` })) }),
296
+ Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n'),
297
+ 'plain english prose with ordinary words that merge well. '.repeat(50),
298
+ '한국어 컨텍스트 추정 정확도 검증 문장입니다. '.repeat(30),
299
+ '😀🚀🔥✅'.repeat(50),
300
+ ]) {
301
+ assert.equal(estimateTokens(text), enc.encode(text, undefined, []).length,
302
+ 'estimateTokens must equal the real o200k token count');
303
+ }
304
+ // Repeat call exercises the hash-keyed LRU for long strings — the
305
+ // cached count must be identical to the fresh encode.
306
+ const longText = 'cached long payload '.repeat(200);
307
+ assert.equal(estimateTokens(longText), estimateTokens(longText),
308
+ 'cached long-string counts must be stable');
309
+ } finally {
310
+ enc.free();
291
311
  }
292
- const shortJsonl = Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n');
293
- assert.ok(estimateTokens(shortJsonl) >= shortJsonl.replace(/\s/g, '').length * 0.7,
294
- 'short JSONL records must receive the structured multiline floor');
295
- const spacedShortIdentifiers = 'A1b2C3d4E5 F6G7H8I9J0 '.repeat(100);
296
- assert.ok(estimateTokens(spacedShortIdentifiers) >= spacedShortIdentifiers.length * 0.7,
297
- 'space-separated encoded identifiers below the long-run threshold must receive the dense floor');
298
312
  });
299
313
 
300
314
  test('assistantBlocks and reasoningItems count provider-visible data but not image bytes', () => {
@@ -508,7 +522,9 @@ test('provider baselines fingerprint actual sendTools and reject provider, model
508
522
  const policy = { ...resolveWorkerCompactPolicy(session, session.tools), reserveTokens: 0 };
509
523
  return {
510
524
  pressure: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
511
- fallback: estimateMessagesTokens(messages),
525
+ // The estimate fallback includes provider billing calibration, so
526
+ // compare against the same pressure computation without a baseline.
527
+ fallback: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: null }),
512
528
  };
513
529
  };
514
530
  for (const mutate of [
@@ -591,7 +607,8 @@ test('provider callback usage counts assistant output once and estimates only la
591
607
  const wholeEstimate = estimateMessagesTokens(messages);
592
608
  assert.ok(wholeEstimate < policy.triggerTokens, 'fixture must reproduce local estimator undercount');
593
609
  const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
594
- const expectedPressure = 94_800 + estimateMessagesTokens([laterToolResult]);
610
+ const expectedPressure = 94_800
611
+ + Math.round(estimateMessagesTokens([laterToolResult]) * providerTokenCalibration('anthropic'));
595
612
  assert.equal(pressure, expectedPressure, 'assistant output/reasoning must stay in actual usage, not be estimated again');
596
613
  assert.ok(pressure >= 95_000, `actual usage plus later tool growth should cross trigger, got ${pressure}`);
597
614
  assert.equal(shouldCompactForSession(wholeEstimate, policy, {
@@ -619,7 +636,8 @@ test('thinking-only continuation without assistant replay excludes provider outp
619
636
 
620
637
  const wholeEstimate = estimateMessagesTokens(messages);
621
638
  const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
622
- const expectedPressure = 74_000 + estimateMessagesTokens([nudge]);
639
+ const expectedPressure = 74_000
640
+ + Math.round(estimateMessagesTokens([nudge]) * providerTokenCalibration('anthropic'));
623
641
  assert.equal(pressure, expectedPressure, 'unreplayed output must be removed while the later nudge is estimated');
624
642
  assert.equal(shouldCompactForSession(wholeEstimate, policy, {
625
643
  messages,
@@ -639,22 +657,25 @@ test('OpenAI OAuth WS warmup remains billed but does not double the main context
639
657
  const usage = _combineUsageWithWarmup(main, main, { separateMainContext: true });
640
658
  assert.equal(usage.inputTokens, 335_270, 'billing usage must retain warmup plus main input');
641
659
  const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
642
- const { createSession, askSession, getSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
660
+ const { createSession, askSession, getSession, closeSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
643
661
  await initProviders({ 'openai-oauth': { enabled: true } });
644
662
  const provider = getProvider('openai-oauth');
645
663
  const originalSend = provider.send;
646
664
  const compactEvents = [];
665
+ let regressionSessionId = '';
647
666
  provider.send = async () => ({ content: 'done', usage });
648
667
  try {
649
668
  const session = createSession({
650
669
  provider: 'openai-oauth',
651
670
  model: 'warmup-context-regression',
671
+ owner: 'test',
652
672
  tools: [],
653
673
  cwd: process.cwd(),
654
674
  skipAgentRules: true,
655
675
  skipSkills: true,
656
676
  compaction: { auto: true },
657
677
  });
678
+ regressionSessionId = session.id;
658
679
  session.contextWindow = 272_000;
659
680
  session.rawContextWindow = 272_000;
660
681
  await askSession(session.id, 'large OAuth WebSocket request', null, null, process.cwd(), null, {
@@ -680,6 +701,9 @@ test('OpenAI OAuth WS warmup remains billed but does not double the main context
680
701
  }), false, 'main-request pressure must not trigger compaction');
681
702
  assert.equal(compactEvents.length, 0, 'warmup must not cause an auto-compaction');
682
703
  } finally {
704
+ if (regressionSessionId) {
705
+ closeSession(regressionSessionId, 'warmup-context-regression-cleanup', { tombstone: true });
706
+ }
683
707
  provider.send = originalSend;
684
708
  }
685
709
  });
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ compactDigestRows,
4
+ renderEntryLines,
5
+ } from '../src/runtime/memory/lib/recall-format.mjs';
6
+ import { createQueryHandlers } from '../src/runtime/memory/lib/query-handlers.mjs';
7
+
8
+ const longPlan = 'cache recent session snapshots and display immediately while the runtime initializes in the background without a blocking veil';
9
+ const nearPlan = `${longPlan} and keep the newest click authoritative`;
10
+ const rows = [
11
+ { id: 10, ts: 300, role: 'assistant', content: nearPlan, is_root: 0, chunk_root: null },
12
+ { id: 9, ts: 290, role: 'assistant', content: longPlan, is_root: 0, chunk_root: null },
13
+ { id: 8, ts: 280, role: 'assistant', content: longPlan, is_root: 0, chunk_root: null },
14
+ { id: 7, ts: 270, role: 'user', content: '오케이', is_root: 0, chunk_root: null },
15
+ { id: 6, ts: 260, role: 'user', content: '오케이', is_root: 0, chunk_root: null },
16
+ { id: 5, ts: 250, role: 'assistant', content: 'distinct completed result', is_root: 0, chunk_root: null },
17
+ ];
18
+
19
+ const compact = compactDigestRows(rows, 30);
20
+ assert.equal(compact.filter((row) => row.content === '오케이').length, 1, 'short exact duplicates collapse');
21
+ assert.equal(compact.filter((row) => row.content === longPlan).length, 0, 'older near-duplicate plans collapse');
22
+ assert.ok(compact.some((row) => row.content === nearPlan), 'newest near-duplicate plan is retained');
23
+ assert.ok(compact.some((row) => row.content === 'distinct completed result'), 'distinct state survives');
24
+
25
+ const normalText = renderEntryLines(compact);
26
+ assert.match(normalText, /\[pending\]/, 'normal recall keeps raw-row pipeline status');
27
+ const digestText = renderEntryLines(compact, { pendingMarks: false });
28
+ assert.doesNotMatch(digestText, /\[pending\]/, 'compact digest omits misleading pipeline status');
29
+
30
+ const fakeDb = {
31
+ async query(sql) {
32
+ if (/SELECT source_turn t/.test(sql)) return { rows: [] };
33
+ if (/id <> ALL/.test(sql)) return { rows: [] };
34
+ if (/FROM entries/.test(sql)) return { rows };
35
+ return { rows: [] };
36
+ },
37
+ };
38
+ const { handleSearch } = createQueryHandlers({
39
+ getDb: () => fakeDb,
40
+ log: () => {},
41
+ resolveProjectScope: () => null,
42
+ embeddingWarmupCanStart: () => false,
43
+ getBootTimestamp: () => 0,
44
+ getTraceDb: () => null,
45
+ });
46
+ const integrated = await handleSearch({
47
+ sessionId: 'compact-digest-session',
48
+ limit: 30,
49
+ includeMembers: true,
50
+ includeRaw: true,
51
+ compactDigest: true,
52
+ });
53
+ assert.doesNotMatch(integrated.text, /\[pending\]/, 'compact search path suppresses pipeline status');
54
+ assert.equal((integrated.text.match(/오케이/g) || []).length, 1, 'compact search path removes exact duplicates');
55
+ assert.equal((integrated.text.match(/cache recent session snapshots/g) || []).length, 1, 'compact search path removes near duplicates');
56
+
57
+ console.log('compact recall digest test passed \u2713');
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { semanticCompactMessages, recallFastTrackCompactMessages, SUMMARY_PREFIX, COMPACT_TYPE_SEMANTIC, COMPACT_TYPE_RECALL_FASTTRACK, normalizeCompactType } from '../src/runtime/agent/orchestrator/session/compact.mjs';
3
4
  import { agentLoop } from '../src/runtime/agent/orchestrator/session/loop.mjs';
4
- import { estimateMessagesTokens, estimateToolSchemaTokens } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
5
+ import { estimateMessagesTokens, estimateToolSchemaTokens, providerTokenCalibration } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
5
6
  import { runSessionCompaction } from '../src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs';
6
7
  import { autoCompactWindowForRoute, summarizeGatewayUsage } from '../src/vendor/statusline/src/gateway/route-meta.mjs';
7
8
 
@@ -928,58 +929,53 @@ assert(!tinyCapUserContent.startsWith('[...'), 'tiny cap recall must not use a p
928
929
  // ---------------------------------------------------------------------------
929
930
  // Conservative Unicode-aware estimator (strict-fit hardening).
930
931
  //
931
- // The estimator is a SAFETY device, not exact tokenizer parity: it must never
932
- // undercount Korean/CJK/emoji/JSON the way the legacy chars/4 heuristic did, or
933
- // a transcript that "fits" locally can still overflow the real model window.
934
- // These probes prove the conservative estimate is meaningfully higher than a
935
- // chars/4 style count for non-ASCII heavy content while staying near chars/4
936
- // for plain ASCII.
937
- function chars4(text) {
938
- return Math.ceil(String(text ?? '').length / 4);
932
+ // The estimator now encodes the provider-visible projection with the real
933
+ // o200k_base BPE (tiktoken), so local estimates equal actual tokenizer counts
934
+ // for every script (Korean/CJK/emoji/ASCII alike). Provider-specific billing
935
+ // deltas (e.g. Anthropic ~1.7x o200k) are applied separately via
936
+ // providerTokenCalibration at the pressure/gauge aggregation boundary.
937
+ const requireForTokenizer = createRequire(import.meta.url);
938
+ const { Tiktoken: SmokeTiktoken } = requireForTokenizer('tiktoken/lite');
939
+ const smokeO200k = requireForTokenizer('tiktoken/encoders/o200k_base.json');
940
+ const smokeEncoder = new SmokeTiktoken(smokeO200k.bpe_ranks, smokeO200k.special_tokens, smokeO200k.pat_str);
941
+ const bpeCount = (text) => smokeEncoder.encode(String(text), undefined, []).length;
942
+
943
+ for (const [label, probe] of [
944
+ ['korean', '\uD55C\uAD6D\uC5B4 \uCEF4\uD329\uC158 \uACBD\uACC4 \uD14C\uC2A4\uD2B8 '.repeat(200)],
945
+ ['cjk', '上下文压缩边界测试令牌预算'.repeat(200)],
946
+ ['emoji', '😀🚀🔥✅'.repeat(200)],
947
+ ['ascii', 'plain ascii sentence with normal words. '.repeat(200)],
948
+ ]) {
949
+ const est = estimateMessagesTokens([{ role: 'user', content: probe }]);
950
+ const expected = bpeCount(probe) + 4;
951
+ assert(
952
+ est === expected,
953
+ `${label} message estimate must equal the real o200k count plus framing (est=${est}, expected=${expected})`,
954
+ );
939
955
  }
940
956
 
941
- const koHeavy = '\uD55C\uAD6D\uC5B4 \uCEF4\uD329\uC158 \uACBD\uACC4 \uD14C\uC2A4\uD2B8 '.repeat(200);
942
- const koEst = estimateMessagesTokens([{ role: 'user', content: koHeavy }]);
943
- assert(
944
- koEst > chars4(koHeavy) * 2,
945
- `Korean-heavy content must estimate well above chars/4 (est=${koEst}, chars/4=${chars4(koHeavy)})`,
946
- );
947
-
948
- const cjkHeavy = '上下文压缩边界测试令牌预算'.repeat(200);
949
- const cjkEst = estimateMessagesTokens([{ role: 'user', content: cjkHeavy }]);
950
- assert(
951
- cjkEst > chars4(cjkHeavy) * 1.5,
952
- `CJK-heavy content must estimate above chars/4 (est=${cjkEst}, chars/4=${chars4(cjkHeavy)})`,
953
- );
954
-
955
- const emojiHeavy = '😀🚀🔥✅'.repeat(200);
956
- const emojiEst = estimateMessagesTokens([{ role: 'user', content: emojiHeavy }]);
957
- assert(
958
- emojiEst > chars4(emojiHeavy) * 2,
959
- `Emoji-heavy content must estimate well above chars/4 (est=${emojiEst}, chars/4=${chars4(emojiHeavy)})`,
960
- );
961
-
962
- // Plain ASCII must stay close to the chars/4 floor (within the safety
963
- // multiplier band) — the estimator overcounts non-ASCII, not everything.
964
- const asciiPlain = 'plain ascii sentence with normal words. '.repeat(200);
965
- const asciiEst = estimateMessagesTokens([{ role: 'user', content: asciiPlain }]);
957
+ // Provider calibration reconciles o200k counts with actual billing.
966
958
  assert(
967
- asciiEst >= chars4(asciiPlain) && asciiEst <= chars4(asciiPlain) * 1.5,
968
- `ASCII must stay near chars/4 (est=${asciiEst}, chars/4=${chars4(asciiPlain)})`,
959
+ providerTokenCalibration('anthropic-oauth') > 1.5 && providerTokenCalibration('anthropic-oauth') < 2,
960
+ 'anthropic calibration must reflect the measured ~1.7x billed/o200k ratio',
969
961
  );
962
+ assert(providerTokenCalibration('openai-oauth') === 1.0, 'openai calibration must stay neutral');
963
+ assert(providerTokenCalibration(undefined) === 1.0, 'unknown provider calibration must stay neutral');
970
964
 
971
965
  // Tool schemas are serialized into the request body, so their estimate must
972
- // also use the conservative estimator (>= chars/4 of the serialized JSON).
966
+ // track the real tokenizer over the wire-shape serialization.
973
967
  const toolSchema = [{
974
968
  name: 'apply_patch',
975
969
  description: '\uD328\uCE58\uB97C \uD30C\uC77C\uC5D0 \uC801\uC6A9 (\uD55C\uAD6D\uC5B4 \uC124\uBA85) — applies a patch',
976
970
  parameters: { type: 'object', properties: { patch: { type: 'string', description: '\uD328\uCE58 \uBCF8\uBB38 \uD14D\uC2A4\uD2B8' } } },
977
971
  }];
978
972
  const toolSchemaEst = estimateToolSchemaTokens(toolSchema);
973
+ const toolWireJson = JSON.stringify(toolSchema.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })));
979
974
  assert(
980
- toolSchemaEst >= chars4(JSON.stringify(toolSchema)),
981
- `tool schema estimate must be >= chars/4 of serialized JSON (est=${toolSchemaEst})`,
975
+ toolSchemaEst === bpeCount(toolWireJson),
976
+ `tool schema estimate must equal the o200k count of the serialized wire JSON (est=${toolSchemaEst}, expected=${bpeCount(toolWireJson)})`,
982
977
  );
978
+ smokeEncoder.free();
983
979
 
984
980
  // Strict-fit smoke: a Korean/CJK-heavy newest turn far larger than the preserve
985
981
  // budget must be summarized/truncated safely — never preserved verbatim — and