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.
- package/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +105 -14
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/plugins/otp.mdx +58 -46
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/auth/otp-capability.ts +14 -14
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +607 -66
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/compiler/extract.ts +3 -3
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +118 -11
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +8 -3
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +111 -1
- package/src/manifest/types.ts +8 -2
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/auth-methods.security.test.ts +3 -3
- package/src/plugins/auth-methods.test.ts +1 -1
- package/src/plugins/index.ts +2 -2
- package/src/plugins/otp.test.ts +21 -19
- package/src/plugins/otp.ts +36 -34
- package/src/plugins/taqnyat.live.test.ts +3 -3
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
|
@@ -1,76 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart Ollama recommendation
|
|
2
|
+
* Smart Ollama recommendation — RAM tiers (no needs quiz).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
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
|
-
|
|
16
|
+
formatLlamaCppBanner,
|
|
17
|
+
formatModelRow,
|
|
18
|
+
formatModelTableHeader,
|
|
19
|
+
formatOllamaBanner,
|
|
10
20
|
isTightFit,
|
|
11
21
|
modelFitsComfortably,
|
|
12
22
|
modelFitsOnMachine,
|
|
13
23
|
recommendChatForNeeds,
|
|
14
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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("
|
|
35
|
-
test("
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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("
|
|
42
|
-
|
|
43
|
-
expect(
|
|
44
|
-
expect(
|
|
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("
|
|
48
|
-
const pick =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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("
|
|
59
|
-
const pick = recommendChatForNeeds(
|
|
60
|
-
|
|
61
|
-
|
|
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("
|
|
68
|
-
const pick = recommendChatForNeeds(
|
|
69
|
-
|
|
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).
|
|
118
|
+
expect(recommendChatModel(8).ramGb).toBeLessThanOrEqual(8);
|
|
96
119
|
expect(recommendChatModel(16).ramGb).toBeLessThanOrEqual(16);
|
|
97
120
|
expect(recommendChatModel(24).ramGb).toBeLessThanOrEqual(16);
|
|
98
|
-
|
|
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
|
-
*
|
|
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).
|
|
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 {
|
|
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
|
-
*
|
|
92
|
+
* Suggest a tier for the machine's fit RAM budget.
|
|
105
93
|
*
|
|
106
|
-
* @param
|
|
107
|
-
* @param needs - Questionnaire answers
|
|
108
|
-
* @param totalRamGb - Machine RAM (tight-fit penalty)
|
|
94
|
+
* @param totalRamGb - Machine RAM
|
|
109
95
|
*/
|
|
110
|
-
export function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
115
|
+
* Shared hardware lines for local AI banners.
|
|
171
116
|
*
|
|
172
|
-
* @param
|
|
173
|
-
* @param wantVision - From questionnaire
|
|
117
|
+
* @param machine - Detected hardware
|
|
174
118
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
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
|
-
*
|
|
139
|
+
* Ollama banner lines — OS · CPU · RAM · fit · detected · tiers.
|
|
186
140
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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
|
|
191
|
-
* @param chat - Chosen chat model
|
|
167
|
+
* @param machine - Detected hardware
|
|
192
168
|
*/
|
|
193
|
-
export function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
});
|