create-oke 0.11.0 → 0.11.2
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/package.json +1 -1
- package/src/ai-setup/apply.ts +61 -1
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +45 -10
- package/src/customize-flow.test.ts +14 -7
- package/templates/advanced/src/db/seed/index.ts +1 -1
- package/templates/advanced/src/flows/notes/index.ts +59 -12
- package/templates/advanced/src/flows/notes/shapes.ts +7 -1
- package/templates/advanced/tests/advanced.test.ts +3 -2
package/package.json
CHANGED
package/src/ai-setup/apply.ts
CHANGED
|
@@ -192,9 +192,27 @@ export function renderAiTs(input: AiSetupApplyInput): string {
|
|
|
192
192
|
const lines = [
|
|
193
193
|
`import { ai } from "okengine";`,
|
|
194
194
|
``,
|
|
195
|
+
`/** Cloud OpenAI-compatible binding (OpenAI / Groq / OpenRouter / …). */`,
|
|
195
196
|
`export const smart = ai.model("smart", {`,
|
|
196
197
|
` provider: "${provider}",`,
|
|
197
|
-
` model: process.env.OKE_AI_MODEL ?? "${chat}",`,
|
|
198
|
+
` model: process.env.OKE_AI_CLOUD_MODEL ?? process.env.OKE_AI_MODEL ?? "${chat}",`,
|
|
199
|
+
` baseUrl: process.env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/v1",`,
|
|
200
|
+
` ...(process.env.OPENAI_API_KEY?.trim()`,
|
|
201
|
+
` ? { apiKey: process.env.OPENAI_API_KEY.trim() }`,
|
|
202
|
+
` : {}),`,
|
|
203
|
+
`});`,
|
|
204
|
+
``,
|
|
205
|
+
`/** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */`,
|
|
206
|
+
`export const local = ai.model("local", {`,
|
|
207
|
+
` provider: "${provider === "ollama" ? "ollama" : "openai-compatible"}",`,
|
|
208
|
+
` model: process.env.OKE_AI_LOCAL_MODEL ?? "${chat}",`,
|
|
209
|
+
` ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),`,
|
|
210
|
+
`});`,
|
|
211
|
+
``,
|
|
212
|
+
`/** Advanced Notes summarize — used by \`notes.summarize\` via \`fx.ask\`. */`,
|
|
213
|
+
`export const summarizeNote = smart.prompt("summarize-note", {`,
|
|
214
|
+
` via: ["smart", "local"],`,
|
|
215
|
+
` timeout: "30s",`,
|
|
198
216
|
`});`,
|
|
199
217
|
];
|
|
200
218
|
|
|
@@ -249,6 +267,10 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
|
|
|
249
267
|
if (existsSync(coreTsPath)) {
|
|
250
268
|
const existing = readFileSync(coreTsPath, "utf8");
|
|
251
269
|
if (hasAiModels(existing)) {
|
|
270
|
+
const withPrompt = ensureSummarizeNotePrompt(existing);
|
|
271
|
+
if (withPrompt !== existing) {
|
|
272
|
+
writeFileSync(coreTsPath, withPrompt, "utf8");
|
|
273
|
+
}
|
|
252
274
|
return coreTsPath;
|
|
253
275
|
}
|
|
254
276
|
writeFileSync(coreTsPath, mergeAiIntoCore(existing, rendered), "utf8");
|
|
@@ -270,6 +292,44 @@ function hasAiModels(source: string): boolean {
|
|
|
270
292
|
);
|
|
271
293
|
}
|
|
272
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Append the advanced Notes `summarize-note` prompt when a `smart` model
|
|
297
|
+
* exists but the prompt was never declared (common after older `--ai` runs).
|
|
298
|
+
*
|
|
299
|
+
* @param source - Existing `src/core.ts` (or AI sidecar) source
|
|
300
|
+
*/
|
|
301
|
+
export function ensureSummarizeNotePrompt(source: string): string {
|
|
302
|
+
let next = source;
|
|
303
|
+
if (
|
|
304
|
+
!/\bai\.model\s*\(\s*["']local["']/.test(next) &&
|
|
305
|
+
/\bai\.model\s*\(\s*["']smart["']/.test(next)
|
|
306
|
+
) {
|
|
307
|
+
next = `${next.trimEnd()}
|
|
308
|
+
|
|
309
|
+
/** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */
|
|
310
|
+
export const local = ai.model("local", {
|
|
311
|
+
provider: "openai-compatible",
|
|
312
|
+
model: process.env.OKE_AI_LOCAL_MODEL ?? "granite3.3:2b",
|
|
313
|
+
...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),
|
|
314
|
+
});
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
if (/summarize-note/.test(next) || /summarizeNote/.test(next)) {
|
|
318
|
+
return next;
|
|
319
|
+
}
|
|
320
|
+
if (!/\bai\.model\s*\(\s*["']smart["']/.test(next)) {
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
const prompt = `
|
|
324
|
+
/** Advanced Notes summarize — used by \`notes.summarize\` via \`fx.ask\`. */
|
|
325
|
+
export const summarizeNote = smart.prompt("summarize-note", {
|
|
326
|
+
via: ["smart", "local"],
|
|
327
|
+
timeout: "30s",
|
|
328
|
+
});
|
|
329
|
+
`;
|
|
330
|
+
return `${next.trimEnd()}\n${prompt}\n`;
|
|
331
|
+
}
|
|
332
|
+
|
|
273
333
|
/**
|
|
274
334
|
* Merge rendered AI module into an existing `src/core.ts`.
|
|
275
335
|
*
|
package/src/cli.test.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shouldPrompt,
|
|
28
28
|
sourceFromArgs,
|
|
29
29
|
withBackOption,
|
|
30
|
+
withLocalesPgDog,
|
|
30
31
|
type InteractiveAnswers,
|
|
31
32
|
} from "./cli.ts";
|
|
32
33
|
import {
|
|
@@ -46,6 +47,55 @@ import {
|
|
|
46
47
|
transformPackageJson,
|
|
47
48
|
} from "./transform.ts";
|
|
48
49
|
|
|
50
|
+
describe("withLocalesPgDog", () => {
|
|
51
|
+
test("updates session defaults (reuse / customize)", () => {
|
|
52
|
+
const session = recommendedDefaults("docker-ready", "advanced");
|
|
53
|
+
const next = withLocalesPgDog({
|
|
54
|
+
template: "advanced",
|
|
55
|
+
locales: ["ar"],
|
|
56
|
+
pgdog: true,
|
|
57
|
+
session,
|
|
58
|
+
previous: null,
|
|
59
|
+
});
|
|
60
|
+
expect(next.template).toBe("advanced");
|
|
61
|
+
expect(next.locales).toEqual(["ar"]);
|
|
62
|
+
expect(next.pgdog).toBe(true);
|
|
63
|
+
expect(next.drivers.store.sql.dev).toBe(session.drivers.store.sql.dev);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("updates previous settings when recommended has no session", () => {
|
|
67
|
+
const previous = {
|
|
68
|
+
...recommendedDefaults("docker-ready", "advanced"),
|
|
69
|
+
locales: [] as const,
|
|
70
|
+
pgdog: false,
|
|
71
|
+
};
|
|
72
|
+
const next = withLocalesPgDog({
|
|
73
|
+
template: "advanced",
|
|
74
|
+
locales: ["ar", "fr"],
|
|
75
|
+
pgdog: true,
|
|
76
|
+
session: undefined,
|
|
77
|
+
previous,
|
|
78
|
+
});
|
|
79
|
+
expect(next.locales).toEqual(["ar", "fr"]);
|
|
80
|
+
expect(next.pgdog).toBe(true);
|
|
81
|
+
expect(next.drivers).toEqual(previous.drivers);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("falls back to recommended pins when nothing is saved", () => {
|
|
85
|
+
const next = withLocalesPgDog({
|
|
86
|
+
template: "standard",
|
|
87
|
+
locales: ["ar"],
|
|
88
|
+
pgdog: true,
|
|
89
|
+
session: undefined,
|
|
90
|
+
previous: null,
|
|
91
|
+
});
|
|
92
|
+
expect(next.template).toBe("standard");
|
|
93
|
+
expect(next.locales).toEqual(["ar"]);
|
|
94
|
+
expect(next.pgdog).toBe(true);
|
|
95
|
+
expect(next.profile).toBe("docker-ready");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
49
99
|
describe("defaultsBranchOptions", () => {
|
|
50
100
|
test("hides reuse when no previous settings exist", () => {
|
|
51
101
|
const values = defaultsBranchOptions(false).map((o) => o.value);
|
package/src/cli.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./ai-setup/from-pref.ts";
|
|
40
40
|
import { askAiSetup } from "./ai-setup/prompts.ts";
|
|
41
41
|
import { askCustomizeFlow } from "./customize-flow.ts";
|
|
42
|
+
import { recommendedDefaults } from "./drivers-catalog.ts";
|
|
42
43
|
import { parseExtraLocales } from "./locales.ts";
|
|
43
44
|
import {
|
|
44
45
|
scaffold,
|
|
@@ -406,8 +407,9 @@ ${templateLines}
|
|
|
406
407
|
|
|
407
408
|
On a TTY: pick standard|advanced, then recommended defaults, customize
|
|
408
409
|
(Docker-first facets; store.index with none; AI setup Recommended /
|
|
409
|
-
Customize / Off
|
|
410
|
-
|
|
410
|
+
Customize / Off), optional extra locales + PgDog pooling, or reuse when
|
|
411
|
+
saved for that template. Locales + PgDog (and customize pins) write to
|
|
412
|
+
~/.oke/create-defaults.json on every TTY run.
|
|
411
413
|
Non-TTY / --yes stay English-only / no PgDog unless --locales / --pgdog.
|
|
412
414
|
`;
|
|
413
415
|
}
|
|
@@ -528,6 +530,35 @@ export type CreateDefaultsIo = {
|
|
|
528
530
|
readonly write: (defaults: CreateDefaults) => void;
|
|
529
531
|
};
|
|
530
532
|
|
|
533
|
+
/**
|
|
534
|
+
* Merge wizard locales / PgDog into a create-defaults document to persist.
|
|
535
|
+
*
|
|
536
|
+
* Prefer the in-session answers (customize / reuse), else same-template
|
|
537
|
+
* previous settings, else recommended pins for the template.
|
|
538
|
+
*
|
|
539
|
+
* @param input - Template, locales, pgdog, and optional session / previous docs
|
|
540
|
+
*/
|
|
541
|
+
export function withLocalesPgDog(input: {
|
|
542
|
+
readonly template: TemplateId;
|
|
543
|
+
readonly locales: readonly string[];
|
|
544
|
+
readonly pgdog: boolean;
|
|
545
|
+
readonly session: CreateDefaults | undefined;
|
|
546
|
+
readonly previous: CreateDefaults | null;
|
|
547
|
+
}): CreateDefaults {
|
|
548
|
+
const { template, locales, pgdog, session, previous } = input;
|
|
549
|
+
const base =
|
|
550
|
+
session ??
|
|
551
|
+
(previous?.template === template ? previous : null) ??
|
|
552
|
+
recommendedDefaults("docker-ready", template);
|
|
553
|
+
return {
|
|
554
|
+
...base,
|
|
555
|
+
template,
|
|
556
|
+
locales,
|
|
557
|
+
pgdog,
|
|
558
|
+
updatedAt: new Date().toISOString(),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
531
562
|
/**
|
|
532
563
|
* Collect interactive answers via `@clack/prompts`.
|
|
533
564
|
*
|
|
@@ -664,16 +695,20 @@ export async function askInteractiveAnswers(
|
|
|
664
695
|
}
|
|
665
696
|
}
|
|
666
697
|
|
|
698
|
+
// Locales / PgDog are asked on every TTY run — always persist them so reuse
|
|
699
|
+
// (and recommended) keep the last answers in ~/.oke/create-defaults.json.
|
|
700
|
+
const persisted = withLocalesPgDog({
|
|
701
|
+
template,
|
|
702
|
+
locales,
|
|
703
|
+
pgdog,
|
|
704
|
+
session: createDefaults,
|
|
705
|
+
previous: io.read(),
|
|
706
|
+
});
|
|
667
707
|
if (createDefaults) {
|
|
668
|
-
createDefaults =
|
|
669
|
-
...createDefaults,
|
|
670
|
-
locales,
|
|
671
|
-
pgdog,
|
|
672
|
-
updatedAt: new Date().toISOString(),
|
|
673
|
-
};
|
|
708
|
+
createDefaults = persisted;
|
|
674
709
|
}
|
|
675
|
-
|
|
676
|
-
|
|
710
|
+
io.write(persisted);
|
|
711
|
+
if (persistDefaults) {
|
|
677
712
|
note(`Saved globally for next projects → ${io.path}`, "Defaults");
|
|
678
713
|
}
|
|
679
714
|
|
|
@@ -48,13 +48,20 @@ describe("catalog labels", () => {
|
|
|
48
48
|
|
|
49
49
|
describe("recommendedAiApply", () => {
|
|
50
50
|
test("returns llama.cpp (openai-compatible) with curated ai/ model", () => {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const prev = process.env.OKE_AI_URL;
|
|
52
|
+
delete process.env.OKE_AI_URL;
|
|
53
|
+
try {
|
|
54
|
+
const apply = recommendedAiApply();
|
|
55
|
+
expect(apply.driver).toBe("openai-compatible");
|
|
56
|
+
expect(apply.chatModel).toBe("granite3.3:2b");
|
|
57
|
+
expect(apply.baseUrl).toContain("8080");
|
|
58
|
+
expect(apply.image).toContain("llama.cpp");
|
|
59
|
+
expect(apply.image).not.toContain("latest");
|
|
60
|
+
expect(apply.visionModel).toBeNull();
|
|
61
|
+
} finally {
|
|
62
|
+
if (prev !== undefined) process.env.OKE_AI_URL = prev;
|
|
63
|
+
else delete process.env.OKE_AI_URL;
|
|
64
|
+
}
|
|
58
65
|
});
|
|
59
66
|
});
|
|
60
67
|
|
|
@@ -53,7 +53,7 @@ async function sampleNotes(fx: Fx) {
|
|
|
53
53
|
{
|
|
54
54
|
id: "sample-summarize",
|
|
55
55
|
title: "Try summarize",
|
|
56
|
-
body: "POST /notes/:id/summarize uses fx.ask
|
|
56
|
+
body: "POST /notes/:id/summarize uses fx.ask with the prompt's via recovery chain.",
|
|
57
57
|
archivedAt: null,
|
|
58
58
|
createdAt: 4,
|
|
59
59
|
},
|
|
@@ -14,12 +14,54 @@ import {
|
|
|
14
14
|
NoteSummarizeIn,
|
|
15
15
|
NoteSummarizeOut,
|
|
16
16
|
NotFound,
|
|
17
|
+
Unavailable,
|
|
17
18
|
} from "./shapes";
|
|
18
19
|
import { noteCreated } from "./signals";
|
|
19
20
|
|
|
20
21
|
import "./shapes";
|
|
21
22
|
import "./signals";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Pull a usable summary string from an `fx.ask` payload.
|
|
26
|
+
*
|
|
27
|
+
* @param out - Model output object
|
|
28
|
+
*/
|
|
29
|
+
function extractSummary(out: unknown): string {
|
|
30
|
+
if (typeof out === "string") return unwrapSummaryText(out);
|
|
31
|
+
if (!out || typeof out !== "object") return "";
|
|
32
|
+
const record = out as Record<string, unknown>;
|
|
33
|
+
if (typeof record.summary === "string") return record.summary.trim();
|
|
34
|
+
if (typeof record.text === "string") return unwrapSummaryText(record.text);
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Local models sometimes return over-escaped JSON (`{\\"summary\\":...}`).
|
|
40
|
+
* Peel one or two JSON layers, then fall back to the raw text.
|
|
41
|
+
*
|
|
42
|
+
* @param text - Model text payload
|
|
43
|
+
*/
|
|
44
|
+
function unwrapSummaryText(text: string): string {
|
|
45
|
+
let current = text.trim();
|
|
46
|
+
for (let i = 0; i < 2; i++) {
|
|
47
|
+
if (!(current.startsWith("{") || current.startsWith('"'))) break;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(current) as unknown;
|
|
50
|
+
if (typeof parsed === "string") {
|
|
51
|
+
current = parsed.trim();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (parsed && typeof parsed === "object" && "summary" in parsed) {
|
|
55
|
+
return String((parsed as { summary: unknown }).summary).trim();
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
} catch {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
|
|
23
65
|
/** List active (non-archived) notes, newest first. */
|
|
24
66
|
export const list = on(
|
|
25
67
|
http.get("/notes").gate(gate.public),
|
|
@@ -162,32 +204,37 @@ export const digest = on(
|
|
|
162
204
|
);
|
|
163
205
|
|
|
164
206
|
/**
|
|
165
|
-
* Summarize a note via
|
|
166
|
-
*
|
|
207
|
+
* Summarize a note via the prompt's declared recovery chain.
|
|
208
|
+
* Exhausted / failed asks surface as Unavailable — never a body excerpt.
|
|
167
209
|
*/
|
|
168
210
|
export const summarize = on(
|
|
169
211
|
http.post("/notes/:id/summarize").gate(gate.public),
|
|
170
212
|
flow("notes.summarize", {
|
|
171
213
|
in: NoteSummarizeIn,
|
|
172
214
|
out: NoteSummarizeOut,
|
|
173
|
-
errors: { NotFound },
|
|
215
|
+
errors: { NotFound, Unavailable },
|
|
174
216
|
do: async (input, fx) => {
|
|
175
217
|
const row = await fx.store(db).findById(notes, input.id);
|
|
176
218
|
if (!row) return fail("NotFound", { id: input.id });
|
|
177
|
-
const body = String(row.body);
|
|
178
219
|
try {
|
|
179
220
|
const out = await fx.ask("summarize-note", {
|
|
221
|
+
instruction:
|
|
222
|
+
'Summarize this note in one or two sentences. Reply with JSON only: {"summary":"..."}',
|
|
180
223
|
title: String(row.title),
|
|
181
|
-
body,
|
|
224
|
+
body: String(row.body),
|
|
182
225
|
});
|
|
183
|
-
const summary =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
const summary = extractSummary(out);
|
|
227
|
+
const via = typeof out.via === "string" ? out.via.trim() : "";
|
|
228
|
+
if (!summary || !via) {
|
|
229
|
+
return fail("Unavailable", {
|
|
230
|
+
message: "AI service unavailable. Try again later.",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { id: input.id, summary, via };
|
|
188
234
|
} catch {
|
|
189
|
-
|
|
190
|
-
|
|
235
|
+
return fail("Unavailable", {
|
|
236
|
+
message: "AI service unavailable. Try again later.",
|
|
237
|
+
});
|
|
191
238
|
}
|
|
192
239
|
},
|
|
193
240
|
}),
|
|
@@ -48,5 +48,11 @@ export const NoteSummarizeIn = z.object({
|
|
|
48
48
|
export const NoteSummarizeOut = z.object({
|
|
49
49
|
id: z.string(),
|
|
50
50
|
summary: z.string(),
|
|
51
|
-
|
|
51
|
+
/** Logical model name that answered (`smart`, `local`, …). */
|
|
52
|
+
via: z.string().min(1),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Both recovery links failed — no silent text excerpt. */
|
|
56
|
+
export const Unavailable = z.object({
|
|
57
|
+
message: z.string(),
|
|
52
58
|
});
|
|
@@ -18,7 +18,7 @@ test("boots — health flow is named main.health", async () => {
|
|
|
18
18
|
expect(data).toEqual({ ok: true });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("notes create → attach → summarize
|
|
21
|
+
test("notes create → attach → summarize → archive", async () => {
|
|
22
22
|
const created = await t.api.notes!.create!({
|
|
23
23
|
title: "Advanced",
|
|
24
24
|
body: "Body long enough to exercise attach and summarize paths in the advanced starter.",
|
|
@@ -32,10 +32,11 @@ test("notes create → attach → summarize fallback → archive", async () => {
|
|
|
32
32
|
expect(attached.error).toBeNull();
|
|
33
33
|
expect((attached.data as { key: string }).key).toBe(`notes/${id}/attachment.txt`);
|
|
34
34
|
|
|
35
|
+
t.ai.mock("summarize-note", { summary: "Advanced starter summary." });
|
|
35
36
|
const summary = await t.api.notes!.summarize!({ id });
|
|
36
37
|
expect(summary.error).toBeNull();
|
|
37
38
|
const out = summary.data as { via: string; summary: string };
|
|
38
|
-
expect(out.via).
|
|
39
|
+
expect(out.via.length).toBeGreaterThan(0);
|
|
39
40
|
expect(out.summary.length).toBeGreaterThan(0);
|
|
40
41
|
|
|
41
42
|
const archived = await t.api.notes!.archive!({ id });
|