@mono-agent/agent-app 0.3.0 → 0.4.1

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 (186) hide show
  1. package/README.md +24 -10
  2. package/dist/adapter-send-tools-main.d.ts +3 -0
  3. package/dist/adapter-send-tools-main.d.ts.map +1 -0
  4. package/dist/adapter-send-tools-main.js +20 -0
  5. package/dist/adapter-send-tools-main.js.map +1 -0
  6. package/dist/adapter-send-tools.d.ts +125 -0
  7. package/dist/adapter-send-tools.d.ts.map +1 -0
  8. package/dist/adapter-send-tools.js +629 -0
  9. package/dist/adapter-send-tools.js.map +1 -0
  10. package/dist/app-config.d.ts +86 -26
  11. package/dist/app-config.d.ts.map +1 -1
  12. package/dist/app-config.js +284 -90
  13. package/dist/app-config.js.map +1 -1
  14. package/dist/app.d.ts +51 -20
  15. package/dist/app.d.ts.map +1 -1
  16. package/dist/app.js +889 -119
  17. package/dist/app.js.map +1 -1
  18. package/dist/artifact-retention.d.ts +32 -0
  19. package/dist/artifact-retention.d.ts.map +1 -0
  20. package/dist/artifact-retention.js +111 -0
  21. package/dist/artifact-retention.js.map +1 -0
  22. package/dist/audit-runs.d.ts +12 -0
  23. package/dist/audit-runs.d.ts.map +1 -0
  24. package/dist/audit-runs.js +68 -0
  25. package/dist/audit-runs.js.map +1 -0
  26. package/dist/backfill.d.ts +75 -0
  27. package/dist/backfill.d.ts.map +1 -0
  28. package/dist/backfill.js +350 -0
  29. package/dist/backfill.js.map +1 -0
  30. package/dist/background.d.ts +8 -5
  31. package/dist/background.d.ts.map +1 -1
  32. package/dist/background.js +279 -53
  33. package/dist/background.js.map +1 -1
  34. package/dist/broadcast-recorder.d.ts +27 -0
  35. package/dist/broadcast-recorder.d.ts.map +1 -0
  36. package/dist/broadcast-recorder.js +82 -0
  37. package/dist/broadcast-recorder.js.map +1 -0
  38. package/dist/channel-config-view.d.ts +28 -0
  39. package/dist/channel-config-view.d.ts.map +1 -0
  40. package/dist/channel-config-view.js +79 -0
  41. package/dist/channel-config-view.js.map +1 -0
  42. package/dist/channel-gate.d.ts +22 -0
  43. package/dist/channel-gate.d.ts.map +1 -0
  44. package/dist/channel-gate.js +35 -0
  45. package/dist/channel-gate.js.map +1 -0
  46. package/dist/channel-plugins.d.ts +39 -0
  47. package/dist/channel-plugins.d.ts.map +1 -0
  48. package/dist/channel-plugins.js +346 -0
  49. package/dist/channel-plugins.js.map +1 -0
  50. package/dist/channels.d.ts +62 -66
  51. package/dist/channels.d.ts.map +1 -1
  52. package/dist/channels.js +850 -120
  53. package/dist/channels.js.map +1 -1
  54. package/dist/cli.d.ts +103 -4
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +984 -112
  57. package/dist/cli.js.map +1 -1
  58. package/dist/config-reference.d.ts +24 -0
  59. package/dist/config-reference.d.ts.map +1 -0
  60. package/dist/config-reference.js +580 -0
  61. package/dist/config-reference.js.map +1 -0
  62. package/dist/configured-agent.d.ts +104 -0
  63. package/dist/configured-agent.d.ts.map +1 -0
  64. package/dist/configured-agent.js +578 -0
  65. package/dist/configured-agent.js.map +1 -0
  66. package/dist/consumer-contract.d.ts +36 -0
  67. package/dist/consumer-contract.d.ts.map +1 -0
  68. package/dist/consumer-contract.js +290 -0
  69. package/dist/consumer-contract.js.map +1 -0
  70. package/dist/doctor.d.ts +17 -0
  71. package/dist/doctor.d.ts.map +1 -1
  72. package/dist/doctor.js +392 -95
  73. package/dist/doctor.js.map +1 -1
  74. package/dist/index.d.ts +14 -6
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +7 -3
  77. package/dist/index.js.map +1 -1
  78. package/dist/init.d.ts +23 -7
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +91 -26
  81. package/dist/init.js.map +1 -1
  82. package/dist/install-skill.d.ts +1 -1
  83. package/dist/install-skill.js +2 -2
  84. package/dist/install-skill.js.map +1 -1
  85. package/dist/interaction-bridge.d.ts +72 -0
  86. package/dist/interaction-bridge.d.ts.map +1 -0
  87. package/dist/interaction-bridge.js +354 -0
  88. package/dist/interaction-bridge.js.map +1 -0
  89. package/dist/launchd.d.ts +0 -2
  90. package/dist/launchd.d.ts.map +1 -1
  91. package/dist/launchd.js +0 -2
  92. package/dist/launchd.js.map +1 -1
  93. package/dist/memory-command.d.ts +10 -0
  94. package/dist/memory-command.d.ts.map +1 -0
  95. package/dist/memory-command.js +649 -0
  96. package/dist/memory-command.js.map +1 -0
  97. package/dist/memory-recall-main.d.ts +3 -0
  98. package/dist/memory-recall-main.d.ts.map +1 -0
  99. package/dist/memory-recall-main.js +30 -0
  100. package/dist/memory-recall-main.js.map +1 -0
  101. package/dist/memory-recall.d.ts +147 -0
  102. package/dist/memory-recall.d.ts.map +1 -0
  103. package/dist/memory-recall.js +345 -0
  104. package/dist/memory-recall.js.map +1 -0
  105. package/dist/memory-rituals.d.ts +8 -11
  106. package/dist/memory-rituals.d.ts.map +1 -1
  107. package/dist/memory-rituals.js +26 -25
  108. package/dist/memory-rituals.js.map +1 -1
  109. package/dist/metrics.d.ts +13 -0
  110. package/dist/metrics.d.ts.map +1 -0
  111. package/dist/metrics.js +105 -0
  112. package/dist/metrics.js.map +1 -0
  113. package/dist/notify-destinations.d.ts +23 -0
  114. package/dist/notify-destinations.d.ts.map +1 -0
  115. package/dist/notify-destinations.js +79 -0
  116. package/dist/notify-destinations.js.map +1 -0
  117. package/dist/posted-message-index.d.ts +61 -0
  118. package/dist/posted-message-index.d.ts.map +1 -0
  119. package/dist/posted-message-index.js +178 -0
  120. package/dist/posted-message-index.js.map +1 -0
  121. package/dist/proactive-notify.d.ts +32 -0
  122. package/dist/proactive-notify.d.ts.map +1 -0
  123. package/dist/proactive-notify.js +62 -0
  124. package/dist/proactive-notify.js.map +1 -0
  125. package/dist/recipes/base.d.ts +15 -0
  126. package/dist/recipes/base.d.ts.map +1 -0
  127. package/dist/recipes/base.js +51 -0
  128. package/dist/recipes/base.js.map +1 -0
  129. package/dist/recipes/catalog.d.ts +4 -0
  130. package/dist/recipes/catalog.d.ts.map +1 -0
  131. package/dist/recipes/catalog.js +509 -0
  132. package/dist/recipes/catalog.js.map +1 -0
  133. package/dist/recipes/index.d.ts +11 -0
  134. package/dist/recipes/index.d.ts.map +1 -0
  135. package/dist/recipes/index.js +14 -0
  136. package/dist/recipes/index.js.map +1 -0
  137. package/dist/recipes/types.d.ts +70 -0
  138. package/dist/recipes/types.d.ts.map +1 -0
  139. package/dist/recipes/types.js +15 -0
  140. package/dist/recipes/types.js.map +1 -0
  141. package/dist/request-model-override.d.ts +67 -0
  142. package/dist/request-model-override.d.ts.map +1 -0
  143. package/dist/request-model-override.js +96 -0
  144. package/dist/request-model-override.js.map +1 -0
  145. package/dist/runs-health.d.ts +22 -0
  146. package/dist/runs-health.d.ts.map +1 -0
  147. package/dist/runs-health.js +144 -0
  148. package/dist/runs-health.js.map +1 -0
  149. package/dist/seen-conversations.d.ts +22 -0
  150. package/dist/seen-conversations.d.ts.map +1 -0
  151. package/dist/seen-conversations.js +120 -0
  152. package/dist/seen-conversations.js.map +1 -0
  153. package/dist/sessions.d.ts +21 -0
  154. package/dist/sessions.d.ts.map +1 -0
  155. package/dist/sessions.js +45 -0
  156. package/dist/sessions.js.map +1 -0
  157. package/dist/setup.d.ts +29 -0
  158. package/dist/setup.d.ts.map +1 -0
  159. package/dist/setup.js +97 -0
  160. package/dist/setup.js.map +1 -0
  161. package/dist/trigger-overrides.d.ts +17 -0
  162. package/dist/trigger-overrides.d.ts.map +1 -0
  163. package/dist/trigger-overrides.js +30 -0
  164. package/dist/trigger-overrides.js.map +1 -0
  165. package/dist/tui-command.d.ts +56 -0
  166. package/dist/tui-command.d.ts.map +1 -0
  167. package/dist/tui-command.js +160 -0
  168. package/dist/tui-command.js.map +1 -0
  169. package/dist/ui.d.ts +54 -0
  170. package/dist/ui.d.ts.map +1 -0
  171. package/dist/ui.js +157 -0
  172. package/dist/ui.js.map +1 -0
  173. package/dist/web-command.d.ts +39 -0
  174. package/dist/web-command.d.ts.map +1 -0
  175. package/dist/web-command.js +151 -0
  176. package/dist/web-command.js.map +1 -0
  177. package/package.json +22 -17
  178. package/schema/mono-agent.config.schema.json +1686 -0
  179. package/skills/mono-agent-composer/SKILL.md +38 -12
  180. package/skills/mono-agent-composer/agents/openai.yaml +4 -0
  181. package/skills/mono-agent-composer/references/config-blueprint.md +84 -31
  182. package/skills/mono-agent-composer/references/discovery-questions.md +49 -36
  183. package/skills/mono-agent-composer/references/feature-coverage.md +26 -19
  184. package/skills/mono-agent-composer/references/package-map.md +20 -22
  185. package/skills/mono-agent-composer/references/playbooks.md +209 -0
  186. package/skills/mono-agent-composer/references/validation.md +14 -5
package/dist/doctor.js CHANGED
@@ -1,8 +1,17 @@
1
- import { mkdir, stat } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { describeMonoRuntimeSupport } from "@mono-agent/runtime-adapter";
4
- import { isAppCoreConfigError, loadAppCoreConfig, resolveAppConsoleSettings } from "./app-config.js";
5
- import { defaultChannelDrivers } from "./channels.js";
1
+ import { constants } from "node:fs";
2
+ import { access, mkdir, readFile, stat } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { listRecordedRuns } from "@mono-agent/observability";
5
+ import { serializeTraceSpans } from "@mono-agent/observability/otel";
6
+ import { describeMonoRuntimeSupport, modelReferenceKey, parseMonoRuntimeModelReference } from "@mono-agent/runtime-adapter";
7
+ import { buildMonoAgentConfigView, findJsonSecretConfigWarnings, findRemovedConfigWarnings, readMonoAgentConfigJson, redactMonoAgentConfig, resolveSupermemoryContainer, } from "@mono-agent/config";
8
+ import { describeSandboxEffectiveState, resolveSandboxEffectiveState, sandboxEffectiveStateWarning, } from "@mono-agent/runtime-adapter";
9
+ import { describeSensitiveDataExportWarning, isAppCoreConfigError, loadAppCoreConfig, phoenixAppBaseUrl, resolveAppArtifactDir, resolveAppObservabilityExporters, } from "./app-config.js";
10
+ import { adapterSendToolNames, resolveAdapterSendToolsSettings } from "./adapter-send-tools.js";
11
+ import { collectChannelConfigViews } from "./channel-config-view.js";
12
+ import { resolveChannelDrivers } from "./channels.js";
13
+ import { findUnknownAppConfigWarnings } from "./config-reference.js";
14
+ import { buildRunsHealthDisplay, RUNS_HEALTH_MAX_RUNS } from "./runs-health.js";
6
15
  /**
7
16
  * Loads every config section the app would use at start and reports it
8
17
  * per-section, so an engineer can see exactly what would run, wait, or fail —
@@ -10,7 +19,9 @@ import { defaultChannelDrivers } from "./channels.js";
10
19
  */
11
20
  export async function validateMonoAgentFolder(options) {
12
21
  const sections = [];
13
- const drivers = options.drivers ?? defaultChannelDrivers();
22
+ const drivers = options.drivers ?? await resolveChannelDrivers(options);
23
+ const liveness = options.liveness ?? true;
24
+ const allowFilesystemWrites = options.allowFilesystemWrites ?? true;
14
25
  let coreConfig;
15
26
  try {
16
27
  coreConfig = await loadAppCoreConfig(options);
@@ -23,13 +34,33 @@ export async function validateMonoAgentFolder(options) {
23
34
  sections.push({ id: "core", label: "Core config", status: "error", details: [error.message] });
24
35
  }
25
36
  if (coreConfig !== undefined) {
37
+ const jsonResult = await readMonoAgentConfigJson(options.configPath);
38
+ // Channel secrets (bot tokens, API keys) live outside the core view, so the
39
+ // placement check spans both: core sections + every channel's config view.
40
+ const configWarnings = [
41
+ ...findUnknownAppConfigWarnings(jsonResult.json),
42
+ ...findJsonSecretConfigWarnings([
43
+ ...buildMonoAgentConfigView({
44
+ redacted: redactMonoAgentConfig(coreConfig),
45
+ json: jsonResult.json,
46
+ env: options.env,
47
+ }),
48
+ ...(await collectChannelConfigViews(drivers, options)),
49
+ ]),
50
+ ...findRemovedConfigWarnings({ json: jsonResult.json, env: options.env }),
51
+ ];
52
+ if (configWarnings.length > 0) {
53
+ sections.push({ id: "secret-placement", label: "Config warnings", status: "waiting", details: configWarnings });
54
+ }
26
55
  sections.push(runtimeSection(coreConfig));
56
+ sections.push(await credentialsSection(coreConfig));
27
57
  sections.push(await contextSection(coreConfig));
28
- sections.push(await memorySection(coreConfig));
29
- sections.push(await toolsSection(coreConfig));
30
- sections.push(sandboxSection(coreConfig));
58
+ sections.push(await memorySection(coreConfig, liveness, allowFilesystemWrites));
59
+ sections.push(await toolsSection(coreConfig, options));
60
+ sections.push(await sandboxSection(coreConfig, options.sandboxEngine));
31
61
  }
32
- sections.push(await consoleSection(options));
62
+ sections.push(await exporterSection(options, liveness));
63
+ sections.push(await runsSection(options, coreConfig));
33
64
  for (const driver of drivers) {
34
65
  sections.push(await channelSection(driver, options));
35
66
  }
@@ -61,6 +92,105 @@ function runtimeSection(config) {
61
92
  }
62
93
  return { id: "runtime", label: "Runtime", status, details };
63
94
  }
95
+ /** Parses the Pi OAuth auth store (provider -> credentials) at `path`, best-effort. */
96
+ async function readPiAuthProviders(path) {
97
+ try {
98
+ const parsed = JSON.parse(await readFile(path, "utf8"));
99
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
100
+ return parsed;
101
+ }
102
+ }
103
+ catch {
104
+ // Missing/invalid auth file is reported per-provider below, not thrown here.
105
+ }
106
+ return undefined;
107
+ }
108
+ /** Provider ids configured via the sibling `models.json` (custom/local providers need no OAuth). */
109
+ async function readPiCustomProviders(authPath) {
110
+ try {
111
+ const parsed = JSON.parse(await readFile(join(dirname(authPath), "models.json"), "utf8"));
112
+ const providers = parsed?.providers;
113
+ if (providers !== null && typeof providers === "object" && !Array.isArray(providers)) {
114
+ return new Set(Object.keys(providers));
115
+ }
116
+ }
117
+ catch {
118
+ // No models.json is fine; such providers simply won't be marked custom.
119
+ }
120
+ return new Set();
121
+ }
122
+ /**
123
+ * Checks that every referenced model (primary, fallbacks, and the agent-host memory LLM)
124
+ * has discoverable credentials, so a keyless or expired-OAuth provider is caught at
125
+ * `validate` time instead of degrading crons/memory silently at runtime (the failure mode
126
+ * that broke memory capture for ~10 days: the auth store's OAuth token had quietly expired).
127
+ *
128
+ * The check is intentionally STATIC and read-only — a validator must not mutate the auth
129
+ * store or hit the network. For each Pi provider it inspects the auth store (`piAuthPath`)
130
+ * and the sibling `models.json`: a custom/local provider needs no OAuth; an OAuth provider
131
+ * absent from the store, or whose access token has expired, is flagged `waiting` with a
132
+ * re-auth hint. SDK-authenticated models (claude/codex) are validated by their own SDK and
133
+ * are noted but not key-checked here. `waiting` (never `error`) keeps the verdict non-fatal,
134
+ * mirroring the Ollama/Phoenix probes — the goal is visibility, not blocking start.
135
+ */
136
+ async function credentialsSection(config) {
137
+ const refs = [
138
+ { label: "Primary", ref: config.runtime.model },
139
+ ...(config.runtime.fallbackModels ?? []).map((ref) => ({ label: "Fallback", ref })),
140
+ ];
141
+ if (config.memory?.llm !== undefined && config.memory.llm.provider !== "ollama") {
142
+ try {
143
+ refs.push({ label: "Memory LLM", ref: parseMonoRuntimeModelReference(config.memory.llm.model) });
144
+ }
145
+ catch {
146
+ // A malformed memory model reference is surfaced by the memory/runtime shape checks.
147
+ }
148
+ }
149
+ const piRefs = refs.filter((r) => r.ref.sdk === "pi" && typeof r.ref.provider === "string");
150
+ if (piRefs.length === 0) {
151
+ return {
152
+ id: "credentials",
153
+ label: "Provider credentials",
154
+ status: "disabled",
155
+ details: ["No Pi provider-key models referenced (SDK-authenticated models are checked by their SDK)."],
156
+ };
157
+ }
158
+ const authPath = config.providers?.piAuthPath;
159
+ const authProviders = authPath === undefined ? undefined : await readPiAuthProviders(authPath);
160
+ const customProviders = authPath === undefined ? new Set() : await readPiCustomProviders(authPath);
161
+ const now = Date.now();
162
+ const details = [];
163
+ let status = "ok";
164
+ if (authPath !== undefined) {
165
+ details.push(`Pi auth store: ${authPath}`);
166
+ }
167
+ for (const { label, ref } of piRefs) {
168
+ const provider = ref.provider;
169
+ const refStr = referenceOf(ref);
170
+ if (customProviders.has(provider)) {
171
+ details.push(`${label} ${refStr}: provider \`${provider}\` configured via pi models.json.`);
172
+ continue;
173
+ }
174
+ const entry = authProviders?.[provider];
175
+ if (entry === undefined) {
176
+ status = "waiting";
177
+ details.push(`[WARN] ${label} ${refStr}: no Pi credentials found for provider \`${provider}\` (absent from the auth store and models.json). Authenticate it (\`pi auth login ${provider}\`) or set providers.piAuthPath.`);
178
+ continue;
179
+ }
180
+ const isOAuth = entry.type === "oauth" || typeof entry.expires === "number";
181
+ const expired = typeof entry.expires === "number" && entry.expires < now;
182
+ const whenNote = typeof entry.expires === "number" ? ` ${new Date(entry.expires).toISOString()}` : "";
183
+ if (isOAuth && expired) {
184
+ status = "waiting";
185
+ details.push(`[WARN] ${label} ${refStr}: OAuth token for \`${provider}\` expired${whenNote} — the runtime auto-refreshes, but if runs fail with "No API key for provider: ${provider}" the refresh is dead; re-authenticate: \`pi auth login ${provider}\`.`);
186
+ continue;
187
+ }
188
+ details.push(isOAuth
189
+ ? `${label} ${refStr}: OAuth credentials for \`${provider}\` present (token valid${whenNote}).`
190
+ : `${label} ${refStr}: credentials for \`${provider}\` present.`);
191
+ }
192
+ return { id: "credentials", label: "Provider credentials", status, details };
193
+ }
64
194
  async function contextSection(config) {
65
195
  const details = [];
66
196
  let status = "ok";
@@ -100,66 +230,77 @@ async function contextSection(config) {
100
230
  }
101
231
  return { id: "context", label: "Context & skills", status, details };
102
232
  }
103
- const DEFAULT_REFLECTION_CRON = "0 3 * * *";
104
- const DEFAULT_MIGRATION_CRON = "0 4 1 * *";
105
- async function memorySection(config) {
233
+ const DEFAULT_CONSOLIDATION_CRON = "0 */2 * * *";
234
+ async function memorySection(config, liveness, allowFilesystemWrites) {
106
235
  if (config.memory === undefined) {
107
236
  return { id: "memory", label: "Memory", status: "disabled", details: ["No memory configured."] };
108
237
  }
238
+ // External backend (e.g. supermemory): mode/embeddings/llm are bujo-only and ignored, so report the
239
+ // backend's own shape. We do not ping the instance here (config-shape check); the playbook covers
240
+ // starting the server.
241
+ if ((config.memory.backend ?? "bujo") === "supermemory") {
242
+ const sm = config.memory.supermemory;
243
+ if (sm === undefined) {
244
+ return {
245
+ id: "memory",
246
+ label: "Memory",
247
+ status: "error",
248
+ details: ["[ERROR] backend 'supermemory' requires a memory.supermemory block."],
249
+ };
250
+ }
251
+ return {
252
+ id: "memory",
253
+ label: "Memory",
254
+ status: "ok",
255
+ details: [
256
+ `Backend: supermemory, writeMode: ${config.memory.writeMode}.`,
257
+ `Endpoint: ${sm.baseUrl} (container "${resolveSupermemoryContainer(config)}").`,
258
+ sm.apiKey === undefined
259
+ ? "Auth: no API key configured (keyless — works only if the instance allows it)."
260
+ : "Auth: API key configured.",
261
+ "Start the Supermemory instance (e.g. `supermemory-server`) before sending turns; ingestion is async.",
262
+ ],
263
+ };
264
+ }
109
265
  const details = [
110
266
  `Mode: ${config.memory.mode}, path: ${config.memory.path}, writeMode: ${config.memory.writeMode}.`,
111
267
  ];
268
+ if (config.memory.llm !== undefined) {
269
+ details.push(`Chat LLM: ${memoryLlmLabel(config.memory.llm)}.`);
270
+ }
112
271
  if (config.memory.mode === "bujo") {
113
- // Report ritual scheduler cadence
114
- const reflectionEnabled = config.memory.reflection?.enabled !== false;
115
- const migrationEnabled = config.memory.migration?.enabled !== false;
116
- const reflectionCron = config.memory.reflection?.cron ?? DEFAULT_REFLECTION_CRON;
117
- const migrationCron = config.memory.migration?.cron ?? DEFAULT_MIGRATION_CRON;
272
+ // Report consolidation scheduler cadence.
273
+ const consolidationEnabled = config.memory.consolidation?.enabled !== false;
274
+ const consolidationCron = config.memory.consolidation?.cron ?? DEFAULT_CONSOLIDATION_CRON;
118
275
  const hasLlm = config.memory.llm !== undefined;
119
276
  if (hasLlm) {
120
- const ritualParts = [];
121
- if (reflectionEnabled) {
122
- ritualParts.push(`reflection ${reflectionCron}`);
123
- }
124
- else {
125
- ritualParts.push("reflection disabled");
126
- }
127
- if (migrationEnabled) {
128
- ritualParts.push(`migration ${migrationCron}`);
277
+ if (consolidationEnabled) {
278
+ details.push(`Consolidation: ${consolidationCron} (auto).`);
129
279
  }
130
280
  else {
131
- ritualParts.push("migration disabled");
281
+ details.push("Consolidation: disabled.");
132
282
  }
133
- details.push(`Rituals: ${ritualParts.join(" / ")} (auto).`);
134
283
  }
135
284
  else {
136
- details.push("Rituals: manual (no chat model — reflect/migrate need a local LLM).");
285
+ details.push("Consolidation: not scheduled (no chat model — bujo runtime tier downgrades to journal).");
137
286
  }
138
287
  }
139
288
  if (config.memory.mode === "journal" || config.memory.mode === "bujo") {
140
- const warns = await memoryLivenessWarnings(config.memory);
289
+ const warns = await memoryLivenessWarnings(config.memory, liveness, allowFilesystemWrites);
141
290
  if (warns.length > 0) {
142
291
  return { id: "memory", label: "Memory", status: "waiting", details: [...details, ...warns] };
143
292
  }
144
293
  }
145
294
  if (config.memory.mode === "lite") {
146
- const liteWarns = await liteRootWritableWarning(config.memory.path);
295
+ const liteWarns = await liteRootWritableWarning(config.memory.path, allowFilesystemWrites);
147
296
  if (liteWarns.length > 0) {
148
297
  return { id: "memory", label: "Memory", status: "waiting", details: [...details, ...liteWarns] };
149
298
  }
150
299
  }
151
300
  return { id: "memory", label: "Memory", status: "ok", details };
152
301
  }
153
- async function liteRootWritableWarning(memoryPath) {
154
- try {
155
- await mkdir(memoryPath, { recursive: true });
156
- return [];
157
- }
158
- catch (err) {
159
- return [
160
- `[WARN] lite memory root is not writable: ${memoryPath} (${err instanceof Error ? err.message : String(err)}). Fix filesystem permissions.`,
161
- ];
162
- }
302
+ async function liteRootWritableWarning(memoryPath, allowFilesystemWrites) {
303
+ return await memoryRootWritableWarnings("lite", memoryPath, allowFilesystemWrites);
163
304
  }
164
305
  const BUJO_PROBE_TIMEOUT_MS = 3_000;
165
306
  /** Probes Ollama /api/tags and returns a sorted list of model names, or throws. */
@@ -178,47 +319,101 @@ async function fetchOllamaModels(endpoint) {
178
319
  clearTimeout(timer);
179
320
  }
180
321
  }
181
- async function memoryLivenessWarnings(memory) {
322
+ async function memoryLivenessWarnings(memory, liveness, allowFilesystemWrites) {
182
323
  const warns = [];
183
324
  const mode = memory.mode;
184
325
  const embeddingsUsesOllama = (memory.embeddings?.provider ?? "ollama") === "ollama";
185
326
  const llmUsesOllama = memory.llm?.provider === "ollama";
186
- // 1. Memory root writable (every embedded tier)
187
- try {
188
- await mkdir(memory.path, { recursive: true });
189
- }
190
- catch (err) {
191
- warns.push(`[WARN] ${mode} memory root is not writable: ${memory.path} (${err instanceof Error ? err.message : String(err)}). Fix filesystem permissions.`);
192
- }
193
- // 2. Ollama liveness — only probe when embeddings and/or the chat LLM actually use Ollama. With
194
- // OpenAI embeddings there is nothing local to probe, so we must not tell the user to start Ollama.
195
- if (embeddingsUsesOllama || llmUsesOllama) {
196
- const endpoint = ((embeddingsUsesOllama ? memory.embeddings?.endpoint : memory.llm?.endpoint) ?? "http://localhost:11434").replace(/\/$/u, "");
197
- let ollamaModels;
327
+ const ollamaModelsByEndpoint = new Map();
328
+ // 1. Memory root writable (every embedded tier) — local I/O, always checked.
329
+ const rootWarns = await memoryRootWritableWarnings(mode, memory.path, allowFilesystemWrites);
330
+ warns.push(...rootWarns);
331
+ // Network-dependent probes below only ever produce `waiting`, so the start
332
+ // preflight skips them (liveness=false) without changing the pass/fail verdict.
333
+ if (!liveness) {
334
+ return warns;
335
+ }
336
+ async function modelsForOllamaEndpoint(endpoint) {
337
+ const normalizedEndpoint = endpoint.replace(/\/$/u, "");
338
+ if (ollamaModelsByEndpoint.has(normalizedEndpoint)) {
339
+ return ollamaModelsByEndpoint.get(normalizedEndpoint);
340
+ }
198
341
  try {
199
- ollamaModels = await fetchOllamaModels(endpoint);
342
+ const models = await fetchOllamaModels(normalizedEndpoint);
343
+ ollamaModelsByEndpoint.set(normalizedEndpoint, models);
344
+ return models;
200
345
  }
201
346
  catch (err) {
202
- warns.push(`[WARN] Ollama not reachable at ${endpoint}; ${mode} memory cannot embed and will fail at runtime (${err instanceof Error ? err.message : String(err)}). Start Ollama or fix the endpoint.`);
347
+ ollamaModelsByEndpoint.set(normalizedEndpoint, undefined);
348
+ warns.push(`[WARN] Ollama not reachable at ${normalizedEndpoint}; ${mode} memory components configured for that endpoint will fail at runtime (${err instanceof Error ? err.message : String(err)}). Start Ollama or fix the endpoint.`);
349
+ return undefined;
203
350
  }
351
+ }
352
+ // 2. Ollama liveness — only probe components that actually use Ollama. OpenAI embeddings and
353
+ // agent-host chat LLMs have no local model list to validate here.
354
+ if (embeddingsUsesOllama) {
355
+ const endpoint = memory.embeddings?.endpoint ?? "http://localhost:11434";
356
+ const ollamaModels = await modelsForOllamaEndpoint(endpoint);
204
357
  if (ollamaModels !== undefined) {
205
- if (embeddingsUsesOllama) {
206
- const embeddingsModel = memory.embeddings?.model ?? "nomic-embed-text:v1.5";
207
- if (!ollamaModels.includes(embeddingsModel)) {
208
- warns.push(`[WARN] Embeddings model ${embeddingsModel} not pulled — run \`ollama pull ${embeddingsModel}\`.`);
209
- }
358
+ const embeddingsModel = memory.embeddings?.model ?? "nomic-embed-text:v1.5";
359
+ if (!ollamaModels.includes(embeddingsModel)) {
360
+ warns.push(`[WARN] Embeddings model ${embeddingsModel} not pulled — run \`ollama pull ${embeddingsModel}\`.`);
210
361
  }
211
- if (llmUsesOllama && memory.llm !== undefined) {
212
- const chatModel = memory.llm.model;
213
- if (!ollamaModels.includes(chatModel)) {
214
- warns.push(`[WARN] Chat LLM model ${chatModel} not pulled — run \`ollama pull ${chatModel}\`.`);
215
- }
362
+ }
363
+ }
364
+ if (llmUsesOllama && memory.llm !== undefined) {
365
+ const endpoint = memory.llm.endpoint ?? "http://localhost:11434";
366
+ const ollamaModels = await modelsForOllamaEndpoint(endpoint);
367
+ if (ollamaModels !== undefined) {
368
+ const chatModel = memory.llm.model;
369
+ if (!ollamaModels.includes(chatModel)) {
370
+ warns.push(`[WARN] Chat LLM model ${chatModel} not pulled — run \`ollama pull ${chatModel}\`.`);
216
371
  }
217
372
  }
218
373
  }
219
374
  return warns;
220
375
  }
221
- async function toolsSection(config) {
376
+ async function memoryRootWritableWarnings(mode, memoryPath, allowFilesystemWrites) {
377
+ if (allowFilesystemWrites) {
378
+ try {
379
+ await mkdir(memoryPath, { recursive: true });
380
+ return [];
381
+ }
382
+ catch (err) {
383
+ return [
384
+ `[WARN] ${mode} memory root is not writable: ${memoryPath} (${err instanceof Error ? err.message : String(err)}). Fix filesystem permissions.`,
385
+ ];
386
+ }
387
+ }
388
+ try {
389
+ const info = await stat(memoryPath);
390
+ if (!info.isDirectory()) {
391
+ return [`[WARN] ${mode} memory root is not a directory: ${memoryPath}. Fix filesystem permissions.`];
392
+ }
393
+ await access(memoryPath, constants.W_OK);
394
+ return [];
395
+ }
396
+ catch (err) {
397
+ const code = err !== null && typeof err === "object" && "code" in err ? String(err.code) : undefined;
398
+ if (code === "ENOENT") {
399
+ return [
400
+ `[WARN] ${mode} memory root is missing: ${memoryPath}. Consumer validation is read-only and did not create it.`,
401
+ ];
402
+ }
403
+ return [
404
+ `[WARN] ${mode} memory root is not writable: ${memoryPath} (${err instanceof Error ? err.message : String(err)}). Fix filesystem permissions.`,
405
+ ];
406
+ }
407
+ }
408
+ function memoryLlmLabel(llm) {
409
+ if (llm === undefined) {
410
+ return "none";
411
+ }
412
+ return llm.provider === "ollama"
413
+ ? `ollama:${llm.model}`
414
+ : `agent-host:${llm.model}${llm.executionMode === undefined ? "" : ` (${llm.executionMode})`}`;
415
+ }
416
+ async function toolsSection(config, input) {
222
417
  const details = [];
223
418
  let status = "ok";
224
419
  details.push(config.tools.allowedTools.length === 0
@@ -236,48 +431,143 @@ async function toolsSection(config) {
236
431
  details.push(`MCP config file is missing: ${config.tools.mcpConfigPath}`);
237
432
  }
238
433
  }
434
+ const adapterSendTools = await resolveAdapterSendToolsSettings(input, {
435
+ allowedTools: config.tools.allowedTools,
436
+ disallowedTools: config.tools.disallowedTools,
437
+ });
438
+ if (adapterSendTools === undefined) {
439
+ details.push("No adapter-derived send tools enabled.");
440
+ }
441
+ else {
442
+ details.push(`Adapter send tools: ${adapterSendToolNames(adapterSendTools).join(", ")}.`);
443
+ }
239
444
  return { id: "tools", label: "Tools & MCP", status, details };
240
445
  }
241
- function sandboxSection(config) {
446
+ async function sandboxSection(config, engine) {
242
447
  if (config.sandbox === undefined) {
243
448
  return { id: "sandbox", label: "Sandbox", status: "disabled", details: ["No sandbox policy configured."] };
244
449
  }
450
+ const state = await resolveSandboxEffectiveState({
451
+ policy: config.sandbox,
452
+ ...(engine === undefined ? {} : { engine }),
453
+ });
454
+ const warning = sandboxEffectiveStateWarning(state);
455
+ const details = [
456
+ `Mode: ${config.sandbox.mode}, network: ${config.sandbox.network.mode}, fallback: ${config.sandbox.fallback}.`,
457
+ describeSandboxEffectiveState(state),
458
+ ...(warning === undefined ? [] : [warning]),
459
+ ];
460
+ const status = warning !== undefined
461
+ ? "waiting"
462
+ : state.effective === "off"
463
+ ? "disabled"
464
+ : state.effective === "blocked"
465
+ ? "waiting"
466
+ : "ok";
245
467
  return {
246
468
  id: "sandbox",
247
469
  label: "Sandbox",
248
- status: "ok",
249
- details: [
250
- `Mode: ${config.sandbox.mode}, network: ${config.sandbox.network.mode}, fallback: ${config.sandbox.fallback}.`,
251
- ],
470
+ status,
471
+ details,
252
472
  };
253
473
  }
254
- async function consoleSection(input) {
474
+ async function runsSection(input, config) {
475
+ const artifactDir = await resolveAppArtifactDir(input);
476
+ const { totalRuns, runs, warnings } = await listRecordedRuns({ artifactDir, maxRuns: RUNS_HEALTH_MAX_RUNS, scope: "agent" });
477
+ const display = buildRunsHealthDisplay({ artifactDir, totalRuns, runs, warnings });
478
+ const retentionDetails = config === undefined
479
+ ? []
480
+ : [
481
+ `Artifact retention: maxAgeDays=${config.artifacts.retention.maxAgeDays}, maxCount=${config.artifacts.retention.maxCount}, dryRun=${config.artifacts.retention.dryRun ? "true" : "false"}.`,
482
+ `Memory artifact retention: maxAgeDays=${config.artifacts.memoryRetention.maxAgeDays}, maxCount=${config.artifacts.memoryRetention.maxCount}, dryRun=${config.artifacts.memoryRetention.dryRun ? "true" : "false"}.`,
483
+ ];
484
+ return { id: "runs", label: "Runs health", status: display.status, details: [...retentionDetails, ...display.details] };
485
+ }
486
+ const LOCAL_ARTIFACTS_NOTE = "JSONL artifacts remain local (the exporter is additive; export failures never affect them).";
487
+ /**
488
+ * Reports observability exporter state and, uniquely among the validation
489
+ * sections, performs a LIVE reachability probe of the Phoenix endpoint. An
490
+ * invalid exporter shape is a hard `error` (fails the report) so bad config is
491
+ * caught before startup, but an unreachable endpoint is only `waiting` — Phoenix
492
+ * may start after the agent, mirroring the Ollama-unreachable precedent so
493
+ * `validate` still passes. Probe failures are swallowed into a warning.
494
+ */
495
+ async function exporterSection(input, liveness) {
496
+ let exporters;
255
497
  try {
256
- const settings = await resolveAppConsoleSettings(input);
257
- if (!settings.enabled) {
258
- return {
259
- id: "console",
260
- label: "Operator console",
261
- status: "disabled",
262
- details: ["Disabled via console.enabled; start stays headless."],
263
- };
264
- }
265
- return {
266
- id: "console",
267
- label: "Operator console",
268
- status: "ok",
269
- details: [
270
- settings.port === undefined
271
- ? "Starts on a free loopback port."
272
- : `Starts on loopback port ${settings.port}.`,
273
- ],
274
- };
498
+ exporters = await resolveAppObservabilityExporters(input);
275
499
  }
276
500
  catch (error) {
277
501
  if (!isAppCoreConfigError(error)) {
278
502
  throw error;
279
503
  }
280
- return { id: "console", label: "Operator console", status: "error", details: [error.message] };
504
+ return { id: "observability", label: "Observability exporter", status: "error", details: [error.message] };
505
+ }
506
+ if (exporters.length === 0) {
507
+ return {
508
+ id: "observability",
509
+ label: "Observability exporter",
510
+ status: "disabled",
511
+ details: ["No observability exporter configured."],
512
+ };
513
+ }
514
+ const exporter = exporters[0];
515
+ const details = [`Exporter: ${exporter.type} -> ${exporter.endpoint}`];
516
+ const appUrl = phoenixAppBaseUrl(exporter.endpoint);
517
+ if (appUrl !== undefined) {
518
+ details.push(`Phoenix app: ${appUrl}`);
519
+ }
520
+ if (exporter.includeSensitiveData) {
521
+ details.push(describeSensitiveDataExportWarning(exporter.endpoint));
522
+ }
523
+ // The reachability probe only ever yields `waiting`, so the start preflight
524
+ // skips it (liveness=false): the exporter shape is valid, and Phoenix may
525
+ // legitimately come up after the agent.
526
+ if (!liveness) {
527
+ details.push(LOCAL_ARTIFACTS_NOTE);
528
+ return { id: "observability", label: "Observability exporter", status: "ok", details };
529
+ }
530
+ const probeError = await probeExporterEndpoint(exporter.endpoint);
531
+ if (probeError !== undefined) {
532
+ details.push(`[WARN] Phoenix export not confirmed at ${exporter.endpoint} (${probeError}); exports will fail until it accepts OTLP protobuf. This is non-fatal.`);
533
+ details.push(LOCAL_ARTIFACTS_NOTE);
534
+ return { id: "observability", label: "Observability exporter", status: "waiting", details };
535
+ }
536
+ details.push(LOCAL_ARTIFACTS_NOTE);
537
+ return { id: "observability", label: "Observability exporter", status: "ok", details };
538
+ }
539
+ /**
540
+ * Live export-compatibility probe: POSTs a tiny but VALID empty OTLP protobuf
541
+ * `ExportTraceServiceRequest` (zero spans, so nothing is recorded) with
542
+ * `content-type: application/x-protobuf` — exactly the wire format a real export
543
+ * uses. Returns undefined when the endpoint accepts it (2xx), else an error
544
+ * string. This catches what the old OPTIONS reachability probe missed: a server
545
+ * that is listening but rejects the export (e.g. Phoenix returns 415 for the
546
+ * wrong content type), so `validate` no longer reports `[ok]` for an endpoint
547
+ * that every real export would 415. A throw (refused / DNS / timeout) means
548
+ * nothing is listening. Kept non-fatal upstream (`waiting`, not `error`): Phoenix
549
+ * may start after the agent.
550
+ */
551
+ async function probeExporterEndpoint(endpoint) {
552
+ const ctrl = new AbortController();
553
+ const timer = setTimeout(() => { ctrl.abort(); }, BUJO_PROBE_TIMEOUT_MS);
554
+ try {
555
+ const response = await fetch(endpoint, {
556
+ method: "POST",
557
+ headers: { "content-type": "application/x-protobuf" },
558
+ body: serializeTraceSpans([]),
559
+ signal: ctrl.signal,
560
+ });
561
+ if (!response.ok) {
562
+ return `HTTP ${response.status}`;
563
+ }
564
+ return undefined;
565
+ }
566
+ catch (err) {
567
+ return err instanceof Error ? err.message : String(err);
568
+ }
569
+ finally {
570
+ clearTimeout(timer);
281
571
  }
282
572
  }
283
573
  async function channelSection(driver, input) {
@@ -292,6 +582,13 @@ async function channelSection(driver, input) {
292
582
  if (waitingReason !== undefined) {
293
583
  return { id, label: driver.label, status: "waiting", details: [waitingReason] };
294
584
  }
585
+ // A structural issue (e.g. a typo'd per-trigger model override) fails
586
+ // validate loudly here; `start` still runs the channel and warn-ignores
587
+ // the bad value at run time.
588
+ const issues = driver.configIssues?.(config) ?? [];
589
+ if (issues.length > 0) {
590
+ return { id, label: driver.label, status: "error", details: [...issues] };
591
+ }
295
592
  return { id, label: driver.label, status: "ok", details: ["Configured; will start with the app."] };
296
593
  }
297
594
  catch (error) {
@@ -303,7 +600,7 @@ async function channelSection(driver, input) {
303
600
  }
304
601
  }
305
602
  function referenceOf(model) {
306
- return model.reference ?? `${model.sdk}:${model.provider === undefined ? "" : `${model.provider}:`}${model.model}`;
603
+ return modelReferenceKey(model);
307
604
  }
308
605
  async function pathExists(path) {
309
606
  try {