yamlite 0.35.1 → 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 = [
@@ -6015,6 +6134,12 @@ async function localDriver(root, gh) {
6015
6134
  if (!f) throw new NoRepository();
6016
6135
  return f;
6017
6136
  };
6137
+ const defaultName = async () => {
6138
+ const f = await lookup();
6139
+ if (!f) return null;
6140
+ base ??= await defaultBranch(f.repo);
6141
+ return base;
6142
+ };
6018
6143
  await lookup();
6019
6144
  return {
6020
6145
  namesBranch: true,
@@ -6053,12 +6178,7 @@ async function localDriver(root, gh) {
6053
6178
  changes
6054
6179
  };
6055
6180
  },
6056
- async defaultBranch() {
6057
- const f = await lookup();
6058
- if (!f) return null;
6059
- base ??= await defaultBranch(f.repo);
6060
- return base;
6061
- },
6181
+ defaultBranch: defaultName,
6062
6182
  async baseContent(rootPath) {
6063
6183
  const { repo } = await need();
6064
6184
  const top = insideRoot(repo, rootPath);
@@ -6070,38 +6190,65 @@ async function localDriver(root, gh) {
6070
6190
  }
6071
6191
  },
6072
6192
  async baseFile(rootPath) {
6073
- const { repo } = await need();
6074
- const top = insideRoot(repo, rootPath);
6075
- if (top === null) throw new GitError(`${rootPath} is outside the repository`);
6076
- let entry;
6077
- try {
6078
- entry = await runGit(repo.top, [
6079
- "ls-tree",
6193
+ return blobAt((await need()).repo, "HEAD", rootPath);
6194
+ },
6195
+ defaultBranchFiles: {
6196
+ async changes() {
6197
+ const f = await lookup();
6198
+ if (!f) return [];
6199
+ const [branch, name] = [await currentBranch(f.repo), await defaultName()];
6200
+ if (branch === null || name === null || branch === name) return [];
6201
+ const parts = (await runGit(f.repo.top, [
6202
+ "diff",
6203
+ "--name-status",
6204
+ "--no-renames",
6080
6205
  "-z",
6081
- "HEAD",
6206
+ `${await defaultRef(f.repo, name)}...HEAD`,
6082
6207
  "--",
6083
- top
6084
- ]);
6085
- } catch (e) {
6086
- if (!await runGit(repo.top, [
6087
- "rev-parse",
6088
- "-q",
6089
- "--verify",
6090
- "HEAD"
6091
- ]).then(() => true, () => false)) return null;
6092
- throw e;
6208
+ f.repo.prefix || "."
6209
+ ])).split("\0");
6210
+ const changes = [];
6211
+ for (let i = 0; i + 1 < parts.length; i += 2) {
6212
+ const path = toRoot(f.repo, parts[i + 1]);
6213
+ if (path === null) continue;
6214
+ const code = parts[i];
6215
+ changes.push({
6216
+ path,
6217
+ status: code === "A" ? "added" : code === "D" ? "deleted" : "modified"
6218
+ });
6219
+ }
6220
+ return changes;
6221
+ },
6222
+ async file(rootPath) {
6223
+ const { repo } = await need();
6224
+ const name = await defaultName();
6225
+ if (name === null) throw new GitError("the default branch is not known");
6226
+ return blobAt(repo, await defaultRef(repo, name), rootPath);
6093
6227
  }
6094
- if (entry === "") return null;
6095
- const type = entry.split(" ")[1];
6096
- if (type !== "blob") throw new GitError(`${rootPath} is a ${type === "commit" ? "submodule" : "folder"}`);
6097
- return runGitBytes(repo.top, [
6098
- "cat-file",
6099
- "--filters",
6100
- `HEAD:${top}`
6101
- ], { timeoutMs: SLOW_MS });
6102
6228
  },
6103
6229
  history: (t, extract, o) => recordHistory(t, extract, o),
6104
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
+ },
6105
6252
  async review(req, o) {
6106
6253
  if (req.target !== null) throw new ReviewRefused(400, "this server cannot add to an existing pull request");
6107
6254
  const { repo, steps } = await need();
@@ -6112,6 +6259,117 @@ async function localDriver(root, gh) {
6112
6259
  }
6113
6260
  };
6114
6261
  }
6262
+ async function defaultRef(repo, name) {
6263
+ for (const ref of [`refs/remotes/origin/${name}`, `refs/heads/${name}`]) if (await runGit(repo.top, [
6264
+ "rev-parse",
6265
+ "-q",
6266
+ "--verify",
6267
+ `${ref}^{commit}`
6268
+ ]).then(() => true, () => false)) return ref;
6269
+ throw new GitError(`the default branch ${name} is not in this clone`);
6270
+ }
6271
+ async function blobAt(repo, rev, rootPath) {
6272
+ const top = insideRoot(repo, rootPath);
6273
+ if (top === null) throw new GitError(`${rootPath} is outside the repository`);
6274
+ let entry;
6275
+ try {
6276
+ entry = await runGit(repo.top, [
6277
+ "ls-tree",
6278
+ "-z",
6279
+ rev,
6280
+ "--",
6281
+ top
6282
+ ]);
6283
+ } catch (e) {
6284
+ if (!await runGit(repo.top, [
6285
+ "rev-parse",
6286
+ "-q",
6287
+ "--verify",
6288
+ rev
6289
+ ]).then(() => true, () => false)) return null;
6290
+ throw e;
6291
+ }
6292
+ if (entry === "") return null;
6293
+ const type = entry.split(" ")[1];
6294
+ if (type !== "blob") throw new GitError(`${rootPath} is a ${type === "commit" ? "submodule" : "folder"}`);
6295
+ return runGitBytes(repo.top, [
6296
+ "cat-file",
6297
+ "--filters",
6298
+ `${rev}:${top}`
6299
+ ], { timeoutMs: SLOW_MS });
6300
+ }
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
+ }
6115
6373
  //#endregion
6116
6374
  //#region src/serve/gitrecords.ts
6117
6375
  function ownerOf(tables, file) {
@@ -6242,121 +6500,418 @@ async function headFile(git, root, spec, key) {
6242
6500
  return null;
6243
6501
  }
6244
6502
  //#endregion
6245
- //#region src/serve/routes/git.ts
6246
- function reviewBody(body) {
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) {
6247
6533
  const b = objectBody(body, "body");
6248
- if (typeof b.title !== "string" || b.title.trim() === "") throw new HttpError(400, "title is required", { field: "title" });
6249
- if (b.body !== void 0 && typeof b.body !== "string") throw new HttpError(400, "body must be text", { field: "body" });
6250
- 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" });
6251
- if (b.branch !== void 0 && typeof b.branch !== "string") throw new HttpError(400, "branch must be text", { field: "branch" });
6252
- const branch = b.branch?.trim();
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");
6253
6541
  return {
6254
- title: b.title.trim(),
6255
- body: b.body ?? "",
6256
- paths: b.paths,
6257
- ...branch ? { branch } : {}
6542
+ system,
6543
+ prompt: readPrompt(prompt),
6544
+ schema,
6545
+ model,
6546
+ maxOutputTokens
6258
6547
  };
6259
6548
  }
6260
- const gitRoutes = (router, ctx) => {
6261
- router.add("GET", "/api/git", async () => {
6262
- const git = ctx.git;
6263
- if (!git || !await git.hasRemote()) return { git: null };
6264
- const status = await git.status();
6265
- const changes = await Promise.all(status.changes.map(async (c) => ({
6266
- path: c.path,
6267
- status: c.status,
6268
- ...summary(await recordChanges(git, ctx.root, ctx.y.tables, c.path))
6269
- })));
6270
- changes.sort((a, b) => a.path.localeCompare(b.path));
6271
- return { git: {
6272
- branch: status.branch,
6273
- defaultBranch: await git.defaultBranch(),
6274
- upstream: status.upstream,
6275
- namesBranch: git.namesBranch === true,
6276
- changes
6277
- } };
6278
- });
6279
- router.add("GET", "/api/git/diff", async ({ query }) => {
6280
- const git = ctx.git;
6281
- if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6282
- const path = query.get("path") ?? "";
6283
- const rel = relative(ctx.root, resolve(ctx.root, path));
6284
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(400, `${path} is outside the data folder`);
6285
- const found = await recordChanges(git, ctx.root, ctx.y.tables, path).catch(notFound);
6286
- if (!found?.records) throw new HttpError(404, `${path} holds no records to compare`);
6287
- return { records: found.records.map((r) => {
6288
- const keyField = ctx.y.tables.find((t) => t.name === found.table)?.key ?? "";
6289
- const before = r.before ? withoutKey(r.before, keyField) : {};
6290
- const after = r.after ? withoutKey(r.after, keyField) : {};
6291
- const fields = [.../* @__PURE__ */ new Set([...Object.keys(after), ...Object.keys(before)])].filter((f) => r.kind !== "modified" || r.fields.includes(f)).map((field) => ({
6292
- field,
6293
- from: wireValue(own$1(before, field) ?? null),
6294
- to: wireValue(own$1(after, field) ?? null)
6295
- }));
6296
- return {
6297
- key: r.key,
6298
- kind: r.kind,
6299
- fields
6300
- };
6301
- }) };
6302
- });
6303
- router.add("POST", "/api/git/review", async ({ body }) => {
6304
- const git = ctx.git;
6305
- if (!git || !await git.hasRemote()) throw new HttpError(404, "the data folder is not in a git repository with an origin");
6306
- const req = reviewBody(body);
6307
- if (req.branch !== void 0 && !git.namesBranch) throw new HttpError(400, "this host names the branch for a review itself", { field: "branch" });
6308
- const target = objectBody(body, "body").target;
6309
- if (target !== void 0 && target !== null && typeof target !== "string") throw new HttpError(400, "target must be a branch name or null", { field: "target" });
6310
- return ctx.proposals.exclusive(async () => {
6311
- assertSynced(await ctx.y.sync(), "could not write the latest edits to files for", "; nothing was sent");
6312
- try {
6313
- return await git.review({
6314
- ...req,
6315
- target: target ?? null
6316
- }, { afterTreeChange: async () => assertSynced(await ctx.y.sync(), "the database could not follow the files for") });
6317
- } catch (e) {
6318
- if (e instanceof ReviewRefused) throw new HttpError(e.status, e.message);
6319
- if (e instanceof NoRepository) throw new HttpError(404, "the data folder is not in a git repository with an origin");
6320
- throw e;
6321
- }
6322
- });
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
+ };
6323
6562
  });
6324
- router.add("POST", "/api/git/revert", async ({ body }) => {
6325
- const git = ctx.git;
6326
- if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6327
- ctx.proposals.assertIdle();
6328
- const b = objectBody(body, "body");
6329
- const items = b.records ?? [];
6330
- const paths = b.files ?? [];
6331
- if (!Array.isArray(items)) throw new HttpError(400, "records must be a list", { field: "records" });
6332
- if (!Array.isArray(paths) || paths.some((p) => typeof p !== "string")) throw new HttpError(400, "files must be a list of paths", { field: "files" });
6333
- if (items.length === 0 && paths.length === 0) throw new HttpError(400, "choose at least one record or file", { field: "records" });
6334
- const changed = new Map(paths.length > 0 ? (await git.status()).changes.map((c) => [c.path, c.status]) : []);
6335
- const files = await Promise.all(paths.map(async (path) => {
6336
- const file = resolve(ctx.root, path);
6337
- const rel = relative(ctx.root, file);
6338
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(400, `${path} is outside the data folder`);
6339
- const status = changed.get(path);
6340
- if (status === void 0) throw new HttpError(400, `${path} has no changes`);
6341
- const content = await git.baseFile(path).catch(notFound);
6342
- if (content === null && status !== "added") throw new HttpError(409, `${path} is not in the last commit under this name; put it back with git`);
6343
- if (content === null && existsSync(file) && lstatSync(file).isDirectory()) throw new HttpError(409, `${path} is a folder; remove it with git`);
6344
- return {
6345
- path,
6346
- file,
6347
- content
6348
- };
6349
- }));
6350
- const plan = await Promise.all(items.map(async (raw) => {
6351
- const item = objectBody(raw, "record");
6352
- const spec = tableSpec(ctx, String(item.table));
6353
- const key = String(item.key);
6354
- const head = await headRecord(git, ctx.root, spec, key).catch(notFound);
6355
- const now = readRecord(ctx.store, spec, key);
6356
- const before = now ? withoutKey(now, spec.key) : null;
6357
- const after = head ? wireValue(head.values) : null;
6358
- if (before === null && after === null) throw new HttpError(400, `${spec.name}/${key} has no changes`);
6359
- if (before !== null && after !== null && canonical(before) === canonical(after)) throw new HttpError(400, `${spec.name}/${key} has no changes`);
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
6701
+ //#region src/serve/routes/git.ts
6702
+ function reviewBody(body) {
6703
+ const b = objectBody(body, "body");
6704
+ if (typeof b.title !== "string" || b.title.trim() === "") throw new HttpError(400, "title is required", { field: "title" });
6705
+ if (b.body !== void 0 && typeof b.body !== "string") throw new HttpError(400, "body must be text", { field: "body" });
6706
+ 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" });
6707
+ if (b.branch !== void 0 && typeof b.branch !== "string") throw new HttpError(400, "branch must be text", { field: "branch" });
6708
+ const branch = b.branch?.trim();
6709
+ return {
6710
+ title: b.title.trim(),
6711
+ body: b.body ?? "",
6712
+ paths: b.paths,
6713
+ ...branch ? { branch } : {}
6714
+ };
6715
+ }
6716
+ const gitRoutes = (router, ctx) => {
6717
+ router.add("GET", "/api/git", async () => {
6718
+ const git = ctx.git;
6719
+ if (!git || !await git.hasRemote()) return { git: null };
6720
+ const status = await git.status();
6721
+ const changes = await Promise.all(status.changes.map(async (c) => ({
6722
+ path: c.path,
6723
+ status: c.status,
6724
+ ...summary(await recordChanges(git, ctx.root, ctx.y.tables, c.path))
6725
+ })));
6726
+ changes.sort((a, b) => a.path.localeCompare(b.path));
6727
+ const defaultBranch = await git.defaultBranch();
6728
+ return { git: {
6729
+ branch: status.branch,
6730
+ defaultBranch,
6731
+ upstream: status.upstream,
6732
+ namesBranch: git.namesBranch === true,
6733
+ restoresDefault: git.defaultBranchFiles !== void 0 && status.branch !== null && defaultBranch !== null && status.branch !== defaultBranch,
6734
+ changes
6735
+ } };
6736
+ });
6737
+ router.add("GET", "/api/git/default", async () => {
6738
+ const from = ctx.git?.defaultBranchFiles;
6739
+ if (!from) throw new HttpError(404, "this host cannot read the default branch");
6740
+ const [changes, status] = await Promise.all([from.changes().catch(notFound), ctx.git.status()]);
6741
+ const unsent = new Set(status.changes.map((c) => c.path));
6742
+ return { files: changes.map((c) => ({
6743
+ path: c.path,
6744
+ status: c.status,
6745
+ table: ownerOf(ctx.y.tables, join(ctx.root, c.path))?.spec.name ?? null,
6746
+ unsent: unsent.has(c.path)
6747
+ })).sort((a, b) => a.path.localeCompare(b.path)) };
6748
+ });
6749
+ router.add("POST", "/api/git/restore-default", async ({ body }) => {
6750
+ const from = ctx.git?.defaultBranchFiles;
6751
+ if (!from) throw new HttpError(404, "this host cannot read the default branch");
6752
+ const paths = objectBody(body, "body").files;
6753
+ if (!Array.isArray(paths) || paths.length === 0 || paths.some((p) => typeof p !== "string")) throw new HttpError(400, "choose at least one file", { field: "files" });
6754
+ return ctx.proposals.exclusive(async () => {
6755
+ const changed = new Set((await from.changes().catch(notFound)).map((c) => c.path));
6756
+ const files = await Promise.all(paths.map(async (path) => {
6757
+ const file = resolve(ctx.root, path);
6758
+ const rel = relative(ctx.root, file);
6759
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(400, `${path} is outside the data folder`);
6760
+ if (!changed.has(path)) throw new HttpError(400, `${path} is the same as on the default branch`);
6761
+ const content = await from.file(path).catch(notFound);
6762
+ if (content === null && existsSync(file) && lstatSync(file).isDirectory()) throw new HttpError(409, `${path} is a folder; remove it with git`);
6763
+ return {
6764
+ path,
6765
+ file,
6766
+ content
6767
+ };
6768
+ }));
6769
+ assertSynced(await ctx.y.sync(), "could not write the latest edits to files for", "; nothing was restored");
6770
+ for (const f of files) if (f.content === null) rmSync(f.file, { force: true });
6771
+ else {
6772
+ mkdirSync(dirname(f.file), { recursive: true });
6773
+ writeAtomic(f.file, f.content);
6774
+ }
6775
+ if (files.some((f) => f.file === resolve(ctx.configFile))) ctx.reloadConfig();
6776
+ assertSynced(await ctx.y.sync({ force: true }), "the database could not follow the files for");
6777
+ return { files: files.map((f) => f.path) };
6778
+ });
6779
+ });
6780
+ router.add("GET", "/api/git/diff", async ({ query }) => {
6781
+ const git = ctx.git;
6782
+ if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6783
+ const path = query.get("path") ?? "";
6784
+ const rel = relative(ctx.root, resolve(ctx.root, path));
6785
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(400, `${path} is outside the data folder`);
6786
+ const found = await recordChanges(git, ctx.root, ctx.y.tables, path).catch(notFound);
6787
+ if (!found?.records) throw new HttpError(404, `${path} holds no records to compare`);
6788
+ return { records: found.records.map((r) => {
6789
+ const keyField = ctx.y.tables.find((t) => t.name === found.table)?.key ?? "";
6790
+ const before = r.before ? withoutKey(r.before, keyField) : {};
6791
+ const after = r.after ? withoutKey(r.after, keyField) : {};
6792
+ const fields = [.../* @__PURE__ */ new Set([...Object.keys(after), ...Object.keys(before)])].filter((f) => r.kind !== "modified" || r.fields.includes(f)).map((field) => ({
6793
+ field,
6794
+ from: wireValue(own$1(before, field) ?? null),
6795
+ to: wireValue(own$1(after, field) ?? null)
6796
+ }));
6797
+ return {
6798
+ key: r.key,
6799
+ kind: r.kind,
6800
+ fields
6801
+ };
6802
+ }) };
6803
+ });
6804
+ router.add("POST", "/api/git/review", async ({ body }) => {
6805
+ const git = ctx.git;
6806
+ if (!git || !await git.hasRemote()) throw new HttpError(404, "the data folder is not in a git repository with an origin");
6807
+ const req = reviewBody(body);
6808
+ if (req.branch !== void 0 && !git.namesBranch) throw new HttpError(400, "this host names the branch for a review itself", { field: "branch" });
6809
+ const target = objectBody(body, "body").target;
6810
+ if (target !== void 0 && target !== null && typeof target !== "string") throw new HttpError(400, "target must be a branch name or null", { field: "target" });
6811
+ return ctx.proposals.exclusive(async () => {
6812
+ assertSynced(await ctx.y.sync(), "could not write the latest edits to files for", "; nothing was sent");
6813
+ try {
6814
+ return await git.review({
6815
+ ...req,
6816
+ target: target ?? null
6817
+ }, { afterTreeChange: async () => assertSynced(await ctx.y.sync(), "the database could not follow the files for") });
6818
+ } catch (e) {
6819
+ if (e instanceof ReviewRefused) throw new HttpError(e.status, e.message);
6820
+ if (e instanceof NoRepository) throw new HttpError(404, "the data folder is not in a git repository with an origin");
6821
+ throw e;
6822
+ }
6823
+ });
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
+ });
6879
+ router.add("POST", "/api/git/revert", async ({ body }) => {
6880
+ const git = ctx.git;
6881
+ if (!git) throw new HttpError(404, "the data folder is not in a git repository");
6882
+ ctx.proposals.assertIdle();
6883
+ const b = objectBody(body, "body");
6884
+ const items = b.records ?? [];
6885
+ const paths = b.files ?? [];
6886
+ if (!Array.isArray(items)) throw new HttpError(400, "records must be a list", { field: "records" });
6887
+ if (!Array.isArray(paths) || paths.some((p) => typeof p !== "string")) throw new HttpError(400, "files must be a list of paths", { field: "files" });
6888
+ if (items.length === 0 && paths.length === 0) throw new HttpError(400, "choose at least one record or file", { field: "records" });
6889
+ const changed = new Map(paths.length > 0 ? (await git.status()).changes.map((c) => [c.path, c.status]) : []);
6890
+ const files = await Promise.all(paths.map(async (path) => {
6891
+ const file = resolve(ctx.root, path);
6892
+ const rel = relative(ctx.root, file);
6893
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(400, `${path} is outside the data folder`);
6894
+ const status = changed.get(path);
6895
+ if (status === void 0) throw new HttpError(400, `${path} has no changes`);
6896
+ const content = await git.baseFile(path).catch(notFound);
6897
+ if (content === null && status !== "added") throw new HttpError(409, `${path} is not in the last commit under this name; put it back with git`);
6898
+ if (content === null && existsSync(file) && lstatSync(file).isDirectory()) throw new HttpError(409, `${path} is a folder; remove it with git`);
6899
+ return {
6900
+ path,
6901
+ file,
6902
+ content
6903
+ };
6904
+ }));
6905
+ const plan = await Promise.all(items.map(async (raw) => {
6906
+ const item = objectBody(raw, "record");
6907
+ const spec = tableSpec(ctx, String(item.table));
6908
+ const key = String(item.key);
6909
+ const head = await headRecord(git, ctx.root, spec, key).catch(notFound);
6910
+ const now = readRecord(ctx.store, spec, key);
6911
+ const before = now ? withoutKey(now, spec.key) : null;
6912
+ const after = head ? wireValue(head.values) : null;
6913
+ if (before === null && after === null) throw new HttpError(400, `${spec.name}/${key} has no changes`);
6914
+ if (before !== null && after !== null && canonical(before) === canonical(after)) throw new HttpError(400, `${spec.name}/${key} has no changes`);
6360
6915
  if (after === null && item.delete !== true) throw new HttpError(400, `${spec.name}/${key} is not committed; send delete: true to remove it`);
6361
6916
  return {
6362
6917
  spec,
@@ -6397,6 +6952,18 @@ const gitRoutes = (router, ctx) => {
6397
6952
  };
6398
6953
  });
6399
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
+ }
6400
6967
  function notFound(e) {
6401
6968
  if (e instanceof NoRepository) throw new HttpError(404, e.message);
6402
6969
  throw e;
@@ -6540,204 +7107,6 @@ const metaRoutes = (router, ctx) => {
6540
7107
  }));
6541
7108
  };
6542
7109
  //#endregion
6543
- //#region src/serve/routes/pages.ts
6544
- const MAX_PAGE_BYTES = 1048576;
6545
- function readPageHtml(path, shown) {
6546
- let size;
6547
- try {
6548
- const st = statSync(path);
6549
- if (!st.isFile()) throw new HttpError(404, `page file is not a file: ${shown}`);
6550
- size = st.size;
6551
- } catch (e) {
6552
- if (e instanceof HttpError) throw e;
6553
- throw new HttpError(404, `page file not found: ${shown}`);
6554
- }
6555
- if (size > MAX_PAGE_BYTES) throw new HttpError(413, `${shown} is larger than 1 MB`);
6556
- return readFileSync(path, "utf8");
6557
- }
6558
- function pageSpec(ctx, name) {
6559
- const page = ctx.y.pages.find((p) => p.name === name);
6560
- if (!page) throw new HttpError(404, `unknown page: ${name}`);
6561
- return page;
6562
- }
6563
- const pageRoutes = (router, ctx) => {
6564
- router.add("GET", "/api/pages/:page/html", ({ params }) => {
6565
- const page = pageSpec(ctx, params.page);
6566
- return { html: readPageHtml(page.path, displayPath$1(ctx.root, page.path)) };
6567
- });
6568
- };
6569
- //#endregion
6570
- //#region src/serve/routes/pageai.ts
6571
- const isMap$2 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6572
- function readAsk(body, streaming) {
6573
- const b = objectBody(body, "body");
6574
- if (b.apiKey !== void 0) throw new HttpError(400, "pages cannot send API keys");
6575
- const { system, prompt, schema, model, maxOutputTokens } = b;
6576
- if (system !== void 0 && typeof system !== "string") throw new HttpError(400, "system must be a string");
6577
- if (model !== void 0 && typeof model !== "string") throw new HttpError(400, "model must be a string");
6578
- if (schema !== void 0 && streaming) throw new HttpError(400, "schema is not supported when streaming");
6579
- if (schema !== void 0 && !isMap$2(schema)) throw new HttpError(400, "schema must be an object");
6580
- if (maxOutputTokens !== void 0 && !(Number.isInteger(maxOutputTokens) && maxOutputTokens > 0)) throw new HttpError(400, "maxOutputTokens must be a positive integer");
6581
- return {
6582
- system,
6583
- prompt: readPrompt(prompt),
6584
- schema,
6585
- model,
6586
- maxOutputTokens
6587
- };
6588
- }
6589
- function readPrompt(prompt) {
6590
- if (typeof prompt === "string") {
6591
- if (prompt.trim() === "") throw new HttpError(400, "prompt is required");
6592
- return prompt;
6593
- }
6594
- const bad = () => new HttpError(400, "prompt must be a string or a list of { role: user | assistant, content: string }");
6595
- if (!Array.isArray(prompt) || prompt.length === 0) throw bad();
6596
- return prompt.map((m) => {
6597
- if (!isMap$2(m) || m.role !== "user" && m.role !== "assistant" || typeof m.content !== "string") throw bad();
6598
- return {
6599
- role: m.role,
6600
- content: m.content
6601
- };
6602
- });
6603
- }
6604
- function retryAfterSeconds(value) {
6605
- if (!value) return void 0;
6606
- const seconds = Number(value);
6607
- if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
6608
- const at = Date.parse(value);
6609
- return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - Date.now()) / 1e3));
6610
- }
6611
- function isNoKey(body) {
6612
- try {
6613
- const error = JSON.parse(body ?? "").error;
6614
- return error?.type === "no_key" || error?.code === "no_key";
6615
- } catch {
6616
- return false;
6617
- }
6618
- }
6619
- function modelError(e, provider) {
6620
- const cause = RetryError.isInstance(e) ? e.lastError : e;
6621
- if (cause instanceof HttpError) return cause;
6622
- const message = cause instanceof Error ? cause.message : String(cause);
6623
- if (APICallError.isInstance(cause)) {
6624
- if (cause.statusCode === 429) {
6625
- const retryAfter = retryAfterSeconds(cause.responseHeaders?.["retry-after"]);
6626
- return new HttpError(429, message, {
6627
- provider,
6628
- ...retryAfter === void 0 ? {} : { retryAfter }
6629
- });
6630
- }
6631
- if (cause.statusCode === 422 && isNoKey(cause.responseBody)) return new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6632
- }
6633
- return new HttpError(502, message, { provider });
6634
- }
6635
- const KEEPALIVE_MS = 15e3;
6636
- const encoder = new TextEncoder();
6637
- const sseEvent = (event, data) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
6638
- const errorEvent = (e) => sseEvent("error", {
6639
- error: e.message,
6640
- ...e.extra,
6641
- status: e.status
6642
- });
6643
- const pageAiRoutes = (router, ctx) => {
6644
- const prepare = (name, body, streaming) => {
6645
- const page = pageSpec(ctx, name);
6646
- if (!page.ai) throw new HttpError(403, `page ${name} may not use AI`);
6647
- if (!ctx.agent) throw new HttpError(503, "no AI key is configured on this host", { code: "no_key" });
6648
- const ask = readAsk(body, streaming);
6649
- const { provider, languageModel } = ctx.agent.pageModel(ask.model);
6650
- return {
6651
- ask,
6652
- provider,
6653
- release: ctx.agent.pageSlot(),
6654
- call: {
6655
- model: languageModel,
6656
- ...ask.system === void 0 || ask.system.trim() === "" ? {} : { instructions: ask.system },
6657
- prompt: ask.prompt,
6658
- maxOutputTokens: Math.min(ask.maxOutputTokens ?? page.ai.maxOutputTokens, page.ai.maxOutputTokens, PAGE_AI_MAX_TOKENS),
6659
- maxRetries: 0
6660
- }
6661
- };
6662
- };
6663
- router.add("POST", "/api/pages/:page/ai", async ({ params, body, signal }) => {
6664
- const { ask, provider, release, call } = prepare(params.page, body, false);
6665
- try {
6666
- if (ask.schema) return { object: (await generateText({
6667
- ...call,
6668
- output: Output.object({ schema: jsonSchema(ask.schema) }),
6669
- abortSignal: signal
6670
- })).output };
6671
- return { text: (await generateText({
6672
- ...call,
6673
- abortSignal: signal
6674
- })).text };
6675
- } catch (e) {
6676
- throw modelError(e, provider);
6677
- } finally {
6678
- release();
6679
- }
6680
- });
6681
- router.add("POST", "/api/pages/:page/ai/stream", async ({ params, body, signal }) => {
6682
- const { provider, release, call } = prepare(params.page, body, true);
6683
- const stop = new AbortController();
6684
- let ping;
6685
- const finish = () => {
6686
- clearInterval(ping);
6687
- stop.abort();
6688
- release();
6689
- };
6690
- const gone = () => stop.signal.aborted || signal.aborted;
6691
- let parts;
6692
- let head;
6693
- try {
6694
- parts = streamText({
6695
- ...call,
6696
- abortSignal: AbortSignal.any([signal, stop.signal]),
6697
- onError: () => {}
6698
- }).fullStream[Symbol.asyncIterator]();
6699
- for (;;) {
6700
- head = await parts.next();
6701
- if (head.done || head.value.type !== "start") break;
6702
- }
6703
- if (!head.done && head.value.type === "error") throw head.value.error;
6704
- } catch (e) {
6705
- finish();
6706
- throw modelError(e, provider);
6707
- }
6708
- const stream = new ReadableStream({
6709
- async start(k) {
6710
- ping = setInterval(() => k.enqueue(encoder.encode(": ping\n\n")), KEEPALIVE_MS);
6711
- try {
6712
- for (let next = head; !next.done; next = await parts.next()) {
6713
- const p = next.value;
6714
- if (p.type === "abort") break;
6715
- if (p.type === "text-delta") k.enqueue(sseEvent("delta", { text: p.text }));
6716
- if (p.type === "error") {
6717
- if (!gone()) k.enqueue(errorEvent(modelError(p.error, provider)));
6718
- return;
6719
- }
6720
- }
6721
- if (!gone()) k.enqueue(sseEvent("done", {}));
6722
- } catch (e) {
6723
- if (!gone()) k.enqueue(errorEvent(modelError(e, provider)));
6724
- } finally {
6725
- finish();
6726
- try {
6727
- k.close();
6728
- } catch {}
6729
- }
6730
- },
6731
- cancel: finish
6732
- });
6733
- return new Response(stream, { headers: {
6734
- "content-type": "text/event-stream; charset=utf-8",
6735
- "cache-control": "no-store",
6736
- connection: "keep-alive"
6737
- } });
6738
- });
6739
- };
6740
- //#endregion
6741
7110
  //#region src/serve/routes/rules.ts
6742
7111
  const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6743
7112
  const scalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
@@ -7165,6 +7534,7 @@ const ROUTES = [
7165
7534
  pageAiRoutes,
7166
7535
  conflictRoutes,
7167
7536
  agentRoutes,
7537
+ aiRoutes,
7168
7538
  gitRoutes
7169
7539
  ];
7170
7540
  //#endregion
@@ -7976,6 +8346,9 @@ async function open(options) {
7976
8346
  get pages() {
7977
8347
  return config.pages;
7978
8348
  },
8349
+ get ai() {
8350
+ return config.ai;
8351
+ },
7979
8352
  async status(opts = {}) {
7980
8353
  if (closed) throw new Error("YAMLite is closed");
7981
8354
  const dbTime = store.lastWriteMs();
@@ -8006,6 +8379,7 @@ async function open(options) {
8006
8379
  const next = resolveConfig(options);
8007
8380
  config.tables = next.tables;
8008
8381
  config.pages = next.pages;
8382
+ config.ai = next.ai;
8009
8383
  registerTables();
8010
8384
  return config.tables;
8011
8385
  }
@@ -8027,4 +8401,4 @@ async function open(options) {
8027
8401
  };
8028
8402
  }
8029
8403
  //#endregion
8030
- 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 };