okengine 0.6.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +90 -20
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Smart Ollama recommendation scoring + RAM tier rules.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { recommendChatModel } from "./catalog.ts";
7
+ import {
8
+ fittingChatModels,
9
+ formatMachineSummary,
10
+ isTightFit,
11
+ modelFitsComfortably,
12
+ modelFitsOnMachine,
13
+ recommendChatForNeeds,
14
+ type AiNeeds,
15
+ } from "./recommend.ts";
16
+
17
+ const balanced: AiNeeds = {
18
+ useCase: "balanced",
19
+ priority: "balanced",
20
+ wantVision: false,
21
+ };
22
+
23
+ describe("modelFitsOnMachine", () => {
24
+ test("catalog ramGb is machine tier, not download size", () => {
25
+ expect(modelFitsOnMachine({ id: "x", label: "x", hint: "", role: "chat", ramGb: 16 }, 24)).toBe(
26
+ true,
27
+ );
28
+ expect(modelFitsOnMachine({ id: "x", label: "x", hint: "", role: "chat", ramGb: 32 }, 24)).toBe(
29
+ false,
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("recommendChatForNeeds", () => {
35
+ test("8GB → entry-level only", () => {
36
+ const pick = recommendChatForNeeds(8, balanced);
37
+ expect(pick.id).toBe("gemma4:e4b");
38
+ expect(pick.ramGb).toBeLessThanOrEqual(8);
39
+ });
40
+
41
+ test("16GB → never recommends 27b / 32GB-class", () => {
42
+ const pick = recommendChatForNeeds(16, { ...balanced, useCase: "coding", priority: "quality" });
43
+ expect(pick.ramGb).toBeLessThanOrEqual(16);
44
+ expect(pick.id).not.toBe("qwen3.5:27b");
45
+ });
46
+
47
+ test("24GB does not treat full RAM as model size", () => {
48
+ const pick = recommendChatForNeeds(24, balanced);
49
+ // Must leave headroom — not a fictional "24GB model"
50
+ expect(pick.ramGb).toBeLessThan(24);
51
+ expect(pick.ramGb).toBeLessThanOrEqual(16);
52
+ const summary = formatMachineSummary(24, pick);
53
+ expect(summary).toContain("Your machine: ~24GB");
54
+ expect(summary).toContain(`${pick.ramGb}GB-class`);
55
+ expect(summary).not.toMatch(/download a 24GB/i);
56
+ });
57
+
58
+ test("coding preference leans Qwen when it fits", () => {
59
+ const pick = recommendChatForNeeds(24, {
60
+ useCase: "coding",
61
+ priority: "balanced",
62
+ wantVision: false,
63
+ });
64
+ expect(pick.id.startsWith("qwen")).toBe(true);
65
+ });
66
+
67
+ test("reasoning preference leans DeepSeek when it fits", () => {
68
+ const pick = recommendChatForNeeds(16, {
69
+ useCase: "reasoning",
70
+ priority: "balanced",
71
+ wantVision: false,
72
+ });
73
+ expect(pick.id).toContain("deepseek");
74
+ });
75
+ });
76
+
77
+ describe("comfortable vs tight", () => {
78
+ test("16GB-class on 16GB machine is tight", () => {
79
+ const qwen = fittingChatModels(16).find((m) => m.id === "qwen3.5:9b");
80
+ expect(qwen).toBeDefined();
81
+ expect(modelFitsOnMachine(qwen!, 16)).toBe(true);
82
+ expect(modelFitsComfortably(qwen!, 16)).toBe(false);
83
+ expect(isTightFit(qwen!, 16)).toBe(true);
84
+ });
85
+
86
+ test("16GB-class on 24GB machine is comfortable", () => {
87
+ const qwen = fittingChatModels(24).find((m) => m.id === "qwen3.5:9b")!;
88
+ expect(modelFitsComfortably(qwen, 24)).toBe(true);
89
+ expect(isTightFit(qwen, 24)).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("recommendChatModel", () => {
94
+ test("respects tiers with headroom preference", () => {
95
+ expect(recommendChatModel(8).id).toBe("gemma4:e4b");
96
+ expect(recommendChatModel(16).ramGb).toBeLessThanOrEqual(16);
97
+ expect(recommendChatModel(24).ramGb).toBeLessThanOrEqual(16);
98
+ expect(recommendChatModel(null).recommended).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Smart local-model recommendations — machine-tier fit + use-case scoring.
3
+ *
4
+ * Catalog `ramGb` is the **recommended machine tier** for that model (not the
5
+ * download size). A 24GB laptop must never be told it will "download a 24GB
6
+ * model" — that number was machine RAM shown in the wrong place.
7
+ *
8
+ * Most dev machines are 8–16GB. Prefer picks that leave ~4GB for OS / IDE /
9
+ * browser when several models fit.
10
+ */
11
+
12
+ import { CHAT_MODELS, VISION_MODELS, type CatalogModel } from "./catalog.ts";
13
+
14
+ /** Reserved for OS + IDE + browser while the model runs. */
15
+ export const OS_HEADROOM_GB = 4;
16
+
17
+ /** What the developer mainly wants from the local model. */
18
+ export type AiUseCase = "coding" | "general" | "reasoning" | "balanced";
19
+
20
+ /** Speed vs quality preference. */
21
+ export type AiPriority = "speed" | "balanced" | "quality";
22
+
23
+ /** Answers from the short Ollama questionnaire. */
24
+ export type AiNeeds = {
25
+ readonly useCase: AiUseCase;
26
+ readonly priority: AiPriority;
27
+ readonly wantVision: boolean;
28
+ };
29
+
30
+ /**
31
+ * RAM left after reserving OS/IDE headroom.
32
+ *
33
+ * @param totalRamGb - Detected machine RAM
34
+ */
35
+ export function usableRamGb(totalRamGb: number): number {
36
+ return Math.max(0, totalRamGb - OS_HEADROOM_GB);
37
+ }
38
+
39
+ /**
40
+ * Whether the machine meets the model's recommended tier (`model.ramGb`).
41
+ *
42
+ * @param model - Catalog entry
43
+ * @param totalRamGb - Machine RAM (null → only the catalog default recommended)
44
+ */
45
+ export function modelFitsOnMachine(model: CatalogModel, totalRamGb: number | null): boolean {
46
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
47
+ return Boolean(model.recommended);
48
+ }
49
+ return model.ramGb <= totalRamGb;
50
+ }
51
+
52
+ /**
53
+ * Whether the model fits with comfortable headroom for OS/IDE.
54
+ *
55
+ * @param model - Catalog entry
56
+ * @param totalRamGb - Machine RAM
57
+ */
58
+ export function modelFitsComfortably(model: CatalogModel, totalRamGb: number | null): boolean {
59
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
60
+ return Boolean(model.recommended);
61
+ }
62
+ const smallest = [...CHAT_MODELS].sort((a, b) => a.ramGb - b.ramGb)[0]!;
63
+ // Always allow the entry-level model on machines that meet its tier.
64
+ if (model.id === smallest.id) return model.ramGb <= totalRamGb;
65
+ return model.ramGb + OS_HEADROOM_GB <= totalRamGb;
66
+ }
67
+
68
+ /**
69
+ * True when the model meets the tier but leaves little room for OS/IDE.
70
+ *
71
+ * @param model - Catalog entry
72
+ * @param totalRamGb - Machine RAM
73
+ */
74
+ export function isTightFit(model: CatalogModel, totalRamGb: number | null): boolean {
75
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) return false;
76
+ return modelFitsOnMachine(model, totalRamGb) && !modelFitsComfortably(model, totalRamGb);
77
+ }
78
+
79
+ /**
80
+ * Chat models that meet the machine tier, smallest → largest.
81
+ *
82
+ * @param totalRamGb - Machine RAM
83
+ */
84
+ export function fittingChatModels(totalRamGb: number | null): readonly CatalogModel[] {
85
+ const fits = CHAT_MODELS.filter((m) => modelFitsOnMachine(m, totalRamGb));
86
+ if (fits.length > 0) return [...fits].sort((a, b) => a.ramGb - b.ramGb);
87
+ return [[...CHAT_MODELS].sort((a, b) => a.ramGb - b.ramGb)[0]!];
88
+ }
89
+
90
+ /**
91
+ * Prefer comfortable fits; fall back to any tier-fit if needed.
92
+ *
93
+ * @param totalRamGb - Machine RAM
94
+ */
95
+ export function comfortableChatModels(totalRamGb: number | null): readonly CatalogModel[] {
96
+ const comfortable = CHAT_MODELS.filter((m) => modelFitsComfortably(m, totalRamGb));
97
+ if (comfortable.length > 0) {
98
+ return [...comfortable].sort((a, b) => a.ramGb - b.ramGb);
99
+ }
100
+ return fittingChatModels(totalRamGb);
101
+ }
102
+
103
+ /**
104
+ * Score a fitting chat model for the user's needs (higher = better).
105
+ *
106
+ * @param model - Candidate
107
+ * @param needs - Questionnaire answers
108
+ * @param totalRamGb - Machine RAM (tight-fit penalty)
109
+ */
110
+ export function scoreChatModel(
111
+ model: CatalogModel,
112
+ needs: AiNeeds,
113
+ totalRamGb: number | null = null,
114
+ ): number {
115
+ let score = 0;
116
+ if (needs.priority === "speed") score += 100 - model.ramGb * 3;
117
+ else if (needs.priority === "quality") score += model.ramGb * 4;
118
+ else score += model.ramGb * 2;
119
+
120
+ if (needs.useCase === "coding") {
121
+ if (model.id.startsWith("qwen")) score += 40;
122
+ if (model.id.includes("deepseek")) score += 15;
123
+ } else if (needs.useCase === "reasoning") {
124
+ if (model.id.includes("deepseek") || model.id.includes("r1")) score += 45;
125
+ if (model.id.startsWith("qwen")) score += 20;
126
+ } else if (needs.useCase === "general") {
127
+ if (model.id.includes("llama") || model.id.includes("gemma")) score += 35;
128
+ if (model.id.startsWith("qwen")) score += 20;
129
+ } else {
130
+ if (model.recommended) score += 25;
131
+ if (model.id.startsWith("qwen3.5:9b")) score += 30;
132
+ if (model.id.startsWith("gemma")) score += 20;
133
+ }
134
+
135
+ // Prefer leaving OS/IDE headroom unless the user asked for max quality.
136
+ if (isTightFit(model, totalRamGb) && needs.priority !== "quality") {
137
+ score -= 35;
138
+ }
139
+ return score;
140
+ }
141
+
142
+ /**
143
+ * Pick the best chat model for RAM + needs.
144
+ *
145
+ * @param totalRamGb - Machine RAM
146
+ * @param needs - Questionnaire (defaults to balanced/speed-friendly)
147
+ */
148
+ export function recommendChatForNeeds(
149
+ totalRamGb: number | null,
150
+ needs: AiNeeds = { useCase: "balanced", priority: "balanced", wantVision: false },
151
+ ): CatalogModel {
152
+ // Prefer comfortable pool; quality may still score a tight tier-fit higher.
153
+ const pool =
154
+ needs.priority === "quality"
155
+ ? fittingChatModels(totalRamGb)
156
+ : comfortableChatModels(totalRamGb);
157
+ let best = pool[0]!;
158
+ let bestScore = Number.NEGATIVE_INFINITY;
159
+ for (const m of pool) {
160
+ const s = scoreChatModel(m, needs, totalRamGb);
161
+ if (s > bestScore) {
162
+ best = m;
163
+ bestScore = s;
164
+ }
165
+ }
166
+ return best;
167
+ }
168
+
169
+ /**
170
+ * Vision model that fits; null if user skipped or none fit.
171
+ *
172
+ * @param totalRamGb - Machine RAM
173
+ * @param wantVision - From questionnaire
174
+ */
175
+ export function recommendVisionForNeeds(
176
+ totalRamGb: number | null,
177
+ wantVision: boolean,
178
+ ): CatalogModel | null {
179
+ if (!wantVision) return null;
180
+ const fits = VISION_MODELS.filter((m) => modelFitsOnMachine(m, totalRamGb));
181
+ return fits.find((m) => m.recommended) ?? fits[0] ?? null;
182
+ }
183
+
184
+ /**
185
+ * Human summary line for the recommendation panel.
186
+ *
187
+ * Separates **machine RAM** from **model tier** so "~24GB" is never read as
188
+ * download size.
189
+ *
190
+ * @param totalRamGb - Machine RAM
191
+ * @param chat - Chosen chat model
192
+ */
193
+ export function formatMachineSummary(totalRamGb: number | null, chat: CatalogModel): string {
194
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
195
+ return `⭐ Suggested: ${chat.label} (≈${chat.ramGb}GB-class machine · not download size)`;
196
+ }
197
+ const budget = usableRamGb(totalRamGb);
198
+ const tight = isTightFit(chat, totalRamGb) ? " · tight on this machine" : "";
199
+ return [
200
+ `Your machine: ~${totalRamGb}GB RAM (keep ~${OS_HEADROOM_GB}GB free for OS/IDE → ~${budget}GB model budget)`,
201
+ `⭐ For you: ${chat.label} — ≈${chat.ramGb}GB-class (Ollama pull is usually much smaller than RAM)${tight}`,
202
+ ].join("\n");
203
+ }
package/src/cli/ai.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `oke ai` — AI element CLI commands.
3
+ */
4
+
5
+ import { EXIT_OK, EXIT_USAGE } from "./exit.ts";
6
+ import { aiSetupHelp, parseAiSetupArgs, runAiSetup } from "./ai-setup/index.ts";
7
+
8
+ /**
9
+ * CLI entry for `oke ai …`.
10
+ *
11
+ * @param args - Args after `ai`
12
+ */
13
+ export async function aiCli(args: readonly string[]): Promise<number> {
14
+ const [sub, ...rest] = args;
15
+
16
+ if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
17
+ console.log(`oke ai — AI element helpers
18
+
19
+ Commands:
20
+ setup Configure AI driver + models (interactive)
21
+
22
+ ${aiSetupHelp()}`);
23
+ return sub ? EXIT_OK : EXIT_USAGE;
24
+ }
25
+
26
+ if (sub === "setup") {
27
+ try {
28
+ return await runAiSetup(parseAiSetupArgs(rest));
29
+ } catch (e) {
30
+ console.error(e instanceof Error ? e.message : e);
31
+ return 1;
32
+ }
33
+ }
34
+
35
+ console.error(`oke ai: unknown subcommand ${sub}`);
36
+ console.error("Run `oke ai --help` for usage.");
37
+ return EXIT_USAGE;
38
+ }
@@ -15,7 +15,12 @@ describe("isDomainSchemaWatchPath", () => {
15
15
  expect(isDomainSchemaWatchPath("drizzle.config.ts")).toBe(true);
16
16
  expect(isDomainSchemaWatchPath("flows/notes/schema.ts")).toBe(true);
17
17
  expect(isDomainSchemaWatchPath("src/schema.decl.ts")).toBe(true);
18
- expect(isDomainSchemaWatchPath("src/schema.generated.ts")).toBe(true);
18
+ });
19
+
20
+ test("ignores emit output (avoids push ↔ generated feedback loop)", () => {
21
+ expect(isDomainSchemaWatchPath("src/schema.generated.ts")).toBe(false);
22
+ expect(isDomainSchemaWatchPath("schema.generated.ts")).toBe(false);
23
+ expect(isDomainSchemaWatchPath("flows/notes/schema.generated.tsx")).toBe(false);
19
24
  });
20
25
 
21
26
  test("ignores unrelated files", () => {
@@ -10,20 +10,25 @@ export const DB_AUTO_PUSH_DEBOUNCE_MS = 300;
10
10
  /**
11
11
  * Whether a watched path should trigger `oke db push`.
12
12
  *
13
+ * Inputs only: declaration / drizzle config / app entry. Never the emit
14
+ * output (`schema.generated.ts`) — push rewrites that file every run, so
15
+ * watching it loops `oke db push` forever under `oke dev`.
16
+ *
13
17
  * @param filename - Relative path from the watcher (may be undefined)
14
18
  */
15
19
  export function isDomainSchemaWatchPath(filename: string | null | undefined): boolean {
16
20
  if (!filename) return false;
17
21
  const normalized = filename.replace(/\\/g, "/");
18
22
  const base = basename(normalized);
23
+ // Emit output — never a watch trigger (feedback loop with `oke db push`).
24
+ if (base === "schema.generated.ts" || base === "schema.generated.tsx") return false;
19
25
  if (base === "schema.ts" || base === "schema.tsx") return true;
20
26
  if (base === "schema.decl.ts" || base === "schema.decl.tsx") return true;
21
- if (base === "schema.generated.ts" || base === "schema.generated.tsx") return true;
22
27
  if (base === "drizzle.config.ts" || base === "drizzle.config.js") return true;
23
28
  // App entry / plugins — `.plug()` table contributions feed emit.
24
29
  if (base === "app.ts" || base === "app.tsx") return true;
25
- // Nested domain schemas commonly live under src/**/schema.ts
26
- if (/(^|\/)schema(\.decl|\.generated)?\.tsx?$/.test(normalized)) return true;
30
+ // Nested domain schemas commonly live under src/**/schema.ts (not .generated).
31
+ if (/(^|\/)schema(\.decl)?\.tsx?$/.test(normalized)) return true;
27
32
  return false;
28
33
  }
29
34
 
@@ -139,6 +139,65 @@ export const entries = sqliteTable("entries", {
139
139
  expect(pushes.length).toBeGreaterThan(before);
140
140
  });
141
141
 
142
+ test("schema.generated.ts change does not re-trigger dbPush", async () => {
143
+ const cwd = await scaffoldProject();
144
+ const pushes: string[] = [];
145
+ const watcher = controlledWatcher();
146
+ let resolveReady!: () => void;
147
+ const ready = new Promise<void>((r) => {
148
+ resolveReady = r;
149
+ });
150
+
151
+ const result = await runDev({
152
+ cwd,
153
+ local: true,
154
+ keepAlive: false,
155
+ dryRun: false,
156
+ appPort: 0,
157
+ consolePort: 0,
158
+ mcpPort: 0,
159
+ silentClaim: true,
160
+ stdinIsTTY: false,
161
+ startApp: async () => ({
162
+ port: 0,
163
+ url: new URL("http://127.0.0.1:9"),
164
+ stop() {},
165
+ }),
166
+ serveConsole: async () => ({
167
+ port: 0,
168
+ stop() {},
169
+ }),
170
+ serveMcp: async () => ({
171
+ port: 0,
172
+ url: new URL("http://127.0.0.1:9"),
173
+ stop() {},
174
+ }),
175
+ regenClient: async () => {},
176
+ dbPush: async () => {
177
+ pushes.push("push");
178
+ return 0;
179
+ },
180
+ onDbAutoPush: () => {
181
+ resolveReady();
182
+ },
183
+ watchFs: watcher.watchFs,
184
+ onReady: async (session) => {
185
+ sessions.push(session);
186
+ },
187
+ });
188
+
189
+ expect(result.code).toBe(0);
190
+ await Promise.race([ready, Bun.sleep(800)]);
191
+ const before = pushes.length;
192
+ expect(before).toBeGreaterThanOrEqual(1);
193
+
194
+ // Simulate emit writing schema.generated.ts after push — must not loop.
195
+ watcher.change("schema.generated.ts");
196
+ watcher.change("src/schema.generated.ts");
197
+ await Bun.sleep(500);
198
+ expect(pushes.length).toBe(before);
199
+ });
200
+
142
201
  test("--no-db-push / noDbPush never calls dbPush", async () => {
143
202
  const cwd = await scaffoldProject();
144
203
  const pushes: string[] = [];
@@ -645,6 +645,46 @@ describe("oke dev mode resolution", () => {
645
645
  expect(await readDevMode(dir)).toBeNull();
646
646
  });
647
647
 
648
+ test("session stop calls composeStop after a successful docker up", async () => {
649
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-compose-stop-"));
650
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
651
+ await writeDevMode(dir, "docker");
652
+ let upCalls = 0;
653
+ let stopCalls = 0;
654
+ let stoppedFiles: readonly string[] | undefined;
655
+ const result = await runDev({
656
+ cwd: dir,
657
+ stdinIsTTY: false,
658
+ images: { "store.sql": "postgres:16-alpine" },
659
+ credentials: {
660
+ "store.sql": {
661
+ user: "oke",
662
+ password: "test-password-not-in-yaml",
663
+ database: "oke",
664
+ },
665
+ },
666
+ composeUp: async (files) => {
667
+ upCalls++;
668
+ expect(files.length).toBeGreaterThan(0);
669
+ },
670
+ composeStop: (files) => {
671
+ stopCalls++;
672
+ stoppedFiles = files;
673
+ },
674
+ startApp: async () => ({ stop() {} }),
675
+ serveConsole: async () => ({ stop() {} }),
676
+ regenClient: async () => {},
677
+ write: () => {},
678
+ keepAlive: false,
679
+ });
680
+ expect(result.code).toBe(0);
681
+ expect(upCalls).toBe(1);
682
+ expect(result.session).toBeDefined();
683
+ result.session!.stop();
684
+ expect(stopCalls).toBe(1);
685
+ expect(stoppedFiles?.length).toBeGreaterThan(0);
686
+ });
687
+
648
688
  test("saved docker + compose failure exits loudly (no silent downgrade)", async () => {
649
689
  const dir = await mkdtemp(join(tmpdir(), "oke-dev-mode-fail-"));
650
690
  await Bun.write(join(dir, "src/app.ts"), "export {}\n");
package/src/cli/dev.ts CHANGED
@@ -101,7 +101,7 @@ export interface DevServeDocsMcpOptions {
101
101
  /** Live `oke dev` session — returned when {@link DevOptions.keepAlive} is false. */
102
102
  export interface DevSession {
103
103
  readonly plan: DevPlan;
104
- /** Stop app · Console · MCP · watcher. */
104
+ /** Stop app · Console · MCP · watcher · docker compose (when this session started it). */
105
105
  readonly stop: () => void;
106
106
  readonly appPort: number;
107
107
  readonly consolePort: number;
@@ -185,6 +185,19 @@ export interface DevOptions {
185
185
  * @param cwd - Compose directory (`docker/`)
186
186
  */
187
187
  readonly composeUp?: (composeFiles: readonly string[], cwd: string) => Promise<void>;
188
+ /**
189
+ * Stop compose containers on session exit (injectable). Keeps volumes.
190
+ * Default: `docker compose … stop` with the same `-f` / cwd / env as up.
191
+ *
192
+ * @param composeFiles - `-f` list used at up
193
+ * @param cwd - Compose directory (`docker/`)
194
+ * @param env - Stack env merged into the process env for compose
195
+ */
196
+ readonly composeStop?: (
197
+ composeFiles: readonly string[],
198
+ cwd: string,
199
+ env: Readonly<Record<string, string>>,
200
+ ) => void;
188
201
  /**
189
202
  * Regenerate client types (injectable).
190
203
  *
@@ -330,6 +343,12 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
330
343
  let stackRoles: string[] | null = null;
331
344
  let composeFiles: string[] | null = null;
332
345
  let stackEnv: Record<string, string> | null = null;
346
+ /** Set only after a successful `compose up` — torn down on SIGINT / stop. */
347
+ let dockerStarted: {
348
+ files: readonly string[];
349
+ cwd: string;
350
+ env: Record<string, string>;
351
+ } | null = null;
333
352
  let stackSqlDriver = "postgres";
334
353
  let stackKvDriver = "redis";
335
354
  let loadedConfig: Awaited<ReturnType<typeof loadOkeConfig>>["config"] | null = null;
@@ -437,6 +456,12 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
437
456
  });
438
457
  // Compose files live under docker/; cwd for compose is that directory.
439
458
  await up(composeFiles, dockerOut);
459
+ // Remember for SIGINT / terminal close — `stop` keeps volumes (not `down -v`).
460
+ dockerStarted = {
461
+ files: composeFiles,
462
+ cwd: dockerOut,
463
+ env: { ...stackEnv! },
464
+ };
440
465
 
441
466
  // Real OpenBao vault: init once / unseal every start, then expose the
442
467
  // least-privilege app token to the app + Console (never root).
@@ -450,6 +475,7 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
450
475
  names: vaultNames,
451
476
  });
452
477
  Object.assign(stackEnv!, openbaoStackEnv(boot));
478
+ Object.assign(dockerStarted.env, openbaoStackEnv(boot));
453
479
  const envPath = resolve(dockerOut, ".env.docker");
454
480
  const current = await Bun.file(envPath).text();
455
481
  const tokenLine = `OKE_VAULT_TOKEN=${boot.appToken}`;
@@ -779,8 +805,34 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
779
805
  consoleServer.stop();
780
806
  mcpServer?.stop();
781
807
  docsMcpServer?.stop();
808
+ if (dockerStarted) {
809
+ const started = dockerStarted;
810
+ dockerStarted = null;
811
+ const stopCompose =
812
+ options.composeStop ??
813
+ ((files, dir, env) => {
814
+ const args = ["compose", ...files.flatMap((f) => ["-f", f]), "stop"];
815
+ const result = Bun.spawnSync(["docker", ...args], {
816
+ cwd: dir,
817
+ stdout: "inherit",
818
+ stderr: "inherit",
819
+ env: { ...process.env, ...env },
820
+ });
821
+ if (result.exitCode !== 0) {
822
+ console.error(
823
+ `oke dev: docker compose stop exited ${result.exitCode ?? "unknown"} (containers may still be running)`,
824
+ );
825
+ }
826
+ });
827
+ try {
828
+ stopCompose(started.files, started.cwd, started.env);
829
+ } catch (err) {
830
+ console.error(err instanceof Error ? err.message : String(err));
831
+ }
832
+ }
782
833
  process.off("SIGINT", onSignal);
783
834
  process.off("SIGTERM", onSignal);
835
+ process.off("SIGHUP", onSignal);
784
836
  };
785
837
 
786
838
  const onSignal = () => {
@@ -789,6 +841,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
789
841
  };
790
842
  process.on("SIGINT", onSignal);
791
843
  process.on("SIGTERM", onSignal);
844
+ // Terminal close often delivers SIGHUP rather than SIGINT.
845
+ process.on("SIGHUP", onSignal);
792
846
 
793
847
  const session: DevSession = {
794
848
  plan: {