mixdog 0.9.95 → 0.9.96

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 (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -1,49 +1,135 @@
1
1
  /**
2
- * agent-ipc.mjs — IPC client for agent dispatch calls from the memory worker.
2
+ * Agent-dispatch client for the memory process.
3
3
  *
4
- * The memory worker runs in its own fork, while the agent runtime / provider
5
- * registry lives in-process in the parent server. cycle1 / cycle2 can't
6
- * call makeAgentDispatch() locally (provider map is empty here), so we route
7
- * every LLM call over IPC:
8
- *
9
- * memory → parent : { type: 'agent_ipc_request', callId, tool, params }
10
- * parent → memory : { type: 'agent_ipc_response', callId, ok, result | error }
11
- *
12
- * Uses a module-level pending map keyed by callId. Parent-side handler
13
- * lives in server.mjs spawnWorker's message listener.
4
+ * PG/embedding/recall remain isolated in the memory process, while the heavy
5
+ * provider/session graph lives exactly once in the machine-global backend
6
+ * daemon. Calls use its authenticated loopback broker, so the memory process
7
+ * can outlive its original fork parent and reconnect after a backend restart.
14
8
  */
15
- import { safeIpcSend } from '../../shared/safe-ipc-send.mjs'
9
+ import http from 'node:http'
10
+ import { readFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
16
13
 
17
- const pending = new Map()
18
- let listenerInstalled = false
14
+ const brokerAgent = new http.Agent({
15
+ keepAlive: true,
16
+ maxSockets: 64,
17
+ maxFreeSockets: 8,
18
+ })
19
19
  let _idSeq = 0
20
20
 
21
- function installListener() {
22
- if (listenerInstalled) return
23
- listenerInstalled = true
24
- process.on('message', (msg) => {
25
- if (!msg || msg.type !== 'agent_ipc_response' || !msg.callId) return
26
- const entry = pending.get(msg.callId)
27
- if (!entry) return
28
- pending.delete(msg.callId)
29
- if (msg.ok) entry.resolve(msg.result)
30
- else entry.reject(new Error(msg.error || 'agent_ipc_response error'))
31
- })
32
- process.once('disconnect', () => {
33
- const error = new Error('agent-ipc: parent IPC disconnected')
34
- for (const [, entry] of pending) entry.reject(error)
35
- pending.clear()
36
- })
37
- }
38
-
39
21
  function nextCallId() {
40
22
  _idSeq += 1
41
23
  return `mem-${process.pid}-${Date.now()}-${_idSeq}`
42
24
  }
43
25
 
26
+ function runtimeRoot() {
27
+ return process.env.MIXDOG_RUNTIME_ROOT
28
+ ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
29
+ : join(tmpdir(), 'mixdog')
30
+ }
31
+
32
+ function isPidAlive(value) {
33
+ const pid = Number(value)
34
+ if (!Number.isInteger(pid) || pid <= 0) return false
35
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
36
+ }
37
+
38
+ function readBrokerDiscovery() {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(join(runtimeRoot(), 'channel-daemon.json'), 'utf8'))
41
+ const port = Number(raw?.port)
42
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !raw?.token || !isPidAlive(raw?.pid)) {
43
+ return null
44
+ }
45
+ return { port, token: String(raw.token) }
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function abortError(signal) {
52
+ return signal?.reason instanceof Error
53
+ ? signal.reason
54
+ : new Error(String(signal?.reason || 'agent broker request canceled'))
55
+ }
56
+
57
+ function requestBroker(discovery, path, body, { timeoutMs, signal = null } = {}) {
58
+ return new Promise((resolvePromise, rejectPromise) => {
59
+ const payload = JSON.stringify(body || {})
60
+ let settled = false
61
+ let req = null
62
+ const finish = (fn, value) => {
63
+ if (settled) return
64
+ settled = true
65
+ clearTimeout(deadline)
66
+ try { signal?.removeEventListener?.('abort', onAbort) } catch {}
67
+ fn(value)
68
+ }
69
+ const reject = (error) => finish(
70
+ rejectPromise,
71
+ error instanceof Error ? error : new Error(String(error)),
72
+ )
73
+ const onAbort = () => {
74
+ const error = abortError(signal)
75
+ try { req?.destroy?.(error) } catch {}
76
+ reject(error)
77
+ }
78
+ const deadline = setTimeout(() => {
79
+ const error = new Error(`agent broker request timed out after ${timeoutMs}ms`)
80
+ try { req?.destroy?.(error) } catch {}
81
+ reject(error)
82
+ }, timeoutMs)
83
+ deadline.unref?.()
84
+ if (signal?.aborted) {
85
+ onAbort()
86
+ return
87
+ }
88
+ try { signal?.addEventListener?.('abort', onAbort, { once: true }) } catch {}
89
+ req = http.request({
90
+ hostname: '127.0.0.1',
91
+ port: discovery.port,
92
+ path,
93
+ method: 'POST',
94
+ agent: brokerAgent,
95
+ headers: {
96
+ 'X-Mixdog-Daemon-Token': discovery.token,
97
+ 'Content-Type': 'application/json',
98
+ 'Content-Length': Buffer.byteLength(payload),
99
+ },
100
+ }, (res) => {
101
+ let data = ''
102
+ res.setEncoding('utf8')
103
+ res.on('data', (chunk) => { data += chunk })
104
+ res.on('error', reject)
105
+ res.on('end', () => {
106
+ let parsed = null
107
+ try { parsed = data ? JSON.parse(data) : null } catch {}
108
+ if ((res.statusCode || 0) >= 400) {
109
+ reject(new Error(parsed?.error || data || `HTTP ${res.statusCode}`))
110
+ return
111
+ }
112
+ if (parsed?.ok === false) {
113
+ reject(new Error(parsed.error || 'agent broker dispatch failed'))
114
+ return
115
+ }
116
+ finish(resolvePromise, parsed)
117
+ })
118
+ })
119
+ req.on('error', reject)
120
+ req.write(payload)
121
+ req.end()
122
+ })
123
+ }
124
+
125
+ function cancelBrokerCall(discovery, callId, reason) {
126
+ return requestBroker(discovery, '/agent/cancel', { callId, reason }, {
127
+ timeoutMs: 1500,
128
+ }).catch(() => {})
129
+ }
130
+
44
131
  /**
45
- * Send an agent-agent dispatch request to the parent. Throws if IPC is
46
- * unavailable (worker not forked) or the parent reports an error.
132
+ * Dispatch through the singleton backend provider/session graph.
47
133
  *
48
134
  * @param {object} opts agent-dispatch construction options
49
135
  * @param {string} [opts.agent]
@@ -55,55 +141,33 @@ function nextCallId() {
55
141
  * @param {string} prompt user message
56
142
  * @returns {Promise<string>} raw assistant content
57
143
  */
58
- export function callAgentDispatch(opts = {}, prompt) {
59
- if (!process.send || !process.connected) {
60
- return Promise.reject(new Error('agent-ipc: IPC channel unavailable (no process.send / not connected)'))
144
+ export async function callAgentDispatch(opts = {}, prompt) {
145
+ const discovery = readBrokerDiscovery()
146
+ if (!discovery) {
147
+ throw new Error('agent-broker: backend daemon unavailable')
61
148
  }
62
- installListener()
63
149
  const callId = nextCallId()
64
150
  const timeoutMs = Math.max(1000, Number(opts.timeout ?? 600000))
65
- return new Promise((resolve, reject) => {
66
- const timer = setTimeout(() => {
67
- if (!pending.has(callId)) return
68
- pending.delete(callId)
69
- if (process.connected) {
70
- safeIpcSend(process, { type: 'agent_ipc_cancel', callId })
71
- }
72
- reject(new Error(`agent-ipc: timed out after ${timeoutMs}ms`))
73
- }, timeoutMs)
74
- pending.set(callId, {
75
- resolve: (v) => { clearTimeout(timer); resolve(v) },
76
- reject: (e) => { clearTimeout(timer); reject(e) },
151
+ try {
152
+ const response = await requestBroker(discovery, '/agent/dispatch', {
153
+ callId,
154
+ params: {
155
+ agent: opts.agent || null,
156
+ taskType: opts.taskType || null,
157
+ mode: opts.mode || null,
158
+ preset: opts.preset || null,
159
+ cwd: opts.cwd || null,
160
+ prompt: String(prompt ?? ''),
161
+ timeout: timeoutMs,
162
+ },
163
+ }, {
164
+ // Small transport grace after the dispatch's own timeout/watchdog.
165
+ timeoutMs: timeoutMs + 5000,
166
+ signal: opts.signal || null,
77
167
  })
78
- if (!process.connected) {
79
- pending.delete(callId)
80
- clearTimeout(timer)
81
- reject(new Error('agent-ipc: IPC channel not connected'))
82
- return
83
- }
84
- // Pass a write callback so a closed-channel write surfaces as an async
85
- // error here (rejecting this Promise) instead of bubbling to the process
86
- // 'error' event and crashing the daemon with ERR_IPC_CHANNEL_CLOSED.
87
- safeIpcSend(process, {
88
- type: 'agent_ipc_request',
89
- callId,
90
- tool: 'agent_dispatch',
91
- params: {
92
- agent: opts.agent || null,
93
- taskType: opts.taskType || null,
94
- mode: opts.mode || null,
95
- preset: opts.preset || null,
96
- cwd: opts.cwd || null,
97
- prompt: String(prompt ?? ''),
98
- timeout: timeoutMs,
99
- },
100
- }, {
101
- onError: (err) => {
102
- if (!pending.has(callId)) return
103
- pending.delete(callId)
104
- clearTimeout(timer)
105
- reject(err)
106
- },
107
- })
108
- })
168
+ return String(response?.result ?? '')
169
+ } catch (error) {
170
+ await cancelBrokerCall(discovery, callId, error?.message || 'agent broker request failed')
171
+ throw error
172
+ }
109
173
  }
@@ -0,0 +1,88 @@
1
+ const DEFAULT_MAX_ENTRIES = 1000
2
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024
3
+
4
+ function entryBytes(key, vector) {
5
+ // Vector storage is exact (Float32Array.byteLength). UTF-16 key bytes plus a
6
+ // small fixed allowance bound Map/string/object overhead conservatively.
7
+ return vector.byteLength + String(key).length * 2 + 64
8
+ }
9
+
10
+ /**
11
+ * LRU cache specialized for embedding vectors.
12
+ *
13
+ * Worker results are semantically float32 but arrive over structured clone as
14
+ * JS number[]. Keeping 1,000 such arrays stores every component as a wider JS
15
+ * Number. Compact once on insertion; callers still receive normal arrays, so
16
+ * the embedding provider's public contract and PG serialization stay intact.
17
+ */
18
+ export function createCompactVectorCache({
19
+ maxEntries = DEFAULT_MAX_ENTRIES,
20
+ maxBytes = DEFAULT_MAX_BYTES,
21
+ } = {}) {
22
+ const entryLimit = Math.max(1, Math.floor(Number(maxEntries) || DEFAULT_MAX_ENTRIES))
23
+ const byteLimit = Math.max(1024, Math.floor(Number(maxBytes) || DEFAULT_MAX_BYTES))
24
+ const entries = new Map()
25
+ let bytes = 0
26
+
27
+ function remove(key) {
28
+ const current = entries.get(key)
29
+ if (!current) return false
30
+ entries.delete(key)
31
+ bytes = Math.max(0, bytes - current.bytes)
32
+ return true
33
+ }
34
+
35
+ function trim() {
36
+ while (entries.size > entryLimit || bytes > byteLimit) {
37
+ const oldest = entries.keys().next().value
38
+ if (oldest === undefined) break
39
+ remove(oldest)
40
+ }
41
+ }
42
+
43
+ function set(key, input) {
44
+ if (!Array.isArray(input) && !ArrayBuffer.isView(input)) {
45
+ throw new TypeError('embedding cache vector must be an array or typed array')
46
+ }
47
+ const vector = input instanceof Float32Array ? input.slice() : Float32Array.from(input)
48
+ remove(key)
49
+ const size = entryBytes(key, vector)
50
+ // A single vector larger than the whole budget is useful to its caller but
51
+ // must not permanently violate the cache bound.
52
+ if (size > byteLimit) return false
53
+ entries.set(key, { vector, bytes: size })
54
+ bytes += size
55
+ trim()
56
+ return true
57
+ }
58
+
59
+ function get(key) {
60
+ const current = entries.get(key)
61
+ if (!current) return null
62
+ entries.delete(key)
63
+ entries.set(key, current)
64
+ return current.vector
65
+ }
66
+
67
+ function clear() {
68
+ entries.clear()
69
+ bytes = 0
70
+ }
71
+
72
+ function snapshot() {
73
+ return {
74
+ entries: entries.size,
75
+ bytes,
76
+ maxEntries: entryLimit,
77
+ maxBytes: byteLimit,
78
+ }
79
+ }
80
+
81
+ return {
82
+ get,
83
+ set,
84
+ has: (key) => entries.has(key),
85
+ clear,
86
+ snapshot,
87
+ }
88
+ }
@@ -29,6 +29,20 @@ export const CORE_DEDUP_TOP_K = 5
29
29
 
30
30
  const CORE_ELEMENT_DERIVE_LENGTH = 40
31
31
 
32
+ // Op aliases: the surface verbs are add/edit/delete/list/…, and a caller
33
+ // naming the same intent differently ("update", "remove") used to get a hard
34
+ // rejection. Only unambiguous synonyms map through.
35
+ const CORE_OP_ALIASES = {
36
+ update: 'edit', modify: 'edit', change: 'edit', set: 'edit',
37
+ create: 'add', new: 'add', insert: 'add', store: 'add', save: 'add',
38
+ remove: 'delete', rm: 'delete', del: 'delete', forget: 'delete',
39
+ show: 'list', get: 'list', read: 'list',
40
+ }
41
+ export function normalizeCoreOp(op) {
42
+ const raw = String(op ?? '').trim().toLowerCase()
43
+ return CORE_OP_ALIASES[raw] ?? raw
44
+ }
45
+
32
46
  function trimOrNull(v) {
33
47
  if (v == null) return null
34
48
  const s = String(v).trim()
@@ -36,7 +50,10 @@ function trimOrNull(v) {
36
50
  }
37
51
 
38
52
  export function normalizeCoreInput(input = {}, options = {}) {
39
- const summary = trimOrNull(input.summary ?? input.content)
53
+ // Summary aliases: the field is `summary`, but callers routinely send the
54
+ // synonym they'd use in prose. Accepting them costs nothing and removes a
55
+ // measured class of bounced calls.
56
+ const summary = trimOrNull(input.summary ?? input.content ?? input.text ?? input.value ?? input.note)
40
57
  const element = trimOrNull(input.element) ?? (summary ? summary.slice(0, CORE_ELEMENT_DERIVE_LENGTH) : null)
41
58
  const suppliedCategory = trimOrNull(input.category)
42
59
  const category = (suppliedCategory ?? 'fact').toLowerCase()
@@ -1,43 +1,22 @@
1
1
  // Cycle LLM dispatch adapters, extracted from index.mjs (pass 3).
2
2
  //
3
- // Each cycleN scheduler/handler routes its LLM work through an in-process
4
- // makeAgentDispatch() adapter (the memory daemon runs the provider registry
5
- // locally, so the dead IPC agent path is never used). The factory built per
6
- // cycle is fixed (role/taskType), so it is memoized in a single-slot closure
7
- // and reshaped to the cycle call signature:
3
+ // Each cycleN scheduler/handler routes its LLM work through the backend's
4
+ // process-singleton agent broker. The memory process owns PG/embeddings only;
5
+ // provider/session modules are not duplicated here. The factory fixes the
6
+ // cycle role/taskType and reshapes to the cycle call signature:
8
7
  // callLlm({ role, taskType, mode, preset, timeout, cwd }, userMessage) -> string
9
- // while makeAgentDispatch's function takes a single { prompt } object. The
10
- // adapter maps the two — preset/cwd resolution is handled inside
11
- // makeAgentDispatch via role.
8
+ // while callAgentDispatch takes (opts, prompt).
12
9
  //
13
- // Pure factory: index.mjs injects makeAgentDispatch. No import-time side
10
+ // Pure factory: index.mjs injects callAgentDispatch. No import-time side
14
11
  // effects, no db/config coupling.
15
- export function createCycleLlmAdapters({ makeAgentDispatch }) {
12
+ export function createCycleLlmAdapters({ callAgentDispatch }) {
16
13
  function buildAdapter(agent) {
17
- let dispatch = null
18
14
  return async (opts = {}, userMessage) => {
19
- if (!dispatch) {
20
- dispatch = makeAgentDispatch({
21
- agent,
22
- taskType: 'maintenance',
23
- sourceType: 'memory-cycle',
24
- // The cycle agents parse the full raw line-format response; the
25
- // agent brief cap (12KB) would truncate a large valid response and
26
- // append prose, causing partial parsing / omitted / invalid chunks.
27
- // Opt out so the no-truncation contract is preserved.
28
- brief: false,
29
- })
30
- }
31
- // Preserve the cycle timeout contract: the caller derives opts.timeout
32
- // from config / caller deadline and expects it to bound the call.
33
- // makeAgentDispatch takes it as a per-call idleTimeoutMs (stale
34
- // watchdog). Map it through; omit when absent/0 so agent defaults apply.
35
- const callTimeout = Number(opts?.timeout)
36
- return dispatch({
37
- prompt: String(userMessage ?? ''),
38
- preset: opts?.preset || undefined,
39
- ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
40
- })
15
+ return callAgentDispatch({
16
+ ...opts,
17
+ agent,
18
+ taskType: 'maintenance',
19
+ }, String(userMessage ?? ''))
41
20
  }
42
21
  }
43
22
 
@@ -8,6 +8,7 @@ import { Worker } from 'worker_threads'
8
8
  import { join } from 'path'
9
9
  import { fileURLToPath } from 'url'
10
10
  import { writeProfilePoint } from './model-profile.mjs'
11
+ import { createCompactVectorCache } from './compact-vector-cache.mjs'
11
12
  import {
12
13
  getConfiguredEmbeddingModelId,
13
14
  getDefaultEmbeddingDtype,
@@ -33,27 +34,21 @@ let _warmupPromise = null
33
34
  let _embedCallCount = 0
34
35
  let _msgId = 0
35
36
  const _pending = new Map()
37
+ const retiringWorkers = new WeakSet()
36
38
  const EMBED_STEADY_SAMPLE_EVERY = 20
37
- const queryEmbeddingCache = new Map()
38
- const QUERY_EMBEDDING_CACHE_LIMIT = 1000
39
+ const queryEmbeddingCache = createCompactVectorCache({
40
+ maxEntries: 1000,
41
+ maxBytes: 8 * 1024 * 1024,
42
+ })
39
43
 
40
44
  const WORKER_PATH = join(fileURLToPath(import.meta.url), '..', 'embedding-worker.mjs')
41
45
 
42
46
  function cacheEmbedding(key, vector) {
43
- if (queryEmbeddingCache.has(key)) queryEmbeddingCache.delete(key)
44
47
  queryEmbeddingCache.set(key, vector)
45
- if (queryEmbeddingCache.size > QUERY_EMBEDDING_CACHE_LIMIT) {
46
- const oldestKey = queryEmbeddingCache.keys().next().value
47
- if (oldestKey) queryEmbeddingCache.delete(oldestKey)
48
- }
49
48
  }
50
49
 
51
50
  function getCachedEmbedding(key) {
52
- if (!queryEmbeddingCache.has(key)) return null
53
- const value = queryEmbeddingCache.get(key)
54
- queryEmbeddingCache.delete(key)
55
- queryEmbeddingCache.set(key, value)
56
- return value
51
+ return queryEmbeddingCache.get(key)
57
52
  }
58
53
 
59
54
  function ensureWorker() {
@@ -68,8 +63,16 @@ function ensureWorker() {
68
63
  }
69
64
  _lastRestartMs = now
70
65
  const execArgv = process.execArgv.filter((arg) => !String(arg).startsWith('--input-type'))
71
- worker = new Worker(WORKER_PATH, { env: { ...process.env }, execArgv })
72
- worker.on('message', (msg) => {
66
+ const created = new Worker(WORKER_PATH, { env: { ...process.env }, execArgv })
67
+ worker = created
68
+ const rejectWorkerPending = (error) => {
69
+ for (const [id, pending] of _pending) {
70
+ if (pending.worker !== created) continue
71
+ _pending.delete(id)
72
+ pending.reject(error)
73
+ }
74
+ }
75
+ created.on('message', (msg) => {
73
76
  if (msg.type === 'log') {
74
77
  // Worker-thread stdio forwarded via IPC (see embedding-worker.mjs
75
78
  // header): route through the parent's guardable stderr so TUI runs
@@ -85,8 +88,25 @@ function ensureWorker() {
85
88
  _modelReady = false
86
89
  _device = 'cpu'
87
90
  const reason = String(msg.reason || 'idle timeout')
88
- __mixdogMemoryLog(`[embed] ${reason} — model disposed\n`)
91
+ __mixdogMemoryLog(`[embed] ${reason} — model disposed; retiring worker thread\n`)
89
92
  writeProfilePoint({ phase: 'post-idle', model: MODEL_ID, device: msg.device, dtype: msg.dtype, note: reason })
93
+ // A request can be posted after the worker's idle timer starts disposal
94
+ // but before this notification reaches the parent. Do not terminate that
95
+ // worker out from under the accepted request; its next idle cycle will
96
+ // retire it after the request finishes.
97
+ const hasAcceptedRequest = [..._pending.values()]
98
+ .some((pending) => pending.worker === created)
99
+ if (hasAcceptedRequest) {
100
+ __mixdogMemoryLog(`[embed] ${reason} — worker retirement deferred for accepted request\n`)
101
+ return
102
+ }
103
+ // ORT's native allocator does not reliably return model pages when only
104
+ // InferenceSession.dispose() runs on Windows. Ending the worker thread is
105
+ // the actual native-memory boundary; the next recall lazily creates a
106
+ // fresh worker while cached dimensions remain available.
107
+ if (worker === created) worker = null
108
+ retiringWorkers.add(created)
109
+ void created.terminate().catch(() => {})
90
110
  return
91
111
  }
92
112
  const pending = _pending.get(msg.id)
@@ -98,28 +118,28 @@ function ensureWorker() {
98
118
  pending.resolve(msg)
99
119
  }
100
120
  })
101
- worker.on('error', (err) => {
121
+ created.on('error', (err) => {
102
122
  __mixdogMemoryLog(`[embed] worker error: ${err?.message || err}\n`)
103
- for (const [, p] of _pending) p.reject(err)
104
- _pending.clear()
105
- worker = null
123
+ rejectWorkerPending(err)
124
+ if (worker === created) worker = null
106
125
  _modelReady = false
107
126
  _restartCount++
108
127
  })
109
- worker.on('exit', (code) => {
128
+ created.on('exit', (code) => {
129
+ const retired = retiringWorkers.has(created)
130
+ retiringWorkers.delete(created)
110
131
  const exitError = new Error(`Worker exited with code ${code}`)
111
- if (code !== 0) {
132
+ if (!retired && code !== 0) {
112
133
  __mixdogMemoryLog(`[embed] worker exited with code ${code}\n`)
113
134
  _restartCount++
114
- } else {
135
+ } else if (!retired) {
115
136
  _restartCount = 0
116
137
  }
117
- for (const [, p] of _pending) p.reject(exitError)
118
- _pending.clear()
119
- worker = null
138
+ rejectWorkerPending(exitError)
139
+ if (worker === created) worker = null
120
140
  _modelReady = false
121
141
  })
122
- return worker
142
+ return created
123
143
  }
124
144
 
125
145
  const EMBED_WORKER_TIMEOUT_MS = 60_000
@@ -140,6 +160,7 @@ function sendToWorker(action, extra = {}, timeoutMs = EMBED_WORKER_TIMEOUT_MS) {
140
160
  reject(new Error(`embed worker ${action} timed out after ${timeoutMs}ms`))
141
161
  }, timeoutMs)
142
162
  _pending.set(id, {
163
+ worker: w,
143
164
  resolve: (v) => { clearTimeout(timer); resolve(v) },
144
165
  reject: (e) => { clearTimeout(timer); reject(e) },
145
166
  })
@@ -57,15 +57,11 @@ const execFileAsync = promisify(execFile)
57
57
  const _envLoadCores = Number(process.env.MIXDOG_EMBED_LOAD_CORES)
58
58
  const LOAD_AFFINITY_CORES = Number.isInteger(_envLoadCores) && _envLoadCores >= 1 ? _envLoadCores : 1
59
59
  const MODEL_CACHE_DIR = join(resolvePluginData(), 'memory-models')
60
- // Reclaim the model after a short idle window. The worker remains available,
61
- // so the next recall reloads on demand without keeping hundreds of MB resident
62
- // throughout an otherwise idle desktop session. Set 0 to opt into keep-alive.
60
+ // Reclaim the model after a short idle window. The parent retires this worker
61
+ // thread so the next recall reloads on demand without keeping hundreds of MB
62
+ // resident throughout an otherwise idle desktop session.
63
63
  const _envIdleMs = Number(process.env.MIXDOG_EMBED_IDLE_TIMEOUT_MS)
64
- const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0 ? _envIdleMs : 3 * 60_000
65
- const _envPressureMb = Number(process.env.MIXDOG_EMBED_PRESSURE_MIN_FREE_MB)
66
- const PRESSURE_MIN_FREE_BYTES = (
67
- Number.isFinite(_envPressureMb) && _envPressureMb >= 0 ? _envPressureMb : 1536
68
- ) * 1024 * 1024
64
+ const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0 ? _envIdleMs : 60_000
69
65
 
70
66
  // Defensive belt against giant model inputs. Callers should already bound text
71
67
  // (see memory-embed truncateForEmbed), but the worker is the last line before
@@ -87,6 +83,7 @@ let configuredDtype = DEFAULT_DTYPE
87
83
  let _device = 'cpu'
88
84
  let _idleTimer = null
89
85
  let _embedInFlight = false
86
+ let _reclaiming = false
90
87
  const _msgQueue = []
91
88
  let ortPatched = false
92
89
  // Control ops must never be overtaken by a priority embed: crossing a queued
@@ -155,44 +152,41 @@ function enqueue(msg) {
155
152
  // reset / ext.dispose() while the prior extractor is still being torn down.
156
153
  const GUARDED_ACTIONS = new Set(['embed', 'embed-batch', 'warmup', 'configure', 'dispose'])
157
154
 
158
- function disposeLoadedExtractor(reason) {
159
- if (!extractorPromise) return false
155
+ async function disposeLoadedExtractor(reason) {
156
+ if (!extractorPromise || _reclaiming) return false
157
+ _reclaiming = true
160
158
  const loaded = extractorPromise
161
159
  extractorPromise = null
162
160
  const prevDevice = _device
163
161
  _device = 'cpu'
164
- loaded.then(ext => { try { ext.dispose() } catch {} }).catch(() => {})
165
- __mixdogMemoryLog(`[embed-worker] ${reason} — model disposed\n`)
166
- parentPort.postMessage({
167
- type: 'idle-dispose',
168
- reason,
169
- device: prevDevice,
170
- dtype: configuredDtype,
171
- })
172
- return true
162
+ try {
163
+ try {
164
+ const ext = await loaded
165
+ try { await Promise.resolve(ext.dispose()) } catch {}
166
+ } catch {}
167
+ __mixdogMemoryLog(`[embed-worker] ${reason} — model disposed\n`)
168
+ parentPort.postMessage({
169
+ type: 'idle-dispose',
170
+ reason,
171
+ device: prevDevice,
172
+ dtype: configuredDtype,
173
+ })
174
+ return true
175
+ } finally {
176
+ _reclaiming = false
177
+ }
173
178
  }
174
179
 
175
180
  function resetIdleTimer() {
176
181
  if (_idleTimer) clearTimeout(_idleTimer)
177
182
  if (IDLE_TIMEOUT_MS <= 0) return
178
183
  _idleTimer = setTimeout(() => {
179
- if (!_embedInFlight) disposeLoadedExtractor('idle timeout')
184
+ if (!_embedInFlight) void disposeLoadedExtractor('idle timeout')
180
185
  _idleTimer = null
181
186
  }, IDLE_TIMEOUT_MS)
182
187
  _idleTimer.unref?.()
183
188
  }
184
189
 
185
- const _pressureTimer = setInterval(() => {
186
- if (PRESSURE_MIN_FREE_BYTES <= 0 || _embedInFlight || !extractorPromise) return
187
- let freeBytes = Number.POSITIVE_INFINITY
188
- try { freeBytes = Number(os.freemem()) } catch {}
189
- if (Number.isFinite(freeBytes) && freeBytes < PRESSURE_MIN_FREE_BYTES) {
190
- if (_idleTimer) { clearTimeout(_idleTimer); _idleTimer = null }
191
- disposeLoadedExtractor('host memory pressure')
192
- }
193
- }, 5_000)
194
- _pressureTimer.unref?.()
195
-
196
190
  // Set this process's CPU affinity to `mask` (bitmask of allowed logical
197
191
  // processors). Windows-only and best-effort: there is no Node API, so go
198
192
  // through PowerShell; a process may set its own affinity without elevation.
@@ -243,9 +237,6 @@ function patchOrtThreads() {
243
237
  async function loadExtractor() {
244
238
  if (!extractorPromise) {
245
239
  extractorPromise = (async () => {
246
- if (PRESSURE_MIN_FREE_BYTES > 0 && os.freemem() < PRESSURE_MIN_FREE_BYTES) {
247
- throw new Error('embedding model load deferred under host memory pressure')
248
- }
249
240
  parentPort.postMessage({ type: 'profile', record: { phase: 'baseline', model: MODEL_ID, device: _device, dtype: configuredDtype, note: 'pre-load' } })
250
241
  patchOrtThreads()
251
242
  const { pipeline, env } = await import('@huggingface/transformers')
@@ -441,6 +432,11 @@ async function drainQueue() {
441
432
  await processMessage(next)
442
433
  }
443
434
  _embedInFlight = false
435
+ // Idle means time since the LAST completed inference, not time since it
436
+ // started. A cold model load can outlive a short idle window; without this
437
+ // re-arm that timer fires while busy, is discarded, and the loaded ORT
438
+ // session then remains resident indefinitely.
439
+ if (extractorPromise && !_reclaiming) resetIdleTimer()
444
440
  }
445
441
 
446
442
  parentPort.on('message', async (msg) => {