create-oke 0.18.4 → 0.19.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 (62) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/agents-md.ts +6 -4
  4. package/src/ai-setup/apply.test.ts +278 -0
  5. package/src/ai-setup/apply.ts +430 -52
  6. package/src/ai-setup/catalog.ts +250 -1343
  7. package/src/ai-setup/from-pref.ts +3 -71
  8. package/src/ai-setup/prompts.ts +60 -443
  9. package/src/cli.test.ts +21 -15
  10. package/src/cli.ts +20 -11
  11. package/src/create-defaults.test.ts +4 -4
  12. package/src/create-defaults.ts +3 -3
  13. package/src/customize-flow.test.ts +9 -15
  14. package/src/customize-flow.ts +59 -66
  15. package/src/drivers-catalog.ts +18 -23
  16. package/src/transform.test.ts +69 -10
  17. package/src/transform.ts +10 -35
  18. package/templates/advanced/.env.example +28 -11
  19. package/templates/advanced/.github/workflows/ci.yml +1 -1
  20. package/templates/advanced/oke.config.ts +3 -4
  21. package/templates/advanced/package.json +11 -10
  22. package/templates/advanced/src/app.ts +36 -1
  23. package/templates/advanced/src/core.ts +12 -11
  24. package/templates/advanced/src/db/schema.decl.ts +6 -2
  25. package/templates/advanced/src/db/seed/index.ts +4 -4
  26. package/templates/advanced/src/flows/main/route.ts +3 -2
  27. package/templates/advanced/src/flows/notes/[id]/archive.ts +5 -8
  28. package/templates/advanced/src/flows/notes/[id]/attach.ts +1 -4
  29. package/templates/advanced/src/flows/notes/[id]/get.ts +4 -7
  30. package/templates/advanced/src/flows/notes/[id]/summarize.ts +3 -4
  31. package/templates/advanced/src/flows/notes/create.ts +4 -6
  32. package/templates/advanced/src/flows/notes/digest.ts +6 -5
  33. package/templates/advanced/src/flows/notes/list.ts +4 -5
  34. package/templates/advanced/src/flows/notes/shapes.ts +13 -3
  35. package/templates/advanced/src/flows/notes/signals.ts +1 -2
  36. package/templates/advanced/src/vault.ts +138 -0
  37. package/templates/advanced/tests/advanced.test.ts +11 -8
  38. package/templates/advanced/web/src/App.css +7 -0
  39. package/templates/advanced/web/src/App.tsx +101 -1
  40. package/templates/advanced/web/src/client.ts +16 -3
  41. package/templates/advanced/web/vite.config.ts +1 -0
  42. package/templates/standard/.env.example +22 -11
  43. package/templates/standard/.github/workflows/ci.yml +1 -1
  44. package/templates/standard/oke.config.ts +3 -3
  45. package/templates/standard/package.json +11 -10
  46. package/templates/standard/src/app.ts +6 -1
  47. package/templates/standard/src/core.ts +10 -11
  48. package/templates/standard/src/db/schema.decl.ts +6 -2
  49. package/templates/standard/src/db/seed/index.ts +3 -3
  50. package/templates/standard/src/flows/main/route.ts +3 -2
  51. package/templates/standard/src/flows/notes/[id]/archive.ts +5 -8
  52. package/templates/standard/src/flows/notes/[id]/get.ts +4 -7
  53. package/templates/standard/src/flows/notes/create.ts +4 -6
  54. package/templates/standard/src/flows/notes/list.ts +4 -5
  55. package/templates/standard/src/flows/notes/shapes.ts +12 -2
  56. package/templates/standard/src/flows/notes/signals.ts +1 -2
  57. package/templates/standard/src/vault.ts +123 -0
  58. package/templates/standard/tests/standard.test.ts +3 -1
  59. package/templates/standard/web/src/client.ts +2 -2
  60. package/templates/standard/web/vite.config.ts +1 -0
  61. package/src/ai-setup/detect-ollama.ts +0 -228
  62. package/src/ai-setup/recommend.ts +0 -220
@@ -4,12 +4,17 @@
4
4
 
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
- import { OLLAMA_IMAGE } from "../drivers-catalog.ts";
8
7
  import { extractImages, findImagesBlock, replaceImagesBlock } from "../transform.ts";
8
+ import { CLOUD_PROVIDERS } from "./catalog.ts";
9
9
 
10
10
  /** Choices applied to the project. */
11
11
  export type AiSetupApplyInput = {
12
- readonly driver: "ollama" | "anthropic" | "openai-compatible" | "mock";
12
+ readonly driver: "anthropic" | "openai-compatible" | "mock";
13
+ /**
14
+ * Name written to `ai.model({ provider })` — registry id (`openrouter`,
15
+ * `openai`, …), or `openai-compatible` / `local` for self-hosted.
16
+ */
17
+ readonly provider?: string;
13
18
  readonly baseUrl?: string;
14
19
  readonly chatModel?: string;
15
20
  readonly visionModel?: string | null;
@@ -17,7 +22,7 @@ export type AiSetupApplyInput = {
17
22
  readonly apiKeyEnv?: string;
18
23
  /** When set with {@link apiKeyEnv}, writes the token into `.env.local`. */
19
24
  readonly apiKey?: string;
20
- /** Optional `images.ai` pin (llama.cpp / Ollama / vLLM / SGLang). */
25
+ /** Optional `images.ai` pin (legacy; cloud / custom / lmstudio leave unset). */
21
26
  readonly image?: string;
22
27
  };
23
28
 
@@ -57,8 +62,9 @@ export function applyAiSetup(
57
62
  config = upsertAiDrivers(config, input.driver);
58
63
  if (input.image) {
59
64
  config = upsertImage(config, "ai", input.image);
60
- } else if (input.driver === "ollama") {
61
- config = upsertImage(config, "ai", OLLAMA_IMAGE);
65
+ } else {
66
+ // Cloud / host-side providers must not leave a leftover `images.ai` pin.
67
+ config = removeImage(config, "ai");
62
68
  }
63
69
  writeFileSync(configPath, config, "utf8");
64
70
  }
@@ -93,10 +99,135 @@ export function applyAiSetup(
93
99
  writeFileSync(envPath, env.endsWith("\n") ? env : `${env}\n`, "utf8");
94
100
 
95
101
  const aiTsPath = writeAiModels(cwd, input);
102
+ if (input.apiKeyEnv) {
103
+ ensureAiApiKeyVaultSecret(cwd, input.apiKeyEnv);
104
+ }
105
+
106
+ if (input.embedModel) {
107
+ ensureHybridSearchEmbedWiring(cwd);
108
+ }
96
109
 
97
110
  return { configPath, envPath, aiTsPath };
98
111
  }
99
112
 
113
+ /** Default dims for nomic-embed-text (starter / local RAG default). */
114
+ export const DEFAULT_SEARCH_EMBED_DIMS = 768;
115
+
116
+ /**
117
+ * When AI setup includes an embed model, wire Notes hybrid search:
118
+ * - `body: …searchable().embed()` in `schema.decl.ts`
119
+ * - `oke({ store: { search: { embed: { model: embedModel, dims } } } })` in `app.ts`
120
+ *
121
+ * Idempotent — safe to re-run.
122
+ *
123
+ * @param cwd - Project root
124
+ * @param dims - Vector dimensionality (default {@link DEFAULT_SEARCH_EMBED_DIMS})
125
+ */
126
+ export function ensureHybridSearchEmbedWiring(
127
+ cwd: string,
128
+ dims: number = DEFAULT_SEARCH_EMBED_DIMS,
129
+ ): void {
130
+ const declPath = join(cwd, "src", "db", "schema.decl.ts");
131
+ if (existsSync(declPath)) {
132
+ const prev = readFileSync(declPath, "utf8");
133
+ const next = ensureNotesBodyEmbed(prev);
134
+ if (next !== prev) writeFileSync(declPath, next, "utf8");
135
+ }
136
+
137
+ const appPath = join(cwd, "src", "app.ts");
138
+ if (existsSync(appPath)) {
139
+ const prev = readFileSync(appPath, "utf8");
140
+ const next = ensureAppStoreSearchEmbed(prev, dims);
141
+ if (next !== prev) writeFileSync(appPath, next, "utf8");
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Ensure the Notes `body` column chains bare `.embed()` after `.searchable()`.
147
+ *
148
+ * @param source - `schema.decl.ts` source
149
+ */
150
+ export function ensureNotesBodyEmbed(source: string): string {
151
+ if (/\bbody:\s*field\.[\s\S]*?\.embed\s*\(/.test(source)) return source;
152
+ // Prefer exact Notes starter shapes, then a generic searchable body line.
153
+ const patterns: RegExp[] = [
154
+ /(body:\s*field\.text\(\)\.searchable\(\))\s*(\.notNull\(\))?/,
155
+ /(body:\s*field\.text\(\)\.searchable\(\{[^}]*\}\))\s*(\.notNull\(\))?/,
156
+ /(body:\s*field\.text\(\))\s*(\.notNull\(\))?/,
157
+ ];
158
+ for (const re of patterns) {
159
+ if (!re.test(source)) continue;
160
+ return source.replace(re, (_m, head: string, notNull?: string) => {
161
+ const mid = /\.searchable\s*\(/.test(head)
162
+ ? `${head}.embed()`
163
+ : `${head}.searchable().embed()`;
164
+ return `${mid}${notNull ?? ""}`;
165
+ });
166
+ }
167
+ return source;
168
+ }
169
+
170
+ /**
171
+ * Stamp project default `store.search.embed` on `oke({…})` and import `embedModel`.
172
+ *
173
+ * @param source - `src/app.ts` source
174
+ * @param dims - Embedding dimensionality
175
+ */
176
+ export function ensureAppStoreSearchEmbed(source: string, dims: number): string {
177
+ if (/store\s*:\s*\{[\s\S]*?search\s*:\s*\{[\s\S]*?embed\s*:/.test(source)) {
178
+ return source;
179
+ }
180
+
181
+ let next = source;
182
+ if (!/\bembedModel\b/.test(next)) {
183
+ if (/import\s*\{([^}]*)\}\s*from\s*["']@\/core["']/.test(next)) {
184
+ next = ensureNamedImportFrom(next, "@/core", "embedModel");
185
+ } else {
186
+ next = `import { embedModel } from "@/core";\n${next}`;
187
+ }
188
+ }
189
+
190
+ const simple = /oke\(\s*\{\s*name:\s*(["'])([^"']+)\1\s*\}\s*\)/;
191
+ if (simple.test(next)) {
192
+ return next.replace(
193
+ simple,
194
+ (_m, q: string, name: string) =>
195
+ `oke({\n name: ${q}${name}${q},\n store: {\n search: {\n embed: { model: embedModel, dims: ${dims} },\n },\n },\n})`,
196
+ );
197
+ }
198
+
199
+ // oke({ name: "…", …other }) — inject store before the closing `}`.
200
+ const named = /oke\(\s*\{([\s\S]*?)\}\s*\)/;
201
+ const m = named.exec(next);
202
+ if (!m) return next;
203
+ const body = m[1] ?? "";
204
+ if (/^\s*store\s*:/m.test(body)) return next;
205
+ const injected = `${body.trimEnd().replace(/,?\s*$/, "")},\n store: {\n search: {\n embed: { model: embedModel, dims: ${dims} },\n },\n },\n`;
206
+ return next.replace(named, `oke({${injected}})`);
207
+ }
208
+
209
+ /**
210
+ * Add a named binding to an existing `import { … } from "<mod>"`.
211
+ *
212
+ * @param source - Module source
213
+ * @param mod - Module specifier
214
+ * @param name - Binding to add
215
+ */
216
+ function ensureNamedImportFrom(source: string, mod: string, name: string): string {
217
+ const re = new RegExp(
218
+ `import\\s*\\{([^}]*)\\}\\s*from\\s*["']${mod.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*;`,
219
+ );
220
+ const m = re.exec(source);
221
+ if (!m) return `import { ${name} } from "${mod}";\n${source}`;
222
+ const names = m[1]!
223
+ .split(",")
224
+ .map((s) => s.trim())
225
+ .filter(Boolean);
226
+ if (names.includes(name)) return source;
227
+ const sorted = [...names, name].sort((a, b) => a.localeCompare(b));
228
+ return source.replace(re, `import { ${sorted.join(", ")} } from "${mod}";`);
229
+ }
230
+
100
231
  /**
101
232
  * @param source - oke.config.ts
102
233
  * @param driver - AI driver id
@@ -140,6 +271,20 @@ export function upsertImage(source: string, key: string, image: string): string
140
271
  return replaceImagesBlock(source, images);
141
272
  }
142
273
 
274
+ /**
275
+ * Drop one dotted role's image pin (e.g. clear `images.ai` for cloud AI).
276
+ *
277
+ * @param source - Config source
278
+ * @param key - Image role
279
+ */
280
+ export function removeImage(source: string, key: string): string {
281
+ if (!findImagesBlock(source)) return source;
282
+ const images = { ...extractImages(source) };
283
+ if (!(key in images)) return source;
284
+ delete images[key];
285
+ return replaceImagesBlock(source, images);
286
+ }
287
+
143
288
  /** Options for {@link upsertEnv}. */
144
289
  export type UpsertEnvOptions = {
145
290
  /**
@@ -178,34 +323,39 @@ export function upsertEnv(
178
323
  */
179
324
  export function renderAiTs(input: AiSetupApplyInput): string {
180
325
  const chat = input.chatModel ?? "default";
181
- const provider =
182
- input.driver === "ollama"
183
- ? "ollama"
184
- : input.driver === "anthropic"
185
- ? "anthropic"
186
- : input.driver === "mock"
187
- ? "mock"
188
- : "openai-compatible";
326
+ const provider = resolveDeclProvider(input);
327
+ const apiKeyEnv = input.apiKeyEnv;
328
+ const registryCloud = isRegistryCloudProvider(provider);
329
+ const nativeAnthropic = input.driver === "anthropic";
330
+ const localPrimary = isSelfHostedLocal(input);
189
331
 
190
- const lines = [
191
- `import { ai } from "okengine";`,
192
- ``,
193
- `/** Cloud OpenAI-compatible binding (OpenAI / Groq / OpenRouter / …). */`,
332
+ const smartLines = [
194
333
  `export const smart = ai.model("smart", {`,
195
334
  ` provider: "${provider}",`,
335
+ ...(nativeAnthropic ? [` driverId: "anthropic",`] : []),
196
336
  ` model: process.env.OKE_AI_CLOUD_MODEL ?? process.env.OKE_AI_MODEL ?? "${chat}",`,
197
- ` baseUrl: process.env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/v1",`,
198
- ` ...(process.env.OPENAI_API_KEY?.trim()`,
199
- ` ? { apiKey: process.env.OPENAI_API_KEY.trim() }`,
200
- ` : {}),`,
337
+ ...smartBaseUrlLines(input, { registryCloud, localPrimary }),
338
+ ...apiKeySpreadLines(apiKeyEnv),
201
339
  `});`,
202
- ``,
203
- `/** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */`,
340
+ ];
341
+
342
+ const localDefaultModel = localPrimary ? chat : "local-model";
343
+ const localLines = [
204
344
  `export const local = ai.model("local", {`,
205
- ` provider: "${provider === "ollama" ? "ollama" : "openai-compatible"}",`,
206
- ` model: process.env.OKE_AI_LOCAL_MODEL ?? "${chat}",`,
345
+ ` provider: "openai-compatible",`,
346
+ ` model: process.env.OKE_AI_LOCAL_MODEL ?? "${localDefaultModel}",`,
207
347
  ` ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),`,
208
348
  `});`,
349
+ ];
350
+
351
+ const lines = [
352
+ `import { ai } from "okengine";`,
353
+ ``,
354
+ `/** Primary model binding (${provider}). */`,
355
+ ...smartLines,
356
+ ``,
357
+ `/** Local OpenAI-compatible binding (via \`OKE_AI_URL\`). */`,
358
+ ...localLines,
209
359
  ``,
210
360
  `/** Advanced Notes summarize — used by \`notes.summarize\` via \`fx.ask\`. */`,
211
361
  `export const summarizeNote = smart.prompt("summarize-note", {`,
@@ -219,7 +369,9 @@ export function renderAiTs(input: AiSetupApplyInput): string {
219
369
  ``,
220
370
  `export const vision = ai.model("vision", {`,
221
371
  ` provider: "${provider}",`,
372
+ ...(nativeAnthropic ? [` driverId: "anthropic",`] : []),
222
373
  ` model: process.env.OKE_AI_VISION_MODEL ?? "${input.visionModel}",`,
374
+ ...apiKeySpreadLines(apiKeyEnv),
223
375
  `});`,
224
376
  );
225
377
  }
@@ -227,11 +379,14 @@ export function renderAiTs(input: AiSetupApplyInput): string {
227
379
  if (input.embedModel) {
228
380
  lines.push(
229
381
  ``,
382
+ `/** Embedding model — also the project default via \`oke({ store: { search: { embed } } })\`. */`,
230
383
  `export const embedModel = ai.model("embed", {`,
231
384
  ` provider: "${provider}",`,
232
385
  ` model: process.env.OKE_AI_EMBED_MODEL ?? "${input.embedModel}",`,
386
+ ...apiKeySpreadLines(apiKeyEnv),
233
387
  `});`,
234
388
  ``,
389
+ `/** Index-facet embed pipeline (Meilisearch / pgvector) — separate from SQL \`.embed()\`. */`,
235
390
  `export const docsEmbed = ai.embed("docs", { model: embedModel });`,
236
391
  );
237
392
  }
@@ -240,6 +395,176 @@ export function renderAiTs(input: AiSetupApplyInput): string {
240
395
  return `${lines.join("\n")}\n`;
241
396
  }
242
397
 
398
+ /**
399
+ * Provider string for generated `ai.model` declarations.
400
+ *
401
+ * @param input - Setup choices
402
+ */
403
+ export function resolveDeclProvider(input: AiSetupApplyInput): string {
404
+ if (input.provider !== undefined && input.provider.length > 0) return input.provider;
405
+ if (input.driver === "anthropic") return "anthropic";
406
+ if (input.driver === "mock") return "mock";
407
+ return "openai-compatible";
408
+ }
409
+
410
+ function isRegistryCloudProvider(provider: string): boolean {
411
+ return CLOUD_PROVIDERS.some(
412
+ (p) => (p.provider ?? p.value) === provider && p.driver === "openai-compatible" && p.baseUrl,
413
+ );
414
+ }
415
+
416
+ function isSelfHostedLocal(input: AiSetupApplyInput): boolean {
417
+ return input.image !== undefined;
418
+ }
419
+
420
+ function smartBaseUrlLines(
421
+ input: AiSetupApplyInput,
422
+ flags: { readonly registryCloud: boolean; readonly localPrimary: boolean },
423
+ ): string[] {
424
+ if (input.driver === "anthropic") {
425
+ return [];
426
+ }
427
+ if (flags.registryCloud) {
428
+ return [
429
+ ` ...(process.env.OPENAI_BASE_URL?.trim() ? { baseUrl: process.env.OPENAI_BASE_URL.trim() } : {}),`,
430
+ ];
431
+ }
432
+ if (flags.localPrimary) {
433
+ return [
434
+ ` ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),`,
435
+ ];
436
+ }
437
+ if (input.baseUrl) {
438
+ return [` baseUrl: process.env.OPENAI_BASE_URL?.trim() || ${JSON.stringify(input.baseUrl)},`];
439
+ }
440
+ return [
441
+ ` ...(process.env.OPENAI_BASE_URL?.trim() ? { baseUrl: process.env.OPENAI_BASE_URL.trim() } : {}),`,
442
+ ];
443
+ }
444
+
445
+ function apiKeySpreadLines(apiKeyEnv: string | undefined): string[] {
446
+ if (!apiKeyEnv) return [];
447
+ return [
448
+ ` ...(process.env.${apiKeyEnv}?.trim()`,
449
+ ` ? { apiKey: process.env.${apiKeyEnv}.trim() }`,
450
+ ` : {}),`,
451
+ ];
452
+ }
453
+
454
+ /**
455
+ * `OPENROUTER_API_KEY` → `openrouterApiKey`.
456
+ *
457
+ * @param envName - Env / vault contract name
458
+ */
459
+ export function envNameToCamelBinding(envName: string): string {
460
+ const parts = envName
461
+ .toLowerCase()
462
+ .split(/_+/g)
463
+ .filter((p) => p.length > 0);
464
+ if (parts.length === 0) return "apiKey";
465
+ return parts
466
+ .map((part, i) => (i === 0 ? part : `${part[0]!.toUpperCase()}${part.slice(1)}`))
467
+ .join("");
468
+ }
469
+
470
+ /**
471
+ * Human description for a provider API key env.
472
+ *
473
+ * @param envName - Env / vault contract name
474
+ */
475
+ export function apiKeyEnvDescription(envName: string): string {
476
+ const meta = CLOUD_PROVIDERS.find((p) => p.apiKeyEnv === envName);
477
+ if (meta) return `${meta.label} API key`;
478
+ const stem = envName.replace(/_API_KEY$/i, "").replace(/_/g, " ");
479
+ return `${stem} API key`;
480
+ }
481
+
482
+ /**
483
+ * Render a `vault.secret` block for an AI provider key (no `dev:` stub so
484
+ * missing values become Vault boot gaps on `oke dev`).
485
+ *
486
+ * @param apiKeyEnv - Env / contract name
487
+ */
488
+ export function renderAiApiKeyVaultSecret(apiKeyEnv: string): string {
489
+ const binding = envNameToCamelBinding(apiKeyEnv);
490
+ const description = apiKeyEnvDescription(apiKeyEnv);
491
+ return `/** ${description} — no \`dev:\` stub so first \`oke dev\` asks via Vault gaps. */
492
+ export const ${binding} = vault.secret(${JSON.stringify(apiKeyEnv)}, {
493
+ description: ${JSON.stringify(description)},
494
+ rotate: "90d",
495
+ });
496
+ `;
497
+ }
498
+
499
+ /**
500
+ * True when `source` already declares `vault.secret("ENV")` (or `vault("ENV")`).
501
+ *
502
+ * @param source - TypeScript source
503
+ * @param apiKeyEnv - Contract name
504
+ */
505
+ export function hasAiApiKeyVaultSecret(source: string, apiKeyEnv: string): boolean {
506
+ const lit = apiKeyEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
507
+ return new RegExp(`\\bvault(?:\\.secret)?\\s*\\(\\s*["']${lit}["']`).test(source);
508
+ }
509
+
510
+ /**
511
+ * Merge a vault.secret contract for the AI API key into core / vault sources.
512
+ *
513
+ * When a `NOTES_VAULT` / `KEEL_VAULT` array exists, inserts the binding there
514
+ * too so `oke({ secrets })` keeps the new contract.
515
+ *
516
+ * @param existing - Current module source
517
+ * @param apiKeyEnv - Env / contract name
518
+ */
519
+ export function mergeAiApiKeyVaultSecret(existing: string, apiKeyEnv: string): string {
520
+ if (hasAiApiKeyVaultSecret(existing, apiKeyEnv)) return existing;
521
+ let next = ensureNamedOkengineImport(existing, "vault");
522
+ const block = renderAiApiKeyVaultSecret(apiKeyEnv);
523
+ const binding = envNameToCamelBinding(apiKeyEnv);
524
+ const vaultList = /\bexport const (NOTES_VAULT|KEEL_VAULT)\s*=\s*\[/;
525
+ if (vaultList.test(next)) {
526
+ next = next.replace(vaultList, `${block}\n$&`);
527
+ next = next.replace(
528
+ /(export const (?:NOTES_VAULT|KEEL_VAULT)\s*=\s*\[\s*\n)/,
529
+ `$1 ${binding},\n`,
530
+ );
531
+ return next;
532
+ }
533
+ const vaultHeading = /(\/\/ --- Vault[^\n]*\n)/;
534
+ if (vaultHeading.test(next)) {
535
+ return next.replace(vaultHeading, `$1\n${block}`);
536
+ }
537
+ const aiHeading = /(\/\/ --- AI[^\n]*\n)/;
538
+ if (aiHeading.test(next)) {
539
+ return next.replace(aiHeading, `${block}\n$1`);
540
+ }
541
+ return `${next.trimEnd()}\n\n${block}`;
542
+ }
543
+
544
+ /**
545
+ * Declare `vault.secret(apiKeyEnv)` in the project so missing keys surface as
546
+ * Vault gaps (and Console Vault) — never silently optional.
547
+ *
548
+ * Prefers `src/core/vault.ts`, then `src/vault.ts`, else merges into `src/core.ts`.
549
+ *
550
+ * @param cwd - Project root
551
+ * @param apiKeyEnv - Env / contract name
552
+ */
553
+ export function ensureAiApiKeyVaultSecret(cwd: string, apiKeyEnv: string): void {
554
+ const candidates = [
555
+ join(cwd, "src", "core", "vault.ts"),
556
+ join(cwd, "src", "vault.ts"),
557
+ join(cwd, "src", "core.ts"),
558
+ ] as const;
559
+ for (const path of candidates) {
560
+ if (!existsSync(path)) continue;
561
+ const prev = readFileSync(path, "utf8");
562
+ const next = mergeAiApiKeyVaultSecret(prev, apiKeyEnv);
563
+ if (next !== prev) writeFileSync(path, next, "utf8");
564
+ return;
565
+ }
566
+ }
567
+
243
568
  /**
244
569
  * Write AI model declarations into `src/core/ai.ts` when that split exists
245
570
  * (so a thin `src/core.ts` barrel stays a re-export), else `src/core.ts`,
@@ -257,14 +582,7 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
257
582
 
258
583
  if (existsSync(coreAiPath)) {
259
584
  const existing = readFileSync(coreAiPath, "utf8");
260
- if (hasAiModels(existing)) {
261
- const withPrompt = ensureSummarizeNotePrompt(existing);
262
- if (withPrompt !== existing) {
263
- writeFileSync(coreAiPath, withPrompt, "utf8");
264
- }
265
- } else {
266
- writeFileSync(coreAiPath, mergeAiIntoCore(existing, rendered), "utf8");
267
- }
585
+ writeFileSync(coreAiPath, resolveAiCoreSource(existing, rendered), "utf8");
268
586
  ensureCoreBarrelExportsAi(cwd);
269
587
  ensureCoreImported(cwd);
270
588
  return coreAiPath;
@@ -282,14 +600,7 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
282
600
  mkdirSync(dirname(coreTsPath), { recursive: true });
283
601
  if (existsSync(coreTsPath)) {
284
602
  const existing = readFileSync(coreTsPath, "utf8");
285
- if (hasAiModels(existing)) {
286
- const withPrompt = ensureSummarizeNotePrompt(existing);
287
- if (withPrompt !== existing) {
288
- writeFileSync(coreTsPath, withPrompt, "utf8");
289
- }
290
- return coreTsPath;
291
- }
292
- writeFileSync(coreTsPath, mergeAiIntoCore(existing, rendered), "utf8");
603
+ writeFileSync(coreTsPath, resolveAiCoreSource(existing, rendered), "utf8");
293
604
  } else {
294
605
  writeFileSync(coreTsPath, rendered, "utf8");
295
606
  }
@@ -297,17 +608,82 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
297
608
  return coreTsPath;
298
609
  }
299
610
 
611
+ /**
612
+ * Decide whether to merge a full AI module, repair a broken stub, or only
613
+ * backfill `local` / `summarizeNote` on an already-complete core.
614
+ *
615
+ * @param existing - Current core / AI sidecar source
616
+ * @param rendered - Output of {@link renderAiTs}
617
+ */
618
+ export function resolveAiCoreSource(existing: string, rendered: string): string {
619
+ if (isIncompleteAiSetup(existing)) {
620
+ return mergeAiIntoCore(stripIncompleteAiExports(existing), rendered);
621
+ }
622
+ if (hasAiModels(existing)) {
623
+ return ensureSummarizeNotePrompt(existing);
624
+ }
625
+ return mergeAiIntoCore(existing, rendered);
626
+ }
627
+
300
628
  /**
301
629
  * @param source - Existing TypeScript
302
630
  */
303
631
  function hasAiModels(source: string): boolean {
632
+ // Require real exports — template comments like `// ai.model("smart", …)`
633
+ // must not look like an already-configured core.
304
634
  return (
305
- /\bai\.model\s*\(/.test(source) ||
635
+ /\bexport\s+const\s+(?:smart|local|vision|embedModel)\s*=\s*ai\.model\s*\(/.test(source) ||
306
636
  /from\s+["']\.\/(?:core\/)?ai["']/.test(source) ||
307
637
  /import\s+["']\.\/(?:core\/)?ai["']/.test(source)
308
638
  );
309
639
  }
310
640
 
641
+ /**
642
+ * True when prior AI setup left unusable stubs (e.g. `local` + `summarizeNote`
643
+ * without `smart` / without an `ai` import — the old comment-as-configured bug).
644
+ *
645
+ * @param source - Existing TypeScript
646
+ */
647
+ export function isIncompleteAiSetup(source: string): boolean {
648
+ const hasAiImport = /import\s*\{[^}]*\bai\b[^}]*\}\s*from\s*["']okengine["']/.test(source);
649
+ const hasSmart = /\bexport\s+const\s+smart\s*=\s*ai\.model\s*\(/.test(source);
650
+ const hasLocal = /\bexport\s+const\s+local\s*=\s*ai\.model\s*\(/.test(source);
651
+ const hasSummarize = /\bexport\s+const\s+summarizeNote\s*=/.test(source);
652
+ const hasAnyModel =
653
+ /\bexport\s+const\s+(?:smart|local|vision|embedModel)\s*=\s*ai\.model\s*\(/.test(source);
654
+ if (hasAnyModel && !hasAiImport) return true;
655
+ if ((hasLocal || hasSummarize) && !hasSmart) return true;
656
+ return false;
657
+ }
658
+
659
+ /**
660
+ * Remove partial AI exports so {@link mergeAiIntoCore} can rewrite a full set.
661
+ *
662
+ * @param source - Existing TypeScript
663
+ */
664
+ export function stripIncompleteAiExports(source: string): string {
665
+ let next = source;
666
+ next = next.replace(
667
+ /(?:\/\*\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)?export\s+const\s+local\s*=\s*ai\.model\s*\(\s*["']local["']\s*,\s*\{[\s\S]*?\}\s*\)\s*;\s*/g,
668
+ "",
669
+ );
670
+ next = next.replace(
671
+ /(?:\/\*\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)?export\s+const\s+summarizeNote\s*=\s*smart\.prompt\s*\([\s\S]*?\}\s*\)\s*;\s*/g,
672
+ "",
673
+ );
674
+ if (!/\bexport\s+const\s+smart\s*=\s*ai\.model\s*\(/.test(next)) {
675
+ next = next.replace(
676
+ /(?:\/\*\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)?export\s+const\s+(?:vision|embedModel)\s*=\s*ai\.model\s*\([\s\S]*?\}\s*\)\s*;\s*/g,
677
+ "",
678
+ );
679
+ next = next.replace(
680
+ /(?:\/\*\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)?export\s+const\s+docsEmbed\s*=\s*ai\.embed\s*\([\s\S]*?\}\s*\)\s*;\s*/g,
681
+ "",
682
+ );
683
+ }
684
+ return next.replace(/\n{3,}/g, "\n\n");
685
+ }
686
+
311
687
  /**
312
688
  * Append the advanced Notes `summarize-note` prompt when a `smart` model
313
689
  * exists but the prompt was never declared (common after older `--ai` runs).
@@ -316,24 +692,26 @@ function hasAiModels(source: string): boolean {
316
692
  */
317
693
  export function ensureSummarizeNotePrompt(source: string): string {
318
694
  let next = source;
319
- if (
320
- !/\bai\.model\s*\(\s*["']local["']/.test(next) &&
321
- /\bai\.model\s*\(\s*["']smart["']/.test(next)
322
- ) {
695
+ const hasSmartExport = /\bexport\s+const\s+smart\s*=/.test(next);
696
+ const hasLocalExport = /\bexport\s+const\s+local\s*=/.test(next);
697
+ if (!hasLocalExport && hasSmartExport) {
323
698
  next = `${next.trimEnd()}
324
699
 
325
- /** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */
700
+ /** Local OpenAI-compatible binding (via \`OKE_AI_URL\`). */
326
701
  export const local = ai.model("local", {
327
702
  provider: "openai-compatible",
328
- model: process.env.OKE_AI_LOCAL_MODEL ?? "granite3.3:2b",
703
+ model: process.env.OKE_AI_LOCAL_MODEL ?? "local-model",
329
704
  ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),
330
705
  });
331
706
  `;
332
707
  }
333
- if (/summarize-note/.test(next) || /summarizeNote/.test(next)) {
334
- return next;
708
+ const hasSummarizeExport =
709
+ /\bexport\s+const\s+summarizeNote\s*=/.test(next) ||
710
+ /\.prompt\s*\(\s*["']summarize-note["']/.test(next);
711
+ if (hasSummarizeExport) {
712
+ return next === source ? next : ensureNamedOkengineImport(next, "ai");
335
713
  }
336
- if (!/\bai\.model\s*\(\s*["']smart["']/.test(next)) {
714
+ if (!hasSmartExport) {
337
715
  return next;
338
716
  }
339
717
  const prompt = `
@@ -343,7 +721,7 @@ export const summarizeNote = smart.prompt("summarize-note", {
343
721
  timeout: "30s",
344
722
  });
345
723
  `;
346
- return `${next.trimEnd()}\n${prompt}\n`;
724
+ return ensureNamedOkengineImport(`${next.trimEnd()}\n${prompt}\n`, "ai");
347
725
  }
348
726
 
349
727
  /**