yamlite 0.36.0 → 0.37.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.
@@ -1,7 +1,7 @@
1
1
  import { basename, delimiter, dirname, extname, isAbsolute, join, matchesGlob, normalize, posix, relative, resolve, sep } from "node:path";
2
2
  import { accessSync, chmodSync, constants, cpSync, createReadStream, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { Document, isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
4
+ import { Document, Scalar, isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
5
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
6
  import { DatabaseSync, constants as constants$1 } from "node:sqlite";
7
7
  import { watch } from "chokidar";
@@ -1326,7 +1326,8 @@ function readRootConfig(root, text) {
1326
1326
  const path = configPath(root) ?? (text === void 0 ? null : "yamlite.yaml");
1327
1327
  if (path === null) return {
1328
1328
  tables: [],
1329
- pages: []
1329
+ pages: [],
1330
+ ai: NO_AI
1330
1331
  };
1331
1332
  return parseConfig(text ?? readFileSync(path, "utf8"), basename(path));
1332
1333
  }
@@ -1363,7 +1364,30 @@ function parseConfig(text, name = "yamlite.yaml") {
1363
1364
  })));
1364
1365
  return {
1365
1366
  tables,
1366
- pages: toPages(raw?.pages, (p) => p)
1367
+ pages: toPages(raw?.pages, (p) => p),
1368
+ ai: toAi(raw?.ai)
1369
+ };
1370
+ }
1371
+ const AI_INSTRUCTIONS_MAX = 4e3;
1372
+ const NO_AI = {
1373
+ instructions: null,
1374
+ pullRequests: null
1375
+ };
1376
+ function toAi(raw) {
1377
+ if (raw === void 0 || raw === null) return NO_AI;
1378
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("ai must be a map");
1379
+ const { instructions, pullRequests, ...rest } = raw;
1380
+ const unknown = Object.keys(rest)[0];
1381
+ if (unknown !== void 0) throw new Error(`unknown key "ai.${unknown}"`);
1382
+ const text = (key, v) => {
1383
+ if (v === void 0 || v === null) return null;
1384
+ if (typeof v !== "string") throw new Error(`ai.${key} must be text`);
1385
+ if (v.length > 4e3) throw new Error(`ai.${key} is longer than ${AI_INSTRUCTIONS_MAX} characters`);
1386
+ return v.trim() === "" ? null : v.trim();
1387
+ };
1388
+ return {
1389
+ instructions: text("instructions", instructions),
1390
+ pullRequests: text("pullRequests", pullRequests)
1367
1391
  };
1368
1392
  }
1369
1393
  const placeholder = (t) => ({
@@ -1770,7 +1794,8 @@ function resolveConfig(opts, { requireConfig = true } = {}) {
1770
1794
  pages: checkAccess$1(config.pages.map((p) => ({
1771
1795
  ...p,
1772
1796
  path: expandPath(p.path, root)
1773
- })), tables)
1797
+ })), tables),
1798
+ ai: config.ai
1774
1799
  };
1775
1800
  }
1776
1801
  if (!opts.db) throw new Error("either root or db is required");
@@ -1779,7 +1804,8 @@ function resolveConfig(opts, { requireConfig = true } = {}) {
1779
1804
  db,
1780
1805
  stateDir: opts.stateDir ? expandPath(opts.stateDir, process.cwd()) : join(dirname(db), ".yamlite"),
1781
1806
  tables: checkViewNames(withClaims((opts.tables ?? []).map((t) => toSpec(locateInput(t, process.cwd()), false)), [])),
1782
- pages: []
1807
+ pages: [],
1808
+ ai: NO_AI
1783
1809
  };
1784
1810
  }
1785
1811
  //#endregion
@@ -1978,6 +2004,36 @@ function applySchema(text, target, edit) {
1978
2004
  flowCollectionPadding: padded
1979
2005
  });
1980
2006
  }
2007
+ function applyAiInstructions(text, next) {
2008
+ const doc = parseDocument(text);
2009
+ if (doc.errors.length > 0) throw new Error(`invalid yamlite.yaml: ${doc.errors[0]?.message.split("\n")[0]}`);
2010
+ if (!isMap(doc.contents)) doc.contents = doc.createNode({});
2011
+ const root = doc.contents;
2012
+ const current = root.get("ai", true);
2013
+ let changed = false;
2014
+ for (const key of ["instructions", "pullRequests"]) {
2015
+ const value = next[key];
2016
+ if (((isMap(current) ? current.get(key) : void 0) ?? null) === value) continue;
2017
+ changed = true;
2018
+ if (value === null) {
2019
+ if (isMap(current)) current.delete(key);
2020
+ continue;
2021
+ }
2022
+ const scalar = new Scalar(value);
2023
+ if (value.includes("\n")) scalar.type = Scalar.BLOCK_LITERAL;
2024
+ const ai = blockMap(doc, root, "ai");
2025
+ if (scalar.type === Scalar.BLOCK_LITERAL) ai.flow = false;
2026
+ ai.set(key, scalar);
2027
+ }
2028
+ if (!changed) return null;
2029
+ const ai = root.get("ai", true);
2030
+ if (isMap(ai) && ai.items.length === 0) root.delete("ai");
2031
+ const padded = /[[{] [^\s\]}]/.test(text);
2032
+ return doc.toString({
2033
+ ...stringifyOptions(text),
2034
+ flowCollectionPadding: padded
2035
+ });
2036
+ }
1981
2037
  //#endregion
1982
2038
  //#region src/value.ts
1983
2039
  function toSafe(value) {
@@ -2232,7 +2288,7 @@ function assertRebuildable(store, table) {
2232
2288
  const pointer = new RegExp(`\\bREFERENCES\\s+["\`[]?${table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
2233
2289
  for (const other of store.query("SELECT name, sql FROM sqlite_schema WHERE type = 'table' AND name != ?", table)) if (pointer.test(String(other.sql))) throw refuse(table, `is referenced by table "${String(other.name)}"`);
2234
2290
  }
2235
- const show = (v) => typeof v === "string" ? JSON.stringify(v) : String(v);
2291
+ const show$1 = (v) => typeof v === "string" ? JSON.stringify(v) : String(v);
2236
2292
  function rebuildTable(store, spec, alterations, base, inFiles, forceConvert) {
2237
2293
  const table = spec.name;
2238
2294
  assertRebuildable(store, table);
@@ -2255,7 +2311,7 @@ function rebuildTable(store, spec, alterations, base, inFiles, forceConvert) {
2255
2311
  else {
2256
2312
  next[column] = null;
2257
2313
  if (key !== null) cleared.add(key);
2258
- if (!synced) failures.set(column, [...failures.get(column) ?? [], `${key} (${show(row[column] ?? null)})`]);
2314
+ if (!synced) failures.set(column, [...failures.get(column) ?? [], `${key} (${show$1(row[column] ?? null)})`]);
2259
2315
  }
2260
2316
  }
2261
2317
  converted.push(next);
@@ -4565,6 +4621,57 @@ const agentRoutes = (router, ctx) => {
4565
4621
  router.add("POST", "/api/agent/proposals/:id/discard", ({ params }) => ctx.proposals.discard(params.id));
4566
4622
  };
4567
4623
  //#endregion
4624
+ //#region src/serve/routes/ai.ts
4625
+ function instructionsBody(b) {
4626
+ const text = (key) => {
4627
+ const v = b[key];
4628
+ if (v === void 0 || v === null) return null;
4629
+ if (typeof v !== "string") throw new HttpError(400, `${key} must be text`, { field: key });
4630
+ return v.trim() === "" ? null : v.trim();
4631
+ };
4632
+ return {
4633
+ instructions: text("instructions"),
4634
+ pullRequests: text("pullRequests")
4635
+ };
4636
+ }
4637
+ const aiRoutes = (router, ctx) => {
4638
+ router.add("GET", "/api/ai/instructions", () => {
4639
+ const text = readFileSync(ctx.configFile, "utf8");
4640
+ let ai;
4641
+ try {
4642
+ ai = parseConfig(text).ai;
4643
+ } catch (e) {
4644
+ throw new HttpError(409, e instanceof Error ? e.message : String(e));
4645
+ }
4646
+ return {
4647
+ ...ai,
4648
+ base: configHash(text)
4649
+ };
4650
+ });
4651
+ router.add("PUT", "/api/ai/instructions", ({ body }) => {
4652
+ ctx.proposals.assertIdle();
4653
+ const b = objectBody(body, "body");
4654
+ if (typeof b.base !== "string") throw new HttpError(400, "base is required", { field: "base" });
4655
+ const next = instructionsBody(b);
4656
+ const text = readFileSync(ctx.configFile, "utf8");
4657
+ if (configHash(text) !== b.base) throw new HttpError(409, "yamlite.yaml changed since editing started", { current: configHash(text) });
4658
+ const written = applyAiInstructions(text, next);
4659
+ if (written !== null) {
4660
+ try {
4661
+ parseConfig(written);
4662
+ } catch (e) {
4663
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
4664
+ }
4665
+ writeAtomic(ctx.configFile, written);
4666
+ ctx.reloadConfig();
4667
+ }
4668
+ return {
4669
+ ...next,
4670
+ base: configHash(written ?? text)
4671
+ };
4672
+ });
4673
+ };
4674
+ //#endregion
4568
4675
  //#region src/serve/conflicts.ts
4569
4676
  const conflictsDir = (stateDir) => join(stateDir, "conflicts");
4570
4677
  function entryFor(table, file, text, mtime) {
@@ -5632,6 +5739,18 @@ async function findOpenPr(repo, branch, gh) {
5632
5739
  return null;
5633
5740
  }
5634
5741
  }
5742
+ const TEMPLATE_DIRS = [
5743
+ ".github/",
5744
+ "",
5745
+ "docs/"
5746
+ ];
5747
+ function templatePath(paths) {
5748
+ for (const dir of TEMPLATE_DIRS) {
5749
+ const found = paths.find((p) => p.toLowerCase() === `${dir}pull_request_template.md`);
5750
+ if (found !== void 0) return found;
5751
+ }
5752
+ return null;
5753
+ }
5635
5754
  //#endregion
5636
5755
  //#region src/git/steps.ts
5637
5756
  const STEP_KINDS = [
@@ -6109,6 +6228,27 @@ async function localDriver(root, gh) {
6109
6228
  },
6110
6229
  history: (t, extract, o) => recordHistory(t, extract, o),
6111
6230
  fileDiff: (t, rev) => fileDiff(t, rev),
6231
+ async openPr() {
6232
+ const f = await lookup();
6233
+ if (!f) return null;
6234
+ const branch = await currentBranch(f.repo);
6235
+ if (branch === null || branch === await defaultName()) return null;
6236
+ return findOpenPr(f.repo, branch, await gh());
6237
+ },
6238
+ async pullRequestTemplate() {
6239
+ const f = await lookup();
6240
+ if (!f) return null;
6241
+ const path = templatePath((await runGit(f.repo.top, [
6242
+ "ls-tree",
6243
+ "--name-only",
6244
+ "HEAD",
6245
+ "--",
6246
+ ".",
6247
+ ".github/",
6248
+ "docs/"
6249
+ ]).catch(() => "")).split("\n"));
6250
+ return path === null ? null : runGit(f.repo.top, ["show", `HEAD:${path}`]).catch(() => null);
6251
+ },
6112
6252
  async review(req, o) {
6113
6253
  if (req.target !== null) throw new ReviewRefused(400, "this server cannot add to an existing pull request");
6114
6254
  const { repo, steps } = await need();
@@ -6159,6 +6299,78 @@ async function blobAt(repo, rev, rootPath) {
6159
6299
  ], { timeoutMs: SLOW_MS });
6160
6300
  }
6161
6301
  //#endregion
6302
+ //#region src/agent/reviewdraft.ts
6303
+ const VALUE_CHARS = 1e3;
6304
+ const PROMPT_CHARS = 24e3;
6305
+ const MAX_OUTPUT_TOKENS = 1e3;
6306
+ const clip = (s, n) => s.length > n ? `${s.slice(0, n)}… (${s.length - n} more characters)` : s;
6307
+ const show = (v) => clip(typeof v === "string" ? v : JSON.stringify(v), VALUE_CHARS);
6308
+ function describeRecord(r) {
6309
+ if (r.kind === "added") return ` + ${r.key}: ${show(r.after)}`;
6310
+ if (r.kind === "deleted") return ` - ${r.key}: ${show(r.before)}`;
6311
+ if (r.fields.length === 0) return ` ~ ${r.key}: only formatting or comments`;
6312
+ const fields = r.fields.map((f) => ` ${f}: ${show(r.before?.[f] ?? null)} → ${show(r.after?.[f] ?? null)}`);
6313
+ return [` ~ ${r.key}`, ...fields].join("\n");
6314
+ }
6315
+ function describeFile(f) {
6316
+ const head = `${f.path} (${f.status}${f.table ? `, table ${f.table}` : ""})`;
6317
+ if (f.records) return [head, ...f.records.map(describeRecord)].join("\n");
6318
+ const parts = [head];
6319
+ if (f.before) parts.push(` before:\n${clip(f.before, VALUE_CHARS * 2)}`);
6320
+ if (f.after) parts.push(` after:\n${clip(f.after, VALUE_CHARS * 2)}`);
6321
+ return parts.join("\n");
6322
+ }
6323
+ function draftPrompt(input) {
6324
+ const pr = input.mode === "pr";
6325
+ const what = pr ? "a pull request title (one line, also used as the commit message) and a short description" : "a one-line commit message";
6326
+ const template = pr && input.template?.trim() ? input.template.trim() : null;
6327
+ return {
6328
+ system: [
6329
+ `You write ${what} for changes to data files in a YAMLite folder (YAML and Markdown records kept in sync with SQLite).`,
6330
+ "The title says what changed in the data, briefly, without file paths or a trailing period.",
6331
+ pr ? "The description says what changed and, when the changes make it clear, why, as a few Markdown bullet points. Do not invent reasons." : "",
6332
+ `Write in the language with this code: ${input.locale}.`,
6333
+ template ? `Fill in the description following this pull request template: keep its headings and structure, replace its comments and placeholders, and leave a section empty when the changes say nothing for it.\n<template>\n${clip(template, VALUE_CHARS * 4)}\n</template>` : "",
6334
+ input.ai.instructions ? `Instructions from the repository, which override the ones above:\n${input.ai.instructions}` : "",
6335
+ pr && input.ai.pullRequests ? `Instructions from the repository for pull requests, which override all of the above:\n${input.ai.pullRequests}` : ""
6336
+ ].filter(Boolean).join("\n\n"),
6337
+ prompt: clip(`Changes:\n${input.files.map(describeFile).join("\n\n")}`, PROMPT_CHARS)
6338
+ };
6339
+ }
6340
+ const SCHEMA = {
6341
+ pr: {
6342
+ type: "object",
6343
+ properties: {
6344
+ title: { type: "string" },
6345
+ body: { type: "string" }
6346
+ },
6347
+ required: ["title", "body"],
6348
+ additionalProperties: false
6349
+ },
6350
+ commit: {
6351
+ type: "object",
6352
+ properties: { title: { type: "string" } },
6353
+ required: ["title"],
6354
+ additionalProperties: false
6355
+ }
6356
+ };
6357
+ async function draftReview(model, input, signal) {
6358
+ const { system, prompt } = draftPrompt(input);
6359
+ const r = await generateText({
6360
+ model,
6361
+ instructions: system,
6362
+ prompt,
6363
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
6364
+ output: Output.object({ schema: jsonSchema(SCHEMA[input.mode]) }),
6365
+ abortSignal: signal
6366
+ });
6367
+ const title = r.output.title.replace(/\s+/g, " ").trim();
6368
+ return input.mode === "pr" ? {
6369
+ title,
6370
+ body: (r.output.body ?? "").trim()
6371
+ } : { title };
6372
+ }
6373
+ //#endregion
6162
6374
  //#region src/serve/gitrecords.ts
6163
6375
  function ownerOf(tables, file) {
6164
6376
  for (const t of tables) {
@@ -6288,6 +6500,204 @@ async function headFile(git, root, spec, key) {
6288
6500
  return null;
6289
6501
  }
6290
6502
  //#endregion
6503
+ //#region src/serve/routes/pages.ts
6504
+ const MAX_PAGE_BYTES = 1048576;
6505
+ function readPageHtml(path, shown) {
6506
+ let size;
6507
+ try {
6508
+ const st = statSync(path);
6509
+ if (!st.isFile()) throw new HttpError(404, `page file is not a file: ${shown}`);
6510
+ size = st.size;
6511
+ } catch (e) {
6512
+ if (e instanceof HttpError) throw e;
6513
+ throw new HttpError(404, `page file not found: ${shown}`);
6514
+ }
6515
+ if (size > MAX_PAGE_BYTES) throw new HttpError(413, `${shown} is larger than 1 MB`);
6516
+ return readFileSync(path, "utf8");
6517
+ }
6518
+ function pageSpec(ctx, name) {
6519
+ const page = ctx.y.pages.find((p) => p.name === name);
6520
+ if (!page) throw new HttpError(404, `unknown page: ${name}`);
6521
+ return page;
6522
+ }
6523
+ const pageRoutes = (router, ctx) => {
6524
+ router.add("GET", "/api/pages/:page/html", ({ params }) => {
6525
+ const page = pageSpec(ctx, params.page);
6526
+ return { html: readPageHtml(page.path, displayPath$1(ctx.root, page.path)) };
6527
+ });
6528
+ };
6529
+ //#endregion
6530
+ //#region src/serve/routes/pageai.ts
6531
+ const isMap$2 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6532
+ function readAsk(body, streaming) {
6533
+ const b = objectBody(body, "body");
6534
+ if (b.apiKey !== void 0) throw new HttpError(400, "pages cannot send API keys");
6535
+ const { system, prompt, schema, model, maxOutputTokens } = b;
6536
+ if (system !== void 0 && typeof system !== "string") throw new HttpError(400, "system must be a string");
6537
+ if (model !== void 0 && typeof model !== "string") throw new HttpError(400, "model must be a string");
6538
+ if (schema !== void 0 && streaming) throw new HttpError(400, "schema is not supported when streaming");
6539
+ if (schema !== void 0 && !isMap$2(schema)) throw new HttpError(400, "schema must be an object");
6540
+ if (maxOutputTokens !== void 0 && !(Number.isInteger(maxOutputTokens) && maxOutputTokens > 0)) throw new HttpError(400, "maxOutputTokens must be a positive integer");
6541
+ return {
6542
+ system,
6543
+ prompt: readPrompt(prompt),
6544
+ schema,
6545
+ model,
6546
+ maxOutputTokens
6547
+ };
6548
+ }
6549
+ function readPrompt(prompt) {
6550
+ if (typeof prompt === "string") {
6551
+ if (prompt.trim() === "") throw new HttpError(400, "prompt is required");
6552
+ return prompt;
6553
+ }
6554
+ const bad = () => new HttpError(400, "prompt must be a string or a list of { role: user | assistant, content: string }");
6555
+ if (!Array.isArray(prompt) || prompt.length === 0) throw bad();
6556
+ return prompt.map((m) => {
6557
+ if (!isMap$2(m) || m.role !== "user" && m.role !== "assistant" || typeof m.content !== "string") throw bad();
6558
+ return {
6559
+ role: m.role,
6560
+ content: m.content
6561
+ };
6562
+ });
6563
+ }
6564
+ function retryAfterSeconds(value) {
6565
+ if (!value) return void 0;
6566
+ const seconds = Number(value);
6567
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
6568
+ const at = Date.parse(value);
6569
+ return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - Date.now()) / 1e3));
6570
+ }
6571
+ function isNoKey(body) {
6572
+ try {
6573
+ const error = JSON.parse(body ?? "").error;
6574
+ return error?.type === "no_key" || error?.code === "no_key";
6575
+ } catch {
6576
+ return false;
6577
+ }
6578
+ }
6579
+ function modelError(e, provider) {
6580
+ const cause = RetryError.isInstance(e) ? e.lastError : e;
6581
+ if (cause instanceof HttpError) return cause;
6582
+ const message = cause instanceof Error ? cause.message : String(cause);
6583
+ if (APICallError.isInstance(cause)) {
6584
+ if (cause.statusCode === 429) {
6585
+ const retryAfter = retryAfterSeconds(cause.responseHeaders?.["retry-after"]);
6586
+ return new HttpError(429, message, {
6587
+ provider,
6588
+ ...retryAfter === void 0 ? {} : { retryAfter }
6589
+ });
6590
+ }
6591
+ if (cause.statusCode === 422 && isNoKey(cause.responseBody)) return new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6592
+ }
6593
+ return new HttpError(502, message, { provider });
6594
+ }
6595
+ const KEEPALIVE_MS = 15e3;
6596
+ const encoder = new TextEncoder();
6597
+ const sseEvent = (event, data) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
6598
+ const errorEvent = (e) => sseEvent("error", {
6599
+ error: e.message,
6600
+ ...e.extra,
6601
+ status: e.status
6602
+ });
6603
+ const pageAiRoutes = (router, ctx) => {
6604
+ const prepare = (name, body, streaming) => {
6605
+ const page = pageSpec(ctx, name);
6606
+ if (!page.ai) throw new HttpError(403, `page ${name} may not use AI`);
6607
+ if (!ctx.agent) throw new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6608
+ const ask = readAsk(body, streaming);
6609
+ const { provider, languageModel } = ctx.agent.pageModel(ask.model);
6610
+ return {
6611
+ ask,
6612
+ provider,
6613
+ release: ctx.agent.pageSlot(),
6614
+ call: {
6615
+ model: languageModel,
6616
+ ...ask.system === void 0 || ask.system.trim() === "" ? {} : { instructions: ask.system },
6617
+ prompt: ask.prompt,
6618
+ maxOutputTokens: Math.min(ask.maxOutputTokens ?? page.ai.maxOutputTokens, page.ai.maxOutputTokens, PAGE_AI_MAX_TOKENS),
6619
+ maxRetries: 0
6620
+ }
6621
+ };
6622
+ };
6623
+ router.add("POST", "/api/pages/:page/ai", async ({ params, body, signal }) => {
6624
+ const { ask, provider, release, call } = prepare(params.page, body, false);
6625
+ try {
6626
+ if (ask.schema) return { object: (await generateText({
6627
+ ...call,
6628
+ output: Output.object({ schema: jsonSchema(ask.schema) }),
6629
+ abortSignal: signal
6630
+ })).output };
6631
+ return { text: (await generateText({
6632
+ ...call,
6633
+ abortSignal: signal
6634
+ })).text };
6635
+ } catch (e) {
6636
+ throw modelError(e, provider);
6637
+ } finally {
6638
+ release();
6639
+ }
6640
+ });
6641
+ router.add("POST", "/api/pages/:page/ai/stream", async ({ params, body, signal }) => {
6642
+ const { provider, release, call } = prepare(params.page, body, true);
6643
+ const stop = new AbortController();
6644
+ let ping;
6645
+ const finish = () => {
6646
+ clearInterval(ping);
6647
+ stop.abort();
6648
+ release();
6649
+ };
6650
+ const gone = () => stop.signal.aborted || signal.aborted;
6651
+ let parts;
6652
+ let head;
6653
+ try {
6654
+ parts = streamText({
6655
+ ...call,
6656
+ abortSignal: AbortSignal.any([signal, stop.signal]),
6657
+ onError: () => {}
6658
+ }).fullStream[Symbol.asyncIterator]();
6659
+ for (;;) {
6660
+ head = await parts.next();
6661
+ if (head.done || head.value.type !== "start") break;
6662
+ }
6663
+ if (!head.done && head.value.type === "error") throw head.value.error;
6664
+ } catch (e) {
6665
+ finish();
6666
+ throw modelError(e, provider);
6667
+ }
6668
+ const stream = new ReadableStream({
6669
+ async start(k) {
6670
+ ping = setInterval(() => k.enqueue(encoder.encode(": ping\n\n")), KEEPALIVE_MS);
6671
+ try {
6672
+ for (let next = head; !next.done; next = await parts.next()) {
6673
+ const p = next.value;
6674
+ if (p.type === "abort") break;
6675
+ if (p.type === "text-delta") k.enqueue(sseEvent("delta", { text: p.text }));
6676
+ if (p.type === "error") {
6677
+ if (!gone()) k.enqueue(errorEvent(modelError(p.error, provider)));
6678
+ return;
6679
+ }
6680
+ }
6681
+ if (!gone()) k.enqueue(sseEvent("done", {}));
6682
+ } catch (e) {
6683
+ if (!gone()) k.enqueue(errorEvent(modelError(e, provider)));
6684
+ } finally {
6685
+ finish();
6686
+ try {
6687
+ k.close();
6688
+ } catch {}
6689
+ }
6690
+ },
6691
+ cancel: finish
6692
+ });
6693
+ return new Response(stream, { headers: {
6694
+ "content-type": "text/event-stream; charset=utf-8",
6695
+ "cache-control": "no-store",
6696
+ connection: "keep-alive"
6697
+ } });
6698
+ });
6699
+ };
6700
+ //#endregion
6291
6701
  //#region src/serve/routes/git.ts
6292
6702
  function reviewBody(body) {
6293
6703
  const b = objectBody(body, "body");
@@ -6412,6 +6822,60 @@ const gitRoutes = (router, ctx) => {
6412
6822
  }
6413
6823
  });
6414
6824
  });
6825
+ router.add("GET", "/api/git/pr", async () => {
6826
+ const git = ctx.git;
6827
+ if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6828
+ return { url: await git.openPr?.().catch(notFound) ?? null };
6829
+ });
6830
+ router.add("POST", "/api/git/review/draft", async ({ body, signal }) => {
6831
+ const git = ctx.git;
6832
+ if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6833
+ if (!ctx.agent) throw new HttpError(404, "no agent is available");
6834
+ const b = objectBody(body, "body");
6835
+ if (!Array.isArray(b.paths) || b.paths.length === 0 || b.paths.some((p) => typeof p !== "string")) throw new HttpError(400, "choose at least one file", { field: "paths" });
6836
+ if (b.mode !== "pr" && b.mode !== "commit") throw new HttpError(400, "mode must be pr or commit");
6837
+ const locale = typeof b.locale === "string" ? b.locale : "en";
6838
+ const { provider, languageModel } = ctx.agent.viewerModel({
6839
+ provider: b.provider,
6840
+ model: b.model,
6841
+ apiKey: b.apiKey
6842
+ });
6843
+ const changed = new Map((await git.status().catch(notFound)).changes.map((c) => [c.path, c.status]));
6844
+ const files = await Promise.all(b.paths.map(async (path) => {
6845
+ const status = changed.get(path);
6846
+ if (status === void 0) throw new HttpError(400, `${path} has no changes`);
6847
+ const found = await recordChanges(git, ctx.root, ctx.y.tables, path);
6848
+ if (found?.records) return {
6849
+ path,
6850
+ status,
6851
+ table: found.table,
6852
+ records: found.records
6853
+ };
6854
+ return {
6855
+ path,
6856
+ status,
6857
+ table: found?.table ?? null,
6858
+ records: null,
6859
+ before: shownText(await git.baseContent(path)),
6860
+ after: shownFile(join(ctx.root, path))
6861
+ };
6862
+ }));
6863
+ const template = b.mode === "pr" ? await git.pullRequestTemplate?.() ?? null : null;
6864
+ const release = ctx.agent.pageSlot();
6865
+ try {
6866
+ return await draftReview(languageModel, {
6867
+ files,
6868
+ mode: b.mode,
6869
+ locale,
6870
+ template,
6871
+ ai: ctx.y.ai
6872
+ }, signal);
6873
+ } catch (e) {
6874
+ throw modelError(e, provider);
6875
+ } finally {
6876
+ release();
6877
+ }
6878
+ });
6415
6879
  router.add("POST", "/api/git/revert", async ({ body }) => {
6416
6880
  const git = ctx.git;
6417
6881
  if (!git) throw new HttpError(404, "the data folder is not in a git repository");
@@ -6488,6 +6952,18 @@ const gitRoutes = (router, ctx) => {
6488
6952
  };
6489
6953
  });
6490
6954
  };
6955
+ const DRAFT_FILE_BYTES = 65536;
6956
+ const OMITTED = "(not shown: a binary file or one larger than 64 KB)";
6957
+ function shownText(text) {
6958
+ if (text === null) return null;
6959
+ return text.length > DRAFT_FILE_BYTES || text.includes("\0") ? OMITTED : text;
6960
+ }
6961
+ function shownFile(file) {
6962
+ if (!existsSync(file)) return null;
6963
+ const s = lstatSync(file);
6964
+ if (!s.isFile()) return s.isSymbolicLink() ? "(a symbolic link)" : null;
6965
+ return s.size > DRAFT_FILE_BYTES ? OMITTED : shownText(readFileSync(file, "utf8"));
6966
+ }
6491
6967
  function notFound(e) {
6492
6968
  if (e instanceof NoRepository) throw new HttpError(404, e.message);
6493
6969
  throw e;
@@ -6631,204 +7107,6 @@ const metaRoutes = (router, ctx) => {
6631
7107
  }));
6632
7108
  };
6633
7109
  //#endregion
6634
- //#region src/serve/routes/pages.ts
6635
- const MAX_PAGE_BYTES = 1048576;
6636
- function readPageHtml(path, shown) {
6637
- let size;
6638
- try {
6639
- const st = statSync(path);
6640
- if (!st.isFile()) throw new HttpError(404, `page file is not a file: ${shown}`);
6641
- size = st.size;
6642
- } catch (e) {
6643
- if (e instanceof HttpError) throw e;
6644
- throw new HttpError(404, `page file not found: ${shown}`);
6645
- }
6646
- if (size > MAX_PAGE_BYTES) throw new HttpError(413, `${shown} is larger than 1 MB`);
6647
- return readFileSync(path, "utf8");
6648
- }
6649
- function pageSpec(ctx, name) {
6650
- const page = ctx.y.pages.find((p) => p.name === name);
6651
- if (!page) throw new HttpError(404, `unknown page: ${name}`);
6652
- return page;
6653
- }
6654
- const pageRoutes = (router, ctx) => {
6655
- router.add("GET", "/api/pages/:page/html", ({ params }) => {
6656
- const page = pageSpec(ctx, params.page);
6657
- return { html: readPageHtml(page.path, displayPath$1(ctx.root, page.path)) };
6658
- });
6659
- };
6660
- //#endregion
6661
- //#region src/serve/routes/pageai.ts
6662
- const isMap$2 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6663
- function readAsk(body, streaming) {
6664
- const b = objectBody(body, "body");
6665
- if (b.apiKey !== void 0) throw new HttpError(400, "pages cannot send API keys");
6666
- const { system, prompt, schema, model, maxOutputTokens } = b;
6667
- if (system !== void 0 && typeof system !== "string") throw new HttpError(400, "system must be a string");
6668
- if (model !== void 0 && typeof model !== "string") throw new HttpError(400, "model must be a string");
6669
- if (schema !== void 0 && streaming) throw new HttpError(400, "schema is not supported when streaming");
6670
- if (schema !== void 0 && !isMap$2(schema)) throw new HttpError(400, "schema must be an object");
6671
- if (maxOutputTokens !== void 0 && !(Number.isInteger(maxOutputTokens) && maxOutputTokens > 0)) throw new HttpError(400, "maxOutputTokens must be a positive integer");
6672
- return {
6673
- system,
6674
- prompt: readPrompt(prompt),
6675
- schema,
6676
- model,
6677
- maxOutputTokens
6678
- };
6679
- }
6680
- function readPrompt(prompt) {
6681
- if (typeof prompt === "string") {
6682
- if (prompt.trim() === "") throw new HttpError(400, "prompt is required");
6683
- return prompt;
6684
- }
6685
- const bad = () => new HttpError(400, "prompt must be a string or a list of { role: user | assistant, content: string }");
6686
- if (!Array.isArray(prompt) || prompt.length === 0) throw bad();
6687
- return prompt.map((m) => {
6688
- if (!isMap$2(m) || m.role !== "user" && m.role !== "assistant" || typeof m.content !== "string") throw bad();
6689
- return {
6690
- role: m.role,
6691
- content: m.content
6692
- };
6693
- });
6694
- }
6695
- function retryAfterSeconds(value) {
6696
- if (!value) return void 0;
6697
- const seconds = Number(value);
6698
- if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
6699
- const at = Date.parse(value);
6700
- return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - Date.now()) / 1e3));
6701
- }
6702
- function isNoKey(body) {
6703
- try {
6704
- const error = JSON.parse(body ?? "").error;
6705
- return error?.type === "no_key" || error?.code === "no_key";
6706
- } catch {
6707
- return false;
6708
- }
6709
- }
6710
- function modelError(e, provider) {
6711
- const cause = RetryError.isInstance(e) ? e.lastError : e;
6712
- if (cause instanceof HttpError) return cause;
6713
- const message = cause instanceof Error ? cause.message : String(cause);
6714
- if (APICallError.isInstance(cause)) {
6715
- if (cause.statusCode === 429) {
6716
- const retryAfter = retryAfterSeconds(cause.responseHeaders?.["retry-after"]);
6717
- return new HttpError(429, message, {
6718
- provider,
6719
- ...retryAfter === void 0 ? {} : { retryAfter }
6720
- });
6721
- }
6722
- if (cause.statusCode === 422 && isNoKey(cause.responseBody)) return new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6723
- }
6724
- return new HttpError(502, message, { provider });
6725
- }
6726
- const KEEPALIVE_MS = 15e3;
6727
- const encoder = new TextEncoder();
6728
- const sseEvent = (event, data) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
6729
- const errorEvent = (e) => sseEvent("error", {
6730
- error: e.message,
6731
- ...e.extra,
6732
- status: e.status
6733
- });
6734
- const pageAiRoutes = (router, ctx) => {
6735
- const prepare = (name, body, streaming) => {
6736
- const page = pageSpec(ctx, name);
6737
- if (!page.ai) throw new HttpError(403, `page ${name} may not use AI`);
6738
- if (!ctx.agent) throw new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6739
- const ask = readAsk(body, streaming);
6740
- const { provider, languageModel } = ctx.agent.pageModel(ask.model);
6741
- return {
6742
- ask,
6743
- provider,
6744
- release: ctx.agent.pageSlot(),
6745
- call: {
6746
- model: languageModel,
6747
- ...ask.system === void 0 || ask.system.trim() === "" ? {} : { instructions: ask.system },
6748
- prompt: ask.prompt,
6749
- maxOutputTokens: Math.min(ask.maxOutputTokens ?? page.ai.maxOutputTokens, page.ai.maxOutputTokens, PAGE_AI_MAX_TOKENS),
6750
- maxRetries: 0
6751
- }
6752
- };
6753
- };
6754
- router.add("POST", "/api/pages/:page/ai", async ({ params, body, signal }) => {
6755
- const { ask, provider, release, call } = prepare(params.page, body, false);
6756
- try {
6757
- if (ask.schema) return { object: (await generateText({
6758
- ...call,
6759
- output: Output.object({ schema: jsonSchema(ask.schema) }),
6760
- abortSignal: signal
6761
- })).output };
6762
- return { text: (await generateText({
6763
- ...call,
6764
- abortSignal: signal
6765
- })).text };
6766
- } catch (e) {
6767
- throw modelError(e, provider);
6768
- } finally {
6769
- release();
6770
- }
6771
- });
6772
- router.add("POST", "/api/pages/:page/ai/stream", async ({ params, body, signal }) => {
6773
- const { provider, release, call } = prepare(params.page, body, true);
6774
- const stop = new AbortController();
6775
- let ping;
6776
- const finish = () => {
6777
- clearInterval(ping);
6778
- stop.abort();
6779
- release();
6780
- };
6781
- const gone = () => stop.signal.aborted || signal.aborted;
6782
- let parts;
6783
- let head;
6784
- try {
6785
- parts = streamText({
6786
- ...call,
6787
- abortSignal: AbortSignal.any([signal, stop.signal]),
6788
- onError: () => {}
6789
- }).fullStream[Symbol.asyncIterator]();
6790
- for (;;) {
6791
- head = await parts.next();
6792
- if (head.done || head.value.type !== "start") break;
6793
- }
6794
- if (!head.done && head.value.type === "error") throw head.value.error;
6795
- } catch (e) {
6796
- finish();
6797
- throw modelError(e, provider);
6798
- }
6799
- const stream = new ReadableStream({
6800
- async start(k) {
6801
- ping = setInterval(() => k.enqueue(encoder.encode(": ping\n\n")), KEEPALIVE_MS);
6802
- try {
6803
- for (let next = head; !next.done; next = await parts.next()) {
6804
- const p = next.value;
6805
- if (p.type === "abort") break;
6806
- if (p.type === "text-delta") k.enqueue(sseEvent("delta", { text: p.text }));
6807
- if (p.type === "error") {
6808
- if (!gone()) k.enqueue(errorEvent(modelError(p.error, provider)));
6809
- return;
6810
- }
6811
- }
6812
- if (!gone()) k.enqueue(sseEvent("done", {}));
6813
- } catch (e) {
6814
- if (!gone()) k.enqueue(errorEvent(modelError(e, provider)));
6815
- } finally {
6816
- finish();
6817
- try {
6818
- k.close();
6819
- } catch {}
6820
- }
6821
- },
6822
- cancel: finish
6823
- });
6824
- return new Response(stream, { headers: {
6825
- "content-type": "text/event-stream; charset=utf-8",
6826
- "cache-control": "no-store",
6827
- connection: "keep-alive"
6828
- } });
6829
- });
6830
- };
6831
- //#endregion
6832
7110
  //#region src/serve/routes/rules.ts
6833
7111
  const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6834
7112
  const scalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
@@ -7256,6 +7534,7 @@ const ROUTES = [
7256
7534
  pageAiRoutes,
7257
7535
  conflictRoutes,
7258
7536
  agentRoutes,
7537
+ aiRoutes,
7259
7538
  gitRoutes
7260
7539
  ];
7261
7540
  //#endregion
@@ -8067,6 +8346,9 @@ async function open(options) {
8067
8346
  get pages() {
8068
8347
  return config.pages;
8069
8348
  },
8349
+ get ai() {
8350
+ return config.ai;
8351
+ },
8070
8352
  async status(opts = {}) {
8071
8353
  if (closed) throw new Error("YAMLite is closed");
8072
8354
  const dbTime = store.lastWriteMs();
@@ -8097,6 +8379,7 @@ async function open(options) {
8097
8379
  const next = resolveConfig(options);
8098
8380
  config.tables = next.tables;
8099
8381
  config.pages = next.pages;
8382
+ config.ai = next.ai;
8100
8383
  registerTables();
8101
8384
  return config.tables;
8102
8385
  }
@@ -8118,4 +8401,4 @@ async function open(options) {
8118
8401
  };
8119
8402
  }
8120
8403
  //#endregion
8121
- export { readRecord as A, configPath as B, fieldChanges as C, statementCount as D, isRead as E, HttpError as F, Store as G, readsFile as H, isLoopback as I, COLUMN_TYPES as K, loopbackHosts as L, toWire as M, Router as N, EventHub as O, createHandler as P, check as R, describeChange as S, PageSql as T, resolveConfig as U, parseConfig as V, declaredViews as W, gitBranches as _, query as a, gitStatus as b, exportSite as c, validateNewTable as d, ReviewRefused as f, ghReady as g, DIFF_LIMIT as h, formatTable as i, assertSynced as j, ProposalStore as k, ROUTES as l, STEP_KINDS as m, formatCsv as n, generateConfig as o, localDriver as p, formatJson as r, init as s, open as t, createTable as u, gitDiff as v, worthShowing as w, which as x, gitLog as y, CONFIG_NAMES as z };
8404
+ export { ProposalStore as A, CONFIG_NAMES as B, describeChange as C, isRead as D, PageSql as E, createHandler as F, declaredViews as G, parseConfig as H, HttpError as I, Store as K, isLoopback as L, assertSynced as M, toWire as N, statementCount as O, Router as P, loopbackHosts as R, which as S, worthShowing as T, readsFile as U, configPath as V, resolveConfig as W, ghReady as _, query as a, gitLog as b, exportSite as c, validateNewTable as d, ReviewRefused as f, DIFF_LIMIT as g, templatePath as h, formatTable as i, readRecord as j, EventHub as k, ROUTES as l, STEP_KINDS as m, formatCsv as n, generateConfig as o, localDriver as p, COLUMN_TYPES as q, formatJson as r, init as s, open as t, createTable as u, gitBranches as v, fieldChanges as w, gitStatus as x, gitDiff as y, check as z };