okengine 0.9.0 → 0.10.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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -1,76 +1,99 @@
1
1
  /**
2
- * Smart Ollama recommendation scoring + RAM tier rules.
2
+ * Smart Ollama recommendation RAM tiers (no needs quiz).
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { recommendChatModel } from "./catalog.ts";
6
+ import {
7
+ LLAMA_CPP_CHAT_MODELS,
8
+ llamaCppModelsForTier,
9
+ modelsForTier,
10
+ recommendChatModel,
11
+ recommendForTier,
12
+ recommendLlamaCppForTier,
13
+ } from "./catalog.ts";
7
14
  import {
8
15
  fittingChatModels,
9
- formatMachineSummary,
16
+ formatLlamaCppBanner,
17
+ formatModelRow,
18
+ formatModelTableHeader,
19
+ formatOllamaBanner,
10
20
  isTightFit,
11
21
  modelFitsComfortably,
12
22
  modelFitsOnMachine,
13
23
  recommendChatForNeeds,
14
- type AiNeeds,
24
+ suggestTierForRam,
25
+ usableRamGb,
15
26
  } from "./recommend.ts";
16
27
 
17
- const balanced: AiNeeds = {
18
- useCase: "balanced",
19
- priority: "balanced",
20
- wantVision: false,
21
- };
22
-
23
28
  describe("modelFitsOnMachine", () => {
24
29
  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
- );
30
+ expect(
31
+ modelFitsOnMachine(
32
+ {
33
+ id: "x",
34
+ label: "x",
35
+ hint: "",
36
+ role: "chat",
37
+ ramGb: 16,
38
+ tier: "balanced",
39
+ modalities: ["text"],
40
+ },
41
+ 24,
42
+ ),
43
+ ).toBe(true);
44
+ expect(
45
+ modelFitsOnMachine(
46
+ {
47
+ id: "x",
48
+ label: "x",
49
+ hint: "",
50
+ role: "chat",
51
+ ramGb: 32,
52
+ tier: "smart",
53
+ modalities: ["text"],
54
+ },
55
+ 24,
56
+ ),
57
+ ).toBe(false);
31
58
  });
32
59
  });
33
60
 
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);
61
+ describe("tiers", () => {
62
+ test("each tier exposes up to 10 models", () => {
63
+ for (const tier of ["ultra-fast", "fast", "balanced", "smart"] as const) {
64
+ expect(modelsForTier(tier).length).toBeGreaterThan(0);
65
+ expect(modelsForTier(tier).length).toBeLessThanOrEqual(10);
66
+ }
39
67
  });
40
68
 
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");
69
+ test("suggestTierForRam maps fit budget", () => {
70
+ expect(suggestTierForRam(4)).toBe("ultra-fast");
71
+ expect(suggestTierForRam(8)).toBe("fast");
72
+ expect(suggestTierForRam(16)).toBe("balanced");
73
+ expect(suggestTierForRam(32)).toBe("smart");
45
74
  });
46
75
 
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);
76
+ test("recommendForTier returns a model in that tier", () => {
77
+ const pick = recommendForTier("fast", 16);
78
+ expect(pick.tier).toBe("fast");
79
+ });
80
+ });
81
+
82
+ describe("recommendChatForNeeds", () => {
83
+ test("8GB → ultra-fast or fast entry", () => {
84
+ const pick = recommendChatForNeeds(8);
85
+ expect(pick.ramGb).toBeLessThanOrEqual(8);
56
86
  });
57
87
 
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);
88
+ test("16GB never recommends 32GB-class", () => {
89
+ const pick = recommendChatForNeeds(16);
90
+ expect(pick.ramGb).toBeLessThanOrEqual(16);
91
+ expect(pick.id).not.toBe("qwen3.5:27b");
65
92
  });
66
93
 
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");
94
+ test("24GB leaves headroom", () => {
95
+ const pick = recommendChatForNeeds(24);
96
+ expect(pick.ramGb).toBeLessThanOrEqual(usableRamGb(24));
74
97
  });
75
98
  });
76
99
 
@@ -92,9 +115,86 @@ describe("comfortable vs tight", () => {
92
115
 
93
116
  describe("recommendChatModel", () => {
94
117
  test("respects tiers with headroom preference", () => {
95
- expect(recommendChatModel(8).id).toBe("gemma4:e4b");
118
+ expect(recommendChatModel(8).ramGb).toBeLessThanOrEqual(8);
96
119
  expect(recommendChatModel(16).ramGb).toBeLessThanOrEqual(16);
97
120
  expect(recommendChatModel(24).ramGb).toBeLessThanOrEqual(16);
98
- expect(recommendChatModel(null).recommended).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("formatOllamaBanner", () => {
125
+ test("shows OS · CPU · RAM · Fit RAM · detected — no comfortable list", () => {
126
+ const text = formatOllamaBanner({ osName: "macOS", cpuCount: 10, ramGb: 24 }, ["gemma4:e4b"]);
127
+ expect(text).toContain("OS macOS · CPU 10 · RAM ~24GB RAM");
128
+ expect(text).toContain("Fit RAM ~20GB RAM");
129
+ expect(text).toContain("Detected local models");
130
+ expect(text).toContain("gemma4:e4b");
131
+ expect(text).not.toContain("Fits comfortably");
132
+ expect(text).toContain("Tiers · Ultra Fast");
133
+ });
134
+ });
135
+
136
+ describe("llama.cpp catalog", () => {
137
+ test("each tier has ≥10 curated Docker Hub ai/ models (UI shows up to 20)", () => {
138
+ for (const tier of ["ultra-fast", "fast", "balanced", "smart"] as const) {
139
+ const all = LLAMA_CPP_CHAT_MODELS.filter((m) => m.tier === tier);
140
+ expect(all.length).toBeGreaterThanOrEqual(10);
141
+ const list = llamaCppModelsForTier(tier);
142
+ expect(list.length).toBeGreaterThanOrEqual(10);
143
+ expect(list.length).toBeLessThanOrEqual(20);
144
+ expect(list.length).toBe(Math.min(all.length, 20));
145
+ expect(list.every((m) => !m.id.startsWith("ai/"))).toBe(true);
146
+ for (let i = 1; i < list.length; i++) {
147
+ expect(list[i]!.ramGb).toBeGreaterThanOrEqual(list[i - 1]!.ramGb);
148
+ }
149
+ }
150
+ });
151
+
152
+ test("recommendLlamaCppForTier returns a model in that tier", () => {
153
+ expect(recommendLlamaCppForTier("ultra-fast", 8).id).toBe("smollm2");
154
+ expect(recommendLlamaCppForTier("fast", 16).tier).toBe("fast");
155
+ });
156
+
157
+ test("visible list surfaces Gemma 4 and Qwen", () => {
158
+ const ids = (tier: "fast" | "balanced" | "smart") =>
159
+ llamaCppModelsForTier(tier).map((m) => m.id);
160
+ expect(ids("fast").some((id) => id.startsWith("gemma4:"))).toBe(true);
161
+ expect(ids("fast").some((id) => id.startsWith("qwen"))).toBe(true);
162
+ expect(ids("balanced")).toContain("gemma4:e4b");
163
+ expect(ids("balanced").some((id) => id.startsWith("qwen3"))).toBe(true);
164
+ expect(ids("smart")).toContain("gemma4:31b");
165
+ expect(ids("smart").some((id) => id.startsWith("qwen"))).toBe(true);
166
+ });
167
+
168
+ test("formatLlamaCppBanner shows Docker Hub ai/ source — not Ollama detect", () => {
169
+ const text = formatLlamaCppBanner({ osName: "macOS", cpuCount: 10, ramGb: 24 });
170
+ expect(text).toContain("OS macOS · CPU 10 · RAM ~24GB RAM");
171
+ expect(text).toContain("Fit RAM ~20GB RAM");
172
+ expect(text).toContain("Docker Hub ai/");
173
+ expect(text).not.toContain("Detected local models");
174
+ expect(text).toContain("Tiers · Ultra Fast");
175
+ });
176
+ });
177
+
178
+ describe("formatModelRow", () => {
179
+ test("aligned Model | RAM | Caps columns", () => {
180
+ const row = formatModelRow({
181
+ id: "x",
182
+ label: "Gemma 4 4B",
183
+ hint: "",
184
+ role: "chat",
185
+ ramGb: 8,
186
+ tier: "fast",
187
+ modalities: ["text", "code"],
188
+ });
189
+ const header = formatModelTableHeader();
190
+ expect(header.startsWith("Model")).toBe(true);
191
+ expect(header).toContain("RAM");
192
+ expect(header).toContain("Caps");
193
+ // Caps column starts at the same offset as the header.
194
+ expect(row.indexOf("text")).toBe(header.indexOf("Caps"));
195
+ expect(row).toContain("Gemma 4 4B");
196
+ expect(row).toContain("≈8GB");
197
+ expect(row).toContain("text · code");
198
+ expect(row).not.toContain(" · ≈");
99
199
  });
100
200
  });
@@ -1,32 +1,21 @@
1
1
  /**
2
- * Smart local-model recommendations machine-tier fit + use-case scoring.
2
+ * Machine fit helpers for local Ollama model picks.
3
3
  *
4
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.
5
+ * download size). Prefer picks that leave ~4GB for OS / IDE / browser.
10
6
  */
11
7
 
12
- import { CHAT_MODELS, VISION_MODELS, type CatalogModel } from "./catalog.ts";
8
+ import {
9
+ CHAT_MODELS,
10
+ MODEL_TIERS,
11
+ type CatalogModel,
12
+ type ModelTier,
13
+ recommendForTier,
14
+ } from "./catalog.ts";
13
15
 
14
16
  /** Reserved for OS + IDE + browser while the model runs. */
15
17
  export const OS_HEADROOM_GB = 4;
16
18
 
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
19
  /**
31
20
  * RAM left after reserving OS/IDE headroom.
32
21
  *
@@ -60,7 +49,6 @@ export function modelFitsComfortably(model: CatalogModel, totalRamGb: number | n
60
49
  return Boolean(model.recommended);
61
50
  }
62
51
  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
52
  if (model.id === smallest.id) return model.ramGb <= totalRamGb;
65
53
  return model.ramGb + OS_HEADROOM_GB <= totalRamGb;
66
54
  }
@@ -101,103 +89,132 @@ export function comfortableChatModels(totalRamGb: number | null): readonly Catal
101
89
  }
102
90
 
103
91
  /**
104
- * Score a fitting chat model for the user's needs (higher = better).
92
+ * Suggest a tier for the machine's fit RAM budget.
105
93
  *
106
- * @param model - Candidate
107
- * @param needs - Questionnaire answers
108
- * @param totalRamGb - Machine RAM (tight-fit penalty)
94
+ * @param totalRamGb - Machine RAM
109
95
  */
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;
96
+ export function suggestTierForRam(totalRamGb: number | null): ModelTier {
97
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) return "fast";
98
+ const budget = usableRamGb(totalRamGb);
99
+ if (budget >= 24) return "smart";
100
+ if (budget >= 8) return "balanced";
101
+ if (budget >= 4) return "fast";
102
+ return "ultra-fast";
140
103
  }
141
104
 
142
105
  /**
143
- * Pick the best chat model for RAM + needs.
106
+ * Recommend a chat model for host RAM (balanced default — no quiz).
144
107
  *
145
108
  * @param totalRamGb - Machine RAM
146
- * @param needs - Questionnaire (defaults to balanced/speed-friendly)
147
109
  */
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;
110
+ export function recommendChatForNeeds(totalRamGb: number | null): CatalogModel {
111
+ return recommendForTier(suggestTierForRam(totalRamGb), totalRamGb);
167
112
  }
168
113
 
169
114
  /**
170
- * Vision model that fits; null if user skipped or none fit.
115
+ * Shared hardware lines for local AI banners.
171
116
  *
172
- * @param totalRamGb - Machine RAM
173
- * @param wantVision - From questionnaire
117
+ * @param machine - Detected hardware
174
118
  */
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;
119
+ function formatMachineBannerLines(machine: {
120
+ readonly osName: string;
121
+ readonly cpuCount: number | null;
122
+ readonly ramGb: number | null;
123
+ }): readonly string[] {
124
+ const ram =
125
+ machine.ramGb !== null && Number.isFinite(machine.ramGb)
126
+ ? `~${machine.ramGb}GB RAM`
127
+ : "RAM unknown";
128
+ const cpu =
129
+ machine.cpuCount !== null && Number.isFinite(machine.cpuCount) ? String(machine.cpuCount) : "?";
130
+ const fit =
131
+ machine.ramGb !== null && Number.isFinite(machine.ramGb)
132
+ ? `~${usableRamGb(machine.ramGb)}GB RAM`
133
+ : "unknown";
134
+ const sep = " · ";
135
+ return [[`OS ${machine.osName}`, `CPU ${cpu}`, `RAM ${ram}`].join(sep), `Fit RAM ${fit}`];
182
136
  }
183
137
 
184
138
  /**
185
- * Human summary line for the recommendation panel.
139
+ * Ollama banner lines OS · CPU · RAM · fit · detected · tiers.
186
140
  *
187
- * Separates **machine RAM** from **model tier** so "~24GB" is never read as
188
- * download size.
141
+ * @param machine - Detected hardware
142
+ * @param detectedIds - Installed model ids
143
+ */
144
+ export function formatOllamaBanner(
145
+ machine: {
146
+ readonly osName: string;
147
+ readonly cpuCount: number | null;
148
+ readonly ramGb: number | null;
149
+ },
150
+ detectedIds: readonly string[],
151
+ ): string {
152
+ const sep = " · ";
153
+ const lines = [
154
+ ...formatMachineBannerLines(machine),
155
+ "",
156
+ "Detected local models",
157
+ ...(detectedIds.length > 0 ? detectedIds.map((id) => id) : ["(none detected)"]),
158
+ "",
159
+ `Tiers${sep}${MODEL_TIERS.map((t) => t.label).join(sep)}`,
160
+ ];
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /**
165
+ * llama.cpp banner — OS · CPU · RAM · fit · Docker Hub `ai/` source · tiers.
189
166
  *
190
- * @param totalRamGb - Machine RAM
191
- * @param chat - Chosen chat model
167
+ * @param machine - Detected hardware
192
168
  */
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");
169
+ export function formatLlamaCppBanner(machine: {
170
+ readonly osName: string;
171
+ readonly cpuCount: number | null;
172
+ readonly ramGb: number | null;
173
+ }): string {
174
+ const sep = " · ";
175
+ const lines = [
176
+ ...formatMachineBannerLines(machine),
177
+ "",
178
+ "Model source",
179
+ "Docker Hub ai/ (curated) — pulled on first container start",
180
+ "",
181
+ `Tiers${sep}${MODEL_TIERS.map((t) => t.label).join(sep)}`,
182
+ ];
183
+ return lines.join("\n");
184
+ }
185
+
186
+ /** Model name column width (monospace CLI table). */
187
+ const MODEL_COL_NAME = 26;
188
+ /** RAM column width, right-aligned (`≈32GB`). */
189
+ const MODEL_COL_RAM = 7;
190
+
191
+ /**
192
+ * Clip or pad a cell for aligned CLI columns.
193
+ *
194
+ * @param value - Cell text
195
+ * @param width - Fixed width
196
+ */
197
+ function clipPad(value: string, width: number): string {
198
+ if (value.length === width) return value;
199
+ if (value.length < width) return value.padEnd(width);
200
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
201
+ }
202
+
203
+ /**
204
+ * Column header for the model pick list (aligns with {@link formatModelRow}).
205
+ */
206
+ export function formatModelTableHeader(): string {
207
+ return `${clipPad("Model", MODEL_COL_NAME)} ${"RAM".padStart(MODEL_COL_RAM)} Caps`;
208
+ }
209
+
210
+ /**
211
+ * One model row as aligned columns: Model | RAM | Caps.
212
+ *
213
+ * @param model - Catalog entry
214
+ */
215
+ export function formatModelRow(model: CatalogModel): string {
216
+ const name = clipPad(model.label, MODEL_COL_NAME);
217
+ const ram = `≈${model.ramGb}GB`.padStart(MODEL_COL_RAM);
218
+ const caps = model.modalities.join(" · ");
219
+ return `${name} ${ram} ${caps}`;
203
220
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for the one-shot seed prompt after `oke db push`.
3
+ */
4
+
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { describe, expect, test } from "bun:test";
9
+ import { maybeAskSeed, SEEDED_MARKER } from "./ask-seed.ts";
10
+
11
+ describe("maybeAskSeed", () => {
12
+ test("skips when non-TTY", async () => {
13
+ const dir = mkdtempSync(join(tmpdir(), "ask-seed-"));
14
+ try {
15
+ mkdirSync(join(dir, "src", "db", "seed"), { recursive: true });
16
+ writeFileSync(join(dir, "src", "db", "seed", "index.ts"), "export default {};\n");
17
+ let seeded = false;
18
+ await maybeAskSeed({
19
+ cwd: dir,
20
+ env: "local",
21
+ stdinIsTTY: false,
22
+ confirmFn: async () => true,
23
+ seedFn: async () => {
24
+ seeded = true;
25
+ return 0;
26
+ },
27
+ });
28
+ expect(seeded).toBe(false);
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test("asks once then writes marker", async () => {
35
+ const dir = mkdtempSync(join(tmpdir(), "ask-seed-"));
36
+ try {
37
+ mkdirSync(join(dir, "src", "db", "seed"), { recursive: true });
38
+ writeFileSync(join(dir, "src", "db", "seed", "index.ts"), "export default {};\n");
39
+ let calls = 0;
40
+ await maybeAskSeed({
41
+ cwd: dir,
42
+ env: "local",
43
+ stdinIsTTY: true,
44
+ confirmFn: async () => true,
45
+ seedFn: async () => {
46
+ calls += 1;
47
+ return 0;
48
+ },
49
+ });
50
+ expect(calls).toBe(1);
51
+ expect(existsSync(join(dir, SEEDED_MARKER))).toBe(true);
52
+
53
+ await maybeAskSeed({
54
+ cwd: dir,
55
+ env: "local",
56
+ stdinIsTTY: true,
57
+ confirmFn: async () => true,
58
+ seedFn: async () => {
59
+ calls += 1;
60
+ return 0;
61
+ },
62
+ });
63
+ expect(calls).toBe(1);
64
+ } finally {
65
+ rmSync(dir, { recursive: true, force: true });
66
+ }
67
+ });
68
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One-time interactive prompt: run `oke db seed` after first successful push.
3
+ */
4
+
5
+ import { confirm, isCancel } from "@clack/prompts";
6
+ import { resolve } from "node:path";
7
+ import type { ConfigEnv } from "../config/index.ts";
8
+ import { formatCliChrome } from "../term.ts";
9
+ import { resolveSeedModulePath, runSeed } from "./db-seed.ts";
10
+
11
+ /** Marker written after a successful prompted seed (skip re-ask). */
12
+ export const SEEDED_MARKER = ".oke/seeded";
13
+
14
+ /** Options for {@link maybeAskSeed}. */
15
+ export interface AskSeedOptions {
16
+ readonly cwd: string;
17
+ readonly env: ConfigEnv;
18
+ readonly write?: (text: string) => void;
19
+ readonly stdinIsTTY?: boolean;
20
+ /** Skip prompt (tests / `--yes` / non-interactive). */
21
+ readonly skip?: boolean;
22
+ /** Injectable confirm (tests). */
23
+ readonly confirmFn?: () => Promise<boolean>;
24
+ /** Injectable seed runner (tests). */
25
+ readonly seedFn?: (cwd: string, env: ConfigEnv) => Promise<number>;
26
+ }
27
+
28
+ /**
29
+ * After a successful `oke db push`, ask once whether to seed sample data.
30
+ * Skips when non-TTY, already seeded, or no seed module.
31
+ *
32
+ * @param options - Project / env / injectables
33
+ */
34
+ export async function maybeAskSeed(options: AskSeedOptions): Promise<void> {
35
+ if (options.skip) return;
36
+ const tty = options.stdinIsTTY ?? Boolean(process.stdin.isTTY);
37
+ if (!tty) return;
38
+
39
+ const cwd = options.cwd;
40
+ const marker = resolve(cwd, SEEDED_MARKER);
41
+ if (await Bun.file(marker).exists()) return;
42
+
43
+ const seedPath = await resolveSeedModulePath(cwd);
44
+ if (!(await Bun.file(seedPath).exists())) return;
45
+
46
+ const write = options.write ?? ((t) => process.stdout.write(t));
47
+ const chromeWrite = (t: string) => write(formatCliChrome(t));
48
+ const ok =
49
+ options.confirmFn !== undefined
50
+ ? await options.confirmFn()
51
+ : await (async () => {
52
+ const value = await confirm({
53
+ message: "Run oke db seed (essential + sample data)?",
54
+ initialValue: true,
55
+ });
56
+ if (isCancel(value)) return false;
57
+ return Boolean(value);
58
+ })();
59
+
60
+ if (!ok) {
61
+ chromeWrite("oke db seed: skipped (run `oke db seed` later)\n");
62
+ return;
63
+ }
64
+
65
+ const seed =
66
+ options.seedFn ??
67
+ ((projectCwd, env) =>
68
+ runSeed({
69
+ cwd: projectCwd,
70
+ env,
71
+ write: chromeWrite,
72
+ force: true,
73
+ stdinIsTTY: false,
74
+ }));
75
+ const code = await seed(cwd, options.env);
76
+ if (code === 0) {
77
+ await Bun.write(marker, `${new Date().toISOString()}\n`);
78
+ }
79
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Seed display helpers — password redaction must stay human-readable.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { redactConnectionTarget } from "./db-seed.ts";
7
+
8
+ describe("redactConnectionTarget", () => {
9
+ test("replaces password with bullets without percent-encoding", () => {
10
+ const out = redactConnectionTarget("postgres://oke:s3cret@127.0.0.1:5432/oke");
11
+ expect(out).toBe("postgres://oke:••••@127.0.0.1:5432/oke");
12
+ expect(out).not.toContain("%E2%80%A2");
13
+ expect(out).not.toContain("s3cret");
14
+ });
15
+
16
+ test("keeps URLs without passwords", () => {
17
+ expect(redactConnectionTarget("postgres://oke@127.0.0.1:5432/oke")).toContain(
18
+ "postgres://oke@",
19
+ );
20
+ });
21
+
22
+ test("passes through opaque non-URL strings", () => {
23
+ expect(redactConnectionTarget("not a url at all")).toBe("not a url at all");
24
+ });
25
+ });