isorite 0.8.0 → 0.9.1

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 (36) hide show
  1. package/dist/main.js +411 -94
  2. package/docs/setup-for-agents.md +9 -5
  3. package/docs/setup.md +31 -4
  4. package/docs/terminal.md +9 -3
  5. package/package.json +1 -1
  6. package/plugin/tui.tsx +6 -0
  7. package/web/_app/immutable/chunks/{BtUtjfIh.js → B3aPQAYq.js} +1 -1
  8. package/web/_app/immutable/chunks/{D_JZ0swv.js → BXF2XoRC.js} +1 -1
  9. package/web/_app/immutable/chunks/{hOAYc4Tf.js → CBWiKnLz.js} +1 -1
  10. package/web/_app/immutable/chunks/{Di_ac7WN.js → CJ1dhP0p.js} +1 -1
  11. package/web/_app/immutable/chunks/{BrXPCJaM.js → CLkY0zVi.js} +1 -1
  12. package/web/_app/immutable/chunks/{CnP_Si9g.js → D-aFnd2T.js} +1 -1
  13. package/web/_app/immutable/chunks/{DgZW3JtG.js → DDuWClBR.js} +1 -1
  14. package/web/_app/immutable/chunks/{BjPiibIz.js → DII7jNnq.js} +1 -1
  15. package/web/_app/immutable/chunks/{CGzfi9Nl.js → DRQEUdad.js} +1 -1
  16. package/web/_app/immutable/chunks/{NmqB5ax3.js → DacMdKjW.js} +1 -1
  17. package/web/_app/immutable/chunks/{B91efLFT.js → DcgxJ8bF.js} +3 -3
  18. package/web/_app/immutable/chunks/{CCiHXe5d.js → Ddgejj-u.js} +1 -1
  19. package/web/_app/immutable/chunks/{dvrIP-Hm.js → GRrQoDGJ.js} +1 -1
  20. package/web/_app/immutable/chunks/{B-65IYX-.js → vUsuwDSf.js} +1 -1
  21. package/web/_app/immutable/entry/{app.DO9lj7h3.js → app.CswuY3sh.js} +2 -2
  22. package/web/_app/immutable/entry/start.D8ZvSyBD.js +1 -0
  23. package/web/_app/immutable/nodes/0.DBoa3NAQ.js +26 -0
  24. package/web/_app/immutable/nodes/{1.BRTPLKyU.js → 1.DrwFerb8.js} +1 -1
  25. package/web/_app/immutable/nodes/2.DPmbppXV.js +1 -0
  26. package/web/_app/immutable/nodes/3.BW7AbIrS.js +1 -0
  27. package/web/_app/immutable/nodes/{4.DYW7Fxdy.js → 4.B5HAyMKs.js} +1 -1
  28. package/web/_app/immutable/nodes/{5.Dkvkif0E.js → 5.BvScSoCM.js} +1 -1
  29. package/web/_app/immutable/nodes/6.Bna6qY2G.js +1 -0
  30. package/web/_app/version.json +1 -1
  31. package/web/index.html +14 -14
  32. package/web/_app/immutable/entry/start.Bg5TZs6n.js +0 -1
  33. package/web/_app/immutable/nodes/0.N1e1uAFq.js +0 -26
  34. package/web/_app/immutable/nodes/2.BWh1AYEd.js +0 -1
  35. package/web/_app/immutable/nodes/3.Cgqgn7Zv.js +0 -1
  36. package/web/_app/immutable/nodes/6.BinB0fuq.js +0 -1
package/dist/main.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { Document, Scalar, isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
6
6
  import MarkdownIt from "markdown-it";
7
7
  import { parse, stringify as stringify$1 } from "smol-toml";
8
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
8
9
  import { createHash, randomBytes } from "node:crypto";
9
10
  import { arch, constants, homedir, hostname, platform, release, tmpdir } from "node:os";
10
11
  import { createSecureContext, getCACertificates, setDefaultCACertificates } from "node:tls";
11
- import { execFileSync, spawn, spawnSync } from "node:child_process";
12
+ import { get } from "node:http";
12
13
  import { Worker } from "node:worker_threads";
13
14
  import { Agent, request } from "node:https";
14
15
  import { Readable } from "node:stream";
@@ -18,8 +19,10 @@ import { streamSSE } from "hono/streaming";
18
19
  import { z } from "zod";
19
20
  import { OpenCode } from "@opencode/client";
20
21
  import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
22
+ import { createInterface } from "node:readline/promises";
21
23
  import { createServer } from "node:net";
22
- import { createInterface } from "node:readline";
24
+ import { isSea } from "node:sea";
25
+ import { createInterface as createInterface$1 } from "node:readline";
23
26
  //#region \0rolldown/runtime.js
24
27
  var __defProp = Object.defineProperty;
25
28
  var __esmMin = (fn, res, err) => () => {
@@ -1944,19 +1947,50 @@ function unreachable(err, url) {
1944
1947
  try {
1945
1948
  host = new URL(url).host;
1946
1949
  } catch {}
1947
- const advice = UNTRUSTED.has(code) ? `Its certificate is signed by an authority not trusted here, often a company's own. Add it to the system's authorities (isorite trusts those), or set NODE_EXTRA_CA_CERTS to its certificate (a .pem), or name that as ca in [provider].` : code === "CERT_HAS_EXPIRED" ? "Its certificate has expired: that is the gateway's to fix." : code === "ERR_TLS_CERT_ALTNAME_INVALID" ? `Its certificate is not for ${host}: check the url in [provider].` : /CERTIFICATE_REQUIRED|HANDSHAKE_FAILURE|BAD_CERTIFICATE|UNKNOWN_CA/.test(code) ? "It asks for a client certificate (mTLS) and was given none it accepts: name yours as pfx (with pfx_passphrase), or cert and cert_key, in [provider]." : code === "UND_ERR_SOCKET" && url.startsWith("https:") ? "It closed the connection as it was being set up. A gateway that asks for a client certificate (mTLS) does this: name yours as pfx, or cert and cert_key, in [provider]." : code === "ENOTFOUND" || code === "EAI_AGAIN" ? `The name ${host} could not be found: check the url in [provider], and whether it is only found on the company's network or VPN.` : code === "ECONNREFUSED" ? `Nothing answers at ${host}: check the url and its port in [provider].` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ENETUNREACH" || code === "EHOSTUNREACH" ? `${host} did not answer. If the company's network reaches it through a proxy, set HTTPS_PROXY.` : "";
1950
+ const proxy = /Proxy response \((\d{3})\)/.exec(said)?.[1];
1951
+ const advice = proxy === "407" ? "The proxy (HTTPS_PROXY), not the provider, refused with 407: it wants credentials of its own, as http://user:password@proxy:port in HTTPS_PROXY, or its own sign-in. isorite can't answer a proxy that signs in by Kerberos or NTLM." : proxy ? `The proxy (HTTPS_PROXY) answered ${proxy}, not the provider: check HTTPS_PROXY, and NO_PROXY for hosts reached directly.` : UNTRUSTED.has(code) ? `Its certificate is signed by an authority not trusted here, often a company's own. Add it to the system's authorities (isorite trusts those), or set NODE_EXTRA_CA_CERTS to its certificate (a .pem), or name that as ca in [provider].` : code === "CERT_HAS_EXPIRED" ? "Its certificate has expired: that is the gateway's to fix." : code === "ERR_TLS_CERT_ALTNAME_INVALID" ? `Its certificate is not for ${host}: check the url in [provider].` : /CERTIFICATE_REQUIRED|HANDSHAKE_FAILURE|BAD_CERTIFICATE|UNKNOWN_CA/.test(code) ? "It asks for a client certificate (mTLS) and was given none it accepts: name yours as pfx (with pfx_passphrase), or cert and cert_key, in [provider]." : code === "UND_ERR_SOCKET" && url.startsWith("https:") ? "It closed the connection as it was being set up. A gateway that asks for a client certificate (mTLS) does this: name yours as pfx, or cert and cert_key, in [provider]." : code === "ENOTFOUND" || code === "EAI_AGAIN" ? `The name ${host} could not be found: check the url in [provider], and whether it is only found on the company's network or VPN.` : code === "ECONNREFUSED" ? `Nothing answers at ${host}: check the url and its port in [provider].` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ENETUNREACH" || code === "EHOSTUNREACH" ? `${host} did not answer. If the company's network reaches it through a proxy, set HTTPS_PROXY.` : "";
1948
1952
  return [
1949
1953
  said || err?.message || "no reason given",
1950
1954
  code && !said.includes(code) ? ` (${code})` : "",
1951
1955
  advice ? `. ${advice}` : ""
1952
1956
  ].join("");
1953
1957
  }
1958
+ /** The header the key goes in, and what goes before it: Authorization with "Bearer " unless said otherwise (M21).
1959
+ * `key_header = "authorization"` alone means Bearer too: a bare key in Authorization is rarely what a gateway
1960
+ * wants. `key_prefix = ""` sends it bare. */
1961
+ function keySent(p) {
1962
+ const header = p.keyHeader || "Authorization";
1963
+ return {
1964
+ header,
1965
+ prefix: p.keyPrefix ?? (header.toLowerCase() === "authorization" ? "Bearer " : "")
1966
+ };
1967
+ }
1968
+ /** How the key is sent, as said to the person: `Authorization: Bearer <41 characters>`. */
1969
+ function keySentAs(p, key) {
1970
+ const { header, prefix } = keySent(p);
1971
+ return `${header}: ${prefix}<${key.length} characters>`;
1972
+ }
1973
+ /** What a provider said with a refusal: its `error.message` (or `message`, or `error`), else its text without
1974
+ * markup, cut to 300 characters, and never the key, should it say it back. */
1975
+ function refusal(body, key = null) {
1976
+ let said = "";
1977
+ try {
1978
+ const j = JSON.parse(body);
1979
+ said = (typeof j.error === "string" ? j.error : j.error?.message) ?? j.message ?? "";
1980
+ } catch {
1981
+ said = body.replace(/<[^>]+>/g, " ");
1982
+ }
1983
+ said = said.replace(/\s+/g, " ").trim();
1984
+ if (key && key.length >= 8) said = said.split(key).join("<the key>");
1985
+ return said.length > 300 ? said.slice(0, 299) + "…" : said;
1986
+ }
1954
1987
  /** The address of `path` at the provider, and the headers that go with every request to it: its key, where it
1955
1988
  * wants it, and its own. */
1956
1989
  function endpoint(p, key, path) {
1957
1990
  const url = new URL(p.url.replace(/\/+$/, "") + path);
1958
1991
  for (const [name, value] of Object.entries(p.query ?? {})) url.searchParams.set(name, value);
1959
- const auth = !key ? {} : p.keyHeader ? { [p.keyHeader]: key } : { Authorization: `Bearer ${key}` };
1992
+ const sent = keySent(p);
1993
+ const auth = !key ? {} : { [sent.header]: sent.prefix + key };
1960
1994
  return {
1961
1995
  url: url.toString(),
1962
1996
  headers: {
@@ -2023,13 +2057,7 @@ async function completeOnce(http, p, key, call, heard) {
2023
2057
  if (!res.ok || !res.body) {
2024
2058
  let detail = "";
2025
2059
  try {
2026
- const said = await res.text();
2027
- try {
2028
- const j = JSON.parse(said);
2029
- detail = (typeof j.error === "string" ? j.error : j.error?.message) ?? j.message ?? "";
2030
- } catch {
2031
- detail = said.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
2032
- }
2060
+ detail = refusal(await res.text(), key);
2033
2061
  } catch {}
2034
2062
  if (DOWN.has(res.status)) throw new ModelError(`${p.name} is unavailable (${res.status}${detail ? `: ${detail}` : ""})`, 502, {
2035
2063
  kind: "unavailable",
@@ -2041,7 +2069,7 @@ async function completeOnce(http, p, key, call, heard) {
2041
2069
  retry: true,
2042
2070
  after: afterOf(res)
2043
2071
  });
2044
- const advice = res.status === 401 ? " The key was not accepted: set it again in settings." : res.status === 402 ? " The key's credit is spent." : res.status === 404 ? ` ${p.name} may have no model called ${call.model}${p.defaultTiers ? ", which is OpenRouter's name for it: name this provider's models under [tiers] in config.toml" : ""}.` : "";
2072
+ const advice = res.status === 401 ? ` The key was not accepted${key ? `, sent as ${keySentAs(p, key)}` : ""}: set it again in settings, or check key_header and key_prefix.` : res.status === 402 ? " The key's credit is spent." : res.status === 404 ? ` ${p.name} may have no model called ${call.model}${p.defaultTiers ? ", which is OpenRouter's name for it: name this provider's models under [tiers] in config.toml" : ""}.` : "";
2045
2073
  throw new ModelError(`${p.name} said ${res.status}${detail ? `: ${detail}` : ""}.${advice}`, res.status === 401 || res.status === 403 ? 409 : 502);
2046
2074
  }
2047
2075
  let text = "", usage = null, finish = "";
@@ -6697,7 +6725,7 @@ function writeHome(dir, { gateway = null, plugin = pluginDir, instructions = []
6697
6725
  recursive: true,
6698
6726
  force: true
6699
6727
  });
6700
- symlinkSync(plugin, link, "dir");
6728
+ symlinkSync(plugin, link, process.platform === "win32" ? "junction" : "dir");
6701
6729
  }
6702
6730
  return dir;
6703
6731
  }
@@ -6724,12 +6752,14 @@ function withLocal(config, local) {
6724
6752
  };
6725
6753
  }
6726
6754
  function linksTo(path, target) {
6755
+ const plain = (p) => resolve(p).replace(/[\\/]+$/, "");
6727
6756
  try {
6728
- return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
6757
+ return lstatSync(path).isSymbolicLink() && plain(readlinkSync(path)) === plain(target);
6729
6758
  } catch {
6730
6759
  return false;
6731
6760
  }
6732
6761
  }
6762
+ const PACKAGE$1 = `@opencode/cli@2.0.32`;
6733
6763
  /** The version a `--version` line names, as "2.0.32", or null. */
6734
6764
  function parseVersion(output) {
6735
6765
  return output.match(/(\d+)\.(\d+)\.(\d+)/)?.[0] ?? null;
@@ -6749,20 +6779,57 @@ const probe = (command) => {
6749
6779
  return null;
6750
6780
  }
6751
6781
  };
6752
- /** ISORITE_OPENCODE if set, else `opencode2`, else `opencode`: the first that is OpenCode 2. */
6753
- function findOpenCode(env = process.env, run = probe) {
6782
+ /** isorite's own prefix for OpenCode 2. Not ~/.config/isorite/opencode, which is OpenCode's configuration as
6783
+ * `term` writes it (home.ts): an npm prefix there would put node_modules among it. */
6784
+ function ownPrefix(env = process.env) {
6785
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "isorite", "opencode-cli");
6786
+ }
6787
+ /** The OpenCode 2 in isorite's own prefix, as npm links it; on Windows, the package's own opencode.exe, as npm's
6788
+ * opencode2.cmd can't be run without a shell (Node refuses .cmd files since 2024). */
6789
+ const ownCommand = (env = process.env, platform = process.platform) => platform === "win32" ? join(ownPrefix(env), "node_modules", "@opencode", "cli", "bin", "opencode.exe") : join(ownPrefix(env), "node_modules", ".bin", "opencode2");
6790
+ /** A command on the PATH as Windows can run it without a shell: `<name>.exe`, or for npm's `<name>.cmd` the
6791
+ * package's opencode.exe beside it, in the global node_modules. Null when there is neither. */
6792
+ function onWindowsPath(name, env = process.env, exists = existsSync) {
6793
+ for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
6794
+ if (exists(win32.join(dir, `${name}.exe`))) return win32.join(dir, `${name}.exe`);
6795
+ const exe = win32.join(dir, "node_modules", "@opencode", "cli", "bin", "opencode.exe");
6796
+ if (exists(win32.join(dir, `${name}.cmd`)) && exists(exe)) return exe;
6797
+ }
6798
+ return null;
6799
+ }
6800
+ /** The line that installs OpenCode 2 for isorite alone, to copy. */
6801
+ const installLine = (env = process.env) => `isorite opencode (npm install --prefix ${ownPrefix(env)} ${PACKAGE$1})`;
6802
+ /** OpenCode 2 wasn't found. `old` is an OpenCode 1 that was, if one was. */
6803
+ var NoOpenCode = class extends Error {
6804
+ old;
6805
+ constructor(tried, old, env) {
6806
+ super((old ? `isorite term needs OpenCode 2; ${old} is OpenCode 1 (${tried.join("; ")}).\n` : `isorite term needs OpenCode 2 (${tried.join("; ")}).\n`) + ` Install it for isorite only, leaving the global one as it is: ${installLine(env)}\n or name one with: ISORITE_OPENCODE=/path/to/opencode isorite term`);
6807
+ this.old = old;
6808
+ }
6809
+ };
6810
+ /** ISORITE_OPENCODE if set, else isorite's own prefix's, else `opencode2`, else `opencode`: the first that is
6811
+ * OpenCode 2. */
6812
+ function findOpenCode(env = process.env, run = probe, platform = process.platform, where = onWindowsPath) {
6754
6813
  const tried = [];
6755
- const candidates = env.ISORITE_OPENCODE ? [env.ISORITE_OPENCODE] : ["opencode2", "opencode"];
6756
- for (const command of candidates) {
6814
+ let old = null;
6815
+ const candidates = env.ISORITE_OPENCODE ? [[env.ISORITE_OPENCODE, "ISORITE_OPENCODE"]] : [
6816
+ [ownCommand(env, platform), "isorite opencode"],
6817
+ ["opencode2", "the PATH"],
6818
+ ["opencode", "the PATH"]
6819
+ ];
6820
+ for (const [named, from] of candidates) {
6821
+ const command = platform === "win32" && from === "the PATH" ? where(named, env) ?? named : named;
6757
6822
  const output = run(command);
6758
6823
  const version = output === null ? null : parseVersion(output);
6759
6824
  if (version && Number(version.split(".")[0]) === 2) return {
6760
6825
  command,
6761
- version
6826
+ version,
6827
+ from
6762
6828
  };
6763
- tried.push(output === null ? `${command}: not found` : `${command}: ${version ?? "unknown version"}`);
6829
+ if (version && Number(version.split(".")[0]) < 2) old ??= `${command} ${version}`;
6830
+ if (output !== null || from !== "isorite opencode") tried.push(output === null ? `${command}: not found` : `${command}: ${version ?? "unknown version"}`);
6764
6831
  }
6765
- throw new Error(`isorite term needs OpenCode 2 (${tried.join("; ")}).\n Install it with: npm i -g @opencode/cli\n or name one with: ISORITE_OPENCODE=/path/to/opencode isorite term`);
6832
+ throw new NoOpenCode(tried, old, env);
6766
6833
  }
6767
6834
  //#endregion
6768
6835
  //#region packages/server/src/config.ts
@@ -6850,6 +6917,7 @@ function providerFrom(config, env = process.env) {
6850
6917
  ...headers ? { headers } : {},
6851
6918
  ...query ? { query } : {},
6852
6919
  ...typeof p.key_header === "string" && p.key_header ? { keyHeader: p.key_header } : {},
6920
+ ...typeof p.key_prefix === "string" ? { keyPrefix: p.key_prefix } : {},
6853
6921
  ...p.stream_usage === false ? { streamUsage: false } : {},
6854
6922
  ...typeof p.max_tokens === "number" && p.max_tokens > 0 ? { maxTokens: Math.floor(p.max_tokens) } : {}
6855
6923
  };
@@ -6953,6 +7021,23 @@ function lendBase(name, lend, path = configPath()) {
6953
7021
  }
6954
7022
  //#endregion
6955
7023
  //#region packages/server/src/model.ts
7024
+ /** Where a mirror keeps the .tgz (M21): ISORITE_MODEL_BASE_URL names the folder it's in, tried before the rest. */
7025
+ function mirrorOf(env = process.env) {
7026
+ const base = env.ISORITE_MODEL_BASE_URL?.trim();
7027
+ return base ? `${base.replace(/\/+$/, "")}/${RELEASE_FILE}` : null;
7028
+ }
7029
+ /** How to fetch the model by hand, to copy: the file, its SHA-256, and the command that takes it. */
7030
+ function byHand(env = process.env) {
7031
+ const mirror = mirrorOf(env);
7032
+ return [
7033
+ "Fetch this file another way (a browser, or a machine that can reach it):",
7034
+ ` ${RELEASE}`,
7035
+ ...mirror ? [` or from your mirror: ${mirror}`] : [],
7036
+ ` SHA-256 ${RELEASE_SHA256}`,
7037
+ "then run:",
7038
+ ` isorite model --from ~/Downloads/${RELEASE_FILE}`
7039
+ ];
7040
+ }
6956
7041
  /** Where the server looks for the model, in order, and why each place is there. */
6957
7042
  function modelPlaces(env = process.env) {
6958
7043
  if (env.ISORITE_EMBED_DIR) return [{
@@ -7042,7 +7127,7 @@ function unpack(pkg) {
7042
7127
  }
7043
7128
  /** Fetch the model's files into `out`, keeping only files whose SHA-256 is the one expected: from a folder if one is
7044
7129
  * named; else from npm, then the GitHub release, then Hugging Face, each tried in turn. */
7045
- async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release = RELEASE, hub = HUB, files = FILES } = {}) {
7130
+ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release = RELEASE, mirror = null, url, hub = HUB, files = FILES, tgzSha = files === FILES ? RELEASE_SHA256 : null } = {}) {
7046
7131
  mkdirSync(out, { recursive: true });
7047
7132
  const missing = () => files.filter(([, to, want]) => !(existsSync(join(out, to)) && sha(readFileSync(join(out, to))) === want));
7048
7133
  for (const [, to] of files) if (!missing().some((f) => f[1] === to)) say(`${to}: already here`);
@@ -7071,31 +7156,42 @@ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release
7071
7156
  }
7072
7157
  };
7073
7158
  const why = (e, url) => e.message === "fetch failed" || e.cause ? unreachable(e, url) : e.message;
7074
- if (missing().length && pkg) try {
7075
- fromPackage(pkg);
7076
- } catch (e) {
7077
- tried.push(`npm (${pkg}): ${e.message}`);
7078
- say(`From npm (${pkg}): ${e.message}`);
7079
- }
7080
- if (missing().length && release) {
7159
+ /** The .tgz at an address, checked by its SHA-256 before it's unpacked; a failure is kept in `tried`. */
7160
+ const fromWeb = async (where, address) => {
7081
7161
  const tmp = mkdtempSync(join(tmpdir(), "isorite-model-release-"));
7082
7162
  try {
7083
- say(`From GitHub: ${release}`);
7084
- const res = await fetch(release);
7163
+ say(`From ${where}: ${address}`);
7164
+ const res = await fetch(address);
7085
7165
  if (!res.ok) throw new Error(`it said ${res.status}`);
7086
- const tgz = join(tmp, basename(new URL(release).pathname));
7087
- writeFileSync(tgz, new Uint8Array(await res.arrayBuffer()));
7166
+ const buf = new Uint8Array(await res.arrayBuffer());
7167
+ if (tgzSha && sha(buf) !== tgzSha) throw new Error(`not the file expected: its SHA-256 is ${sha(buf)}, not ${tgzSha}, so it was not unpacked`);
7168
+ const tgz = join(tmp, basename(new URL(address).pathname) || "isorite-model-granite-1.0.0.tgz");
7169
+ writeFileSync(tgz, buf);
7088
7170
  fromPackage(tgz);
7089
7171
  } catch (e) {
7090
- tried.push(`GitHub (${release}): ${why(e, release)}`);
7091
- say(`From GitHub: ${why(e, release)}`);
7172
+ tried.push(`${where} (${address}): ${why(e, address)}`);
7173
+ say(`From ${where}: ${why(e, address)}`);
7092
7174
  } finally {
7093
7175
  rmSync(tmp, {
7094
7176
  recursive: true,
7095
7177
  force: true
7096
7178
  });
7097
7179
  }
7180
+ };
7181
+ if (url) {
7182
+ await fromWeb("--url", url);
7183
+ if (missing().length) throw new ModelFetchError(tried);
7184
+ say(`The model is in ${out}`);
7185
+ return;
7098
7186
  }
7187
+ if (missing().length && mirror) await fromWeb("the mirror (ISORITE_MODEL_BASE_URL)", mirror);
7188
+ if (missing().length && pkg) try {
7189
+ fromPackage(pkg);
7190
+ } catch (e) {
7191
+ tried.push(`npm (${pkg}): ${e.message}`);
7192
+ say(`From npm (${pkg}): ${e.message}`);
7193
+ }
7194
+ if (missing().length && release) await fromWeb("GitHub", release);
7099
7195
  if (missing().length && hub) {
7100
7196
  say("From Hugging Face");
7101
7197
  try {
@@ -7115,6 +7211,19 @@ async function fetchModel(out, say = console.log, { from, pkg = PACKAGE, release
7115
7211
  * checked by using it: it must load, and give DIM numbers for a sentence. Kept in the user's models, and used from
7116
7212
  * then on. */
7117
7213
  async function installFrom(from, say = console.log, env = process.env) {
7214
+ if (/\.(tgz|tar\.gz)$/.test(from) && existsSync(from) && statSync(from).isFile()) {
7215
+ const got = sha(readFileSync(from));
7216
+ if (got !== "8d8ab528ae289dfc81fd2bfa80c867ca0c8342a2d98f7c86141df4b15fdf028c") throw new Error(`${from} is not ${RELEASE_FILE}: its SHA-256 is ${got}, not ${RELEASE_SHA256}`);
7217
+ const dir = unpack(resolve(from));
7218
+ try {
7219
+ return await installFrom(join(dir, "package"), say, env);
7220
+ } finally {
7221
+ rmSync(dir, {
7222
+ recursive: true,
7223
+ force: true
7224
+ });
7225
+ }
7226
+ }
7118
7227
  if (!hasModel(from)) throw new Error(`${from} has no model: it needs model.onnx and tokenizer.json`);
7119
7228
  const granite = !existsSync(join(from, "isorite-model.json")) && FILES.every(([, to, want]) => existsSync(join(from, to)) && sha(readFileSync(join(from, to))) === want);
7120
7229
  let out = userModelDir(env);
@@ -7142,7 +7251,7 @@ async function installFrom(from, say = console.log, env = process.env) {
7142
7251
  }
7143
7252
  /** `isorite model [--from <folder>]`: says which model is installed and where, or installs one, and on failure
7144
7253
  * where it looked, what it tried, and what to do. */
7145
- async function modelCommand(from, say = console.log, env = process.env, sources = {}) {
7254
+ async function modelCommand(from, say = console.log, env = process.env, sources = {}, url) {
7146
7255
  const told = (i, why) => say(`search by meaning: ${i.name} at ${i.dir} (sha256 ${i.sha.slice(0, 12)}, ${i.pooling} pooling), found by ${why}`);
7147
7256
  if (env.ISORITE_EMBED === "off") say("ISORITE_EMBED=off: search by meaning is switched off, whatever is installed");
7148
7257
  if (from) try {
@@ -7159,27 +7268,35 @@ async function modelCommand(from, say = console.log, env = process.env, sources
7159
7268
  return 0;
7160
7269
  }
7161
7270
  say(`No model in ${places.map((p) => p.dir).join(", ")}`);
7271
+ if (!url) say(`If it can't be fetched from here: ${RELEASE} (SHA-256 ${RELEASE_SHA256}), then isorite model --from <that file>`);
7162
7272
  try {
7163
- await fetchModel(userModelDir(env), say, sources);
7273
+ await fetchModel(userModelDir(env), say, {
7274
+ mirror: mirrorOf(env),
7275
+ ...sources,
7276
+ ...url ? { url } : {}
7277
+ });
7164
7278
  return 0;
7165
7279
  } catch (e) {
7166
7280
  say(e.message);
7167
7281
  say([
7168
- "What to do:",
7169
- ` - copy the model's files from a machine that has them (${userModelDir()} there), then: isorite model --from <folder>`,
7170
- ` - or the package's .tgz, from ${RELEASE}: unpack it, then isorite model --from <its package folder>`,
7171
- ` - or any compatible model (ONNX, 384 numbers out, with its tokenizer.json): isorite model --from <folder>, or ISORITE_EMBED_DIR=<folder>`,
7282
+ ...byHand(env),
7283
+ "Or:",
7284
+ ` - a mirror: ISORITE_MODEL_BASE_URL=<the folder ${RELEASE_FILE} is in> isorite model, or isorite model --url <its address>`,
7285
+ ` - the model's files from a machine that has them (${userModelDir()} there): isorite model --from <folder>`,
7286
+ ` - any compatible model (ONNX, 384 numbers out, with its tokenizer.json): isorite model --from <folder>, or ISORITE_EMBED_DIR=<folder>`,
7172
7287
  "Search by words works without it."
7173
7288
  ].join("\n"));
7174
7289
  return 1;
7175
7290
  }
7176
7291
  }
7177
- var NAME$2, MODEL, RELEASE, PACKAGE, HUB, FILES, sha, userModelsDir, userModelDir, hasModel, ModelFetchError, modelFiles;
7292
+ var NAME$2, MODEL, RELEASE_FILE, RELEASE, RELEASE_SHA256, PACKAGE, HUB, FILES, sha, userModelsDir, userModelDir, hasModel, ModelFetchError, modelFiles;
7178
7293
  var init_model = __esmMin((() => {
7179
7294
  init_src();
7180
7295
  NAME$2 = "granite-embedding-small-english-r2";
7181
7296
  MODEL = `${NAME$2}-q8`;
7182
- RELEASE = "https://github.com/lachlanjs/isorite/releases/download/model-granite-1.0.0/isorite-model-granite-1.0.0.tgz";
7297
+ RELEASE_FILE = "isorite-model-granite-1.0.0.tgz";
7298
+ RELEASE = `https://github.com/lachlanjs/isorite/releases/download/model-granite-1.0.0/${RELEASE_FILE}`;
7299
+ RELEASE_SHA256 = "8d8ab528ae289dfc81fd2bfa80c867ca0c8342a2d98f7c86141df4b15fdf028c";
7183
7300
  PACKAGE = "isorite-model-granite@1.0.0";
7184
7301
  HUB = `https://huggingface.co/onnx-community/granite-embedding-small-english-r2-ONNX/resolve/1dc7835ba0cb9c76a3618d0bf0c427c97671b3c8`;
7185
7302
  FILES = [
@@ -7982,6 +8099,7 @@ const SetupDraft = z.object({
7982
8099
  url: z.string().max(2e3),
7983
8100
  keyHeld: z.boolean(),
7984
8101
  keyHeader: z.string().max(200),
8102
+ keyPrefix: z.string().max(50).default(""),
7985
8103
  headers: z.array(Pair).max(40),
7986
8104
  query: z.array(Pair).max(40),
7987
8105
  pfx: Path,
@@ -8481,6 +8599,7 @@ const FORM = [
8481
8599
  "auth",
8482
8600
  "key",
8483
8601
  "key_header",
8602
+ "key_prefix",
8484
8603
  "headers",
8485
8604
  "query",
8486
8605
  "pfx",
@@ -8512,6 +8631,7 @@ function readSetup(config) {
8512
8631
  url: gateway ? p.url : OPENROUTER.url,
8513
8632
  keyHeld: text(p.key) !== "",
8514
8633
  keyHeader: text(p.key_header),
8634
+ keyPrefix: text(p.key_prefix).trim(),
8515
8635
  headers: pairs(p.headers),
8516
8636
  query: pairs(p.query),
8517
8637
  pfx: text(p.pfx),
@@ -8553,6 +8673,9 @@ function applySetup(config, draft) {
8553
8673
  }
8554
8674
  if (gateway) {
8555
8675
  if (draft.keyHeader.trim()) p.key_header = draft.keyHeader.trim();
8676
+ const prefix = (draft.keyPrefix ?? "").trim();
8677
+ if (prefix) p.key_prefix = prefix + " ";
8678
+ else if (old.key_prefix === "" && draft.keyHeader.trim()) p.key_prefix = "";
8556
8679
  const pairs = (field, given) => {
8557
8680
  const before = table(old[field]), out = {};
8558
8681
  for (const pair of given) {
@@ -8713,28 +8836,47 @@ var Setup = class {
8713
8836
  };
8714
8837
  //#endregion
8715
8838
  //#region packages/server/src/version.ts
8716
- const VERSION = "0.8.0";
8839
+ const VERSION = "0.9.1";
8717
8840
  //#endregion
8718
- //#region packages/server/src/studybase.ts
8719
- init_src$1();
8720
- function git(cwd, ...args) {
8841
+ //#region packages/server/src/git.ts
8842
+ /** The top of the repository `folder` is in, or null: the nearest folder holding `.git`, a directory or, in a
8843
+ * worktree or submodule, a file. No `git` is run. */
8844
+ function repoOf(folder) {
8845
+ for (let at = resolve(folder);; at = dirname(at)) {
8846
+ if (existsSync(join(at, ".git"))) return at;
8847
+ if (dirname(at) === at) return null;
8848
+ }
8849
+ }
8850
+ /** The environment for a `git` isorite runs: none of the shell's GIT_* (a GIT_DIR would point it elsewhere), no
8851
+ * system configuration (where a company's hooks and helpers are usually set), and no prompt for a password. */
8852
+ function plainGitEnv(env = process.env) {
8853
+ return {
8854
+ ...Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith("GIT_"))),
8855
+ GIT_CONFIG_NOSYSTEM: "1",
8856
+ GIT_TERMINAL_PROMPT: "0"
8857
+ };
8858
+ }
8859
+ /** `git init` in a folder, given `timeout` ms; whether it was made a repository. */
8860
+ function gitInit(folder, { env = process.env, timeout = 1e4 } = {}) {
8721
8861
  try {
8722
- return execFileSync("git", args, {
8723
- cwd,
8724
- encoding: "utf8",
8725
- stdio: [
8726
- "ignore",
8727
- "pipe",
8728
- "ignore"
8729
- ]
8730
- }).trim();
8862
+ execFileSync("git", ["init", "--quiet"], {
8863
+ cwd: folder,
8864
+ stdio: "ignore",
8865
+ env: plainGitEnv(env),
8866
+ timeout,
8867
+ killSignal: "SIGKILL"
8868
+ });
8869
+ return existsSync(join(folder, ".git"));
8731
8870
  } catch {
8732
- return null;
8871
+ return false;
8733
8872
  }
8734
8873
  }
8874
+ //#endregion
8875
+ //#region packages/server/src/studybase.ts
8876
+ init_src$1();
8735
8877
  /** Where format 1 put a study base when not told: beside the knowledge base's repository, named after it. */
8736
8878
  function defaultStudyPath(notes) {
8737
- const repo = git(notes, "rev-parse", "--show-toplevel") ?? notes;
8879
+ const repo = repoOf(notes) ?? resolve(notes);
8738
8880
  return join(dirname(repo), basename(repo) + "-study");
8739
8881
  }
8740
8882
  /** A study base's configuration, and its knowledge base's folder as an absolute path. */
@@ -8927,6 +9069,27 @@ async function serve$1(path, { host = "127.0.0.1", port = 4767, allowHosts = [],
8927
9069
  process.on("SIGTERM", stop);
8928
9070
  }
8929
9071
  //#endregion
9072
+ //#region packages/server/src/self.ts
9073
+ /** Whether this is the single executable. */
9074
+ const single = () => {
9075
+ try {
9076
+ return isSea();
9077
+ } catch {
9078
+ return false;
9079
+ }
9080
+ };
9081
+ /** What runs isorite again with `args`, given the script (npm's or a checkout's) to run when not single. */
9082
+ function self(script, args, execArgv = process.execArgv) {
9083
+ return {
9084
+ command: process.execPath,
9085
+ args: single() ? args : [
9086
+ ...execArgv,
9087
+ script,
9088
+ ...args
9089
+ ]
9090
+ };
9091
+ }
9092
+ //#endregion
8930
9093
  //#region packages/server/src/term.ts
8931
9094
  init_src$1();
8932
9095
  const MAIN = import.meta.url.endsWith(".ts") ? fileURLToPath(new URL("./main.ts", import.meta.url)) : fileURLToPath(import.meta.url);
@@ -8946,6 +9109,12 @@ function openBrowser(url) {
8946
9109
  }).on("error", () => void 0).unref();
8947
9110
  } catch {}
8948
9111
  }
9112
+ /** How long a step may take before it's said what is being waited for (M21), and where it's said: stderr, as
9113
+ * `mcp` speaks on stdout. */
9114
+ const SLOW = 2e3;
9115
+ const waiting = (line) => {
9116
+ process.stderr.write(line + "\n");
9117
+ };
8949
9118
  /** The name the server on this URL serves, or null when nothing answers. */
8950
9119
  async function serving(url) {
8951
9120
  try {
@@ -8959,7 +9128,7 @@ async function serving(url) {
8959
9128
  * well from now on, M20; or for any, when no base is named), or one started for the base, its output kept in a
8960
9129
  * log. `url` is the base's own address there. A server this started stops once the last of those using it lets it
8961
9130
  * go; one you started yourself goes on. */
8962
- async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4 } = {}) {
9131
+ async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4, say = waiting } = {}) {
8963
9132
  const root = `http://127.0.0.1:${port}`;
8964
9133
  const opened = path === void 0 ? null : locate(path);
8965
9134
  let name = await serving(root), started = null;
@@ -8967,14 +9136,14 @@ async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4
8967
9136
  if (!opened) throw new StudyError(`nothing is serving on port ${port}: name a base`);
8968
9137
  const log = join(tmpdir(), `isorite-term-${port}.log`);
8969
9138
  const out = openSync(log, "w");
8970
- started = spawn(process.execPath, [
8971
- MAIN,
9139
+ const again = self(MAIN, [
8972
9140
  "serve",
8973
9141
  path,
8974
9142
  "--port",
8975
9143
  String(port),
8976
9144
  "--held"
8977
- ], {
9145
+ ], []);
9146
+ started = spawn(again.command, again.args, {
8978
9147
  stdio: [
8979
9148
  "ignore",
8980
9149
  out,
@@ -8985,7 +9154,12 @@ async function ensureServer(path, { port = 4767, env = process.env, wait = 12e4
8985
9154
  });
8986
9155
  const exited = new Promise((done) => started.once("exit", () => done()));
8987
9156
  const begun = Date.now();
9157
+ let told = false;
8988
9158
  while ((name = await serving(root)) === null) {
9159
+ if (!told && Date.now() - begun > SLOW) {
9160
+ say(`isorite: waiting for the isorite server; its output is in ${log}`);
9161
+ told = true;
9162
+ }
8989
9163
  if (started.exitCode !== null || Date.now() - begun > wait) {
8990
9164
  started.kill();
8991
9165
  throw new StudyError(`the isorite server did not start; its output is in ${log}`);
@@ -9038,6 +9212,25 @@ async function freePort() {
9038
9212
  });
9039
9213
  });
9040
9214
  }
9215
+ /** Whether `address` asks a browser that opens it for a password (M21): a 401 with Basic, as OpenCode 2 answers
9216
+ * for /api (its page itself is served to anyone). Asked as a browser navigates, by node:http: OpenCode leaves the
9217
+ * challenge out for its own page's fetches (Sec-Fetch-Mode: cors), which is all Node's fetch can send. */
9218
+ function asksPassword(address) {
9219
+ return new Promise((done) => {
9220
+ const req = get(address, {
9221
+ headers: {
9222
+ Accept: "text/html",
9223
+ "Sec-Fetch-Mode": "navigate",
9224
+ "Sec-Fetch-Dest": "document"
9225
+ },
9226
+ timeout: 3e3
9227
+ }, (res) => {
9228
+ res.resume();
9229
+ done(res.statusCode === 401 && /^\s*basic\b/i.test(String(res.headers["www-authenticate"] ?? "")));
9230
+ });
9231
+ req.on("timeout", () => req.destroy()).on("error", () => done(false));
9232
+ });
9233
+ }
9041
9234
  /** A POST to the isorite server, made as its page makes one: from its origin, with the token it hands out. */
9042
9235
  async function tell(url, path, body) {
9043
9236
  const token = await tokenOf(url);
@@ -9054,7 +9247,7 @@ async function tell(url, path, body) {
9054
9247
  }
9055
9248
  /** OpenCode's own server, on this machine only, with a password of its own; its output kept in a log. The terminal
9056
9249
  * attaches to it, and so does the isorite server, which shows its session in the Iso pane (M11). */
9057
- async function startOpenCode(command, { cwd, env, wait = 6e4 }) {
9250
+ async function startOpenCode(command, { cwd, env, wait = 6e4, say = waiting }) {
9058
9251
  const port = await freePort(), password = randomBytes(24).toString("base64url"), url = `http://127.0.0.1:${port}`;
9059
9252
  const log = join(tmpdir(), `isorite-opencode-${port}.log`);
9060
9253
  const out = openSync(log, "w");
@@ -9078,7 +9271,17 @@ async function startOpenCode(command, { cwd, env, wait = 6e4 }) {
9078
9271
  });
9079
9272
  const auth = { authorization: "Basic " + Buffer.from(`opencode:${password}`).toString("base64") };
9080
9273
  const started = Date.now();
9274
+ let told = 0;
9081
9275
  for (;;) {
9276
+ const waited = Date.now() - started;
9277
+ if (told === 0 && waited > SLOW) {
9278
+ say("isorite: waiting for OpenCode's server");
9279
+ told = 1;
9280
+ }
9281
+ if (told === 1 && waited > 1e4) {
9282
+ say(`isorite: OpenCode's server has not answered in 10 s (a git it runs may be waiting); its output is in ${log}`);
9283
+ told = 2;
9284
+ }
9082
9285
  if (server.exitCode !== null || Date.now() - started > wait) {
9083
9286
  server.kill();
9084
9287
  throw new StudyError(`OpenCode's server did not start; its output is in ${log}`);
@@ -9147,8 +9350,57 @@ function where(port, models, env, tty, page = "/") {
9147
9350
  async function tokenOf(url) {
9148
9351
  return (await (await fetch(`${url}/api/session`)).json()).token;
9149
9352
  }
9353
+ /** `isorite opencode` (M21): OpenCode 2, at the version isorite is tested with, in isorite's own npm prefix, the
9354
+ * global one left as it is. npm's registry, proxy and mirror settings apply. Run again, it installs it again. */
9355
+ function installOpenCode(env = process.env, say = console.log, npm = "npm") {
9356
+ const prefix = ownPrefix(env);
9357
+ say(`isorite opencode: installing ${PACKAGE$1} in ${prefix}, for isorite alone; nothing global is changed`);
9358
+ const win = process.platform === "win32";
9359
+ const done = spawnSync(npm, [
9360
+ "install",
9361
+ "--prefix",
9362
+ win ? `"${prefix}"` : prefix,
9363
+ "--no-fund",
9364
+ "--no-audit",
9365
+ "--loglevel=error",
9366
+ PACKAGE$1
9367
+ ], {
9368
+ stdio: "inherit",
9369
+ env,
9370
+ shell: win
9371
+ });
9372
+ if (done.status !== 0) {
9373
+ say(`npm could not install it${done.error ? `: ${done.error.message}` : ""}. If npm's registry or mirror is the trouble, fetch an OpenCode 2 another way and name it: ISORITE_OPENCODE=/path/to/opencode isorite term`);
9374
+ return 1;
9375
+ }
9376
+ try {
9377
+ const found = findOpenCode(env);
9378
+ say(`OpenCode ${found.version} is ${found.command}: isorite term uses it from now on`);
9379
+ return 0;
9380
+ } catch (e) {
9381
+ say(e.message);
9382
+ return 1;
9383
+ }
9384
+ }
9385
+ /** OpenCode 2, or, when there's none and a person is at the terminal, the offer to install it for isorite alone. */
9386
+ async function openCodeFor(env) {
9387
+ try {
9388
+ return findOpenCode(env);
9389
+ } catch (e) {
9390
+ if (!(e instanceof NoOpenCode) || env.ISORITE_OPENCODE || !process.stdin.isTTY || !process.stdout.isTTY) throw e;
9391
+ console.error(e.message);
9392
+ const ask = createInterface({
9393
+ input: process.stdin,
9394
+ output: process.stdout
9395
+ });
9396
+ const yes = /^y(es)?$/i.test((await ask.question(`Install OpenCode 2 for isorite only, in ${ownPrefix(env)}? [y/N] `)).trim());
9397
+ ask.close();
9398
+ if (!yes || installOpenCode(env) !== 0) throw new StudyError("isorite term needs OpenCode 2");
9399
+ return findOpenCode(env);
9400
+ }
9401
+ }
9150
9402
  async function term(path, { port = 4767, cwd = process.cwd(), env: given = process.env, wait = 12e4, onServer } = {}) {
9151
- const opencode = findOpenCode(given);
9403
+ const opencode = await openCodeFor(given);
9152
9404
  const server = await ensureServer(path, {
9153
9405
  port,
9154
9406
  env: given,
@@ -9167,7 +9419,6 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9167
9419
  }
9168
9420
  const { gateway, env } = models;
9169
9421
  const said = where(port, models, given, void 0, new URL(server.url).pathname.replace(/\/?$/, "/"));
9170
- for (const line of said) console.log(line);
9171
9422
  const agents = path === void 0 ? null : join(locate(path).notes, AGENTS_FILE);
9172
9423
  const home = writeHome(defaultHome(env), {
9173
9424
  gateway,
@@ -9176,7 +9427,8 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9176
9427
  const shared = {
9177
9428
  ...env,
9178
9429
  OPENCODE_CONFIG_DIR: home,
9179
- ISORITE_URL: server.url
9430
+ ISORITE_URL: server.url,
9431
+ GIT_TERMINAL_PROMPT: "0"
9180
9432
  };
9181
9433
  let backend;
9182
9434
  try {
@@ -9188,6 +9440,8 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9188
9440
  server.release();
9189
9441
  throw e;
9190
9442
  }
9443
+ if (await asksPassword(`${backend.url}/api/session`)) said.push(` OpenCode's own web page (port ${new URL(backend.url).port}) asks for a password: don't open it. isorite's page is above, and /web in the terminal opens it`);
9444
+ for (const line of said) console.log(line);
9191
9445
  await tell(server.url, "/api/terminal/attach", {
9192
9446
  url: backend.url,
9193
9447
  password: backend.password,
@@ -9200,7 +9454,8 @@ async function term(path, { port = 4767, cwd = process.cwd(), env: given = proce
9200
9454
  ...given,
9201
9455
  OPENCODE_CONFIG_DIR: home,
9202
9456
  ISORITE_URL: server.url,
9203
- OPENCODE_PASSWORD: backend.password
9457
+ OPENCODE_PASSWORD: backend.password,
9458
+ GIT_TERMINAL_PROMPT: "0"
9204
9459
  }
9205
9460
  });
9206
9461
  const forward = (signal) => () => child.kill(signal);
@@ -9253,6 +9508,7 @@ const READ = [
9253
9508
  "SSH_CONNECTION",
9254
9509
  "ISORITE_EMBED",
9255
9510
  "ISORITE_EMBED_DIR",
9511
+ "ISORITE_MODEL_BASE_URL",
9256
9512
  "XDG_DATA_HOME",
9257
9513
  "XDG_CACHE_HOME"
9258
9514
  ];
@@ -9299,7 +9555,10 @@ async function checkProvider(p, h, key, say) {
9299
9555
  const missing = models.filter((m) => !listed.includes(m));
9300
9556
  say("reached", "ok", `${to.url.replace(/\?.*$/, "")} answered with ${listed.length} models`);
9301
9557
  if (listed.length && missing.length) say("models", "fail", `${missing.join(", ")} not among them; it has ${listed.slice(0, 12).join(", ")}${listed.length > 12 ? ", …" : ""}. Name its models under [tiers]`);
9302
- } else say("reached", res.status === 401 || res.status === 403 ? "fail" : "ok", res.status === 401 || res.status === 403 ? `it said ${res.status} to the list of models: the key${p.keyHeader ? "" : ", or the header it goes in (key_header)"}, or a header it wants (headers), is not accepted` : `it answered (${res.status} for the list of models, which some gateways don't have)`);
9558
+ } else if (res.status === 401 || res.status === 403) {
9559
+ const said = refusal(await res.text().catch(() => ""), key);
9560
+ say("reached", "fail", `it said ${res.status} to the list of models${said ? `: “${said}”` : ""}. The key${key ? `, sent as ${keySentAs(p, key)},` : ""} or a header it wants (headers), is not accepted: check key_header and key_prefix`);
9561
+ } else say("reached", "ok", `it answered (${res.status} for the list of models, which some gateways don't have)`);
9303
9562
  } catch (e) {
9304
9563
  reached = false;
9305
9564
  say("reached", "fail", unreachable(e, p.url));
@@ -9359,7 +9618,7 @@ async function doctor(folder, o = {}) {
9359
9618
  const tls = gatewayTls(path, env);
9360
9619
  http ??= gatewayHttp(provider, tls, env);
9361
9620
  if (existsSync(path)) say("config", "ok", `${path}: ${provider.name} at ${provider.url}`);
9362
- say("key", key ? "ok" : tls ? "warn" : "fail", key ? `${hidden(key)}, sent ${provider.keyHeader ? `in ${provider.keyHeader}` : "as Authorization: Bearer"}` : tls ? "none; the client certificate alone is sent" : "none: set one in Iso's settings, or as key under [provider]");
9621
+ say("key", key ? "ok" : tls ? "warn" : "fail", key ? `sent as ${keySentAs(provider, key)}${provider.keyHeader?.toLowerCase() === "authorization" && provider.keyPrefix === void 0 ? ` (key_header = "${provider.keyHeader}" alone means Bearer since 0.9.0; key_prefix = "" sends it bare)` : ""}` : tls ? "none; the client certificate alone is sent" : "none: set one in Iso's settings, or as key under [provider]");
9363
9622
  if (tls) say("certificate", "ok", `${tls.files.join(", ")}${tls.pfx || tls.cert ? "" : " (an authority only, no client certificate)"}`);
9364
9623
  const tiers = `low ${provider.tiers.low}, mid ${provider.tiers.mid}, max ${provider.tiers.max}`;
9365
9624
  say("models", provider.defaultTiers ? "warn" : "ok", provider.defaultTiers ? `${tiers}: OpenRouter's names, kept because [tiers] names none for ${provider.name}; a gateway usually calls its models otherwise` : tiers);
@@ -9385,11 +9644,21 @@ async function doctor(folder, o = {}) {
9385
9644
  if (!o.report) out(` asking ${p.name}: its models, then ${models.length + 1} replies of a few words (a few hundred tokens in all)`);
9386
9645
  await checkProvider(p, h, key, say);
9387
9646
  }
9647
+ const began = Date.now(), git = spawnSync(o.git ?? "git", ["--version"], {
9648
+ encoding: "utf8",
9649
+ env,
9650
+ timeout: 3e3,
9651
+ killSignal: "SIGKILL"
9652
+ });
9653
+ const took = Date.now() - began;
9654
+ if (git.error && git.error.code === "ENOENT") say("git", "skip", "not found: isorite doesn't need it; OpenCode works without it, as a folder that isn't a repository");
9655
+ else if (git.status !== 0 || took >= 3e3) say("git", "warn", `git --version ${took >= 3e3 ? "didn't answer in 3 s" : `failed after ${took} ms`}: likely a wrapper (a company's) that waits on the network. isorite no longer waits on git to start; OpenCode may, and isorite term then says it's waiting for OpenCode's server`);
9656
+ else say("git", took > 1e3 ? "warn" : "ok", `${git.stdout.trim()}, in ${took} ms${took > 1e3 ? ": slow, likely a wrapper; OpenCode may wait on it as it starts" : ""}`);
9388
9657
  try {
9389
9658
  const found = findOpenCode(env, o.probe);
9390
- say("opencode", "ok", `${found.command} ${found.version}`);
9659
+ say("opencode", "ok", `${found.command} ${found.version}${found.from === "the PATH" ? "" : ` (${found.from})`}`);
9391
9660
  } catch (e) {
9392
- say("opencode", "warn", e.message.replace(/\n\s*/g, " "));
9661
+ say("opencode", e instanceof NoOpenCode && e.old ? "fail" : "warn", e.message.replace(/\n\s*/g, " "));
9393
9662
  }
9394
9663
  const port = o.port ?? 4767, url = `http://127.0.0.1:${port}`, served = await serving(url);
9395
9664
  say("port", "ok", served === null ? `${port} is free: isorite term starts a server there` : `${port} is isorite's, serving ${served}`);
@@ -9407,6 +9676,12 @@ async function doctor(folder, o = {}) {
9407
9676
  known = false;
9408
9677
  }
9409
9678
  say("terminal", known ? "ok" : "warn", terminal);
9679
+ const hint = "isorite term also runs OpenCode's own web server, on a port of its own, which asks for a password: open isorite's pages and panes (/web in the terminal), not OpenCode's";
9680
+ if (served === null) say("browser pages", "ok", hint);
9681
+ else {
9682
+ const asking = (await Promise.all([`${url}/`, `${url}/api/session`].map(async (at) => await asksPassword(at) ? at : null))).filter(Boolean);
9683
+ say("browser pages", asking.length ? "fail" : "ok", asking.length ? `${asking.join(" and ")} asked for a password (401, Basic): that is not isorite's server; stop it, or give isorite another port (--port <n>)` : `isorite's pages at ${url}/ ask for no password. ${hint}`);
9684
+ }
9410
9685
  if (env.SSH_CONNECTION) say("browser", "ok", `over SSH: on your own machine, ssh -L ${port}:localhost:${port} <this host>, then http://localhost:${port}/`);
9411
9686
  let model = null;
9412
9687
  if (env.ISORITE_EMBED === "off") say("search by meaning", "skip", "switched off (ISORITE_EMBED=off)");
@@ -9418,7 +9693,23 @@ async function doctor(folder, o = {}) {
9418
9693
  say("search by meaning", "fail", `${dir}: ${e.message}`);
9419
9694
  }
9420
9695
  if (model) say("search by meaning", "ok", `${model.name} at ${model.dir}`);
9421
- else if (!dir) say("search by meaning", "warn", `no model in ${modelPlaces(env).map((p) => p.dir).join(" or ")}: isorite model fetches it (53 MB). Search by words works without it`);
9696
+ else if (!dir) {
9697
+ let reach = "";
9698
+ if (!o.offline) {
9699
+ const from = mirrorOf(env) ?? RELEASE;
9700
+ try {
9701
+ const res = await fetch(from, {
9702
+ method: "HEAD",
9703
+ redirect: "follow",
9704
+ signal: AbortSignal.timeout(1e4)
9705
+ });
9706
+ reach = res.ok ? ` ${from} answers from here.` : ` ${from} said ${res.status}.`;
9707
+ } catch (e) {
9708
+ reach = ` ${from} can't be reached from here: ${unreachable(e, from)}.`;
9709
+ }
9710
+ }
9711
+ say("search by meaning", "warn", `no model in ${modelPlaces(env).map((p) => p.dir).join(" or ")}: isorite model fetches it (53 MB).${reach} By hand: ${mirrorOf(env) ?? RELEASE} (SHA-256 ${RELEASE_SHA256}), then isorite model --from <that file>. Search by words works without it`);
9712
+ }
9422
9713
  }
9423
9714
  if (folder) try {
9424
9715
  const { notes } = locate(folder);
@@ -9496,7 +9787,8 @@ function proxied(env, execArgv = process.execArgv) {
9496
9787
  }
9497
9788
  /** Run this command again with `env`, its input and output its own; its exit code. */
9498
9789
  function rerun(env, argv = process.argv, execArgv = process.execArgv) {
9499
- const child = spawn(process.execPath, [...execArgv, ...argv.slice(1)], {
9790
+ const again = self(argv[1], argv.slice(2), execArgv);
9791
+ const child = spawn(again.command, again.args, {
9500
9792
  stdio: "inherit",
9501
9793
  env
9502
9794
  });
@@ -9704,7 +9996,7 @@ async function mcp(path, { port = 4767, agent = "mcp", quiet = QUIET_MS, input =
9704
9996
  }
9705
9997
  throw Object.assign(/* @__PURE__ */ new Error(`no method ${rpc.method}`), { code: -32601 });
9706
9998
  };
9707
- const lines = createInterface({
9999
+ const lines = createInterface$1({
9708
10000
  input,
9709
10001
  crlfDelay: Infinity
9710
10002
  });
@@ -10941,7 +11233,7 @@ anything secret: keys, passwords, tokens.
10941
11233
  `;
10942
11234
  /** Make the memory, or say where it is: the folder (as isorite new makes it), a git repository of its own, its
10943
11235
  * practice, and its row in bases.toml, lent. */
10944
- function memory(folder = MEMORY_FOLDER, { config = configPath(), out = console.log } = {}) {
11236
+ function memory(folder = MEMORY_FOLDER, { config = configPath(), out = console.log, env = process.env } = {}) {
10945
11237
  const at = resolve(folder), known = knownBases(config);
10946
11238
  const had = known.find((b) => b.path === at) ?? (folder === MEMORY_FOLDER ? known.find((b) => b.name === "memory") : void 0);
10947
11239
  if (had && existsSync(join(had.path, "AGENTS.md"))) {
@@ -10952,15 +11244,7 @@ function memory(folder = MEMORY_FOLDER, { config = configPath(), out = console.l
10952
11244
  if (existsSync(at) && readdirSync(at).some((f) => f !== ".isorite" && f !== ".git")) throw new StudyError(`${at} has files in it already, and no ${AGENTS_FILE}: name another folder, isorite memory <folder>`);
10953
11245
  newBase(at);
10954
11246
  writeFileSync(join(at, AGENTS_FILE), MEMORY_AGENTS);
10955
- try {
10956
- if (!existsSync(join(at, ".git"))) execFileSync("git", ["init", "--quiet"], {
10957
- cwd: at,
10958
- stdio: "ignore"
10959
- });
10960
- } catch {
10961
- out(" (git is not here: the memory is not a repository yet)");
10962
- }
10963
- out(`isorite memory: made at ${at}, a private repository of its own: push it where you like`);
11247
+ out(existsSync(join(at, ".git")) || gitInit(at, { env }) ? `isorite memory: made at ${at}, a private repository of its own: push it where you like` : `isorite memory: made at ${at}; not a git repository (git did not answer, or is not here): git init it yourself`);
10964
11248
  }
10965
11249
  const name = rememberBase(at, {
10966
11250
  path: config,
@@ -11017,9 +11301,13 @@ const USAGE = `isorite: an agentic harness for understanding
11017
11301
  isorite mcp [<notes>] [--port <n>] [--agent <name>]
11018
11302
  the knowledge base's tools over MCP, on stdio, for Claude Code or Pi:
11019
11303
  claude mcp add isorite -- isorite mcp <base> --agent claude-code
11020
- isorite model [--from <folder>] the model for search by meaning: which is installed, and where; else
11021
- fetched (53 MB, once) from npm, GitHub or Hugging Face; or from a
11304
+ isorite model [--from <folder|.tgz>] [--url <url>]
11305
+ the model for search by meaning: which is installed, and where; else
11306
+ fetched (53 MB, once) from ISORITE_MODEL_BASE_URL, npm, GitHub or
11307
+ Hugging Face, or --url alone; or from the .tgz fetched by hand, or a
11022
11308
  folder: its files copied there, or any compatible model
11309
+ isorite opencode OpenCode 2 for isorite term, installed in isorite's own folder
11310
+ (~/.config/isorite/opencode-cli), the global one left as it is
11023
11311
  isorite doctor [<notes>] [--report] [--offline]
11024
11312
  whether isorite can work here: the configuration, the network, the
11025
11313
  provider (asked a few words, a few hundred tokens), the terminal and
@@ -11117,13 +11405,18 @@ function main(argv) {
11117
11405
  });
11118
11406
  }
11119
11407
  if (command === "model") {
11120
- const from = rest[rest.indexOf("--from") + 1];
11408
+ const from = rest[rest.indexOf("--from") + 1], url = rest[rest.indexOf("--url") + 1];
11121
11409
  if (rest.includes("--from") && !from) {
11122
- console.error("--from takes a folder");
11410
+ console.error("--from takes a folder, or the model's .tgz");
11411
+ return 2;
11412
+ }
11413
+ if (rest.includes("--url") && !/^https?:\/\//.test(url ?? "")) {
11414
+ console.error("--url takes the address of the model's .tgz");
11123
11415
  return 2;
11124
11416
  }
11125
- return modelCommand(rest.includes("--from") ? from : void 0);
11417
+ return modelCommand(rest.includes("--from") ? from : void 0, console.log, process.env, {}, rest.includes("--url") ? url : void 0);
11126
11418
  }
11419
+ if (command === "opencode") return installOpenCode();
11127
11420
  if (command === "memory") return memory(rest.find((a) => !a.startsWith("--")));
11128
11421
  if (command === "bases") return bases(rest);
11129
11422
  if (command === "--version" || command === "-v") {
@@ -11182,11 +11475,35 @@ function main(argv) {
11182
11475
  process.stdout.write(USAGE);
11183
11476
  return command && command !== "help" && command !== "--help" ? 2 : 0;
11184
11477
  }
11185
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) {
11478
+ /** The commands that start something, which say so at once (M21): a subprocess that stalls after this is then
11479
+ * seen to stall. To stderr, as `mcp` speaks on stdout; once, not again when the command runs itself again with
11480
+ * the proxy on (ISORITE_STARTING). */
11481
+ const STARTS = /* @__PURE__ */ new Set([
11482
+ "serve",
11483
+ "term",
11484
+ "mcp",
11485
+ "guide",
11486
+ "tutorial",
11487
+ "--tutorial"
11488
+ ]);
11489
+ function starting(argv, env = process.env, write = (s) => process.stderr.write(s)) {
11490
+ if (!STARTS.has(argv[0] ?? "") || env.ISORITE_STARTING) return false;
11491
+ write("isorite: starting…\n");
11492
+ return true;
11493
+ }
11494
+ /** The command as run: said to be starting, the proxy turned on (running itself again), the system's authorities
11495
+ * trusted; its exit code. The single executable calls this (M21). */
11496
+ async function run(argv) {
11497
+ starting(argv);
11498
+ delete process.env.ISORITE_STARTING;
11186
11499
  const env = proxied(process.env);
11187
- if (env) process.exit(await rerun(env));
11500
+ if (env) return rerun({
11501
+ ...env,
11502
+ ISORITE_STARTING: "1"
11503
+ });
11188
11504
  trustSystem();
11189
- process.exit(await main(process.argv.slice(2)));
11505
+ return main(argv);
11190
11506
  }
11507
+ if (!single() && process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) process.exit(await run(process.argv.slice(2)));
11191
11508
  //#endregion
11192
- export { check, main };
11509
+ export { check, main, run, starting };