create-oke 0.11.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-oke",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Scaffold an okengine app — bunx create-oke@latest <name>",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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; saved to ~/.oke/create-defaults.json),
410
- optional extra locales + PgDog pooling, or reuse when saved for that template.
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
- if (persistDefaults && createDefaults) {
676
- io.write(createDefaults);
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 apply = recommendedAiApply();
52
- expect(apply.driver).toBe("openai-compatible");
53
- expect(apply.chatModel).toBe("granite3.3:2b");
54
- expect(apply.baseUrl).toContain("8080");
55
- expect(apply.image).toContain("llama.cpp");
56
- expect(apply.image).not.toContain("latest");
57
- expect(apply.visionModel).toBeNull();
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 when AI is configured, else a local excerpt.",
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 AI when configured (`oke ai setup` / create-oke --ai).
166
- * Without an AI runtime, returns a local fallback excerpt.
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
- typeof out === "object" && out !== null && "summary" in out
185
- ? String((out as { summary: unknown }).summary)
186
- : JSON.stringify(out);
187
- return { id: input.id, summary, via: "ai" as const };
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
- const summary = body.length > 160 ? `${body.slice(0, 157)}…` : body;
190
- return { id: input.id, summary, via: "fallback" as const };
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
- via: z.enum(["ai", "fallback"]),
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 fallback → archive", async () => {
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).toBe("fallback");
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 });