mixdog 0.9.19 → 0.9.21

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/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -58,6 +58,50 @@ function addCompactUsageToSession(session, usage) {
58
58
  session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
59
59
  session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
60
60
  }
61
+ // A dead/unreachable memory runtime makes each proxy RPC wait ~30s (waitForPort)
62
+ // and retry once (memory-runtime-proxy.mjs) — that must NEVER wedge the
63
+ // compact//clear path. Bound every recall-fasttrack memory call with a short
64
+ // local timeout: on timeout we abort (best-effort cancel via a chained signal)
65
+ // and treat it exactly like an RPC failure, so compaction proceeds WITHOUT
66
+ // recall-fasttrack instead of hanging.
67
+ const RECALL_MEMORY_CALL_TIMEOUT_MS = Math.max(
68
+ 250,
69
+ Number(process.env.MIXDOG_AGENT_COMPACT_RECALL_TIMEOUT_MS) || 4000,
70
+ );
71
+ function recallMemoryTimeoutMs(session) {
72
+ const configured = positiveContextWindow(session?.compaction?.recallMemoryTimeoutMs);
73
+ // Clamp ALL sources (session-config included) to the 250ms floor so a
74
+ // misconfigured tiny value can't turn the bound into a busy no-wait.
75
+ return Math.max(250, configured || RECALL_MEMORY_CALL_TIMEOUT_MS);
76
+ }
77
+ async function callMemoryBounded(args, callerCtx, timeoutMs) {
78
+ const ac = new AbortController();
79
+ const outer = callerCtx?.signal;
80
+ const onOuterAbort = () => { try { ac.abort(); } catch {} };
81
+ if (outer) {
82
+ if (outer.aborted) ac.abort();
83
+ else { try { outer.addEventListener?.('abort', onOuterAbort, { once: true }); } catch {} }
84
+ }
85
+ let timer = null;
86
+ const timeout = new Promise((_, reject) => {
87
+ timer = setTimeout(() => {
88
+ try { ac.abort(); } catch {}
89
+ reject(new Error(`memory ${args?.action || 'call'} timed out after ${timeoutMs}ms`));
90
+ }, timeoutMs);
91
+ try { timer.unref?.(); } catch {}
92
+ });
93
+ try {
94
+ return await Promise.race([
95
+ executeInternalTool('memory', args, { ...callerCtx, signal: ac.signal }),
96
+ timeout,
97
+ ]);
98
+ } finally {
99
+ if (timer) clearTimeout(timer);
100
+ // Drop the chained-abort listener when the call settles first, so a
101
+ // later outer abort can't fire into a dead controller / leak.
102
+ try { outer?.removeEventListener?.('abort', onOuterAbort); } catch {}
103
+ }
104
+ }
61
105
  async function runRecallFastTrackForSession(session, messages, opts = {}) {
62
106
  const sessionId = opts.sessionId || session?.id || null;
63
107
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
@@ -72,16 +116,22 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
72
116
  };
73
117
  const hydrateLimit = positiveContextWindow(session?.compaction?.recallIngestLimit)
74
118
  || Math.max(500, Math.min(5000, messages.length || 0));
119
+ const memoryTimeoutMs = recallMemoryTimeoutMs(session);
75
120
  try {
76
- await executeInternalTool('memory', {
121
+ await callMemoryBounded({
77
122
  action: 'ingest_session',
78
123
  sessionId,
79
124
  messages,
80
125
  cwd: session?.cwd,
81
126
  limit: hydrateLimit,
82
- }, callerCtx);
127
+ }, callerCtx, memoryTimeoutMs);
83
128
  } catch (err) {
84
- try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
129
+ // Ingest failed (dead/timed-out memory runtime). The transcript is NOT
130
+ // in memory, so recall-fasttrack MUST NOT proceed with a false "history
131
+ // is in memory" digest — that would drop un-ingested head history.
132
+ // Throw so the caller falls back to the normal compaction path.
133
+ try { process.stderr.write(`[session] recall-fasttrack ingest failed — bailing (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
134
+ throw new Error(`recall-fasttrack ingest failed: ${err?.message || err}`);
85
135
  }
86
136
  // Digest injection (mirrors loop/recall-fasttrack.mjs): no dump + cycle1
87
137
  // drain — that ran memory-pipeline LLM chunking inside the compaction.
@@ -89,24 +139,25 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
89
139
  // chunks it on its own schedule and recall serves anything beyond the
90
140
  // digest.
91
141
  let recallText = '';
92
- let cycle1Error = null;
93
142
  try {
94
- const browsed = await executeInternalTool('memory', {
143
+ const browsed = await callMemoryBounded({
95
144
  action: 'search',
96
145
  sessionId,
97
146
  limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
98
147
  includeMembers: true,
99
148
  includeRaw: true,
100
- }, callerCtx);
149
+ }, callerCtx, memoryTimeoutMs);
101
150
  recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
102
151
  } catch (err) {
103
- cycle1Error = err?.message || String(err);
104
- try { process.stderr.write(`[session] recall-digest browse failed (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
152
+ // Search failed (dead/timed-out memory runtime). Same hazard as a failed
153
+ // ingest: without a real recall dump we can't safely replace head
154
+ // history — bail out to the normal compaction path.
155
+ try { process.stderr.write(`[session] recall-digest browse failed — bailing (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
156
+ throw new Error(`recall-fasttrack search failed: ${err?.message || err}`);
105
157
  }
106
158
  return {
107
159
  query,
108
160
  querySha,
109
- cycle1Error,
110
161
  recallText: [
111
162
  `session_id=${sessionId}`,
112
163
  `Full history is in memory — use the recall tool for details beyond this digest.`,
@@ -221,9 +272,6 @@ export async function runSessionCompaction(session, opts = {}) {
221
272
  if (Array.isArray(recallFastTrackResult?.messages)) {
222
273
  compacted = recallFastTrackResult.messages;
223
274
  }
224
- if (recallPayload.cycle1Error) {
225
- recallFastTrackError = new Error(String(recallPayload.cycle1Error));
226
- }
227
275
  } catch (err) {
228
276
  recallFastTrackError = err;
229
277
  compactError = err;
@@ -0,0 +1,8 @@
1
+ // manager/env-utils.mjs
2
+ // Shared non-negative integer env parser. Extracted verbatim from manager.mjs
3
+ // so ask-session (terminal save timeout) and idle-cleanup (interval constants)
4
+ // read the identical contract without importing manager.mjs back.
5
+ export function nonNegativeIntEnv(name, fallback) {
6
+ const value = Number(process.env[name]);
7
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
8
+ }
@@ -0,0 +1,159 @@
1
+ // manager/idle-cleanup.mjs
2
+ // Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
3
+ // Drives sweepStaleSessions on an unref'd interval; closeSession is imported
4
+ // from session-close.mjs (one-way dependency, no cycle).
5
+ import { sweepStaleSessions } from '../store.mjs';
6
+ import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
+ import { _getRuntimeEntry, _clearSessionRuntime, _runtimeEntries } from './runtime-liveness.mjs';
8
+ import { _closeBashSessionLazy } from './runtime-loaders.mjs';
9
+ import { closeSession } from './session-close.mjs';
10
+ import { nonNegativeIntEnv } from './env-utils.mjs';
11
+
12
+ // --- Periodic idle session cleanup ---
13
+ const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
14
+ const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
15
+ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
16
+ const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
17
+ let _cleanupTimer = null;
18
+ let _cleanupInitialTimer = null;
19
+
20
+ function _previewIds(items, limit = 5) {
21
+ const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
22
+ if (ids.length === 0) return '';
23
+ const more = items.length > limit ? `, +${items.length - limit} more` : '';
24
+ return ` (${ids.join(', ')}${more})`;
25
+ }
26
+
27
+ function sweepIdleSessions({ includeTombstones = true } = {}) {
28
+ const startedAt = Date.now();
29
+ try {
30
+ const result = sweepStaleSessions({
31
+ tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
32
+ });
33
+ const {
34
+ cleaned,
35
+ remaining,
36
+ details,
37
+ tombstonesCleaned = 0,
38
+ tombstoneDetails = [],
39
+ tombstoneErrors = [],
40
+ } = result;
41
+ if (cleaned > 0) {
42
+ for (const d of details) {
43
+ // Skip entries with an active in-flight controller — aborting
44
+ // them via closeSession() is the safe path; clearing the runtime
45
+ // without signalling the controller leaves orphan provider work.
46
+ const rtEntry = _getRuntimeEntry(d.id);
47
+ if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
48
+ try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
49
+ } else {
50
+ _clearSessionRuntime(d.id);
51
+ if (d.bashSessionId) {
52
+ try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
53
+ }
54
+ }
55
+ process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
56
+ }
57
+ process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
58
+ }
59
+ if (tombstonesCleaned > 0) {
60
+ for (const d of tombstoneDetails) {
61
+ if (d?.id) _clearSessionRuntime(d.id);
62
+ }
63
+ process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
64
+ }
65
+ if (tombstoneErrors.length > 0) {
66
+ const first = tombstoneErrors[0];
67
+ process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
68
+ }
69
+ const elapsed = Date.now() - startedAt;
70
+ if (elapsed >= CLEANUP_SLOW_LOG_MS) {
71
+ process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
72
+ }
73
+ } catch (e) {
74
+ process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Unlink tombstone session files (closed=true) older than TOMBSTONE_MAX_AGE_MS.
80
+ *
81
+ * Rationale: closeSession() leaves the tombstone on disk as the authoritative
82
+ * resurrection-blocker for racing saveSession() calls. That race resolves in
83
+ * microseconds (the window inside _doSave between temp write and rename), so
84
+ * 24h is vastly safe. After the TTL expires we reclaim the disk slot.
85
+ *
86
+ * Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
87
+ * latter's inline 30-min idle cleanup would race-unlink tombstones before we
88
+ * get to log them — we want to own the unlink decision and stderr line here.
89
+ */
90
+ export function sweepTombstones() {
91
+ try {
92
+ const { tombstonesCleaned = 0, tombstoneDetails = [], tombstoneErrors = [] } = sweepStaleSessions({
93
+ sweepIdle: false,
94
+ tombstoneMaxAgeMs: TOMBSTONE_MAX_AGE_MS,
95
+ });
96
+ for (const d of tombstoneDetails) {
97
+ if (d?.id) _clearSessionRuntime(d.id);
98
+ }
99
+ if (tombstonesCleaned > 0) {
100
+ process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
101
+ }
102
+ if (tombstoneErrors.length > 0) {
103
+ const first = tombstoneErrors[0];
104
+ process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
105
+ }
106
+ return tombstonesCleaned;
107
+ } catch (e) {
108
+ process.stderr.write(`[session-sweep] tombstone sweep error: ${e && e.message || e}\n`);
109
+ return 0;
110
+ }
111
+ }
112
+
113
+ function hasActiveRuntimeWork() {
114
+ for (const [, entry] of _runtimeEntries()) {
115
+ if (!entry || entry.closed === true) continue;
116
+ if (entry.controller && !entry.controller.signal?.aborted) return true;
117
+ if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ function _runCleanupCycle() {
123
+ if (hasActiveRuntimeWork()) return;
124
+ sweepOrphanedPendingMessages();
125
+ sweepIdleSessions({ includeTombstones: true });
126
+ }
127
+
128
+ function _startCleanupInterval() {
129
+ if (_cleanupTimer) return;
130
+ if (CLEANUP_INTERVAL_MS <= 0) return;
131
+ _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
132
+ if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
133
+ }
134
+
135
+ export function startIdleCleanup() {
136
+ if (_cleanupTimer || _cleanupInitialTimer) return;
137
+ if (CLEANUP_INITIAL_DELAY_MS <= 0) {
138
+ _runCleanupCycle();
139
+ _startCleanupInterval();
140
+ return;
141
+ }
142
+ _cleanupInitialTimer = setTimeout(() => {
143
+ _cleanupInitialTimer = null;
144
+ _runCleanupCycle();
145
+ _startCleanupInterval();
146
+ }, CLEANUP_INITIAL_DELAY_MS);
147
+ if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
148
+ }
149
+
150
+ export function stopIdleCleanup() {
151
+ if (_cleanupInitialTimer) {
152
+ clearTimeout(_cleanupInitialTimer);
153
+ _cleanupInitialTimer = null;
154
+ }
155
+ if (_cleanupTimer) {
156
+ clearInterval(_cleanupTimer);
157
+ _cleanupTimer = null;
158
+ }
159
+ }
@@ -0,0 +1,143 @@
1
+ // manager/message-sanitize.mjs
2
+ // Message sanitization + compaction-failure persistence helpers extracted
3
+ // verbatim from manager.mjs. sanitizeSessionMessagesForModel drops internal
4
+ // runtime-notification turns and cancelled-assistant stubs while keeping image
5
+ // content intact (reference-agent parity); the compact-failure helpers persist
6
+ // a compacted outgoing transcript when an ask throws mid-turn.
7
+ import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
8
+ import { saveSessionAsync } from '../store.mjs';
9
+ import { _getRuntimeEntry } from './runtime-liveness.mjs';
10
+
11
+ function isInternalCancelledAssistantMessage(message) {
12
+ if (!message || message.role !== 'assistant') return false;
13
+ if (message.cancelled === true) return true;
14
+ const text = promptContentText(message.content).trim();
15
+ return /^\[cancelled\]\s+This turn was interrupted before completion\./i.test(text)
16
+ || /Preserve the user request above as the active task context/i.test(text);
17
+ }
18
+
19
+ export function sanitizeSessionMessagesForModel(messages) {
20
+ // Drop internal runtime-notification turns and cancelled-assistant stubs so
21
+ // they never reach the model, but KEEP image content intact. Reference-agent
22
+ // parity: the live transcript and every model request retain attached
23
+ // images across turns; only the compaction-summary call strips them. The
24
+ // disk-stored session JSON replaces image bytes with a text placeholder at
25
+ // serialization time (see store.mjs), so this no longer touches images.
26
+ if (!Array.isArray(messages) || messages.length === 0) return [];
27
+ const out = [];
28
+ let droppingInternalTurn = false;
29
+ for (const message of messages) {
30
+ if (isInternalCancelledAssistantMessage(message)) {
31
+ droppingInternalTurn = false;
32
+ continue;
33
+ }
34
+ if (message?.role === 'user' && isInternalRuntimeNotificationText(message.content)) {
35
+ droppingInternalTurn = true;
36
+ continue;
37
+ }
38
+ if (droppingInternalTurn) {
39
+ if (message?.role === 'user') {
40
+ droppingInternalTurn = false;
41
+ } else {
42
+ continue;
43
+ }
44
+ }
45
+ out.push(message);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function sessionMessagesSnapshotChanged(before, after) {
51
+ if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
52
+ if (before.length !== after.length) return true;
53
+ for (let i = 0; i < before.length; i += 1) {
54
+ if (before[i] !== after[i]) return true;
55
+ try {
56
+ if (JSON.stringify(before[i]) !== JSON.stringify(after[i])) return true;
57
+ } catch {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ function isCompactedOutgoingFinalAssistantMessage(message) {
65
+ if (!message || message.role !== 'assistant') return false;
66
+ if (message.emptyFinal === true) return true;
67
+ return true;
68
+ }
69
+
70
+ function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compactedSanitized) {
71
+ if (!Array.isArray(currentSanitized) || !Array.isArray(compactedSanitized)) return false;
72
+ if (currentSanitized.length !== compactedSanitized.length + 1) return false;
73
+ const prefix = currentSanitized.slice(0, compactedSanitized.length);
74
+ if (sessionMessagesSnapshotChanged(compactedSanitized, prefix)) return false;
75
+ return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
76
+ }
77
+
78
+ export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
79
+
80
+ function applyCompactFailurePersistToSession(activeSession, {
81
+ priorSanitized,
82
+ sanitized,
83
+ messagesAdvanced,
84
+ error = null,
85
+ }) {
86
+ if (!messagesAdvanced && !sessionMessagesSnapshotChanged(priorSanitized, sanitized)) return false;
87
+ if (!messagesAdvanced) {
88
+ activeSession.messages = sanitized;
89
+ activeSession.providerState = undefined;
90
+ }
91
+ activeSession.updatedAt = Date.now();
92
+ activeSession.lastUsedAt = Date.now();
93
+ if (activeSession.compaction && typeof activeSession.compaction === 'object'
94
+ && (activeSession.compaction.lastStage === 'compacting'
95
+ || activeSession.compaction.lastStage === 'overflow_failed')) {
96
+ const prev = activeSession.compaction;
97
+ const cause = error?.cause;
98
+ const overflow = error?.code === 'AGENT_CONTEXT_OVERFLOW';
99
+ activeSession.compaction = {
100
+ ...prev,
101
+ lastStage: prev.lastStage === 'overflow_failed'
102
+ ? 'overflow_failed'
103
+ : (overflow ? 'overflow_failed' : 'failed'),
104
+ lastCheckedAt: Date.now(),
105
+ lastError: prev.lastError || cause?.message || error?.message || null,
106
+ lastSemanticError: prev.lastSemanticError || cause?.message || null,
107
+ lastRecallFastTrackError: prev.lastRecallFastTrackError
108
+ || (cause?.message && String(cause?.name || '').includes('Recall') ? cause.message : null),
109
+ };
110
+ }
111
+ return true;
112
+ }
113
+
114
+ export const _applyCompactFailurePersistToSession = applyCompactFailurePersistToSession;
115
+
116
+ export async function persistCompactedOutgoingAfterAskFailure({
117
+ sessionId,
118
+ activeSession,
119
+ askGeneration,
120
+ turnOutgoing,
121
+ error = null,
122
+ }) {
123
+ if (!activeSession || activeSession.closed === true) return;
124
+ if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
125
+ const currentRuntime = _getRuntimeEntry(sessionId);
126
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
127
+ const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
128
+ const priorSanitized = sanitizeSessionMessagesForModel(
129
+ Array.isArray(activeSession.messages) ? activeSession.messages : [],
130
+ );
131
+ const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
132
+ const applied = applyCompactFailurePersistToSession(activeSession, {
133
+ priorSanitized,
134
+ sanitized,
135
+ messagesAdvanced,
136
+ error,
137
+ });
138
+ if (!applied) return;
139
+ try {
140
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
141
+ } catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
142
+ if (currentRuntime) currentRuntime.session = activeSession;
143
+ }