isorite 0.15.0 → 0.16.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 (35) hide show
  1. package/dist/main.js +416 -61
  2. package/guide/out-of-the-base/web-and-code-links.md +3 -1
  3. package/package.json +1 -1
  4. package/web/_app/immutable/assets/0.BEFtmzIh.css +1 -0
  5. package/web/_app/immutable/assets/4.LR47AJKA.css +1 -0
  6. package/web/_app/immutable/assets/NoteList.C2GYAaLW.css +1 -0
  7. package/web/_app/immutable/chunks/BHORylQC.js +1 -0
  8. package/web/_app/immutable/chunks/{LbnMg2Kc.js → BMijTvgG.js} +1 -1
  9. package/web/_app/immutable/chunks/{Djo-ijeo.js → BtYMl4bD.js} +1 -1
  10. package/web/_app/immutable/chunks/{BsY4iMci.js → C8FxuPry.js} +1 -1
  11. package/web/_app/immutable/chunks/CA-rzUq8.js +524 -0
  12. package/web/_app/immutable/chunks/{D1MhxXyn.js → CTuuQ66F.js} +1 -1
  13. package/web/_app/immutable/chunks/{D4EpFhYZ.js → CglDFp51.js} +1 -1
  14. package/web/_app/immutable/chunks/{B_x6_Y3m.js → D9uNsgxP.js} +1 -1
  15. package/web/_app/immutable/chunks/{wD7RSG2d.js → Qej0SEux.js} +1 -1
  16. package/web/_app/immutable/chunks/{B9TKPEJD.js → TmxIGCN3.js} +1 -1
  17. package/web/_app/immutable/chunks/{BDJlNL-I.js → Wv-ICQRy.js} +3 -3
  18. package/web/_app/immutable/entry/{app.Av5W3V8v.js → app.B9TPZNFw.js} +2 -2
  19. package/web/_app/immutable/entry/start.WU9HKeyT.js +1 -0
  20. package/web/_app/immutable/nodes/{0.BTVYyqUm.js → 0.C9NFEqJd.js} +2 -2
  21. package/web/_app/immutable/nodes/{1.DR-b7PMP.js → 1.B5PJwWOG.js} +1 -1
  22. package/web/_app/immutable/nodes/{2.BePyTqoa.js → 2.DRtC7zjs.js} +1 -1
  23. package/web/_app/immutable/nodes/{3.D2rfIXQP.js → 3.EEMisvUL.js} +1 -1
  24. package/web/_app/immutable/nodes/4.DthmsFQC.js +19 -0
  25. package/web/_app/immutable/nodes/5.CEu0DlpQ.js +1 -0
  26. package/web/_app/version.json +1 -1
  27. package/web/index.html +14 -14
  28. package/web/_app/immutable/assets/0.CtWz9d2A.css +0 -1
  29. package/web/_app/immutable/assets/4.fOHsAKBF.css +0 -1
  30. package/web/_app/immutable/assets/NoteList.E6jC-AtZ.css +0 -1
  31. package/web/_app/immutable/chunks/B7Bf3hc7.js +0 -1
  32. package/web/_app/immutable/chunks/Dx_7xlS4.js +0 -523
  33. package/web/_app/immutable/entry/start.CbpxCvWi.js +0 -1
  34. package/web/_app/immutable/nodes/4.7tuUcODS.js +0 -15
  35. package/web/_app/immutable/nodes/5.Cs9hcGU9.js +0 -1
package/dist/main.js CHANGED
@@ -619,6 +619,39 @@ function splitFrontmatter(source) {
619
619
  if (typeof meta !== "object" || Array.isArray(meta)) throw new FrontmatterError("frontmatter is not a mapping");
620
620
  return [meta, body.replace(/^\n+/, "")];
621
621
  }
622
+ /** A frontmatter line `key: value` at the top level whose value the YAML parser refused, such as an unquoted
623
+ * colon (`title: Foo: bar`) or a value starting with a backtick, `%` or `@`: agents write these often. The value
624
+ * is put in double quotes, for those lines alone, and the text kept as it was otherwise. Null when the
625
+ * frontmatter reads already, or the quotes would not make it read (an unclosed list, say): the note is left as written. */
626
+ function repairFrontmatter(source) {
627
+ try {
628
+ if (frontmatterText(source)[0] === null) return null;
629
+ } catch {
630
+ return null;
631
+ }
632
+ const bom = source.startsWith(BOM$1) ? BOM$1 : "", lines = source.slice(bom.length).split("\n");
633
+ let close = 1;
634
+ while (rstrip(lines[close]) !== "---") close++;
635
+ const raw = lines.slice(1, close).join("\n") + "\n";
636
+ const bad = /* @__PURE__ */ new Set();
637
+ for (const e of parseYaml(raw).errors) {
638
+ const at = e.linePos?.[0]?.line;
639
+ if (at) bad.add(at);
640
+ }
641
+ const fixed = [];
642
+ for (const n of bad) {
643
+ const m = /^([A-Za-z_][\w .-]*):[ \t]+(\S.*?)[ \t]*(\r?)$/.exec(lines[n] ?? "");
644
+ if (!m || /^["'\[{|>&*!#]/.test(m[2])) continue;
645
+ lines[n] = `${m[1]}: ${JSON.stringify(m[2])}${m[3]}`;
646
+ fixed.push(m[1]);
647
+ }
648
+ if (!fixed.length) return null;
649
+ if (parseYaml(lines.slice(1, close).join("\n") + "\n").errors.length) return null;
650
+ return {
651
+ text: bom + lines.join("\n"),
652
+ fixed
653
+ };
654
+ }
622
655
  /** Serialise frontmatter, keeping key order. */
623
656
  function dumpFrontmatter(meta) {
624
657
  return stringify(meta, {
@@ -885,16 +918,19 @@ function embedFor(id, ids) {
885
918
  for (const other of ids) if (other.split("/").at(-1) === name) shared++;
886
919
  return `[[!${shared > 1 ? id : name}]]`;
887
920
  }
888
- /** A code link's file and lines, or null when it is not one, or names a path outside the repository. */
921
+ /** A code link's file and, after `#`, its lines (`L40-L52`) or the symbol it names (`leapfrog`, `Body.step`: a
922
+ * function, method or class, found by its name wherever it has moved to, M29). Null when it is not one, or names a
923
+ * path outside the repository. */
889
924
  function codeTarget(href) {
890
- const m = /^code:([^#?]+)(?:#L(\d+)(?:-L?(\d+))?)?$/.exec(href.trim());
925
+ const m = /^code:([^#?]+)(?:#(?:L(\d+)(?:-L?(\d+))?|([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)))?$/.exec(href.trim());
891
926
  if (!m) return null;
892
927
  const path = decodeURIComponent(m[1]).replace(/^\.?\/+/, "");
893
928
  if (!path || path.split("/").some((p) => p === ".." || !p)) return null;
894
929
  return {
895
930
  path,
896
931
  ...m[2] ? { line: Number(m[2]) } : {},
897
- ...m[3] ? { end: Number(m[3]) } : {}
932
+ ...m[3] ? { end: Number(m[3]) } : {},
933
+ ...m[4] ? { symbol: m[4] } : {}
898
934
  };
899
935
  }
900
936
  /** A note's links out of the base, in order, each once. */
@@ -922,14 +958,20 @@ function outwardLinks(body) {
922
958
  for (const { href, embed, alt } of citeRefs(body)) {
923
959
  if (!embed || seen.has(href)) continue;
924
960
  const c = codeTarget(href);
925
- if (!c || !pictureType(c.path)) continue;
926
- out.push({
961
+ if (!c) continue;
962
+ if (pictureType(c.path)) out.push({
927
963
  kind: "code",
928
964
  href,
929
965
  text: alt || c.path.split("/").at(-1),
930
966
  path: c.path,
931
967
  picture: true
932
968
  });
969
+ else out.push({
970
+ kind: "code",
971
+ href,
972
+ text: alt || c.symbol || c.path.split("/").at(-1),
973
+ ...c
974
+ });
933
975
  seen.add(href);
934
976
  }
935
977
  return out;
@@ -2197,6 +2239,176 @@ function mapModel(base) {
2197
2239
  };
2198
2240
  }
2199
2241
  //#endregion
2242
+ //#region packages/core/src/symbol.ts
2243
+ const PY = /* @__PURE__ */ new Set(["py", "pyi"]);
2244
+ const BRACE = /* @__PURE__ */ new Set([
2245
+ "js",
2246
+ "jsx",
2247
+ "mjs",
2248
+ "cjs",
2249
+ "ts",
2250
+ "tsx",
2251
+ "mts",
2252
+ "cts",
2253
+ "go",
2254
+ "rs",
2255
+ "java",
2256
+ "kt",
2257
+ "kts",
2258
+ "cs",
2259
+ "c",
2260
+ "h",
2261
+ "cc",
2262
+ "cpp",
2263
+ "cxx",
2264
+ "hpp",
2265
+ "swift",
2266
+ "php",
2267
+ "scala",
2268
+ "dart",
2269
+ "zig",
2270
+ "svelte",
2271
+ "vue"
2272
+ ]);
2273
+ /** Whether symbols can be found in a file of this name. */
2274
+ function hasSymbols(path) {
2275
+ const ext = path.split(".").at(-1).toLowerCase();
2276
+ return PY.has(ext) || BRACE.has(ext);
2277
+ }
2278
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2279
+ /** The lines of `path`'s symbol `name` (`Class.method` for one inside another) in `text`, or null. */
2280
+ function findSymbol(text, path, name) {
2281
+ const ext = path.split(".").at(-1).toLowerCase();
2282
+ const lines = text.split(/\r?\n/);
2283
+ const parts = name.split(".").filter(Boolean);
2284
+ if (!parts.length || !PY.has(ext) && !BRACE.has(ext)) return null;
2285
+ let from = 0, to = lines.length - 1, span = null;
2286
+ for (const part of parts) {
2287
+ const found = PY.has(ext) ? pyFind(lines, part, from, to) : braceFind(lines, part, from, to);
2288
+ if (!found) return null;
2289
+ span = found;
2290
+ from = found.start;
2291
+ to = found.end - 1;
2292
+ }
2293
+ return span;
2294
+ }
2295
+ function pyFind(lines, name, from, to) {
2296
+ const re = new RegExp(`^(\\s*)(?:async\\s+)?(?:def|class)\\s+${esc(name)}\\b`);
2297
+ const at = [];
2298
+ for (let i = from; i <= to; i++) {
2299
+ const m = re.exec(lines[i]);
2300
+ if (m) at.push({
2301
+ i,
2302
+ indent: m[1].length
2303
+ });
2304
+ }
2305
+ const shallow = Math.min(...at.map((a) => a.indent), Infinity);
2306
+ const top = from === 0 && to === lines.length - 1;
2307
+ const here = at.filter((a) => a.indent === shallow && (!top || shallow === 0));
2308
+ let first = null, count = 0;
2309
+ for (const { i, indent } of here) {
2310
+ count++;
2311
+ if (first) continue;
2312
+ let open = i;
2313
+ while (open < lines.length - 1 && !/:\s*(#.*)?$/.test(lines[open])) open++;
2314
+ let end = open;
2315
+ for (let j = open + 1; j < lines.length; j++) {
2316
+ const l = lines[j];
2317
+ if (!l.trim()) continue;
2318
+ if (l.length - l.trimStart().length <= indent) break;
2319
+ end = j;
2320
+ }
2321
+ let start = i;
2322
+ while (start > 0 && new RegExp(`^\\s{${indent}}@`).test(lines[start - 1])) start--;
2323
+ first = {
2324
+ start: start + 1,
2325
+ end: Math.min(end, to) + 1,
2326
+ count: 1
2327
+ };
2328
+ }
2329
+ return first ? {
2330
+ ...first,
2331
+ count
2332
+ } : null;
2333
+ }
2334
+ const DECL = (n) => [
2335
+ new RegExp(`\\b(?:function\\*?|class|interface|enum|struct|trait|union|impl|fn|func|def|type|record|namespace|object|module)\\s+(?:<[^>]*>\\s*)?(?:\\([^)]*\\)\\s*)?${n}\\b`),
2336
+ new RegExp(`\\b(?:const|let|var|val)\\s+${n}\\s*(?::[^=]+)?=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]+)?=>|\\w+\\s*=>)`),
2337
+ new RegExp(`^\\s*(?:(?:public|private|protected|internal|static|async|override|final|abstract|export|default|get|set|pub|unsafe|virtual|readonly|suspend|inline|open)\\s+)*(?:[\\w<>\\[\\],.?*&:]+\\s+)?\\*?${n}\\s*(?:<[^>]*>)?\\(`)
2338
+ ];
2339
+ function braceFind(lines, name, from, to) {
2340
+ const res = DECL(esc(name));
2341
+ let first = null, count = 0;
2342
+ let i = from;
2343
+ while (i <= to) {
2344
+ const line = lines[i];
2345
+ if (/^\s*(\/\/|\*|\/\*|#)/.test(line) || !res.some((re) => re.test(line))) {
2346
+ i++;
2347
+ continue;
2348
+ }
2349
+ const body = openBrace(lines, i, to);
2350
+ if (!body) {
2351
+ i++;
2352
+ continue;
2353
+ }
2354
+ count++;
2355
+ const end = closeBrace(lines, body.line, body.col);
2356
+ if (!first) {
2357
+ let start = i;
2358
+ while (start > 0 && /^\s*(@|#\[|\/\/|\/\*|\*)/.test(lines[start - 1])) start--;
2359
+ first = {
2360
+ start: start + 1,
2361
+ end: Math.min(end, to) + 1,
2362
+ count: 1
2363
+ };
2364
+ }
2365
+ i = end + 1;
2366
+ }
2367
+ return first ? {
2368
+ ...first,
2369
+ count
2370
+ } : null;
2371
+ }
2372
+ /** The first `{` from line `i`, if it comes before a `;` (within a few lines, the signature). */
2373
+ function openBrace(lines, i, to) {
2374
+ for (let j = i; j <= Math.min(to, i + 8); j++) {
2375
+ const l = stripStrings(lines[j]);
2376
+ const b = l.indexOf("{"), s = l.indexOf(";");
2377
+ if (b >= 0 && (s < 0 || b < s)) return {
2378
+ line: j,
2379
+ col: b
2380
+ };
2381
+ if (s >= 0) return null;
2382
+ }
2383
+ return null;
2384
+ }
2385
+ /** Blank out what is inside quotes and after `//`, so braces there are not counted. */
2386
+ function stripStrings(l) {
2387
+ return l.replace(/\/\/.*$/, (m) => " ".repeat(m.length)).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, (m) => " ".repeat(m.length));
2388
+ }
2389
+ /** The line holding the `}` that closes the `{` at (line, col). The end of the file when it does not. */
2390
+ function closeBrace(lines, line, col) {
2391
+ let depth = 0, block = false;
2392
+ for (let j = line; j < lines.length; j++) {
2393
+ let l = stripStrings(lines[j]);
2394
+ if (block) {
2395
+ const e = l.indexOf("*/");
2396
+ if (e < 0) continue;
2397
+ l = " ".repeat(e + 2) + l.slice(e + 2);
2398
+ block = false;
2399
+ }
2400
+ l = l.replace(/\/\*.*?\*\//g, (m) => " ".repeat(m.length));
2401
+ const o = l.indexOf("/*");
2402
+ if (o >= 0) {
2403
+ block = true;
2404
+ l = l.slice(0, o);
2405
+ }
2406
+ for (let k = j === line ? col : 0; k < l.length; k++) if (l[k] === "{") depth++;
2407
+ else if (l[k] === "}" && --depth === 0) return j;
2408
+ }
2409
+ return lines.length - 1;
2410
+ }
2411
+ //#endregion
2200
2412
  //#region packages/core/src/node.ts
2201
2413
  /** Every file and folder under `root`, Markdown with its text. */
2202
2414
  function readFolder(root) {
@@ -2231,6 +2443,32 @@ function loadBundle(root) {
2231
2443
  return Bundle.fromFiles(readFolder(root));
2232
2444
  }
2233
2445
  //#endregion
2446
+ //#region packages/server/src/term.ts
2447
+ const wrap = (open, close) => (text) => colour() ? `\x1b[${open}m${text}\x1b[${close}m` : text;
2448
+ /** Whether to paint: asked each time, so a test or a caller can change the environment. */
2449
+ function colour(env = process.env, tty = Boolean(process.stdout.isTTY)) {
2450
+ if (env.NO_COLOR) return false;
2451
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
2452
+ return tty && env.TERM !== "dumb";
2453
+ }
2454
+ const bold = wrap(1, 22);
2455
+ const dim = wrap(2, 22);
2456
+ const red = wrap(31, 39);
2457
+ const green = wrap(32, 39);
2458
+ const yellow = wrap(33, 39);
2459
+ /** A help text with its commands in bold and what they say dimmed: a line ` isorite word … said` and its continuations. */
2460
+ function helpText(text) {
2461
+ if (!colour()) return text;
2462
+ return text.split("\n").map((line, i) => {
2463
+ if (i === 0) return bold(line);
2464
+ if (/^ {20,}\S/.test(line)) return dim(line);
2465
+ const m = /^( isorite(?: [a-z-]+)?)( .*?)(\s{2,})(\S.*)$/.exec(line);
2466
+ if (m) return `${bold(m[1])}${m[2]}${m[3]}${dim(m[4])}`;
2467
+ const c = /^( isorite(?: [a-z-]+)?)(.*)$/.exec(line);
2468
+ return c ? `${bold(c[1])}${c[2]}` : line;
2469
+ }).join("\n");
2470
+ }
2471
+ //#endregion
2234
2472
  //#region packages/server/src/config.ts
2235
2473
  const configPath = (env = process.env) => join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "isorite", "config.toml");
2236
2474
  const DEVICE$1 = /^[a-z0-9]{4,16}$/;
@@ -3233,6 +3471,14 @@ const ISORITE = `# Their knowledge base
3233
3471
  together. A problem has no steps inside it.
3234
3472
  - Files beside the notes that are not notes (data, documents, pictures,
3235
3473
  artifacts) are linked as any note is: [runs](/simulation/runs.csv).
3474
+ - Code in their repository is linked as [the step](code:sim/step.py#leapfrog):
3475
+ the path from the repository's root, then a name (\`#leapfrog\`, or
3476
+ \`#Class.method\`) or lines (\`#L40-L52\`). Prefer a name: it still finds the
3477
+ code when it moves. Look in the file for the name before you write it; a name
3478
+ that is not there marks the link broken. \`![the step](code:sim/step.py#leapfrog)\`
3479
+ shows that code in the note, where it is read.
3480
+ - Frontmatter is YAML: put a value in quotes when it holds a colon, as
3481
+ \`title: "Gauss: Bonnet"\` and \`description: "Why: the proof"\`.
3236
3482
  - Every change you make is marked as yours, in the note's history, and they
3237
3483
  can undo it.
3238
3484
 
@@ -3931,7 +4177,9 @@ var Engine = class Engine {
3931
4177
  for (const c of b.concepts.values()) for (const o of c.outward) {
3932
4178
  if (o.kind !== "code") continue;
3933
4179
  const k = o.path.lastIndexOf("/");
3934
- if (!(await names(k < 0 ? "" : o.path.slice(0, k))).has(o.path.slice(k + 1))) broken.set(c.id, [...broken.get(c.id) ?? [], o.href]);
4180
+ let gone = !(await names(k < 0 ? "" : o.path.slice(0, k))).has(o.path.slice(k + 1));
4181
+ if (!gone && o.symbol) gone = (await this.code(o.href)).state === "no-symbol";
4182
+ if (gone) broken.set(c.id, [...broken.get(c.id) ?? [], o.href]);
3935
4183
  }
3936
4184
  }
3937
4185
  this.codeCheck = {
@@ -4307,6 +4555,25 @@ var Engine = class Engine {
4307
4555
  createNote(id, text) {
4308
4556
  return this.one(() => this.put(id, text, null));
4309
4557
  }
4558
+ async makeFolder(path) {
4559
+ path = path.trim().replace(/^\/+|\/+$/g, "");
4560
+ if (!inBase(path)) throw new EngineError("only a folder of the base is made here");
4561
+ checkNoteId(path);
4562
+ if (!this.opts.notes.mkdir) throw new EngineError("an empty folder can't be made here");
4563
+ if (this.files.has(`${path}.md`)) throw new EngineError(`there is a note at ${path} already`, 409);
4564
+ await this.one(async () => {
4565
+ await this.opts.notes.mkdir(path);
4566
+ const parts = path.split("/");
4567
+ for (let i = 1; i <= parts.length; i++) {
4568
+ const p = parts.slice(0, i).join("/");
4569
+ if (!this.files.has(p)) this.files.set(p, {
4570
+ path: p,
4571
+ dir: true
4572
+ });
4573
+ }
4574
+ this.built = this.index = null;
4575
+ });
4576
+ }
4310
4577
  /** `who` is you unless Iso moves it, in Write mode. */
4311
4578
  async moveNote(id, to, base, who = "you") {
4312
4579
  to = to.trim().replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
@@ -4451,6 +4718,63 @@ var Engine = class Engine {
4451
4718
  version: sha256(bytes).slice(0, 16)
4452
4719
  };
4453
4720
  }
4721
+ /** The code a code link names (M29): its symbol's lines, or the lines it gives, or the file's start. A symbol is
4722
+ * found by its name in the file as it is now, so the link holds where the code has moved. */
4723
+ async code(href) {
4724
+ const target = codeTarget(href);
4725
+ if (!target) throw new EngineError("not a code link", 400);
4726
+ const { path, symbol } = target, where = await this.repositoryOfBase();
4727
+ const files = where ? this.opts.repository?.(where) ?? null : null;
4728
+ if (!files) return {
4729
+ state: "no-repository",
4730
+ path,
4731
+ ...symbol ? { symbol } : {}
4732
+ };
4733
+ const bytes = await files.readBytes(path);
4734
+ if (!bytes) return {
4735
+ state: "no-file",
4736
+ path,
4737
+ ...symbol ? { symbol } : {}
4738
+ };
4739
+ if (bytes.length > 2e6) return {
4740
+ state: "too-large",
4741
+ path
4742
+ };
4743
+ const lines = new TextDecoder().decode(bytes).split(/\r?\n/);
4744
+ if (lines.at(-1) === "") lines.pop();
4745
+ let start = target.line ?? 1, end = target.end ?? target.line ?? lines.length, count;
4746
+ if (symbol) {
4747
+ if (!hasSymbols(path)) return {
4748
+ state: "unsupported",
4749
+ path,
4750
+ symbol
4751
+ };
4752
+ const found = findSymbol(lines.join("\n"), path, symbol);
4753
+ if (!found) return {
4754
+ state: "no-symbol",
4755
+ path,
4756
+ symbol
4757
+ };
4758
+ ({start, end, count} = found);
4759
+ }
4760
+ end = Math.min(Math.max(end, start), lines.length);
4761
+ if (start > lines.length) return {
4762
+ state: "no-symbol",
4763
+ path
4764
+ };
4765
+ const shown = Math.min(end, start + 120 - 1);
4766
+ return {
4767
+ state: "ok",
4768
+ path,
4769
+ ...symbol ? { symbol } : {},
4770
+ start,
4771
+ end,
4772
+ total: lines.length,
4773
+ text: lines.slice(start - 1, shown).join("\n"),
4774
+ ...shown < end ? { more: true } : {},
4775
+ ...count && count > 1 ? { count } : {}
4776
+ };
4777
+ }
4454
4778
  /** What was read of each picture, kept while its size and time are the same. */
4455
4779
  pictureCache = /* @__PURE__ */ new Map();
4456
4780
  /** Every picture in the notes (M22), found afresh: one put in the folder by hand since the base was opened is
@@ -4556,6 +4880,7 @@ var Engine = class Engine {
4556
4880
  async put(id, text, base, why = "edit", extra = {}, who = "you") {
4557
4881
  const { files, path } = this.where(id);
4558
4882
  if (text.length > 2e6) throw new EngineError("note too large", 413);
4883
+ if (who !== "you" && path.endsWith(".md")) text = repairFrontmatter(text)?.text ?? text;
4559
4884
  const now = await files.read(path);
4560
4885
  if (base === null && now !== null) throw new EngineError(`there is already a note at ${id}`, 409);
4561
4886
  if (base !== null && now === null) throw new Conflict(`${id} is gone`, null);
@@ -4947,6 +5272,8 @@ var MemoryFiles = class {
4947
5272
  files = /* @__PURE__ */ new Map();
4948
5273
  /** Files held as bytes (pictures), apart from text. */
4949
5274
  bytes = /* @__PURE__ */ new Map();
5275
+ /** Folders made empty (M31). */
5276
+ folders = /* @__PURE__ */ new Set();
4950
5277
  constructor(files = {}) {
4951
5278
  for (const [path, text] of Object.entries(files)) this.files.set(path, text);
4952
5279
  }
@@ -4971,10 +5298,17 @@ var MemoryFiles = class {
4971
5298
  this.files.delete(path);
4972
5299
  this.bytes.delete(path);
4973
5300
  }
5301
+ async mkdir(dir) {
5302
+ this.folders.add(dir);
5303
+ }
4974
5304
  async list(dir) {
4975
5305
  const prefix = dir ? dir + "/" : "";
4976
5306
  const found = /* @__PURE__ */ new Map();
4977
- for (const path of [...this.files.keys(), ...this.bytes.keys()]) {
5307
+ for (const path of [
5308
+ ...this.files.keys(),
5309
+ ...this.bytes.keys(),
5310
+ ...[...this.folders].map((f) => f + "/")
5311
+ ]) {
4978
5312
  if (!path.startsWith(prefix)) continue;
4979
5313
  const rest = path.slice(prefix.length), cut = rest.indexOf("/");
4980
5314
  if (cut >= 0) {
@@ -6155,7 +6489,7 @@ var AcpFollower = class {
6155
6489
  };
6156
6490
  //#endregion
6157
6491
  //#region packages/server/src/version.ts
6158
- const VERSION = "0.15.0";
6492
+ const VERSION = "0.16.0";
6159
6493
  //#endregion
6160
6494
  //#region packages/server/src/connect.ts
6161
6495
  const HARNESSES = [{
@@ -6989,6 +7323,10 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes, acp, bas
6989
7323
  if (c.req.header("if-none-match") === etag) return c.body(null, 304, headers);
6990
7324
  return c.body(found.bytes, 200, headers);
6991
7325
  });
7326
+ app.get("/api/code", async (c) => {
7327
+ const href = c.req.query("href") ?? "";
7328
+ return answer(c, () => api.code(href));
7329
+ });
6992
7330
  app.put("/api/note", async (c) => {
6993
7331
  const refused = writeRefused(c);
6994
7332
  if (refused) return refused;
@@ -7026,6 +7364,7 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes, acp, bas
7026
7364
  to: z.string().max(400),
7027
7365
  base: z.string().max(64)
7028
7366
  }).strict(), (c, b) => api.moveNote(id(c), b.to, b.base));
7367
+ post("/api/folder", z.object({ path: z.string().max(400) }).strict(), (_c, b) => api.makeFolder(b.path));
7029
7368
  post("/api/note/delete", z.object({ base: z.string().max(64) }).strict(), (c, b) => api.deleteNote(id(c), b.base));
7030
7369
  post("/api/note/done", z.object({
7031
7370
  done: z.boolean(),
@@ -7401,6 +7740,9 @@ var NodeFiles = class {
7401
7740
  throw e;
7402
7741
  }
7403
7742
  }
7743
+ async mkdir(dir) {
7744
+ await mkdir(this.at(dir), { recursive: true });
7745
+ }
7404
7746
  async append(path, text) {
7405
7747
  const full = this.at(path);
7406
7748
  await mkdir(dirname(full), { recursive: true });
@@ -7757,12 +8099,14 @@ async function serve$1(path, { host = "127.0.0.1", port = 4767, allowHosts = [],
7757
8099
  console.error(e.code === "EADDRINUSE" ? `port ${port} is in use (isorite serve --port <n>)` : e.message);
7758
8100
  process.exit(1);
7759
8101
  });
7760
- console.log(`isorite: ${notes}`);
7761
- console.log(embedder ? " search by meaning: on" : " search by meaning: off (isorite model fetches its model, 53 MB)");
8102
+ const row = (label, text) => console.log(` ${dim(label.padEnd(8))}${text}`);
8103
+ console.log(`${bold("isorite")} ${notes}`);
8104
+ row("search", embedder ? "by meaning" : `by words ${dim("(isorite model fetches the model, 53 MB)")}`);
7762
8105
  const lent = bases.lent().filter((l) => l.name !== bases.first).map((l) => l.name);
7763
- if (lent.length) console.log(` lent to every session: ${lent.join(", ")} (isorite bases)`);
7764
- console.log(existsSync(join(WEB_DIR, "index.html")) ? ` ${link(`http://localhost:${port}/`)}` : ` the app is not built (npm run build); the engine alone is at http://localhost:${port}/api/`);
7765
- if (process.env.SSH_CONNECTION && LOOPBACK.has(host)) console.log(` over SSH, the link opens on this machine; from your own, first run: ssh -L ${port}:localhost:${port} ${hostname()}`);
8106
+ if (lent.length) row("lent", `${lent.join(", ")} ${dim("(isorite bases)")}`);
8107
+ if (existsSync(join(WEB_DIR, "index.html"))) row("open", link(`http://localhost:${port}/`));
8108
+ else row("open", `the app is not built (npm run build); the engine alone is at http://localhost:${port}/api/`);
8109
+ if (process.env.SSH_CONNECTION && LOOPBACK.has(host)) row("ssh", `opens on this machine; from your own, first: ssh -L ${port}:localhost:${port} ${hostname()}`);
7766
8110
  stop = () => {
7767
8111
  server.close();
7768
8112
  process.exit(0);
@@ -7781,6 +8125,12 @@ const MARK = {
7781
8125
  warn: "!",
7782
8126
  skip: "–"
7783
8127
  };
8128
+ const PAINT = {
8129
+ ok: green,
8130
+ fail: red,
8131
+ warn: yellow,
8132
+ skip: dim
8133
+ };
7784
8134
  /** The environment variables isorite and Node read for it; the secret ones are shown only by length. */
7785
8135
  const READ = [
7786
8136
  "XDG_CONFIG_HOME",
@@ -7823,7 +8173,7 @@ async function doctor(folder, o = {}) {
7823
8173
  outcome,
7824
8174
  said
7825
8175
  });
7826
- if (!o.report) out(`${MARK[outcome]} ${step}: ${said}`);
8176
+ if (!o.report) out(`${PAINT[outcome](MARK[outcome])} ${bold(step)}: ${outcome === "skip" ? dim(said) : said}`);
7827
8177
  };
7828
8178
  const path = configPath(env), execArgv = o.execArgv ?? process.execArgv;
7829
8179
  if (!existsSync(path)) say("config", "ok", `there is no ${path} yet: it is made when a base is first opened`);
@@ -7931,7 +8281,7 @@ async function doctor(folder, o = {}) {
7931
8281
  ...checks.map((c) => `${MARK[c.outcome]} ${c.step}: ${c.said}`),
7932
8282
  "```"
7933
8283
  ].join("\n"));
7934
- } else out(failed ? `${failed} failed. isorite doctor --report gives all this as one block, its secrets hidden, to paste to someone helping.` : "nothing failed");
8284
+ } else out(failed ? `${red(`${failed} failed.`)} isorite doctor --report gives all this as one block, its secrets hidden, to paste to someone helping.` : green("nothing failed"));
7935
8285
  return failed ? 1 : 0;
7936
8286
  }
7937
8287
  //#endregion
@@ -9521,7 +9871,7 @@ function bases(args, { config = configPath(), out = console.log } = {}) {
9521
9871
  return 0;
9522
9872
  }
9523
9873
  const width = Math.max(...known.map((b) => b.name.length));
9524
- for (const b of known) out(`${b.name.padEnd(width)} ${b.lend ? "lent " : " "} ${b.path}${existsSync(b.path) ? "" : " (not there)"}`);
9874
+ for (const b of known) out(`${bold(b.name.padEnd(width))} ${b.lend ? "lent " : " "} ${dim(b.path)}${existsSync(b.path) ? "" : ` ${red("(not there)")}`}`);
9525
9875
  return 0;
9526
9876
  }
9527
9877
  //#endregion
@@ -9591,7 +9941,7 @@ async function start(io) {
9591
9941
  if (found.kind === "base") return io.open(found.notes);
9592
9942
  if (found.kind === "several") {
9593
9943
  io.out(`isorite: more than one base here:`);
9594
- found.notes.forEach((n, i) => io.out(` ${i + 1} ${n}`));
9944
+ found.notes.forEach((n, i) => io.out(` ${dim(String(i + 1))} ${n}`));
9595
9945
  const pick = await io.choose("Open which?", found.notes.length);
9596
9946
  return pick === null ? 2 : io.open(found.notes[pick]);
9597
9947
  }
@@ -9612,7 +9962,7 @@ async function start(io) {
9612
9962
  "the guide"
9613
9963
  ];
9614
9964
  io.out(known.length ? "isorite: the bases on this machine, last used first:" : "isorite:");
9615
- rows.forEach((r, i) => io.out(` ${i + 1} ${r}`));
9965
+ rows.forEach((r, i) => io.out(` ${dim(String(i + 1))} ${r}`));
9616
9966
  const pick = await io.choose("Open which?", rows.length);
9617
9967
  if (pick === null) return 2;
9618
9968
  if (pick < known.length) return io.open(known[pick].path);
@@ -9639,62 +9989,66 @@ init_embedder();
9639
9989
  init_model();
9640
9990
  const SHORT = `isorite: an agentic harness for understanding
9641
9991
 
9642
- isorite open the base here, or choose one (a terminal; --no-open, --port <n>)
9643
- isorite doctor whether isorite can work here, and what to change if not (--report to paste to
9644
- someone helping; run in a project it checks that project's base)
9645
- isorite help this; isorite help all lists every command
9992
+ isorite open the base here, or choose one
9993
+ isorite doctor can isorite work here, and what to change if not
9994
+ isorite help this; help all lists every command
9646
9995
  isorite --version
9647
9996
 
9648
- Notes are plain Markdown in a folder of your project; your own agent reads and writes them with you.
9997
+ Notes are plain Markdown in a folder of your project. Your agent reads and writes them with you.
9649
9998
  `;
9650
9999
  const USAGE = `isorite: an agentic harness for understanding
9651
10000
 
9652
- isorite new <folder> start a knowledge base: the folder, empty, with isorite's .isorite/
9653
- isorite check <notes> [--unused] check a folder of notes: frontmatter, links, cycles; --unused lists
9654
- the pictures no note shows
10001
+ isorite new <folder> a new base: an empty folder with .isorite/
10002
+ isorite check <notes> [--unused] [--fix]
10003
+ lint: frontmatter, links, cycles; --unused: pictures no note
10004
+ shows; --fix: quote a colon in frontmatter
9655
10005
  isorite serve <notes> [--port <n>] [--allow-host <name>]
9656
10006
  open it in the browser, on this machine only
9657
- isorite migrate <study base> [--merge] move a study base (format 1) into its notes' .isorite/; exercises
9658
- become problem notes; the study base is left as it was
9659
- isorite example <folder> [--geometry] write the example base (notes on an N-body simulation, three weeks
9660
- into being built), into an empty folder; --geometry: notes on the
9661
- differential geometry of surfaces, with their questions and problems
10007
+ isorite migrate <study base> [--merge] a study base (format 1) into its notes' .isorite/
10008
+ isorite example <folder> [--geometry] an example base, partway built, in an empty folder
9662
10009
  isorite mcp [<notes>] [--port <n>] [--agent <name>]
9663
- the knowledge base's tools over MCP, on stdio, for your agent in its
9664
- own terminal: claude mcp add isorite -- isorite mcp <base>
10010
+ the base's tools over MCP, on stdio:
10011
+ claude mcp add isorite -- isorite mcp <base>
9665
10012
  isorite acp --agent "<command>" [<notes>] [--port <n>] [--pane]
9666
- an ACP agent (claude-agent-acp, codex-acp, opencode acp, hermes acp)
9667
- behind your ACP front end (Toad, Zed), with the base's MCP, and its
9668
- sessions in Iso's pane; --pane: no front end, the pane alone
10013
+ an ACP agent behind your ACP front end, its sessions in Iso's
10014
+ pane; --pane: the pane alone
9669
10015
  isorite model [--from <folder|.tgz>] [--url <url>]
9670
- the model for search by meaning: which is installed, and where; else
9671
- fetched (53 MB, once) from ISORITE_MODEL_BASE_URL, npm, GitHub or
9672
- Hugging Face, or --url alone; or from the .tgz fetched by hand, or a
9673
- folder: its files copied there, or any compatible model
10016
+ the model for search by meaning: which, and where; else fetch it
10017
+ (53 MB, once)
9674
10018
  isorite doctor [<notes>] [--report] [--offline]
9675
- whether isorite can work here: the configuration, the network, the
9676
- model for search by meaning, the server, the notes and the project's
9677
- MCP config; --report gives it as one block, to paste to someone (or
9678
- an AI) helping
9679
- isorite memory [<folder>] the agent's memory: a knowledge base (~/isorite-memory unless named),
9680
- a git repository of its own, lent to every agent's session
9681
- isorite bases [lend|keep <name>] the bases this device knows, each listed as it is opened; lend one to
9682
- every session, or keep it to itself
9683
- isorite help setup how isorite is set up, every setting and failure: written for an AI
9684
- helping you set it up (docs/setup.md is for people)
9685
- isorite guide [<folder>] [--port <n>] the guide: a knowledge base about isorite, with things to do in it,
9686
- written into a folder (~/isorite-guide unless named) and opened in
9687
- the browser; run again, as you left it (also: isorite --tutorial)
10019
+ can isorite work here, a step at a time; --report: one block to
10020
+ paste to someone helping
10021
+ isorite memory [<folder>] the agent's memory: a base of its own, lent to every session
10022
+ isorite bases [lend|keep <name>] the bases this device knows; lend one to every session, or keep it
10023
+ isorite help setup every setting and failure, written for an AI helping you set up
10024
+ isorite guide [<folder>] [--port <n>] the guide: a base about isorite, to do things in (--tutorial)
9688
10025
  `;
9689
- function check(notes, out = console.log, unused = false) {
10026
+ function check(notes, out = console.log, unused = false, fix = false) {
9690
10027
  const root = resolve(notes);
9691
10028
  if (!existsSync(root) || !statSync(root).isDirectory()) {
9692
10029
  out(`no folder at ${root}`);
9693
10030
  return 2;
9694
10031
  }
9695
- const bundle = loadBundle(root), issues = bundle.lint(), cycles = bundle.requiresCycles();
9696
- for (const i of issues) out(`${i.level.padEnd(7)} ${i.path}: ${i.message}`);
9697
- for (const c of cycles) out(`warning requires cycle: ${c.join(" -> ")}`);
10032
+ let bundle = loadBundle(root), issues = bundle.lint();
10033
+ const mendable = issues.filter((i) => i.code === "frontmatter-invalid").flatMap((i) => {
10034
+ const m = repairFrontmatter(readFileSync(join(root, i.path), "utf8"));
10035
+ return m ? [{
10036
+ path: i.path,
10037
+ ...m
10038
+ }] : [];
10039
+ });
10040
+ if (fix && mendable.length) {
10041
+ for (const m of mendable) {
10042
+ writeFileSync(join(root, m.path), m.text);
10043
+ out(`fixed ${m.path}: quoted ${m.fixed.join(", ")}`);
10044
+ }
10045
+ bundle = loadBundle(root);
10046
+ issues = bundle.lint();
10047
+ }
10048
+ const cycles = bundle.requiresCycles();
10049
+ const level = (l) => (l === "error" ? red(l) : yellow(l)) + " ".repeat(Math.max(0, 7 - l.length));
10050
+ for (const i of issues) out(`${level(i.level)} ${bold(i.path)}: ${i.message}${i.code === "frontmatter-invalid" && !fix && mendable.some((m) => m.path === i.path) ? " (isorite check --fix quotes it)" : ""}`);
10051
+ for (const c of cycles) out(`${level("warning")} requires cycle: ${c.join(" -> ")}`);
9698
10052
  let artifactErrors = 0, artifactWarnings = 0;
9699
10053
  for (const f of [...bundle.files].filter((x) => citeKind(x) === "artifact").sort()) {
9700
10054
  const html = readFileSync(join(root, f), "utf8");
@@ -9711,13 +10065,14 @@ function check(notes, out = console.log, unused = false) {
9711
10065
  files,
9712
10066
  columns
9713
10067
  })) {
9714
- out(`${b.level.padEnd(7)} ${f}: ${b.message}`);
10068
+ out(`${level(b.level)} ${bold(f)}: ${b.message}`);
9715
10069
  if (b.level === "error") artifactErrors++;
9716
10070
  else artifactWarnings++;
9717
10071
  }
9718
10072
  }
9719
10073
  const errors = issues.filter((i) => i.level === "error").length + artifactErrors;
9720
- out(`${bundle.concepts.size} notes in ${bundle.directories.size} folders; ${errors} errors, ${issues.length - issues.filter((i) => i.level === "error").length + artifactWarnings} warnings, ${cycles.length} requires cycles`);
10074
+ const warnings = issues.length - issues.filter((i) => i.level === "error").length + artifactWarnings;
10075
+ out(`${dim(`${bundle.concepts.size} notes in ${bundle.directories.size} folders;`)} ${(errors ? red : green)(`${errors} errors`)}, ${(warnings ? yellow : green)(`${warnings} warnings`)}, ${cycles.length} requires cycles`);
9721
10076
  if (unused) {
9722
10077
  const cited = new Set([...bundle.concepts.values()].flatMap((c) => c.cites.map((x) => x.target)));
9723
10078
  const idle = [...bundle.files].filter((f) => citeKind(f) === "image" && !cited.has(f)).sort();
@@ -9803,7 +10158,7 @@ function main(argv) {
9803
10158
  noOpen: args.includes("--no-open")
9804
10159
  });
9805
10160
  }
9806
- if (command === "check" && rest.find((r) => !r.startsWith("--"))) return check(rest.find((r) => !r.startsWith("--")), console.log, rest.includes("--unused"));
10161
+ if (command === "check" && rest.find((r) => !r.startsWith("--"))) return check(rest.find((r) => !r.startsWith("--")), console.log, rest.includes("--unused"), rest.includes("--fix"));
9807
10162
  if (command === "new" && rest[0]) {
9808
10163
  const made = newBase(rest[0]);
9809
10164
  const next = `${made}: a knowledge base, empty. Open it in the browser, and attach your agent from Iso's pane:\n isorite serve ${made}`;
@@ -10001,7 +10356,7 @@ function main(argv) {
10001
10356
  }
10002
10357
  throw e;
10003
10358
  }
10004
- process.stdout.write(command === "help" && rest[0] === "all" ? USAGE : command && command !== "help" && command !== "--help" ? USAGE : SHORT);
10359
+ process.stdout.write(helpText(command === "help" && rest[0] === "all" ? USAGE : command && command !== "help" && command !== "--help" ? USAGE : SHORT));
10005
10360
  return command && command !== "help" && command !== "--help" ? 2 : 0;
10006
10361
  }
10007
10362
  /** The commands that start something, which say so at once (M21): a subprocess that stalls after this is then