@swarmclawai/swarmclaw 1.9.28 → 1.9.30

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.
package/README.md CHANGED
@@ -151,15 +151,23 @@ clawhub install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153
153
 
154
- ## v1.9.28 Highlights
154
+ ## v1.9.30 Highlights
155
155
 
156
- Issue-fix release for installed CLI groups, email bridge TLS handling, built-in model overrides, and Windows desktop native modules.
156
+ PR integration release for dream-model routing, email bridge TLS opt-outs, installed CLI runtime resolution, and an OpenClaw plugin workflow example.
157
157
 
158
- - **Installed CLI groups.** Global npm installs route legacy API-backed group commands through the bundled TS runtime when installed under `node_modules`, avoiding Node 22.6+/25 type-stripping failures.
159
- - **Email bridge TLS resilience.** The email connector logs IMAP socket errors without crashing the daemon and supports `tlsRejectUnauthorized=false` for local self-signed IMAP/SMTP servers.
160
- - **Provider model override persistence.** Built-in provider live model saves now reload array-valued overrides instead of falling back to catalog defaults.
161
- - **Windows desktop native modules.** Desktop packaging syncs rebuilt Electron-native modules into traced `.next/node_modules` aliases so packaged Windows installs start against the correct ABI.
162
- - **Regression coverage.** CLI, email, provider route, and Electron after-pack tests cover the reported failure modes.
158
+ - **Dream model routing.** Memory dream cycles and daily digests can use optional `dreamProvider` settings so background consolidation can run on a smaller local model.
159
+ - **Email bridge TLS opt-outs.** `tlsRejectUnauthorized=false` now disables hostname checks too, matching the explicit self-signed-server opt-out.
160
+ - **Installed CLI stability.** Legacy API-backed CLI commands import the package-local `tsx` runtime instead of resolving `tsx` from the caller's project.
161
+ - **OpenClaw plugin workflow.** README guidance now includes a concrete TweetClaw plugin workflow for OpenClaw operators.
162
+
163
+ ## v1.9.29 Highlights
164
+
165
+ Issue-fix release for Edit Agent tooltips, installed package builds, and structured dream output on local Ollama models.
166
+
167
+ - **Edit Agent tooltips.** Help tips in the Edit Agent sheet now render above modal layers instead of being hidden behind the dialog.
168
+ - **Installed package builds.** The npm package now ships the Dagre type declarations needed by `swarmclaw server --build`.
169
+ - **Local Ollama dream output.** Structured dream/reflection calls request Ollama JSON mode and validate balanced JSON before writing memories.
170
+ - **Regression coverage.** CLI/package, model-build, and dream-parser tests cover the reported failure modes.
163
171
 
164
172
  ## Hosted Deploys
165
173
 
@@ -220,6 +228,7 @@ SwarmClaw is built for OpenClaw operators who need more than one agent or one ga
220
228
  - Deploy official-image OpenClaw runtimes locally, via VPS bundles, or over SSH.
221
229
  - Edit OpenClaw agent files such as `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, and `AGENTS.md`.
222
230
  - Import OpenClaw `SKILL.md` files and use them in SwarmClaw's runtime skill system.
231
+ - Use OpenClaw plugins for domain workflows. For example, `openclaw plugins install @xquik/tweetclaw` installs [TweetClaw](https://github.com/Xquik-dev/tweetclaw) via [ClawHub](https://clawhub.ai/kriptoburak/xquik-tweetclaw) for X/Twitter search, follower export, monitors, webhooks, and approval-gated post/reply actions.
223
232
 
224
233
  ## Use Cases
225
234
 
@@ -411,6 +420,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
411
420
 
412
421
  ## Releases
413
422
 
423
+ ### v1.9.30 Highlights
424
+
425
+ PR integration release for dream-model routing, email bridge TLS opt-outs, installed CLI runtime resolution, and an OpenClaw plugin workflow example.
426
+
427
+ - **Dream model routing.** Memory dream cycles and daily digests can use optional `dreamProvider` settings so background consolidation can run on a smaller local model.
428
+ - **Email bridge TLS opt-outs.** `tlsRejectUnauthorized=false` now disables hostname checks too, matching the explicit self-signed-server opt-out.
429
+ - **Installed CLI stability.** Legacy API-backed CLI commands import the package-local `tsx` runtime instead of resolving `tsx` from the caller's project.
430
+ - **OpenClaw plugin workflow.** README guidance now includes a concrete TweetClaw plugin workflow for OpenClaw operators.
431
+
432
+ ### v1.9.29 Highlights
433
+
434
+ Issue-fix release for Edit Agent tooltips, installed package builds, and structured dream output on local Ollama models.
435
+
436
+ - **Edit Agent tooltips.** Help tips in the Edit Agent sheet now render above modal layers instead of being hidden behind the dialog.
437
+ - **Installed package builds.** The npm package now ships the Dagre type declarations needed by `swarmclaw server --build`.
438
+ - **Local Ollama dream output.** Structured dream/reflection calls request Ollama JSON mode and validate balanced JSON before writing memories.
439
+ - **Regression coverage.** CLI/package, model-build, and dream-parser tests cover the reported failure modes.
440
+
414
441
  ### v1.9.28 Highlights
415
442
 
416
443
  Issue-fix release for installed CLI groups, email bridge TLS handling, built-in model overrides, and Windows desktop native modules.
package/bin/swarmclaw.js CHANGED
@@ -47,11 +47,14 @@ function supportsStripTypes() {
47
47
  }
48
48
 
49
49
  function hasTsxRuntime() {
50
+ return Boolean(resolveTsxRuntimeImportPath())
51
+ }
52
+
53
+ function resolveTsxRuntimeImportPath() {
50
54
  try {
51
- require.resolve('tsx/package.json')
52
- return true
55
+ return require.resolve('tsx')
53
56
  } catch {
54
- return false
57
+ return null
55
58
  }
56
59
  }
57
60
 
@@ -71,9 +74,10 @@ function buildLegacyTsCliArgs(cliPath, argv, options = {}) {
71
74
  return ['--no-warnings', '--experimental-strip-types', cliPath, ...argv]
72
75
  }
73
76
 
74
- const tsxAvailable = options.hasTsxRuntime ?? hasTsxRuntime()
77
+ const tsxImportPath = options.tsxImportPath ?? resolveTsxRuntimeImportPath()
78
+ const tsxAvailable = options.hasTsxRuntime ?? Boolean(tsxImportPath)
75
79
  if (tsxAvailable) {
76
- return ['--no-warnings', '--import', 'tsx', cliPath, ...argv]
80
+ return ['--no-warnings', '--import', tsxImportPath || 'tsx', cliPath, ...argv]
77
81
  }
78
82
 
79
83
  return null
@@ -374,6 +378,7 @@ module.exports = {
374
378
  normalizeLegacyTsCliArgv,
375
379
  pathIsInsideNodeModules,
376
380
  resolveLegacyTsCliPath,
381
+ resolveTsxRuntimeImportPath,
377
382
  TS_CLI_ACTIONS,
378
383
  normalizeLegacyCliEnv,
379
384
  printPackageVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.28",
3
+ "version": "1.9.30",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/memory/dream-service.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -119,6 +119,7 @@
119
119
  "@tailwindcss/postcss": "^4",
120
120
  "@tanstack/react-query": "^5.91.0",
121
121
  "@types/better-sqlite3": "^7.6.13",
122
+ "@types/dagre": "^0.7.54",
122
123
  "@types/mailparser": "^3.4.6",
123
124
  "@types/node": "^20",
124
125
  "@types/nodemailer": "^7.0.11",
@@ -179,7 +180,6 @@
179
180
  },
180
181
  "devDependencies": {
181
182
  "@electron/rebuild": "^3.7.2",
182
- "@types/dagre": "^0.7.54",
183
183
  "electron": "^33.3.0",
184
184
  "electron-builder": "^25.1.8",
185
185
  "eslint": "^9",
@@ -7,7 +7,7 @@ const fs = require('node:fs')
7
7
  const os = require('node:os')
8
8
  const path = require('node:path')
9
9
  const { spawnSync } = require('node:child_process')
10
- const { buildLegacyTsCliArgs, resolveLegacyTsCliPath } = require('../../bin/swarmclaw.js')
10
+ const { buildLegacyTsCliArgs, resolveLegacyTsCliPath, resolveTsxRuntimeImportPath } = require('../../bin/swarmclaw.js')
11
11
 
12
12
  const CLI_BIN = path.join(__dirname, '..', '..', 'bin', 'swarmclaw.js')
13
13
  const PACKAGE_JSON = require('../../package.json')
@@ -199,23 +199,38 @@ test('binary -v alias output matches package version', () => {
199
199
  assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
200
200
  })
201
201
 
202
+ test('package ships dagre type declarations required by installed builds', () => {
203
+ assert.equal(PACKAGE_JSON.dependencies.dagre, '^0.8.5')
204
+ assert.equal(PACKAGE_JSON.dependencies['@types/dagre'], '^0.7.54')
205
+ assert.equal(PACKAGE_JSON.devDependencies?.['@types/dagre'], undefined)
206
+ })
207
+
202
208
  test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
203
209
  const cliPath = path.join(APP_ROOT, 'src', 'cli', 'index.ts')
210
+ const tsxImportPath = resolveTsxRuntimeImportPath()
204
211
  const args = buildLegacyTsCliArgs(cliPath, ['runs', 'list'], {
205
212
  supportsStripTypes: false,
206
213
  hasTsxRuntime: true,
207
214
  })
208
215
 
209
- assert.deepEqual(args, ['--no-warnings', '--import', 'tsx', cliPath, 'runs', 'list'])
216
+ assert.deepEqual(args, ['--no-warnings', '--import', tsxImportPath, cliPath, 'runs', 'list'])
210
217
  })
211
218
 
212
219
  test('legacy TS launcher uses tsx instead of strip-types inside node_modules', () => {
213
220
  const cliPath = path.join(os.tmpdir(), 'node_modules', '@swarmclawai', 'swarmclaw', 'src', 'cli', 'index.ts')
221
+ const tsxImportPath = resolveTsxRuntimeImportPath()
214
222
  const args = buildLegacyTsCliArgs(cliPath, ['agents', 'list'], {
215
223
  supportsStripTypes: true,
216
224
  hasTsxRuntime: true,
217
225
  })
218
226
 
219
- assert.deepEqual(args, ['--no-warnings', '--import', 'tsx', cliPath, 'agents', 'list'])
227
+ assert.deepEqual(args, ['--no-warnings', '--import', tsxImportPath, cliPath, 'agents', 'list'])
220
228
  assert.equal(resolveLegacyTsCliPath(), path.join(APP_ROOT, 'src', 'cli', 'index.ts'))
221
229
  })
230
+
231
+ test('legacy TS launcher imports the package-local tsx runtime by absolute path', () => {
232
+ const tsxImportPath = resolveTsxRuntimeImportPath()
233
+
234
+ assert.equal(path.isAbsolute(tsxImportPath), true)
235
+ assert.match(tsxImportPath, /tsx/)
236
+ })
@@ -42,7 +42,7 @@ function TooltipContent({
42
42
  data-slot="tooltip-content"
43
43
  sideOffset={sideOffset}
44
44
  className={cn(
45
- "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
45
+ "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[1300] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
46
46
  className
47
47
  )}
48
48
  {...props}
@@ -250,6 +250,47 @@ test('buildChatModel disables parallel_tool_calls for Ollama local to avoid dupl
250
250
  assert.equal(llm.clientConfig?.baseURL, 'http://localhost:11434/v1')
251
251
  })
252
252
 
253
+ test('buildChatModel passes JSON format mode only to local Ollama structured calls', () => {
254
+ const local = buildChatModel({
255
+ provider: 'ollama',
256
+ model: 'gemma4:e4b',
257
+ ollamaMode: 'local',
258
+ apiKey: null,
259
+ responseFormat: 'json_object',
260
+ }) as ChatOpenAI & { modelKwargs?: Record<string, unknown> }
261
+
262
+ assert.equal(local.modelKwargs?.parallel_tool_calls, false)
263
+ assert.equal(local.modelKwargs?.format, 'json')
264
+
265
+ saveCredentials({
266
+ 'cred-ollama-cloud-json': {
267
+ id: 'cred-ollama-cloud-json',
268
+ provider: 'ollama',
269
+ name: 'Ollama Cloud',
270
+ encryptedKey: encryptKey('ollama-cloud-key'),
271
+ createdAt: Date.now(),
272
+ },
273
+ } as Record<string, {
274
+ id: string
275
+ provider: string
276
+ name: string
277
+ encryptedKey: string
278
+ createdAt: number
279
+ }>)
280
+
281
+ const cloud = buildChatModel({
282
+ provider: 'ollama',
283
+ model: 'glm-5:cloud',
284
+ ollamaMode: 'cloud',
285
+ apiKey: null,
286
+ credentialId: 'cred-ollama-cloud-json',
287
+ responseFormat: 'json_object',
288
+ }) as ChatOpenAI & { modelKwargs?: Record<string, unknown> }
289
+
290
+ assert.equal(cloud.modelKwargs?.parallel_tool_calls, false)
291
+ assert.equal(cloud.modelKwargs?.format, undefined)
292
+ })
293
+
253
294
  test('buildChatModel uses a reasoning_content-preserving bridge for DeepSeek', () => {
254
295
  const llm = buildChatModel({
255
296
  provider: 'deepseek',
@@ -18,6 +18,7 @@ const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
18
18
  const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
19
19
  export const OPENAI_COMPAT_MODEL_TIMEOUT_MS = 180_000
20
20
  export const OPENAI_COMPAT_MODEL_MAX_RETRIES = 2
21
+ export type GenerationResponseFormat = 'json_object'
21
22
 
22
23
  export interface GenerationModelPreference {
23
24
  provider?: string | null
@@ -27,6 +28,7 @@ export interface GenerationModelPreference {
27
28
  apiEndpoint?: string | null
28
29
  gatewayProfileId?: string | null
29
30
  thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high' | null
31
+ responseFormat?: GenerationResponseFormat | null
30
32
  }
31
33
 
32
34
  interface ResolvedGenerationModelConfig {
@@ -36,6 +38,7 @@ interface ResolvedGenerationModelConfig {
36
38
  apiKey: string | null
37
39
  apiEndpoint: string | null
38
40
  thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'
41
+ responseFormat?: GenerationResponseFormat
39
42
  }
40
43
 
41
44
  type OpenAiReasoningEffort = 'low' | 'medium' | 'high'
@@ -43,6 +46,7 @@ type ChatOpenAiConfig = ConstructorParameters<typeof ChatOpenAI>[0] & {
43
46
  modelKwargs?: {
44
47
  reasoning_effort?: OpenAiReasoningEffort
45
48
  parallel_tool_calls?: boolean
49
+ format?: 'json'
46
50
  }
47
51
  configuration?: {
48
52
  baseURL?: string
@@ -68,8 +72,9 @@ export function buildChatModel(opts: {
68
72
  credentialId?: string | null
69
73
  apiEndpoint?: string | null
70
74
  thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high'
75
+ responseFormat?: GenerationResponseFormat
71
76
  }) {
72
- const { provider, model, ollamaMode, apiKey, credentialId, apiEndpoint, thinkingLevel } = opts
77
+ const { provider, model, ollamaMode, apiKey, credentialId, apiEndpoint, thinkingLevel, responseFormat } = opts
73
78
  const resolvedCredentialId = resolveProviderCredentialId({ provider, ollamaMode: ollamaMode ?? null, credentialId })
74
79
  const resolvedApiKey = apiKey ?? resolveApiKeyFromCredential(resolvedCredentialId)
75
80
  const providers = getProviderList()
@@ -118,7 +123,10 @@ export function buildChatModel(opts: {
118
123
  timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
119
124
  maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
120
125
  configuration: { baseURL },
121
- modelKwargs: { parallel_tool_calls: false },
126
+ modelKwargs: {
127
+ parallel_tool_calls: false,
128
+ ...(responseFormat === 'json_object' && !runtime.useCloud ? { format: 'json' as const } : {}),
129
+ },
122
130
  })
123
131
  }
124
132
 
@@ -222,6 +230,7 @@ function resolvePreferredGenerationConfig(
222
230
  apiKey,
223
231
  apiEndpoint,
224
232
  thinkingLevel: candidate.thinkingLevel || undefined,
233
+ responseFormat: candidate.responseFormat || undefined,
225
234
  }
226
235
  }
227
236
  return null
@@ -232,6 +241,7 @@ export function resolveGenerationModelConfig(options?: {
232
241
  sessionId?: string | null
233
242
  agentId?: string | null
234
243
  excludeProviders?: string[]
244
+ responseFormat?: GenerationResponseFormat
235
245
  }): ResolvedGenerationModelConfig {
236
246
  const providers = getProviderList()
237
247
  const excludeProviders = new Set((options?.excludeProviders || []).map((value) => normalizePreferenceValue(value)).filter(Boolean))
@@ -252,7 +262,9 @@ export function resolveGenerationModelConfig(options?: {
252
262
  ...getAgentGenerationPreferences(sessionAgent),
253
263
  ...getAgentGenerationPreferences(directAgent),
254
264
  ], excludeProviders)
255
- if (resolved) return resolved
265
+ if (resolved) return options?.responseFormat
266
+ ? { ...resolved, responseFormat: options.responseFormat }
267
+ : resolved
256
268
 
257
269
  const sessionLabel = options?.sessionId ? `session "${options.sessionId}"` : null
258
270
  const agentLabel = options?.agentId ? `agent "${options.agentId}"` : null
@@ -271,6 +283,7 @@ export async function buildLLM(options?: {
271
283
  sessionId?: string | null
272
284
  agentId?: string | null
273
285
  excludeProviders?: string[]
286
+ responseFormat?: GenerationResponseFormat
274
287
  }) {
275
288
  const resolved = resolveGenerationModelConfig(options)
276
289
  return {
@@ -81,7 +81,10 @@ describe('email TLS configuration', () => {
81
81
  assert.equal(parseTlsRejectUnauthorized(false), false)
82
82
  assert.equal(parseTlsRejectUnauthorized('false'), false)
83
83
  assert.equal(parseTlsRejectUnauthorized('0'), false)
84
- assert.deepEqual(buildEmailTlsOptions({ tlsRejectUnauthorized: false }), { rejectUnauthorized: false })
84
+ const tls = buildEmailTlsOptions({ tlsRejectUnauthorized: false })
85
+ assert.equal(tls.rejectUnauthorized, false)
86
+ assert.equal(typeof tls.checkServerIdentity, 'function')
87
+ assert.equal(tls.checkServerIdentity?.('localhost', {} as never), undefined)
85
88
  })
86
89
 
87
90
  it('handles IMAP socket errors without leaving the emitter unhandled', () => {
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
+ import type { ConnectionOptions } from 'node:tls'
3
4
  import { ImapFlow } from 'imapflow'
4
5
  import { createTransport, type Transporter } from 'nodemailer'
5
6
  import { simpleParser } from 'mailparser'
@@ -34,6 +35,10 @@ interface ImapErrorEmitter {
34
35
  on(event: 'error', listener: (err: unknown) => void): unknown
35
36
  }
36
37
 
38
+ type EmailTlsOptions = Pick<ConnectionOptions, 'rejectUnauthorized' | 'checkServerIdentity'> & {
39
+ rejectUnauthorized: boolean
40
+ }
41
+
37
42
  export function buildAttachments(options?: OutboundSendOptions): MailAttachment[] {
38
43
  const source = options?.mediaPath
39
44
  if (!source) return []
@@ -59,8 +64,11 @@ export function parseTlsRejectUnauthorized(value: unknown): boolean {
59
64
  return true
60
65
  }
61
66
 
62
- export function buildEmailTlsOptions(config: Pick<EmailConfig, 'tlsRejectUnauthorized'>): { rejectUnauthorized: boolean } {
63
- return { rejectUnauthorized: config.tlsRejectUnauthorized !== false }
67
+ export function buildEmailTlsOptions(config: Pick<EmailConfig, 'tlsRejectUnauthorized'>): EmailTlsOptions {
68
+ const reject = config.tlsRejectUnauthorized !== false
69
+ return reject
70
+ ? { rejectUnauthorized: true }
71
+ : { rejectUnauthorized: false, checkServerIdentity: () => undefined }
64
72
  }
65
73
 
66
74
  export function attachImapErrorHandler(imap: ImapErrorEmitter, onDisconnected: () => void): void {
@@ -0,0 +1,22 @@
1
+ import type { GenerationModelPreference } from '@/lib/server/build-llm'
2
+ import type { AppSettings } from '@/types'
3
+
4
+ type DreamGenerationSettings = Pick<AppSettings, 'dreamProvider' | 'dreamModel' | 'dreamCredentialId' | 'dreamEndpoint'> | Record<string, unknown> | null | undefined
5
+
6
+ function optionalSettingString(value: unknown): string | undefined {
7
+ const normalized = typeof value === 'string' ? value.trim() : ''
8
+ return normalized || undefined
9
+ }
10
+
11
+ export function resolveDreamGenerationPreference(settings: DreamGenerationSettings): GenerationModelPreference | undefined {
12
+ const record = (settings || {}) as Record<string, unknown>
13
+ const provider = optionalSettingString(record.dreamProvider)
14
+ if (!provider) return undefined
15
+
16
+ return {
17
+ provider,
18
+ model: optionalSettingString(record.dreamModel),
19
+ credentialId: optionalSettingString(record.dreamCredentialId),
20
+ apiEndpoint: optionalSettingString(record.dreamEndpoint),
21
+ }
22
+ }
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { resolveDreamGenerationPreference } from './dream-generation-preference'
4
+ import { parseTier2DreamResponseText } from './dream-service'
5
+
6
+ describe('resolveDreamGenerationPreference', () => {
7
+ it('returns no preference when no dream provider is configured', () => {
8
+ assert.equal(resolveDreamGenerationPreference({}), undefined)
9
+ assert.equal(resolveDreamGenerationPreference({ dreamProvider: ' ' }), undefined)
10
+ })
11
+
12
+ it('builds a trimmed dream model preference from app settings', () => {
13
+ assert.deepEqual(resolveDreamGenerationPreference({
14
+ dreamProvider: ' ollama ',
15
+ dreamModel: ' gemma4:e4b ',
16
+ dreamCredentialId: ' cred-1 ',
17
+ dreamEndpoint: ' http://localhost:11434 ',
18
+ }), {
19
+ provider: 'ollama',
20
+ model: 'gemma4:e4b',
21
+ credentialId: 'cred-1',
22
+ apiEndpoint: 'http://localhost:11434',
23
+ })
24
+ })
25
+ })
26
+
27
+ describe('parseTier2DreamResponseText', () => {
28
+ it('parses a plain structured dream response', () => {
29
+ const parsed = parseTier2DreamResponseText(JSON.stringify({
30
+ consolidations: [{
31
+ sourceIds: ['mem-1', 'mem-2'],
32
+ title: 'Shared pattern',
33
+ content: 'Both memories describe the same workflow.',
34
+ }],
35
+ reflections: [{ title: 'Focus', content: 'The agent prefers short release loops.' }],
36
+ flagged: [{ memoryId: 'mem-3', reason: 'Contradicts the current release process.' }],
37
+ }))
38
+
39
+ assert.deepEqual(parsed?.consolidations?.[0]?.sourceIds, ['mem-1', 'mem-2'])
40
+ assert.equal(parsed?.reflections?.[0]?.title, 'Focus')
41
+ assert.equal(parsed?.flagged?.[0]?.memoryId, 'mem-3')
42
+ })
43
+
44
+ it('extracts fenced JSON with nested braces inside strings', () => {
45
+ const parsed = parseTier2DreamResponseText([
46
+ '```json',
47
+ '{',
48
+ ' "consolidations": [{',
49
+ ' "sourceIds": ["mem-1"],',
50
+ ' "title": "Payload shape",',
51
+ ' "content": "The JSON example was {\\"ok\\":true} and should stay intact."',
52
+ ' }]',
53
+ '}',
54
+ '```',
55
+ ].join('\n'))
56
+
57
+ assert.equal(parsed?.consolidations?.[0]?.content, 'The JSON example was {"ok":true} and should stay intact.')
58
+ })
59
+
60
+ it('rejects malformed or schema-incompatible responses', () => {
61
+ assert.equal(parseTier2DreamResponseText('no json here'), null)
62
+ assert.equal(parseTier2DreamResponseText('{"consolidations":[{"sourceIds":[123],"title":"Bad","content":"Bad"}]}'), null)
63
+ })
64
+ })
@@ -1,10 +1,12 @@
1
1
  import crypto from 'crypto'
2
+ import { z } from 'zod'
2
3
  import type { DreamCycle, DreamCycleResult, DreamConfig, DreamTrigger, Agent } from '@/types'
3
4
  import { DEFAULT_DREAM_CONFIG } from '@/types/dream'
4
5
  import { getMemoryDb } from '@/lib/server/memory/memory-db'
5
6
  import { saveDreamCycle } from '@/lib/server/memory/dream-cycles'
6
- import { errorMessage, safeJsonParse } from '@/lib/shared-utils'
7
+ import { errorMessage } from '@/lib/shared-utils'
7
8
  import { log } from '@/lib/server/logger'
9
+ import { resolveDreamGenerationPreference } from '@/lib/server/memory/dream-generation-preference'
8
10
 
9
11
  const TAG = 'dream-service'
10
12
 
@@ -102,6 +104,70 @@ interface Tier2Response {
102
104
  flagged?: Tier2Flagged[]
103
105
  }
104
106
 
107
+ const Tier2ResponseSchema = z.object({
108
+ consolidations: z.array(z.object({
109
+ sourceIds: z.array(z.string()),
110
+ title: z.string(),
111
+ content: z.string(),
112
+ })).optional(),
113
+ reflections: z.array(z.object({
114
+ title: z.string(),
115
+ content: z.string(),
116
+ })).optional(),
117
+ flagged: z.array(z.object({
118
+ memoryId: z.string(),
119
+ reason: z.string(),
120
+ })).optional(),
121
+ }).passthrough()
122
+
123
+ function findBalancedJsonObjectEnd(text: string, start: number): number {
124
+ let depth = 0
125
+ let inString = false
126
+ let escaped = false
127
+
128
+ for (let index = start; index < text.length; index += 1) {
129
+ const char = text[index]
130
+ if (inString) {
131
+ if (escaped) escaped = false
132
+ else if (char === '\\') escaped = true
133
+ else if (char === '"') inString = false
134
+ continue
135
+ }
136
+ if (char === '"') {
137
+ inString = true
138
+ continue
139
+ }
140
+ if (char === '{') depth += 1
141
+ else if (char === '}') depth -= 1
142
+ if (depth === 0) return index + 1
143
+ }
144
+ return -1
145
+ }
146
+
147
+ function extractFirstBalancedJsonObject(text: string): string | null {
148
+ const source = String(text || '')
149
+ for (let index = 0; index < source.length; index += 1) {
150
+ if (source[index] !== '{') continue
151
+ const end = findBalancedJsonObjectEnd(source, index)
152
+ if (end === -1) return null
153
+ return source.slice(index, end)
154
+ }
155
+ return null
156
+ }
157
+
158
+ export function parseTier2DreamResponseText(text: string): Tier2Response | null {
159
+ const jsonText = extractFirstBalancedJsonObject(text)
160
+ if (!jsonText) return null
161
+ let raw: unknown
162
+ try {
163
+ raw = JSON.parse(jsonText)
164
+ } catch {
165
+ return null
166
+ }
167
+ const parsed = Tier2ResponseSchema.safeParse(raw)
168
+ return parsed.success ? parsed.data : null
169
+ }
170
+
105
171
  export async function runTier2Dream(
106
172
  agentId: string,
107
173
  config: DreamConfig,
@@ -149,7 +215,9 @@ ${memoryLines.join('\n')}`
149
215
 
150
216
  try {
151
217
  const { buildLLM } = await import('@/lib/server/build-llm')
152
- const { llm } = await buildLLM({ agentId })
218
+ const { loadSettings } = await import('@/lib/server/settings/settings-repository')
219
+ const preferred = resolveDreamGenerationPreference(loadSettings())
220
+ const { llm } = await buildLLM({ agentId, preferred, responseFormat: 'json_object' })
153
221
  const { HumanMessage } = await import('@langchain/core/messages')
154
222
 
155
223
  const response = await llm.invoke([new HumanMessage(prompt)])
@@ -159,9 +227,10 @@ ${memoryLines.join('\n')}`
159
227
  ? response.content.map((b) => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('')
160
228
  : ''
161
229
 
162
- // Extract JSON from response (handle markdown code blocks)
163
- const jsonMatch = text.match(/\{[\s\S]*\}/)
164
- const parsed = safeJsonParse<Tier2Response>(jsonMatch?.[0] ?? '', {})
230
+ const parsed = parseTier2DreamResponseText(text) ?? {}
231
+ if (!parsed.consolidations && !parsed.reflections && !parsed.flagged) {
232
+ errors.push('Tier 2 dream response was not valid structured JSON.')
233
+ }
165
234
 
166
235
  // Process consolidations
167
236
  if (Array.isArray(parsed.consolidations)) {
@@ -38,6 +38,7 @@ after(() => {
38
38
  })
39
39
 
40
40
  test('runDailyConsolidation skips orphaned and CLI-only agent namespaces without reporting errors', async () => {
41
+ storage.saveSettings({})
41
42
  const db = memDb.getMemoryDb()
42
43
  const now = Date.now()
43
44
  const orphanId = 'live-orphan-agent'
@@ -87,3 +88,39 @@ test('runDailyConsolidation skips orphaned and CLI-only agent namespaces without
87
88
  false,
88
89
  )
89
90
  })
91
+
92
+ test('canCreateDailyDigestForAgent allows CLI-only agents when a dream model is configured', async () => {
93
+ const now = Date.now()
94
+ const agentId = 'dream-routed-cli-agent'
95
+ storage.saveAgents({
96
+ [agentId]: {
97
+ id: agentId,
98
+ name: 'Dream Routed CLI Agent',
99
+ description: '',
100
+ systemPrompt: '',
101
+ provider: 'claude-cli',
102
+ model: 'claude-sonnet-4-5',
103
+ credentialId: null,
104
+ fallbackCredentialIds: [],
105
+ apiEndpoint: null,
106
+ createdAt: now,
107
+ updatedAt: now,
108
+ } as Agent,
109
+ })
110
+
111
+ storage.saveSettings({})
112
+ assert.equal(
113
+ consolidation.canCreateDailyDigestForAgent(agentId, storage.loadAgents({ includeTrashed: true }), storage.loadSettings()),
114
+ false,
115
+ )
116
+
117
+ storage.saveSettings({
118
+ dreamProvider: 'ollama',
119
+ dreamModel: 'llama3.2',
120
+ dreamEndpoint: 'http://127.0.0.1:11434',
121
+ })
122
+ assert.equal(
123
+ consolidation.canCreateDailyDigestForAgent(agentId, storage.loadAgents({ includeTrashed: true }), storage.loadSettings()),
124
+ true,
125
+ )
126
+ })
@@ -4,6 +4,9 @@ import { resolveGenerationModelConfig } from '@/lib/server/build-llm'
4
4
  import { HumanMessage } from '@langchain/core/messages'
5
5
  import { errorMessage } from '@/lib/shared-utils'
6
6
  import { onNextIdleWindow } from '@/lib/server/runtime/idle-window'
7
+ import { loadSettings } from '@/lib/server/settings/settings-repository'
8
+ import { resolveDreamGenerationPreference } from '@/lib/server/memory/dream-generation-preference'
9
+ import type { AppSettings } from '@/types'
7
10
 
8
11
  let consolidationRegistered = false
9
12
  let compactionRegistered = false
@@ -35,14 +38,18 @@ export function registerCompactionIdleCallback(): void {
35
38
  })
36
39
  }
37
40
 
38
- function canCreateDailyDigestForAgent(
41
+ export function canCreateDailyDigestForAgent(
39
42
  agentId: string,
40
43
  agents: ReturnType<typeof loadAgents>,
44
+ settings: Partial<AppSettings> | Record<string, unknown> | null | undefined = loadSettings(),
41
45
  ): boolean {
42
46
  const agent = agents[agentId]
43
47
  if (!agent || agent.trashedAt) return false
44
48
  try {
45
- resolveGenerationModelConfig({ agentId })
49
+ resolveGenerationModelConfig({
50
+ agentId,
51
+ preferred: resolveDreamGenerationPreference(settings),
52
+ })
46
53
  return true
47
54
  } catch (err: unknown) {
48
55
  const message = errorMessage(err)
@@ -65,6 +72,7 @@ export async function runDailyConsolidation(): Promise<{
65
72
  const memDb = getMemoryDb()
66
73
  const counts = memDb.countsByAgent()
67
74
  const agents = loadAgents({ includeTrashed: true })
75
+ const settings = loadSettings()
68
76
  const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
69
77
  const digestTitle = `Daily digest: ${today}`
70
78
  const cutoff24h = Date.now() - 24 * 3600_000
@@ -76,7 +84,7 @@ export async function runDailyConsolidation(): Promise<{
76
84
  const agentId = agentKey
77
85
 
78
86
  try {
79
- if (!canCreateDailyDigestForAgent(agentId, agents)) continue
87
+ if (!canCreateDailyDigestForAgent(agentId, agents, settings)) continue
80
88
 
81
89
  // Check if digest already exists for today
82
90
  const existing = memDb.search(digestTitle, agentId)
@@ -109,9 +117,12 @@ export async function runDailyConsolidation(): Promise<{
109
117
  ...memoryLines,
110
118
  ].join('\n')
111
119
 
112
- // Use the target agent's configured generation provider
120
+ // Use an optional dream-model override before the target agent's generation provider.
113
121
  const { buildLLM } = await import('@/lib/server/build-llm')
114
- const { llm } = await buildLLM({ agentId })
122
+ const { llm } = await buildLLM({
123
+ agentId,
124
+ preferred: resolveDreamGenerationPreference(settings),
125
+ })
115
126
 
116
127
  const response = await llm.invoke([new HumanMessage(prompt)])
117
128
  const digestContent = typeof response.content === 'string'
@@ -27,6 +27,11 @@ export interface AppSettings {
27
27
  embeddingModel?: string | null
28
28
  embeddingCredentialId?: string | null
29
29
  embeddingEndpoint?: string | null
30
+ // Optional model override for memory consolidation and dream cycles.
31
+ dreamProvider?: string | null
32
+ dreamModel?: string | null
33
+ dreamCredentialId?: string | null
34
+ dreamEndpoint?: string | null
30
35
  loopMode?: LoopMode
31
36
  agentLoopRecursionLimit?: number
32
37
  delegationMaxDepth?: number