isorite 0.8.1 → 0.10.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.
Files changed (112) hide show
  1. package/dist/core.js +84 -10
  2. package/dist/main.js +692 -99
  3. package/docs/setup-for-agents.md +8 -5
  4. package/docs/setup.md +31 -4
  5. package/docs/terminal.md +9 -3
  6. package/notes/algorithms/the-quadtree/subdivision.svg +15 -0
  7. package/notes/algorithms/the-quadtree.md +2 -0
  8. package/package.json +1 -1
  9. package/plugin/server.ts +1 -1
  10. package/plugin/tui.tsx +6 -0
  11. package/web/_app/immutable/assets/{0.CTbO0owi.css → 0.CPW9fjPu.css} +1 -1
  12. package/web/_app/immutable/assets/NoteEditor.Wt5vU9Tb.css +1 -0
  13. package/web/_app/immutable/chunks/{8wVGBFSs.js → -2Meesge.js} +3 -3
  14. package/web/_app/immutable/chunks/{C_ER1Any.js → 6RXH8WsD.js} +1 -1
  15. package/web/_app/immutable/chunks/{BL3mLym4.js → 9z49435t.js} +1 -1
  16. package/web/_app/immutable/chunks/B0A4Snxv.js +6 -0
  17. package/web/_app/immutable/chunks/B8Qvvm53.js +1 -0
  18. package/web/_app/immutable/chunks/{DMAYXnpq.js → BA8Hpxhg.js} +1 -1
  19. package/web/_app/immutable/chunks/{CKSRTWxH.js → BAclEN0O.js} +1 -1
  20. package/web/_app/immutable/chunks/BJo70SOc.js +1 -0
  21. package/web/_app/immutable/chunks/{DECNRCf2.js → BLmF1B2i.js} +1 -1
  22. package/web/_app/immutable/chunks/{CiVjR8gq.js → BTclWEfx.js} +1 -1
  23. package/web/_app/immutable/chunks/{Cp_JbuGh.js → BWVMfAMh.js} +1 -1
  24. package/web/_app/immutable/chunks/{D4fLEcOQ.js → BXAOBcPb.js} +1 -1
  25. package/web/_app/immutable/chunks/BYSDTLqw.js +1 -0
  26. package/web/_app/immutable/chunks/{Cgml88Pl.js → BcgO-fBX.js} +1 -1
  27. package/web/_app/immutable/chunks/BimZp6R0.js +523 -0
  28. package/web/_app/immutable/chunks/{CO2Y5y64.js → Bq2GBAJ0.js} +1 -1
  29. package/web/_app/immutable/chunks/BqrMZ9MX.js +1 -0
  30. package/web/_app/immutable/chunks/{BN6xhG8e.js → Bt7VOT5H.js} +1 -1
  31. package/web/_app/immutable/chunks/{BGbXxNFa.js → BuUzVkG2.js} +1 -1
  32. package/web/_app/immutable/chunks/{Brxvm_b1.js → By4S_gy5.js} +1 -1
  33. package/web/_app/immutable/chunks/{CRIvE37b.js → BymwHy7o.js} +1 -1
  34. package/web/_app/immutable/chunks/{B4sg4e8i.js → C5MPRibb.js} +1 -1
  35. package/web/_app/immutable/chunks/CBkjDkEN.js +151 -0
  36. package/web/_app/immutable/chunks/{BrqJKzQU.js → CD6xPodA.js} +1 -1
  37. package/web/_app/immutable/chunks/{CEufJyoj.js → CQd9D8Nc.js} +1 -1
  38. package/web/_app/immutable/chunks/{BrGmYbJT.js → CT_Ry4q8.js} +1 -1
  39. package/web/_app/immutable/chunks/{CQadubS9.js → C_amgxV1.js} +1 -1
  40. package/web/_app/immutable/chunks/CiU6tYJv.js +1 -0
  41. package/web/_app/immutable/chunks/{spNKdrPy.js → CjtKPNlv.js} +1 -1
  42. package/web/_app/immutable/chunks/{DxLkKbK2.js → Cl5zRP_n.js} +1 -1
  43. package/web/_app/immutable/chunks/{yXUzBjUq.js → CsRPJjQS.js} +1 -1
  44. package/web/_app/immutable/chunks/{CETSRtnV.js → CyFLqlvP.js} +1 -1
  45. package/web/_app/immutable/chunks/{Bq5hCPe1.js → Czz9qOh8.js} +1 -1
  46. package/web/_app/immutable/chunks/{BguXRgvc.js → D3-rqfsM.js} +1 -1
  47. package/web/_app/immutable/chunks/{bcQIrid8.js → D5XlJBy8.js} +1 -1
  48. package/web/_app/immutable/chunks/{TuFg0KqZ.js → D5vocsTl.js} +1 -1
  49. package/web/_app/immutable/chunks/{CTscJvp6.js → D95RKsvN.js} +1 -1
  50. package/web/_app/immutable/chunks/{BzI3yqBQ.js → DBC2xjOo.js} +1 -1
  51. package/web/_app/immutable/chunks/{C1hqerMA.js → DCD8_jrW.js} +1 -1
  52. package/web/_app/immutable/chunks/{BsLNwr5A.js → DF89zyH-.js} +1 -1
  53. package/web/_app/immutable/chunks/{DsIcKd8x.js → DQjIi5Jv.js} +1 -1
  54. package/web/_app/immutable/chunks/{C-dfcq9W.js → DRX-TQzj.js} +1 -1
  55. package/web/_app/immutable/chunks/{BunrC_8P.js → DX4bJJO0.js} +1 -1
  56. package/web/_app/immutable/chunks/{EQVRMnZU.js → DXK7PB4g.js} +1 -1
  57. package/web/_app/immutable/chunks/{mbLqADPr.js → DXTK-eyo.js} +1 -1
  58. package/web/_app/immutable/chunks/{wCTXGvXm.js → DbNETcPs.js} +1 -1
  59. package/web/_app/immutable/chunks/{CX-xckzu.js → DfCZPwp1.js} +1 -1
  60. package/web/_app/immutable/chunks/{DKNOMI4Y.js → Dh1lBxfx.js} +1 -1
  61. package/web/_app/immutable/chunks/{BgczuZKA.js → DkxkvOjx.js} +1 -1
  62. package/web/_app/immutable/chunks/DlKQFacy.js +1 -0
  63. package/web/_app/immutable/chunks/{0M1khDs2.js → DlXrOzlh.js} +1 -1
  64. package/web/_app/immutable/chunks/{B85D_PlV.js → Dpa4Syyy.js} +1 -1
  65. package/web/_app/immutable/chunks/DshHzxgH.js +1 -0
  66. package/web/_app/immutable/chunks/{CdY1YAit.js → EMo1IeTW.js} +1 -1
  67. package/web/_app/immutable/chunks/Iqr2CudW.js +3 -0
  68. package/web/_app/immutable/chunks/{CoyQYage.js → RFxekmX-.js} +1 -1
  69. package/web/_app/immutable/chunks/{vk5Wkr31.js → RlApGeew.js} +1 -1
  70. package/web/_app/immutable/chunks/{CLVUDRRk.js → Xm0YTnxV.js} +1 -1
  71. package/web/_app/immutable/chunks/c7PFFMl4.js +1 -0
  72. package/web/_app/immutable/chunks/{CZ8wTb_M.js → gPtCGHiy.js} +1 -1
  73. package/web/_app/immutable/chunks/{D4SKbj9K.js → oWM_kgYq.js} +1 -1
  74. package/web/_app/immutable/chunks/{wj7G6pU5.js → rghE0CVz.js} +1 -1
  75. package/web/_app/immutable/chunks/{B9YLdnR_.js → tUSDgbpy.js} +1 -1
  76. package/web/_app/immutable/chunks/{DTdlqvEb.js → vfDvz3qP.js} +1 -1
  77. package/web/_app/immutable/chunks/{BRKS-CCX.js → whhiiiga.js} +1 -1
  78. package/web/_app/immutable/entry/app.iw6ZIOh2.js +2 -0
  79. package/web/_app/immutable/entry/start.CFnKMSWs.js +1 -0
  80. package/web/_app/immutable/nodes/0.BzEgwgsa.js +26 -0
  81. package/web/_app/immutable/nodes/1.rzuq9VRh.js +1 -0
  82. package/web/_app/immutable/nodes/2.D_jWsQLW.js +1 -0
  83. package/web/_app/immutable/nodes/3.BzRx3SiD.js +1 -0
  84. package/web/_app/immutable/nodes/4.DP2MGav1.js +2 -0
  85. package/web/_app/immutable/nodes/5.BPS-CCQT.js +5 -0
  86. package/web/_app/immutable/nodes/6.DQMCyNJ3.js +1 -0
  87. package/web/_app/immutable/nodes/7.MWEn-qPC.js +1 -0
  88. package/web/_app/version.json +1 -1
  89. package/web/index.html +17 -17
  90. package/web/_app/immutable/assets/NoteEditor.8wWtZK_A.css +0 -1
  91. package/web/_app/immutable/chunks/B2TCcw-P.js +0 -6
  92. package/web/_app/immutable/chunks/BAkGB2fM.js +0 -1
  93. package/web/_app/immutable/chunks/CHbKgHy3.js +0 -1
  94. package/web/_app/immutable/chunks/CMTjeXEe.js +0 -1
  95. package/web/_app/immutable/chunks/CurmVJyp.js +0 -1
  96. package/web/_app/immutable/chunks/D29fKEzc.js +0 -1
  97. package/web/_app/immutable/chunks/D3BEa5-_.js +0 -673
  98. package/web/_app/immutable/chunks/DPnfDiTn.js +0 -1
  99. package/web/_app/immutable/chunks/DZYaqCwi.js +0 -1
  100. package/web/_app/immutable/chunks/D_19S_Iu.js +0 -1
  101. package/web/_app/immutable/chunks/DtPULAe0.js +0 -1
  102. package/web/_app/immutable/chunks/nMarIA7J.js +0 -3
  103. package/web/_app/immutable/entry/app.idRgnoYp.js +0 -2
  104. package/web/_app/immutable/entry/start.CEx_UQO6.js +0 -1
  105. package/web/_app/immutable/nodes/0.BUd_gY_8.js +0 -26
  106. package/web/_app/immutable/nodes/1.DHK7VmG0.js +0 -1
  107. package/web/_app/immutable/nodes/2.CDAt-6SJ.js +0 -1
  108. package/web/_app/immutable/nodes/3.4Wv5nmaw.js +0 -1
  109. package/web/_app/immutable/nodes/4.Ceb5g4Ps.js +0 -2
  110. package/web/_app/immutable/nodes/5.DioYOANs.js +0 -5
  111. package/web/_app/immutable/nodes/6.CzYfNb6g.js +0 -1
  112. package/web/_app/immutable/nodes/7.BxQaF3lD.js +0 -1
package/dist/main.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { Document, Scalar, isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
6
6
  import MarkdownIt from "markdown-it";
7
7
  import { parse, stringify as stringify$1 } from "smol-toml";
8
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
8
9
  import { createHash, randomBytes } from "node:crypto";
9
10
  import { arch, constants, homedir, hostname, platform, release, tmpdir } from "node:os";
10
11
  import { createSecureContext, getCACertificates, setDefaultCACertificates } from "node:tls";
11
- import { execFileSync, spawn, spawnSync } from "node:child_process";
12
+ import { get } from "node:http";
12
13
  import { Worker } from "node:worker_threads";
13
14
  import { Agent, request } from "node:https";
14
15
  import { Readable } from "node:stream";
@@ -17,9 +18,11 @@ import { Hono } from "hono";
17
18
  import { streamSSE } from "hono/streaming";
18
19
  import { z } from "zod";
19
20
  import { OpenCode } from "@opencode/client";
20
- import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
21
+ import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
22
+ import { createInterface } from "node:readline/promises";
21
23
  import { createServer } from "node:net";
22
- import { createInterface } from "node:readline";
24
+ import { isSea } from "node:sea";
25
+ import { createInterface as createInterface$1 } from "node:readline";
23
26
  //#region \0rolldown/runtime.js
24
27
  var __defProp = Object.defineProperty;
25
28
  var __esmMin = (fn, res, err) => () => {
@@ -139,7 +142,7 @@ function normpath(p) {
139
142
  }
140
143
  return "/".repeat(initial) + out.join("/") || ".";
141
144
  }
142
- /** SHA-256 as hex, synchronous and dependency-free, so the core runs anywhere. */
145
+ /** SHA-256 as hex, synchronous and dependency-free, so the core runs anywhere: of text (as UTF-8), or of bytes. */
143
146
  function sha256(message) {
144
147
  const K = new Uint32Array([
145
148
  1116352408,
@@ -207,7 +210,7 @@ function sha256(message) {
207
210
  3204031479,
208
211
  3329325298
209
212
  ]);
210
- const data = new TextEncoder().encode(message);
213
+ const data = typeof message === "string" ? new TextEncoder().encode(message) : message;
211
214
  const len = data.length;
212
215
  const padded = new Uint8Array(len + 9 + 63 >> 6 << 6);
213
216
  padded.set(data);
@@ -374,7 +377,7 @@ function linkTexts(body) {
374
377
  return out;
375
378
  }
376
379
  /** Every link and image in a body, in order: where it points, its title, and whether it is an image
377
- * (`![…](…)`, which for an artifact or a picture means "shown here", an embed). */
380
+ * (`![…](…)`, which for an artifact or a picture means "shown here", an embed), with an image's alt text. */
378
381
  function citeRefs(body) {
379
382
  const { tokens } = parse$1(body);
380
383
  const out = [];
@@ -388,7 +391,8 @@ function citeRefs(body) {
388
391
  else if (c.type === "image") out.push({
389
392
  href: c.attrGet("src") ?? "",
390
393
  title: c.attrGet("title"),
391
- embed: true
394
+ embed: true,
395
+ alt: c.content
392
396
  });
393
397
  }
394
398
  return out;
@@ -443,6 +447,64 @@ var init_markdown = __esmMin((() => {
443
447
  SPACES = /[\s_]+/gu;
444
448
  }));
445
449
  //#endregion
450
+ //#region packages/core/src/picture.ts
451
+ /** Width and height in pixels, or null. */
452
+ function pictureSize(bytes, type) {
453
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), n = bytes.length;
454
+ const at = (i, s) => [...s].every((c, k) => bytes[i + k] === c.charCodeAt(0));
455
+ const ok = (w, h) => w > 0 && h > 0 ? {
456
+ width: w,
457
+ height: h
458
+ } : null;
459
+ try {
460
+ if (type === "image/png" && n >= 24 && at(12, "IHDR")) return ok(v.getUint32(16), v.getUint32(20));
461
+ if (type === "image/gif" && n >= 10 && at(0, "GIF")) return ok(v.getUint16(6, true), v.getUint16(8, true));
462
+ if (type === "image/jpeg") {
463
+ for (let i = 2; i + 9 < n;) {
464
+ if (bytes[i] !== 255) return null;
465
+ const m = bytes[i + 1];
466
+ if (m >= 192 && m <= 207 && m !== 196 && m !== 200 && m !== 204) return ok(v.getUint16(i + 7), v.getUint16(i + 5));
467
+ i += 2 + v.getUint16(i + 2);
468
+ }
469
+ return null;
470
+ }
471
+ if (type === "image/webp" && n >= 30 && at(0, "RIFF") && at(8, "WEBP")) {
472
+ if (at(12, "VP8 ")) return ok(v.getUint16(26, true) & 16383, v.getUint16(28, true) & 16383);
473
+ if (at(12, "VP8L")) {
474
+ const b = v.getUint32(21, true);
475
+ return ok((b & 16383) + 1, (b >> 14 & 16383) + 1);
476
+ }
477
+ if (at(12, "VP8X")) return ok(1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16), 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16));
478
+ return null;
479
+ }
480
+ if (type === "image/svg+xml") {
481
+ const head = new TextDecoder().decode(bytes.subarray(0, 4096)), tag = /<svg\b[^>]*>/i.exec(head)?.[0] ?? "";
482
+ const attr = (name) => new RegExp(`\\s${name}\\s*=\\s*["']\\s*([\\d.]+)\\s*(px)?\\s*["']`, "i").exec(tag);
483
+ const w = attr("width"), h = attr("height");
484
+ if (w && h) return ok(Math.round(Number(w[1])), Math.round(Number(h[1])));
485
+ const box = /\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(tag);
486
+ return box ? ok(Math.round(Number(box[1])), Math.round(Number(box[2]))) : null;
487
+ }
488
+ } catch {}
489
+ return null;
490
+ }
491
+ var TYPES$1, pictureType;
492
+ var init_picture = __esmMin((() => {
493
+ TYPES$1 = {
494
+ png: "image/png",
495
+ jpg: "image/jpeg",
496
+ jpeg: "image/jpeg",
497
+ gif: "image/gif",
498
+ svg: "image/svg+xml",
499
+ webp: "image/webp",
500
+ avif: "image/avif"
501
+ };
502
+ pictureType = (path) => {
503
+ const dot = path.lastIndexOf(".");
504
+ return dot < 0 || path.lastIndexOf("/") > dot ? null : TYPES$1[path.slice(dot + 1).toLowerCase()] ?? null;
505
+ };
506
+ }));
507
+ //#endregion
446
508
  //#region packages/core/src/kinds.ts
447
509
  function kindOf(type) {
448
510
  const t = type.trim().toLowerCase();
@@ -515,11 +577,25 @@ function outwardLinks(body) {
515
577
  }
516
578
  seen.add(href);
517
579
  }
580
+ for (const { href, embed, alt } of citeRefs(body)) {
581
+ if (!embed || seen.has(href)) continue;
582
+ const c = codeTarget(href);
583
+ if (!c || !pictureType(c.path)) continue;
584
+ out.push({
585
+ kind: "code",
586
+ href,
587
+ text: alt || c.path.split("/").at(-1),
588
+ path: c.path,
589
+ picture: true
590
+ });
591
+ seen.add(href);
592
+ }
518
593
  return out;
519
594
  }
520
595
  var ABOUT, DONE, EMBED;
521
596
  var init_kinds = __esmMin((() => {
522
597
  init_markdown();
598
+ init_picture();
523
599
  init_text();
524
600
  ABOUT = {
525
601
  question: "about",
@@ -722,7 +798,7 @@ var init_bundle = __esmMin((() => {
722
798
  concept.links = links;
723
799
  concept.outward = outwardLinks(concept.body);
724
800
  for (const link of links) if (link.broken) this.issue(concept.path, "warning", "broken-link", `broken link to ${link.target}`);
725
- for (const { href, title, embed } of citeRefs(concept.body)) {
801
+ for (const { href, title, embed, alt } of citeRefs(concept.body)) {
726
802
  const path = this.filePath(concept, href), kind = path === null ? null : citeKind(path);
727
803
  if (path === null || kind === null) continue;
728
804
  const t = strip((title ?? "").toLowerCase());
@@ -732,10 +808,11 @@ var init_bundle = __esmMin((() => {
732
808
  embed,
733
809
  broken: !this.files.has(path),
734
810
  rel: rating(title),
735
- align: t === "left" ? "left" : t === "center" || t === "centre" ? "center" : null
811
+ align: t === "left" ? "left" : t === "center" || t === "centre" ? "center" : null,
812
+ ...embed && alt ? { alt } : {}
736
813
  };
737
814
  concept.cites.push(cite);
738
- if (cite.broken) this.issue(concept.path, "warning", "broken-link", `broken link to ${path}`);
815
+ if (cite.broken) this.issue(concept.path, "warning", "broken-link", kind === "image" ? `picture not in the notes: ${path}` : `broken link to ${path}`);
739
816
  }
740
817
  }
741
818
  const byName = /* @__PURE__ */ new Map();
@@ -1806,13 +1883,16 @@ function mapModel(base) {
1806
1883
  s
1807
1884
  ]);
1808
1885
  }
1886
+ const holdsFiles = /* @__PURE__ */ new Set();
1887
+ for (const f of base.files) holdsFiles.add(f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
1809
1888
  const dir = (id) => {
1810
1889
  const d = base.directories.get(id);
1811
- return {
1890
+ const children = [...[...d.children].sort().map(dir).filter((c) => c !== null), ...[...d.concepts].sort().map(note)];
1891
+ return id && !children.length && (holdsFiles.has(id) || d.children.length) ? null : {
1812
1892
  kind: "dir",
1813
1893
  id: "d:" + id,
1814
1894
  ref: id,
1815
- children: [...[...d.children].sort().map(dir), ...[...d.concepts].sort().map(note)]
1895
+ children
1816
1896
  };
1817
1897
  };
1818
1898
  return {
@@ -1827,6 +1907,7 @@ var init_map = __esmMin((() => {
1827
1907
  //#region packages/core/src/index.ts
1828
1908
  var init_src$1 = __esmMin((() => {
1829
1909
  init_bundle();
1910
+ init_picture();
1830
1911
  init_frontmatter();
1831
1912
  init_source();
1832
1913
  init_markdown();
@@ -1881,8 +1962,9 @@ function loadBundle(root) {
1881
1962
  }
1882
1963
  //#endregion
1883
1964
  //#region packages/engine/src/api.ts
1884
- var EngineError, Conflict;
1965
+ var MAX_FILE_BYTES, EngineError, Conflict;
1885
1966
  var init_api = __esmMin((() => {
1967
+ MAX_FILE_BYTES = 2e7;
1886
1968
  EngineError = class extends Error {
1887
1969
  status;
1888
1970
  constructor(message, status = 400) {
@@ -1944,19 +2026,50 @@ function unreachable(err, url) {
1944
2026
  try {
1945
2027
  host = new URL(url).host;
1946
2028
  } catch {}
1947
- const advice = UNTRUSTED.has(code) ? `Its certificate is signed by an authority not trusted here, often a company's own. Add it to the system's authorities (isorite trusts those), or set NODE_EXTRA_CA_CERTS to its certificate (a .pem), or name that as ca in [provider].` : code === "CERT_HAS_EXPIRED" ? "Its certificate has expired: that is the gateway's to fix." : code === "ERR_TLS_CERT_ALTNAME_INVALID" ? `Its certificate is not for ${host}: check the url in [provider].` : /CERTIFICATE_REQUIRED|HANDSHAKE_FAILURE|BAD_CERTIFICATE|UNKNOWN_CA/.test(code) ? "It asks for a client certificate (mTLS) and was given none it accepts: name yours as pfx (with pfx_passphrase), or cert and cert_key, in [provider]." : code === "UND_ERR_SOCKET" && url.startsWith("https:") ? "It closed the connection as it was being set up. A gateway that asks for a client certificate (mTLS) does this: name yours as pfx, or cert and cert_key, in [provider]." : code === "ENOTFOUND" || code === "EAI_AGAIN" ? `The name ${host} could not be found: check the url in [provider], and whether it is only found on the company's network or VPN.` : code === "ECONNREFUSED" ? `Nothing answers at ${host}: check the url and its port in [provider].` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ENETUNREACH" || code === "EHOSTUNREACH" ? `${host} did not answer. If the company's network reaches it through a proxy, set HTTPS_PROXY.` : "";
2029
+ const proxy = /Proxy response \((\d{3})\)/.exec(said)?.[1];
2030
+ const advice = proxy === "407" ? "The proxy (HTTPS_PROXY), not the provider, refused with 407: it wants credentials of its own, as http://user:password@proxy:port in HTTPS_PROXY, or its own sign-in. isorite can't answer a proxy that signs in by Kerberos or NTLM." : proxy ? `The proxy (HTTPS_PROXY) answered ${proxy}, not the provider: check HTTPS_PROXY, and NO_PROXY for hosts reached directly.` : UNTRUSTED.has(code) ? `Its certificate is signed by an authority not trusted here, often a company's own. Add it to the system's authorities (isorite trusts those), or set NODE_EXTRA_CA_CERTS to its certificate (a .pem), or name that as ca in [provider].` : code === "CERT_HAS_EXPIRED" ? "Its certificate has expired: that is the gateway's to fix." : code === "ERR_TLS_CERT_ALTNAME_INVALID" ? `Its certificate is not for ${host}: check the url in [provider].` : /CERTIFICATE_REQUIRED|HANDSHAKE_FAILURE|BAD_CERTIFICATE|UNKNOWN_CA/.test(code) ? "It asks for a client certificate (mTLS) and was given none it accepts: name yours as pfx (with pfx_passphrase), or cert and cert_key, in [provider]." : code === "UND_ERR_SOCKET" && url.startsWith("https:") ? "It closed the connection as it was being set up. A gateway that asks for a client certificate (mTLS) does this: name yours as pfx, or cert and cert_key, in [provider]." : code === "ENOTFOUND" || code === "EAI_AGAIN" ? `The name ${host} could not be found: check the url in [provider], and whether it is only found on the company's network or VPN.` : code === "ECONNREFUSED" ? `Nothing answers at ${host}: check the url and its port in [provider].` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ENETUNREACH" || code === "EHOSTUNREACH" ? `${host} did not answer. If the company's network reaches it through a proxy, set HTTPS_PROXY.` : "";
1948
2031
  return [
1949
2032
  said || err?.message || "no reason given",
1950
2033
  code && !said.includes(code) ? ` (${code})` : "",
1951
2034
  advice ? `. ${advice}` : ""
1952
2035
  ].join("");
1953
2036
  }
2037
+ /** The header the key goes in, and what goes before it: Authorization with "Bearer " unless said otherwise (M21).
2038
+ * `key_header = "authorization"` alone means Bearer too: a bare key in Authorization is rarely what a gateway
2039
+ * wants. `key_prefix = ""` sends it bare. */
2040
+ function keySent(p) {
2041
+ const header = p.keyHeader || "Authorization";
2042
+ return {
2043
+ header,
2044
+ prefix: p.keyPrefix ?? (header.toLowerCase() === "authorization" ? "Bearer " : "")
2045
+ };
2046
+ }
2047
+ /** How the key is sent, as said to the person: `Authorization: Bearer <41 characters>`. */
2048
+ function keySentAs(p, key) {
2049
+ const { header, prefix } = keySent(p);
2050
+ return `${header}: ${prefix}<${key.length} characters>`;
2051
+ }
2052
+ /** What a provider said with a refusal: its `error.message` (or `message`, or `error`), else its text without
2053
+ * markup, cut to 300 characters, and never the key, should it say it back. */
2054
+ function refusal(body, key = null) {
2055
+ let said = "";
2056
+ try {
2057
+ const j = JSON.parse(body);
2058
+ said = (typeof j.error === "string" ? j.error : j.error?.message) ?? j.message ?? "";
2059
+ } catch {
2060
+ said = body.replace(/<[^>]+>/g, " ");
2061
+ }
2062
+ said = said.replace(/\s+/g, " ").trim();
2063
+ if (key && key.length >= 8) said = said.split(key).join("<the key>");
2064
+ return said.length > 300 ? said.slice(0, 299) + "…" : said;
2065
+ }
1954
2066
  /** The address of `path` at the provider, and the headers that go with every request to it: its key, where it
1955
2067
  * wants it, and its own. */
1956
2068
  function endpoint(p, key, path) {
1957
2069
  const url = new URL(p.url.replace(/\/+$/, "") + path);
1958
2070
  for (const [name, value] of Object.entries(p.query ?? {})) url.searchParams.set(name, value);
1959
- const auth = !key ? {} : p.keyHeader ? { [p.keyHeader]: key } : { Authorization: `Bearer ${key}` };
2071
+ const sent = keySent(p);
2072
+ const auth = !key ? {} : { [sent.header]: sent.prefix + key };
1960
2073
  return {
1961
2074
  url: url.toString(),
1962
2075
  headers: {
@@ -2023,13 +2136,7 @@ async function completeOnce(http, p, key, call, heard) {
2023
2136
  if (!res.ok || !res.body) {
2024
2137
  let detail = "";
2025
2138
  try {
2026
- const said = await res.text();
2027
- try {
2028
- const j = JSON.parse(said);
2029
- detail = (typeof j.error === "string" ? j.error : j.error?.message) ?? j.message ?? "";
2030
- } catch {
2031
- detail = said.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
2032
- }
2139
+ detail = refusal(await res.text(), key);
2033
2140
  } catch {}
2034
2141
  if (DOWN.has(res.status)) throw new ModelError(`${p.name} is unavailable (${res.status}${detail ? `: ${detail}` : ""})`, 502, {
2035
2142
  kind: "unavailable",
@@ -2041,7 +2148,7 @@ async function completeOnce(http, p, key, call, heard) {
2041
2148
  retry: true,
2042
2149
  after: afterOf(res)
2043
2150
  });
2044
- const advice = res.status === 401 ? " The key was not accepted: set it again in settings." : res.status === 402 ? " The key's credit is spent." : res.status === 404 ? ` ${p.name} may have no model called ${call.model}${p.defaultTiers ? ", which is OpenRouter's name for it: name this provider's models under [tiers] in config.toml" : ""}.` : "";
2151
+ const advice = res.status === 401 ? ` The key was not accepted${key ? `, sent as ${keySentAs(p, key)}` : ""}: set it again in settings, or check key_header and key_prefix.` : res.status === 402 ? " The key's credit is spent." : res.status === 404 ? ` ${p.name} may have no model called ${call.model}${p.defaultTiers ? ", which is OpenRouter's name for it: name this provider's models under [tiers] in config.toml" : ""}.` : "";
2045
2152
  throw new ModelError(`${p.name} said ${res.status}${detail ? `: ${detail}` : ""}.${advice}`, res.status === 401 || res.status === 403 ? 409 : 502);
2046
2153
  }
2047
2154
  let text = "", usage = null, finish = "";
@@ -2671,6 +2778,9 @@ something at one place in the note they have open.`;
2671
2778
  is drawn as the diagram. Draw one where a picture of how things stand to
2672
2779
  each other helps, or when asked; not by habit. Maths in a label goes
2673
2780
  between $$ and $$.
2781
+ - A picture is shown as ![what it shows](its/path.png). You can't see it: what
2782
+ you know of it is the words in the brackets. Keep a picture's line as it is
2783
+ when you rewrite around it, and never make up a picture's path.
2674
2784
  - Ordinary Markdown otherwise. No HTML.`;
2675
2785
  FORMAT_RULES = FORMAT;
2676
2786
  LOOKUP = (rounds, meaning) => `# Looking things up
@@ -2779,7 +2889,7 @@ var init_lookup = __esmMin((() => {
2779
2889
  ...str$1,
2780
2890
  description: "The note's id or path: basics/entropy or /basics/entropy.md."
2781
2891
  } }, ["id"]),
2782
- fn$1("read_note", "Read one section of a note, by its heading, or the whole note when no section is named. Prefer a section.", {
2892
+ fn$1("read_note", "Read one section of a note, by its heading, or the whole note when no section is named. Prefer a section. A picture in it is ![what it shows](path): you have its words, not the picture.", {
2783
2893
  id: str$1,
2784
2894
  section: {
2785
2895
  ...str$1,
@@ -4002,18 +4112,41 @@ var init_engine = __esmMin((() => {
4002
4112
  const notes = {}, broken = await this.brokenCode();
4003
4113
  for (const c of this.bundle.concepts.values()) {
4004
4114
  const bad = broken.get(c.id) ?? [];
4005
- const links = c.outward.map((o) => ({
4115
+ const links = c.outward.filter((o) => !(o.kind === "code" && o.picture)).map((o) => ({
4006
4116
  kind: o.kind,
4007
4117
  href: o.href,
4008
4118
  text: o.text,
4009
4119
  ...bad.includes(o.href) ? { broken: true } : {}
4010
4120
  }));
4121
+ const pictures = [], shown = /* @__PURE__ */ new Set();
4122
+ for (const p of c.cites) {
4123
+ if (p.kind !== "image" || !p.embed || shown.has(p.target)) continue;
4124
+ shown.add(p.target);
4125
+ pictures.push({
4126
+ href: p.target,
4127
+ path: p.target,
4128
+ from: "notes",
4129
+ text: p.alt || p.target.split("/").at(-1),
4130
+ ...p.broken ? { broken: true } : {}
4131
+ });
4132
+ }
4133
+ for (const o of c.outward) {
4134
+ if (o.kind !== "code" || !o.picture) continue;
4135
+ pictures.push({
4136
+ href: o.href,
4137
+ path: o.path,
4138
+ from: "code",
4139
+ text: o.text,
4140
+ ...bad.includes(o.href) ? { broken: true } : {}
4141
+ });
4142
+ }
4011
4143
  notes[c.id] = {
4012
4144
  title: c.title,
4013
4145
  tokens: estimate(c.body.trim()),
4014
4146
  ...c.kind !== "note" ? { kind: c.kind } : {},
4015
4147
  ...c.done ? { done: c.done } : {},
4016
- ...links.length ? { links } : {}
4148
+ ...links.length ? { links } : {},
4149
+ ...pictures.length ? { pictures } : {}
4017
4150
  };
4018
4151
  }
4019
4152
  return {
@@ -4410,6 +4543,107 @@ var init_engine = __esmMin((() => {
4410
4543
  this.queue = run.catch(() => void 0);
4411
4544
  return run;
4412
4545
  }
4546
+ /** A picture (M22), in the notes or in the base's repository. The path must be a picture's, within its folder,
4547
+ * and (in the notes) never in a hidden folder such as `.isorite/`: nothing but a picture is given this way. */
4548
+ async file(path, from = "notes") {
4549
+ const type = pictureType(path);
4550
+ if (!type) return null;
4551
+ let files, at;
4552
+ if (from === "code") {
4553
+ const target = codeTarget(`code:${path}`), where = (await this.settingsOfBase()).repository;
4554
+ files = target && where ? this.opts.repository?.(where) ?? null : null;
4555
+ at = target?.path ?? "";
4556
+ } else {
4557
+ at = path.replace(/^\/+/, "");
4558
+ files = at.split("/").some((p) => !p || p === "." || p === ".." || p.startsWith(".")) ? null : this.opts.notes;
4559
+ }
4560
+ const bytes = files ? await files.readBytes(at) : null;
4561
+ if (!bytes) return null;
4562
+ if (bytes.length > 2e7) throw new EngineError(`${path} is larger than isorite gives (${MAX_FILE_BYTES / 1e6} MB)`, 413);
4563
+ return {
4564
+ bytes,
4565
+ type,
4566
+ version: sha256(bytes).slice(0, 16)
4567
+ };
4568
+ }
4569
+ /** What was read of each picture, kept while its size and time are the same. */
4570
+ pictureCache = /* @__PURE__ */ new Map();
4571
+ /** Every picture in the notes (M22), found afresh: one put in the folder by hand since the base was opened is
4572
+ * among them, and from then on in the bundle too, so a link to it isn't broken. A hidden folder is never looked in. */
4573
+ async pictures() {
4574
+ const found = [];
4575
+ const visit = async (dir) => {
4576
+ for (const f of await this.opts.notes.list(dir)) {
4577
+ if (f.name.startsWith(".")) continue;
4578
+ const path = dir ? `${dir}/${f.name}` : f.name;
4579
+ if (f.dir) await visit(path);
4580
+ else if (pictureType(f.name)) found.push({
4581
+ path,
4582
+ ...f.size === void 0 ? {} : { size: f.size },
4583
+ ...f.mtime === void 0 ? {} : { mtime: f.mtime }
4584
+ });
4585
+ }
4586
+ };
4587
+ await visit("");
4588
+ const out = [], seen = /* @__PURE__ */ new Set();
4589
+ for (const f of found) {
4590
+ seen.add(f.path);
4591
+ const kept = this.pictureCache.get(f.path);
4592
+ if (kept && f.size !== void 0 && kept.size === f.size && kept.mtime === f.mtime) {
4593
+ out.push(kept.info);
4594
+ continue;
4595
+ }
4596
+ const bytes = f.size !== void 0 && f.size > 2e7 ? null : await this.opts.notes.readBytes(f.path);
4597
+ if (!bytes) continue;
4598
+ const px = pictureSize(bytes, pictureType(f.path));
4599
+ const info = {
4600
+ path: f.path,
4601
+ bytes: bytes.length,
4602
+ width: px?.width ?? null,
4603
+ height: px?.height ?? null,
4604
+ sha256: sha256(bytes)
4605
+ };
4606
+ this.pictureCache.set(f.path, {
4607
+ ...f.size === void 0 ? {} : { size: f.size },
4608
+ ...f.mtime === void 0 ? {} : { mtime: f.mtime },
4609
+ info
4610
+ });
4611
+ out.push(info);
4612
+ }
4613
+ for (const path of this.pictureCache.keys()) if (!seen.has(path)) this.pictureCache.delete(path);
4614
+ let changed = false;
4615
+ for (const path of seen) if (!this.files.has(path)) {
4616
+ this.files.set(path, { path });
4617
+ changed = true;
4618
+ }
4619
+ for (const path of [...this.files.keys()]) if (pictureType(path) && !seen.has(path)) {
4620
+ this.files.delete(path);
4621
+ changed = true;
4622
+ }
4623
+ if (changed) this.built = this.index = null;
4624
+ return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
4625
+ }
4626
+ /** Put a picture in the notes (M22). The path must be a picture's, out of any hidden folder, and free: a file
4627
+ * that's there is never replaced. Not journaled: a picture has no history, and stays when its link goes. */
4628
+ savePicture(path, bytes) {
4629
+ return this.one(async () => {
4630
+ const type = pictureType(path), parts = path.split("/");
4631
+ if (!type || parts.some((p) => !p || p === "." || p === ".." || p.startsWith(".")) || /[\\\x00-\x1f]/.test(path)) throw new EngineError(`not a picture's path: ${path}`, 400);
4632
+ if (bytes.length > 2e7) throw new EngineError(`the picture is larger than isorite takes (${MAX_FILE_BYTES / 1e6} MB)`, 413);
4633
+ if (await this.opts.notes.readBytes(path) !== null) throw new Conflict(`${path} is there already`, null);
4634
+ await this.opts.notes.writeBytes(path, bytes);
4635
+ this.files.set(path, { path });
4636
+ this.built = this.index = null;
4637
+ const px = pictureSize(bytes, type);
4638
+ return {
4639
+ path,
4640
+ bytes: bytes.length,
4641
+ width: px?.width ?? null,
4642
+ height: px?.height ?? null,
4643
+ sha256: sha256(bytes)
4644
+ };
4645
+ });
4646
+ }
4413
4647
  /** Where a note's file is: drafts and Understanding notes are among isorite's own files. */
4414
4648
  where(id) {
4415
4649
  if (!inBase(id)) return {
@@ -6463,6 +6697,8 @@ var init_ports = __esmMin((() => {
6463
6697
  systemClock = { now: () => Date.now() };
6464
6698
  MemoryFiles = class {
6465
6699
  files = /* @__PURE__ */ new Map();
6700
+ /** Files held as bytes (pictures), apart from text. */
6701
+ bytes = /* @__PURE__ */ new Map();
6466
6702
  constructor(files = {}) {
6467
6703
  for (const [path, text] of Object.entries(files)) this.files.set(path, text);
6468
6704
  }
@@ -6475,22 +6711,42 @@ var init_ports = __esmMin((() => {
6475
6711
  async append(path, text) {
6476
6712
  this.files.set(path, (this.files.get(path) ?? "") + text);
6477
6713
  }
6714
+ async readBytes(path) {
6715
+ const b = this.bytes.get(path), t = this.files.get(path);
6716
+ return b ?? (t === void 0 ? null : new TextEncoder().encode(t));
6717
+ }
6718
+ async writeBytes(path, bytes) {
6719
+ this.files.delete(path);
6720
+ this.bytes.set(path, bytes);
6721
+ }
6478
6722
  async remove(path) {
6479
6723
  this.files.delete(path);
6724
+ this.bytes.delete(path);
6480
6725
  }
6481
6726
  async list(dir) {
6482
6727
  const prefix = dir ? dir + "/" : "";
6483
6728
  const found = /* @__PURE__ */ new Map();
6484
- for (const path of this.files.keys()) {
6729
+ for (const path of [...this.files.keys(), ...this.bytes.keys()]) {
6485
6730
  if (!path.startsWith(prefix)) continue;
6486
6731
  const rest = path.slice(prefix.length), cut = rest.indexOf("/");
6487
- if (cut < 0) found.set(rest, false);
6488
- else found.set(rest.slice(0, cut), true);
6732
+ if (cut >= 0) {
6733
+ found.set(rest.slice(0, cut), {
6734
+ name: rest.slice(0, cut),
6735
+ dir: true
6736
+ });
6737
+ continue;
6738
+ }
6739
+ const b = this.bytes.get(path);
6740
+ found.set(rest, b ? {
6741
+ name: rest,
6742
+ dir: false,
6743
+ size: b.length
6744
+ } : {
6745
+ name: rest,
6746
+ dir: false
6747
+ });
6489
6748
  }
6490
- return [...found].map(([name, isDir]) => ({
6491
- name,
6492
- dir: isDir
6493
- }));
6749
+ return [...found.values()];
6494
6750
  }
6495
6751
  };
6496
6752
  MemorySecrets = class {
@@ -6697,7 +6953,7 @@ function writeHome(dir, { gateway = null, plugin = pluginDir, instructions = []
6697
6953
  recursive: true,
6698
6954
  force: true
6699
6955
  });
6700
- symlinkSync(plugin, link, "dir");
6956
+ symlinkSync(plugin, link, process.platform === "win32" ? "junction" : "dir");
6701
6957
  }
6702
6958
  return dir;
6703
6959
  }
@@ -6724,12 +6980,14 @@ function withLocal(config, local) {
6724
6980
  };
6725
6981
  }
6726
6982
  function linksTo(path, target) {
6983
+ const plain = (p) => resolve(p).replace(/[\\/]+$/, "");
6727
6984
  try {
6728
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
6985
+ return lstatSync(path).isSymbolicLink() && plain(readlinkSync(path)) === plain(target);
6729
6986
  } catch {
6730
6987
  return false;
6731
6988
  }
6732
6989
  }
6990
+ const PACKAGE$1 = `@opencode/cli@2.0.32`;
6733
6991
  /** The version a `--version` line names, as "2.0.32", or null. */
6734
6992
  function parseVersion(output) {
6735
6993
  return output.match(/(\d+)\.(\d+)\.(\d+)/)?.[0] ?? null;
@@ -6749,20 +7007,57 @@ const probe = (command) => {
6749
7007
  return null;
6750
7008
  }
6751
7009
  };
6752
- /** ISORITE_OPENCODE if set, else `opencode2`, else `opencode`: the first that is OpenCode 2. */
6753
- function findOpenCode(env = process.env, run = probe) {
7010
+ /** isorite's own prefix for OpenCode 2. Not ~/.config/isorite/opencode, which is OpenCode's configuration as
7011
+ * `term` writes it (home.ts): an npm prefix there would put node_modules among it. */
7012
+ function ownPrefix(env = process.env) {
7013
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "isorite", "opencode-cli");
7014
+ }
7015
+ /** The OpenCode 2 in isorite's own prefix, as npm links it; on Windows, the package's own opencode.exe, as npm's
7016
+ * opencode2.cmd can't be run without a shell (Node refuses .cmd files since 2024). */
7017
+ const ownCommand = (env = process.env, platform = process.platform) => platform === "win32" ? join(ownPrefix(env), "node_modules", "@opencode", "cli", "bin", "opencode.exe") : join(ownPrefix(env), "node_modules", ".bin", "opencode2");
7018
+ /** A command on the PATH as Windows can run it without a shell: `<name>.exe`, or for npm's `<name>.cmd` the
7019
+ * package's opencode.exe beside it, in the global node_modules. Null when there is neither. */
7020
+ function onWindowsPath(name, env = process.env, exists = existsSync) {
7021
+ for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
7022
+ if (exists(win32.join(dir, `${name}.exe`))) return win32.join(dir, `${name}.exe`);
7023
+ const exe = win32.join(dir, "node_modules", "@opencode", "cli", "bin", "opencode.exe");
7024
+ if (exists(win32.join(dir, `${name}.cmd`)) && exists(exe)) return exe;
7025
+ }
7026
+ return null;
7027
+ }
7028
+ /** The line that installs OpenCode 2 for isorite alone, to copy. */
7029
+ const installLine = (env = process.env) => `isorite opencode (npm install --prefix ${ownPrefix(env)} ${PACKAGE$1})`;
7030
+ /** OpenCode 2 wasn't found. `old` is an OpenCode 1 that was, if one was. */
7031
+ var NoOpenCode = class extends Error {
7032
+ old;
7033
+ constructor(tried, old, env) {
7034
+ super((old ? `isorite term needs OpenCode 2; ${old} is OpenCode 1 (${tried.join("; ")}).\n` : `isorite term needs OpenCode 2 (${tried.join("; ")}).\n`) + ` Install it for isorite only, leaving the global one as it is: ${installLine(env)}\n or name one with: ISORITE_OPENCODE=/path/to/opencode isorite term`);
7035
+ this.old = old;
7036
+ }
7037
+ };
7038
+ /** ISORITE_OPENCODE if set, else isorite's own prefix's, else `opencode2`, else `opencode`: the first that is
7039
+ * OpenCode 2. */
7040
+ function findOpenCode(env = process.env, run = probe, platform = process.platform, where = onWindowsPath) {
6754
7041
  const tried = [];
6755
- const candidates = env.ISORITE_OPENCODE ? [env.ISORITE_OPENCODE] : ["opencode2", "opencode"];
6756
- for (const command of candidates) {
7042
+ let old = null;
7043
+ const candidates = env.ISORITE_OPENCODE ? [[env.ISORITE_OPENCODE, "ISORITE_OPENCODE"]] : [
7044
+ [ownCommand(env, platform), "isorite opencode"],
7045
+ ["opencode2", "the PATH"],
7046
+ ["opencode", "the PATH"]
7047
+ ];
7048
+ for (const [named, from] of candidates) {
7049
+ const command = platform === "win32" && from === "the PATH" ? where(named, env) ?? named : named;
6757
7050
  const output = run(command);
6758
7051
  const version = output === null ? null : parseVersion(output);
6759
7052
  if (version && Number(version.split(".")[0]) === 2) return {
6760
7053
  command,
6761
- version
7054
+ version,
7055
+ from
6762
7056
  };
6763
- tried.push(output === null ? `${command}: not found` : `${command}: ${version ?? "unknown version"}`);
7057
+ if (version && Number(version.split(".")[0]) < 2) old ??= `${command} ${version}`;
7058
+ if (output !== null || from !== "isorite opencode") tried.push(output === null ? `${command}: not found` : `${command}: ${version ?? "unknown version"}`);
6764
7059
  }
6765
- throw new Error(`isorite term needs OpenCode 2 (${tried.join("; ")}).\n Install it with: npm i -g @opencode/cli\n or name one with: ISORITE_OPENCODE=/path/to/opencode isorite term`);
7060
+ throw new NoOpenCode(tried, old, env);
6766
7061
  }
6767
7062
  //#endregion
6768
7063
  //#region packages/server/src/config.ts
@@ -6850,6 +7145,7 @@ function providerFrom(config, env = process.env) {
6850
7145
  ...headers ? { headers } : {},
6851
7146
  ...query ? { query } : {},
6852
7147
  ...typeof p.key_header === "string" && p.key_header ? { keyHeader: p.key_header } : {},
7148
+ ...typeof p.key_prefix === "string" ? { keyPrefix: p.key_prefix } : {},
6853
7149
  ...p.stream_usage === false ? { streamUsage: false } : {},
6854
7150
  ...typeof p.max_tokens === "number" && p.max_tokens > 0 ? { maxTokens: Math.floor(p.max_tokens) } : {}
6855
7151
  };
@@ -6953,6 +7249,23 @@ function lendBase(name, lend, path = configPath()) {
6953
7249
  }
6954
7250
  //#endregion
6955
7251
  //#region packages/server/src/model.ts
7252
+ /** Where a mirror keeps the .tgz (M21): ISORITE_MODEL_BASE_URL names the folder it's in, tried before the rest. */
7253
+ function mirrorOf(env = process.env) {
7254
+ const base = env.ISORITE_MODEL_BASE_URL?.trim();
7255
+ return base ? `${base.replace(/\/+$/, "")}/${RELEASE_FILE}` : null;
7256
+ }
7257
+ /** How to fetch the model by hand, to copy: the file, its SHA-256, and the command that takes it. */
7258
+ function byHand(env = process.env) {
7259
+ const mirror = mirrorOf(env);
7260
+ return [
7261
+ "Fetch this file another way (a browser, or a machine that can reach it):",
7262
+ ` ${RELEASE}`,
7263
+ ...mirror ? [` or from your mirror: ${mirror}`] : [],
7264
+ ` SHA-256 ${RELEASE_SHA256}`,
7265
+ "then run:",
7266
+ ` isorite model --from ~/Downloads/${RELEASE_FILE}`
7267
+ ];
7268
+ }
6956
7269
  /** Where the server looks for the model, in order, and why each place is there. */
6957
7270
  function modelPlaces(env = process.env) {
6958
7271
  if (env.ISORITE_EMBED_DIR) return [{
@@ -7042,7 +7355,7 @@ function unpack(pkg) {
7042
7355
  }
7043
7356
  /** Fetch the model's files into `out`, keeping only files whose SHA-256 is the one expected: from a folder if one is
7044
7357
  * named; else from npm, then the GitHub release, then Hugging Face, each tried in turn. */
7045
- async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release = RELEASE, hub = HUB, files = FILES } = {}) {
7358
+ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release = RELEASE, mirror = null, url, hub = HUB, files = FILES, tgzSha = files === FILES ? RELEASE_SHA256 : null } = {}) {
7046
7359
  mkdirSync(out, { recursive: true });
7047
7360
  const missing = () => files.filter(([, to, want]) => !(existsSync(join(out, to)) && sha(readFileSync(join(out, to))) === want));
7048
7361
  for (const [, to] of files) if (!missing().some((f) => f[1] === to)) say(`${to}: already here`);
@@ -7071,31 +7384,42 @@ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release
7071
7384
  }
7072
7385
  };
7073
7386
  const why = (e, url) => e.message === "fetch failed" || e.cause ? unreachable(e, url) : e.message;
7074
- if (missing().length && pkg) try {
7075
- fromPackage(pkg);
7076
- } catch (e) {
7077
- tried.push(`npm (${pkg}): ${e.message}`);
7078
- say(`From npm (${pkg}): ${e.message}`);
7079
- }
7080
- if (missing().length && release) {
7387
+ /** The .tgz at an address, checked by its SHA-256 before it's unpacked; a failure is kept in `tried`. */
7388
+ const fromWeb = async (where, address) => {
7081
7389
  const tmp = mkdtempSync(join(tmpdir(), "isorite-model-release-"));
7082
7390
  try {
7083
- say(`From GitHub: ${release}`);
7084
- const res = await fetch(release);
7391
+ say(`From ${where}: ${address}`);
7392
+ const res = await fetch(address);
7085
7393
  if (!res.ok) throw new Error(`it said ${res.status}`);
7086
- const tgz = join(tmp, basename(new URL(release).pathname));
7087
- writeFileSync(tgz, new Uint8Array(await res.arrayBuffer()));
7394
+ const buf = new Uint8Array(await res.arrayBuffer());
7395
+ if (tgzSha && sha(buf) !== tgzSha) throw new Error(`not the file expected: its SHA-256 is ${sha(buf)}, not ${tgzSha}, so it was not unpacked`);
7396
+ const tgz = join(tmp, basename(new URL(address).pathname) || "isorite-model-granite-1.0.0.tgz");
7397
+ writeFileSync(tgz, buf);
7088
7398
  fromPackage(tgz);
7089
7399
  } catch (e) {
7090
- tried.push(`GitHub (${release}): ${why(e, release)}`);
7091
- say(`From GitHub: ${why(e, release)}`);
7400
+ tried.push(`${where} (${address}): ${why(e, address)}`);
7401
+ say(`From ${where}: ${why(e, address)}`);
7092
7402
  } finally {
7093
7403
  rmSync(tmp, {
7094
7404
  recursive: true,
7095
7405
  force: true
7096
7406
  });
7097
7407
  }
7408
+ };
7409
+ if (url) {
7410
+ await fromWeb("--url", url);
7411
+ if (missing().length) throw new ModelFetchError(tried);
7412
+ say(`The model is in ${out}`);
7413
+ return;
7414
+ }
7415
+ if (missing().length && mirror) await fromWeb("the mirror (ISORITE_MODEL_BASE_URL)", mirror);
7416
+ if (missing().length && pkg) try {
7417
+ fromPackage(pkg);
7418
+ } catch (e) {
7419
+ tried.push(`npm (${pkg}): ${e.message}`);
7420
+ say(`From npm (${pkg}): ${e.message}`);
7098
7421
  }
7422
+ if (missing().length && release) await fromWeb("GitHub", release);
7099
7423
  if (missing().length && hub) {
7100
7424
  say("From Hugging Face");
7101
7425
  try {
@@ -7115,6 +7439,19 @@ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release
7115
7439
  * checked by using it: it must load, and give DIM numbers for a sentence. Kept in the user's models, and used from
7116
7440
  * then on. */
7117
7441
  async function installFrom(from, say = console.log, env = process.env) {
7442
+ if (/\.(tgz|tar\.gz)$/.test(from) && existsSync(from) && statSync(from).isFile()) {
7443
+ const got = sha(readFileSync(from));
7444
+ if (got !== "8d8ab528ae289dfc81fd2bfa80c867ca0c8342a2d98f7c86141df4b15fdf028c") throw new Error(`${from} is not ${RELEASE_FILE}: its SHA-256 is ${got}, not ${RELEASE_SHA256}`);
7445
+ const dir = unpack(resolve(from));
7446
+ try {
7447
+ return await installFrom(join(dir, "package"), say, env);
7448
+ } finally {
7449
+ rmSync(dir, {
7450
+ recursive: true,
7451
+ force: true
7452
+ });
7453
+ }
7454
+ }
7118
7455
  if (!hasModel(from)) throw new Error(`${from} has no model: it needs model.onnx and tokenizer.json`);
7119
7456
  const granite = !existsSync(join(from, "isorite-model.json")) && FILES.every(([, to, want]) => existsSync(join(from, to)) && sha(readFileSync(join(from, to))) === want);
7120
7457
  let out = userModelDir(env);
@@ -7142,7 +7479,7 @@ async function installFrom(from, say = console.log, env = process.env) {
7142
7479
  }
7143
7480
  /** `isorite model [--from <folder>]`: says which model is installed and where, or installs one, and on failure
7144
7481
  * where it looked, what it tried, and what to do. */
7145
- async function modelCommand(from, say = console.log, env = process.env, sources = {}) {
7482
+ async function modelCommand(from, say = console.log, env = process.env, sources = {}, url) {
7146
7483
  const told = (i, why) => say(`search by meaning: ${i.name} at ${i.dir} (sha256 ${i.sha.slice(0, 12)}, ${i.pooling} pooling), found by ${why}`);
7147
7484
  if (env.ISORITE_EMBED === "off") say("ISORITE_EMBED=off: search by meaning is switched off, whatever is installed");
7148
7485
  if (from) try {
@@ -7159,27 +7496,35 @@ async function modelCommand(from, say = console.log, env = process.env, sources
7159
7496
  return 0;
7160
7497
  }
7161
7498
  say(`No model in ${places.map((p) => p.dir).join(", ")}`);
7499
+ if (!url) say(`If it can't be fetched from here: ${RELEASE} (SHA-256 ${RELEASE_SHA256}), then isorite model --from <that file>`);
7162
7500
  try {
7163
- await fetchModel(userModelDir(env), say, sources);
7501
+ await fetchModel(userModelDir(env), say, {
7502
+ mirror: mirrorOf(env),
7503
+ ...sources,
7504
+ ...url ? { url } : {}
7505
+ });
7164
7506
  return 0;
7165
7507
  } catch (e) {
7166
7508
  say(e.message);
7167
7509
  say([
7168
- "What to do:",
7169
- ` - copy the model's files from a machine that has them (${userModelDir()} there), then: isorite model --from <folder>`,
7170
- ` - or the package's .tgz, from ${RELEASE}: unpack it, then isorite model --from <its package folder>`,
7171
- ` - or any compatible model (ONNX, 384 numbers out, with its tokenizer.json): isorite model --from <folder>, or ISORITE_EMBED_DIR=<folder>`,
7510
+ ...byHand(env),
7511
+ "Or:",
7512
+ ` - a mirror: ISORITE_MODEL_BASE_URL=<the folder ${RELEASE_FILE} is in> isorite model, or isorite model --url <its address>`,
7513
+ ` - the model's files from a machine that has them (${userModelDir()} there): isorite model --from <folder>`,
7514
+ ` - any compatible model (ONNX, 384 numbers out, with its tokenizer.json): isorite model --from <folder>, or ISORITE_EMBED_DIR=<folder>`,
7172
7515
  "Search by words works without it."
7173
7516
  ].join("\n"));
7174
7517
  return 1;
7175
7518
  }
7176
7519
  }
7177
- var NAME$2, MODEL, RELEASE, PACKAGE, HUB, FILES, sha, userModelsDir, userModelDir, hasModel, ModelFetchError, modelFiles;
7520
+ var NAME$2, MODEL, RELEASE_FILE, RELEASE, RELEASE_SHA256, PACKAGE, HUB, FILES, sha, userModelsDir, userModelDir, hasModel, ModelFetchError, modelFiles;
7178
7521
  var init_model = __esmMin((() => {
7179
7522
  init_src();
7180
7523
  NAME$2 = "granite-embedding-small-english-r2";
7181
7524
  MODEL = `${NAME$2}-q8`;
7182
- RELEASE = "https://github.com/lachlanjs/isorite/releases/download/model-granite-1.0.0/isorite-model-granite-1.0.0.tgz";
7525
+ RELEASE_FILE = "isorite-model-granite-1.0.0.tgz";
7526
+ RELEASE = `https://github.com/lachlanjs/isorite/releases/download/model-granite-1.0.0/${RELEASE_FILE}`;
7527
+ RELEASE_SHA256 = "8d8ab528ae289dfc81fd2bfa80c867ca0c8342a2d98f7c86141df4b15fdf028c";
7183
7528
  PACKAGE = "isorite-model-granite@1.0.0";
7184
7529
  HUB = `https://huggingface.co/onnx-community/granite-embedding-small-english-r2-ONNX/resolve/1dc7835ba0cb9c76a3618d0bf0c427c97671b3c8`;
7185
7530
  FILES = [
@@ -7861,6 +8206,7 @@ var Terminal = class {
7861
8206
  };
7862
8207
  //#endregion
7863
8208
  //#region packages/server/src/http.ts
8209
+ init_src$1();
7864
8210
  init_src();
7865
8211
  const LOOPBACK = /* @__PURE__ */ new Set([
7866
8212
  "localhost",
@@ -7882,6 +8228,8 @@ const TYPES = {
7882
8228
  ".map": "application/json",
7883
8229
  ".txt": "text/plain; charset=utf-8"
7884
8230
  };
8231
+ /** What a picture may do when opened on its own: be drawn, and nothing else (no script, no fetch, no form). */
8232
+ const PICTURE_CSP = "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'";
7885
8233
  const Edit = z.object({
7886
8234
  base: z.string().max(64),
7887
8235
  text: z.string()
@@ -7982,6 +8330,7 @@ const SetupDraft = z.object({
7982
8330
  url: z.string().max(2e3),
7983
8331
  keyHeld: z.boolean(),
7984
8332
  keyHeader: z.string().max(200),
8333
+ keyPrefix: z.string().max(50).default(""),
7985
8334
  headers: z.array(Pair).max(40),
7986
8335
  query: z.array(Pair).max(40),
7987
8336
  pfx: Path,
@@ -8103,6 +8452,42 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes, terminal
8103
8452
  app.get("/api/map", (c) => answer(c, () => api.map()));
8104
8453
  app.get("/api/timeline", (c) => answer(c, () => api.timeline()));
8105
8454
  app.get("/api/search", (c) => answer(c, () => api.search(c.req.query("q") ?? "")));
8455
+ app.get("/api/pictures", (c) => answer(c, () => api.pictures()));
8456
+ app.post("/api/file", async (c) => {
8457
+ let origin = "";
8458
+ try {
8459
+ origin = new URL(c.req.header("origin") ?? "").host;
8460
+ } catch {}
8461
+ if (!c.req.header("origin") || origin !== c.req.header("host")) return refuse(c, 403, "cross-origin request refused");
8462
+ if (c.req.header("x-isorite-token") !== token) return refuse(c, 403, "bad token");
8463
+ const path = c.req.query("path") ?? "", type = pictureType(path);
8464
+ if (!type || (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase() !== type) return refuse(c, 415, "a picture, of the type its name says");
8465
+ if (Number(c.req.header("content-length") ?? 0) > 2e7) return refuse(c, 413, "picture too large");
8466
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
8467
+ if (bytes.length > 2e7) return refuse(c, 413, "picture too large");
8468
+ return answer(c, () => api.savePicture(path, bytes));
8469
+ });
8470
+ app.get("/api/file", async (c) => {
8471
+ const code = c.req.query("code"), path = code ?? c.req.query("path") ?? "";
8472
+ let found;
8473
+ try {
8474
+ found = await api.file(path, code === void 0 ? "notes" : "code");
8475
+ } catch (e) {
8476
+ if (e instanceof EngineError) return refuse(c, e.status, e.message);
8477
+ throw e;
8478
+ }
8479
+ if (!found) return refuse(c, 404, `no picture at ${path}`);
8480
+ const etag = `"${found.version}"`;
8481
+ const headers = {
8482
+ "Content-Type": found.type,
8483
+ "Cache-Control": "no-cache",
8484
+ ETag: etag,
8485
+ "X-Content-Type-Options": "nosniff",
8486
+ "Content-Security-Policy": PICTURE_CSP
8487
+ };
8488
+ if (c.req.header("if-none-match") === etag) return c.body(null, 304, headers);
8489
+ return c.body(found.bytes, 200, headers);
8490
+ });
8106
8491
  app.put("/api/note", async (c) => {
8107
8492
  const refused = writeRefused(c);
8108
8493
  if (refused) return refused;
@@ -8437,12 +8822,26 @@ var NodeFiles = class {
8437
8822
  throw e;
8438
8823
  }
8439
8824
  }
8440
- /** Written beside the file and renamed over it, so a reader never sees half a note. */
8825
+ async readBytes(path) {
8826
+ try {
8827
+ return new Uint8Array(await readFile(this.at(path)));
8828
+ } catch (e) {
8829
+ if (gone(e)) return null;
8830
+ throw e;
8831
+ }
8832
+ }
8441
8833
  async write(path, text) {
8834
+ await this.replace(path, text);
8835
+ }
8836
+ async writeBytes(path, bytes) {
8837
+ await this.replace(path, bytes);
8838
+ }
8839
+ /** Written beside the file and renamed over it, so a reader never sees half a note. */
8840
+ async replace(path, data) {
8442
8841
  const full = this.at(path), beside = `${full}.${process.pid}.${this.n++}.tmp`;
8443
8842
  await mkdir(dirname(full), { recursive: true });
8444
8843
  try {
8445
- await writeFile(beside, text, "utf8");
8844
+ await writeFile(beside, data, typeof data === "string" ? "utf8" : void 0);
8446
8845
  await rename(beside, full);
8447
8846
  } catch (e) {
8448
8847
  await rm(beside, { force: true });
@@ -8456,10 +8855,27 @@ var NodeFiles = class {
8456
8855
  }
8457
8856
  async list(dir) {
8458
8857
  try {
8459
- return (await readdir(this.at(dir), { withFileTypes: true })).filter((e) => e.isDirectory() || e.isFile()).map((e) => ({
8460
- name: e.name,
8461
- dir: e.isDirectory()
8462
- })).sort((a, b) => a.name < b.name ? -1 : 1);
8858
+ const at = this.at(dir), entries = await readdir(at, { withFileTypes: true });
8859
+ return (await Promise.all(entries.filter((e) => e.isDirectory() || e.isFile()).map(async (e) => {
8860
+ if (e.isDirectory()) return {
8861
+ name: e.name,
8862
+ dir: true
8863
+ };
8864
+ if (e.name.endsWith(".md")) return {
8865
+ name: e.name,
8866
+ dir: false
8867
+ };
8868
+ const s = await stat(resolve(at, e.name)).catch(() => null);
8869
+ return s ? {
8870
+ name: e.name,
8871
+ dir: false,
8872
+ size: s.size,
8873
+ mtime: s.mtimeMs
8874
+ } : {
8875
+ name: e.name,
8876
+ dir: false
8877
+ };
8878
+ }))).sort((a, b) => a.name < b.name ? -1 : 1);
8463
8879
  } catch (e) {
8464
8880
  if (gone(e)) return [];
8465
8881
  throw e;
@@ -8481,6 +8897,7 @@ const FORM = [
8481
8897
  "auth",
8482
8898
  "key",
8483
8899
  "key_header",
8900
+ "key_prefix",
8484
8901
  "headers",
8485
8902
  "query",
8486
8903
  "pfx",
@@ -8512,6 +8929,7 @@ function readSetup(config) {
8512
8929
  url: gateway ? p.url : OPENROUTER.url,
8513
8930
  keyHeld: text(p.key) !== "",
8514
8931
  keyHeader: text(p.key_header),
8932
+ keyPrefix: text(p.key_prefix).trim(),
8515
8933
  headers: pairs(p.headers),
8516
8934
  query: pairs(p.query),
8517
8935
  pfx: text(p.pfx),
@@ -8553,6 +8971,9 @@ function applySetup(config, draft) {
8553
8971
  }
8554
8972
  if (gateway) {
8555
8973
  if (draft.keyHeader.trim()) p.key_header = draft.keyHeader.trim();
8974
+ const prefix = (draft.keyPrefix ?? "").trim();
8975
+ if (prefix) p.key_prefix = prefix + " ";
8976
+ else if (old.key_prefix === "" && draft.keyHeader.trim()) p.key_prefix = "";
8556
8977
  const pairs = (field, given) => {
8557
8978
  const before = table(old[field]), out = {};
8558
8979
  for (const pair of given) {
@@ -8713,7 +9134,7 @@ var Setup = class {
8713
9134
  };
8714
9135
  //#endregion
8715
9136
  //#region packages/server/src/version.ts
8716
- const VERSION = "0.8.1";
9137
+ const VERSION = "0.10.0";
8717
9138
  //#endregion
8718
9139
  //#region packages/server/src/git.ts
8719
9140
  /** The top of the repository `folder` is in, or null: the nearest folder holding `.git`, a directory or, in a
@@ -8946,6 +9367,27 @@ async function serve$1(path, { host = "127.0.0.1", port = 4767, allowHosts = [],
8946
9367
  process.on("SIGTERM", stop);
8947
9368
  }
8948
9369
  //#endregion
9370
+ //#region packages/server/src/self.ts
9371
+ /** Whether this is the single executable. */
9372
+ const single = () => {
9373
+ try {
9374
+ return isSea();
9375
+ } catch {
9376
+ return false;
9377
+ }
9378
+ };
9379
+ /** What runs isorite again with `args`, given the script (npm's or a checkout's) to run when not single. */
9380
+ function self(script, args, execArgv = process.execArgv) {
9381
+ return {
9382
+ command: process.execPath,
9383
+ args: single() ? args : [
9384
+ ...execArgv,
9385
+ script,
9386
+ ...args
9387
+ ]
9388
+ };
9389
+ }
9390
+ //#endregion
8949
9391
  //#region packages/server/src/term.ts
8950
9392
  init_src$1();
8951
9393
  const MAIN = import.meta.url.endsWith(".ts") ? fileURLToPath(new URL("./main.ts", import.meta.url)) : fileURLToPath(import.meta.url);
@@ -8992,14 +9434,14 @@ async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4,
8992
9434
  if (!opened) throw new StudyError(`nothing is serving on port ${port}: name a base`);
8993
9435
  const log = join(tmpdir(), `isorite-term-${port}.log`);
8994
9436
  const out = openSync(log, "w");
8995
- started = spawn(process.execPath, [
8996
- MAIN,
9437
+ const again = self(MAIN, [
8997
9438
  "serve",
8998
9439
  path,
8999
9440
  "--port",
9000
9441
  String(port),
9001
9442
  "--held"
9002
- ], {
9443
+ ], []);
9444
+ started = spawn(again.command, again.args, {
9003
9445
  stdio: [
9004
9446
  "ignore",
9005
9447
  out,
@@ -9068,6 +9510,25 @@ async function freePort() {
9068
9510
  });
9069
9511
  });
9070
9512
  }
9513
+ /** Whether `address` asks a browser that opens it for a password (M21): a 401 with Basic, as OpenCode 2 answers
9514
+ * for /api (its page itself is served to anyone). Asked as a browser navigates, by node:http: OpenCode leaves the
9515
+ * challenge out for its own page's fetches (Sec-Fetch-Mode: cors), which is all Node's fetch can send. */
9516
+ function asksPassword(address) {
9517
+ return new Promise((done) => {
9518
+ const req = get(address, {
9519
+ headers: {
9520
+ Accept: "text/html",
9521
+ "Sec-Fetch-Mode": "navigate",
9522
+ "Sec-Fetch-Dest": "document"
9523
+ },
9524
+ timeout: 3e3
9525
+ }, (res) => {
9526
+ res.resume();
9527
+ done(res.statusCode === 401 && /^\s*basic\b/i.test(String(res.headers["www-authenticate"] ?? "")));
9528
+ });
9529
+ req.on("timeout", () => req.destroy()).on("error", () => done(false));
9530
+ });
9531
+ }
9071
9532
  /** A POST to the isorite server, made as its page makes one: from its origin, with the token it hands out. */
9072
9533
  async function tell(url, path, body) {
9073
9534
  const token = await tokenOf(url);
@@ -9187,8 +9648,57 @@ function where(port, models, env, tty, page = "/") {
9187
9648
  async function tokenOf(url) {
9188
9649
  return (await (await fetch(`${url}/api/session`)).json()).token;
9189
9650
  }
9651
+ /** `isorite opencode` (M21): OpenCode 2, at the version isorite is tested with, in isorite's own npm prefix, the
9652
+ * global one left as it is. npm's registry, proxy and mirror settings apply. Run again, it installs it again. */
9653
+ function installOpenCode(env = process.env, say = console.log, npm = "npm") {
9654
+ const prefix = ownPrefix(env);
9655
+ say(`isorite opencode: installing ${PACKAGE$1} in ${prefix}, for isorite alone; nothing global is changed`);
9656
+ const win = process.platform === "win32";
9657
+ const done = spawnSync(npm, [
9658
+ "install",
9659
+ "--prefix",
9660
+ win ? `"${prefix}"` : prefix,
9661
+ "--no-fund",
9662
+ "--no-audit",
9663
+ "--loglevel=error",
9664
+ PACKAGE$1
9665
+ ], {
9666
+ stdio: "inherit",
9667
+ env,
9668
+ shell: win
9669
+ });
9670
+ if (done.status !== 0) {
9671
+ say(`npm could not install it${done.error ? `: ${done.error.message}` : ""}. If npm's registry or mirror is the trouble, fetch an OpenCode 2 another way and name it: ISORITE_OPENCODE=/path/to/opencode isorite term`);
9672
+ return 1;
9673
+ }
9674
+ try {
9675
+ const found = findOpenCode(env);
9676
+ say(`OpenCode ${found.version} is ${found.command}: isorite term uses it from now on`);
9677
+ return 0;
9678
+ } catch (e) {
9679
+ say(e.message);
9680
+ return 1;
9681
+ }
9682
+ }
9683
+ /** OpenCode 2, or, when there's none and a person is at the terminal, the offer to install it for isorite alone. */
9684
+ async function openCodeFor(env) {
9685
+ try {
9686
+ return findOpenCode(env);
9687
+ } catch (e) {
9688
+ if (!(e instanceof NoOpenCode) || env.ISORITE_OPENCODE || !process.stdin.isTTY || !process.stdout.isTTY) throw e;
9689
+ console.error(e.message);
9690
+ const ask = createInterface({
9691
+ input: process.stdin,
9692
+ output: process.stdout
9693
+ });
9694
+ const yes = /^y(es)?$/i.test((await ask.question(`Install OpenCode 2 for isorite only, in ${ownPrefix(env)}? [y/N] `)).trim());
9695
+ ask.close();
9696
+ if (!yes || installOpenCode(env) !== 0) throw new StudyError("isorite term needs OpenCode 2");
9697
+ return findOpenCode(env);
9698
+ }
9699
+ }
9190
9700
  async function term(path, { port = 4767, cwd = process.cwd(), env: given = process.env, wait = 12e4, onServer } = {}) {
9191
- const opencode = findOpenCode(given);
9701
+ const opencode = await openCodeFor(given);
9192
9702
  const server = await ensureServer(path, {
9193
9703
  port,
9194
9704
  env: given,
@@ -9207,7 +9717,6 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9207
9717
  }
9208
9718
  const { gateway, env } = models;
9209
9719
  const said = where(port, models, given, void 0, new URL(server.url).pathname.replace(/\/?$/, "/"));
9210
- for (const line of said) console.log(line);
9211
9720
  const agents = path === void 0 ? null : join(locate(path).notes, AGENTS_FILE);
9212
9721
  const home = writeHome(defaultHome(env), {
9213
9722
  gateway,
@@ -9229,6 +9738,8 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9229
9738
  server.release();
9230
9739
  throw e;
9231
9740
  }
9741
+ if (await asksPassword(`${backend.url}/api/session`)) said.push(` OpenCode's own web page (port ${new URL(backend.url).port}) asks for a password: don't open it. isorite's page is above, and /web in the terminal opens it`);
9742
+ for (const line of said) console.log(line);
9232
9743
  await tell(server.url, "/api/terminal/attach", {
9233
9744
  url: backend.url,
9234
9745
  password: backend.password,
@@ -9295,6 +9806,7 @@ const READ = [
9295
9806
  "SSH_CONNECTION",
9296
9807
  "ISORITE_EMBED",
9297
9808
  "ISORITE_EMBED_DIR",
9809
+ "ISORITE_MODEL_BASE_URL",
9298
9810
  "XDG_DATA_HOME",
9299
9811
  "XDG_CACHE_HOME"
9300
9812
  ];
@@ -9341,7 +9853,10 @@ async function checkProvider(p, h, key, say) {
9341
9853
  const missing = models.filter((m) => !listed.includes(m));
9342
9854
  say("reached", "ok", `${to.url.replace(/\?.*$/, "")} answered with ${listed.length} models`);
9343
9855
  if (listed.length && missing.length) say("models", "fail", `${missing.join(", ")} not among them; it has ${listed.slice(0, 12).join(", ")}${listed.length > 12 ? ", …" : ""}. Name its models under [tiers]`);
9344
- } else say("reached", res.status === 401 || res.status === 403 ? "fail" : "ok", res.status === 401 || res.status === 403 ? `it said ${res.status} to the list of models: the key${p.keyHeader ? "" : ", or the header it goes in (key_header)"}, or a header it wants (headers), is not accepted` : `it answered (${res.status} for the list of models, which some gateways don't have)`);
9856
+ } else if (res.status === 401 || res.status === 403) {
9857
+ const said = refusal(await res.text().catch(() => ""), key);
9858
+ say("reached", "fail", `it said ${res.status} to the list of models${said ? `: “${said}”` : ""}. The key${key ? `, sent as ${keySentAs(p, key)},` : ""} or a header it wants (headers), is not accepted: check key_header and key_prefix`);
9859
+ } else say("reached", "ok", `it answered (${res.status} for the list of models, which some gateways don't have)`);
9345
9860
  } catch (e) {
9346
9861
  reached = false;
9347
9862
  say("reached", "fail", unreachable(e, p.url));
@@ -9401,7 +9916,7 @@ async function doctor(folder, o = {}) {
9401
9916
  const tls = gatewayTls(path, env);
9402
9917
  http ??= gatewayHttp(provider, tls, env);
9403
9918
  if (existsSync(path)) say("config", "ok", `${path}: ${provider.name} at ${provider.url}`);
9404
- say("key", key ? "ok" : tls ? "warn" : "fail", key ? `${hidden(key)}, sent ${provider.keyHeader ? `in ${provider.keyHeader}` : "as Authorization: Bearer"}` : tls ? "none; the client certificate alone is sent" : "none: set one in Iso's settings, or as key under [provider]");
9919
+ say("key", key ? "ok" : tls ? "warn" : "fail", key ? `sent as ${keySentAs(provider, key)}${provider.keyHeader?.toLowerCase() === "authorization" && provider.keyPrefix === void 0 ? ` (key_header = "${provider.keyHeader}" alone means Bearer since 0.9.0; key_prefix = "" sends it bare)` : ""}` : tls ? "none; the client certificate alone is sent" : "none: set one in Iso's settings, or as key under [provider]");
9405
9920
  if (tls) say("certificate", "ok", `${tls.files.join(", ")}${tls.pfx || tls.cert ? "" : " (an authority only, no client certificate)"}`);
9406
9921
  const tiers = `low ${provider.tiers.low}, mid ${provider.tiers.mid}, max ${provider.tiers.max}`;
9407
9922
  say("models", provider.defaultTiers ? "warn" : "ok", provider.defaultTiers ? `${tiers}: OpenRouter's names, kept because [tiers] names none for ${provider.name}; a gateway usually calls its models otherwise` : tiers);
@@ -9427,11 +9942,21 @@ async function doctor(folder, o = {}) {
9427
9942
  if (!o.report) out(` asking ${p.name}: its models, then ${models.length + 1} replies of a few words (a few hundred tokens in all)`);
9428
9943
  await checkProvider(p, h, key, say);
9429
9944
  }
9945
+ const began = Date.now(), git = spawnSync(o.git ?? "git", ["--version"], {
9946
+ encoding: "utf8",
9947
+ env,
9948
+ timeout: 3e3,
9949
+ killSignal: "SIGKILL"
9950
+ });
9951
+ const took = Date.now() - began;
9952
+ if (git.error && git.error.code === "ENOENT") say("git", "skip", "not found: isorite doesn't need it; OpenCode works without it, as a folder that isn't a repository");
9953
+ else if (git.status !== 0 || took >= 3e3) say("git", "warn", `git --version ${took >= 3e3 ? "didn't answer in 3 s" : `failed after ${took} ms`}: likely a wrapper (a company's) that waits on the network. isorite no longer waits on git to start; OpenCode may, and isorite term then says it's waiting for OpenCode's server`);
9954
+ else say("git", took > 1e3 ? "warn" : "ok", `${git.stdout.trim()}, in ${took} ms${took > 1e3 ? ": slow, likely a wrapper; OpenCode may wait on it as it starts" : ""}`);
9430
9955
  try {
9431
9956
  const found = findOpenCode(env, o.probe);
9432
- say("opencode", "ok", `${found.command} ${found.version}`);
9957
+ say("opencode", "ok", `${found.command} ${found.version}${found.from === "the PATH" ? "" : ` (${found.from})`}`);
9433
9958
  } catch (e) {
9434
- say("opencode", "warn", e.message.replace(/\n\s*/g, " "));
9959
+ say("opencode", e instanceof NoOpenCode && e.old ? "fail" : "warn", e.message.replace(/\n\s*/g, " "));
9435
9960
  }
9436
9961
  const port = o.port ?? 4767, url = `http://127.0.0.1:${port}`, served = await serving(url);
9437
9962
  say("port", "ok", served === null ? `${port} is free: isorite term starts a server there` : `${port} is isorite's, serving ${served}`);
@@ -9449,6 +9974,12 @@ async function doctor(folder, o = {}) {
9449
9974
  known = false;
9450
9975
  }
9451
9976
  say("terminal", known ? "ok" : "warn", terminal);
9977
+ const hint = "isorite term also runs OpenCode's own web server, on a port of its own, which asks for a password: open isorite's pages and panes (/web in the terminal), not OpenCode's";
9978
+ if (served === null) say("browser pages", "ok", hint);
9979
+ else {
9980
+ const asking = (await Promise.all([`${url}/`, `${url}/api/session`].map(async (at) => await asksPassword(at) ? at : null))).filter(Boolean);
9981
+ say("browser pages", asking.length ? "fail" : "ok", asking.length ? `${asking.join(" and ")} asked for a password (401, Basic): that is not isorite's server; stop it, or give isorite another port (--port <n>)` : `isorite's pages at ${url}/ ask for no password. ${hint}`);
9982
+ }
9452
9983
  if (env.SSH_CONNECTION) say("browser", "ok", `over SSH: on your own machine, ssh -L ${port}:localhost:${port} <this host>, then http://localhost:${port}/`);
9453
9984
  let model = null;
9454
9985
  if (env.ISORITE_EMBED === "off") say("search by meaning", "skip", "switched off (ISORITE_EMBED=off)");
@@ -9460,7 +9991,23 @@ async function doctor(folder, o = {}) {
9460
9991
  say("search by meaning", "fail", `${dir}: ${e.message}`);
9461
9992
  }
9462
9993
  if (model) say("search by meaning", "ok", `${model.name} at ${model.dir}`);
9463
- else if (!dir) say("search by meaning", "warn", `no model in ${modelPlaces(env).map((p) => p.dir).join(" or ")}: isorite model fetches it (53 MB). Search by words works without it`);
9994
+ else if (!dir) {
9995
+ let reach = "";
9996
+ if (!o.offline) {
9997
+ const from = mirrorOf(env) ?? RELEASE;
9998
+ try {
9999
+ const res = await fetch(from, {
10000
+ method: "HEAD",
10001
+ redirect: "follow",
10002
+ signal: AbortSignal.timeout(1e4)
10003
+ });
10004
+ reach = res.ok ? ` ${from} answers from here.` : ` ${from} said ${res.status}.`;
10005
+ } catch (e) {
10006
+ reach = ` ${from} can't be reached from here: ${unreachable(e, from)}.`;
10007
+ }
10008
+ }
10009
+ say("search by meaning", "warn", `no model in ${modelPlaces(env).map((p) => p.dir).join(" or ")}: isorite model fetches it (53 MB).${reach} By hand: ${mirrorOf(env) ?? RELEASE} (SHA-256 ${RELEASE_SHA256}), then isorite model --from <that file>. Search by words works without it`);
10010
+ }
9464
10011
  }
9465
10012
  if (folder) try {
9466
10013
  const { notes } = locate(folder);
@@ -9538,7 +10085,8 @@ function proxied(env, execArgv = process.execArgv) {
9538
10085
  }
9539
10086
  /** Run this command again with `env`, its input and output its own; its exit code. */
9540
10087
  function rerun(env, argv = process.argv, execArgv = process.execArgv) {
9541
- const child = spawn(process.execPath, [...execArgv, ...argv.slice(1)], {
10088
+ const again = self(argv[1], argv.slice(2), execArgv);
10089
+ const child = spawn(again.command, again.args, {
9542
10090
  stdio: "inherit",
9543
10091
  env
9544
10092
  });
@@ -9746,7 +10294,7 @@ async function mcp(path, { port = 4767, agent = "mcp", quiet = QUIET_MS, input =
9746
10294
  }
9747
10295
  throw Object.assign(/* @__PURE__ */ new Error(`no method ${rpc.method}`), { code: -32601 });
9748
10296
  };
9749
- const lines = createInterface({
10297
+ const lines = createInterface$1({
9750
10298
  input,
9751
10299
  crlfDelay: Infinity
9752
10300
  });
@@ -10829,6 +11377,19 @@ const finderHttp = (pace = 0) => scriptedHttp(finderAnswer, pace);
10829
11377
  //#region packages/guide/src/node.ts
10830
11378
  /** In a checkout, the package's notes/; in the npm package, where this is bundled into dist/, the guide/ beside it. */
10831
11379
  const GUIDE_DIR = [join(import.meta.dirname, "..", "guide"), join(import.meta.dirname, "..", "notes")].find((d) => existsSync(join(d, "start-here.md"))) ?? join(import.meta.dirname, "..", "notes");
11380
+ /** The guide's pictures (M22), by path, as bytes. */
11381
+ function loadGuidePictures(dir = GUIDE_DIR) {
11382
+ const out = {};
11383
+ const visit = (at) => {
11384
+ for (const name of readdirSync(at)) {
11385
+ const path = join(at, name);
11386
+ if (statSync(path).isDirectory()) visit(path);
11387
+ else if (/\.(png|jpe?g|gif|svg|webp|avif)$/i.test(name)) out[relative(dir, path).split(sep).join("/")] = new Uint8Array(readFileSync(path));
11388
+ }
11389
+ };
11390
+ visit(dir);
11391
+ return out;
11392
+ }
10832
11393
  /** The guide's files as they stand in the package: its notes and its AGENTS.md, by path. */
10833
11394
  function loadGuide(dir = GUIDE_DIR) {
10834
11395
  const out = {};
@@ -10856,7 +11417,7 @@ function writeGuide(folder) {
10856
11417
  fresh: false
10857
11418
  };
10858
11419
  if (existsSync(at) && readdirSync(at).some((f) => f !== ".isorite")) throw new StudyError(`${at} has files in it that are not the guide: name another folder, isorite guide <folder>`);
10859
- for (const [path, text] of Object.entries(loadGuide())) {
11420
+ for (const [path, text] of [...Object.entries(loadGuide()), ...Object.entries(loadGuidePictures())]) {
10860
11421
  mkdirSync(dirname(join(at, path)), { recursive: true });
10861
11422
  writeFileSync(join(at, path), text);
10862
11423
  }
@@ -10905,8 +11466,21 @@ async function writeExample(folder, now = Date.now()) {
10905
11466
  mkdirSync(dirname(at), { recursive: true });
10906
11467
  writeFileSync(at, text);
10907
11468
  }
11469
+ copyPictures(NOTES_DIR, notes);
10908
11470
  return { notes };
10909
11471
  }
11472
+ /** The pictures among a folder's notes (M22), copied as they are: they have no history. */
11473
+ function copyPictures(from, to, dir = from) {
11474
+ for (const name of readdirSync(dir)) {
11475
+ const path = join(dir, name);
11476
+ if (statSync(path).isDirectory()) copyPictures(from, to, path);
11477
+ else if (citeKind(name) === "image") {
11478
+ const at = join(to, relative(from, path));
11479
+ mkdirSync(dirname(at), { recursive: true });
11480
+ copyFileSync(path, at);
11481
+ }
11482
+ }
11483
+ }
10910
11484
  const GEOMETRY_DIR = join(import.meta.dirname, "..", "geometry");
10911
11485
  /** The geometry example's notes, and the code beside them, as they stand today, by path. */
10912
11486
  function loadGeometry(dir = GEOMETRY_DIR) {
@@ -11037,7 +11611,8 @@ init_model();
11037
11611
  const USAGE = `isorite: an agentic harness for understanding
11038
11612
 
11039
11613
  isorite new <folder> start a knowledge base: the folder, empty, with isorite's .isorite/
11040
- isorite check <notes> check a folder of notes: frontmatter, links, cycles
11614
+ isorite check <notes> [--unused] check a folder of notes: frontmatter, links, cycles; --unused lists
11615
+ the pictures no note shows
11041
11616
  isorite serve <notes> [--port <n>] [--allow-host <name>]
11042
11617
  open it in the browser, on this machine only
11043
11618
  isorite migrate <study base> [--merge] move a study base (format 1) into its notes' .isorite/; exercises
@@ -11051,9 +11626,13 @@ const USAGE = `isorite: an agentic harness for understanding
11051
11626
  isorite mcp [<notes>] [--port <n>] [--agent <name>]
11052
11627
  the knowledge base's tools over MCP, on stdio, for Claude Code or Pi:
11053
11628
  claude mcp add isorite -- isorite mcp <base> --agent claude-code
11054
- isorite model [--from <folder>] the model for search by meaning: which is installed, and where; else
11055
- fetched (53 MB, once) from npm, GitHub or Hugging Face; or from a
11629
+ isorite model [--from <folder|.tgz>] [--url <url>]
11630
+ the model for search by meaning: which is installed, and where; else
11631
+ fetched (53 MB, once) from ISORITE_MODEL_BASE_URL, npm, GitHub or
11632
+ Hugging Face, or --url alone; or from the .tgz fetched by hand, or a
11056
11633
  folder: its files copied there, or any compatible model
11634
+ isorite opencode OpenCode 2 for isorite term, installed in isorite's own folder
11635
+ (~/.config/isorite/opencode-cli), the global one left as it is
11057
11636
  isorite doctor [<notes>] [--report] [--offline]
11058
11637
  whether isorite can work here: the configuration, the network, the
11059
11638
  provider (asked a few words, a few hundred tokens), the terminal and
@@ -11070,7 +11649,7 @@ const USAGE = `isorite: an agentic harness for understanding
11070
11649
  the browser; run again, as you left it. Iso is your model, or with
11071
11650
  none set up, the guide's finder (also: isorite --tutorial)
11072
11651
  `;
11073
- function check(notes, out = console.log) {
11652
+ function check(notes, out = console.log, unused = false) {
11074
11653
  const root = resolve(notes);
11075
11654
  if (!existsSync(root) || !statSync(root).isDirectory()) {
11076
11655
  out(`no folder at ${root}`);
@@ -11081,12 +11660,18 @@ function check(notes, out = console.log) {
11081
11660
  for (const c of cycles) out(`warning requires cycle: ${c.join(" -> ")}`);
11082
11661
  const errors = issues.filter((i) => i.level === "error").length;
11083
11662
  out(`${bundle.concepts.size} notes in ${bundle.directories.size} folders; ${errors} errors, ${issues.length - errors} warnings, ${cycles.length} requires cycles`);
11663
+ if (unused) {
11664
+ const cited = new Set([...bundle.concepts.values()].flatMap((c) => c.cites.map((x) => x.target)));
11665
+ const idle = [...bundle.files].filter((f) => citeKind(f) === "image" && !cited.has(f)).sort();
11666
+ for (const f of idle) out(`unused ${f}`);
11667
+ out(idle.length ? `${idle.length} ${idle.length === 1 ? "picture" : "pictures"} no note shows` : "every picture is shown by a note");
11668
+ }
11084
11669
  return errors ? 1 : 0;
11085
11670
  }
11086
11671
  function main(argv) {
11087
11672
  const [command, ...rest] = argv;
11088
11673
  try {
11089
- if (command === "check" && rest[0]) return check(rest[0]);
11674
+ if (command === "check" && rest.find((r) => !r.startsWith("--"))) return check(rest.find((r) => !r.startsWith("--")), console.log, rest.includes("--unused"));
11090
11675
  if (command === "new" && rest[0]) {
11091
11676
  const made = newBase(rest[0]);
11092
11677
  console.log(`${made}: a knowledge base, empty. Open it in the browser, or beside your code in the terminal:\n isorite serve ${made}\n cd <your code> && isorite term ${made}`);
@@ -11151,13 +11736,18 @@ function main(argv) {
11151
11736
  });
11152
11737
  }
11153
11738
  if (command === "model") {
11154
- const from = rest[rest.indexOf("--from") + 1];
11739
+ const from = rest[rest.indexOf("--from") + 1], url = rest[rest.indexOf("--url") + 1];
11155
11740
  if (rest.includes("--from") && !from) {
11156
- console.error("--from takes a folder");
11741
+ console.error("--from takes a folder, or the model's .tgz");
11742
+ return 2;
11743
+ }
11744
+ if (rest.includes("--url") && !/^https?:\/\//.test(url ?? "")) {
11745
+ console.error("--url takes the address of the model's .tgz");
11157
11746
  return 2;
11158
11747
  }
11159
- return modelCommand(rest.includes("--from") ? from : void 0);
11748
+ return modelCommand(rest.includes("--from") ? from : void 0, console.log, process.env, {}, rest.includes("--url") ? url : void 0);
11160
11749
  }
11750
+ if (command === "opencode") return installOpenCode();
11161
11751
  if (command === "memory") return memory(rest.find((a) => !a.startsWith("--")));
11162
11752
  if (command === "bases") return bases(rest);
11163
11753
  if (command === "--version" || command === "-v") {
@@ -11232,16 +11822,19 @@ function starting(argv, env = process.env, write = (s) => process.stderr.write(s
11232
11822
  write("isorite: starting…\n");
11233
11823
  return true;
11234
11824
  }
11235
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) {
11236
- starting(process.argv.slice(2));
11825
+ /** The command as run: said to be starting, the proxy turned on (running itself again), the system's authorities
11826
+ * trusted; its exit code. The single executable calls this (M21). */
11827
+ async function run(argv) {
11828
+ starting(argv);
11237
11829
  delete process.env.ISORITE_STARTING;
11238
11830
  const env = proxied(process.env);
11239
- if (env) process.exit(await rerun({
11831
+ if (env) return rerun({
11240
11832
  ...env,
11241
11833
  ISORITE_STARTING: "1"
11242
- }));
11834
+ });
11243
11835
  trustSystem();
11244
- process.exit(await main(process.argv.slice(2)));
11836
+ return main(argv);
11245
11837
  }
11838
+ if (!single() && process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) process.exit(await run(process.argv.slice(2)));
11246
11839
  //#endregion
11247
- export { check, main, starting };
11840
+ export { check, main, run, starting };