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,144 @@
1
+ /**
2
+ * Unit tests for `oke ai setup` catalog, detect, and apply.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { applyAiSetup, renderAiTs, upsertAiDrivers, upsertEnv } from "./apply.ts";
10
+ import { cloudChatModels, recommendChatModel, recommendCloudChat } from "./catalog.ts";
11
+ import { isInstalled, parseOllamaList } from "./detect-ollama.ts";
12
+ import { parseAiSetupArgs } from "./index.ts";
13
+
14
+ describe("catalog", () => {
15
+ test("recommendChatModel respects RAM tiers", () => {
16
+ expect(recommendChatModel(8).id).toBe("gemma4:e4b");
17
+ expect(recommendChatModel(16).ramGb).toBeLessThanOrEqual(16);
18
+ expect(recommendChatModel(null).recommended).toBe(true);
19
+ });
20
+
21
+ test("cloud providers ship short curated chat lists + recommended", () => {
22
+ expect(cloudChatModels("openai").length).toBeGreaterThan(0);
23
+ expect(cloudChatModels("anthropic").some((m) => m.recommended)).toBe(true);
24
+ expect(recommendCloudChat("openai")).toBe("gpt-4o-mini");
25
+ expect(recommendCloudChat("anthropic")).toContain("claude");
26
+ });
27
+ });
28
+
29
+ describe("detect-ollama", () => {
30
+ test("parseOllamaList skips header", () => {
31
+ const out = parseOllamaList(`NAME ID SIZE
32
+ qwen3.5:9b abc 5.0 GB
33
+ nomic-embed-text def 274 MB
34
+ `);
35
+ expect(out).toEqual(["qwen3.5:9b", "nomic-embed-text"]);
36
+ });
37
+
38
+ test("isInstalled matches mlx suffixes", () => {
39
+ expect(isInstalled("qwen3.5:9b", ["qwen3.5:9b-mlx"])).toBe(true);
40
+ expect(isInstalled("gemma4:e4b", ["qwen3.5:9b"])).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("apply", () => {
45
+ test("upsertAiDrivers inserts after channel (sibling, not nested)", () => {
46
+ const src = `export default defineConfig({
47
+ drivers: {
48
+ channel: {
49
+ email: {
50
+ local: "console",
51
+ docker: "smtp",
52
+ test: "console",
53
+ prod: "smtp",
54
+ },
55
+ },
56
+ },
57
+ images: {},
58
+ });
59
+ `;
60
+ const next = upsertAiDrivers(src, "ollama");
61
+ expect(next).toMatch(/^ {4}ai:\s*\{/m);
62
+ expect(next).toContain('local: "ollama"');
63
+ const channel = next.match(/^ {4}channel:\s*\{[\s\S]*?\n {4}\},?\n/m)?.[0] ?? "";
64
+ expect(channel).toContain("email:");
65
+ expect(channel).not.toContain("ai:");
66
+ });
67
+
68
+ test("upsertEnv uncomment / set", () => {
69
+ const env = `# OKE_AI_DRIVER=mock\nOKE_AI_URL=http://x\n`;
70
+ expect(upsertEnv(env, "OKE_AI_DRIVER", "ollama")).toContain("OKE_AI_DRIVER=ollama");
71
+ });
72
+
73
+ test("renderAiTs includes vision + embed", () => {
74
+ const ts = renderAiTs({
75
+ driver: "ollama",
76
+ chatModel: "gemma4:e4b",
77
+ visionModel: "qwen3-vl:4b",
78
+ embedModel: "nomic-embed-text",
79
+ });
80
+ expect(ts).toContain('ai.model("smart"');
81
+ expect(ts).toContain('ai.model("vision"');
82
+ expect(ts).toContain("docsEmbed");
83
+ });
84
+
85
+ test("applyAiSetup writes config, env, ai.ts", () => {
86
+ const dir = mkdtempSync(join(tmpdir(), "oke-ai-setup-"));
87
+ try {
88
+ writeFileSync(
89
+ join(dir, "oke.config.ts"),
90
+ `import { defineConfig } from "okengine/config";
91
+ export default defineConfig({
92
+ drivers: {
93
+ channel: {
94
+ email: {
95
+ local: "console",
96
+ docker: "smtp",
97
+ test: "console",
98
+ prod: "smtp",
99
+ },
100
+ },
101
+ },
102
+ images: {
103
+ "store.sql": "postgres:18-alpine",
104
+ },
105
+ });
106
+ `,
107
+ "utf8",
108
+ );
109
+ mkdirSync(join(dir, "src"), { recursive: true });
110
+ writeFileSync(join(dir, "src", "app.ts"), `export const app = {};\n`, "utf8");
111
+
112
+ applyAiSetup(dir, {
113
+ driver: "ollama",
114
+ baseUrl: "http://127.0.0.1:11434",
115
+ chatModel: "gemma4:e4b",
116
+ visionModel: "qwen3-vl:4b",
117
+ embedModel: "nomic-embed-text",
118
+ });
119
+
120
+ const config = readFileSync(join(dir, "oke.config.ts"), "utf8");
121
+ expect(config).toContain('local: "ollama"');
122
+ expect(config).toContain('ai: "ollama/ollama:latest"');
123
+ const env = readFileSync(join(dir, ".env.local"), "utf8");
124
+ expect(env).toContain("OKE_AI_DRIVER=ollama");
125
+ expect(env).toContain("OKE_AI_MODEL=gemma4:e4b");
126
+ const aiTs = readFileSync(join(dir, "src", "ai.ts"), "utf8");
127
+ expect(aiTs).toContain("smart");
128
+ const app = readFileSync(join(dir, "src", "app.ts"), "utf8");
129
+ expect(app).toContain('import "./ai"');
130
+ } finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ });
135
+
136
+ describe("parseAiSetupArgs", () => {
137
+ test("parses provider and models", () => {
138
+ const a = parseAiSetupArgs(["--provider", "ollama", "--chat=qwen3.5:9b", "--no-pull", "--yes"]);
139
+ expect(a.provider).toBe("ollama");
140
+ expect(a.chat).toBe("qwen3.5:9b");
141
+ expect(a.pull).toBe(false);
142
+ expect(a.yes).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Write AI driver config, env, and `src/ai.ts` for `oke ai setup`.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ /** Choices applied to the project. */
9
+ export type AiSetupApplyInput = {
10
+ readonly driver: "ollama" | "anthropic" | "openai-compatible" | "mock";
11
+ readonly baseUrl?: string;
12
+ readonly chatModel?: string;
13
+ readonly visionModel?: string | null;
14
+ readonly embedModel?: string | null;
15
+ readonly apiKeyEnv?: string;
16
+ };
17
+
18
+ /** Options for {@link applyAiSetup}. */
19
+ export type AiSetupApplyOptions = {
20
+ /**
21
+ * When false, leave `drivers.ai` / `images.ai` alone (create-oke already
22
+ * wrote per-env pins). Default true for standalone `oke ai setup`.
23
+ */
24
+ readonly updateDrivers?: boolean;
25
+ };
26
+
27
+ /**
28
+ * Apply AI setup to a project root.
29
+ *
30
+ * @param cwd - Project root
31
+ * @param input - Selected provider / models
32
+ * @param options - Pin-preserving knobs for create-oke
33
+ */
34
+ export function applyAiSetup(
35
+ cwd: string,
36
+ input: AiSetupApplyInput,
37
+ options: AiSetupApplyOptions = {},
38
+ ): {
39
+ readonly configPath: string;
40
+ readonly envPath: string;
41
+ readonly aiTsPath: string;
42
+ } {
43
+ const updateDrivers = options.updateDrivers !== false;
44
+ const configPath = join(cwd, "oke.config.ts");
45
+ if (!existsSync(configPath)) {
46
+ throw new Error("oke ai setup: no oke.config.ts in the current directory");
47
+ }
48
+
49
+ if (updateDrivers) {
50
+ let config = readFileSync(configPath, "utf8");
51
+ config = upsertAiDrivers(config, input.driver);
52
+ if (input.driver === "ollama") {
53
+ config = upsertImage(config, "ai", "ollama/ollama:latest");
54
+ }
55
+ writeFileSync(configPath, config, "utf8");
56
+ }
57
+
58
+ const envPath = join(cwd, ".env.local");
59
+ const envExample = join(cwd, ".env.example");
60
+ let env = existsSync(envPath)
61
+ ? readFileSync(envPath, "utf8")
62
+ : existsSync(envExample)
63
+ ? readFileSync(envExample, "utf8")
64
+ : "";
65
+ env = upsertEnv(env, "OKE_AI_DRIVER", input.driver);
66
+ if (input.baseUrl) env = upsertEnv(env, "OKE_AI_URL", input.baseUrl);
67
+ if (input.chatModel) env = upsertEnv(env, "OKE_AI_MODEL", input.chatModel);
68
+ if (input.visionModel) env = upsertEnv(env, "OKE_AI_VISION_MODEL", input.visionModel);
69
+ if (input.embedModel) env = upsertEnv(env, "OKE_AI_EMBED_MODEL", input.embedModel);
70
+ if (input.apiKeyEnv) {
71
+ // Ensure the key name is present (value left blank for the user)
72
+ if (!new RegExp(`^${input.apiKeyEnv}=`, "m").test(env)) {
73
+ env = `${env.trimEnd()}\n${input.apiKeyEnv}=\n`;
74
+ }
75
+ }
76
+ writeFileSync(envPath, env.endsWith("\n") ? env : `${env}\n`, "utf8");
77
+
78
+ const aiTsPath = join(cwd, "src", "ai.ts");
79
+ mkdirSync(dirname(aiTsPath), { recursive: true });
80
+ writeFileSync(aiTsPath, renderAiTs(input), "utf8");
81
+ ensureAiImported(cwd);
82
+
83
+ return { configPath, envPath, aiTsPath };
84
+ }
85
+
86
+ /**
87
+ * @param source - oke.config.ts
88
+ * @param driver - AI driver id
89
+ */
90
+ export function upsertAiDrivers(source: string, driver: string): string {
91
+ const block = `{
92
+ local: "${driver}",
93
+ docker: "${driver === "mock" ? "mock" : driver}",
94
+ test: "mock",
95
+ prod: "${driver === "mock" ? "mock" : driver}",
96
+ }`;
97
+ // Top-level drivers.ai only (4-space indent) — never nest under channel.email.
98
+ const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
99
+ if (aiRe.test(source)) {
100
+ return source.replace(aiRe, `$1ai: ${block}`);
101
+ }
102
+ const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
103
+ if (channelRe.test(source)) {
104
+ return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
105
+ }
106
+ const driversClose = /(drivers:\s*\{[\s\S]*?)(\n\s*\},)/;
107
+ if (!driversClose.test(source)) {
108
+ throw new Error("oke ai setup: could not find drivers block in oke.config.ts");
109
+ }
110
+ return source.replace(driversClose, `$1\n ai: ${block},$2`);
111
+ }
112
+
113
+ /**
114
+ * @param source - Config source
115
+ * @param key - Image role
116
+ * @param image - Image ref
117
+ */
118
+ export function upsertImage(source: string, key: string, image: string): string {
119
+ const keyLit = key.includes(".") ? `"${key}"` : key;
120
+ const line = ` ${keyLit}: "${image}",`;
121
+ const imagesRe = /images:\s*\{([\s\S]*?)\n\s*\}/;
122
+ const m = imagesRe.exec(source);
123
+ if (!m) return source;
124
+ const body = m[1]!;
125
+ if (new RegExp(`${keyLit}\\s*:`).test(body) || new RegExp(`"${key}"\\s*:`).test(body)) {
126
+ return source.replace(
127
+ new RegExp(`(["']?${key.replace(".", "\\.")}["']?\\s*:\\s*)"[^"]*"`),
128
+ `$1"${image}"`,
129
+ );
130
+ }
131
+ return source.replace(imagesRe, `images: {${body}\n${line}\n }`);
132
+ }
133
+
134
+ /**
135
+ * @param env - dotenv text
136
+ * @param key - Variable name
137
+ * @param value - Value
138
+ */
139
+ export function upsertEnv(env: string, key: string, value: string): string {
140
+ const line = `${key}=${value}`;
141
+ const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
142
+ if (re.test(env)) return env.replace(re, line);
143
+ const aiSection = /(# ── AI[^\n]*\n)/;
144
+ if (aiSection.test(env)) {
145
+ return env.replace(aiSection, `$1${line}\n`);
146
+ }
147
+ return `${env.trimEnd()}\n\n# ── AI ──────────────────────────────────────────────────────\n${line}\n`;
148
+ }
149
+
150
+ /**
151
+ * @param input - Setup choices
152
+ */
153
+ export function renderAiTs(input: AiSetupApplyInput): string {
154
+ const chat = input.chatModel ?? "default";
155
+ const provider =
156
+ input.driver === "ollama"
157
+ ? "ollama"
158
+ : input.driver === "anthropic"
159
+ ? "anthropic"
160
+ : input.driver === "mock"
161
+ ? "mock"
162
+ : "openai-compatible";
163
+
164
+ const lines = [
165
+ `import { ai } from "okengine";`,
166
+ ``,
167
+ `export const smart = ai.model("smart", {`,
168
+ ` provider: "${provider}",`,
169
+ ` model: process.env.OKE_AI_MODEL ?? "${chat}",`,
170
+ `});`,
171
+ ];
172
+
173
+ if (input.visionModel) {
174
+ lines.push(
175
+ ``,
176
+ `export const vision = ai.model("vision", {`,
177
+ ` provider: "${provider}",`,
178
+ ` model: process.env.OKE_AI_VISION_MODEL ?? "${input.visionModel}",`,
179
+ `});`,
180
+ );
181
+ }
182
+
183
+ if (input.embedModel) {
184
+ lines.push(
185
+ ``,
186
+ `export const embedModel = ai.model("embed", {`,
187
+ ` provider: "${provider}",`,
188
+ ` model: process.env.OKE_AI_EMBED_MODEL ?? "${input.embedModel}",`,
189
+ `});`,
190
+ ``,
191
+ `export const docsEmbed = ai.embed("docs", { model: embedModel });`,
192
+ );
193
+ }
194
+
195
+ lines.push(``);
196
+ return `${lines.join("\n")}\n`;
197
+ }
198
+
199
+ /**
200
+ * Ensure `src/app.ts` (or `src/core.ts`) imports `./ai`.
201
+ *
202
+ * @param cwd - Project root
203
+ */
204
+ function ensureAiImported(cwd: string): void {
205
+ for (const rel of ["src/app.ts", "src/core.ts"]) {
206
+ const path = join(cwd, rel);
207
+ if (!existsSync(path)) continue;
208
+ const src = readFileSync(path, "utf8");
209
+ if (/from\s+["']\.\/ai["']/.test(src) || /import\s+["']\.\/ai["']/.test(src)) return;
210
+ const next = `import "./ai";\n${src}`;
211
+ writeFileSync(path, next, "utf8");
212
+ return;
213
+ }
214
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Curated Ollama model catalog for `oke ai setup`.
3
+ *
4
+ * Short lists with Recommended — not the full Ollama library.
5
+ * Tags verified against common 2026 library names; override freely.
6
+ */
7
+
8
+ /** Role a model can fill in the wizard. */
9
+ export type CatalogRole = "chat" | "vision" | "embed";
10
+
11
+ /** One curated model entry. */
12
+ export type CatalogModel = {
13
+ readonly id: string;
14
+ readonly label: string;
15
+ readonly hint: string;
16
+ readonly role: CatalogRole;
17
+ readonly recommended?: boolean;
18
+ /** Approximate RAM (GB) needed to run comfortably. */
19
+ readonly ramGb: number;
20
+ };
21
+
22
+ /** Chat models (short list). */
23
+ export const CHAT_MODELS: readonly CatalogModel[] = [
24
+ {
25
+ id: "gemma4:e4b",
26
+ label: "Gemma 4 4B",
27
+ hint: "Fast · ≈8GB-class machine",
28
+ role: "chat",
29
+ recommended: true,
30
+ ramGb: 8,
31
+ },
32
+ {
33
+ id: "qwen3.5:9b",
34
+ label: "Qwen3.5 9B",
35
+ hint: "Better coding · ≈16GB-class",
36
+ role: "chat",
37
+ ramGb: 16,
38
+ },
39
+ {
40
+ id: "qwen3.5:27b",
41
+ label: "Qwen3.5 27B",
42
+ hint: "Best local quality · ≈32GB-class",
43
+ role: "chat",
44
+ ramGb: 32,
45
+ },
46
+ {
47
+ id: "llama4:scout",
48
+ label: "Llama 4 Scout",
49
+ hint: "General purpose · ≈16GB-class",
50
+ role: "chat",
51
+ ramGb: 16,
52
+ },
53
+ {
54
+ id: "deepseek-r1:8b",
55
+ label: "DeepSeek R1 8B",
56
+ hint: "Strong reasoning · ≈10GB-class",
57
+ role: "chat",
58
+ ramGb: 10,
59
+ },
60
+ ];
61
+
62
+ /** Vision models (short list + skip handled in prompts). */
63
+ export const VISION_MODELS: readonly CatalogModel[] = [
64
+ {
65
+ id: "qwen3-vl:4b",
66
+ label: "Qwen3-VL 4B",
67
+ hint: "Vision-language",
68
+ role: "vision",
69
+ recommended: true,
70
+ ramGb: 8,
71
+ },
72
+ {
73
+ id: "gemma4:e4b",
74
+ label: "Gemma Vision",
75
+ hint: "Multimodal Gemma 4",
76
+ role: "vision",
77
+ ramGb: 8,
78
+ },
79
+ ];
80
+
81
+ /** Embedding models. */
82
+ export const EMBED_MODELS: readonly CatalogModel[] = [
83
+ {
84
+ id: "nomic-embed-text",
85
+ label: "nomic-embed-text",
86
+ hint: "Default local RAG embedder",
87
+ role: "embed",
88
+ recommended: true,
89
+ ramGb: 1,
90
+ },
91
+ {
92
+ id: "bge-m3",
93
+ label: "bge-m3",
94
+ hint: "Multilingual + long docs",
95
+ role: "embed",
96
+ ramGb: 2,
97
+ },
98
+ {
99
+ id: "jina-embeddings-v4",
100
+ label: "jina-embeddings-v4",
101
+ hint: "High-quality embedder",
102
+ role: "embed",
103
+ ramGb: 2,
104
+ },
105
+ ];
106
+
107
+ /** All curated ids (for detect / not-installed). */
108
+ export const ALL_CURATED: readonly CatalogModel[] = [
109
+ ...CHAT_MODELS,
110
+ ...VISION_MODELS.filter((m) => !CHAT_MODELS.some((c) => c.id === m.id)),
111
+ ...EMBED_MODELS,
112
+ ];
113
+
114
+ /**
115
+ * Recommend a chat model for the host RAM (comfortable headroom).
116
+ *
117
+ * Prefer {@link recommendChatForNeeds} when use-case answers are available.
118
+ *
119
+ * @param totalRamGb - Detected total system RAM in GB (or null)
120
+ */
121
+ export function recommendChatModel(totalRamGb: number | null): CatalogModel {
122
+ // Keep catalog free of recommend.ts cycles — same rules as comfortableChatModels.
123
+ const HEADROOM = 4;
124
+ const sorted = [...CHAT_MODELS].sort((a, b) => a.ramGb - b.ramGb);
125
+ const smallest = sorted[0]!;
126
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
127
+ return CHAT_MODELS.find((m) => m.recommended) ?? smallest;
128
+ }
129
+ const comfortable = sorted.filter(
130
+ (m) => (m.id === smallest.id && m.ramGb <= totalRamGb) || m.ramGb + HEADROOM <= totalRamGb,
131
+ );
132
+ if (comfortable.length > 0) return comfortable[comfortable.length - 1]!;
133
+ // Fall back to largest that meets the machine tier (tight but runnable).
134
+ const tierOk = sorted.filter((m) => m.ramGb <= totalRamGb);
135
+ return tierOk.length > 0 ? tierOk[tierOk.length - 1]! : smallest;
136
+ }
137
+
138
+ /**
139
+ * Recommend vision / embed defaults.
140
+ *
141
+ * @param role - vision or embed
142
+ */
143
+ export function recommendForRole(role: "vision" | "embed"): CatalogModel {
144
+ const list = role === "vision" ? VISION_MODELS : EMBED_MODELS;
145
+ return list.find((m) => m.recommended) ?? list[0]!;
146
+ }
147
+
148
+ /** Thin cloud provider menu (non-Ollama). */
149
+ export const CLOUD_PROVIDERS = [
150
+ {
151
+ value: "openai",
152
+ label: "OpenAI",
153
+ driver: "openai-compatible" as const,
154
+ baseUrl: "https://api.openai.com/v1",
155
+ },
156
+ { value: "anthropic", label: "Anthropic", driver: "anthropic" as const, baseUrl: undefined },
157
+ {
158
+ value: "gemini",
159
+ label: "Gemini",
160
+ driver: "openai-compatible" as const,
161
+ baseUrl: undefined,
162
+ },
163
+ {
164
+ value: "lmstudio",
165
+ label: "LM Studio",
166
+ driver: "openai-compatible" as const,
167
+ baseUrl: "http://127.0.0.1:1234/v1",
168
+ },
169
+ {
170
+ value: "openrouter",
171
+ label: "OpenRouter",
172
+ driver: "openai-compatible" as const,
173
+ baseUrl: "https://openrouter.ai/api/v1",
174
+ },
175
+ {
176
+ value: "custom",
177
+ label: "Custom OpenAI Compatible",
178
+ driver: "openai-compatible" as const,
179
+ baseUrl: undefined,
180
+ },
181
+ ] as const;
182
+
183
+ /** Cloud chat model entry (no RAM tier). */
184
+ export type CloudModel = {
185
+ readonly id: string;
186
+ readonly label: string;
187
+ readonly hint: string;
188
+ readonly recommended?: boolean;
189
+ };
190
+
191
+ /** Short curated chat models per cloud / openai-compatible provider. */
192
+ export const CLOUD_CHAT_MODELS: Readonly<Record<string, readonly CloudModel[]>> = {
193
+ openai: [
194
+ { id: "gpt-4o-mini", label: "GPT-4o mini", hint: "Fast · cheap default", recommended: true },
195
+ { id: "gpt-4o", label: "GPT-4o", hint: "Strong general / vision" },
196
+ { id: "gpt-4.1", label: "GPT-4.1", hint: "Latest flagship" },
197
+ { id: "o4-mini", label: "o4-mini", hint: "Reasoning · lower cost" },
198
+ ],
199
+ anthropic: [
200
+ {
201
+ id: "claude-sonnet-4-20250514",
202
+ label: "Claude Sonnet 4",
203
+ hint: "Balanced default",
204
+ recommended: true,
205
+ },
206
+ { id: "claude-opus-4-20250514", label: "Claude Opus 4", hint: "Highest quality" },
207
+ { id: "claude-haiku-4-20250514", label: "Claude Haiku 4", hint: "Fast · cheap" },
208
+ ],
209
+ gemini: [
210
+ {
211
+ id: "gemini-2.5-flash",
212
+ label: "Gemini 2.5 Flash",
213
+ hint: "Fast default",
214
+ recommended: true,
215
+ },
216
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", hint: "Higher quality" },
217
+ ],
218
+ openrouter: [
219
+ {
220
+ id: "openai/gpt-4o-mini",
221
+ label: "GPT-4o mini",
222
+ hint: "via OpenRouter",
223
+ recommended: true,
224
+ },
225
+ { id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4", hint: "via OpenRouter" },
226
+ { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash", hint: "via OpenRouter" },
227
+ ],
228
+ lmstudio: [
229
+ {
230
+ id: "local-model",
231
+ label: "local-model",
232
+ hint: "LM Studio loaded model id",
233
+ recommended: true,
234
+ },
235
+ ],
236
+ custom: [
237
+ {
238
+ id: "default",
239
+ label: "default",
240
+ hint: "Whatever your endpoint expects",
241
+ recommended: true,
242
+ },
243
+ ],
244
+ };
245
+
246
+ /**
247
+ * Curated chat models for a cloud provider (empty → Other-only).
248
+ *
249
+ * @param provider - Menu provider id
250
+ */
251
+ export function cloudChatModels(provider: string): readonly CloudModel[] {
252
+ return CLOUD_CHAT_MODELS[provider] ?? [];
253
+ }
254
+
255
+ /**
256
+ * Recommended cloud chat model id for a provider.
257
+ *
258
+ * @param provider - Menu provider id
259
+ */
260
+ export function recommendCloudChat(provider: string): string {
261
+ const list = cloudChatModels(provider);
262
+ return list.find((m) => m.recommended)?.id ?? list[0]?.id ?? "gpt-4o-mini";
263
+ }