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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/agents-md.ts +6 -4
- package/src/ai-setup/apply.test.ts +278 -0
- package/src/ai-setup/apply.ts +430 -52
- package/src/ai-setup/catalog.ts +250 -1343
- package/src/ai-setup/from-pref.ts +3 -71
- package/src/ai-setup/prompts.ts +60 -443
- package/src/cli.test.ts +21 -15
- package/src/cli.ts +20 -11
- package/src/create-defaults.test.ts +4 -4
- package/src/create-defaults.ts +3 -3
- package/src/customize-flow.test.ts +9 -15
- package/src/customize-flow.ts +59 -66
- package/src/drivers-catalog.ts +18 -23
- package/src/transform.test.ts +69 -10
- package/src/transform.ts +10 -35
- package/templates/advanced/.env.example +28 -11
- package/templates/advanced/.github/workflows/ci.yml +1 -1
- package/templates/advanced/oke.config.ts +3 -4
- package/templates/advanced/package.json +11 -10
- package/templates/advanced/src/app.ts +36 -1
- package/templates/advanced/src/core.ts +12 -11
- package/templates/advanced/src/db/schema.decl.ts +6 -2
- package/templates/advanced/src/db/seed/index.ts +4 -4
- package/templates/advanced/src/flows/main/route.ts +3 -2
- package/templates/advanced/src/flows/notes/[id]/archive.ts +5 -8
- package/templates/advanced/src/flows/notes/[id]/attach.ts +1 -4
- package/templates/advanced/src/flows/notes/[id]/get.ts +4 -7
- package/templates/advanced/src/flows/notes/[id]/summarize.ts +3 -4
- package/templates/advanced/src/flows/notes/create.ts +4 -6
- package/templates/advanced/src/flows/notes/digest.ts +6 -5
- package/templates/advanced/src/flows/notes/list.ts +4 -5
- package/templates/advanced/src/flows/notes/shapes.ts +13 -3
- package/templates/advanced/src/flows/notes/signals.ts +1 -2
- package/templates/advanced/src/vault.ts +138 -0
- package/templates/advanced/tests/advanced.test.ts +11 -8
- package/templates/advanced/web/src/App.css +7 -0
- package/templates/advanced/web/src/App.tsx +101 -1
- package/templates/advanced/web/src/client.ts +16 -3
- package/templates/advanced/web/vite.config.ts +1 -0
- package/templates/standard/.env.example +22 -11
- package/templates/standard/.github/workflows/ci.yml +1 -1
- package/templates/standard/oke.config.ts +3 -3
- package/templates/standard/package.json +11 -10
- package/templates/standard/src/app.ts +6 -1
- package/templates/standard/src/core.ts +10 -11
- package/templates/standard/src/db/schema.decl.ts +6 -2
- package/templates/standard/src/db/seed/index.ts +3 -3
- package/templates/standard/src/flows/main/route.ts +3 -2
- package/templates/standard/src/flows/notes/[id]/archive.ts +5 -8
- package/templates/standard/src/flows/notes/[id]/get.ts +4 -7
- package/templates/standard/src/flows/notes/create.ts +4 -6
- package/templates/standard/src/flows/notes/list.ts +4 -5
- package/templates/standard/src/flows/notes/shapes.ts +12 -2
- package/templates/standard/src/flows/notes/signals.ts +1 -2
- package/templates/standard/src/vault.ts +123 -0
- package/templates/standard/tests/standard.test.ts +3 -1
- package/templates/standard/web/src/client.ts +2 -2
- package/templates/standard/web/vite.config.ts +1 -0
- package/src/ai-setup/detect-ollama.ts +0 -228
- package/src/ai-setup/recommend.ts +0 -220
package/src/ai-setup/apply.ts
CHANGED
|
@@ -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: "
|
|
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 (
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
` ? { apiKey: process.env.OPENAI_API_KEY.trim() }`,
|
|
200
|
-
` : {}),`,
|
|
337
|
+
...smartBaseUrlLines(input, { registryCloud, localPrimary }),
|
|
338
|
+
...apiKeySpreadLines(apiKeyEnv),
|
|
201
339
|
`});`,
|
|
202
|
-
|
|
203
|
-
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const localDefaultModel = localPrimary ? chat : "local-model";
|
|
343
|
+
const localLines = [
|
|
204
344
|
`export const local = ai.model("local", {`,
|
|
205
|
-
` provider: "
|
|
206
|
-
` model: process.env.OKE_AI_LOCAL_MODEL ?? "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/\
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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 ?? "
|
|
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
|
-
|
|
334
|
-
|
|
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 (
|
|
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
|
/**
|