isorite 0.1.0 → 0.2.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 (77) hide show
  1. package/dist/core.js +125 -2
  2. package/dist/embed-worker.js +9 -2
  3. package/dist/main.js +2377 -394
  4. package/package.json +2 -1
  5. package/plugin/server.ts +9 -8
  6. package/plugin/tour.ts +5 -8
  7. package/web/_app/immutable/assets/0.s5xqevrq.css +1 -0
  8. package/web/_app/immutable/assets/4.PVZR2_N5.css +1 -0
  9. package/web/_app/immutable/assets/5.DCxNIgyW.css +1 -0
  10. package/web/_app/immutable/assets/9.CFA9_nYZ.css +1 -0
  11. package/web/_app/immutable/assets/FolderField._A4_sc1Z.css +1 -0
  12. package/web/_app/immutable/assets/NoteEditor.ChlpmyHO.css +1 -0
  13. package/web/_app/immutable/assets/NoteList.F579Zvj_.css +1 -0
  14. package/web/_app/immutable/assets/{Timeline.UMF1LjSO.css → Timeline.KTc3LKuK.css} +1 -1
  15. package/web/_app/immutable/assets/Tutorial.DOfsM49T.css +1 -0
  16. package/web/_app/immutable/chunks/B0NIb88Q.js +1 -0
  17. package/web/_app/immutable/chunks/B7ZjnqOt.js +20 -0
  18. package/web/_app/immutable/chunks/{B-si2ell.js → BEVz4Dgg.js} +1 -1
  19. package/web/_app/immutable/chunks/BEk5idXo.js +1 -0
  20. package/web/_app/immutable/chunks/B_nIecMW.js +667 -0
  21. package/web/_app/immutable/chunks/C8UdwlAg.js +3 -0
  22. package/web/_app/immutable/chunks/CRzLMJPS.js +1 -0
  23. package/web/_app/immutable/chunks/CcQ3P3p1.js +13 -0
  24. package/web/_app/immutable/chunks/CcRs965g.js +1 -0
  25. package/web/_app/immutable/chunks/Cw2GjAJR.js +1 -0
  26. package/web/_app/immutable/chunks/DjBHskLm.js +1 -0
  27. package/web/_app/immutable/chunks/Dm229u8R.js +1 -0
  28. package/web/_app/immutable/chunks/UM_L7-f7.js +1 -0
  29. package/web/_app/immutable/chunks/b-AOhhy1.js +1 -0
  30. package/web/_app/immutable/chunks/k55859zq.js +1 -0
  31. package/web/_app/immutable/entry/app.B3QXxjjH.js +2 -0
  32. package/web/_app/immutable/entry/start.BauJm-6k.js +1 -0
  33. package/web/_app/immutable/nodes/0.DP4c84Wn.js +14 -0
  34. package/web/_app/immutable/nodes/1.BcwcVDJZ.js +1 -0
  35. package/web/_app/immutable/nodes/2.DgGnQZ7P.js +1 -0
  36. package/web/_app/immutable/nodes/3.BudzO7We.js +1 -0
  37. package/web/_app/immutable/nodes/4.DmswyrSt.js +2 -0
  38. package/web/_app/immutable/nodes/5.DjXsfjL5.js +5 -0
  39. package/web/_app/immutable/nodes/6.uBh-sdQx.js +1 -0
  40. package/web/_app/immutable/nodes/7.BYBNXM1A.js +1 -0
  41. package/web/_app/immutable/nodes/8.Tbz9Rh77.js +1 -0
  42. package/web/_app/immutable/nodes/9.NF5w8YC2.js +486 -0
  43. package/web/_app/immutable/workers/worker-rKDNrEKQ.js +1 -0
  44. package/web/_app/version.json +1 -1
  45. package/web/icon-180.png +0 -0
  46. package/web/icon-192.png +0 -0
  47. package/web/icon-512.png +0 -0
  48. package/web/icon-maskable-512.png +0 -0
  49. package/web/index.html +35 -12
  50. package/web/manifest.webmanifest +14 -0
  51. package/web/_app/immutable/assets/0.CbWSsYpm.css +0 -1
  52. package/web/_app/immutable/assets/3.Bgs8n1LK.css +0 -1
  53. package/web/_app/immutable/assets/4.CCFEPTFN.css +0 -1
  54. package/web/_app/immutable/assets/5.BCvLF9Xz.css +0 -1
  55. package/web/_app/immutable/assets/6.kPo5mcJx.css +0 -1
  56. package/web/_app/immutable/assets/NoteEditor.Bp-YDqYj.css +0 -1
  57. package/web/_app/immutable/chunks/BD1Dpm2e.js +0 -3
  58. package/web/_app/immutable/chunks/C0fpmehI.js +0 -1
  59. package/web/_app/immutable/chunks/CWYACT2V.js +0 -1
  60. package/web/_app/immutable/chunks/CdQZ-ArP.js +0 -1
  61. package/web/_app/immutable/chunks/DN3dk6rD.js +0 -147
  62. package/web/_app/immutable/chunks/DtAOoM-T.js +0 -1
  63. package/web/_app/immutable/chunks/DykrN7z5.js +0 -535
  64. package/web/_app/immutable/chunks/cmGruEKS.js +0 -1
  65. package/web/_app/immutable/chunks/gRYnlonl.js +0 -1
  66. package/web/_app/immutable/entry/app.DgJiXIYs.js +0 -2
  67. package/web/_app/immutable/entry/start.l9vV7NaJ.js +0 -1
  68. package/web/_app/immutable/nodes/0.DWPeBju2.js +0 -1
  69. package/web/_app/immutable/nodes/1.CJVDS8FT.js +0 -1
  70. package/web/_app/immutable/nodes/2.o8ItfT-a.js +0 -1
  71. package/web/_app/immutable/nodes/3.uhHyOPGK.js +0 -13
  72. package/web/_app/immutable/nodes/4.BYKkAZTp.js +0 -2
  73. package/web/_app/immutable/nodes/5.BzEQO7Xv.js +0 -4
  74. package/web/_app/immutable/nodes/6.GxiAUa6J.js +0 -1
  75. package/web/_app/immutable/nodes/7.d2Ere8Jb.js +0 -1
  76. package/web/_app/immutable/nodes/8.CvPHZL8j.js +0 -1
  77. package/web/_app/immutable/workers/worker-d9GxGKde.js +0 -1
package/dist/main.js CHANGED
@@ -9,13 +9,15 @@ import { createHash, randomBytes } from "node:crypto";
9
9
  import { serve } from "@hono/node-server";
10
10
  import { homedir, tmpdir } from "node:os";
11
11
  import { Worker } from "node:worker_threads";
12
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
12
13
  import { Hono } from "hono";
13
14
  import { streamSSE } from "hono/streaming";
14
15
  import { z } from "zod";
16
+ import { OpenCode } from "@opencode/client";
15
17
  import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
16
- import { execFileSync, spawn } from "node:child_process";
17
18
  import { createInterface } from "node:readline";
18
- import { createServer } from "node:http";
19
+ import { createServer } from "node:net";
20
+ import { createServer as createServer$1 } from "node:http";
19
21
  //#region packages/core/src/text.ts
20
22
  const WS = "\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
21
23
  const LEAD = new RegExp(`^[${WS}]+`, "u");
@@ -1407,7 +1409,8 @@ function moments(entries) {
1407
1409
  const what = e.why === "start" ? e.who === "external" ? "found" : "wrote" : e.why === "edit" ? "edited" : e.why === "restore" ? "restored" : e.why === "keep" ? "kept" : e.why === "move" ? "moved" : "changed elsewhere";
1408
1410
  out.push({
1409
1411
  ...base,
1410
- what
1412
+ what,
1413
+ ...e.asked ? { asked: e.asked } : {}
1411
1414
  });
1412
1415
  }
1413
1416
  }
@@ -1574,6 +1577,11 @@ function understanding(events, now = Date.now()) {
1574
1577
  s.last = when;
1575
1578
  s.evidence.push(e.id);
1576
1579
  }
1580
+ } else if (e.event === "understood" && typeof e.note === "string") {
1581
+ const s = at(e.note), by = e.by;
1582
+ s.level = clamp(s.level + (typeof by === "number" && Number.isFinite(by) ? Math.max(-1, Math.min(1, by)) : 0));
1583
+ s.last = when;
1584
+ s.evidence.push(e.id);
1577
1585
  } else if (e.event === "moved" && typeof e.from === "string" && typeof e.to === "string") {
1578
1586
  const s = state.get(e.from);
1579
1587
  if (s) {
@@ -2055,7 +2063,7 @@ async function rounds(o) {
2055
2063
  callId: c.id,
2056
2064
  content: text
2057
2065
  });
2058
- o.onStep(step);
2066
+ if (step) o.onStep(step);
2059
2067
  }
2060
2068
  }
2061
2069
  }
@@ -2063,7 +2071,7 @@ async function rounds(o) {
2063
2071
  //#region packages/engine/src/iso.ts
2064
2072
  /** The point in the note where new text is asked for. Not part of the note. */
2065
2073
  const HERE = "⟦HERE⟧";
2066
- const WHO = `You are Iso, the companion in isorite, an app for understanding a subject by
2074
+ const WHO$1 = `You are Iso, the companion in isorite, an app for understanding a subject by
2067
2075
  writing it down. The person is writing a note in their own knowledge base: a
2068
2076
  folder of Markdown notes with links between them. They have asked you for
2069
2077
  something at one place in the note they have open.`;
@@ -2145,6 +2153,8 @@ const FORMAT = `# How notes work here
2145
2153
  equation as a paragraph of its own, with $$ alone on the line before it
2146
2154
  and $$ alone on the line after.
2147
2155
  - Ordinary Markdown otherwise. No HTML.`;
2156
+ /** How notes are written, for any request that writes or quotes them. */
2157
+ const FORMAT_RULES = FORMAT;
2148
2158
  const LOOKUP = (rounds, meaning) => `# Looking things up
2149
2159
 
2150
2160
  Before you reply you may look things up in their knowledge base with the
@@ -2245,14 +2255,23 @@ function windowed(lines, keep, budget = NOTE_BUDGET) {
2245
2255
  flush();
2246
2256
  return out.join("\n");
2247
2257
  }
2258
+ /** The notes a note links to, as they are sent with a request in it. */
2259
+ function linkedSection(linked, intro = "The notes this note links to") {
2260
+ return linked.length ? `# ${intro}\n\n${linked.map((l) => `## ${l.title} (/${l.id}.md)\n\n${l.text}`).join("\n\n")}` : "";
2261
+ }
2262
+ const NO_LOOKUP = `# Looking things up
2263
+
2264
+ You have no tools this time: they chose what you are sent. Answer from it, and
2265
+ say plainly what you would need to look up to say more.`;
2248
2266
  /** The messages for new text at a point, and what they hold, part by part. */
2249
2267
  function insertMessages(a) {
2250
2268
  const system = [
2251
- WHO,
2269
+ WHO$1,
2252
2270
  INSERT$1,
2253
2271
  FORMAT,
2254
- LOOKUP(ROUNDS[a.tier], a.meaning)
2272
+ a.lookup === false ? NO_LOOKUP : looked$1(a)
2255
2273
  ].join("\n\n");
2274
+ const linked = linkedSection(a.linked ?? []);
2256
2275
  const note = `# Their note: ${a.title} (/${a.id}.md)\n\n${a.marked}` + (a.unkept.length ? `\n\n# Text you offered in this note that they have not kept yet\n\nIt is not part of the note. Do not write it again.\n\n${a.unkept.join("\n\n---\n\n")}` : "");
2257
2276
  const earlier = a.conversation ? `# Earlier in this session\n\n${a.conversation}` : "";
2258
2277
  const request = `# What they ask now\n\n${a.asked}`;
@@ -2267,47 +2286,54 @@ function insertMessages(a) {
2267
2286
  role: "user",
2268
2287
  content: [
2269
2288
  earlier,
2289
+ linked,
2270
2290
  note,
2271
2291
  request
2272
2292
  ].filter(Boolean).join("\n\n")
2273
2293
  }],
2274
- parts: [
2275
- {
2276
- name: "instructions and tools",
2277
- detail: "",
2278
- tokens: estimate(system) + 500
2279
- },
2280
- {
2281
- name: "study record",
2282
- detail: "nothing yet",
2283
- tokens: 0
2284
- },
2285
- {
2286
- name: "your prompts and Iso's answers",
2287
- detail: a.moments ? `${a.moments} ${a.moments === 1 ? "moment" : "moments"}` : "",
2288
- tokens: estimate(earlier)
2289
- },
2290
- {
2291
- name: "this note",
2292
- detail: a.title,
2293
- tokens: estimate(note) + estimate(request)
2294
- },
2295
- {
2296
- name: "linked notes",
2297
- detail: a.linked?.notes ? `${a.linked.notes} read` : "",
2298
- tokens: a.linked?.tokens ?? 0
2299
- }
2300
- ]
2294
+ parts: inPlaceParts(a, system, earlier, linked, note, request)
2301
2295
  };
2302
2296
  }
2297
+ /** The look-up instructions for a request in place, saying what is sent below it. */
2298
+ const looked$1 = (a) => LOOKUP(ROUNDS[a.tier], a.meaning).replace("below there is only the note.", a.linked?.length ? "below there is the note, and\nthe notes it links to that they chose to send." : "below there is only the note.");
2299
+ function inPlaceParts(a, system, earlier, linked, note, request) {
2300
+ return [
2301
+ {
2302
+ name: "instructions and tools",
2303
+ detail: a.lookup === false ? "no tools" : "",
2304
+ tokens: estimate(system) + (a.lookup === false ? 0 : 500)
2305
+ },
2306
+ {
2307
+ name: "study record",
2308
+ detail: "nothing yet",
2309
+ tokens: 0
2310
+ },
2311
+ {
2312
+ name: "your prompts and Iso's answers",
2313
+ detail: a.moments ? `${a.moments} ${a.moments === 1 ? "moment" : "moments"}` : "",
2314
+ tokens: estimate(earlier)
2315
+ },
2316
+ {
2317
+ name: "this note",
2318
+ detail: a.title,
2319
+ tokens: estimate(note) + estimate(request)
2320
+ },
2321
+ {
2322
+ name: "linked notes",
2323
+ detail: a.linked?.length ? `${a.linked.length} sent` : "",
2324
+ tokens: estimate(linked)
2325
+ }
2326
+ ];
2327
+ }
2303
2328
  /** The messages for a rewrite of chosen paragraphs. */
2304
2329
  function rewriteMessages(a) {
2305
2330
  const system = [
2306
- WHO,
2331
+ WHO$1,
2307
2332
  REWRITE(a.count),
2308
2333
  FORMAT,
2309
- LOOKUP(ROUNDS[a.tier], a.meaning)
2334
+ a.lookup === false ? NO_LOOKUP : looked$1(a)
2310
2335
  ].join("\n\n");
2336
+ const linked = linkedSection(a.linked ?? []);
2311
2337
  const note = `# Their note: ${a.title} (/${a.id}.md)\n\n${a.marked}`;
2312
2338
  const earlier = a.conversation ? `# Earlier in this session\n\n${a.conversation}` : "";
2313
2339
  const request = `# What they ask now, of the marked ${a.count === 1 ? "passage" : "passages"}\n\n${a.asked}`;
@@ -2322,37 +2348,12 @@ function rewriteMessages(a) {
2322
2348
  role: "user",
2323
2349
  content: [
2324
2350
  earlier,
2351
+ linked,
2325
2352
  note,
2326
2353
  request
2327
2354
  ].filter(Boolean).join("\n\n")
2328
2355
  }],
2329
- parts: [
2330
- {
2331
- name: "instructions and tools",
2332
- detail: "",
2333
- tokens: estimate(system) + 500
2334
- },
2335
- {
2336
- name: "study record",
2337
- detail: "nothing yet",
2338
- tokens: 0
2339
- },
2340
- {
2341
- name: "your prompts and Iso's answers",
2342
- detail: a.moments ? `${a.moments} ${a.moments === 1 ? "moment" : "moments"}` : "",
2343
- tokens: estimate(earlier)
2344
- },
2345
- {
2346
- name: "this note",
2347
- detail: a.title,
2348
- tokens: estimate(note) + estimate(request)
2349
- },
2350
- {
2351
- name: "linked notes",
2352
- detail: a.linked?.notes ? `${a.linked.notes} read` : "",
2353
- tokens: a.linked?.tokens ?? 0
2354
- }
2355
- ]
2356
+ parts: inPlaceParts(a, system, earlier, linked, note, request)
2356
2357
  };
2357
2358
  }
2358
2359
  /** A rewrite's reply, taken apart: the new text for each of the `count` passages (null where it left one as it
@@ -2396,7 +2397,7 @@ const LINKED_BUDGET = 8e3;
2396
2397
  /** The messages for a note written for Iso. */
2397
2398
  function noteMessages(a) {
2398
2399
  const system = [
2399
- WHO.replace("They have asked you for\nsomething at one place in the note they have open.", "This time they have written to you at length."),
2400
+ WHO$1.replace("They have asked you for\nsomething at one place in the note they have open.", "This time they have written to you at length."),
2400
2401
  NOTE,
2401
2402
  FORMAT,
2402
2403
  LOOKUP(ROUNDS[a.tier], a.meaning).replace("below there is only the note.", "below there is what they wrote, and the notes they linked.")
@@ -2654,8 +2655,8 @@ function exerciseReply(reply) {
2654
2655
  }
2655
2656
  //#endregion
2656
2657
  //#region packages/engine/src/lookup.ts
2657
- const str = { type: "string" };
2658
- const fn = (name, description, properties, required) => ({
2658
+ const str$1 = { type: "string" };
2659
+ const fn$1 = (name, description, properties, required) => ({
2659
2660
  type: "function",
2660
2661
  function: {
2661
2662
  name,
@@ -2669,10 +2670,10 @@ const fn = (name, description, properties, required) => ({
2669
2670
  }
2670
2671
  });
2671
2672
  const TOOLS = [
2672
- fn("search_notes", "Keyword search over the knowledge base. Returns note ids, titles, types, one-sentence descriptions and a one-line snippet. Search by the words a note would use.", {
2673
- query: str,
2673
+ fn$1("search_notes", "Keyword search over the knowledge base. Returns note ids, titles, types, one-sentence descriptions and a one-line snippet. Search by the words a note would use.", {
2674
+ query: str$1,
2674
2675
  under: {
2675
- ...str,
2676
+ ...str$1,
2676
2677
  description: "Only notes in this folder."
2677
2678
  },
2678
2679
  limit: {
@@ -2680,25 +2681,25 @@ const TOOLS = [
2680
2681
  description: "How many, 8 unless said, 15 at most."
2681
2682
  }
2682
2683
  }, ["query"]),
2683
- fn("outline_note", "A note's description, its headings, the notes it links to and the notes that link to it, without its body. Use it to choose a section to read.", { id: {
2684
- ...str,
2684
+ fn$1("outline_note", "A note's description, its headings, the notes it links to and the notes that link to it, without its body. Use it to choose a section to read.", { id: {
2685
+ ...str$1,
2685
2686
  description: "The note's id or path: basics/entropy or /basics/entropy.md."
2686
2687
  } }, ["id"]),
2687
- fn("read_note", "Read one section of a note, by its heading, or the whole note when no section is named. Prefer a section.", {
2688
- id: str,
2688
+ 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.", {
2689
+ id: str$1,
2689
2690
  section: {
2690
- ...str,
2691
+ ...str$1,
2691
2692
  description: "A heading's text, as outline_note gives it."
2692
2693
  }
2693
2694
  }, ["id"])
2694
2695
  ];
2695
- const FIND_SIMILAR = fn("find_similar", "Find notes by meaning: notes that say something like the text you give, even in quite other words. Slower and vaguer than search_notes: use it after search_notes and the links of the notes in hand have not found what you need, or when you cannot guess the words a note would use. Give a question or a sentence, not keywords. Returns each note's nearest section, with how alike it is (0 to 1).", {
2696
+ const FIND_SIMILAR = fn$1("find_similar", "Find notes by meaning: notes that say something like the text you give, even in quite other words. Slower and vaguer than search_notes: use it after search_notes and the links of the notes in hand have not found what you need, or when you cannot guess the words a note would use. Give a question or a sentence, not keywords. Returns each note's nearest section, with how alike it is (0 to 1).", {
2696
2697
  text: {
2697
- ...str,
2698
+ ...str$1,
2698
2699
  description: "A question, or a sentence saying what you are looking for."
2699
2700
  },
2700
2701
  under: {
2701
- ...str,
2702
+ ...str$1,
2702
2703
  description: "Only notes in this folder."
2703
2704
  },
2704
2705
  limit: {
@@ -2867,89 +2868,341 @@ var Lookup = class {
2867
2868
  }
2868
2869
  };
2869
2870
  //#endregion
2870
- //#region packages/engine/src/agent.ts
2871
- /** Which occurrence of `text` starts at `offset`, counted from 0 through the note. */
2872
- function nthAt(note, text, offset) {
2873
- let n = 0;
2874
- for (let at = note.indexOf(text); at >= 0 && at < offset; at = note.indexOf(text, at + 1)) n++;
2875
- return n;
2876
- }
2877
- /** The passages a string replacement changes. Refused, in words the agent can act on, when it names nothing or
2878
- * more than one place without saying to replace them all. */
2879
- function editRegions(note, oldString, newString, replaceAll = false) {
2880
- if (!oldString) throw new EngineError("oldString is empty: name the text to replace, or write the whole note.", 400);
2881
- if (oldString === newString) throw new EngineError("oldString and newString are the same: nothing would change.", 400);
2882
- let count = 0;
2883
- for (let at = note.indexOf(oldString); at >= 0; at = note.indexOf(oldString, at + 1)) count++;
2884
- if (!count) throw new EngineError("oldString is not in the note. Read the note again and copy the text exactly.", 400);
2885
- if (count > 1 && !replaceAll) throw new EngineError(`oldString is in the note ${count} times. Give more of the text around it, or replaceAll.`, 400);
2886
- return Array.from({ length: count }, (_, nth) => ({
2887
- old: {
2888
- text: oldString,
2889
- nth
2890
- },
2891
- new: newString
2892
- }));
2871
+ //#region packages/engine/src/understand.ts
2872
+ const UNDERSTANDING_PREFIX = "@understanding/";
2873
+ const UNDERSTANDING_DIR = "understanding";
2874
+ const isUnderstandingId = (id) => id.startsWith(UNDERSTANDING_PREFIX);
2875
+ const understandingId = (note) => UNDERSTANDING_PREFIX + note;
2876
+ const READING = "## Iso's reading";
2877
+ const PAD = "## Scratchpad";
2878
+ /** The two parts of an Understanding note's file. A file in another shape is read as all scratchpad. */
2879
+ function parseUnderstanding(text) {
2880
+ if (!text) return {
2881
+ reading: "",
2882
+ scratchpad: ""
2883
+ };
2884
+ const body = text.replace(/^---\n[\s\S]*?\n---\n/, "");
2885
+ const r = body.indexOf(READING), p = body.indexOf(PAD);
2886
+ if (r < 0 && p < 0) return {
2887
+ reading: "",
2888
+ scratchpad: body.trim()
2889
+ };
2890
+ return {
2891
+ reading: r < 0 ? "" : body.slice(r + 16, p > r ? p : void 0).trim(),
2892
+ scratchpad: p < 0 ? "" : body.slice(p + 13, r > p ? r : void 0).trim()
2893
+ };
2893
2894
  }
2894
- /** The passages that change between a note and the whole of it written anew, by line. A passage is never empty:
2895
- * a line added between others is offered with the nearest line that has text beside it. */
2896
- function writeRegions(before, after) {
2897
- const a = before.split("\n"), b = after.split("\n"), hunks = diffLines(a, b);
2898
- const filled = (i) => (a[i] ?? "").trim() !== "";
2899
- const spans = hunks.map((h) => {
2900
- let from = h.at, to = h.at + h.del.length;
2901
- if (!a.slice(from, to).some((line) => line.trim())) {
2902
- let k = from - 1;
2903
- while (k >= 0 && !filled(k)) k--;
2904
- if (k >= 0) from = k;
2905
- else {
2906
- k = to;
2907
- while (k < a.length && !filled(k)) k++;
2908
- if (k >= a.length) throw new EngineError("the note has no text to place the change against: write it with text in it", 400);
2909
- to = k + 1;
2910
- }
2895
+ /** An Understanding note's file, from its parts. */
2896
+ function understandingText(note, title, parts) {
2897
+ return `---\ntype: Understanding\ntitle: ${JSON.stringify(`Understanding · ${title}`)}\nof: ${note}\n---\n\n${READING}\n\n${parts.reading.trim()}\n\n${PAD}\n\n${parts.scratchpad.trim()}\n`;
2898
+ }
2899
+ //#endregion
2900
+ //#region packages/engine/src/turn.ts
2901
+ /** What Iso may do. Build needs a project and a shell, and is the terminal's (M11). */
2902
+ const MODES = [
2903
+ "ask",
2904
+ "write",
2905
+ "build",
2906
+ "understand"
2907
+ ];
2908
+ const CONVERSATIONS_DIR = "conversations";
2909
+ const conversationPath = (id) => `${CONVERSATIONS_DIR}/${id}.jsonl`;
2910
+ /** A conversation's turns from its file, oldest first. A line that cannot be read is passed over. */
2911
+ function readTurns(text) {
2912
+ const turns = /* @__PURE__ */ new Map();
2913
+ for (const raw of text.split("\n")) {
2914
+ if (!raw.trim()) continue;
2915
+ let line;
2916
+ try {
2917
+ line = JSON.parse(raw);
2918
+ } catch {
2919
+ continue;
2911
2920
  }
2912
- return {
2913
- from,
2914
- to
2915
- };
2916
- });
2917
- const merged = [];
2918
- for (const s of spans) {
2919
- const last = merged.at(-1);
2920
- if (last && s.from <= last.to) last.to = Math.max(last.to, s.to);
2921
- else merged.push({ ...s });
2922
- }
2923
- const moved = (i, counts) => i + hunks.filter((h) => counts(h.at)).reduce((d, h) => d + h.ins.length - h.del.length, 0);
2924
- const start = (i) => moved(i, (at) => at < i), end = (i) => moved(i, (at) => at <= i);
2925
- const offset = (i) => a.slice(0, i).reduce((n, line) => n + line.length + 1, 0);
2926
- return merged.map(({ from, to }) => {
2927
- const lines = b.slice(start(from), end(to));
2928
- let text = a.slice(from, to).join("\n"), at = offset(from);
2929
- if (!lines.length) {
2930
- if (to < a.length) text += "\n";
2931
- else if (from > 0) {
2932
- text = "\n" + text;
2933
- at -= 1;
2934
- }
2921
+ if (line.kind === "turn") {
2922
+ const { kind: _, ...t } = line;
2923
+ turns.set(t.id, {
2924
+ ...t,
2925
+ items: [],
2926
+ state: "running"
2927
+ });
2928
+ continue;
2935
2929
  }
2936
- return {
2937
- old: {
2938
- text,
2939
- nth: nthAt(before, text, at)
2940
- },
2941
- new: lines.join("\n")
2942
- };
2943
- });
2930
+ const t = turns.get(line.turn);
2931
+ if (!t) continue;
2932
+ if (line.kind === "item") t.items.push(line.item);
2933
+ else if (line.kind === "answer") {
2934
+ for (const i of t.items) if (i.kind === "permission" && i.id === line.permission) i.answer = line.decision;
2935
+ } else {
2936
+ t.state = line.state;
2937
+ if (line.message) t.message = line.message;
2938
+ if (line.cost) t.cost = line.cost;
2939
+ }
2940
+ }
2941
+ return [...turns.values()].map((t) => t.state === "running" ? {
2942
+ ...t,
2943
+ state: "stopped"
2944
+ } : t).sort((a, b) => a.id < b.id ? -1 : 1);
2945
+ }
2946
+ const WHO = `You are Iso, the companion in isorite, an app for understanding a subject by
2947
+ writing it down. The person keeps a knowledge base: a folder of Markdown notes
2948
+ with links between them. They are talking with you in isorite's Iso pane,
2949
+ beside their notes and the Atlas, the map of their base. What you look up and
2950
+ what you change is shown to them there as you do it.`;
2951
+ const MODE_RULES = {
2952
+ ask: `# Ask mode: what you may do
2953
+
2954
+ You read their notes and answer. You cannot change anything in this mode: no
2955
+ note, no folder.
2956
+
2957
+ - Answer what they ask, in plain prose. Short: a few sentences to a few
2958
+ paragraphs, unless more is asked for. Markdown and maths are shown as they
2959
+ are in their notes.
2960
+ - Rest on their notes where the base says something: look first, and link to
2961
+ the notes you used, so they can open them. Where the base does not hold what
2962
+ is asked, answer from what you know of the subject, exactly, and say that
2963
+ their notes do not cover it.
2964
+ - When one of their notes would help them and they may not know it, point it
2965
+ out with point_to_note. Use it sparingly: one or two notes.
2966
+ - Never invent a note, a link or a quotation.`,
2967
+ write: `# Write mode: what you may do
2968
+
2969
+ You may change their knowledge base: edit notes, make new ones, move them and
2970
+ delete them. What you change goes into their notes at once. They see every
2971
+ change marked as yours, and can undo it.
2972
+
2973
+ - Do what they ask, and only that. Change the least that does it: edit the
2974
+ passage that needs it with edit_note, not the whole note. Keep their words,
2975
+ links, maths and voice wherever the request does not call for a change.
2976
+ - Look before you write: read the part of the note you change, and the notes
2977
+ it rests on, so what you write agrees with the base and links to it.
2978
+ - A new note (write_note) is written as one of theirs: frontmatter with type
2979
+ and title, their notation, their level, plain words, linked to the notes it
2980
+ rests on.
2981
+ - Moving or deleting a note is asked of them before it happens. Do it only
2982
+ when they asked for it.
2983
+ - When you are done, say in a sentence or two what you changed and why. Do not
2984
+ repeat the text you wrote: they see it in the note.
2985
+ - Never invent a note, a link or a quotation.`,
2986
+ understand: `# Understand mode: what you may do
2987
+
2988
+ You help them understand: what is in their notes, what changed in them, or
2989
+ something they want to understand more deeply. You do not change their notes.
2990
+ You keep a record of their understanding, note by note, in their study base.
2991
+
2992
+ - Start from where they are. Below, for each note in hand, is your reading of
2993
+ their understanding of it so far and what changed it. Use it: do not explain
2994
+ what they have shown they know; go to what they have not yet shown.
2995
+ - Ask before you explain, where a question would show you where they stand.
2996
+ One question at a time. Short turns.
2997
+ - When they show understanding, or a gap, record it with
2998
+ record_understanding: how far this turn is evidence that their
2999
+ understanding of the note differs from what you had (-1, -0.5, 0, 0.5 or 1)
3000
+ and why, in one plain sentence to them. At most once a note a turn.
3001
+ - At the end of a conversation about a note, or when your reading of them has
3002
+ changed, rewrite your reading with write_understanding: a few plain
3003
+ sentences on what they can do with the note and what they have not yet
3004
+ shown. It is theirs to read.
3005
+ - Never invent a note, a link or a quotation.`
3006
+ };
3007
+ const LOOKING = (rounds, meaning, lookup) => lookup ? `# Looking things up
3008
+
3009
+ - Look up only what the request needs. search_notes finds notes by their
3010
+ words; outline_note shows a note's headings and links; read_note reads one
3011
+ section. Prefer a section to a whole note. Follow links one step at a time.${meaning ? `
3012
+ - find_similar finds notes by meaning, when their words have not found them.` : ""}
3013
+ - You may call several tools at once, over ${rounds} rounds at most; then reply.
3014
+ - Say nothing between tool calls. Only the reply is shown.` : `# Looking things up
3015
+
3016
+ They chose what you are sent and asked you not to look further: you have no
3017
+ tools for searching or reading. Answer from what is below, and say plainly what
3018
+ you would need to look at to say more.`;
3019
+ const str = { type: "string" };
3020
+ const fn = (name, description, properties, required) => ({
3021
+ type: "function",
3022
+ function: {
3023
+ name,
3024
+ description,
3025
+ parameters: {
3026
+ type: "object",
3027
+ properties,
3028
+ required,
3029
+ additionalProperties: false
3030
+ }
3031
+ }
3032
+ });
3033
+ const POINT_TO_NOTE = fn("point_to_note", "Point the person to one of their notes: it is marked on their Atlas, with why. For a note that would help them that they may not know of.", {
3034
+ id: {
3035
+ ...str,
3036
+ description: "The note's id or path."
3037
+ },
3038
+ why: {
3039
+ ...str,
3040
+ description: "Why, in a short sentence to them."
3041
+ }
3042
+ }, ["id", "why"]);
3043
+ const EDIT_NOTE$1 = fn("edit_note", "Replace one passage of a note with new text. `old` must be exactly as it is in the note, and must occur in it once: read it first. Give enough of it to be unique. An empty `new` takes the passage out.", {
3044
+ id: {
3045
+ ...str,
3046
+ description: "The note's id or path."
3047
+ },
3048
+ old: {
3049
+ ...str,
3050
+ description: "The passage as it is now, exactly."
3051
+ },
3052
+ new: {
3053
+ ...str,
3054
+ description: "What it becomes."
3055
+ }
3056
+ }, [
3057
+ "id",
3058
+ "old",
3059
+ "new"
3060
+ ]);
3061
+ const WRITE_NOTE$1 = fn("write_note", "Make a new note, or write a note's whole text anew. For a new note give its whole file: frontmatter (type, title) then the body. Prefer edit_note for a change to a note that exists.", {
3062
+ id: {
3063
+ ...str,
3064
+ description: "The note's id: folder/name, lower case with hyphens. A new folder is made for it."
3065
+ },
3066
+ text: {
3067
+ ...str,
3068
+ description: "The note's whole file."
3069
+ }
3070
+ }, ["id", "text"]);
3071
+ const MOVE_NOTE = fn("move_note", "Move or rename a note. Links to it are changed to match. They are asked first.", {
3072
+ id: str,
3073
+ to: {
3074
+ ...str,
3075
+ description: "Its new id: folder/name."
3076
+ }
3077
+ }, ["id", "to"]);
3078
+ const DELETE_NOTE = fn("delete_note", "Delete a note. They are asked first. Its history stays.", { id: str }, ["id"]);
3079
+ const RECORD_UNDERSTANDING = fn("record_understanding", "Record evidence of their understanding of one note from this turn: how far it differs from what you had, and why.", {
3080
+ id: {
3081
+ ...str,
3082
+ description: "The note's id."
3083
+ },
3084
+ by: {
3085
+ type: "number",
3086
+ enum: [
3087
+ -1,
3088
+ -.5,
3089
+ 0,
3090
+ .5,
3091
+ 1
3092
+ ]
3093
+ },
3094
+ why: {
3095
+ ...str,
3096
+ description: "One plain sentence, to them."
3097
+ }
3098
+ }, [
3099
+ "id",
3100
+ "by",
3101
+ "why"
3102
+ ]);
3103
+ const WRITE_UNDERSTANDING = fn("write_understanding", "Rewrite your reading of their understanding of one note: a few plain sentences on what they can do with it and what they have not yet shown. Replaces the reading you had.", {
3104
+ id: {
3105
+ ...str,
3106
+ description: "The note's id."
3107
+ },
3108
+ reading: str
3109
+ }, ["id", "reading"]);
3110
+ /** The tools each mode adds to looking things up. */
3111
+ const MODE_TOOLS = {
3112
+ ask: [POINT_TO_NOTE],
3113
+ write: [
3114
+ EDIT_NOTE$1,
3115
+ WRITE_NOTE$1,
3116
+ MOVE_NOTE,
3117
+ DELETE_NOTE,
3118
+ POINT_TO_NOTE
3119
+ ],
3120
+ understand: [
3121
+ RECORD_UNDERSTANDING,
3122
+ WRITE_UNDERSTANDING,
3123
+ POINT_TO_NOTE
3124
+ ]
3125
+ };
3126
+ function sentText(s) {
3127
+ if (s.kind === "note") return `## ${s.title} (/${s.id}.md)\n\n${windowed(s.text.split("\n"), [0])}`;
3128
+ if (s.kind === "passage") return `## A passage they chose in ${s.title} (/${s.id}.md)\n\n${s.text.split("\n").map((l) => `> ${l}`).join("\n")}`;
3129
+ if (s.kind === "folder") return `## The folder ${s.id || "at the top of the base"}: its notes\n\n${s.notes.map((n) => `- ${n.title} (/${n.id}.md)${n.description ? `: ${n.description}` : ""}`).join("\n") || "(empty)"}`;
3130
+ return `## Their understanding of ${s.title} (/${s.id}.md): ${s.squares} of 6 squares\n\nYour reading so far: ${s.reading || "none yet."}\n\nWhat changed it, newest first:\n${s.events.map((e) => `- ${e}`).join("\n") || "- nothing yet"}`;
3131
+ }
3132
+ /** An earlier turn as it is sent: in the shape `conversation()` shortens. */
3133
+ function turnExchange(t) {
3134
+ const text = t.items.filter((i) => i.kind === "text").map((i) => i.text).join("\n\n").trim();
3135
+ const changed = t.items.flatMap((i) => i.kind === "change" ? [`${i.why === "create" ? "made" : i.why === "delete" ? "deleted" : i.why === "move" ? "moved" : "changed"} ${i.title} (/${i.note}.md)`] : []);
3136
+ const gave = t.given.filter((g) => g.kind !== "understanding").map((g) => g.kind === "passage" ? `a passage of /${g.id}.md` : `/${g.id}${g.kind === "note" ? ".md" : "/"}`);
3137
+ return {
3138
+ id: t.id,
3139
+ note: "",
3140
+ text: `In ${t.mode} mode they asked${gave.length ? `, giving you ${gave.join(", ")}` : ""}: ${t.asked}\n\nYou wrote:\n\n${text || "(nothing)"}\n\n${changed.length ? `You ${changed.join("; ")}.` : t.state === "done" ? "" : `The turn was ${t.state}.`}`.trim()
3141
+ };
2944
3142
  }
2945
- /** The passages an agent's edit changes in a note as it is. */
2946
- function regionsOf(note, edit) {
2947
- return "content" in edit ? writeRegions(note, edit.content) : editRegions(note, edit.oldString, edit.newString, edit.replaceAll);
3143
+ /** The messages for a turn, and what they hold, part by part. */
3144
+ function turnMessages(a) {
3145
+ const system = [
3146
+ WHO,
3147
+ MODE_RULES[a.mode],
3148
+ FORMAT_RULES,
3149
+ LOOKING(ROUNDS[a.tier], a.meaning, a.lookup)
3150
+ ].join("\n\n");
3151
+ const talk = conversation(a.earlier.map(turnExchange));
3152
+ const earlier = talk ? `# Earlier in this conversation\n\n${talk}` : "";
3153
+ const understood = a.sent.filter((s) => s.kind === "understanding");
3154
+ const given = a.sent.filter((s) => s.kind !== "understanding");
3155
+ const gave = given.length ? `# What they gave you\n\n${given.map(sentText).join("\n\n")}` : "";
3156
+ const known = understood.length ? `# Their understanding, as you have recorded it\n\n${understood.map(sentText).join("\n\n")}` : "";
3157
+ const request = `# What they ask now\n\n${a.asked}`;
3158
+ return {
3159
+ messages: [{
3160
+ role: "system",
3161
+ content: [{
3162
+ text: system,
3163
+ cache: true
3164
+ }]
3165
+ }, {
3166
+ role: "user",
3167
+ content: [
3168
+ earlier,
3169
+ known,
3170
+ gave,
3171
+ request
3172
+ ].filter(Boolean).join("\n\n")
3173
+ }],
3174
+ parts: [
3175
+ {
3176
+ name: "instructions and tools",
3177
+ detail: `${a.mode} mode${a.lookup ? "" : ", no look-ups"}`,
3178
+ tokens: estimate(system) + (a.lookup ? 500 : 0) + MODE_TOOLS[a.mode].length * 120
3179
+ },
3180
+ {
3181
+ name: "earlier in this conversation",
3182
+ detail: a.earlier.length ? `${a.earlier.length} ${a.earlier.length === 1 ? "turn" : "turns"}` : "",
3183
+ tokens: estimate(earlier)
3184
+ },
3185
+ {
3186
+ name: "what you gave",
3187
+ detail: given.length ? `${given.length} ${given.length === 1 ? "part" : "parts"}` : "nothing",
3188
+ tokens: estimate(gave)
3189
+ },
3190
+ ...a.mode === "understand" ? [{
3191
+ name: "your understanding",
3192
+ detail: `${understood.length} ${understood.length === 1 ? "note" : "notes"}`,
3193
+ tokens: estimate(known)
3194
+ }] : [],
3195
+ {
3196
+ name: "your message",
3197
+ detail: "",
3198
+ tokens: estimate(request)
3199
+ }
3200
+ ]
3201
+ };
2948
3202
  }
2949
3203
  //#endregion
2950
3204
  //#region packages/engine/src/meaning.ts
2951
3205
  const MAX_PIECE = 1800;
2952
- const QUERY = "Represent this sentence for searching relevant passages: ";
2953
3206
  const fingerprint = (text) => sha256(text).slice(0, 20);
2954
3207
  /** Every note cut into pieces: at its first- and second-level headings (not inside a code fence), and a long section at its paragraphs. */
2955
3208
  function pieces(b) {
@@ -3105,7 +3358,7 @@ var Meaning = class {
3105
3358
  pending: missing.length,
3106
3359
  ready: false
3107
3360
  };
3108
- const q = pack(await this.embedder.embed(QUERY + text)), dim = this.embedder.dim;
3361
+ const q = pack(await this.embedder.embed((this.embedder.query ?? "") + text)), dim = this.embedder.dim;
3109
3362
  const under = opts.under?.replace(/^\/+|\/+$/g, "");
3110
3363
  const best = /* @__PURE__ */ new Map();
3111
3364
  for (const p of ps) {
@@ -3140,9 +3393,9 @@ const DRAFT_PREFIX = "@draft/";
3140
3393
  const DRAFT_DIR = "drafts";
3141
3394
  const isDraftId = (id) => id.startsWith(DRAFT_PREFIX);
3142
3395
  /** A note's id without the prefix that says which folder of the study base it is in. */
3143
- const bare = (id) => isStudyId(id) ? id.slice(7) : isDraftId(id) ? id.slice(7) : id;
3396
+ const bare = (id) => isStudyId(id) ? id.slice(7) : isDraftId(id) ? id.slice(7) : isUnderstandingId(id) ? id.slice(15) : id;
3144
3397
  /** In the knowledge base itself: neither an exercise nor a draft. */
3145
- const inBase = (id) => !isStudyId(id) && !isDraftId(id);
3398
+ const inBase = (id) => !isStudyId(id) && !isDraftId(id) && !isUnderstandingId(id);
3146
3399
  const BAD_PART = /^\.|[\0\\:*?"<>|]|^\s|\s$/;
3147
3400
  /** A note's id, checked before it becomes a path: no "..", nothing hidden, no absolute path. */
3148
3401
  function checkNoteId(id) {
@@ -3150,6 +3403,8 @@ function checkNoteId(id) {
3150
3403
  if (!id || id.length > 400 || parts.some((p) => !p || BAD_PART.test(p))) throw new EngineError(`not a note's id: ${JSON.stringify(id)}`);
3151
3404
  return id;
3152
3405
  }
3406
+ /** A conversation's or turn's id: a ULID. */
3407
+ const TURN_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
3153
3408
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3154
3409
  async function walk(files, root) {
3155
3410
  const out = [];
@@ -3195,12 +3450,12 @@ const NOTE_ARG = {
3195
3450
  type: "string",
3196
3451
  description: "The note's id or path in the knowledge base: physics/softening or /physics/softening.md."
3197
3452
  };
3198
- const WAITS = "Notes are checked by the person: the change waits for them to keep it, in the browser or the terminal, and the note's file is unchanged until they do. Use this, never edit or write, for any note of the knowledge base.";
3199
- const PROPOSE_EDIT = {
3453
+ const MARKED = "The change is written at once, under your name: the person sees your words marked in the note, and can undo it from the browser. Use this, never edit or write, for any note of the knowledge base.";
3454
+ const EDIT_NOTE = {
3200
3455
  type: "function",
3201
3456
  function: {
3202
- name: "propose_note_edit",
3203
- description: `Propose a change to a note of the knowledge base: oldString replaced by newString, as the edit tool does. ${WAITS}`,
3457
+ name: "edit_note",
3458
+ description: `Change a note of the knowledge base: oldString replaced by newString, as the edit tool does. ${MARKED}`,
3204
3459
  parameters: {
3205
3460
  type: "object",
3206
3461
  properties: {
@@ -3224,11 +3479,11 @@ const PROPOSE_EDIT = {
3224
3479
  }
3225
3480
  }
3226
3481
  };
3227
- const PROPOSE_WRITE = {
3482
+ const WRITE_NOTE = {
3228
3483
  type: "function",
3229
3484
  function: {
3230
- name: "propose_note_write",
3231
- description: `Propose a whole note: a new note of the knowledge base, with its frontmatter (type, title), or one written anew. A new note waits as a note for review, and the person chooses its folder. ${WAITS}`,
3485
+ name: "write_note",
3486
+ description: `Write a whole note: a new note of the knowledge base, at folder/name (lower case, hyphens), with its frontmatter (type, title), or one written anew. ${MARKED}`,
3232
3487
  parameters: {
3233
3488
  type: "object",
3234
3489
  properties: {
@@ -3341,6 +3596,14 @@ var Engine = class Engine {
3341
3596
  step: read.get(c.id)?.step ?? 0,
3342
3597
  tokens: estimate(c.body.trim())
3343
3598
  };
3599
+ for (const [id, u] of read) {
3600
+ if (!notes[id] || u.last === null || !study) continue;
3601
+ const changed = (await this.journal(id)).findLast((e) => e.kind === "change" && e.who !== "you" && e.why !== "move");
3602
+ if (changed && Date.parse(changed.at) > u.last) notes[id] = {
3603
+ ...notes[id],
3604
+ changedSince: changed.at
3605
+ };
3606
+ }
3344
3607
  for (const c of exercises?.concepts.values() ?? []) notes[studyId(c.id)] = {
3345
3608
  title: c.title,
3346
3609
  step: 0,
@@ -3371,23 +3634,23 @@ var Engine = class Engine {
3371
3634
  TOOLS[0],
3372
3635
  ...this.similar ? [FIND_SIMILAR] : [],
3373
3636
  ...TOOLS.slice(1),
3374
- PROPOSE_EDIT,
3375
- PROPOSE_WRITE
3637
+ EDIT_NOTE,
3638
+ WRITE_NOTE
3376
3639
  ];
3377
3640
  }
3378
3641
  async agentLook({ agent, session, tool, args, asked = "" }) {
3379
3642
  if (tool === BRIEF.function.name) return { text: this.brief() };
3380
- if (tool === PROPOSE_EDIT.function.name || tool === PROPOSE_WRITE.function.name) {
3643
+ if (tool === EDIT_NOTE.function.name || tool === WRITE_NOTE.function.name) {
3381
3644
  const str = (v, name) => {
3382
3645
  if (typeof v !== "string") throw new EngineError(`${tool} needs ${name}, as text`, 400);
3383
3646
  return v;
3384
3647
  };
3385
- const edit = tool === PROPOSE_WRITE.function.name ? { content: str(args.content, "content") } : {
3648
+ const edit = tool === WRITE_NOTE.function.name ? { content: str(args.content, "content") } : {
3386
3649
  oldString: str(args.oldString, "oldString"),
3387
3650
  newString: str(args.newString, "newString"),
3388
3651
  replaceAll: args.replaceAll === true
3389
3652
  };
3390
- return this.agentPropose({
3653
+ return this.agentWrite({
3391
3654
  agent,
3392
3655
  session,
3393
3656
  note: str(args.note, "note"),
@@ -3419,44 +3682,43 @@ var Engine = class Engine {
3419
3682
  });
3420
3683
  }
3421
3684
  }
3422
- async agentPropose({ agent, session, note, asked, edit }) {
3423
- if (!this.opts.study) throw new EngineError("there is no study base, so an edit to a note cannot wait to be kept: show the person the change instead", 409);
3424
- const id = checkNoteId(note.replace(/^\/+/, "").replace(/\.md$/i, ""));
3425
- const who = `agent:${agent}`, read = this.turns.get(`${agent}/${session}`)?.lookup.read() ?? [];
3685
+ async agentWrite({ agent, session, note, asked, edit }) {
3686
+ const who = `agent:${agent}`;
3687
+ const raw = note.replace(/^\/+/, "").replace(/\.md$/i, "");
3688
+ const existing = noteId(this.bundle, raw) ?? await this.onDisk(raw);
3689
+ if (!existing) {
3690
+ if (!("content" in edit)) throw new EngineError(`there is no note ${raw}: to make one, write the whole of it`, 404);
3691
+ const id = this.newNoteId(raw);
3692
+ if (!id) throw new EngineError(`${raw} is not a note's id in this base: give folder/name, lower case with hyphens`, 400);
3693
+ let text = edit.content;
3694
+ if (!/^---\n/.test(text)) text = `---\ntype: Concept\ntitle: ${JSON.stringify(/^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? id.split("/").at(-1).replace(/-/g, " "))}\n---\n\n${text.replace(/^#\s+.+\n+/, "")}`;
3695
+ await this.one(() => this.put(id, text, null, "edit", { asked }, who));
3696
+ return { text: `Made ${this.titleOf(id, text)} (/${id}.md). The person sees it as yours, and can undo it.` };
3697
+ }
3698
+ const id = existing, now = await this.readNote(id);
3699
+ let text;
3700
+ if ("content" in edit) text = edit.content;
3701
+ else {
3702
+ const at = now.text.indexOf(edit.oldString);
3703
+ if (!edit.oldString || at < 0) throw new EngineError(`that passage is not in ${this.titleOf(id, now.text)} as you wrote it: read the note again and give the passage exactly`, 409);
3704
+ if (edit.replaceAll) text = now.text.split(edit.oldString).join(edit.newString);
3705
+ else {
3706
+ if (now.text.indexOf(edit.oldString, at + 1) >= 0) throw new EngineError(`that passage occurs more than once in ${this.titleOf(id, now.text)}: give more of it, or replaceAll`, 409);
3707
+ text = now.text.slice(0, at) + edit.newString + now.text.slice(at + edit.oldString.length);
3708
+ }
3709
+ }
3710
+ return { text: (await this.one(() => this.put(id, text, now.version, "edit", { asked }, who))).changed ? `Changed ${this.titleOf(id, text)}. The person sees your words marked, and can undo it.` : `That leaves ${this.titleOf(id, now.text)} as it is.` };
3711
+ }
3712
+ /** A note's id when its file is there, though the index may not have it yet; else null. */
3713
+ async onDisk(raw) {
3714
+ let id;
3715
+ try {
3716
+ id = checkNoteId(raw);
3717
+ } catch {
3718
+ return null;
3719
+ }
3426
3720
  const { files, path } = this.where(id);
3427
- if (await files.read(path) === null) {
3428
- if (!("content" in edit)) throw new EngineError(`there is no note ${id}: to propose a new note, write the whole of it`, 404);
3429
- let meta = null, body = edit.content;
3430
- try {
3431
- [meta, body] = splitFrontmatter(edit.content);
3432
- } catch {}
3433
- const title = typeof meta?.title === "string" && meta.title ? meta.title : id.split("/").at(-1);
3434
- const draft = DRAFT_PREFIX + newId(this.opts.clock.now()).toLowerCase();
3435
- await this.createNote(draft, `---\ntype: ${typeof meta?.type === "string" ? meta.type : "Concept"}\ntitle: ${JSON.stringify(title)}\n---\n`);
3436
- await this.one(async () => this.enter(draft, await this.journal(draft), {
3437
- ...await this.stamp(who),
3438
- kind: "proposal",
3439
- form: "insert",
3440
- after: null,
3441
- before: null,
3442
- text: body.replace(/^\n+/, ""),
3443
- asked: asked || `a new note at ${id}`,
3444
- read
3445
- }));
3446
- return { text: `The new note "${title}" waits for the person to review in the browser, as a note for review; they choose its folder when they keep it (you meant ${id.split("/").slice(0, -1).join("/") || "the top"}). Nothing was written to the knowledge base. Do not write it again.` };
3447
- }
3448
- const current = (await this.readNote(id)).text;
3449
- const regions = regionsOf(current, edit);
3450
- if (!regions.length) return { text: `That leaves ${this.titleOf(id, current)} as it is: nothing was proposed.` };
3451
- await this.one(async () => this.enter(id, await this.journal(id), {
3452
- ...await this.stamp(who),
3453
- kind: "proposal",
3454
- form: "rewrite",
3455
- regions,
3456
- asked: asked || "an edit",
3457
- read
3458
- }));
3459
- return { text: `Your edit to ${this.titleOf(id, current)} (${regions.length} ${regions.length === 1 ? "passage" : "passages"}) waits for the person to keep it, in the browser or the terminal's note view. The file is unchanged until they do. Do not write it again; go on as if it may not be kept.` };
3721
+ return await files.read(path) === null ? null : id;
3460
3722
  }
3461
3723
  async agentSend({ note, passage, said }) {
3462
3724
  const id = checkNoteId(note), concept = this.bundle.concepts.get(id);
@@ -3521,7 +3783,8 @@ var Engine = class Engine {
3521
3783
  createNote(id, text) {
3522
3784
  return this.one(() => this.put(id, text, null));
3523
3785
  }
3524
- async moveNote(id, to, base) {
3786
+ /** `who` is you unless Iso moves it, in Write mode. */
3787
+ async moveNote(id, to, base, who = "you") {
3525
3788
  to = to.trim().replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
3526
3789
  if (!inBase(id) || !inBase(to)) throw new EngineError("only a note of the base is moved, and only within it");
3527
3790
  checkNoteId(to);
@@ -3549,7 +3812,7 @@ var Engine = class Engine {
3549
3812
  }
3550
3813
  const hash = hashText(now);
3551
3814
  await this.enter(to, entries, {
3552
- ...await this.stamp("you"),
3815
+ ...await this.stamp(who),
3553
3816
  kind: "change",
3554
3817
  why: "move",
3555
3818
  before: hash,
@@ -3574,13 +3837,13 @@ var Engine = class Engine {
3574
3837
  type: "note-changed",
3575
3838
  id,
3576
3839
  version: null,
3577
- who: "you"
3840
+ who
3578
3841
  });
3579
3842
  this.publish({
3580
3843
  type: "note-changed",
3581
3844
  id: to,
3582
3845
  version: hashText(now),
3583
- who: "you"
3846
+ who
3584
3847
  });
3585
3848
  return now;
3586
3849
  });
@@ -3588,10 +3851,7 @@ var Engine = class Engine {
3588
3851
  for (const c of linking) {
3589
3852
  const source = await this.readNote(c);
3590
3853
  const changed = source.text.replace(old, `](/${to}.md`);
3591
- if (changed !== source.text) await this.saveNote(c, {
3592
- base: source.version,
3593
- text: changed
3594
- });
3854
+ if (changed !== source.text) await this.one(() => this.put(c, changed, source.version, "edit", {}, who));
3595
3855
  }
3596
3856
  if (study) for (const ex of Bundle.fromFiles(await walk(study, STUDY_DIR)).concepts.values()) {
3597
3857
  if (!testsOf(ex.meta).includes(id)) continue;
@@ -3609,7 +3869,7 @@ var Engine = class Engine {
3609
3869
  changed: true
3610
3870
  };
3611
3871
  }
3612
- async deleteNote(id, base) {
3872
+ async deleteNote(id, base, who = "you") {
3613
3873
  if (!inBase(id)) throw new EngineError("only a note of the base is deleted here");
3614
3874
  const notes = this.opts.notes, path = `${checkNoteId(id)}.md`;
3615
3875
  await this.one(async () => {
@@ -3631,7 +3891,7 @@ var Engine = class Engine {
3631
3891
  type: "note-changed",
3632
3892
  id,
3633
3893
  version: null,
3634
- who: "you"
3894
+ who
3635
3895
  });
3636
3896
  }
3637
3897
  /** Writes happen one at a time, in the order asked. */
@@ -3646,7 +3906,7 @@ var Engine = class Engine {
3646
3906
  if (!this.opts.study) throw new EngineError("there is no study base open", 404);
3647
3907
  return {
3648
3908
  files: this.opts.study,
3649
- path: `${isDraftId(id) ? DRAFT_DIR : STUDY_DIR}/${checkNoteId(bare(id))}.md`
3909
+ path: `${isDraftId(id) ? DRAFT_DIR : isUnderstandingId(id) ? UNDERSTANDING_DIR : STUDY_DIR}/${checkNoteId(bare(id))}.md`
3650
3910
  };
3651
3911
  }
3652
3912
  return {
@@ -3666,7 +3926,7 @@ var Engine = class Engine {
3666
3926
  restore(id, entry, base) {
3667
3927
  return this.one(async () => this.put(id, await this.version(id, entry), base, "restore", { to: entry }));
3668
3928
  }
3669
- async put(id, text, base, why = "edit", extra = {}) {
3929
+ async put(id, text, base, why = "edit", extra = {}, who = "you") {
3670
3930
  const { files, path } = this.where(id);
3671
3931
  if (text.length > 2e6) throw new EngineError("note too large", 413);
3672
3932
  const now = await files.read(path);
@@ -3686,7 +3946,8 @@ var Engine = class Engine {
3686
3946
  changed: false
3687
3947
  };
3688
3948
  await files.write(path, text);
3689
- await this.enter(id, entries, change(entries, text, await this.stamp("you"), why, extra));
3949
+ const entry = change(entries, text, await this.stamp(who), why, extra);
3950
+ await this.enter(id, entries, entry);
3690
3951
  if (inBase(id)) {
3691
3952
  this.files.set(path, {
3692
3953
  path,
@@ -3700,13 +3961,14 @@ var Engine = class Engine {
3700
3961
  type: "note-changed",
3701
3962
  id,
3702
3963
  version,
3703
- who: "you"
3964
+ who
3704
3965
  });
3705
3966
  return {
3706
3967
  id,
3707
3968
  text,
3708
3969
  version,
3709
- changed: true
3970
+ changed: true,
3971
+ ...entry ? { entry: entry.id } : {}
3710
3972
  };
3711
3973
  }
3712
3974
  get provider() {
@@ -3808,7 +4070,7 @@ var Engine = class Engine {
3808
4070
  if (!key && /\/\/(?:[^/]*\.)?openrouter\.ai\b/.test(this.provider.url)) throw new EngineError("There is no provider key yet. Set one in settings, behind the logo.", 409);
3809
4071
  const { messages, olds, text: sent } = await this.compose(req, asked);
3810
4072
  const maxTokens = req.form === "insert" ? REPLY_TOKENS : req.form === "note" ? NOTE_TOKENS : req.form === "mark" ? MARK_TOKENS : req.form === "exercise" ? EXERCISE_TOKENS : Math.max(REPLY_TOKENS, Math.ceil(olds.join("").length / 3) + 1e3);
3811
- const most = req.form === "mark" || req.form === "exercise" ? 0 : void 0;
4073
+ const most = req.form === "mark" || req.form === "exercise" || (req.form === "insert" || req.form === "rewrite") && req.choice?.lookup === false ? 0 : void 0;
3812
4074
  const request = newId(this.opts.clock.now()), abort = new AbortController();
3813
4075
  this.running.set(request, abort);
3814
4076
  this.asking.set(req.note, request);
@@ -4042,20 +4304,7 @@ var Engine = class Engine {
4042
4304
  body = splitFrontmatter(text)[1];
4043
4305
  } catch {}
4044
4306
  if (!body.trim()) throw new EngineError("write what you want from Iso first");
4045
- const linked = [];
4046
- let room = LINKED_BUDGET;
4047
- for (const m of body.matchAll(/\]\(\/([^)\s#]+)\.md(?:#[^)\s]*)?(?:\s+"[^"]*")?\)/g)) {
4048
- const c = this.bundle.concepts.get(decodeURIComponent(m[1]));
4049
- if (!c || linked.some((l) => l.id === c.id)) continue;
4050
- const piece = c.body.trim().slice(0, Math.max(0, room) * 4);
4051
- if (piece.length < Math.min(c.body.trim().length, 400)) break;
4052
- linked.push({
4053
- id: c.id,
4054
- title: c.title,
4055
- text: piece + (piece.length < c.body.trim().length ? "\n[…cut: read the rest with read_note]" : "")
4056
- });
4057
- room -= estimate(piece);
4058
- }
4307
+ const linked = this.linkedSent(body);
4059
4308
  const earlier = await this.exchangesNow();
4060
4309
  return {
4061
4310
  ...noteMessages({
@@ -4105,8 +4354,12 @@ var Engine = class Engine {
4105
4354
  }
4106
4355
  const title = this.titleOf(req.note, text);
4107
4356
  const entries = await this.journal(req.note);
4108
- const earlier = await this.exchangesNow();
4109
- const read = new Set(entries.flatMap((e) => e.kind === "proposal" && earlier.some((x) => x.id === e.id) ? e.read ?? [] : []));
4357
+ const off = new Set(req.choice?.off ?? []);
4358
+ const earlier = (await this.exchangesNow()).filter((x) => !off.has(`x:${x.id}`));
4359
+ let body = text;
4360
+ try {
4361
+ body = splitFrontmatter(text)[1];
4362
+ } catch {}
4110
4363
  const given = {
4111
4364
  tier: req.tier,
4112
4365
  meaning: Boolean(this.similar),
@@ -4117,10 +4370,8 @@ var Engine = class Engine {
4117
4370
  unkept: pending(entries).map((p) => p.form === "insert" ? p.text : p.regions.map((r) => r.new).join("\n\n")),
4118
4371
  conversation: conversation(earlier),
4119
4372
  moments: earlier.length,
4120
- linked: {
4121
- notes: read.size,
4122
- tokens: [...read].reduce((n, id) => n + estimate(this.bundle.concepts.get(id)?.body ?? ""), 0)
4123
- }
4373
+ linked: this.linkedSent(body, off, req.note),
4374
+ lookup: req.choice?.lookup !== false
4124
4375
  };
4125
4376
  return {
4126
4377
  ...req.form === "insert" ? insertMessages(given) : rewriteMessages({
@@ -4131,6 +4382,37 @@ var Engine = class Engine {
4131
4382
  olds
4132
4383
  };
4133
4384
  }
4385
+ /** The notes a note's text links to, each once, in order: the note itself left out. */
4386
+ linksOf(body, self = "") {
4387
+ const out = [];
4388
+ for (const m of body.matchAll(/\]\(\/([^)\s#]+)\.md(?:#[^)\s]*)?(?:\s+"[^"]*")?\)/g)) {
4389
+ const c = this.bundle.concepts.get(decodeURIComponent(m[1]));
4390
+ if (c && c.id !== self && !out.some((l) => l.id === c.id)) out.push({
4391
+ id: c.id,
4392
+ title: c.title,
4393
+ body: c.body.trim()
4394
+ });
4395
+ }
4396
+ return out;
4397
+ }
4398
+ /** The linked notes sent with a request, those switched off left out, as far as there is room; the rest Iso
4399
+ * reads with its tools. */
4400
+ linkedSent(body, off = /* @__PURE__ */ new Set(), self = "") {
4401
+ const linked = [];
4402
+ let room = LINKED_BUDGET;
4403
+ for (const c of this.linksOf(body, self)) {
4404
+ if (off.has(`n:${c.id}`)) continue;
4405
+ const piece = c.body.slice(0, Math.max(0, room) * 4);
4406
+ if (piece.length < Math.min(c.body.length, 400)) break;
4407
+ linked.push({
4408
+ id: c.id,
4409
+ title: c.title,
4410
+ text: piece + (piece.length < c.body.length ? "\n[…cut: read the rest with read_note]" : "")
4411
+ });
4412
+ room -= estimate(piece);
4413
+ }
4414
+ return linked;
4415
+ }
4134
4416
  titleOf(id, text) {
4135
4417
  try {
4136
4418
  const t = splitFrontmatter(text)[0]?.title;
@@ -4208,22 +4490,43 @@ var Engine = class Engine {
4208
4490
  if (!this.opts.study) return;
4209
4491
  this.cleared = await this.one(() => this.record({ event: "context-cleared" }));
4210
4492
  }
4211
- async context(note) {
4212
- let parts = null;
4493
+ async context(note, choice) {
4494
+ let parts = null, items = [];
4213
4495
  if (note && isDraftId(note)) parts = await this.exerciseDraft(note).catch(() => null) ? await this.readNote(note).then(async (s) => (await this.exerciseCompose(note, s.text, await this.exerciseAsked(note))).parts, () => null) : await this.compose({
4214
4496
  note,
4215
4497
  form: "note",
4216
4498
  tier: "mid"
4217
4499
  }, "").then((c) => c.parts, () => null);
4218
4500
  else if (note && isStudyId(note)) parts = await this.readNote(note).then(async (s) => (await this.markCompose(note, s.text, null)).parts, () => null);
4219
- else if (note) ({parts} = await this.compose({
4220
- note,
4221
- form: "insert",
4222
- after: null,
4223
- before: null,
4224
- asked: "",
4225
- tier: "mid"
4226
- }, ""));
4501
+ else if (note) {
4502
+ ({parts} = await this.compose({
4503
+ note,
4504
+ form: "insert",
4505
+ after: null,
4506
+ before: null,
4507
+ asked: "",
4508
+ tier: "mid",
4509
+ ...choice ? { choice } : {}
4510
+ }, ""));
4511
+ const off = new Set(choice?.off ?? []);
4512
+ let body = (await this.readNote(note)).text;
4513
+ try {
4514
+ body = splitFrontmatter(body)[1];
4515
+ } catch {}
4516
+ items = [...(await this.exchangesNow()).map((x) => ({
4517
+ id: `x:${x.id}`,
4518
+ kind: "exchange",
4519
+ title: x.text.split("\n")[0].replace(/^In (.+?) \(\/[^)]*\) they asked[^:]*: /, "$1: "),
4520
+ tokens: estimate(x.text),
4521
+ on: !off.has(`x:${x.id}`)
4522
+ })), ...this.linksOf(body, note).map((c) => ({
4523
+ id: `n:${c.id}`,
4524
+ kind: "linked",
4525
+ title: c.title,
4526
+ tokens: estimate(c.body),
4527
+ on: !off.has(`n:${c.id}`)
4528
+ }))];
4529
+ }
4227
4530
  if (!parts) {
4228
4531
  const earlier = await this.exchangesNow();
4229
4532
  ({parts} = insertMessages({
@@ -4243,6 +4546,18 @@ var Engine = class Engine {
4243
4546
  tokens: 0
4244
4547
  } : p);
4245
4548
  }
4549
+ return {
4550
+ tokens: parts.reduce((n, p) => n + p.tokens, 0),
4551
+ limit: this.provider.context ?? 2e5,
4552
+ parts,
4553
+ session: await this.sessionCost(),
4554
+ items,
4555
+ lookup: choice?.lookup !== false
4556
+ };
4557
+ }
4558
+ /** What the session has cost: every reply offered, kept or not, every turn in Iso's pane, and every reply that came
4559
+ * to nothing. */
4560
+ async sessionCost() {
4246
4561
  const session = await this.sessionNow();
4247
4562
  let usd = 0, requests = 0;
4248
4563
  for (const entries of (await this.sessionEntries()).values()) for (const e of entries) if ((e.kind === "proposal" && !e.copyOf || e.kind === "marks") && e.cost) {
@@ -4250,20 +4565,683 @@ var Engine = class Engine {
4250
4565
  requests++;
4251
4566
  }
4252
4567
  const text = this.opts.study ? await this.opts.study.read(recordPath(this.opts.device)) : null;
4253
- for (const e of readRecord(text ?? "")) if (e.event === "iso-unused" && e.session === session) {
4568
+ for (const e of readRecord(text ?? "")) if ((e.event === "iso-unused" || e.event === "iso-turn") && e.session === session) {
4254
4569
  usd += typeof e.usd === "number" ? e.usd : 0;
4255
4570
  requests++;
4256
4571
  }
4257
4572
  return {
4258
- tokens: parts.reduce((n, p) => n + p.tokens, 0),
4259
- limit: this.provider.context ?? 2e5,
4260
- parts,
4261
- session: {
4262
- usd,
4263
- requests
4264
- }
4573
+ usd,
4574
+ requests
4265
4575
  };
4266
4576
  }
4577
+ /** The turn running in each conversation: one at a time. */
4578
+ conversing = /* @__PURE__ */ new Map();
4579
+ /** What Iso asked before doing something, waiting for your answer, by the permission's id. */
4580
+ waitingAnswer = /* @__PURE__ */ new Map();
4581
+ async conversation(id) {
4582
+ const study = this.opts.study;
4583
+ if (!study) return null;
4584
+ if (!id) {
4585
+ id = (await study.list(CONVERSATIONS_DIR).catch(() => [])).filter((f) => !f.dir && f.name.endsWith(".jsonl")).map((f) => f.name.slice(0, -6)).sort().at(-1);
4586
+ if (!id) return null;
4587
+ }
4588
+ if (!TURN_ID.test(id)) throw new EngineError("not a conversation's id");
4589
+ const text = await study.read(conversationPath(id));
4590
+ return text === null ? null : {
4591
+ id,
4592
+ turns: readTurns(text).map((t) => this.running.has(t.id) ? {
4593
+ ...t,
4594
+ state: "running"
4595
+ } : t)
4596
+ };
4597
+ }
4598
+ /** What was given, checked: notes and passages of the base, and its folders. */
4599
+ given(raw) {
4600
+ if (!Array.isArray(raw) || raw.length > 40) throw new EngineError("give Iso between none and forty notes, folders and passages");
4601
+ return raw.map((g) => {
4602
+ const id = typeof g?.id === "string" ? g.id.replace(/^\/+|\/+$/g, "") : "";
4603
+ if (g?.kind === "folder") {
4604
+ if (id) checkNoteId(id);
4605
+ return {
4606
+ kind: "folder",
4607
+ id
4608
+ };
4609
+ }
4610
+ if (!inBase(checkNoteId(id))) throw new EngineError("only notes of the base are given to Iso");
4611
+ if (g?.kind === "note") return {
4612
+ kind: "note",
4613
+ id
4614
+ };
4615
+ if (g?.kind === "understanding") return {
4616
+ kind: "understanding",
4617
+ id
4618
+ };
4619
+ if (g?.kind === "passage" && typeof g.text === "string" && g.text.trim() && g.text.length <= 2e4) return {
4620
+ kind: "passage",
4621
+ id,
4622
+ text: g.text
4623
+ };
4624
+ throw new EngineError("a part given to Iso is a note, a folder or a passage");
4625
+ });
4626
+ }
4627
+ /** What was given, read now, as it is sent. A note that is gone is left out. */
4628
+ async sentOf(given) {
4629
+ const out = [];
4630
+ for (const g of given) {
4631
+ if (g.kind === "folder") {
4632
+ const under = g.id ? `${g.id}/` : "";
4633
+ out.push({
4634
+ kind: "folder",
4635
+ id: g.id,
4636
+ notes: [...this.bundle.concepts.values()].filter((c) => c.id.startsWith(under) && inBase(c.id)).map((c) => ({
4637
+ id: c.id,
4638
+ title: c.title,
4639
+ description: c.description
4640
+ }))
4641
+ });
4642
+ continue;
4643
+ }
4644
+ const c = this.bundle.concepts.get(g.id);
4645
+ if (!c) continue;
4646
+ if (g.kind === "understanding") {
4647
+ const u = await this.understanding(g.id);
4648
+ const said = (e) => `${e.at.slice(0, 10)}: ${e.by > 0 ? "+" : ""}${e.by}${e.event === "wind-back" ? " (they wound you back)" : e.event === "attempt" ? " (an exercise)" : ""}${e.why ? `, ${e.why}` : ""}`;
4649
+ out.push({
4650
+ kind: "understanding",
4651
+ id: g.id,
4652
+ title: c.title,
4653
+ reading: u.reading,
4654
+ events: u.evidence.slice(-8).reverse().map(said),
4655
+ squares: u.squares
4656
+ });
4657
+ continue;
4658
+ }
4659
+ if (g.kind === "passage") out.push({
4660
+ kind: "passage",
4661
+ id: g.id,
4662
+ title: c.title,
4663
+ text: g.text
4664
+ });
4665
+ else out.push({
4666
+ kind: "note",
4667
+ id: g.id,
4668
+ title: c.title,
4669
+ text: (await this.readNote(g.id)).text
4670
+ });
4671
+ }
4672
+ return out;
4673
+ }
4674
+ async turnContext(req) {
4675
+ const mode = req.mode === "build" ? "ask" : req.mode;
4676
+ const all = req.conversation ? (await this.conversation(req.conversation))?.turns ?? [] : [];
4677
+ const off = new Set(req.off ?? []), earlier = all.filter((t) => !off.has(t.id));
4678
+ const sent = await this.sentOf(this.given(req.given ?? []));
4679
+ const { parts } = turnMessages({
4680
+ mode,
4681
+ tier: req.tier,
4682
+ meaning: Boolean(this.similar),
4683
+ lookup: req.lookup !== false,
4684
+ earlier,
4685
+ sent,
4686
+ asked: ""
4687
+ });
4688
+ const items = all.map((t) => ({
4689
+ id: t.id,
4690
+ kind: "exchange",
4691
+ title: t.asked,
4692
+ tokens: estimate(turnExchange(t).text),
4693
+ on: !off.has(t.id)
4694
+ }));
4695
+ return {
4696
+ tokens: parts.reduce((n, p) => n + p.tokens, 0),
4697
+ limit: this.provider.context ?? 2e5,
4698
+ parts,
4699
+ session: await this.sessionCost(),
4700
+ items,
4701
+ lookup: req.lookup !== false,
4702
+ given: sent.map((g) => ({
4703
+ kind: g.kind,
4704
+ id: g.id,
4705
+ ...g.kind === "passage" ? { text: g.text } : {},
4706
+ tokens: estimate(sentText(g))
4707
+ }))
4708
+ };
4709
+ }
4710
+ async converse(req) {
4711
+ const http = this.opts.http, study = this.opts.study;
4712
+ if (!http) throw new EngineError("Iso cannot be asked here", 409);
4713
+ if (!study) throw new EngineError("Iso's conversations are kept in a study base, and none is open (isorite study init)", 409);
4714
+ if (!MODES.includes(req.mode)) throw new EngineError("the mode is ask, write, build or understand");
4715
+ if (req.mode === "build") throw new EngineError("Build mode is the terminal's: start isorite term", 409);
4716
+ if (!TIERS.includes(req.tier)) throw new EngineError("the tier is low, mid or max");
4717
+ const asked = String(req.asked ?? "").trim();
4718
+ if (!asked) throw new EngineError("say what you want from Iso");
4719
+ if (asked.length > 4e3) throw new EngineError("that is too long to ask at once", 413);
4720
+ const given = this.given(req.given ?? []);
4721
+ if (req.conversation !== void 0 && !TURN_ID.test(req.conversation)) throw new EngineError("not a conversation's id");
4722
+ const conversation = req.conversation ?? newId(this.opts.clock.now());
4723
+ if (this.conversing.has(conversation)) throw new EngineError("Iso is still answering in this conversation. Wait for it, or stop it.", 409);
4724
+ this.conversing.set(conversation, "");
4725
+ try {
4726
+ return {
4727
+ conversation,
4728
+ turn: await this.beginTurn(conversation, {
4729
+ ...req,
4730
+ mode: req.mode,
4731
+ asked,
4732
+ given
4733
+ }, http)
4734
+ };
4735
+ } catch (err) {
4736
+ this.conversing.delete(conversation);
4737
+ throw err;
4738
+ }
4739
+ }
4740
+ async beginTurn(conversation, req, http) {
4741
+ const study = this.opts.study;
4742
+ const key = await this.opts.secrets?.get("provider.key") ?? null;
4743
+ if (!key && /\/\/(?:[^/]*\.)?openrouter\.ai\b/.test(this.provider.url)) throw new EngineError("There is no provider key yet. Set one in settings, behind the logo.", 409);
4744
+ const off = new Set(req.off ?? []), earlier = ((await this.conversation(conversation))?.turns ?? []).filter((t) => !off.has(t.id));
4745
+ const lookupOn = req.lookup !== false, mode = req.mode;
4746
+ const { messages } = turnMessages({
4747
+ mode,
4748
+ tier: req.tier,
4749
+ meaning: Boolean(this.similar),
4750
+ lookup: lookupOn,
4751
+ earlier,
4752
+ sent: await this.sentOf(req.given),
4753
+ asked: req.asked
4754
+ });
4755
+ const s = await this.stamp("you"), turn = s.id, abort = new AbortController(), path = conversationPath(conversation);
4756
+ this.last = {
4757
+ at: s.at,
4758
+ session: s.session
4759
+ };
4760
+ const line = (l) => this.one(() => study.append(path, JSON.stringify(l) + "\n"));
4761
+ await line({
4762
+ kind: "turn",
4763
+ id: turn,
4764
+ conversation,
4765
+ at: s.at,
4766
+ session: s.session,
4767
+ mode,
4768
+ asked: req.asked,
4769
+ given: req.given,
4770
+ tier: req.tier
4771
+ });
4772
+ this.running.set(turn, abort);
4773
+ this.conversing.set(conversation, turn);
4774
+ this.publish({
4775
+ type: "iso-start",
4776
+ request: turn,
4777
+ note: "",
4778
+ asked: req.asked,
4779
+ tier: req.tier,
4780
+ place: {
4781
+ form: "turn",
4782
+ conversation,
4783
+ mode,
4784
+ given: req.given
4785
+ }
4786
+ });
4787
+ const item = async (i) => {
4788
+ await line({
4789
+ kind: "item",
4790
+ turn,
4791
+ item: i
4792
+ });
4793
+ this.publish({
4794
+ type: "iso-item",
4795
+ request: turn,
4796
+ conversation,
4797
+ item: i
4798
+ });
4799
+ };
4800
+ const who = `iso:${this.provider.name}/${this.provider.tiers[req.tier]}`;
4801
+ const lookup = new Lookup(this.bundle, req.given.find((g) => g.kind !== "folder")?.id ?? "", this.similar);
4802
+ const looks = new Set(lookup.defs().map((d) => d.function.name));
4803
+ const tools = {
4804
+ defs: () => [...lookupOn ? lookup.defs() : [], ...MODE_TOOLS[mode]],
4805
+ call: async (name, raw) => {
4806
+ if (looks.has(name)) return lookupOn ? lookup.call(name, raw) : { text: "Looking things up is off: they chose what you are sent." };
4807
+ let args = {};
4808
+ try {
4809
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
4810
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed;
4811
+ } catch {}
4812
+ if (!MODE_TOOLS[mode].some((d) => d.function.name === name)) return { text: `There is no tool ${name} in ${mode} mode.` };
4813
+ return { text: await this.turnTool(name, args, {
4814
+ turn,
4815
+ conversation,
4816
+ mode,
4817
+ item,
4818
+ signal: abort.signal,
4819
+ who,
4820
+ asked: req.asked
4821
+ }) };
4822
+ }
4823
+ };
4824
+ (async () => {
4825
+ try {
4826
+ const got = await rounds({
4827
+ http,
4828
+ provider: this.provider,
4829
+ key,
4830
+ tier: req.tier,
4831
+ messages,
4832
+ lookup: tools,
4833
+ maxTokens: REPLY_TOKENS * 2,
4834
+ signal: abort.signal,
4835
+ most: ROUNDS[req.tier],
4836
+ onText: (delta, again) => this.publish({
4837
+ type: "iso-text",
4838
+ request: turn,
4839
+ note: "",
4840
+ delta,
4841
+ again
4842
+ }),
4843
+ onStep: (step) => {
4844
+ item({
4845
+ kind: "look",
4846
+ step
4847
+ });
4848
+ this.publish({
4849
+ type: "iso-step",
4850
+ request: turn,
4851
+ note: "",
4852
+ step
4853
+ });
4854
+ }
4855
+ });
4856
+ if (abort.signal.aborted) throw Object.assign(/* @__PURE__ */ new Error("stopped"), { name: "AbortError" });
4857
+ const cost = {
4858
+ model: got.usage.model,
4859
+ tier: req.tier,
4860
+ tokensIn: got.usage.tokensIn,
4861
+ tokensOut: got.usage.tokensOut,
4862
+ tokensCached: got.usage.tokensCached,
4863
+ usd: got.usage.usd
4864
+ };
4865
+ const said = got.text.trim() + (got.cut ? "\n\n[Cut off at its length limit.]" : "");
4866
+ if (said) await item({
4867
+ kind: "text",
4868
+ text: said
4869
+ });
4870
+ await line({
4871
+ kind: "end",
4872
+ turn,
4873
+ state: "done",
4874
+ cost
4875
+ });
4876
+ if (cost.usd > 0) await this.one(async () => this.record({
4877
+ event: "iso-turn",
4878
+ conversation,
4879
+ turn,
4880
+ session: s.session,
4881
+ model: cost.model,
4882
+ usd: cost.usd
4883
+ }));
4884
+ this.publish({
4885
+ type: "iso-done",
4886
+ request: turn,
4887
+ note: "",
4888
+ proposal: "",
4889
+ cost
4890
+ });
4891
+ } catch (err) {
4892
+ const stopped = abort.signal.aborted || err.name === "AbortError";
4893
+ const message = err.message || "Iso could not answer";
4894
+ await line(stopped ? {
4895
+ kind: "end",
4896
+ turn,
4897
+ state: "stopped"
4898
+ } : {
4899
+ kind: "end",
4900
+ turn,
4901
+ state: "failed",
4902
+ message
4903
+ }).catch(() => void 0);
4904
+ if (stopped) this.publish({
4905
+ type: "iso-stopped",
4906
+ request: turn,
4907
+ note: ""
4908
+ });
4909
+ else this.publish({
4910
+ type: "iso-failed",
4911
+ request: turn,
4912
+ note: "",
4913
+ message
4914
+ });
4915
+ } finally {
4916
+ this.running.delete(turn);
4917
+ this.conversing.delete(conversation);
4918
+ for (const [id, w] of this.waitingAnswer) if (w.turn === turn) {
4919
+ this.waitingAnswer.delete(id);
4920
+ w.settle("reject");
4921
+ }
4922
+ }
4923
+ })();
4924
+ return turn;
4925
+ }
4926
+ /** What you allowed for the rest of a conversation ("always"): by conversation, the tools. */
4927
+ allowed = /* @__PURE__ */ new Map();
4928
+ /** Ask you before doing something; wait for your answer. "always" holds for the rest of the conversation. */
4929
+ async permit(t, tool, note, what) {
4930
+ if (this.allowed.get(t.conversation)?.has(tool)) return "always";
4931
+ const id = newId(this.opts.clock.now());
4932
+ const answer = new Promise((settle) => this.waitingAnswer.set(id, {
4933
+ turn: t.turn,
4934
+ conversation: t.conversation,
4935
+ settle
4936
+ }));
4937
+ await t.item({
4938
+ kind: "permission",
4939
+ id,
4940
+ tool,
4941
+ note,
4942
+ what
4943
+ });
4944
+ const stop = new Promise((settle) => t.signal.addEventListener("abort", () => settle("reject"), { once: true }));
4945
+ const d = await Promise.race([answer, stop]);
4946
+ this.waitingAnswer.delete(id);
4947
+ if (d === "always") this.allowed.set(t.conversation, /* @__PURE__ */ new Set([...this.allowed.get(t.conversation) ?? [], tool]));
4948
+ return d;
4949
+ }
4950
+ /** A note's id as Iso writes one for a new note: folder/name, without a leading slash or `.md`. */
4951
+ newNoteId(raw) {
4952
+ const id = String(raw ?? "").trim().replace(/^\/+|\/+$/g, "").replace(/\.md$/i, "");
4953
+ try {
4954
+ checkNoteId(id);
4955
+ } catch {
4956
+ return null;
4957
+ }
4958
+ return inBase(id) ? id : null;
4959
+ }
4960
+ /** One of a mode's own tools, in a turn: what is said back to Iso. */
4961
+ async turnTool(name, args, t) {
4962
+ const title = (id) => this.bundle.concepts.get(id)?.title ?? id;
4963
+ if (name === "point_to_note") {
4964
+ const id = noteId(this.bundle, args.id);
4965
+ if (!id) return `There is no note ${String(args.id ?? "")}.`;
4966
+ await t.item({
4967
+ kind: "point",
4968
+ note: id,
4969
+ why: String(args.why ?? "").trim().slice(0, 300)
4970
+ });
4971
+ return `${title(id)} is marked on their Atlas.`;
4972
+ }
4973
+ const counted = (before, after) => {
4974
+ const hunks = diffLines(before.split("\n"), after.split("\n"));
4975
+ return {
4976
+ added: hunks.reduce((n, h) => n + h.ins.length, 0),
4977
+ removed: hunks.reduce((n, h) => n + h.del.length, 0)
4978
+ };
4979
+ };
4980
+ if (name === "edit_note") {
4981
+ const id = noteId(this.bundle, args.id);
4982
+ if (!id) return `There is no note ${String(args.id ?? "")}. Search for it by its words.`;
4983
+ const old = String(args.old ?? ""), next = String(args.new ?? "");
4984
+ if (!old) return "Give the passage as it is now, in old.";
4985
+ const now = await this.readNote(id);
4986
+ const at = now.text.indexOf(old), again = at < 0 ? -1 : now.text.indexOf(old, at + 1);
4987
+ if (at < 0) return `That passage is not in ${title(id)} as you wrote it. Read the note again and give the passage exactly.`;
4988
+ if (again >= 0) return `That passage occurs more than once in ${title(id)}. Give more of it, so it is found once.`;
4989
+ const text = now.text.slice(0, at) + next + now.text.slice(at + old.length);
4990
+ const saved = await this.one(() => this.put(id, text, now.version, "edit", { asked: t.asked }, t.who));
4991
+ if (saved.entry) await t.item({
4992
+ kind: "change",
4993
+ note: id,
4994
+ entry: saved.entry,
4995
+ why: "edit",
4996
+ title: title(id),
4997
+ ...counted(now.text, text)
4998
+ });
4999
+ return `Changed ${title(id)}.`;
5000
+ }
5001
+ if (name === "write_note") {
5002
+ const id = this.newNoteId(args.id);
5003
+ if (!id) return `${String(args.id ?? "")} is not a note's id in their base: give folder/name, lower case with hyphens.`;
5004
+ let text = String(args.text ?? "");
5005
+ if (!text.trim()) return "Give the note's whole text.";
5006
+ if (!/^---\n/.test(text)) text = `---\ntype: Concept\ntitle: ${JSON.stringify(/^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? id.split("/").at(-1).replace(/-/g, " "))}\n---\n\n${text.replace(/^#\s+.+\n+/, "")}`;
5007
+ const was = this.bundle.concepts.has(id) ? await this.readNote(id) : null;
5008
+ const saved = await this.one(() => this.put(id, text, was?.version ?? null, "edit", { asked: t.asked }, t.who));
5009
+ if (saved.entry) await t.item({
5010
+ kind: "change",
5011
+ note: id,
5012
+ entry: saved.entry,
5013
+ why: was ? "edit" : "create",
5014
+ title: title(id),
5015
+ ...counted(was?.text ?? "", text)
5016
+ });
5017
+ return was ? `Wrote ${title(id)} anew.` : `Made ${title(id)} (/${id}.md).`;
5018
+ }
5019
+ if (name === "move_note") {
5020
+ const id = noteId(this.bundle, args.id), to = this.newNoteId(args.to);
5021
+ if (!id) return `There is no note ${String(args.id ?? "")}.`;
5022
+ if (!to) return `${String(args.to ?? "")} is not a note's id in their base.`;
5023
+ if (await this.permit(t, "move_note", id, `move ${title(id)} to ${to}`) === "reject") return "They did not allow it. It was not moved.";
5024
+ const now = await this.readNote(id), before = title(id);
5025
+ try {
5026
+ await this.moveNote(id, to, now.version, t.who);
5027
+ } catch (e) {
5028
+ return `It could not be moved: ${e.message}`;
5029
+ }
5030
+ const entry = (await this.journal(to)).at(-1)?.id ?? "";
5031
+ await t.item({
5032
+ kind: "change",
5033
+ note: to,
5034
+ entry,
5035
+ why: "move",
5036
+ to,
5037
+ title: before,
5038
+ added: 0,
5039
+ removed: 0
5040
+ });
5041
+ return `Moved ${before} to /${to}.md.`;
5042
+ }
5043
+ if (name === "delete_note") {
5044
+ const id = noteId(this.bundle, args.id);
5045
+ if (!id) return `There is no note ${String(args.id ?? "")}.`;
5046
+ if (await this.permit(t, "delete_note", id, `delete ${title(id)}`) === "reject") return "They did not allow it. It was not deleted.";
5047
+ const now = await this.readNote(id), before = title(id);
5048
+ try {
5049
+ await this.deleteNote(id, now.version, t.who);
5050
+ } catch (e) {
5051
+ return `It could not be deleted: ${e.message}`;
5052
+ }
5053
+ await t.item({
5054
+ kind: "change",
5055
+ note: id,
5056
+ entry: (await this.journal(id)).at(-1)?.id ?? "",
5057
+ why: "delete",
5058
+ title: before,
5059
+ added: 0,
5060
+ removed: now.text.split("\n").length
5061
+ });
5062
+ return `Deleted ${before}. Its history stays.`;
5063
+ }
5064
+ if (name === "record_understanding") {
5065
+ const id = noteId(this.bundle, args.id);
5066
+ if (!id) return `There is no note ${String(args.id ?? "")}.`;
5067
+ const by = Number(args.by);
5068
+ if (![
5069
+ -1,
5070
+ -.5,
5071
+ 0,
5072
+ .5,
5073
+ 1
5074
+ ].includes(by)) return "by is one of -1, -0.5, 0, 0.5, 1.";
5075
+ const why = String(args.why ?? "").trim().slice(0, 300);
5076
+ if (this.recorded.get(t.turn)?.has(id)) return `You recorded ${title(id)} once this turn already.`;
5077
+ this.recorded.set(t.turn, /* @__PURE__ */ new Set([...this.recorded.get(t.turn) ?? [], id]));
5078
+ const event = await this.one(() => this.record({
5079
+ event: "understood",
5080
+ note: id,
5081
+ by,
5082
+ why,
5083
+ conversation: t.conversation,
5084
+ turn: t.turn
5085
+ }));
5086
+ await t.item({
5087
+ kind: "understood",
5088
+ note: id,
5089
+ event,
5090
+ by,
5091
+ why
5092
+ });
5093
+ return `Recorded for ${title(id)}.`;
5094
+ }
5095
+ if (name === "write_understanding") {
5096
+ const id = noteId(this.bundle, args.id);
5097
+ if (!id) return `There is no note ${String(args.id ?? "")}.`;
5098
+ const reading = String(args.reading ?? "").trim();
5099
+ if (!reading) return "Give your reading.";
5100
+ const uid = understandingId(id), was = await this.readNote(uid).catch(() => null);
5101
+ const text = understandingText(id, title(id), {
5102
+ ...parseUnderstanding(was?.text ?? null),
5103
+ reading
5104
+ });
5105
+ const saved = await this.one(() => this.put(uid, text, was?.version ?? null, "edit", {}, t.who));
5106
+ if (saved.entry) await t.item({
5107
+ kind: "change",
5108
+ note: uid,
5109
+ entry: saved.entry,
5110
+ why: was ? "edit" : "create",
5111
+ title: `Understanding · ${title(id)}`,
5112
+ added: 0,
5113
+ removed: 0
5114
+ });
5115
+ return `Your reading of ${title(id)} is written.`;
5116
+ }
5117
+ return `${name} cannot be used yet.`;
5118
+ }
5119
+ /** The notes each turn recorded understanding of: once a note a turn. */
5120
+ recorded = /* @__PURE__ */ new Map();
5121
+ /** A note's Understanding note (M10): Iso's reading, your scratchpad, and what the record says of it. */
5122
+ async understanding(note) {
5123
+ if (!inBase(checkNoteId(note))) throw new EngineError("only a note of the base has an Understanding note");
5124
+ const uid = understandingId(note);
5125
+ const source = this.opts.study ? await this.readNote(uid).catch(() => null) : null;
5126
+ const read = (await this.study()).notes[note];
5127
+ return {
5128
+ note,
5129
+ ...parseUnderstanding(source?.text ?? null),
5130
+ version: source?.version ?? null,
5131
+ level: read?.level ?? 0,
5132
+ squares: read?.squares ?? 0,
5133
+ step: read?.step ?? 0,
5134
+ due: read?.due ?? false,
5135
+ evidence: read?.evidence ?? []
5136
+ };
5137
+ }
5138
+ /** Your scratchpad in a note's Understanding note: yours alone; Iso does not write it. `base` as for saveNote, or
5139
+ * null when there is no Understanding note yet. */
5140
+ async saveScratchpad(note, scratchpad, base) {
5141
+ if (!this.opts.study) throw new EngineError("there is no study base open", 409);
5142
+ if (!inBase(checkNoteId(note))) throw new EngineError("only a note of the base has an Understanding note");
5143
+ const uid = understandingId(note), was = await this.readNote(uid).catch(() => null);
5144
+ const title = this.bundle.concepts.get(note)?.title ?? note;
5145
+ await this.one(() => this.put(uid, understandingText(note, title, {
5146
+ ...parseUnderstanding(was?.text ?? null),
5147
+ scratchpad
5148
+ }), base));
5149
+ return this.understanding(note);
5150
+ }
5151
+ async undoTurn(conversation, turn) {
5152
+ const t = (await this.conversation(conversation))?.turns.find((x) => x.id === turn);
5153
+ if (!t) throw new EngineError("there is no such turn", 404);
5154
+ if (t.state === "running") throw new EngineError("wait for Iso to finish, or stop it, first", 409);
5155
+ if (t.items.some((i) => i.kind === "undone")) throw new EngineError("that turn was undone already", 409);
5156
+ const changes = t.items.filter((i) => i.kind === "change");
5157
+ const notes = [], left = [];
5158
+ const done = /* @__PURE__ */ new Set();
5159
+ for (const ch of [...changes].reverse()) {
5160
+ const key = ch.why === "move" ? ch.to : ch.note;
5161
+ if (done.has(key)) continue;
5162
+ done.add(key);
5163
+ try {
5164
+ if (ch.why === "delete") {
5165
+ const was = replay(await this.journal(ch.note))?.text;
5166
+ if (was === void 0 || this.bundle.concepts.has(ch.note)) {
5167
+ left.push(ch.note);
5168
+ continue;
5169
+ }
5170
+ await this.createNote(ch.note, was);
5171
+ } else if (ch.why === "move") {
5172
+ const from = (await this.journal(ch.to)).find((e) => e.id === ch.entry);
5173
+ const back = from && "from" in from ? from.from : void 0;
5174
+ if (!back || !this.bundle.concepts.has(ch.to)) {
5175
+ left.push(ch.to);
5176
+ continue;
5177
+ }
5178
+ await this.moveNote(ch.to, back, (await this.readNote(ch.to)).version);
5179
+ } else {
5180
+ const entries = await this.journal(ch.note);
5181
+ const mine = changes.filter((x) => x.note === ch.note && x.why !== "move").map((x) => x.entry);
5182
+ const first = entries.findIndex((e) => mine.includes(e.id)), last = entries.findLastIndex((e) => e.kind === "change");
5183
+ if (first < 0 || !mine.includes(entries[last]?.id ?? "")) {
5184
+ left.push(ch.note);
5185
+ continue;
5186
+ }
5187
+ const now = await this.readNote(ch.note);
5188
+ if (hashText(now.text) !== hashText(replay(entries).text)) {
5189
+ left.push(ch.note);
5190
+ continue;
5191
+ }
5192
+ if (first === 0) await this.deleteNote(ch.note, now.version);
5193
+ else await this.restore(ch.note, entries.slice(0, first).findLast((e) => e.kind === "change").id, now.version);
5194
+ }
5195
+ notes.push(key);
5196
+ } catch {
5197
+ left.push(key);
5198
+ }
5199
+ }
5200
+ const item = {
5201
+ kind: "undone",
5202
+ notes,
5203
+ left
5204
+ };
5205
+ await this.one(() => this.opts.study.append(conversationPath(conversation), JSON.stringify({
5206
+ kind: "item",
5207
+ turn,
5208
+ item
5209
+ }) + "\n"));
5210
+ this.publish({
5211
+ type: "iso-item",
5212
+ request: turn,
5213
+ conversation,
5214
+ item
5215
+ });
5216
+ return {
5217
+ notes,
5218
+ left
5219
+ };
5220
+ }
5221
+ async answer(turn, permission, decision) {
5222
+ if (![
5223
+ "once",
5224
+ "always",
5225
+ "reject"
5226
+ ].includes(decision)) throw new EngineError("the answer is once, always or reject");
5227
+ const w = this.waitingAnswer.get(permission);
5228
+ if (!w || w.turn !== turn) throw new EngineError("Iso is not waiting for that answer", 409);
5229
+ this.waitingAnswer.delete(permission);
5230
+ await this.one(() => this.opts.study.append(conversationPath(w.conversation), JSON.stringify({
5231
+ kind: "answer",
5232
+ turn,
5233
+ permission,
5234
+ decision
5235
+ }) + "\n"));
5236
+ this.publish({
5237
+ type: "iso-answer",
5238
+ request: turn,
5239
+ conversation: w.conversation,
5240
+ permission,
5241
+ decision
5242
+ });
5243
+ w.settle(decision);
5244
+ }
4267
5245
  async drafts() {
4268
5246
  const study = this.opts.study;
4269
5247
  if (!study) return [];
@@ -4538,6 +5516,14 @@ var Engine = class Engine {
4538
5516
  event: "wind-back",
4539
5517
  by: -1
4540
5518
  }];
5519
+ if (e.event === "understood") return [{
5520
+ id,
5521
+ at: String(e.at),
5522
+ event: "understood",
5523
+ by: typeof e.by === "number" ? e.by : 0,
5524
+ ...typeof e.why === "string" && e.why ? { why: e.why } : {},
5525
+ ...typeof e.conversation === "string" ? { conversation: e.conversation } : {}
5526
+ }];
4541
5527
  const move = e.moves?.[note];
4542
5528
  return [{
4543
5529
  id,
@@ -4854,54 +5840,116 @@ function providerConfig(path = configPath()) {
4854
5840
  }
4855
5841
  //#endregion
4856
5842
  //#region packages/server/src/model.ts
4857
- const BASE = `https://huggingface.co/Xenova/bge-small-en-v1.5/resolve/ea104dacec62c0de699686887e3f920caeb4f3e3`;
5843
+ const NAME$1 = "granite-embedding-small-english-r2";
5844
+ /** The npm package that carries the files, and the version this isorite expects. */
5845
+ const PACKAGE = "isorite-model-granite@1.0.0";
5846
+ const HUB = `https://huggingface.co/onnx-community/granite-embedding-small-english-r2-ONNX/resolve/1dc7835ba0cb9c76a3618d0bf0c427c97671b3c8`;
5847
+ /** Each file: where it is on Hugging Face, its name here, and its SHA-256. The weights are kept apart from the
5848
+ * graph, under the name the graph gives them. */
4858
5849
  const FILES = [
4859
5850
  [
4860
5851
  "onnx/model_quantized.onnx",
4861
5852
  "model.onnx",
4862
- "6c9c6101a956d62dfb5e7190c538226c0c5bb9cb27b651234b6df063ee7dbfe4"
5853
+ "a3fad524afc3f060216a8ddbb1ac89c9b6498fba8995b5718bde879076a2e9ba"
5854
+ ],
5855
+ [
5856
+ "onnx/model_quantized.onnx_data",
5857
+ "model_quantized.onnx_data",
5858
+ "1f4cf47e4adec7f7ae09db03d071ba8667e07f9a4203142c7efa8d37fe453597"
4863
5859
  ],
4864
5860
  [
4865
5861
  "tokenizer.json",
4866
5862
  "tokenizer.json",
4867
- "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
5863
+ "feeb83348dcb033bc6b9d2e1f7906ca9eb2d122845000c9416d894d7c2927149"
4868
5864
  ],
4869
5865
  [
4870
5866
  "tokenizer_config.json",
4871
5867
  "tokenizer_config.json",
4872
- "9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3"
5868
+ "ce06781b38bb393db68c9e0709bddd31ef5d88f2c6fbb3fd9f369778fb85e451"
4873
5869
  ]
4874
5870
  ];
4875
5871
  const sha = (buf) => createHash("sha256").update(buf).digest("hex");
4876
5872
  /** Where `isorite model` keeps the model: the user's data folder, outside any repository. */
4877
- const userModelDir = (env = process.env) => join(env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "isorite", "models", "bge-small-en-v1.5");
4878
- /** Fetch the model's files into `out`, keeping only files whose SHA-256 is the one expected. */
4879
- async function fetchModel(out, say = console.log) {
5873
+ const userModelDir = (env = process.env) => join(env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "isorite", "models", NAME$1);
5874
+ /** Keep `buf` as `out/to` if it is the file expected. */
5875
+ function keep(out, to, want, buf, say) {
5876
+ if (sha(buf) !== want) throw new Error(`${to}: not the file expected (its SHA-256 differs), so it was not kept`);
5877
+ writeFileSync(join(out, to) + ".tmp", buf);
5878
+ renameSync(join(out, to) + ".tmp", join(out, to));
5879
+ say(`${to}: ${(buf.length / 1e6).toFixed(1)} MB`);
5880
+ }
5881
+ /** The package's files, unpacked into a temporary folder by npm itself (so its registry, proxy and mirror settings
5882
+ * apply). The folder is the caller's to remove. */
5883
+ function unpack(pkg) {
5884
+ const tmp = mkdtempSync(join(tmpdir(), "isorite-model-"));
5885
+ const win = process.platform === "win32";
5886
+ const run = (cmd, args) => spawnSync(cmd, args, {
5887
+ cwd: tmp,
5888
+ encoding: "utf8",
5889
+ shell: win
5890
+ });
5891
+ const packed = run("npm", [
5892
+ "pack",
5893
+ pkg,
5894
+ "--loglevel=error"
5895
+ ]);
5896
+ const tgz = packed.stdout?.trim().split("\n").at(-1);
5897
+ if (packed.status !== 0 || !tgz) {
5898
+ rmSync(tmp, {
5899
+ recursive: true,
5900
+ force: true
5901
+ });
5902
+ throw new Error((packed.stderr?.split("\n").find((l) => /^npm error \d{3} /.test(l)) ?? packed.stderr?.split("\n").find((l) => /error/i.test(l)))?.replace(/^npm error\s*/, "").trim() || packed.error?.message || "npm pack failed");
5903
+ }
5904
+ if (run("tar", ["-xzf", tgz]).status !== 0) {
5905
+ rmSync(tmp, {
5906
+ recursive: true,
5907
+ force: true
5908
+ });
5909
+ throw new Error("could not unpack " + tgz);
5910
+ }
5911
+ return tmp;
5912
+ }
5913
+ /** Fetch the model's files into `out`, keeping only files whose SHA-256 is the one expected: from a folder if one is
5914
+ * named, else from npm, else from Hugging Face. */
5915
+ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, files = FILES } = {}) {
4880
5916
  mkdirSync(out, { recursive: true });
4881
- for (const [from, to, want] of FILES) {
4882
- const path = join(out, to);
4883
- if (existsSync(path) && sha(readFileSync(path)) === want) {
4884
- say(`${to}: already here`);
4885
- continue;
5917
+ const missing = files.filter(([, to, want]) => !(existsSync(join(out, to)) && sha(readFileSync(join(out, to))) === want));
5918
+ for (const [, to] of files) if (!missing.some((f) => f[1] === to)) say(`${to}: already here`);
5919
+ if (missing.length && from) for (const [, to, want] of missing) {
5920
+ if (!existsSync(join(from, to))) throw new Error(`${to} is not in ${from}`);
5921
+ keep(out, to, want, readFileSync(join(from, to)), say);
5922
+ }
5923
+ else if (missing.length && pkg) {
5924
+ let dir = null;
5925
+ try {
5926
+ dir = unpack(pkg);
5927
+ for (const [, to, want] of missing) keep(out, to, want, readFileSync(join(dir, "package", to)), say);
5928
+ missing.length = 0;
5929
+ } catch (e) {
5930
+ say(`From npm (${pkg}): ${e.message}. Trying Hugging Face.`);
5931
+ } finally {
5932
+ if (dir) rmSync(dir, {
5933
+ recursive: true,
5934
+ force: true
5935
+ });
4886
5936
  }
4887
- const res = await fetch(`${BASE}/${from}`);
4888
- if (!res.ok) throw new Error(`${from}: ${res.status} ${res.statusText}`);
4889
- const buf = new Uint8Array(await res.arrayBuffer());
4890
- if (sha(buf) !== want) throw new Error(`${from}: not the file expected (its SHA-256 differs), so it was not kept`);
4891
- writeFileSync(path + ".tmp", buf);
4892
- renameSync(path + ".tmp", path);
4893
- say(`${to}: ${(buf.length / 1e6).toFixed(1)} MB`);
5937
+ }
5938
+ if (missing.length && !from) for (const [hub, to, want] of missing.filter(([, to, want]) => !(existsSync(join(out, to)) && sha(readFileSync(join(out, to))) === want))) {
5939
+ const res = await fetch(`${HUB}/${hub}`);
5940
+ if (!res.ok) throw new Error(`${hub}: ${res.status} ${res.statusText}`);
5941
+ keep(out, to, want, new Uint8Array(await res.arrayBuffer()), say);
4894
5942
  }
4895
5943
  say(`The model is in ${out}`);
4896
5944
  }
4897
5945
  //#endregion
4898
5946
  //#region packages/server/src/embedder.ts
4899
- const MODEL = "bge-small-en-v1.5-q8";
4900
- /** Where the model's files are (npm run model in a checkout, isorite model when installed), or null when they are
4901
- * not there. */
5947
+ const MODEL = `${NAME$1}-q8`;
5948
+ /** Where the model's files are (npm run model in a checkout, into packages/model; isorite model when installed), or
5949
+ * null when they are not there. */
4902
5950
  function modelDir(env = process.env) {
4903
5951
  if (env.ISORITE_EMBED === "off") return null;
4904
- return (env.ISORITE_EMBED_DIR ? [env.ISORITE_EMBED_DIR] : [join(dirname(fileURLToPath(import.meta.url)), "..", "models", "bge-small-en-v1.5"), userModelDir(env)]).find((dir) => existsSync(join(dir, "model.onnx")) && existsSync(join(dir, "tokenizer.json"))) ?? null;
5952
+ return (env.ISORITE_EMBED_DIR ? [env.ISORITE_EMBED_DIR] : [join(dirname(fileURLToPath(import.meta.url)), "..", "..", "model"), userModelDir(env)]).find((dir) => existsSync(join(dir, "model.onnx")) && existsSync(join(dir, "tokenizer.json"))) ?? null;
4905
5953
  }
4906
5954
  /** The folder caches are kept in: outside both repositories, rebuilt from the notes whenever it is lost. */
4907
5955
  const cacheDir = (env = process.env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "isorite");
@@ -4952,6 +6000,341 @@ function nodeEmbedder(dir) {
4952
6000
  };
4953
6001
  }
4954
6002
  //#endregion
6003
+ //#region packages/server/src/terminal.ts
6004
+ const OUTPUT = 4e3;
6005
+ const LOOKUPS = /* @__PURE__ */ new Set([
6006
+ "search_notes",
6007
+ "find_similar",
6008
+ "outline_note",
6009
+ "read_note",
6010
+ "brief_base"
6011
+ ]);
6012
+ /** isorite's tools that change a note: their line opens it, where the agent's words are marked. */
6013
+ const CHANGES = /* @__PURE__ */ new Set(["edit_note", "write_note"]);
6014
+ /** What a tool call acted on, in a line. */
6015
+ function titleOf(name, input = {}) {
6016
+ const s = (k) => typeof input[k] === "string" ? input[k] : "";
6017
+ if (name === "shell" || name === "bash") return s("command");
6018
+ if (LOOKUPS.has(name)) return s("query") || s("text") || s("id") + (s("section") ? ` § ${s("section")}` : "");
6019
+ if (CHANGES.has(name)) return s("note");
6020
+ return s("path") || s("filePath") || s("pattern") || s("url") || s("description") || "";
6021
+ }
6022
+ /** An edit's lines as a diff: what it took out, then what it put in. A write is all put in. */
6023
+ function diffOf(name, input) {
6024
+ const lines = (v) => typeof v === "string" ? v.replace(/\n$/, "").split("\n") : [];
6025
+ if ((name === "edit" || name === "edit_note") && typeof input.oldString === "string") return [...lines(input.oldString).map((l) => "-" + l), ...lines(input.newString).map((l) => "+" + l)].slice(0, 200);
6026
+ if ((name === "write" || name === "write_note") && typeof input.content === "string") return lines(input.content).map((l) => "+" + l).slice(0, 200);
6027
+ }
6028
+ /** The session's messages (oldest first) as the pane's turns. Text still arriving (`live`, by assistant message) is
6029
+ * added where the listed message does not have it yet; `ours` are the prompts asked from a page. */
6030
+ function toTurns(messages, { live = /* @__PURE__ */ new Map(), ours = /* @__PURE__ */ new Set(), busy = false } = {}) {
6031
+ const turns = [];
6032
+ for (const m of messages) {
6033
+ if (m.type === "user" && "text" in m) {
6034
+ turns.push({
6035
+ id: m.id,
6036
+ asked: m.text,
6037
+ from: ours.has(m.id) || ours.has(m.text) ? "here" : "terminal",
6038
+ items: [],
6039
+ state: "running"
6040
+ });
6041
+ continue;
6042
+ }
6043
+ const turn = turns.at(-1);
6044
+ if (!turn) continue;
6045
+ if (m.type === "idle" && "outcome" in m) {
6046
+ turn.state = m.outcome === "succeeded" ? "done" : /interrupt|cancel|abort/.test(m.outcome) ? "stopped" : "failed";
6047
+ continue;
6048
+ }
6049
+ if (m.type !== "assistant" || !("content" in m)) continue;
6050
+ let texts = 0;
6051
+ for (const c of m.content) if (c.type === "text" && "text" in c) {
6052
+ texts++;
6053
+ if (c.text) turn.items.push({
6054
+ kind: "text",
6055
+ text: c.text
6056
+ });
6057
+ } else if (c.type === "tool" && "state" in c) {
6058
+ const input = c.state.input ?? {};
6059
+ const output = (c.state.content ?? []).map((x) => x.text ?? "").join("").slice(0, OUTPUT);
6060
+ const status = c.state.status === "completed" ? "done" : /error|fail/.test(c.state.status) ? "failed" : "running";
6061
+ const item = {
6062
+ kind: "tool",
6063
+ id: c.id,
6064
+ name: c.name,
6065
+ title: titleOf(c.name, input),
6066
+ status,
6067
+ output
6068
+ };
6069
+ const named = LOOKUPS.has(c.name) ? input.id : CHANGES.has(c.name) ? input.note : void 0;
6070
+ if (typeof named === "string" && named) item.note = named.replace(/^\//, "").replace(/\.md$/, "");
6071
+ const diff = diffOf(c.name, input);
6072
+ if (diff) item.diff = diff;
6073
+ turn.items.push(item);
6074
+ }
6075
+ const streaming = live.get(m.id);
6076
+ if (streaming && !texts) turn.items.push({
6077
+ kind: "text",
6078
+ text: streaming
6079
+ });
6080
+ }
6081
+ const listed = new Set(messages.map((m) => m.id));
6082
+ const last = turns.at(-1);
6083
+ if (last && last.state === "running") {
6084
+ for (const [id, text] of live) if (!listed.has(id) && text) last.items.push({
6085
+ kind: "text",
6086
+ text
6087
+ });
6088
+ }
6089
+ if (!busy) {
6090
+ for (const t of turns) if (t.state === "running") t.state = "done";
6091
+ }
6092
+ return turns;
6093
+ }
6094
+ /** What is asked from a page, with what was given in the Iso pane's context: notes and folders by name, for the
6095
+ * agent to read with isorite's tools, and passages as they are. */
6096
+ function given(asked, parts, title) {
6097
+ const notes = parts.filter((p) => p.kind === "note" || p.kind === "understanding").map((p) => `- ${title(p.id)} (${p.id})`);
6098
+ const folders = parts.filter((p) => p.kind === "folder").map((p) => `- the folder ${p.id}`);
6099
+ const passages = parts.flatMap((p) => p.kind === "passage" ? [`From ${title(p.id)} (${p.id}):\n${p.text.split("\n").map((l) => "> " + l).join("\n")}`] : []);
6100
+ const out = [asked.trim()];
6101
+ if (notes.length || folders.length) out.push(`From my knowledge base (read them with read_note, or outline_note first):\n${[...notes, ...folders].join("\n")}`);
6102
+ if (passages.length) out.push(passages.join("\n\n"));
6103
+ return out.join("\n\n");
6104
+ }
6105
+ const EMPTY = {
6106
+ connected: false,
6107
+ session: null,
6108
+ title: "",
6109
+ busy: false,
6110
+ turns: [],
6111
+ asking: [],
6112
+ cost: 0,
6113
+ tokens: {
6114
+ input: 0,
6115
+ output: 0
6116
+ },
6117
+ model: ""
6118
+ };
6119
+ /** Events after which the session is read again; text arrives as deltas in between. */
6120
+ const REREAD = /^session\.(step\.ended|tool\.|inbox\.delivered|execution\.|created|renamed)|^permission\./;
6121
+ /** The terminal's session, followed. One terminal at a time: a second `isorite term` on the same server takes over. */
6122
+ var Terminal = class {
6123
+ #view = EMPTY;
6124
+ #client = null;
6125
+ #stop = null;
6126
+ #listeners = /* @__PURE__ */ new Set();
6127
+ #live = /* @__PURE__ */ new Map();
6128
+ #ours = /* @__PURE__ */ new Set();
6129
+ #asking = /* @__PURE__ */ new Map();
6130
+ #timer = null;
6131
+ #directory = "";
6132
+ view() {
6133
+ return this.#view;
6134
+ }
6135
+ listen(fn) {
6136
+ this.#listeners.add(fn);
6137
+ return () => this.#listeners.delete(fn);
6138
+ }
6139
+ #tell() {
6140
+ for (const fn of this.#listeners) fn(this.#view);
6141
+ }
6142
+ async attach({ url, password, directory }) {
6143
+ this.detach();
6144
+ const client = OpenCode.make({
6145
+ baseUrl: url,
6146
+ headers: { authorization: "Basic " + Buffer.from(`opencode:${password}`).toString("base64") }
6147
+ });
6148
+ await client.server.info();
6149
+ this.#client = client;
6150
+ this.#directory = directory;
6151
+ this.#view = {
6152
+ ...EMPTY,
6153
+ connected: true
6154
+ };
6155
+ const stop = this.#stop = new AbortController();
6156
+ this.#follow(client, stop.signal);
6157
+ await this.#choose();
6158
+ await this.#read();
6159
+ }
6160
+ detach() {
6161
+ this.#stop?.abort();
6162
+ this.#stop = null;
6163
+ this.#client = null;
6164
+ this.#live.clear();
6165
+ this.#asking.clear();
6166
+ if (this.#timer) clearTimeout(this.#timer);
6167
+ if (this.#view.connected) {
6168
+ this.#view = EMPTY;
6169
+ this.#tell();
6170
+ }
6171
+ }
6172
+ /** Ask in the session the terminal shows, from a page. While it works, the prompt steers the turn under way. */
6173
+ async prompt(text) {
6174
+ const client = this.#need();
6175
+ if (!this.#view.session) await this.#choose(true);
6176
+ this.#ours.add(text);
6177
+ const sent = await client.session.prompt({
6178
+ sessionID: this.#view.session,
6179
+ text
6180
+ });
6181
+ if (sent?.id) this.#ours.add(sent.id);
6182
+ this.#soon();
6183
+ }
6184
+ async interrupt() {
6185
+ const client = this.#need();
6186
+ if (this.#view.session) await client.session.interrupt({ sessionID: this.#view.session });
6187
+ this.#soon();
6188
+ }
6189
+ /** Answer what the agent asked; only what this session is asking can be answered. */
6190
+ async reply(id, decision) {
6191
+ const client = this.#need();
6192
+ if (!this.#asking.has(id) || !this.#view.session) throw new Error("the terminal is not asking that");
6193
+ await client.permission.reply({
6194
+ sessionID: this.#view.session,
6195
+ requestID: id,
6196
+ decision
6197
+ });
6198
+ this.#asking.delete(id);
6199
+ this.#soon();
6200
+ }
6201
+ #need() {
6202
+ if (!this.#client) throw new Error("no terminal is attached: start one with isorite term");
6203
+ return this.#client;
6204
+ }
6205
+ /** The session to follow: the one the terminal last showed, else the latest. With `make`, one is made when none is. */
6206
+ async #choose(make = false) {
6207
+ const client = this.#client;
6208
+ if (!client) return;
6209
+ const sessions = [...(await client.session.list().catch(() => null))?.data ?? []].sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0));
6210
+ if (sessions[0]) {
6211
+ this.#show(sessions[0].id);
6212
+ return;
6213
+ }
6214
+ if (make) {
6215
+ const made = await client.session.create({ location: { directory: this.#directory } });
6216
+ this.#show(made.id);
6217
+ }
6218
+ }
6219
+ #show(session) {
6220
+ if (this.#view.session === session) return;
6221
+ this.#live.clear();
6222
+ this.#asking.clear();
6223
+ this.#view = {
6224
+ ...this.#view,
6225
+ session,
6226
+ turns: [],
6227
+ asking: []
6228
+ };
6229
+ this.#pending();
6230
+ }
6231
+ async #pending() {
6232
+ const client = this.#client, session = this.#view.session;
6233
+ if (!client || !session) return;
6234
+ const asked = await client.permission.list({ sessionID: session }).catch(() => []);
6235
+ for (const p of asked ?? []) this.#asking.set(p.id, {
6236
+ id: p.id,
6237
+ action: p.action,
6238
+ resources: p.resources ?? [],
6239
+ ...p.source?.id ? { tool: p.source.id } : {}
6240
+ });
6241
+ this.#soon();
6242
+ }
6243
+ async #follow(client, signal) {
6244
+ try {
6245
+ for await (const raw of client.event.subscribe()) {
6246
+ if (signal.aborted) return;
6247
+ const e = raw;
6248
+ const d = e.data ?? {};
6249
+ const session = typeof d.sessionID === "string" ? d.sessionID : null;
6250
+ if ((e.type === "session.viewed" || e.type === "session.created") && session) this.#show(session);
6251
+ if (session && session !== this.#view.session) continue;
6252
+ if (e.type === "session.execution.started") this.#view = {
6253
+ ...this.#view,
6254
+ busy: true
6255
+ };
6256
+ if (/^session\.execution\.(succeeded|failed|interrupted|cancelled|stopped)/.test(e.type)) {
6257
+ this.#view = {
6258
+ ...this.#view,
6259
+ busy: false
6260
+ };
6261
+ this.#live.clear();
6262
+ }
6263
+ if (e.type === "session.text.delta" && typeof d.assistantMessageID === "string") {
6264
+ this.#live.set(d.assistantMessageID, (this.#live.get(d.assistantMessageID) ?? "") + String(d.delta ?? ""));
6265
+ this.#view = {
6266
+ ...this.#view,
6267
+ turns: this.#turns(this.#messages)
6268
+ };
6269
+ this.#tell();
6270
+ continue;
6271
+ }
6272
+ if (e.type === "permission.asked" && typeof d.id === "string") {
6273
+ const source = d.source;
6274
+ this.#asking.set(d.id, {
6275
+ id: d.id,
6276
+ action: String(d.action ?? ""),
6277
+ resources: d.resources ?? [],
6278
+ ...source?.id ? { tool: source.id } : {}
6279
+ });
6280
+ }
6281
+ if (e.type === "permission.replied" && typeof d.requestID === "string") this.#asking.delete(d.requestID);
6282
+ if (e.type === "session.usage.updated") this.#view = {
6283
+ ...this.#view,
6284
+ cost: Number(d.cost ?? 0),
6285
+ tokens: {
6286
+ input: Number(d.tokens?.input ?? 0),
6287
+ output: Number(d.tokens?.output ?? 0)
6288
+ }
6289
+ };
6290
+ if (REREAD.test(e.type) || e.type === "session.usage.updated") this.#soon();
6291
+ }
6292
+ } catch {
6293
+ if (!signal.aborted && this.#client === client) this.detach();
6294
+ }
6295
+ }
6296
+ #messages = [];
6297
+ #turns(messages) {
6298
+ return toTurns(messages, {
6299
+ live: this.#live,
6300
+ ours: this.#ours,
6301
+ busy: this.#view.busy
6302
+ });
6303
+ }
6304
+ /** Read the session again shortly, once for a burst of events. */
6305
+ #soon() {
6306
+ if (this.#timer) return;
6307
+ this.#timer = setTimeout(() => {
6308
+ this.#timer = null;
6309
+ this.#read();
6310
+ }, 80);
6311
+ }
6312
+ async #read() {
6313
+ const client = this.#client, session = this.#view.session;
6314
+ if (!client) return;
6315
+ if (!session) {
6316
+ this.#tell();
6317
+ return;
6318
+ }
6319
+ const [listed, info] = await Promise.all([client.message.list({
6320
+ sessionID: session,
6321
+ limit: 200,
6322
+ order: "desc"
6323
+ }).catch(() => null), client.session.get({ sessionID: session }).catch(() => null)]);
6324
+ if (this.#client !== client || this.#view.session !== session) return;
6325
+ this.#messages = [...listed?.data ?? []].reverse();
6326
+ const model = [...this.#messages].reverse().find((m) => m.type === "assistant" && "content" in m)?.model?.id ?? info?.model?.id ?? this.#view.model;
6327
+ this.#view = {
6328
+ ...this.#view,
6329
+ title: info?.title ?? this.#view.title,
6330
+ model,
6331
+ turns: this.#turns(this.#messages),
6332
+ asking: [...this.#asking.values()]
6333
+ };
6334
+ this.#tell();
6335
+ }
6336
+ };
6337
+ //#endregion
4955
6338
  //#region packages/server/src/http.ts
4956
6339
  const LOOPBACK = /* @__PURE__ */ new Set([
4957
6340
  "localhost",
@@ -4964,6 +6347,7 @@ const TYPES = {
4964
6347
  ".js": "text/javascript; charset=utf-8",
4965
6348
  ".css": "text/css; charset=utf-8",
4966
6349
  ".json": "application/json",
6350
+ ".webmanifest": "application/manifest+json",
4967
6351
  ".svg": "image/svg+xml",
4968
6352
  ".png": "image/png",
4969
6353
  ".woff2": "font/woff2",
@@ -4986,6 +6370,11 @@ const Tier = z.enum([
4986
6370
  "mid",
4987
6371
  "max"
4988
6372
  ]);
6373
+ /** What you chose of Iso's context (I3): parts switched off by id, and whether it may look up more. */
6374
+ const ChoiceSchema = z.object({
6375
+ off: z.array(z.string().max(400)).max(500).optional(),
6376
+ lookup: z.boolean().optional()
6377
+ }).strict();
4989
6378
  const Ask = z.discriminatedUnion("form", [
4990
6379
  z.object({
4991
6380
  note: z.string(),
@@ -4993,7 +6382,8 @@ const Ask = z.discriminatedUnion("form", [
4993
6382
  after: Anchor.nullable(),
4994
6383
  before: Anchor.nullable(),
4995
6384
  asked: z.string(),
4996
- tier: Tier
6385
+ tier: Tier,
6386
+ choice: ChoiceSchema.optional()
4997
6387
  }).strict(),
4998
6388
  z.object({
4999
6389
  note: z.string(),
@@ -5002,7 +6392,8 @@ const Ask = z.discriminatedUnion("form", [
5002
6392
  after: Anchor.nullable(),
5003
6393
  before: Anchor.nullable(),
5004
6394
  asked: z.string(),
5005
- tier: Tier
6395
+ tier: Tier,
6396
+ choice: ChoiceSchema.optional()
5006
6397
  }).strict(),
5007
6398
  z.object({
5008
6399
  note: z.string(),
@@ -5021,6 +6412,41 @@ const Ask = z.discriminatedUnion("form", [
5021
6412
  tier: Tier
5022
6413
  }).strict()
5023
6414
  ]);
6415
+ const Given = z.discriminatedUnion("kind", [
6416
+ z.object({
6417
+ kind: z.literal("note"),
6418
+ id: z.string().max(400)
6419
+ }).strict(),
6420
+ z.object({
6421
+ kind: z.literal("folder"),
6422
+ id: z.string().max(400)
6423
+ }).strict(),
6424
+ z.object({
6425
+ kind: z.literal("passage"),
6426
+ id: z.string().max(400),
6427
+ text: z.string().max(2e4)
6428
+ }).strict(),
6429
+ z.object({
6430
+ kind: z.literal("understanding"),
6431
+ id: z.string().max(400)
6432
+ }).strict()
6433
+ ]);
6434
+ const Mode = z.enum([
6435
+ "ask",
6436
+ "write",
6437
+ "build",
6438
+ "understand"
6439
+ ]);
6440
+ const TurnAsk = z.object({
6441
+ conversation: z.string().max(64).optional(),
6442
+ mode: Mode,
6443
+ asked: z.string().max(8e3),
6444
+ given: z.array(Given).max(40),
6445
+ tier: Tier,
6446
+ lookup: z.boolean().optional(),
6447
+ off: z.array(z.string().max(64)).max(500).optional()
6448
+ }).strict();
6449
+ const TurnShape = TurnAsk.omit({ asked: true });
5024
6450
  const ExerciseAsk = z.object({
5025
6451
  folder: z.string().max(400),
5026
6452
  tests: z.array(z.string().max(400)).min(1).max(20),
@@ -5048,7 +6474,7 @@ const AgentEdit = z.union([z.object({
5048
6474
  newString: z.string(),
5049
6475
  replaceAll: z.boolean().optional()
5050
6476
  }).strict(), z.object({ content: z.string() }).strict()]);
5051
- const AgentProposal = z.object({
6477
+ const AgentChange = z.object({
5052
6478
  agent: AgentName,
5053
6479
  session: z.string().min(1).max(200),
5054
6480
  note: z.string().max(400),
@@ -5060,7 +6486,7 @@ const AgentStatus = z.object({
5060
6486
  session: z.string().min(1).max(200),
5061
6487
  busy: z.boolean()
5062
6488
  }).strict();
5063
- function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
6489
+ function createApp({ api, token, loopback, web, allowHosts = [], notes, terminal }) {
5064
6490
  const app = new Hono();
5065
6491
  const allowed = new Set([...allowHosts].map((h) => h.toLowerCase()));
5066
6492
  const reply = (c, status, value) => c.body(JSON.stringify(value), status, {
@@ -5144,6 +6570,36 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5144
6570
  return sent instanceof Response ? sent : answer(c, async () => await act(c, sent) ?? {});
5145
6571
  });
5146
6572
  post("/api/iso/ask", Ask, (_c, b) => api.ask(b));
6573
+ post("/api/iso/converse", TurnAsk, (_c, b) => api.converse(b));
6574
+ app.get("/api/understanding", (c) => answer(c, () => api.understanding(id(c))));
6575
+ post("/api/understanding/scratchpad", z.object({
6576
+ scratchpad: z.string().max(2e5),
6577
+ base: z.string().max(64).nullable()
6578
+ }).strict(), (c, b) => api.saveScratchpad(id(c), b.scratchpad, b.base));
6579
+ post("/api/iso/undo", z.object({
6580
+ conversation: z.string().max(64),
6581
+ turn: z.string().max(64)
6582
+ }).strict(), (_c, b) => api.undoTurn(b.conversation, b.turn));
6583
+ post("/api/iso/answer", z.object({
6584
+ turn: z.string().max(64),
6585
+ permission: z.string().max(64),
6586
+ decision: z.enum([
6587
+ "once",
6588
+ "always",
6589
+ "reject"
6590
+ ])
6591
+ }).strict(), (_c, b) => api.answer(b.turn, b.permission, b.decision));
6592
+ app.get("/api/conversation", (c) => answer(c, async () => await api.conversation(c.req.query("id") || void 0) ?? {
6593
+ id: "",
6594
+ turns: []
6595
+ }));
6596
+ app.get("/api/turn/context", (c) => answer(c, () => {
6597
+ let raw = {};
6598
+ try {
6599
+ raw = JSON.parse(c.req.query("turn") ?? "{}");
6600
+ } catch {}
6601
+ return api.turnContext(TurnShape.parse(raw));
6602
+ }));
5147
6603
  post("/api/iso/stop", z.object({ request: z.string().max(64) }).strict(), (_c, b) => api.stop(b.request));
5148
6604
  post("/api/note/restore", z.object({
5149
6605
  entry: z.string().max(64),
@@ -5168,7 +6624,7 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5168
6624
  post("/api/exercise/draft", ExerciseAsk, (_c, b) => api.createExerciseDraft(b.folder, b.tests, b.after ?? null));
5169
6625
  post("/api/exercise/keep", z.object({ proposal: z.string().max(64) }).strict(), (c, b) => api.keepExercise(id(c), b.proposal));
5170
6626
  post("/api/exercise/wind-back", z.object({}).strict(), (c) => api.windBack(id(c)));
5171
- app.get("/api/context", (c) => answer(c, () => api.context(c.req.query("id") || void 0)));
6627
+ app.get("/api/context", (c) => answer(c, () => api.context(c.req.query("id") || void 0, choiceOf(c.req.query("choice")))));
5172
6628
  post("/api/context/clear", z.object({}).strict(), () => api.clearConversation());
5173
6629
  app.get("/api/settings", (c) => answer(c, () => api.settings()));
5174
6630
  post("/api/settings/key", Key, (_c, b) => api.setKey(b.key), "put");
@@ -5180,7 +6636,7 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5180
6636
  app.get("/api/agent/tools", (c) => answer(c, () => api.agentTools()));
5181
6637
  post("/api/agent/look", AgentLook, (_c, b) => api.agentLook(b));
5182
6638
  app.get("/api/agent/base", (c) => answer(c, async () => ({ notes: notes ?? null })));
5183
- post("/api/agent/propose", AgentProposal, (_c, b) => api.agentPropose(b));
6639
+ post("/api/agent/write", AgentChange, (_c, b) => api.agentWrite(b));
5184
6640
  post("/api/agent/send", z.object({
5185
6641
  note: z.string().max(400),
5186
6642
  passage: z.string().max(1e4).optional(),
@@ -5191,6 +6647,53 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5191
6647
  agent: AgentName
5192
6648
  }).strict(), (_c, b) => api.agentReceived(b.id, b.agent));
5193
6649
  post("/api/agent/status", AgentStatus, (_c, b) => api.agentStatus(b.agent, b.session, b.busy));
6650
+ if (terminal) {
6651
+ const Local = z.string().max(200).refine((u) => {
6652
+ try {
6653
+ const h = new URL(u).hostname;
6654
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
6655
+ } catch {
6656
+ return false;
6657
+ }
6658
+ }, "OpenCode's server must be on this machine");
6659
+ post("/api/terminal/attach", z.object({
6660
+ url: Local,
6661
+ password: z.string().min(1).max(200),
6662
+ directory: z.string().max(4e3)
6663
+ }).strict(), async (_c, b) => {
6664
+ await terminal.attach(b).catch((e) => {
6665
+ throw new EngineError(`OpenCode's server did not answer: ${e.message}`, 502);
6666
+ });
6667
+ });
6668
+ post("/api/terminal/detach", z.object({}).strict(), async () => {
6669
+ terminal.detach();
6670
+ });
6671
+ app.get("/api/terminal", (c) => answer(c, async () => terminal.view()));
6672
+ const failed = (e) => {
6673
+ throw new EngineError(e.message, 409);
6674
+ };
6675
+ post("/api/terminal/prompt", z.object({
6676
+ asked: z.string().min(1).max(8e3),
6677
+ given: z.array(Given).max(40)
6678
+ }).strict(), async (_c, b) => {
6679
+ const titles = new Map((await api.notes()).notes.map((n) => [n.id, n.title]));
6680
+ const text = given(b.asked, b.given, (id) => titles.get(id) ?? id);
6681
+ await terminal.prompt(text).catch(failed);
6682
+ });
6683
+ post("/api/terminal/stop", z.object({}).strict(), async () => {
6684
+ await terminal.interrupt().catch(failed);
6685
+ });
6686
+ post("/api/terminal/permission", z.object({
6687
+ id: z.string().max(200),
6688
+ decision: z.enum([
6689
+ "once",
6690
+ "always",
6691
+ "reject"
6692
+ ])
6693
+ }).strict(), async (_c, b) => {
6694
+ await terminal.reply(b.id, b.decision).catch(failed);
6695
+ });
6696
+ }
5194
6697
  const streams = /* @__PURE__ */ new Map();
5195
6698
  const watchers = () => {
5196
6699
  const browsers = [...streams.values()].filter(Boolean).length;
@@ -5209,6 +6712,16 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5209
6712
  data: JSON.stringify(event)
5210
6713
  });
5211
6714
  });
6715
+ const send = (view) => {
6716
+ stream.writeSSE({
6717
+ event: "terminal",
6718
+ data: JSON.stringify({
6719
+ type: "terminal",
6720
+ terminal: view
6721
+ })
6722
+ }).catch(() => void 0);
6723
+ };
6724
+ const unfollow = terminal?.listen(send);
5212
6725
  const beat = setInterval(() => {
5213
6726
  stream.write(": still here\n\n");
5214
6727
  }, 25e3);
@@ -5216,6 +6729,7 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5216
6729
  event: "open",
5217
6730
  data: "{}"
5218
6731
  });
6732
+ if (terminal?.view().connected) send(terminal.view());
5219
6733
  streams.set(stream, c.req.query("agent") === void 0);
5220
6734
  watchers();
5221
6735
  await new Promise((done) => stream.onAbort(done));
@@ -5223,6 +6737,7 @@ function createApp({ api, token, loopback, web, allowHosts = [], notes }) {
5223
6737
  watchers();
5224
6738
  clearInterval(beat);
5225
6739
  stop();
6740
+ unfollow?.();
5226
6741
  }));
5227
6742
  app.all("/api/*", (c) => refuse(c, 404, "not found"));
5228
6743
  app.on(["GET", "HEAD"], "*", (c) => !hostOk(c) ? c.text(NOT_HERE, 403) : web ? staticFile(c, web) : c.text("Not found", 404));
@@ -5247,6 +6762,19 @@ function staticFile(c, web) {
5247
6762
  "X-Content-Type-Options": "nosniff"
5248
6763
  });
5249
6764
  }
6765
+ /** The choice of what Iso is sent, as the context route takes it: JSON, and only its known fields kept. */
6766
+ function choiceOf(raw) {
6767
+ if (!raw) return void 0;
6768
+ try {
6769
+ const c = JSON.parse(raw);
6770
+ return {
6771
+ ...Array.isArray(c.off) ? { off: c.off.filter((x) => typeof x === "string").slice(0, 500) } : {},
6772
+ ...typeof c.lookup === "boolean" ? { lookup: c.lookup } : {}
6773
+ };
6774
+ } catch {
6775
+ return;
6776
+ }
6777
+ }
5250
6778
  //#endregion
5251
6779
  //#region packages/server/src/nodefiles.ts
5252
6780
  const gone = (e) => ["ENOENT", "ENOTDIR"].includes(e.code ?? "");
@@ -5420,13 +6948,15 @@ async function serve$1(path, { host = "127.0.0.1", port = 4767, allowHosts = []
5420
6948
  const opened = locate(path);
5421
6949
  const api = await openEngine(opened);
5422
6950
  const web = existsSync(join(WEB_DIR, "index.html")) ? WEB_DIR : null;
6951
+ const terminal = new Terminal();
5423
6952
  const app = createApp({
5424
6953
  api,
5425
6954
  token: randomBytes(24).toString("base64url"),
5426
6955
  loopback: LOOPBACK.has(host),
5427
6956
  web,
5428
6957
  allowHosts,
5429
- notes: opened.notes
6958
+ notes: opened.notes,
6959
+ terminal
5430
6960
  });
5431
6961
  const server = serve({
5432
6962
  fetch: app.fetch,
@@ -5462,100 +6992,333 @@ function scriptedHttp(answer, pace = 0) {
5462
6992
  stop(Object.assign(/* @__PURE__ */ new Error("aborted"), { name: "AbortError" }));
5463
6993
  return;
5464
6994
  }
5465
- const t = setTimeout(done, ms);
5466
- signal?.addEventListener("abort", () => {
5467
- clearTimeout(t);
5468
- stop(Object.assign(/* @__PURE__ */ new Error("aborted"), { name: "AbortError" }));
5469
- });
5470
- });
5471
- const body = new ReadableStream({ async start(c) {
5472
- const enc = new TextEncoder(), send = (o) => c.enqueue(enc.encode(data(o)));
5473
- try {
5474
- const text = reply.text ?? "";
5475
- for (const piece of pace ? text.match(/[\s\S]{1,14}/g) ?? [] : text ? [text] : []) {
5476
- send({
5477
- model: request.model,
5478
- choices: [{ delta: { content: piece } }]
5479
- });
5480
- if (pace) await wait(pace);
6995
+ const t = setTimeout(done, ms);
6996
+ signal?.addEventListener("abort", () => {
6997
+ clearTimeout(t);
6998
+ stop(Object.assign(/* @__PURE__ */ new Error("aborted"), { name: "AbortError" }));
6999
+ });
7000
+ });
7001
+ const body = new ReadableStream({ async start(c) {
7002
+ const enc = new TextEncoder(), send = (o) => c.enqueue(enc.encode(data(o)));
7003
+ try {
7004
+ const text = reply.text ?? "";
7005
+ for (const piece of pace ? text.match(/[\s\S]{1,14}/g) ?? [] : text ? [text] : []) {
7006
+ send({
7007
+ model: request.model,
7008
+ choices: [{ delta: { content: piece } }]
7009
+ });
7010
+ if (pace) await wait(pace);
7011
+ }
7012
+ for (const [index, call] of (reply.calls ?? []).entries()) send({ choices: [{ delta: { tool_calls: [{
7013
+ index,
7014
+ id: `call_${index}_${Math.random().toString(36).slice(2, 8)}`,
7015
+ function: {
7016
+ name: call.name,
7017
+ arguments: JSON.stringify(call.arguments)
7018
+ }
7019
+ }] } }] });
7020
+ if (pace && reply.calls) await wait(pace * 20);
7021
+ const u = reply.usage ?? {
7022
+ in: 3e3,
7023
+ out: 200,
7024
+ usd: .008
7025
+ };
7026
+ send({ choices: [{
7027
+ delta: {},
7028
+ finish_reason: reply.calls ? "tool_calls" : "stop"
7029
+ }] });
7030
+ send({
7031
+ choices: [],
7032
+ usage: {
7033
+ prompt_tokens: u.in,
7034
+ completion_tokens: u.out,
7035
+ cost: u.usd,
7036
+ prompt_tokens_details: { cached_tokens: u.cached ?? 0 }
7037
+ }
7038
+ });
7039
+ c.enqueue(enc.encode("data: [DONE]\n\n"));
7040
+ c.close();
7041
+ } catch (e) {
7042
+ c.error(e);
7043
+ }
7044
+ } });
7045
+ return new Response(body, {
7046
+ status: 200,
7047
+ headers: { "content-type": "text/event-stream" }
7048
+ });
7049
+ });
7050
+ return { fetch };
7051
+ }
7052
+ //#endregion
7053
+ //#region packages/example/src/tutorial.ts
7054
+ const UNSCRIPTED$1 = "In this tutorial I answer only what it walks you through. With a provider key in your own isorite, I would answer this too.";
7055
+ const text = (r) => r.messages.filter((m) => m.role === "user").map((m) => String(m.content)).join("\n\n");
7056
+ const looked = (r) => r.messages.some((m) => m.role === "tool");
7057
+ const asked = (r) => (text(r).split(/# What they ask(?: now)?[^\n]*\n\n/).at(-1) ?? "").trim().toLowerCase();
7058
+ const INSERT = `To insert a body, start at the root and walk down: at each node, go into the one of its four squares that holds the body's position.
7059
+
7060
+ - **An empty leaf** takes the body, and is done.
7061
+ - **A leaf with a body already** is split: both bodies go down into its new children, which may split again if they land in the same square.
7062
+ - **An inner node** passes the body down to the child whose square holds it.
7063
+
7064
+ On the way down, every node passed adds the body to its total mass $M$ and to $M\\mathbf{R}$, so that each keeps its mass and centre of mass with no second pass. A node keeps its square (centre and side $s$), its mass, its centre of mass, and its four children or its one body.`;
7065
+ const THETA = `With $\\theta = 0.5$ the force error is about a percent for most bodies, and several percent for a few near dense nodes; it falls quickly as $\\theta$ shrinks. The error of a node used whole is of order $(s/d)^2$, the [centre of mass](/algorithms/centre-of-mass.md "uses")'s quadrupole, and $s/d < \\theta$ bounds it.
7066
+
7067
+ The cost: a body opens nodes only near it. Around it, each level of the tree adds a ring of about the same number of nodes used whole, and there are $\\log_4 N$ levels, so each body visits $O(\\log N)$ nodes, and all of them $O(N \\log N)$.`;
7068
+ const SPEED = `About ten times fewer interactions. Direct summation computes $N(N-1)/2 \\approx 1.25 \\times 10^7$ pairs at $N = 5000$. Barnes–Hut at $\\theta = 0.5$ uses some 200 nodes for each body, so about $10^6$ interactions in all.
7069
+
7070
+ Each of them costs more: the tree walk branches, and in plain Python it is slow enough to lose the whole factor. Measure it, as your note on [cost and accuracy](/algorithms/cost-and-accuracy.md) says, before counting on it.`;
7071
+ const OCTREE = `An octree is the same tree with eight children instead of four: each cube is split in half along each of the three axes.
7072
+
7073
+ What changes:
7074
+ - **Insertion** picks one of eight children, by the sign of the body's position against the cube's centre on each axis: three comparisons, one bit each.
7075
+ - **The opening rule** is the same, $s/d < \\theta$, with $s$ the cube's side.
7076
+ - **The cost** is still $O(N \\log N)$; the log is now base 8, but each opened node has twice as many children, so the work per body is about the same.
7077
+
7078
+ Nothing in the [main loop](/simulation/the-main-loop.md) changes: only \`accelerations\`.`;
7079
+ const OCTREE_NOTE = `---
7080
+ type: Concept
7081
+ title: The octree
7082
+ description: The quadtree in 3D: each cube split into eight.
7083
+ ---
7084
+
7085
+ The [quadtree](/algorithms/the-quadtree.md "requires") in three dimensions, for [Barnes–Hut](/algorithms/barnes-hut.md "uses") on the 3D simulation.
7086
+
7087
+ ${OCTREE}
7088
+ `;
7089
+ const CLOSE = `When two bodies pass within a small fraction of their usual distance, their acceleration grows as $1/r^2$, and a step of fixed length overshoots: the energy jumps.
7090
+
7091
+ Three ways out:
7092
+ - **Soften the force.** With $\\varepsilon$ a little larger than the closest passes you care about, the force stays finite. Right for a galaxy, whose bodies stand for many stars.
7093
+ - **A shorter step**, for everyone. Simple, and leapfrog stays symplectic, but every body pays for the one close pair.
7094
+ - **Steps that differ by body** (block timesteps). Efficient, but only a careful scheme keeps the energy from drifting.
7095
+
7096
+ For two galaxies colliding, the first is the usual answer.`;
7097
+ /** The answer to one request to the tutorial's Iso. */
7098
+ /** In a turn in Iso's pane (M10): which mode, from what it was told. */
7099
+ const modeOf = (r) => {
7100
+ const system = JSON.stringify(r.messages.find((m) => m.role === "system")?.content ?? "");
7101
+ return system.includes("# Write mode") ? "write" : system.includes("# Understand mode") ? "understand" : system.includes("# Ask mode") ? "ask" : null;
7102
+ };
7103
+ const tools = (r) => r.messages.filter((m) => m.role === "tool").length;
7104
+ const TODO = "TODO: how big an error does θ = 0.5 give, and why does the cost grow only as N log N?";
7105
+ /** Iso's pane (M10): Ask, Write and Understand mode, on the questions the tutorial walks through. */
7106
+ function turnAnswer(r, mode) {
7107
+ const q = asked(r), n = tools(r);
7108
+ if (mode === "ask" && q.includes("inserted")) {
7109
+ if (n === 0) return {
7110
+ calls: [{
7111
+ name: "search_notes",
7112
+ arguments: { query: "quadtree" }
7113
+ }, {
7114
+ name: "read_note",
7115
+ arguments: { id: "algorithms/centre-of-mass" }
7116
+ }],
7117
+ usage: {
7118
+ in: 5100,
7119
+ out: 50,
7120
+ usd: .0059
7121
+ }
7122
+ };
7123
+ if (n === 2) return {
7124
+ calls: [{
7125
+ name: "point_to_note",
7126
+ arguments: {
7127
+ id: "algorithms/centre-of-mass",
7128
+ why: "It is what each node keeps as bodies pass down."
7129
+ }
7130
+ }],
7131
+ usage: {
7132
+ in: 7800,
7133
+ out: 40,
7134
+ usd: .0061
7135
+ }
7136
+ };
7137
+ return {
7138
+ text: INSERT,
7139
+ usage: {
7140
+ in: 8400,
7141
+ out: 310,
7142
+ cached: 4800,
7143
+ usd: .0163
7144
+ }
7145
+ };
7146
+ }
7147
+ if (mode === "ask" && q.includes("faster")) {
7148
+ if (n === 0) return {
7149
+ calls: [{
7150
+ name: "read_note",
7151
+ arguments: { id: "algorithms/direct-summation" }
7152
+ }, {
7153
+ name: "read_note",
7154
+ arguments: { id: "algorithms/barnes-hut" }
7155
+ }],
7156
+ usage: {
7157
+ in: 5300,
7158
+ out: 50,
7159
+ usd: .006
7160
+ }
7161
+ };
7162
+ return {
7163
+ text: SPEED,
7164
+ usage: {
7165
+ in: 8800,
7166
+ out: 260,
7167
+ cached: 5e3,
7168
+ usd: .0158
7169
+ }
7170
+ };
7171
+ }
7172
+ if (mode === "write" && q.includes("octree")) {
7173
+ if (n === 0) return {
7174
+ calls: [{
7175
+ name: "read_note",
7176
+ arguments: { id: "algorithms/the-quadtree" }
7177
+ }, {
7178
+ name: "read_note",
7179
+ arguments: { id: "algorithms/barnes-hut" }
7180
+ }],
7181
+ usage: {
7182
+ in: 5200,
7183
+ out: 60,
7184
+ usd: .0058
7185
+ }
7186
+ };
7187
+ if (n === 2) return {
7188
+ calls: [{
7189
+ name: "write_note",
7190
+ arguments: {
7191
+ id: "algorithms/the-octree",
7192
+ text: OCTREE_NOTE
7193
+ }
7194
+ }],
7195
+ usage: {
7196
+ in: 9900,
7197
+ out: 380,
7198
+ cached: 4400,
7199
+ usd: .0192
7200
+ }
7201
+ };
7202
+ return {
7203
+ text: "I wrote The octree in algorithms, from the quadtree and Barnes–Hut, and linked it to both.",
7204
+ usage: {
7205
+ in: 10400,
7206
+ out: 40,
7207
+ cached: 9800,
7208
+ usd: .0043
7209
+ }
7210
+ };
7211
+ }
7212
+ if (mode === "write" && q.includes("todo")) {
7213
+ if (n === 0) return {
7214
+ calls: [{
7215
+ name: "read_note",
7216
+ arguments: { id: "algorithms/barnes-hut" }
7217
+ }, {
7218
+ name: "read_note",
7219
+ arguments: { id: "algorithms/the-quadtree" }
7220
+ }],
7221
+ usage: {
7222
+ in: 5600,
7223
+ out: 60,
7224
+ usd: .0061
7225
+ }
7226
+ };
7227
+ if (n === 2) return {
7228
+ calls: [{
7229
+ name: "edit_note",
7230
+ arguments: {
7231
+ id: "algorithms/barnes-hut",
7232
+ old: TODO,
7233
+ new: THETA
7234
+ }
7235
+ }],
7236
+ usage: {
7237
+ in: 8900,
7238
+ out: 290,
7239
+ cached: 5100,
7240
+ usd: .0154
7241
+ }
7242
+ };
7243
+ return {
7244
+ text: "I answered both questions in your TODO, from the quadtree's depth and the centre of mass, and took the TODO away.",
7245
+ usage: {
7246
+ in: 9400,
7247
+ out: 40,
7248
+ cached: 8800,
7249
+ usd: .0041
7250
+ }
7251
+ };
7252
+ }
7253
+ if (mode === "understand" && /backwards|retrace|reverse/.test(q)) {
7254
+ if (n === 0) return {
7255
+ calls: [{
7256
+ name: "record_understanding",
7257
+ arguments: {
7258
+ id: "integrators/leapfrog",
7259
+ by: .5,
7260
+ why: "You saw that running it backwards retraces it, and that this is why its error cannot pile up."
5481
7261
  }
5482
- for (const [index, call] of (reply.calls ?? []).entries()) send({ choices: [{ delta: { tool_calls: [{
5483
- index,
5484
- id: `call_${index}_${Math.random().toString(36).slice(2, 8)}`,
5485
- function: {
5486
- name: call.name,
5487
- arguments: JSON.stringify(call.arguments)
5488
- }
5489
- }] } }] });
5490
- if (pace && reply.calls) await wait(pace * 20);
5491
- const u = reply.usage ?? {
5492
- in: 3e3,
5493
- out: 200,
5494
- usd: .008
5495
- };
5496
- send({ choices: [{
5497
- delta: {},
5498
- finish_reason: reply.calls ? "tool_calls" : "stop"
5499
- }] });
5500
- send({
5501
- choices: [],
5502
- usage: {
5503
- prompt_tokens: u.in,
5504
- completion_tokens: u.out,
5505
- cost: u.usd,
5506
- prompt_tokens_details: { cached_tokens: u.cached ?? 0 }
5507
- }
5508
- });
5509
- c.enqueue(enc.encode("data: [DONE]\n\n"));
5510
- c.close();
5511
- } catch (e) {
5512
- c.error(e);
7262
+ }, {
7263
+ name: "write_understanding",
7264
+ arguments: {
7265
+ id: "integrators/leapfrog",
7266
+ reading: "You can say why leapfrog's energy error stays bounded: it is time-reversible, so it cannot drift one way. Not yet shown: what being symplectic adds to that."
7267
+ }
7268
+ }],
7269
+ usage: {
7270
+ in: 6200,
7271
+ out: 120,
7272
+ usd: .0068
5513
7273
  }
5514
- } });
5515
- return new Response(body, {
5516
- status: 200,
5517
- headers: { "content-type": "text/event-stream" }
5518
- });
5519
- });
5520
- return { fetch };
7274
+ };
7275
+ return {
7276
+ text: "Yes: run backwards it retraces its steps, so an error it made going forward is undone coming back, and it cannot keep growing one way. That is reversibility. Being [symplectic](/integrators/symplectic-integrators.md) is the stronger reason, and the next thing to look at.",
7277
+ usage: {
7278
+ in: 7100,
7279
+ out: 90,
7280
+ cached: 5600,
7281
+ usd: .0049
7282
+ }
7283
+ };
7284
+ }
7285
+ if (mode === "understand" && /leapfrog|bounded|drift/.test(q)) {
7286
+ if (n === 0) return {
7287
+ calls: [{
7288
+ name: "read_note",
7289
+ arguments: {
7290
+ id: "integrators/leapfrog",
7291
+ section: "Why it does not drift"
7292
+ }
7293
+ }],
7294
+ usage: {
7295
+ in: 5200,
7296
+ out: 40,
7297
+ usd: .0056
7298
+ }
7299
+ };
7300
+ return {
7301
+ text: "Before I say why: what happens if you run leapfrog with $-\\Delta t$, from where it ended?",
7302
+ usage: {
7303
+ in: 6900,
7304
+ out: 30,
7305
+ cached: 4800,
7306
+ usd: .0044
7307
+ }
7308
+ };
7309
+ }
7310
+ return null;
5521
7311
  }
5522
- //#endregion
5523
- //#region packages/example/src/tutorial.ts
5524
- const UNSCRIPTED$1 = "In this tutorial I answer only what it walks you through. With a provider key in your own isorite, I would answer this too.";
5525
- const text = (r) => r.messages.filter((m) => m.role === "user").map((m) => String(m.content)).join("\n\n");
5526
- const looked = (r) => r.messages.some((m) => m.role === "tool");
5527
- const asked = (r) => (text(r).split(/# What they ask(?: now)?[^\n]*\n\n/).at(-1) ?? "").trim().toLowerCase();
5528
- const INSERT = `To insert a body, start at the root and walk down: at each node, go into the one of its four squares that holds the body's position.
5529
-
5530
- - **An empty leaf** takes the body, and is done.
5531
- - **A leaf with a body already** is split: both bodies go down into its new children, which may split again if they land in the same square.
5532
- - **An inner node** passes the body down to the child whose square holds it.
5533
-
5534
- On the way down, every node passed adds the body to its total mass $M$ and to $M\\mathbf{R}$, so that each keeps its mass and centre of mass with no second pass. A node keeps its square (centre and side $s$), its mass, its centre of mass, and its four children or its one body.`;
5535
- const THETA = `With $\\theta = 0.5$ the force error is about a percent for most bodies, and several percent for a few near dense nodes; it falls quickly as $\\theta$ shrinks. The error of a node used whole is of order $(s/d)^2$, the [centre of mass](/algorithms/centre-of-mass.md "uses")'s quadrupole, and $s/d < \\theta$ bounds it.
5536
-
5537
- The cost: a body opens nodes only near it. Around it, each level of the tree adds a ring of about the same number of nodes used whole, and there are $\\log_4 N$ levels, so each body visits $O(\\log N)$ nodes, and all of them $O(N \\log N)$.`;
5538
- const SPEED = `About ten times fewer interactions. Direct summation computes $N(N-1)/2 \\approx 1.25 \\times 10^7$ pairs at $N = 5000$. Barnes–Hut at $\\theta = 0.5$ uses some 200 nodes for each body, so about $10^6$ interactions in all.
5539
-
5540
- Each of them costs more: the tree walk branches, and in plain Python it is slow enough to lose the whole factor. Measure it, as your note on [cost and accuracy](/algorithms/cost-and-accuracy.md) says, before counting on it.`;
5541
- const OCTREE = `An octree is the same tree with eight children instead of four: each cube is split in half along each of the three axes.
5542
-
5543
- What changes:
5544
- - **Insertion** picks one of eight children, by the sign of the body's position against the cube's centre on each axis: three comparisons, one bit each.
5545
- - **The opening rule** is the same, $s/d < \\theta$, with $s$ the cube's side.
5546
- - **The cost** is still $O(N \\log N)$; the log is now base 8, but each opened node has twice as many children, so the work per body is about the same.
5547
-
5548
- Nothing in the [main loop](/simulation/the-main-loop.md) changes: only \`accelerations\`.`;
5549
- const CLOSE = `When two bodies pass within a small fraction of their usual distance, their acceleration grows as $1/r^2$, and a step of fixed length overshoots: the energy jumps.
5550
-
5551
- Three ways out:
5552
- - **Soften the force.** With $\\varepsilon$ a little larger than the closest passes you care about, the force stays finite. Right for a galaxy, whose bodies stand for many stars.
5553
- - **A shorter step**, for everyone. Simple, and leapfrog stays symplectic, but every body pays for the one close pair.
5554
- - **Steps that differ by body** (block timesteps). Efficient, but only a careful scheme keeps the energy from drifting.
5555
-
5556
- For two galaxies colliding, the first is the usual answer.`;
5557
- /** The answer to one request to the tutorial's Iso. */
5558
7312
  function tutorialAnswer(r) {
7313
+ const mode = modeOf(r);
7314
+ if (mode) return turnAnswer(r, mode) ?? {
7315
+ text: "In this tutorial I answer only what it walks you through. With a provider key in your own isorite, I would answer this too.",
7316
+ usage: {
7317
+ in: 4e3,
7318
+ out: 40,
7319
+ usd: .0049
7320
+ }
7321
+ };
5559
7322
  const all = text(r);
5560
7323
  if (all.includes("⟦answer⟧")) {
5561
7324
  const answer = /⟦answer⟧([\s\S]*?)⟦\/answer⟧/.exec(all)?.[1] ?? "";
@@ -5721,6 +7484,70 @@ function tutorialAnswer(r) {
5721
7484
  }
5722
7485
  };
5723
7486
  }
7487
+ if (q.includes("octree") && q.includes("barnes")) {
7488
+ if (!looked(r)) return {
7489
+ calls: [
7490
+ {
7491
+ name: "read_note",
7492
+ arguments: { id: "algorithms/the-quadtree" }
7493
+ },
7494
+ {
7495
+ name: "read_note",
7496
+ arguments: { id: "algorithms/barnes-hut" }
7497
+ },
7498
+ {
7499
+ name: "search_notes",
7500
+ arguments: { query: "octree" }
7501
+ }
7502
+ ],
7503
+ usage: {
7504
+ in: 5200,
7505
+ out: 60,
7506
+ usd: .0058
7507
+ }
7508
+ };
7509
+ return {
7510
+ text: OCTREE,
7511
+ usage: {
7512
+ in: 9900,
7513
+ out: 340,
7514
+ cached: 4400,
7515
+ usd: .0188
7516
+ }
7517
+ };
7518
+ }
7519
+ if (/pass(es)? close|close (pass|encounter)/.test(q)) {
7520
+ if (!looked(r)) return {
7521
+ calls: [
7522
+ {
7523
+ name: "read_note",
7524
+ arguments: { id: "physics/softening" }
7525
+ },
7526
+ {
7527
+ name: "read_note",
7528
+ arguments: { id: "integrators/choosing-the-timestep" }
7529
+ },
7530
+ {
7531
+ name: "find_similar",
7532
+ arguments: { text: "an energy jump when two bodies pass close" }
7533
+ }
7534
+ ],
7535
+ usage: {
7536
+ in: 4900,
7537
+ out: 60,
7538
+ usd: .0056
7539
+ }
7540
+ };
7541
+ return {
7542
+ text: CLOSE,
7543
+ usage: {
7544
+ in: 9600,
7545
+ out: 360,
7546
+ cached: 4100,
7547
+ usd: .0191
7548
+ }
7549
+ };
7550
+ }
5724
7551
  if (/\b3d\b|three dimensions/.test(q)) return {
5725
7552
  text: "In 3D the same tree has eight children a node: an octree.",
5726
7553
  usage: {
@@ -6632,7 +8459,7 @@ function writeHome(dir, { gateway = null, plugin = pluginDir } = {}) {
6632
8459
  mkdirSync(join(dir, "themes"), { recursive: true });
6633
8460
  mkdirSync(join(dir, "plugins"), { recursive: true });
6634
8461
  const json = (value) => JSON.stringify(value, null, 2) + "\n";
6635
- writeFileSync(join(dir, "opencode.json"), json(openCodeConfig(gateway)));
8462
+ writeFileSync(join(dir, "opencode.json"), json(withLocal(openCodeConfig(gateway), join(dir, "local.json"))));
6636
8463
  writeFileSync(join(dir, "cli.json"), json({
6637
8464
  $schema: "https://opencode.ai/cli.json",
6638
8465
  theme: {
@@ -6651,6 +8478,28 @@ function writeHome(dir, { gateway = null, plugin = pluginDir } = {}) {
6651
8478
  }
6652
8479
  return dir;
6653
8480
  }
8481
+ /** Your local.json over what isorite writes: its providers added to isorite's, everything else of yours taking the
8482
+ * place of isorite's. A local.json that is not JSON is said, and left out. */
8483
+ function withLocal(config, local) {
8484
+ if (!existsSync(local)) return config;
8485
+ let mine;
8486
+ try {
8487
+ mine = JSON.parse(readFileSync(local, "utf8"));
8488
+ } catch (e) {
8489
+ console.error(`isorite: ${local} is not JSON, so it is left out: ${e.message}`);
8490
+ return config;
8491
+ }
8492
+ const ours = config;
8493
+ const providers = {
8494
+ ...ours.providers,
8495
+ ...mine.providers
8496
+ };
8497
+ return {
8498
+ ...ours,
8499
+ ...mine,
8500
+ ...Object.keys(providers).length ? { providers } : {}
8501
+ };
8502
+ }
6654
8503
  function linksTo(path, target) {
6655
8504
  try {
6656
8505
  return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
@@ -6703,7 +8552,7 @@ function lastAsked(request) {
6703
8552
  }
6704
8553
  async function startScripted(script) {
6705
8554
  const requests = [];
6706
- const server = createServer((req, res) => {
8555
+ const server = createServer$1((req, res) => {
6707
8556
  let body = "";
6708
8557
  req.on("data", (chunk) => body += chunk);
6709
8558
  req.on("end", () => {
@@ -6891,7 +8740,7 @@ function tutorialScript(notes) {
6891
8740
  newString: NOW
6892
8741
  }
6893
8742
  }
6894
- ][done] ?? { text: "barnes_hut.py builds the quadtree and walks it with θ = 0.5 `[1]`. Your note on the goal now says where the code is: that change waits for you to keep or not `[2]`.\n\n```isorite\n[1] algorithms/barnes-hut\n[2] simulation/two-galaxies-colliding\n```" };
8743
+ ][done] ?? { text: "barnes_hut.py builds the quadtree and walks it with θ = 0.5 `[1]`. Your note on the goal now says where the code is, marked as mine `[2]`.\n\n```isorite\n[1] algorithms/barnes-hut\n[2] simulation/two-galaxies-colliding\n```" };
6895
8744
  return { text: UNSCRIPTED };
6896
8745
  };
6897
8746
  }
@@ -6969,23 +8818,147 @@ async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4
6969
8818
  }
6970
8819
  };
6971
8820
  }
6972
- async function term(path, { port = 4767, cwd = process.cwd(), env = process.env, wait = 12e4, onServer } = {}) {
6973
- const opencode = findOpenCode(env);
6974
- const gateway = gatewayFrom(env);
8821
+ /** A port nobody is listening on, on this machine. */
8822
+ async function freePort() {
8823
+ return new Promise((done, fail) => {
8824
+ const probe = createServer().once("error", fail).listen(0, "127.0.0.1", () => {
8825
+ const { port } = probe.address();
8826
+ probe.close(() => done(port));
8827
+ });
8828
+ });
8829
+ }
8830
+ /** A POST to the isorite server, made as its page makes one: from its origin, with the token it hands out. */
8831
+ async function tell(url, path, body) {
8832
+ const { token } = await (await fetch(`${url}/api/session`)).json();
8833
+ const response = await fetch(url + path, {
8834
+ method: "POST",
8835
+ headers: {
8836
+ "Content-Type": "application/json",
8837
+ "x-isorite-token": token,
8838
+ Origin: url
8839
+ },
8840
+ body: JSON.stringify(body)
8841
+ });
8842
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? `${response.status} from ${path}`);
8843
+ }
8844
+ /** OpenCode's own server, on this machine only, with a password of its own; its output kept in a log. The terminal
8845
+ * attaches to it, and so does the isorite server, which shows its session in the Iso pane (M11). */
8846
+ async function startOpenCode(command, { cwd, env, wait = 6e4 }) {
8847
+ const port = await freePort(), password = randomBytes(24).toString("base64url"), url = `http://127.0.0.1:${port}`;
8848
+ const log = join(tmpdir(), `isorite-opencode-${port}.log`);
8849
+ const out = openSync(log, "w");
8850
+ const server = spawn(command, [
8851
+ "serve",
8852
+ "--port",
8853
+ String(port),
8854
+ "--hostname",
8855
+ "127.0.0.1"
8856
+ ], {
8857
+ cwd,
8858
+ stdio: [
8859
+ "ignore",
8860
+ out,
8861
+ out
8862
+ ],
8863
+ env: {
8864
+ ...env,
8865
+ OPENCODE_PASSWORD: password
8866
+ }
8867
+ });
8868
+ const auth = { authorization: "Basic " + Buffer.from(`opencode:${password}`).toString("base64") };
8869
+ const started = Date.now();
8870
+ for (;;) {
8871
+ if (server.exitCode !== null || Date.now() - started > wait) {
8872
+ server.kill();
8873
+ throw new StudyError(`OpenCode's server did not start; its output is in ${log}`);
8874
+ }
8875
+ try {
8876
+ if ((await fetch(url, {
8877
+ headers: auth,
8878
+ signal: AbortSignal.timeout(1e3)
8879
+ })).status < 500) break;
8880
+ } catch {}
8881
+ await new Promise((r) => setTimeout(r, 250));
8882
+ }
8883
+ return {
8884
+ url,
8885
+ password,
8886
+ stop: () => {
8887
+ server.kill();
8888
+ }
8889
+ };
8890
+ }
8891
+ /** The models the terminal uses: ISORITE_GATEWAY_URL and _MODELS when set; else Iso's own provider, as
8892
+ * ~/.config/isorite/config.toml has it (OpenRouter unless `[provider] url` names another), its models the tiers'
8893
+ * (mid, the default, then max and low), with the key it holds handed to OpenCode in the environment only. Null without a key: then
8894
+ * OpenCode's own models, or whatever your local.json names. */
8895
+ async function modelsFor(env) {
8896
+ const named = gatewayFrom(env);
8897
+ if (named) return {
8898
+ gateway: named,
8899
+ env
8900
+ };
8901
+ const path = configPath(env);
8902
+ const key = await new ConfigSecrets(path).get("provider.key");
8903
+ if (!key) return {
8904
+ gateway: null,
8905
+ env
8906
+ };
8907
+ const provider = providerConfig(path);
8908
+ const models = [.../* @__PURE__ */ new Set([
8909
+ provider.tiers.mid,
8910
+ provider.tiers.max,
8911
+ provider.tiers.low
8912
+ ])];
8913
+ return {
8914
+ gateway: {
8915
+ url: provider.url,
8916
+ models
8917
+ },
8918
+ env: {
8919
+ ...env,
8920
+ [GATEWAY_KEY]: key
8921
+ }
8922
+ };
8923
+ }
8924
+ async function term(path, { port = 4767, cwd = process.cwd(), env: given = process.env, wait = 12e4, onServer } = {}) {
8925
+ const opencode = findOpenCode(given);
8926
+ const { gateway, env } = await modelsFor(given);
6975
8927
  const server = await ensureServer(path, {
6976
8928
  port,
6977
- env,
8929
+ env: given,
6978
8930
  wait
6979
8931
  });
6980
8932
  onServer?.(server.url);
6981
8933
  const home = writeHome(defaultHome(env), { gateway });
6982
- const child = spawn(opencode.command, ["--standalone"], {
8934
+ const shared = {
8935
+ ...env,
8936
+ OPENCODE_CONFIG_DIR: home,
8937
+ ISORITE_URL: server.url
8938
+ };
8939
+ let backend;
8940
+ try {
8941
+ backend = await startOpenCode(opencode.command, {
8942
+ cwd,
8943
+ env: shared
8944
+ });
8945
+ } catch (e) {
8946
+ server.release();
8947
+ throw e;
8948
+ }
8949
+ await tell(server.url, "/api/terminal/attach", {
8950
+ url: backend.url,
8951
+ password: backend.password,
8952
+ directory: cwd
8953
+ }).catch((e) => console.error(`isorite: the Iso pane will not show the terminal: ${e.message}`));
8954
+ const child = spawn(opencode.command, ["--server", backend.url], {
6983
8955
  cwd,
6984
8956
  stdio: "inherit",
6985
8957
  env: {
6986
- ...env,
8958
+ ...given,
6987
8959
  OPENCODE_CONFIG_DIR: home,
6988
- ISORITE_URL: server.url
8960
+ ISORITE_URL: server.url,
8961
+ OPENCODE_PASSWORD: backend.password
6989
8962
  }
6990
8963
  });
6991
8964
  const forward = (signal) => () => child.kill(signal);
@@ -6999,6 +8972,8 @@ async function term(path, { port = 4767, cwd = process.cwd(), env = process.env,
6999
8972
  child.once("exit", (exit) => done(exit ?? 1));
7000
8973
  });
7001
8974
  for (const [signal, handler] of handlers) process.off(signal, handler);
8975
+ await tell(server.url, "/api/terminal/detach", {}).catch(() => void 0);
8976
+ backend.stop();
7002
8977
  server.release();
7003
8978
  return code;
7004
8979
  }
@@ -7176,7 +9151,7 @@ async function mcp(path, { port = 4767, agent = "mcp", quiet = QUIET_MS, input =
7176
9151
  }
7177
9152
  //#endregion
7178
9153
  //#region packages/server/src/main.ts
7179
- const VERSION = "0.1.0";
9154
+ const VERSION = "0.2.0";
7180
9155
  const USAGE = `isorite: an agentic harness for understanding
7181
9156
 
7182
9157
  isorite check <notes> check a folder of notes: frontmatter, links, cycles
@@ -7191,7 +9166,8 @@ const USAGE = `isorite: an agentic harness for understanding
7191
9166
  isorite mcp [<study base | notes>] [--port <n>] [--agent <name>]
7192
9167
  the knowledge base's tools over MCP, on stdio, for Claude Code or Pi:
7193
9168
  claude mcp add isorite -- isorite mcp <base> --agent claude-code
7194
- isorite model fetch the model for search by meaning (34 MB, once)
9169
+ isorite model [--from <folder>] fetch the model for search by meaning (53 MB, once): from npm, else
9170
+ Hugging Face; or from a folder its files were copied into
7195
9171
  isorite --tutorial [<folder>] [--port <n>]
7196
9172
  the tutorial, on the example base, with a scripted model (no key,
7197
9173
  nothing sent anywhere): the terminal's tour in OpenCode 2, and the
@@ -7268,10 +9244,17 @@ function main(argv) {
7268
9244
  return 2;
7269
9245
  });
7270
9246
  }
7271
- if (command === "model") return fetchModel(userModelDir()).then(() => 0, (e) => {
7272
- console.error(e.message);
7273
- return 1;
7274
- });
9247
+ if (command === "model") {
9248
+ const from = rest[rest.indexOf("--from") + 1];
9249
+ if (rest.includes("--from") && !from) {
9250
+ console.error("--from takes a folder");
9251
+ return 2;
9252
+ }
9253
+ return fetchModel(userModelDir(), console.log, from && rest.includes("--from") ? { from } : {}).then(() => 0, (e) => {
9254
+ console.error(e.message);
9255
+ return 1;
9256
+ });
9257
+ }
7275
9258
  if (command === "--version" || command === "-v") {
7276
9259
  console.log(VERSION);
7277
9260
  return 0;