@lobu/cli 6.1.1 → 7.1.0

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 (177) hide show
  1. package/dist/commands/_lib/apply/apply-cmd.d.ts +36 -0
  2. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  3. package/dist/commands/_lib/apply/apply-cmd.js +696 -40
  4. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  5. package/dist/commands/_lib/apply/client.d.ts +285 -0
  6. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  7. package/dist/commands/_lib/apply/client.js +469 -28
  8. package/dist/commands/_lib/apply/client.js.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.d.ts +187 -3
  10. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.js +879 -88
  12. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  13. package/dist/commands/_lib/apply/diff.d.ts +72 -3
  14. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  15. package/dist/commands/_lib/apply/diff.js +473 -84
  16. package/dist/commands/_lib/apply/diff.js.map +1 -1
  17. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  18. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.js +16 -0
  20. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  21. package/dist/commands/_lib/apply/render.d.ts +9 -0
  22. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  23. package/dist/commands/_lib/apply/render.js +80 -3
  24. package/dist/commands/_lib/apply/render.js.map +1 -1
  25. package/dist/commands/_lib/connector-loader.d.ts +3 -0
  26. package/dist/commands/_lib/connector-loader.d.ts.map +1 -0
  27. package/dist/commands/_lib/connector-loader.js +129 -0
  28. package/dist/commands/_lib/connector-loader.js.map +1 -0
  29. package/dist/commands/_lib/connector-run-cmd.d.ts +35 -0
  30. package/dist/commands/_lib/connector-run-cmd.d.ts.map +1 -0
  31. package/dist/commands/_lib/connector-run-cmd.js +351 -0
  32. package/dist/commands/_lib/connector-run-cmd.js.map +1 -0
  33. package/dist/commands/_lib/export/export-cmd.d.ts +35 -0
  34. package/dist/commands/_lib/export/export-cmd.d.ts.map +1 -0
  35. package/dist/commands/_lib/export/export-cmd.js +329 -0
  36. package/dist/commands/_lib/export/export-cmd.js.map +1 -0
  37. package/dist/commands/agent.d.ts.map +1 -1
  38. package/dist/commands/agent.js +11 -14
  39. package/dist/commands/agent.js.map +1 -1
  40. package/dist/commands/chat.d.ts.map +1 -1
  41. package/dist/commands/chat.js +28 -7
  42. package/dist/commands/chat.js.map +1 -1
  43. package/dist/commands/connector.d.ts +3 -0
  44. package/dist/commands/connector.d.ts.map +1 -0
  45. package/dist/commands/connector.js +5 -0
  46. package/dist/commands/connector.js.map +1 -0
  47. package/dist/commands/dev.d.ts +23 -0
  48. package/dist/commands/dev.d.ts.map +1 -1
  49. package/dist/commands/dev.js +273 -8
  50. package/dist/commands/dev.js.map +1 -1
  51. package/dist/commands/doctor.d.ts.map +1 -1
  52. package/dist/commands/doctor.js +2 -3
  53. package/dist/commands/doctor.js.map +1 -1
  54. package/dist/commands/eval.d.ts.map +1 -1
  55. package/dist/commands/eval.js +28 -18
  56. package/dist/commands/eval.js.map +1 -1
  57. package/dist/commands/init.d.ts +2 -0
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +29 -1
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/login.d.ts.map +1 -1
  62. package/dist/commands/login.js +22 -16
  63. package/dist/commands/login.js.map +1 -1
  64. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  65. package/dist/commands/memory/_lib/browser-auth-cmd.js +15 -144
  66. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  67. package/dist/commands/memory/_lib/schema.d.ts +28 -1
  68. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  69. package/dist/commands/memory/_lib/schema.js +120 -4
  70. package/dist/commands/memory/_lib/schema.js.map +1 -1
  71. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  72. package/dist/commands/memory/_lib/seed-cmd.js +41 -18
  73. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  74. package/dist/commands/org.d.ts +4 -0
  75. package/dist/commands/org.d.ts.map +1 -1
  76. package/dist/commands/org.js +10 -0
  77. package/dist/commands/org.js.map +1 -1
  78. package/dist/commands/token.d.ts +9 -0
  79. package/dist/commands/token.d.ts.map +1 -1
  80. package/dist/commands/token.js +54 -3
  81. package/dist/commands/token.js.map +1 -1
  82. package/dist/commands/validate.d.ts.map +1 -1
  83. package/dist/commands/validate.js +4 -13
  84. package/dist/commands/validate.js.map +1 -1
  85. package/dist/config/loader.js +2 -2
  86. package/dist/config/loader.js.map +1 -1
  87. package/dist/connectors/README.md +2 -3
  88. package/dist/connectors/apple_health.ts +138 -0
  89. package/dist/connectors/apple_photos.ts +178 -0
  90. package/dist/connectors/apple_screen_time.ts +82 -0
  91. package/dist/connectors/browser/evaluate.ts +120 -0
  92. package/dist/connectors/browser/fill_form.ts +107 -0
  93. package/dist/connectors/browser/page_text.ts +108 -0
  94. package/dist/connectors/browser-scraper-utils.ts +111 -3
  95. package/dist/connectors/capterra.ts +5 -1
  96. package/dist/connectors/chrome_tabs.ts +74 -0
  97. package/dist/connectors/g2.ts +5 -1
  98. package/dist/connectors/github.ts +16 -38
  99. package/dist/connectors/glassdoor.ts +5 -1
  100. package/dist/connectors/google_calendar.ts +28 -6
  101. package/dist/connectors/google_gmail.ts +6 -3
  102. package/dist/connectors/google_play.ts +32 -5
  103. package/dist/connectors/hackernews.ts +37 -2
  104. package/dist/connectors/index.ts +14 -1
  105. package/dist/connectors/linkedin.ts +32 -9
  106. package/dist/connectors/local_directory.ts +91 -0
  107. package/dist/connectors/reddit.ts +1 -0
  108. package/dist/connectors/revolut.ts +569 -0
  109. package/dist/connectors/rss.ts +33 -8
  110. package/dist/connectors/trustpilot.ts +36 -21
  111. package/dist/connectors/website.ts +8 -69
  112. package/dist/connectors/whatsapp.ts +21 -22
  113. package/dist/connectors/whatsapp_local.ts +125 -0
  114. package/dist/connectors/x.ts +17 -7
  115. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  116. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  117. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  118. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  119. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  120. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  121. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  122. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  123. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  124. package/dist/db/migrations/20260514130000_connection_action_modes.sql +103 -0
  125. package/dist/db/migrations/20260514160000_auth_profiles_mirror_mode.sql +32 -0
  126. package/dist/db/migrations/20260515120000_agents_per_org_pk.sql +66 -0
  127. package/dist/db/migrations/20260515150000_geo_enrichment.sql +208 -0
  128. package/dist/db/migrations/20260515160000_drop_agents_org_id_unique.sql +24 -0
  129. package/dist/db/migrations/20260515170000_auth_profiles_default_for_connector.sql +23 -0
  130. package/dist/db/migrations/20260516120000_agents_per_org_pk_swap.sql +125 -0
  131. package/dist/db/migrations/20260516200000_events_search_tsv.sql +134 -0
  132. package/dist/db/migrations/20260516200100_events_lifecycle_changes_index.sql +25 -0
  133. package/dist/db/migrations/20260517010000_drop_unused_indexes.sql +49 -0
  134. package/dist/db/migrations/20260517020000_softdelete_orphan_feeds.sql +56 -0
  135. package/dist/db/migrations/20260517030000_pat_worker_id_binding.sql +27 -0
  136. package/dist/db/migrations/20260517040000_archive_orphan_watchers.sql +30 -0
  137. package/dist/db/migrations/20260517050000_watcher_agent_id_not_null.sql +34 -0
  138. package/dist/db/migrations/20260517060000_watcher_schema_additions.sql +78 -0
  139. package/dist/db/migrations/20260517150000_goals_primitive.sql +55 -0
  140. package/dist/db/migrations/20260517160000_drop_goals_primitive.sql +45 -0
  141. package/dist/db/migrations/20260518000000_pending_interactions.sql +49 -0
  142. package/dist/db/migrations/20260518010000_runs_heartbeat_reaper_index.sql +22 -0
  143. package/dist/eval/client.d.ts.map +1 -1
  144. package/dist/eval/client.js +11 -0
  145. package/dist/eval/client.js.map +1 -1
  146. package/dist/eval/grader.js +2 -1
  147. package/dist/eval/grader.js.map +1 -1
  148. package/dist/eval/types.d.ts +2 -0
  149. package/dist/eval/types.d.ts.map +1 -1
  150. package/dist/index.d.ts +11 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +115 -114
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/context.d.ts +9 -0
  155. package/dist/internal/context.d.ts.map +1 -1
  156. package/dist/internal/context.js +41 -6
  157. package/dist/internal/context.js.map +1 -1
  158. package/dist/internal/credentials.d.ts +5 -0
  159. package/dist/internal/credentials.d.ts.map +1 -1
  160. package/dist/internal/credentials.js +75 -1
  161. package/dist/internal/credentials.js.map +1 -1
  162. package/dist/internal/gateway-url.d.ts +14 -0
  163. package/dist/internal/gateway-url.d.ts.map +1 -1
  164. package/dist/internal/gateway-url.js +19 -0
  165. package/dist/internal/gateway-url.js.map +1 -1
  166. package/dist/internal/index.d.ts +1 -1
  167. package/dist/internal/index.d.ts.map +1 -1
  168. package/dist/internal/index.js +1 -1
  169. package/dist/internal/index.js.map +1 -1
  170. package/dist/internal/local-env.d.ts.map +1 -1
  171. package/dist/internal/local-env.js +9 -2
  172. package/dist/internal/local-env.js.map +1 -1
  173. package/dist/server.bundle.mjs +42251 -36931
  174. package/dist/start-local.bundle.mjs +16437 -9882
  175. package/dist/templates/TESTING.md.tmpl +9 -9
  176. package/package.json +8 -6
  177. package/dist/connectors/google_photos.ts +0 -776
@@ -1,8 +1,36 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
1
2
  import { readdir, readFile, stat } from "node:fs/promises";
2
3
  import { join, resolve } from "node:path";
4
+ import Ajv from "ajv";
5
+ import addFormats from "ajv-formats";
3
6
  import { parse as parseToml } from "smol-toml";
4
7
  import { ValidationError } from "../../memory/_lib/errors.js";
8
+ import { expandModelDefinition, parseModelYamlFile, validateModel, } from "../../memory/_lib/schema.js";
5
9
  import { CONFIG_FILENAME, isLoadError, loadConfig, } from "../../../config/loader.js";
10
+ import { CronExpressionParser } from "cron-parser";
11
+ // ── Connector slug / schedule validators (round-2) ─────────────────────────
12
+ // Mirror packages/server/src/utils/connections.ts CONNECTION_SLUG_PATTERN and
13
+ // the server's validateSchedule (packages/server/src/utils/cron.ts) so the CLI
14
+ // fails loud *before* any mutation instead of getting a server 4xx.
15
+ const CONNECTION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
16
+ // auth_profiles slugs are sanitized server-side; require canonical form so the
17
+ // diff key matches what is stored (server cap is 80 chars).
18
+ const AUTH_PROFILE_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,79}$/;
19
+ const MIN_CRON_INTERVAL_MS = 60_000;
20
+ function cronError(schedule) {
21
+ try {
22
+ const it = CronExpressionParser.parse(schedule);
23
+ const first = it.next().toDate();
24
+ const second = it.next().toDate();
25
+ if (second.getTime() - first.getTime() < MIN_CRON_INTERVAL_MS) {
26
+ return `schedule "${schedule}" is too frequent (minimum interval is 1 minute)`;
27
+ }
28
+ return null;
29
+ }
30
+ catch (err) {
31
+ return `invalid cron expression "${schedule}" — ${err instanceof Error ? err.message : String(err)}`;
32
+ }
33
+ }
6
34
  // ── Stable platform IDs (mirror of file-loader.ts) ─────────────────────────
7
35
  //
8
36
  // keep in sync with packages/server/src/gateway/config/file-loader.ts
@@ -25,55 +53,48 @@ function asEnvRef(value) {
25
53
  const match = ENV_REF.exec(value.trim());
26
54
  return match?.[1] ?? null;
27
55
  }
56
+ /** Visit every string leaf in `value` (recursing arrays + plain objects). */
57
+ function walkStrings(value, visit) {
58
+ if (typeof value === "string") {
59
+ visit(value);
60
+ return;
61
+ }
62
+ if (Array.isArray(value)) {
63
+ for (const item of value)
64
+ walkStrings(item, visit);
65
+ return;
66
+ }
67
+ if (value && typeof value === "object") {
68
+ for (const v of Object.values(value)) {
69
+ walkStrings(v, visit);
70
+ }
71
+ }
72
+ }
73
+ /** Add every `$ENV` reference found among the string leaves of `value`. */
74
+ function collectEnvRefsFrom(value, out) {
75
+ walkStrings(value, (s) => {
76
+ const ref = asEnvRef(s);
77
+ if (ref)
78
+ out.add(ref);
79
+ });
80
+ }
28
81
  function collectEnvRefs(config, out) {
29
82
  for (const agentConfig of Object.values(config.agents)) {
30
83
  for (const provider of agentConfig.providers) {
31
- if (provider.key) {
32
- const ref = asEnvRef(provider.key);
33
- if (ref)
34
- out.add(ref);
35
- }
36
- if (provider.secret_ref) {
37
- const ref = asEnvRef(provider.secret_ref);
38
- if (ref)
39
- out.add(ref);
40
- }
84
+ if (provider.key)
85
+ collectEnvRefsFrom(provider.key, out);
86
+ if (provider.secret_ref)
87
+ collectEnvRefsFrom(provider.secret_ref, out);
41
88
  }
42
89
  for (const platform of agentConfig.platforms) {
43
- for (const value of Object.values(platform.config)) {
44
- const ref = asEnvRef(value);
45
- if (ref)
46
- out.add(ref);
47
- }
90
+ collectEnvRefsFrom(platform.config, out);
48
91
  }
49
92
  if (agentConfig.skills.mcp) {
50
93
  for (const mcp of Object.values(agentConfig.skills.mcp)) {
51
- if (mcp.headers) {
52
- for (const v of Object.values(mcp.headers)) {
53
- const ref = asEnvRef(v);
54
- if (ref)
55
- out.add(ref);
56
- }
57
- }
58
- if (mcp.env) {
59
- for (const v of Object.values(mcp.env)) {
60
- const ref = asEnvRef(v);
61
- if (ref)
62
- out.add(ref);
63
- }
64
- }
65
- if (mcp.oauth) {
66
- if (mcp.oauth.client_id) {
67
- const ref = asEnvRef(mcp.oauth.client_id);
68
- if (ref)
69
- out.add(ref);
70
- }
71
- if (mcp.oauth.client_secret) {
72
- const ref = asEnvRef(mcp.oauth.client_secret);
73
- if (ref)
74
- out.add(ref);
75
- }
76
- }
94
+ collectEnvRefsFrom(mcp.headers, out);
95
+ collectEnvRefsFrom(mcp.env, out);
96
+ collectEnvRefsFrom(mcp.oauth?.client_id, out);
97
+ collectEnvRefsFrom(mcp.oauth?.client_secret, out);
77
98
  }
78
99
  }
79
100
  }
@@ -426,6 +447,17 @@ function buildPlatforms(agentId, agentConfig, env) {
426
447
  };
427
448
  if (platform.name)
428
449
  desired.name = platform.name;
450
+ if (platform.channels && platform.channels.length > 0) {
451
+ if (platform.type !== "slack") {
452
+ throw new ValidationError(`agent "${agentId}" platform "${platform.type}": \`channels\` is only supported for Slack`);
453
+ }
454
+ for (const entry of platform.channels) {
455
+ if (!/^[^/\s]+\/[^/\s]+$/.test(entry.trim())) {
456
+ throw new ValidationError(`agent "${agentId}" Slack \`channels\` entry "${entry}" must be in "<teamId>/<channelId>" form (e.g. "T0ABCDEF/C0123ABCD")`);
457
+ }
458
+ }
459
+ desired.channels = platform.channels.map((e) => e.trim());
460
+ }
429
461
  out.push(desired);
430
462
  }
431
463
  return out;
@@ -435,25 +467,197 @@ function isRecord(value) {
435
467
  }
436
468
  function parseEntityType(raw) {
437
469
  if (!isRecord(raw) || typeof raw.slug !== "string") {
438
- throw new ValidationError(`memory.entity_types entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}`);
470
+ throw new ValidationError(`model-bundle "entities" entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}`);
439
471
  }
440
472
  const out = { slug: raw.slug };
441
473
  if (typeof raw.name === "string")
442
474
  out.name = raw.name;
443
475
  if (typeof raw.description === "string")
444
476
  out.description = raw.description;
445
- if (Array.isArray(raw.required)) {
446
- out.required = raw.required.filter((v) => typeof v === "string");
477
+ if (isRecord(raw.metadata_schema)) {
478
+ if (Array.isArray(raw.metadata_schema.required)) {
479
+ out.required = raw.metadata_schema.required.filter((v) => typeof v === "string");
480
+ }
481
+ if (isRecord(raw.metadata_schema.properties)) {
482
+ out.properties = raw.metadata_schema.properties;
483
+ }
447
484
  }
448
- if (isRecord(raw.properties))
449
- out.properties = raw.properties;
450
485
  if (isRecord(raw.metadata))
451
486
  out.metadata = raw.metadata;
452
487
  return out;
453
488
  }
489
+ const NOTIFICATION_CHANNELS = new Set(["canvas", "notification", "both"]);
490
+ const NOTIFICATION_PRIORITIES = new Set(["low", "normal", "high"]);
491
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
492
+ function parseWatcher(raw, modelFileAbsPath) {
493
+ if (!isRecord(raw) || typeof raw.slug !== "string") {
494
+ throw new ValidationError(`watcher model files must be objects with a "slug" string field; got ${JSON.stringify(raw)}`);
495
+ }
496
+ if (typeof raw.prompt !== "string" || !raw.prompt.trim()) {
497
+ throw new ValidationError(`watcher "${raw.slug}" is missing a "prompt" string`);
498
+ }
499
+ if (typeof raw.agent !== "string" || !raw.agent.trim()) {
500
+ throw new ValidationError(`watcher "${raw.slug}" is missing an "agent" field — every watcher must name the agent that owns it (e.g. \`agent: my-agent\`, matching an \`[agents.<id>]\` block in lobu.toml)`);
501
+ }
502
+ const extractionSchema = isRecord(raw.extraction_schema)
503
+ ? raw.extraction_schema
504
+ : {};
505
+ const out = {
506
+ slug: raw.slug,
507
+ agent: raw.agent,
508
+ prompt: raw.prompt,
509
+ extractionSchema,
510
+ };
511
+ if (typeof raw.name === "string")
512
+ out.name = raw.name;
513
+ if (typeof raw.description === "string")
514
+ out.description = raw.description;
515
+ if (typeof raw.schedule === "string") {
516
+ const err = cronError(raw.schedule);
517
+ if (err) {
518
+ throw new ValidationError(`watcher "${raw.slug}" ${err}`);
519
+ }
520
+ out.schedule = raw.schedule;
521
+ }
522
+ if (Array.isArray(raw.sources)) {
523
+ out.sources = raw.sources
524
+ .filter(isRecord)
525
+ .filter((s) => typeof s.name === "string" && typeof s.query === "string")
526
+ .map((s) => ({ name: s.name, query: s.query }));
527
+ }
528
+ // Reaction script — sibling `.ts` file, resolved relative to the YAML.
529
+ // Path constraints: must be a relative POSIX-style path that stays under
530
+ // the YAML's directory tree (no leading `/`, no `..` segments), must end
531
+ // in `.ts`, and the file must be ≤ 256 KiB. The server compiles and
532
+ // executes the source in an isolate, so the trust boundary is the
533
+ // operator's file system — this validation prevents a hostile YAML
534
+ // (e.g. a PR that touches an unrelated model file) from sucking in a
535
+ // sensitive file like `/etc/passwd` or `../../.ssh/id_rsa`.
536
+ if (raw.reaction_script !== undefined) {
537
+ if (typeof raw.reaction_script !== "string" ||
538
+ !raw.reaction_script.trim()) {
539
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` must be a path to a sibling .ts file (e.g. \`reaction_script: ./funnel-digest.ts\`). Inline scripts are not supported — keep the TypeScript in its own file so your IDE can type-check it.`);
540
+ }
541
+ const rel = raw.reaction_script.trim();
542
+ if (rel.startsWith("/") || rel.includes("\\")) {
543
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` must be a relative POSIX path (./foo.ts) — absolute paths and backslashes are not allowed`);
544
+ }
545
+ if (rel.split("/").some((seg) => seg === "..")) {
546
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` must not contain \`..\` segments — keep the script under the model file's directory tree`);
547
+ }
548
+ if (!rel.endsWith(".ts")) {
549
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` must end in \`.ts\` (got ${JSON.stringify(rel)})`);
550
+ }
551
+ const baseDir = resolve(modelFileAbsPath, "..");
552
+ const abs = resolve(baseDir, rel);
553
+ // Belt-and-braces — symlinks or unusual relative-path forms shouldn't
554
+ // escape the baseDir even if the above checks let one through.
555
+ if (!abs.startsWith(`${baseDir}/`) && abs !== baseDir) {
556
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` resolves outside the model directory (${abs})`);
557
+ }
558
+ let sourceCode;
559
+ try {
560
+ sourceCode = readFileSync(abs, "utf-8");
561
+ }
562
+ catch {
563
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` ${rel} does not exist (resolved to ${abs})`);
564
+ }
565
+ const REACTION_SCRIPT_MAX_BYTES = 256 * 1024;
566
+ if (Buffer.byteLength(sourceCode, "utf8") > REACTION_SCRIPT_MAX_BYTES) {
567
+ throw new ValidationError(`watcher "${raw.slug}" \`reaction_script\` exceeds the ${REACTION_SCRIPT_MAX_BYTES}-byte cap — reaction scripts should be a few hundred lines, not a vendored library`);
568
+ }
569
+ out.reactionScript = { sourcePath: abs, sourceCode };
570
+ }
571
+ if (raw.reactions_guidance !== undefined) {
572
+ if (typeof raw.reactions_guidance !== "string") {
573
+ throw new ValidationError(`watcher "${raw.slug}" \`reactions_guidance\` must be a string`);
574
+ }
575
+ out.reactionsGuidance = raw.reactions_guidance;
576
+ }
577
+ if (raw.device_worker_id !== undefined) {
578
+ if (typeof raw.device_worker_id !== "string" ||
579
+ !UUID_PATTERN.test(raw.device_worker_id.trim())) {
580
+ throw new ValidationError(`watcher "${raw.slug}" \`device_worker_id\` must be a UUID string (the device_workers.id of the device this watcher should run on)`);
581
+ }
582
+ out.deviceWorkerId = raw.device_worker_id.trim();
583
+ }
584
+ if (raw.scheduler_client_id !== undefined) {
585
+ if (typeof raw.scheduler_client_id !== "string" ||
586
+ !raw.scheduler_client_id.trim()) {
587
+ throw new ValidationError(`watcher "${raw.slug}" \`scheduler_client_id\` must be a non-empty string`);
588
+ }
589
+ out.schedulerClientId = raw.scheduler_client_id.trim();
590
+ }
591
+ if (raw.notification_channel !== undefined) {
592
+ if (typeof raw.notification_channel !== "string" ||
593
+ !NOTIFICATION_CHANNELS.has(raw.notification_channel)) {
594
+ throw new ValidationError(`watcher "${raw.slug}" \`notification_channel\` must be one of: canvas, notification, both`);
595
+ }
596
+ out.notificationChannel =
597
+ raw.notification_channel;
598
+ }
599
+ if (raw.notification_priority !== undefined) {
600
+ if (typeof raw.notification_priority !== "string" ||
601
+ !NOTIFICATION_PRIORITIES.has(raw.notification_priority)) {
602
+ throw new ValidationError(`watcher "${raw.slug}" \`notification_priority\` must be one of: low, normal, high`);
603
+ }
604
+ out.notificationPriority =
605
+ raw.notification_priority;
606
+ }
607
+ if (raw.min_cooldown_seconds !== undefined) {
608
+ if (typeof raw.min_cooldown_seconds !== "number" ||
609
+ !Number.isFinite(raw.min_cooldown_seconds) ||
610
+ raw.min_cooldown_seconds < 0) {
611
+ throw new ValidationError(`watcher "${raw.slug}" \`min_cooldown_seconds\` must be a non-negative number`);
612
+ }
613
+ out.minCooldownSeconds = raw.min_cooldown_seconds;
614
+ }
615
+ if (raw.tags !== undefined) {
616
+ if (!Array.isArray(raw.tags) ||
617
+ !raw.tags.every((t) => typeof t === "string")) {
618
+ throw new ValidationError(`watcher "${raw.slug}" \`tags\` must be an array of strings`);
619
+ }
620
+ out.tags = raw.tags;
621
+ }
622
+ if (raw.agent_kind !== undefined) {
623
+ if (typeof raw.agent_kind !== "string" || !raw.agent_kind.trim()) {
624
+ throw new ValidationError(`watcher "${raw.slug}" \`agent_kind\` must be a non-empty string`);
625
+ }
626
+ out.agentKind = raw.agent_kind.trim();
627
+ }
628
+ if (raw.json_template !== undefined) {
629
+ out.jsonTemplate = raw.json_template;
630
+ }
631
+ if (raw.keying_config !== undefined) {
632
+ if (!isRecord(raw.keying_config)) {
633
+ throw new ValidationError(`watcher "${raw.slug}" \`keying_config\` must be an object`);
634
+ }
635
+ out.keyingConfig = raw.keying_config;
636
+ }
637
+ if (raw.classifiers !== undefined) {
638
+ if (!Array.isArray(raw.classifiers)) {
639
+ throw new ValidationError(`watcher "${raw.slug}" \`classifiers\` must be an array`);
640
+ }
641
+ out.classifiers = raw.classifiers;
642
+ }
643
+ if (raw.condensation_prompt !== undefined) {
644
+ if (typeof raw.condensation_prompt !== "string") {
645
+ throw new ValidationError(`watcher "${raw.slug}" \`condensation_prompt\` must be a string`);
646
+ }
647
+ out.condensationPrompt = raw.condensation_prompt;
648
+ }
649
+ if (raw.condensation_window_count !== undefined) {
650
+ if (typeof raw.condensation_window_count !== "number" ||
651
+ raw.condensation_window_count < 2) {
652
+ throw new ValidationError(`watcher "${raw.slug}" \`condensation_window_count\` must be a number ≥ 2`);
653
+ }
654
+ out.condensationWindowCount = raw.condensation_window_count;
655
+ }
656
+ return out;
657
+ }
454
658
  function parseRelationshipType(raw) {
455
659
  if (!isRecord(raw) || typeof raw.slug !== "string") {
456
- throw new ValidationError(`memory.relationship_types entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}`);
660
+ throw new ValidationError(`model-bundle "relationships" entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}`);
457
661
  }
458
662
  const out = { slug: raw.slug };
459
663
  if (typeof raw.name === "string")
@@ -471,57 +675,83 @@ function parseRelationshipType(raw) {
471
675
  return out;
472
676
  }
473
677
  /**
474
- * Read memory schema files referenced by `[memory].models`. Each YAML
475
- * file in that directory should declare `type: entity_type` or
476
- * `type: relationship_type` (matches the seed-cmd schema).
477
- *
478
- * v1: parse only entity_type and relationship_type. Watchers are deferred.
678
+ * Read memory schema files referenced by `[memory].models`. Every nested YAML
679
+ * file must be a dbt-style `version: 2` bundle with top-level `entities`,
680
+ * `relationships`, and `watchers` arrays; multi-document YAML streams are
681
+ * supported. `lobu apply` syncs entity types, relationship types, and watchers
682
+ * from these files; watcher sync is create-only (drift ignored).
479
683
  */
480
- async function loadMemorySchema(config, projectRoot) {
481
- const empty = { entityTypes: [], relationshipTypes: [] };
684
+ async function loadMemoryModels(config, projectRoot) {
685
+ const empty = {
686
+ entityTypes: [],
687
+ relationshipTypes: [],
688
+ watchers: [],
689
+ };
482
690
  const mem = config.memory;
483
691
  if (!mem || mem.enabled === false)
484
692
  return empty;
485
- const inline = config.memory;
486
- if (inline?.schema) {
487
- const entityTypesRaw = Array.isArray(inline.schema.entity_types)
488
- ? inline.schema.entity_types
489
- : [];
490
- const relTypesRaw = Array.isArray(inline.schema.relationship_types)
491
- ? inline.schema.relationship_types
492
- : [];
493
- return {
494
- entityTypes: entityTypesRaw.map(parseEntityType),
495
- relationshipTypes: relTypesRaw.map(parseRelationshipType),
496
- };
497
- }
498
693
  // Models directory (matches seed-cmd's resolution rules).
499
694
  const modelsRel = mem.models?.trim() || "./models";
500
695
  const modelsPath = resolve(projectRoot, modelsRel);
501
- const { existsSync, readdirSync, readFileSync } = await import("node:fs");
502
- const { parse: parseYaml } = await import("yaml");
503
696
  if (!existsSync(modelsPath))
504
697
  return empty;
505
698
  const entityTypes = [];
506
699
  const relationshipTypes = [];
507
- const files = readdirSync(modelsPath)
508
- .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
509
- .sort();
510
- for (const file of files) {
700
+ const watchers = [];
701
+ const readModelFiles = (dir, prefix = "") => {
702
+ return readdirSync(dir, { withFileTypes: true })
703
+ .sort((a, b) => a.name.localeCompare(b.name))
704
+ .flatMap((entry) => {
705
+ const relPath = prefix ? join(prefix, entry.name) : entry.name;
706
+ const fullPath = join(dir, entry.name);
707
+ if (entry.isDirectory())
708
+ return readModelFiles(fullPath, relPath);
709
+ if (entry.isFile() &&
710
+ (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
711
+ return [relPath];
712
+ }
713
+ return [];
714
+ });
715
+ };
716
+ const errors = [];
717
+ for (const file of readModelFiles(modelsPath)) {
511
718
  const raw = readFileSync(join(modelsPath, file), "utf-8");
512
- const parsed = parseYaml(raw);
513
- if (!isRecord(parsed) || typeof parsed.type !== "string")
514
- continue;
515
- if (parsed.type === "entity_type" || parsed.type === "entity") {
516
- entityTypes.push(parseEntityType(parsed));
517
- }
518
- else if (parsed.type === "relationship_type" ||
519
- parsed.type === "relationship") {
520
- relationshipTypes.push(parseRelationshipType(parsed));
719
+ const { documents, errors: parseErrors } = parseModelYamlFile(raw, file);
720
+ errors.push(...parseErrors);
721
+ for (const { data: document, file: documentFile } of documents) {
722
+ const expanded = expandModelDefinition(document, documentFile);
723
+ errors.push(...expanded.errors);
724
+ for (const model of expanded.models) {
725
+ const modelErrors = validateModel(model.data, model.file);
726
+ if (modelErrors.length > 0) {
727
+ errors.push(...modelErrors);
728
+ continue;
729
+ }
730
+ if (model.modelType === "entity") {
731
+ entityTypes.push(parseEntityType(model.data));
732
+ }
733
+ else if (model.modelType === "relationship") {
734
+ relationshipTypes.push(parseRelationshipType(model.data));
735
+ }
736
+ else if (model.modelType === "watcher") {
737
+ // `model.file` is like `schema.yaml:watchers[0]` (optionally with
738
+ // `#docIdx` for multi-doc streams) — strip the synthetic suffix
739
+ // before resolving on disk, then map through `modelsPath` to the
740
+ // absolute YAML path. `parseWatcher` reads `reaction_script:`
741
+ // sibling .ts files relative to that.
742
+ const yamlRel = model.file.replace(/[:#].*$/, "");
743
+ watchers.push(parseWatcher(model.data, join(modelsPath, yamlRel)));
744
+ }
745
+ }
521
746
  }
522
- // watcher files are out of scope for v1 apply
523
747
  }
524
- return { entityTypes, relationshipTypes };
748
+ if (errors.length > 0) {
749
+ const detail = errors
750
+ .map((e) => `${e.file}: ${e.field} — ${e.message}`)
751
+ .join("\n ");
752
+ throw new ValidationError(`Model validation failed\n ${detail}`);
753
+ }
754
+ return { entityTypes, relationshipTypes, watchers };
525
755
  }
526
756
  /**
527
757
  * The Zod schema strips unknown keys, so we re-parse the raw TOML to surface
@@ -552,10 +782,534 @@ async function rejectUnsupportedAgentShapes(cwd) {
552
782
  continue;
553
783
  const watchers = agentConfig.watchers;
554
784
  if (Array.isArray(watchers) && watchers.length > 0) {
555
- throw new ValidationError(`agent "${agentId}" declares [[agents.${agentId}.watchers]] \`lobu apply\` does not sync watchers in v1. Remove the block or use \`lobu memory seed\`.`);
785
+ throw new ValidationError(`agent "${agentId}" declares [[agents.${agentId}.watchers]] in lobu.toml watchers live in a \`[memory].models\` YAML bundle (the same file as your entity types), each with an \`agent: ${agentId}\` field pointing back here. Move the watcher there.`);
556
786
  }
557
787
  }
558
788
  }
789
+ // ── Connectors (data-source connectors) ───────────────────────────────────
790
+ const AUTH_PROFILE_KINDS = new Set([
791
+ "env",
792
+ "oauth_app",
793
+ "oauth_account",
794
+ "browser_session",
795
+ ]);
796
+ function asString(value) {
797
+ return typeof value === "string" && value.trim() ? value : undefined;
798
+ }
799
+ function parseConnectionDoc(raw, file) {
800
+ const slug = asString(raw.slug);
801
+ if (!slug) {
802
+ throw new ValidationError(`${file}: \`type: connection\` doc is missing a "slug" string`);
803
+ }
804
+ if (!CONNECTION_SLUG_PATTERN.test(slug)) {
805
+ throw new ValidationError(`${file}: connection slug "${slug}" must match /^[a-z0-9][a-z0-9-]{0,62}$/ (lowercase letters/digits/hyphens, no leading hyphen, ≤63 chars)`);
806
+ }
807
+ const connector = asString(raw.connector);
808
+ if (!connector) {
809
+ throw new ValidationError(`${file}: connection "${slug}" is missing a "connector" key`);
810
+ }
811
+ const out = {
812
+ slug,
813
+ connector,
814
+ feeds: [],
815
+ sourceFile: file,
816
+ };
817
+ const name = asString(raw.name);
818
+ if (name)
819
+ out.name = name;
820
+ const auth = asString(raw.auth);
821
+ if (auth)
822
+ out.authProfileSlug = auth;
823
+ const appAuth = asString(raw.app_auth);
824
+ if (appAuth)
825
+ out.appAuthProfileSlug = appAuth;
826
+ if (raw.device_worker_id !== undefined) {
827
+ if (typeof raw.device_worker_id !== "string" ||
828
+ !UUID_PATTERN.test(raw.device_worker_id.trim())) {
829
+ throw new ValidationError(`${file}: connection "${slug}" \`device_worker_id\` must be a UUID (the device_workers.id of the device this connection runs on)`);
830
+ }
831
+ out.deviceWorkerId = raw.device_worker_id.trim();
832
+ }
833
+ if (raw.config !== undefined) {
834
+ if (!isRecord(raw.config)) {
835
+ throw new ValidationError(`${file}: connection "${slug}" \`config\` must be an object`);
836
+ }
837
+ // action_modes is keyed by operation_key and each value must be one of
838
+ // 'disabled' | 'approval' | 'auto'. The server tolerates unknown values
839
+ // (falls back to the connector default), so a typo like 'auto-approve'
840
+ // would silently degrade. Fail fast at apply time instead.
841
+ if (raw.config.action_modes !== undefined) {
842
+ if (!isRecord(raw.config.action_modes)) {
843
+ throw new ValidationError(`${file}: connection "${slug}" \`config.action_modes\` must be an object mapping operation keys to one of: disabled, approval, auto`);
844
+ }
845
+ for (const [opKey, mode] of Object.entries(raw.config.action_modes)) {
846
+ if (mode !== "disabled" && mode !== "approval" && mode !== "auto") {
847
+ throw new ValidationError(`${file}: connection "${slug}" \`config.action_modes.${opKey}\` must be one of: disabled, approval, auto (got ${JSON.stringify(mode)})`);
848
+ }
849
+ }
850
+ }
851
+ out.config = raw.config;
852
+ }
853
+ if (raw.feeds !== undefined) {
854
+ if (!Array.isArray(raw.feeds)) {
855
+ throw new ValidationError(`${file}: connection "${slug}" \`feeds\` must be an array`);
856
+ }
857
+ const seen = new Set();
858
+ for (const entry of raw.feeds) {
859
+ if (!isRecord(entry)) {
860
+ throw new ValidationError(`${file}: connection "${slug}" feed entries must be objects`);
861
+ }
862
+ const feedKey = asString(entry.feed);
863
+ if (!feedKey) {
864
+ throw new ValidationError(`${file}: connection "${slug}" feed entry is missing a "feed" key`);
865
+ }
866
+ if (seen.has(feedKey)) {
867
+ throw new ValidationError(`${file}: connection "${slug}" declares feed "${feedKey}" twice`);
868
+ }
869
+ seen.add(feedKey);
870
+ const feed = { feedKey };
871
+ const feedName = asString(entry.name);
872
+ if (feedName)
873
+ feed.name = feedName;
874
+ const schedule = asString(entry.schedule);
875
+ if (schedule) {
876
+ const err = cronError(schedule);
877
+ if (err) {
878
+ throw new ValidationError(`${file}: connection "${slug}" feed "${feedKey}" ${err}`);
879
+ }
880
+ feed.schedule = schedule;
881
+ }
882
+ if (entry.config !== undefined) {
883
+ if (!isRecord(entry.config)) {
884
+ throw new ValidationError(`${file}: connection "${slug}" feed "${feedKey}" \`config\` must be an object`);
885
+ }
886
+ feed.config = entry.config;
887
+ }
888
+ out.feeds.push(feed);
889
+ }
890
+ }
891
+ return out;
892
+ }
893
+ function parseAuthProfileDoc(raw, file) {
894
+ const slug = asString(raw.slug);
895
+ if (!slug) {
896
+ throw new ValidationError(`${file}: \`type: auth_profile\` doc is missing a "slug" string`);
897
+ }
898
+ if (!AUTH_PROFILE_SLUG_PATTERN.test(slug)) {
899
+ throw new ValidationError(`${file}: auth_profile slug "${slug}" must match /^[a-z0-9][a-z0-9-]{0,79}$/ (lowercase letters/digits/hyphens, no leading hyphen, ≤80 chars)`);
900
+ }
901
+ const connector = asString(raw.connector);
902
+ if (!connector) {
903
+ throw new ValidationError(`${file}: auth_profile "${slug}" is missing a "connector" key`);
904
+ }
905
+ const kind = asString(raw.kind);
906
+ if (!kind || !AUTH_PROFILE_KINDS.has(kind)) {
907
+ throw new ValidationError(`${file}: auth_profile "${slug}" \`kind\` must be one of env|oauth_app|oauth_account|browser_session (got ${JSON.stringify(raw.kind)})`);
908
+ }
909
+ const out = {
910
+ slug,
911
+ connector,
912
+ kind: kind,
913
+ sourceFile: file,
914
+ };
915
+ const name = asString(raw.name);
916
+ if (name)
917
+ out.name = name;
918
+ if (raw.credentials !== undefined) {
919
+ if (!isRecord(raw.credentials)) {
920
+ throw new ValidationError(`${file}: auth_profile "${slug}" \`credentials\` must be an object`);
921
+ }
922
+ const creds = {};
923
+ for (const [k, v] of Object.entries(raw.credentials)) {
924
+ if (typeof v !== "string") {
925
+ throw new ValidationError(`${file}: auth_profile "${slug}" credential "${k}" must be a string (use $ENV for secrets)`);
926
+ }
927
+ creds[k] = v;
928
+ }
929
+ if (kind === "oauth_account" || kind === "browser_session") {
930
+ if (Object.keys(creds).length > 0) {
931
+ throw new ValidationError(`${file}: auth_profile "${slug}" has \`kind: ${kind}\` — credentials must not be set; \`lobu apply\` never writes interactive-auth tokens (complete auth via the connect URL).`);
932
+ }
933
+ }
934
+ else {
935
+ out.credentials = creds;
936
+ }
937
+ }
938
+ return out;
939
+ }
940
+ function parseConnectorDoc(raw, file) {
941
+ const key = asString(raw.key);
942
+ if (!key) {
943
+ throw new ValidationError(`${file}: \`type: connector\` doc is missing a "key" string`);
944
+ }
945
+ const sourcePath = asString(raw.source_path);
946
+ const sourceUrl = asString(raw.source_url);
947
+ if (!!sourcePath === !!sourceUrl) {
948
+ throw new ValidationError(`${file}: connector "${key}" must declare exactly one of \`source_path\` or \`source_url\``);
949
+ }
950
+ if (sourceUrl) {
951
+ let parsed;
952
+ try {
953
+ parsed = new URL(sourceUrl);
954
+ }
955
+ catch {
956
+ throw new ValidationError(`${file}: connector "${key}" source_url is not a valid URL: ${sourceUrl}`);
957
+ }
958
+ if (parsed.protocol !== "https:") {
959
+ throw new ValidationError(`${file}: connector "${key}" source_url must use https (got ${parsed.protocol}//)`);
960
+ }
961
+ }
962
+ return {
963
+ key,
964
+ ...(sourcePath ? { sourcePath } : {}),
965
+ ...(sourceUrl ? { sourceUrl } : {}),
966
+ };
967
+ }
968
+ const EMPTY_CONNECTORS = {
969
+ definitions: [],
970
+ authProfiles: [],
971
+ connections: [],
972
+ };
973
+ /**
974
+ * Load the `[memory].connectors` directory:
975
+ * - every `*.connector.ts` is auto-discovered as a connector definition
976
+ * (raw source pushed to the server, which compiles + extracts the key)
977
+ * - `*.yaml` files are multi-doc (`---`-separated); each doc carries
978
+ * `version: 1` and a `type:` of `connection`, `auth_profile`, or `connector`
979
+ *
980
+ * `connector:` config validation against the connector's `optionsSchema` /
981
+ * feed `configSchema` / `authSchema` happens later (in `apply-cmd`) once the
982
+ * remote connector-definition catalog is available — the CLI never compiles
983
+ * connectors locally.
984
+ */
985
+ async function loadConnectors(config, projectRoot, env, envRefs) {
986
+ const mem = config.memory;
987
+ if (!mem || mem.enabled === false)
988
+ return EMPTY_CONNECTORS;
989
+ const dirRel = mem.connectors?.trim() || "./connectors";
990
+ const dirPath = resolve(projectRoot, dirRel);
991
+ let entries;
992
+ try {
993
+ entries = (await readdir(dirPath)).sort();
994
+ }
995
+ catch {
996
+ return EMPTY_CONNECTORS;
997
+ }
998
+ const { parseAllDocuments } = await import("yaml");
999
+ const definitionsByKey = new Map();
1000
+ // Keys explicitly declared by a `type: connector` doc (vs auto-discovered
1001
+ // from a `*.connector.ts` filename). A given connector key may be declared by
1002
+ // at most one such doc — even two docs pointing at the same `source_path`.
1003
+ const connectorDocKeyDeclaredBy = new Map();
1004
+ // `.connector.ts` files keyed by their *absolute path* — we don't know the
1005
+ // connector key until the server compiles them. `type: connector` docs with
1006
+ // `source_path:` that point at one of these files just dedupe to the file.
1007
+ const tsFileDefinitions = new Map();
1008
+ const authProfiles = new Map();
1009
+ const connections = new Map();
1010
+ for (const entry of entries) {
1011
+ const entryPath = join(dirPath, entry);
1012
+ let entryStat;
1013
+ try {
1014
+ entryStat = await stat(entryPath);
1015
+ }
1016
+ catch {
1017
+ continue;
1018
+ }
1019
+ if (!entryStat.isFile())
1020
+ continue;
1021
+ // Auto-discovered local connector definition.
1022
+ if (entry.endsWith(".connector.ts")) {
1023
+ const sourceCode = await readFile(entryPath, "utf-8");
1024
+ tsFileDefinitions.set(entryPath, {
1025
+ key: null,
1026
+ sourcePath: entryPath,
1027
+ sourceCode,
1028
+ sourceFile: `${dirRel}/${entry}`,
1029
+ });
1030
+ continue;
1031
+ }
1032
+ if (!entry.endsWith(".yaml") && !entry.endsWith(".yml"))
1033
+ continue;
1034
+ const rel = `${dirRel}/${entry}`;
1035
+ const raw = await readFile(entryPath, "utf-8");
1036
+ let docs;
1037
+ try {
1038
+ docs = parseAllDocuments(raw)
1039
+ .map((doc) => doc.toJSON())
1040
+ .filter((doc) => doc !== null && doc !== undefined);
1041
+ }
1042
+ catch (err) {
1043
+ throw new ValidationError(`${rel}: failed to parse YAML — ${err instanceof Error ? err.message : String(err)}`);
1044
+ }
1045
+ for (const doc of docs) {
1046
+ if (!isRecord(doc)) {
1047
+ throw new ValidationError(`${rel}: each connectors doc must be a mapping with \`version\` and \`type\``);
1048
+ }
1049
+ const type = asString(doc.type);
1050
+ if (!type) {
1051
+ throw new ValidationError(`${rel}: connectors doc is missing a "type" (connection|auth_profile|connector)`);
1052
+ }
1053
+ if (doc.version !== undefined && doc.version !== 1) {
1054
+ throw new ValidationError(`${rel}: unsupported connectors doc version ${JSON.stringify(doc.version)} (expected 1)`);
1055
+ }
1056
+ if (type === "connection") {
1057
+ const conn = parseConnectionDoc(doc, rel);
1058
+ if (connections.has(conn.slug)) {
1059
+ throw new ValidationError(`${rel}: duplicate connection slug "${conn.slug}"`);
1060
+ }
1061
+ connections.set(conn.slug, conn);
1062
+ }
1063
+ else if (type === "auth_profile") {
1064
+ const profile = parseAuthProfileDoc(doc, rel);
1065
+ if (authProfiles.has(profile.slug)) {
1066
+ throw new ValidationError(`${rel}: duplicate auth_profile slug "${profile.slug}"`);
1067
+ }
1068
+ if (profile.credentials) {
1069
+ // Expand `$ENV` refs in-place (collect them too, so the apply
1070
+ // secrets gate fails loud) — never push literal `$NAME` strings.
1071
+ const resolved = {};
1072
+ for (const [k, v] of Object.entries(profile.credentials)) {
1073
+ const ref = asEnvRef(v);
1074
+ if (!ref) {
1075
+ resolved[k] = v;
1076
+ continue;
1077
+ }
1078
+ envRefs.add(ref);
1079
+ const value = env[ref];
1080
+ if (value === undefined || value === "") {
1081
+ throw new ValidationError(`${rel}: auth_profile "${profile.slug}" credential "${k}" references $${ref}, but it is unset or empty in the apply environment`);
1082
+ }
1083
+ resolved[k] = value;
1084
+ }
1085
+ profile.credentials = resolved;
1086
+ }
1087
+ authProfiles.set(profile.slug, profile);
1088
+ }
1089
+ else if (type === "connector") {
1090
+ const parsed = parseConnectorDoc(doc, rel);
1091
+ const priorDoc = connectorDocKeyDeclaredBy.get(parsed.key);
1092
+ if (priorDoc) {
1093
+ throw new ValidationError(`connector key "${parsed.key}" is declared by two \`type: connector\` docs — ${priorDoc} and ${rel}; keys must be unique`);
1094
+ }
1095
+ connectorDocKeyDeclaredBy.set(parsed.key, rel);
1096
+ if (parsed.sourceUrl) {
1097
+ const prior = definitionsByKey.get(parsed.key);
1098
+ if (prior) {
1099
+ throw new ValidationError(`connector key "${parsed.key}" is declared twice — in ${prior.sourceFile} and ${rel}; keys must be unique`);
1100
+ }
1101
+ const priorTs = [...tsFileDefinitions.values()].find((d) => d.key === parsed.key);
1102
+ if (priorTs) {
1103
+ throw new ValidationError(`connector key "${parsed.key}" is declared twice — in ${priorTs.sourceFile} and ${rel}; keys must be unique`);
1104
+ }
1105
+ definitionsByKey.set(parsed.key, {
1106
+ key: parsed.key,
1107
+ sourceUrl: parsed.sourceUrl,
1108
+ sourceFile: rel,
1109
+ });
1110
+ }
1111
+ else if (parsed.sourcePath) {
1112
+ // `source_path` is resolved relative to the manifest YAML file's
1113
+ // directory (the connectors/ dir), matching the watcher-classifier
1114
+ // `source_path` convention.
1115
+ const abs = resolve(dirPath, parsed.sourcePath);
1116
+ // The declared key must not collide with another connector definition.
1117
+ const keyClash = definitionsByKey.get(parsed.key) ??
1118
+ [...tsFileDefinitions.entries()].find(([p, d]) => d.key === parsed.key && p !== abs)?.[1];
1119
+ if (keyClash) {
1120
+ throw new ValidationError(`connector key "${parsed.key}" is declared twice — in ${keyClash.sourceFile} and ${rel}; keys must be unique`);
1121
+ }
1122
+ if (tsFileDefinitions.has(abs)) {
1123
+ // Already auto-discovered as a `*.connector.ts` file; the
1124
+ // `type: connector` doc just declares its key for clearer output.
1125
+ const existing = tsFileDefinitions.get(abs);
1126
+ if (existing) {
1127
+ if (existing.key !== null && existing.key !== parsed.key) {
1128
+ throw new ValidationError(`${existing.sourceFile} declares connector key "${existing.key}" but ${rel} declares "${parsed.key}" for the same file — they must agree`);
1129
+ }
1130
+ existing.key = parsed.key;
1131
+ }
1132
+ }
1133
+ else {
1134
+ let sourceCode;
1135
+ try {
1136
+ sourceCode = await readFile(abs, "utf-8");
1137
+ }
1138
+ catch {
1139
+ throw new ValidationError(`${rel}: connector "${parsed.key}" \`source_path\` ${parsed.sourcePath} does not exist`);
1140
+ }
1141
+ tsFileDefinitions.set(abs, {
1142
+ key: parsed.key,
1143
+ sourcePath: abs,
1144
+ sourceCode,
1145
+ sourceFile: rel,
1146
+ });
1147
+ }
1148
+ }
1149
+ }
1150
+ else {
1151
+ throw new ValidationError(`${rel}: unknown connectors doc type "${type}" (expected connection|auth_profile|connector)`);
1152
+ }
1153
+ }
1154
+ }
1155
+ const allDefs = [...definitionsByKey.values(), ...tsFileDefinitions.values()];
1156
+ const seenKeys = new Map();
1157
+ for (const def of allDefs) {
1158
+ if (def.key === null)
1159
+ continue;
1160
+ const prior = seenKeys.get(def.key);
1161
+ if (prior) {
1162
+ throw new ValidationError(`connector key "${def.key}" is declared twice — in ${prior} and ${def.sourceFile}; keys must be unique`);
1163
+ }
1164
+ seenKeys.set(def.key, def.sourceFile);
1165
+ }
1166
+ return {
1167
+ definitions: allDefs.sort((a, b) => (a.key ?? a.sourceFile).localeCompare(b.key ?? b.sourceFile)),
1168
+ authProfiles: [...authProfiles.values()].sort((a, b) => a.slug.localeCompare(b.slug)),
1169
+ connections: [...connections.values()].sort((a, b) => a.slug.localeCompare(b.slug)),
1170
+ };
1171
+ }
1172
+ function schemaFromAuthMethods(authSchema) {
1173
+ const kinds = new Set();
1174
+ if (!authSchema || typeof authSchema !== "object")
1175
+ return kinds;
1176
+ const methods = authSchema.methods;
1177
+ if (!Array.isArray(methods))
1178
+ return kinds;
1179
+ for (const method of methods) {
1180
+ if (!isRecord(method))
1181
+ continue;
1182
+ const t = asString(method.type);
1183
+ // ConnectorAuthMethod `type` ∈ env_keys | oauth | browser | interactive | none
1184
+ if (t === "env_keys")
1185
+ kinds.add("env");
1186
+ else if (t === "oauth") {
1187
+ kinds.add("oauth_app");
1188
+ kinds.add("oauth_account");
1189
+ }
1190
+ else if (t === "browser" || t === "interactive") {
1191
+ kinds.add("browser_session");
1192
+ }
1193
+ }
1194
+ return kinds;
1195
+ }
1196
+ /**
1197
+ * Build per-connector validation schemas from a connector definition. Accepts
1198
+ * either a typed `ConnectorDefinition` (from `@lobu/connector-sdk`) or the
1199
+ * snake_cased shape the server's `manage_connections list_connector_definitions`
1200
+ * returns (`options_schema`, `feeds_schema`, `auth_schema`).
1201
+ */
1202
+ export function resolveConnectorSchemas(def) {
1203
+ const optionsSchema = ("optionsSchema" in def ? def.optionsSchema : undefined) ??
1204
+ ("options_schema" in def ? (def.options_schema ?? undefined) : undefined) ??
1205
+ undefined;
1206
+ const feedsRaw = ("feeds" in def ? def.feeds : undefined) ??
1207
+ ("feeds_schema" in def ? (def.feeds_schema ?? undefined) : undefined) ??
1208
+ undefined;
1209
+ const authSchema = ("authSchema" in def ? def.authSchema : undefined) ??
1210
+ ("auth_schema" in def ? (def.auth_schema ?? undefined) : undefined) ??
1211
+ undefined;
1212
+ const feedKeys = new Set();
1213
+ const feedConfigSchemas = new Map();
1214
+ if (feedsRaw && typeof feedsRaw === "object") {
1215
+ for (const [feedKey, feedDef] of Object.entries(feedsRaw)) {
1216
+ if (!feedDef || typeof feedDef !== "object")
1217
+ continue;
1218
+ feedKeys.add(feedKey);
1219
+ const cfg = feedDef.configSchema;
1220
+ if (cfg && typeof cfg === "object") {
1221
+ feedConfigSchemas.set(feedKey, cfg);
1222
+ }
1223
+ }
1224
+ }
1225
+ return {
1226
+ ...(optionsSchema ? { optionsSchema } : {}),
1227
+ feedKeys,
1228
+ feedConfigSchemas,
1229
+ authKinds: schemaFromAuthMethods(authSchema),
1230
+ };
1231
+ }
1232
+ let sharedAjv = null;
1233
+ function getAjv() {
1234
+ if (!sharedAjv) {
1235
+ sharedAjv = new Ajv({ allErrors: true, strict: false });
1236
+ addFormats(sharedAjv);
1237
+ }
1238
+ return sharedAjv;
1239
+ }
1240
+ function validateAgainstSchema(schema, value, context) {
1241
+ const ajv = getAjv();
1242
+ let validate;
1243
+ try {
1244
+ validate = ajv.compile(schema);
1245
+ }
1246
+ catch (err) {
1247
+ // A malformed connector schema is the connector author's problem, not the
1248
+ // operator's — surface it but don't block the whole apply on it.
1249
+ throw new ValidationError(`${context}: connector declares an invalid JSON schema — ${err instanceof Error ? err.message : String(err)}`);
1250
+ }
1251
+ if (!validate(value ?? {})) {
1252
+ const detail = (validate.errors ?? [])
1253
+ .map((e) => `${e.instancePath || "(root)"} ${e.message ?? ""}`.trim())
1254
+ .join("; ");
1255
+ throw new ValidationError(`${context}: ${detail || "does not match the connector schema"}`);
1256
+ }
1257
+ }
1258
+ /**
1259
+ * Validate a single connection (+ its feeds) and its referenced auth-profile
1260
+ * kinds against a resolved connector schema. Pass `null` to skip schema
1261
+ * checks (e.g. a connector that only exists as a local `.ts` not yet
1262
+ * compiled by the server) — structural checks have already run at load time.
1263
+ */
1264
+ export function validateConnectionAgainstConnector(connection, authProfiles, schemas) {
1265
+ // Validate against `{}` when config is omitted too — that surfaces missing
1266
+ // required keys instead of letting an empty config slip through.
1267
+ if (schemas?.optionsSchema) {
1268
+ validateAgainstSchema(schemas.optionsSchema, connection.config ?? {}, `${connection.sourceFile}: connection "${connection.slug}" config`);
1269
+ }
1270
+ for (const feed of connection.feeds) {
1271
+ if (!schemas)
1272
+ continue;
1273
+ if (schemas.feedKeys.size > 0 && !schemas.feedKeys.has(feed.feedKey)) {
1274
+ throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references unknown feed "${feed.feedKey}" for connector "${connection.connector}" (known feeds: ${[...schemas.feedKeys].sort().join(", ") || "(none)"})`);
1275
+ }
1276
+ const feedSchema = schemas.feedConfigSchemas.get(feed.feedKey);
1277
+ if (feedSchema) {
1278
+ validateAgainstSchema(feedSchema, feed.config ?? {}, `${connection.sourceFile}: connection "${connection.slug}" feed "${feed.feedKey}" config`);
1279
+ }
1280
+ }
1281
+ // `auth:` must reference a runtime/account profile (never `oauth_app`);
1282
+ // `app_auth:` must reference an `oauth_app` profile.
1283
+ if (connection.authProfileSlug) {
1284
+ const profile = requireAuthProfile(connection, authProfiles, connection.authProfileSlug);
1285
+ if (profile.kind === "oauth_app") {
1286
+ throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" \`auth\` references auth profile "${connection.authProfileSlug}" of kind \`oauth_app\` — use \`app_auth\` for OAuth-app credentials and \`auth\` for the account/runtime profile`);
1287
+ }
1288
+ }
1289
+ if (connection.appAuthProfileSlug) {
1290
+ const profile = requireAuthProfile(connection, authProfiles, connection.appAuthProfileSlug);
1291
+ if (profile.kind !== "oauth_app") {
1292
+ throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" \`app_auth\` must reference an \`oauth_app\` auth profile (got \`${profile.kind}\`)`);
1293
+ }
1294
+ }
1295
+ }
1296
+ function requireAuthProfile(connection, authProfiles, slugRef) {
1297
+ const profile = authProfiles.get(slugRef);
1298
+ if (!profile) {
1299
+ throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references auth profile "${slugRef}" which is not declared in any \`type: auth_profile\` doc`);
1300
+ }
1301
+ if (profile.connector !== connection.connector) {
1302
+ throw new ValidationError(`${connection.sourceFile}: connection "${connection.slug}" references auth profile "${slugRef}" for connector "${profile.connector}", but the connection uses connector "${connection.connector}"`);
1303
+ }
1304
+ return profile;
1305
+ }
1306
+ export function validateAuthProfileAgainstConnector(profile, schemas) {
1307
+ if (!schemas)
1308
+ return;
1309
+ if (schemas.authKinds.size > 0 && !schemas.authKinds.has(profile.kind)) {
1310
+ throw new ValidationError(`${profile.sourceFile}: auth_profile "${profile.slug}" uses \`kind: ${profile.kind}\`, but connector "${profile.connector}" supports: ${[...schemas.authKinds].sort().join(", ") || "(none)"}`);
1311
+ }
1312
+ }
559
1313
  export async function loadDesiredState(opts) {
560
1314
  const result = await loadConfig(opts.cwd);
561
1315
  if (isLoadError(result)) {
@@ -579,19 +1333,56 @@ export async function loadDesiredState(opts) {
579
1333
  ]);
580
1334
  const settings = buildAgentSettings(agentConfig, markdown, skillFiles);
581
1335
  const platforms = buildPlatforms(agentId, agentConfig, env);
1336
+ // Resolve `[[providers]] key = "$VAR"` against the apply env. The
1337
+ // required-secrets gate in apply-cmd already failed loudly if any $VAR
1338
+ // is unset, so a missing value here means the operator omitted `key`
1339
+ // entirely (BYOK / web-UI flow); silently skip those.
1340
+ const providerKeys = [];
1341
+ for (const provider of agentConfig.providers) {
1342
+ if (!provider.key)
1343
+ continue;
1344
+ const ref = asEnvRef(provider.key);
1345
+ const resolved = ref ? env[ref] : provider.key;
1346
+ if (!resolved)
1347
+ continue;
1348
+ providerKeys.push({ providerId: provider.id, value: resolved });
1349
+ }
582
1350
  const metadata = {
583
1351
  agentId,
584
1352
  name: agentConfig.name,
585
1353
  };
586
1354
  if (agentConfig.description)
587
1355
  metadata.description = agentConfig.description;
588
- agents.push({ metadata, settings, platforms });
1356
+ agents.push({ metadata, settings, platforms, providerKeys });
1357
+ }
1358
+ const { entityTypes, relationshipTypes, watchers } = await loadMemoryModels(config, opts.cwd);
1359
+ for (const watcher of watchers) {
1360
+ if (!config.agents[watcher.agent]) {
1361
+ throw new ValidationError(`watcher "${watcher.slug}" names agent "${watcher.agent}", but there is no \`[agents.${watcher.agent}]\` block in lobu.toml`);
1362
+ }
589
1363
  }
590
- const memorySchema = await loadMemorySchema(config, opts.cwd);
1364
+ const connectors = opts.only
1365
+ ? { definitions: [], authProfiles: [], connections: [] }
1366
+ : await loadConnectors(config, opts.cwd, env, requiredSecrets);
1367
+ const memory = config.memory && config.memory.enabled !== false
1368
+ ? {
1369
+ ...(config.memory.org ? { org: config.memory.org } : {}),
1370
+ ...(config.memory.organization_id
1371
+ ? { organizationId: config.memory.organization_id }
1372
+ : {}),
1373
+ ...(config.memory.name ? { name: config.memory.name } : {}),
1374
+ ...(config.memory.description
1375
+ ? { description: config.memory.description }
1376
+ : {}),
1377
+ }
1378
+ : undefined;
591
1379
  return {
592
1380
  state: {
593
1381
  agents,
594
- memorySchema,
1382
+ ...(memory ? { memory } : {}),
1383
+ memorySchema: { entityTypes, relationshipTypes },
1384
+ watchers,
1385
+ connectors,
595
1386
  requiredSecrets: [...requiredSecrets].sort(),
596
1387
  },
597
1388
  configPath,