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