mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -52,12 +52,42 @@ test('media assets are organized by kind, provider, model, and local date while
52
52
 
53
53
  try {
54
54
  const store = await import(`./store.mjs?test=${Date.now()}`);
55
- const listed = store.listMediaAssets({ limit: 10 }).assets;
55
+ const beforeMigration = store.listMediaAssets({ limit: 10 }).assets;
56
+ assert.equal(beforeMigration[0].file, 'legacy-image.jpg',
57
+ 'read-only listing must not run the write-side layout migration');
58
+ const saved = store.saveMediaAsset({
59
+ kind: 'video',
60
+ lane: 'gemini',
61
+ model: 'veo:3/preview',
62
+ prompt: 'new clip',
63
+ options: { aspectRatio: '16:9' },
64
+ mime: 'video/mp4',
65
+ bytes: Buffer.from('new-video'),
66
+ });
67
+ const migrated = store.listMediaAssets({ limit: 10 }).assets;
68
+ const listed = ['legacy-image', 'legacy-video'].map((id) =>
69
+ migrated.find((entry) => entry.id === id));
56
70
  assert.equal(listed[0].file, 'images/gemini/image-alpha/2026-07-05/legacy-image.jpg');
57
71
  assert.equal(listed[1].file, 'videos/grok/video-beta/2026-07-05/legacy-video.mp4');
58
72
  assert.equal(existsSync(join(assetsDir, ...listed[0].file.split('/'))), true);
59
73
  assert.equal(existsSync(join(assetsDir, ...listed[1].file.split('/'))), true);
60
74
  assert.equal(existsSync(join(assetsDir, 'legacy-image.jpg')), false);
75
+ const cacheBeforeReads = store.mediaStoreCacheStats();
76
+ await Promise.all([
77
+ store.resolveMediaFile('legacy-image'),
78
+ store.resolveMediaFile('legacy-video'),
79
+ store.resolveMediaFile('legacy-image'),
80
+ ]);
81
+ const cacheAfterReads = store.mediaStoreCacheStats();
82
+ assert.equal(cacheAfterReads.indexDiskReads, cacheBeforeReads.indexDiskReads,
83
+ 'a thumbnail-resolution burst must reuse the parsed media index');
84
+ assert.ok(cacheAfterReads.indexCacheHits >= cacheBeforeReads.indexCacheHits + 3);
85
+ const cachedOnlyMiss = await store.resolveMediaFile('legacy-image', {
86
+ variant: 'thumb',
87
+ generate: false,
88
+ });
89
+ assert.equal(cachedOnlyMiss.available, false,
90
+ 'a local protocol probe must not start native rendition generation on a cache miss');
61
91
  assert.equal(
62
92
  (await store.readMediaAsset('legacy-video')).base64,
63
93
  Buffer.from('video').toString('base64'),
@@ -66,7 +96,10 @@ test('media assets are organized by kind, provider, model, and local date while
66
96
  // A tile-sized rendition is impossible for these stub bytes (and video
67
97
  // needs ffmpeg): the read must REPORT the miss instead of quietly
68
98
  // shipping the original, which is what made the remote gallery slow.
69
- const missing = await store.readMediaAsset('legacy-image', { variant: 'thumb' });
99
+ const missing = await store.readMediaAsset('legacy-image', {
100
+ variant: 'thumb',
101
+ generate: false,
102
+ });
70
103
  assert.equal(missing.available, false);
71
104
  assert.equal(missing.base64, '');
72
105
  // Callers that can afford full-size bytes (local IPC) opt in explicitly
@@ -74,20 +107,29 @@ test('media assets are organized by kind, provider, model, and local date while
74
107
  const downgraded = await store.readMediaAsset('legacy-image', {
75
108
  variant: 'thumb',
76
109
  allowOriginal: true,
110
+ generate: false,
77
111
  });
78
112
  assert.equal(downgraded.variant, 'original');
79
113
  assert.equal(downgraded.downgraded, true);
80
114
  assert.equal(downgraded.base64, Buffer.from('image').toString('base64'));
81
-
82
- const saved = store.saveMediaAsset({
83
- kind: 'video',
84
- lane: 'gemini',
85
- model: 'veo:3/preview',
86
- prompt: 'new clip',
87
- options: { aspectRatio: '16:9' },
88
- mime: 'video/mp4',
89
- bytes: Buffer.from('new-video'),
115
+ const browserThumb = Buffer.from('browser-generated-thumbnail');
116
+ const cached = store.cacheMediaThumbnail('legacy-video', {
117
+ mime: 'image/jpeg',
118
+ base64: browserThumb.toString('base64'),
119
+ durationSeconds: 7,
90
120
  });
121
+ assert.equal(cached.available, true);
122
+ const cachedTarget = await store.resolveMediaFile('legacy-video', {
123
+ variant: 'thumb',
124
+ generate: false,
125
+ });
126
+ assert.equal(cachedTarget.available, true,
127
+ 'a cache-only local protocol probe must serve a browser-generated rendition');
128
+ const cachedRead = await store.readMediaAsset('legacy-video', { variant: 'thumb' });
129
+ assert.equal(cachedRead.mime, 'image/jpeg');
130
+ assert.equal(cachedRead.base64, browserThumb.toString('base64'));
131
+ assert.equal(cachedRead.durationSeconds, 7);
132
+
91
133
  assert.match(saved.file, /^videos\/gemini\/veo-3-preview\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+\.mp4$/);
92
134
  assert.equal(existsSync(join(assetsDir, ...saved.file.split('/'))), true);
93
135
  assert.equal(store.deleteMediaAsset(saved.id).removed, true);
@@ -4,6 +4,15 @@ import { __mixdogMemoryLog } from './lib/memory-log.mjs';
4
4
  process.removeAllListeners('warning')
5
5
  process.on('warning', () => {})
6
6
 
7
+ // V8 compile cache: the memory runtime is its own long-lived child process
8
+ // (spawned by the channel daemon / standalone proxy) and never passes through
9
+ // cli.mjs, so it pays full ESM parse cost on every boot without this. Same
10
+ // best-effort guard as cli.mjs.
11
+ try {
12
+ const { enableCompileCache } = await import('node:module')
13
+ enableCompileCache?.()
14
+ } catch { /* launch-speed optimization only */ }
15
+
7
16
  import http from 'node:http'
8
17
  import os from 'node:os'
9
18
  import { performance } from 'node:perf_hooks'
@@ -55,21 +64,21 @@ import {
55
64
  sessionMessageContentForIngest,
56
65
  shouldExcludeIngestMessage,
57
66
  } from './lib/session-ingest.mjs'
58
- import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
67
+ import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, shutdownEmbeddingProvider, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
59
68
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
60
69
  import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, flushRawEmbeddings, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
61
70
  import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
62
71
  import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
63
72
  import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
64
73
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
65
- import { claimAndMarkScheduledCycle, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
74
+ import { claimAndMarkScheduledCycle, markCycleRequest, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
66
75
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
67
76
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
68
77
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
69
78
  import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
70
79
  import { computeEntryScore } from './lib/memory-score.mjs'
71
80
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
72
- import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
81
+ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate } from './lib/core-memory-store.mjs'
73
82
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
74
83
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
75
84
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
@@ -79,8 +88,7 @@ import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, se
79
88
  import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
80
89
  import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
81
90
  import { createTranscriptIngest } from './lib/transcript-ingest.mjs'
82
- import { createEmbeddingWarmup } from './lib/embedding-warmup.mjs'
83
- import { init as initKoMorph } from './lib/ko-morph.mjs'
91
+ import { init as initKoMorph, reset as resetKoMorph } from './lib/ko-morph.mjs'
84
92
  import { createCycleLlmAdapters } from './lib/cycle-llm-adapters.mjs'
85
93
  import { createCycleScheduler } from './lib/cycle-scheduler.mjs'
86
94
  import { createQueryHandlers } from './lib/query-handlers.mjs'
@@ -260,15 +268,6 @@ const _sessionIngest = createSessionIngestRuntime({
260
268
  })
261
269
  const { ingestSessionMessages } = _sessionIngest
262
270
 
263
- // Boot-edge embedding warmup queue (extracted to lib/embedding-warmup.mjs).
264
- const _embeddingWarmup = createEmbeddingWarmup({
265
- canStart: embeddingWarmupCanStart,
266
- warmup: warmupEmbeddingProvider,
267
- getDims: getEmbeddingDims,
268
- persistMeta: (metaPath, value) => writeJsonAtomicSync(metaPath, value, { lock: true }),
269
- log: __mixdogMemoryLog,
270
- })
271
-
272
271
  // DATA_DIR-bound wrappers over the extracted pure flag helpers (see
273
272
  // ./lib/memory-config-flags.mjs). The pg-attach check needs DATA_DIR, which is
274
273
  // module-local here.
@@ -324,20 +323,9 @@ async function _initStore() {
324
323
  if (dimsResolved) {
325
324
  primeEmbeddingDims(dimsResolved)
326
325
  assertSecondaryPgAttachable()
327
- // Start embedding + kiwi warmup NOW so they run on their worker threads
328
- // during the PG ensure I/O-wait window below: openDatabase spawns/awaits
329
- // Postgres (I/O-bound, not CPU), so overlapping warmup there is safe and
330
- // lands a cold boot's first recall dense instead of lexical-fallback.
331
- // Both are fire-and-forget; the boot-complete fireDeferred/initKoMorph edge
332
- // stays an idempotent no-op fallback. schedule() respects canStart
333
- // (secondary/env-disabled skip → fireDeferred no-op); kiwi skips secondary.
334
- _embeddingWarmup.schedule(EMBEDDING_META_PATH, metaKey)
335
- memoryProfile('embedding:warmup:fire')
336
- _embeddingWarmup.fireDeferred()
337
- if (!memorySecondaryMode()) {
338
- memoryProfile('ko-morph:warmup:fire')
339
- initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
340
- }
326
+ // Known dimensions are enough to open the vector schema. Keep the ONNX
327
+ // model and the ~560 MB Kiwi analyzer completely off the boot path; the
328
+ // first relevant query starts them on demand without blocking startup.
341
329
  const openStartedAt = performance.now()
342
330
  db = await openDatabase(DATA_DIR, dimsResolved)
343
331
  memoryProfile('open-db:done', { ms: (performance.now() - openStartedAt).toFixed(1), dims: dimsResolved })
@@ -352,12 +340,6 @@ async function _initStore() {
352
340
  memoryProfile('embedding:cold-warmup:done', { ms: (performance.now() - warmupStartedAt).toFixed(1) })
353
341
  dimsResolved = Number(getEmbeddingDims())
354
342
  assertSecondaryPgAttachable()
355
- // Embedding is already warm (awaited above); still fire kiwi during the PG
356
- // ensure I/O-wait so the first recall's FTS path is morph-aware too.
357
- if (!memorySecondaryMode()) {
358
- memoryProfile('ko-morph:warmup:fire')
359
- initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
360
- }
361
343
  const openStartedAt = performance.now()
362
344
  db = await openDatabase(DATA_DIR, dimsResolved)
363
345
  memoryProfile('open-db:done', { ms: (performance.now() - openStartedAt).toFixed(1), dims: dimsResolved })
@@ -478,6 +460,7 @@ const _cycleScheduler = createCycleScheduler({
478
460
  flushRawEmbeddings,
479
461
  getInFlightCycle1,
480
462
  claimAndMarkScheduledCycle,
463
+ markCycleRequest,
481
464
  resolveCoalesceMaxRetries,
482
465
  scheduleCoalescedCycleRetry,
483
466
  scheduledCycle1Signature,
@@ -535,20 +518,6 @@ async function _initRuntime() {
535
518
  __mixdogMemoryLog('[memory-service] background cycle tick loop not started (secondary/env-disabled)\n')
536
519
  }
537
520
  _initialized = true
538
- // Boot complete — continue straight into the deferred embedding warmup.
539
- // Fire-and-forget on the embedding worker thread; never awaited so it does
540
- // not delay init() returning or the memory-ready signal.
541
- memoryProfile('embedding:warmup:fire')
542
- _embeddingWarmup.fireDeferred()
543
- // Boot-edge Korean morph warmup. Same fire-and-forget style: lazy async init
544
- // downloads+caches the Kiwi model once under DATA_DIR/kiwi-model/<version>,
545
- // then builds the WASM analyzer. On any failure ko-morph stays null and
546
- // buildFtsQuery keeps the websearch_to_tsquery fallback (never throws here).
547
- // Skipped in secondary mode (query path lives on the primary).
548
- if (!memorySecondaryMode()) {
549
- memoryProfile('ko-morph:warmup:fire')
550
- initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
551
- }
552
521
  memoryProfile('runtime-init:done', { ms: (performance.now() - runtimeStartedAt).toFixed(1) })
553
522
  }
554
523
 
@@ -569,6 +538,9 @@ const __queryHandlers = createQueryHandlers({
569
538
  log: __mixdogMemoryLog,
570
539
  resolveProjectScope,
571
540
  embeddingWarmupCanStart,
541
+ ensureKoMorph: () => memorySecondaryMode()
542
+ ? Promise.resolve(false)
543
+ : initKoMorph(DATA_DIR, __mixdogMemoryLog),
572
544
  getBootTimestamp: () => _bootTimestamp,
573
545
  getTraceDb: () => _traceDb,
574
546
  })
@@ -598,6 +570,7 @@ const _actionHandlers = createMemoryActionHandlers({
598
570
  startCycle1Run: _startCycle1Run,
599
571
  finalizeCycle2Run: _finalizeCycle2Run,
600
572
  finalizeCycle3Run: _finalizeCycle3Run,
573
+ requestCycle3Review: _cycleScheduler.requestCycle3Review,
601
574
  getSchedulerCycle1InFlight: () => _cycleScheduler.getCycle1InFlight(),
602
575
  getCycle2CallLlm,
603
576
  getCycle3CallLlm,
@@ -703,9 +676,12 @@ export async function stop() {
703
676
  _stopPromise = (async () => {
704
677
  _stopCycles()
705
678
  _memoryPortAdvertiser.reset()
706
- _embeddingWarmup.reset()
707
679
  _daemonLifecycle.reset()
708
- await stopLlmWorker()
680
+ await Promise.allSettled([
681
+ stopLlmWorker(),
682
+ shutdownEmbeddingProvider(),
683
+ ])
684
+ resetKoMorph()
709
685
  resetHttpListenErrorHandler()
710
686
  if (_httpBoundPort != null || _httpReadyPromise) {
711
687
  await new Promise(resolve => {
@@ -4,7 +4,7 @@ import { cachedEmbedTextBatch } from './memory-embed.mjs'
4
4
  import { callAgentDispatch } from './agent-ipc.mjs'
5
5
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
6
6
  import { checkedConnect } from './pg/adapter.mjs'
7
- import { __mixdogMemoryLog, CORE_DEDUP_TOP_K, CORE_SUMMARY_MAX, _getDb, throwIfAborted, addCore } from './core-memory-store.mjs';
7
+ import { __mixdogMemoryLog, CORE_DEDUP_TOP_K, _getDb, throwIfAborted, addCore } from './core-memory-store.mjs';
8
8
 
9
9
  //
10
10
  // nominateCoreCandidates flags strong active entries as core-memory
@@ -225,12 +225,7 @@ export async function promoteCoreCandidate(dataDir, id, options = {}) {
225
225
  if (rowPid != null && rowPid !== scope) {
226
226
  throw new Error(`candidate id=${numId} belongs to project "${rowPid}", not the resolved scope "${scope ?? 'common'}"`)
227
227
  }
228
- // Core summary cap: candidate summaries may exceed CORE_SUMMARY_MAX. Prefer
229
- // the explicit override, else compress by truncation so addCore accepts it.
230
228
  const summary = options?.summary ?? cur.summary
231
- const cappedSummary = summary && String(summary).length > CORE_SUMMARY_MAX
232
- ? String(summary).slice(0, CORE_SUMMARY_MAX)
233
- : summary
234
229
  const now = Date.now()
235
230
  // Phase 1 — claim (active+candidate guarded so a concurrent archive/promote
236
231
  // loses the race → 0 rows → nothing to promote). Embedding NOT nulled here
@@ -251,7 +246,7 @@ export async function promoteCoreCandidate(dataDir, id, options = {}) {
251
246
  try {
252
247
  entry = await addCore(
253
248
  dataDir,
254
- { element: cur.element, summary: cappedSummary, category: cur.category },
249
+ { element: cur.element, summary, category: cur.category },
255
250
  rowPid,
256
251
  )
257
252
  } catch (err) {
@@ -27,8 +27,7 @@ const VALID_CAT = new Set([
27
27
  const SIM_RECALL = 0.65
28
28
  export const CORE_DEDUP_TOP_K = 5
29
29
 
30
- export const CORE_SUMMARY_MAX = 100
31
- const CORE_ELEMENT_MAX = 40
30
+ const CORE_ELEMENT_DERIVE_LENGTH = 40
32
31
 
33
32
  function trimOrNull(v) {
34
33
  if (v == null) return null
@@ -38,7 +37,7 @@ function trimOrNull(v) {
38
37
 
39
38
  export function normalizeCoreInput(input = {}, options = {}) {
40
39
  const summary = trimOrNull(input.summary ?? input.content)
41
- const element = trimOrNull(input.element) ?? (summary ? summary.slice(0, CORE_ELEMENT_MAX) : null)
40
+ const element = trimOrNull(input.element) ?? (summary ? summary.slice(0, CORE_ELEMENT_DERIVE_LENGTH) : null)
42
41
  const suppliedCategory = trimOrNull(input.category)
43
42
  const category = (suppliedCategory ?? 'fact').toLowerCase()
44
43
  const errors = []
@@ -46,12 +45,6 @@ export function normalizeCoreInput(input = {}, options = {}) {
46
45
  if (options.requireElement && !element) errors.push('element required')
47
46
  if (options.requireSummary && !summary) errors.push('summary required')
48
47
  if (options.requireCategory && !suppliedCategory) errors.push('category required')
49
- if (element && element.length > CORE_ELEMENT_MAX) {
50
- errors.push(`element too long (${element.length}/${CORE_ELEMENT_MAX} chars, remove ${element.length - CORE_ELEMENT_MAX})`)
51
- }
52
- if (summary && summary.length > CORE_SUMMARY_MAX) {
53
- errors.push(`summary too long (${summary.length}/${CORE_SUMMARY_MAX} chars, remove ${summary.length - CORE_SUMMARY_MAX})`)
54
- }
55
48
  if (suppliedCategory && !VALID_CAT.has(category)) {
56
49
  errors.push(`invalid category "${category}". Valid: ${[...VALID_CAT].join(', ')}`)
57
50
  }
@@ -62,6 +62,7 @@ export function createCycleScheduler(deps) {
62
62
  flushRawEmbeddings,
63
63
  getInFlightCycle1,
64
64
  claimAndMarkScheduledCycle,
65
+ markCycleRequest,
65
66
  resolveCoalesceMaxRetries,
66
67
  scheduleCoalescedCycleRetry,
67
68
  scheduledCycle1Signature,
@@ -362,6 +363,14 @@ export function createCycleScheduler(deps) {
362
363
  }, retryConfig, signature)
363
364
  }
364
365
 
366
+ async function requestCycle3Review(reason = 'core-mutation') {
367
+ const config = getConfig() || readMainConfig() || {}
368
+ const signature = scheduledCycle3Signature(config)
369
+ const marked = await markCycleRequest(getDb(), 'cycle3', reason, signature)
370
+ if (marked) scheduleScheduledCycle3(config, signature)
371
+ return marked
372
+ }
373
+
365
374
  async function _finalizeCycle2Run(result) {
366
375
  if (result?.skippedInFlight) {
367
376
  log('[cycle2] skipped: in flight\n')
@@ -558,6 +567,7 @@ export function createCycleScheduler(deps) {
558
567
  await setCycleLastRun('cycle3', Date.now())
559
568
  markCycleDone('cycle3', true)
560
569
  },
570
+ requestCycle3Review,
561
571
  periodicCycle1Config,
562
572
  // lifecycle
563
573
  startCycles,
@@ -82,11 +82,11 @@ function ensureWorker() {
82
82
  return
83
83
  }
84
84
  if (msg.type === 'idle-dispose') {
85
- cachedDims = null
86
85
  _modelReady = false
87
86
  _device = 'cpu'
88
- __mixdogMemoryLog('[embed] idle timeout — model disposed\n')
89
- writeProfilePoint({ phase: 'post-idle', model: MODEL_ID, device: msg.device, dtype: msg.dtype, note: 'idle dispose' })
87
+ const reason = String(msg.reason || 'idle timeout')
88
+ __mixdogMemoryLog(`[embed] ${reason} — model disposed\n`)
89
+ writeProfilePoint({ phase: 'post-idle', model: MODEL_ID, device: msg.device, dtype: msg.dtype, note: reason })
90
90
  return
91
91
  }
92
92
  const pending = _pending.get(msg.id)
@@ -57,14 +57,15 @@ 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
- // Idle dispose was previously 15 min. Production profiling showed the model
61
- // re-load cost (~3 s DirectML cold start) repeating ~880×/4 h because cycle1
62
- // gaps exceed 15 min in normal use. Default to keep-alive (0) so the model
63
- // stays resident for the lifetime of the worker. Set MIXDOG_EMBED_IDLE_TIMEOUT_MS
64
- // to a positive value (in ms) to restore the prior dispose behaviour when GPU
65
- // VRAM pressure is a concern.
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.
66
63
  const _envIdleMs = Number(process.env.MIXDOG_EMBED_IDLE_TIMEOUT_MS)
67
- const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0 ? _envIdleMs : 0
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
68
69
 
69
70
  // Defensive belt against giant model inputs. Callers should already bound text
70
71
  // (see memory-embed truncateForEmbed), but the worker is the last line before
@@ -98,6 +99,15 @@ const CONTROL_ACTIONS = new Set(['configure', 'dispose', 'warmup'])
98
99
  // under sustained interactive traffic. MIXDOG_EMBED_PRIORITY_SKIP_CAP overrides.
99
100
  const _envSkipCap = Number(process.env.MIXDOG_EMBED_PRIORITY_SKIP_CAP)
100
101
  const PRIORITY_SKIP_CAP = Number.isFinite(_envSkipCap) && _envSkipCap >= 1 ? Math.floor(_envSkipCap) : 8
102
+ // Bounded backlog: the queue previously grew without limit while a slow embed
103
+ // held the in-flight guard — a flush storm could park thousands of messages
104
+ // (each carrying full text payloads) in worker memory until the caller's 60s
105
+ // timeout killed the whole worker. Reject new INFERENCE work beyond the cap
106
+ // with an immediate error reply instead; control ops (configure/dispose/
107
+ // warmup) always queue so lifecycle invariants can never be dropped.
108
+ // MIXDOG_EMBED_QUEUE_MAX overrides (default 128).
109
+ const _envQueueMax = Number(process.env.MIXDOG_EMBED_QUEUE_MAX)
110
+ const QUEUE_MAX = Number.isFinite(_envQueueMax) && _envQueueMax >= 1 ? Math.floor(_envQueueMax) : 128
101
111
  // Queue with a single priority lane. Interactive query embeds (msg.priority)
102
112
  // jump ahead of background flush embed-batch work so an in-progress large
103
113
  // flush cannot starve a live recall's query vector. Constraints:
@@ -106,6 +116,16 @@ const PRIORITY_SKIP_CAP = Number.isFinite(_envSkipCap) && _envSkipCap >= 1 ? Mat
106
116
  // - never preempt the running ONNX call,
107
117
  // - never overtake the same flush batch more than PRIORITY_SKIP_CAP times.
108
118
  function enqueue(msg) {
119
+ if (!CONTROL_ACTIONS.has(msg?.action) && _msgQueue.length >= QUEUE_MAX) {
120
+ try {
121
+ parentPort.postMessage({
122
+ id: msg?.id,
123
+ type: 'error',
124
+ message: `embed worker queue full (max ${QUEUE_MAX}); retry after the embedding backlog drains`,
125
+ })
126
+ } catch { /* reply must never break the worker */ }
127
+ return
128
+ }
109
129
  if (!msg?.priority) { _msgQueue.push(msg); return }
110
130
  // Barrier: insertion must land after the last queued control op.
111
131
  let lastControl = -1
@@ -135,22 +155,44 @@ function enqueue(msg) {
135
155
  // reset / ext.dispose() while the prior extractor is still being torn down.
136
156
  const GUARDED_ACTIONS = new Set(['embed', 'embed-batch', 'warmup', 'configure', 'dispose'])
137
157
 
158
+ function disposeLoadedExtractor(reason) {
159
+ if (!extractorPromise) return false
160
+ const loaded = extractorPromise
161
+ extractorPromise = null
162
+ const prevDevice = _device
163
+ _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
173
+ }
174
+
138
175
  function resetIdleTimer() {
139
176
  if (_idleTimer) clearTimeout(_idleTimer)
140
177
  if (IDLE_TIMEOUT_MS <= 0) return
141
178
  _idleTimer = setTimeout(() => {
142
- if (extractorPromise) {
143
- extractorPromise.then(ext => { try { ext.dispose() } catch {} }).catch(() => {})
144
- extractorPromise = null
145
- const prevDevice = _device
146
- _device = 'cpu'
147
- __mixdogMemoryLog('[embed-worker] idle timeout — model disposed\n')
148
- parentPort.postMessage({ type: 'idle-dispose', device: prevDevice, dtype: configuredDtype })
149
- }
179
+ if (!_embedInFlight) disposeLoadedExtractor('idle timeout')
150
180
  _idleTimer = null
151
181
  }, IDLE_TIMEOUT_MS)
182
+ _idleTimer.unref?.()
152
183
  }
153
184
 
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
+
154
196
  // Set this process's CPU affinity to `mask` (bitmask of allowed logical
155
197
  // processors). Windows-only and best-effort: there is no Node API, so go
156
198
  // through PowerShell; a process may set its own affinity without elevation.
@@ -201,6 +243,9 @@ function patchOrtThreads() {
201
243
  async function loadExtractor() {
202
244
  if (!extractorPromise) {
203
245
  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
+ }
204
249
  parentPort.postMessage({ type: 'profile', record: { phase: 'baseline', model: MODEL_ID, device: _device, dtype: configuredDtype, note: 'pre-load' } })
205
250
  patchOrtThreads()
206
251
  const { pipeline, env } = await import('@huggingface/transformers')
@@ -230,6 +230,10 @@ export function createHttpRouter({
230
230
  const body = await readBody(req)
231
231
  const projectId = normalizeCoreProjectId(body.project_id)
232
232
  const entry = await addCore(DATA_DIR, body, projectId)
233
+ if (typeof cycleScheduler?.requestCycle3Review === 'function') {
234
+ try { await cycleScheduler.requestCycle3Review('core-http-add') }
235
+ catch (err) { log?.(`[core-memory] HTTP follow-up review queue failed: ${err?.message || err}\n`) }
236
+ }
233
237
  sendJson(res, { ok: true, item: entry })
234
238
  } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
235
239
  return
@@ -15,6 +15,7 @@
15
15
  // analyze(text) — sync; returns TokenInfo[] or null when not ready.
16
16
  // stems(text) — sync; content-morpheme stems (NNG/NNP/VV/VA/XR/SL) or null.
17
17
  import fs from 'node:fs'
18
+ import os from 'node:os'
18
19
  import path from 'node:path'
19
20
  import zlib from 'node:zlib'
20
21
 
@@ -49,11 +50,45 @@ let _initPromise = null
49
50
  let _kiwi = null
50
51
  let _log = () => {}
51
52
  let _initMs = 0
53
+ let _idleTimer = null
54
+ const _envIdleMs = Number(process.env.MIXDOG_KO_MORPH_IDLE_TIMEOUT_MS)
55
+ const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0
56
+ ? _envIdleMs
57
+ : 2 * 60_000
58
+ const _envPressureMb = Number(process.env.MIXDOG_KO_MORPH_PRESSURE_MIN_FREE_MB)
59
+ const PRESSURE_MIN_FREE_BYTES = (
60
+ Number.isFinite(_envPressureMb) && _envPressureMb >= 0 ? _envPressureMb : 1536
61
+ ) * 1024 * 1024
52
62
 
53
63
  export function isReady() { return _state === 'ready' && _kiwi != null }
54
64
  export function state() { return _state }
55
65
  function initLatencyMs() { return _initMs }
56
66
 
67
+ function releaseLoaded(reason = '') {
68
+ if (_idleTimer) clearTimeout(_idleTimer)
69
+ _idleTimer = null
70
+ const current = _kiwi
71
+ _kiwi = null
72
+ _state = 'idle'
73
+ _initPromise = null
74
+ _initMs = 0
75
+ try { current?.dispose?.() } catch {}
76
+ if (current && reason) _log(`[memory-service] kiwi morph ${reason} — analyzer released\n`)
77
+ }
78
+
79
+ function touchIdleTimer() {
80
+ if (_idleTimer) clearTimeout(_idleTimer)
81
+ let delay = IDLE_TIMEOUT_MS
82
+ try {
83
+ if (PRESSURE_MIN_FREE_BYTES > 0 && os.freemem() < PRESSURE_MIN_FREE_BYTES) delay = 1
84
+ } catch {}
85
+ if (delay <= 0) return
86
+ _idleTimer = setTimeout(() => releaseLoaded(
87
+ delay <= 1 ? 'host memory pressure' : 'idle timeout',
88
+ ), delay)
89
+ _idleTimer.unref?.()
90
+ }
91
+
57
92
  function modelDir(dataDir) {
58
93
  return path.join(dataDir, 'kiwi-model', KIWI_MODEL_VERSION)
59
94
  }
@@ -125,9 +160,18 @@ function readModelFiles(dir) {
125
160
 
126
161
  // Lazy, idempotent, never-throwing init. Fire-and-forget at boot.
127
162
  export async function init(dataDir, log = () => {}) {
128
- if (_state === 'ready') return true
129
- if (_initPromise) return _initPromise
130
163
  _log = typeof log === 'function' ? log : (() => {})
164
+ if (_state === 'ready') {
165
+ touchIdleTimer()
166
+ return true
167
+ }
168
+ if (_initPromise) return _initPromise
169
+ try {
170
+ if (PRESSURE_MIN_FREE_BYTES > 0 && os.freemem() < PRESSURE_MIN_FREE_BYTES) {
171
+ _log('[memory-service] kiwi morph load deferred under host memory pressure\n')
172
+ return false
173
+ }
174
+ } catch {}
131
175
  _state = 'loading'
132
176
  const t0 = Date.now()
133
177
  _log(`[memory-service] kiwi morph init start (model ${KIWI_MODEL_VERSION})\n`)
@@ -151,6 +195,7 @@ export async function init(dataDir, log = () => {}) {
151
195
  _state = 'ready'
152
196
  _initMs = Date.now() - t0
153
197
  _log(`[memory-service] kiwi morph ready in ${_initMs}ms (model ${KIWI_MODEL_VERSION}, rss≈${Math.round(process.memoryUsage().rss / 1e6)}MB)\n`)
198
+ touchIdleTimer()
154
199
  return true
155
200
  })().catch(err => {
156
201
  _state = 'failed'
@@ -168,6 +213,7 @@ export function analyze(text) {
168
213
  if (!s) return null
169
214
  try {
170
215
  const r = _kiwi.analyze(s)
216
+ touchIdleTimer()
171
217
  return Array.isArray(r?.tokens) ? r.tokens : null
172
218
  } catch {
173
219
  return null
@@ -189,8 +235,5 @@ export function stems(text) {
189
235
  }
190
236
 
191
237
  export function reset() {
192
- _state = 'idle'
193
- _initPromise = null
194
- _kiwi = null
195
- _initMs = 0
238
+ releaseLoaded()
196
239
  }