billion-context 0.1.19 → 0.1.21

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.
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  // src/config.ts
4
4
  import { defaultConfig } from "acp-kernel";
5
- import { readFileSync } from "fs";
5
+ import { readFileSync, existsSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
6
+ import { dirname } from "path";
6
7
 
7
8
  // src/paths.ts
8
9
  import { homedir } from "os";
@@ -194,6 +195,22 @@ function loadConfigFile() {
194
195
  }
195
196
  return {};
196
197
  }
198
+ var TEMPLATE_CONFIG = `{
199
+ "providers": {
200
+ }
201
+ }`;
202
+ function ensureConfigTemplate() {
203
+ const p = configFile();
204
+ if (existsSync(p)) return false;
205
+ try {
206
+ mkdirSync2(dirname(p), { recursive: true });
207
+ writeFileSync(p, TEMPLATE_CONFIG + "\n", "utf8");
208
+ log("info", `[acp-config] created empty config at ${p} \u2014 add your providers (see README Quickstart), then restart`);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
197
214
  function parseRouteEntry(v) {
198
215
  if (typeof v === "string" && v.length > 0) {
199
216
  return { url: v.replace(/\/$/, "") };
@@ -743,11 +760,11 @@ import { createInitialState as createInitialState2 } from "acp-kernel";
743
760
 
744
761
  // src/persist.ts
745
762
  import { promises as fs } from "fs";
746
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, unlinkSync, writeFileSync } from "fs";
763
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
747
764
  import { createHash as createHash2 } from "crypto";
748
765
  import * as path3 from "path";
749
766
  import { createInitialState } from "acp-kernel";
750
- var PERSIST_VERSION = 1;
767
+ var PERSIST_VERSION = 2;
751
768
  function mergeState(parsed) {
752
769
  const fresh = createInitialState();
753
770
  return {
@@ -835,8 +852,9 @@ var SessionStore = class {
835
852
  try {
836
853
  const parsed = JSON.parse(await fs.readFile(full, "utf8"));
837
854
  if (!isValidRecord(parsed)) continue;
838
- const proto = parsed.protocol;
839
- const origin = parsed.upstreamOrigin;
855
+ const pm = parsed.meta ?? {};
856
+ const proto = pm.protocol ?? parsed.protocol;
857
+ const origin = pm.upstreamOrigin ?? parsed.upstreamOrigin;
840
858
  const expectedNamespaced = path3.basename(relPathFor(parsed.id, proto, origin));
841
859
  const expectedLegacy = legacyFileNameFor(parsed.id);
842
860
  if (name !== expectedNamespaced && name !== expectedLegacy) {
@@ -859,7 +877,7 @@ var SessionStore = class {
859
877
  const candidates = [this.filePath(id, meta?.protocol, meta?.upstreamOrigin)];
860
878
  if (meta?.protocol) candidates.push(this.filePath(id));
861
879
  for (const file of candidates) {
862
- if (!existsSync(file)) continue;
880
+ if (!existsSync2(file)) continue;
863
881
  try {
864
882
  const parsed = JSON.parse(readFileSync2(file, "utf8"));
865
883
  if (!isValidRecord(parsed) || parsed.id !== id) continue;
@@ -890,7 +908,7 @@ var SessionStore = class {
890
908
  async writeNow(session) {
891
909
  if (!this.enabled) return;
892
910
  const record = buildRecord(session);
893
- const file = this.filePath(session.id, session.protocol, session.upstreamOrigin);
911
+ const file = this.filePath(session.id, session.meta.protocol, session.meta.upstreamOrigin);
894
912
  try {
895
913
  await fs.mkdir(path3.dirname(file), { recursive: true });
896
914
  } catch (e) {
@@ -924,15 +942,15 @@ var SessionStore = class {
924
942
  this.timers.delete(session.id);
925
943
  }
926
944
  const record = buildRecord(session);
927
- const file = this.filePath(session.id, session.protocol, session.upstreamOrigin);
945
+ const file = this.filePath(session.id, session.meta.protocol, session.meta.upstreamOrigin);
928
946
  try {
929
- mkdirSync2(path3.dirname(file), { recursive: true });
947
+ mkdirSync3(path3.dirname(file), { recursive: true });
930
948
  } catch (e) {
931
949
  this.log("warn", `[persist] could not create session dir ${this.dir}: ${msg(e)}`);
932
950
  }
933
951
  const tmp = this.tempPath(session.id);
934
952
  try {
935
- writeFileSync(tmp, JSON.stringify(record), "utf8");
953
+ writeFileSync2(tmp, JSON.stringify(record), "utf8");
936
954
  renameSync2(tmp, file);
937
955
  return true;
938
956
  } catch (e) {
@@ -978,13 +996,12 @@ function buildRecord(session) {
978
996
  version: PERSIST_VERSION,
979
997
  savedAt: Date.now(),
980
998
  id: session.id,
981
- protocol: session.protocol,
982
- upstreamOrigin: session.upstreamOrigin,
983
- createdAt: session.createdAt,
984
- requests: session.requests,
985
- tokensSaved: session.tokensSaved,
999
+ meta: { ...session.meta },
1000
+ stats: { ...session.stats },
1001
+ metadata: { ...session.metadata },
986
1002
  state: session.state,
987
- blockContents: Object.fromEntries(session.blockContents)
1003
+ blockContents: Object.fromEntries(session.blockContents),
1004
+ createdAt: session.createdAt
988
1005
  };
989
1006
  }
990
1007
  function buildSession(parsed) {
@@ -992,15 +1009,29 @@ function buildSession(parsed) {
992
1009
  for (const [bid, content] of Object.entries(parsed.blockContents ?? {})) {
993
1010
  if (content && typeof content === "object") blockContents.set(bid, content);
994
1011
  }
1012
+ const meta = parsed.meta ?? {};
1013
+ const stats = parsed.stats ?? {};
995
1014
  return {
996
1015
  id: parsed.id,
997
- protocol: parsed.protocol,
998
- upstreamOrigin: parsed.upstreamOrigin,
1016
+ meta: {
1017
+ protocol: meta.protocol ?? parsed.protocol,
1018
+ upstreamOrigin: meta.upstreamOrigin ?? parsed.upstreamOrigin,
1019
+ label: meta.label ?? parsed.label,
1020
+ title: meta.title
1021
+ },
1022
+ stats: {
1023
+ requests: stats.requests ?? parsed.requests ?? 0,
1024
+ tokensSaved: stats.tokensSaved ?? parsed.tokensSaved ?? 0,
1025
+ inputTokens: stats.inputTokens ?? parsed.inputTokens ?? 0,
1026
+ cachedTokens: stats.cachedTokens ?? parsed.cachedTokens ?? 0,
1027
+ outputTokens: stats.outputTokens ?? parsed.outputTokens ?? 0,
1028
+ cacheSamples: stats.cacheSamples ?? parsed.cacheSamples ?? 0,
1029
+ contextTokens: stats.contextTokens ?? parsed.contextTokens ?? 0
1030
+ },
1031
+ metadata: parsed.metadata ?? {},
999
1032
  state: mergeState(parsed.state),
1000
1033
  createdAt: parsed.createdAt ?? Date.now(),
1001
1034
  lastSeen: Date.now(),
1002
- requests: parsed.requests ?? 0,
1003
- tokensSaved: parsed.tokensSaved ?? 0,
1004
1035
  blockContents,
1005
1036
  inFlight: 0,
1006
1037
  persisted: true
@@ -1065,8 +1096,9 @@ function getSession(id, meta) {
1065
1096
  const existing = sessions.get(id);
1066
1097
  if (existing) {
1067
1098
  existing.lastSeen = Date.now();
1068
- if (meta?.protocol && !existing.protocol) existing.protocol = meta.protocol;
1069
- if (meta?.upstreamOrigin && !existing.upstreamOrigin) existing.upstreamOrigin = meta.upstreamOrigin;
1099
+ if (meta?.protocol && !existing.meta.protocol) existing.meta.protocol = meta.protocol;
1100
+ if (meta?.upstreamOrigin && !existing.meta.upstreamOrigin) existing.meta.upstreamOrigin = meta.upstreamOrigin;
1101
+ if (meta?.label && !existing.meta.label) existing.meta.label = meta.label;
1070
1102
  return existing;
1071
1103
  }
1072
1104
  const store = getStore();
@@ -1080,13 +1112,12 @@ function getSession(id, meta) {
1080
1112
  if (sessions.size >= MAX_SESSIONS) evictOldest();
1081
1113
  const session = {
1082
1114
  id,
1083
- protocol: meta?.protocol,
1084
- upstreamOrigin: meta?.upstreamOrigin,
1115
+ meta: { protocol: meta?.protocol, upstreamOrigin: meta?.upstreamOrigin, label: meta?.label },
1116
+ stats: { requests: 0, tokensSaved: 0, inputTokens: 0, cachedTokens: 0, outputTokens: 0, cacheSamples: 0, contextTokens: 0 },
1117
+ metadata: {},
1085
1118
  state: createInitialState2(),
1086
1119
  createdAt: Date.now(),
1087
1120
  lastSeen: Date.now(),
1088
- requests: 0,
1089
- tokensSaved: 0,
1090
1121
  blockContents: /* @__PURE__ */ new Map(),
1091
1122
  inFlight: 0,
1092
1123
  persisted: false
@@ -1440,8 +1471,31 @@ function routeEvent(ev, blocks, ctx, markConverted, markRealToolUse, getConverte
1440
1471
  return emitEvent(ev);
1441
1472
  }
1442
1473
  if (t === "message_delta") {
1474
+ if (!getConverted()) {
1475
+ const u = d.usage;
1476
+ if (u) {
1477
+ const out = u.output_tokens;
1478
+ if (typeof out === "number") ctx.session.stats.outputTokens += out;
1479
+ }
1480
+ }
1443
1481
  return getConverted() ? NOOP : emitEvent(ev);
1444
1482
  }
1483
+ if (t === "message_start" && !getConverted()) {
1484
+ const u = d.message?.usage;
1485
+ if (u) {
1486
+ const inp = u.input_tokens;
1487
+ const cc = u.cache_creation_input_tokens;
1488
+ const cr = u.cache_read_input_tokens;
1489
+ if (typeof inp === "number") ctx.session.stats.inputTokens += inp;
1490
+ if (typeof cr === "number") {
1491
+ ctx.session.stats.cachedTokens += cr;
1492
+ ctx.session.stats.cacheSamples += 1;
1493
+ } else if (typeof inp === "number") {
1494
+ ctx.session.stats.cacheSamples += 1;
1495
+ }
1496
+ if (typeof cc === "number") ctx.session.stats.inputTokens += cc;
1497
+ }
1498
+ }
1445
1499
  if (t === "message_stop") {
1446
1500
  return getConverted() ? NOOP : emitEvent(ev);
1447
1501
  }
@@ -1571,6 +1625,447 @@ function rewriteJsonResponse(body, ctx) {
1571
1625
  return body;
1572
1626
  }
1573
1627
 
1628
+ // src/web.ts
1629
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
1630
+ import { dirname as dirname3, join as join2 } from "path";
1631
+ import { fileURLToPath } from "url";
1632
+ function getVersion() {
1633
+ try {
1634
+ const here = fileURLToPath(import.meta.url);
1635
+ const pkg = join2(dirname3(here), "..", "package.json");
1636
+ return JSON.parse(readFileSync3(pkg, "utf8")).version ?? "dev";
1637
+ } catch {
1638
+ return "dev";
1639
+ }
1640
+ }
1641
+ function readProviders() {
1642
+ const parsed = safeReadJson(configFile());
1643
+ const routes = {};
1644
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1645
+ const providers = parsed.providers;
1646
+ if (providers) {
1647
+ for (const [k, v] of Object.entries(providers)) {
1648
+ const route = parseRouteEntry(v);
1649
+ if (route) routes[k] = route;
1650
+ }
1651
+ }
1652
+ }
1653
+ return routes;
1654
+ }
1655
+ async function handleConfigGet(res) {
1656
+ res.writeHead(200, { "content-type": "application/json" });
1657
+ res.end(JSON.stringify({ path: configFile(), providers: readProviders() }, null, 2));
1658
+ }
1659
+ async function handleConfigPut(req, res) {
1660
+ const raw = await readJsonBody(req);
1661
+ if (!raw || typeof raw !== "object") {
1662
+ res.writeHead(400, { "content-type": "application/json" });
1663
+ res.end(JSON.stringify({ error: "expected JSON body" }));
1664
+ return;
1665
+ }
1666
+ const body = raw;
1667
+ if (!body.providers || typeof body.providers !== "object" || Array.isArray(body.providers)) {
1668
+ res.writeHead(400, { "content-type": "application/json" });
1669
+ res.end(JSON.stringify({ error: 'expected JSON: { "providers": { ... } }' }));
1670
+ return;
1671
+ }
1672
+ const routes = {};
1673
+ for (const [name, val] of Object.entries(body.providers)) {
1674
+ if (!name || typeof name !== "string") {
1675
+ res.writeHead(400, { "content-type": "application/json" });
1676
+ res.end(JSON.stringify({ error: `invalid provider name: ${JSON.stringify(name)}` }));
1677
+ return;
1678
+ }
1679
+ const route = parseRouteEntry(val);
1680
+ if (!route) {
1681
+ res.writeHead(400, { "content-type": "application/json" });
1682
+ res.end(JSON.stringify({ error: `invalid provider "${name}": expected "url" or { url, models }` }));
1683
+ return;
1684
+ }
1685
+ routes[name] = route;
1686
+ }
1687
+ const existing = safeReadJson(configFile()) ?? {};
1688
+ existing.providers = body.providers;
1689
+ try {
1690
+ mkdirSync4(dirname3(configFile()), { recursive: true });
1691
+ writeFileSync3(configFile(), JSON.stringify(existing, null, 2) + "\n", "utf8");
1692
+ } catch (e) {
1693
+ res.writeHead(500, { "content-type": "application/json" });
1694
+ res.end(JSON.stringify({ error: `failed to write: ${String(e)}` }));
1695
+ return;
1696
+ }
1697
+ log("info", `[acp-web] providers updated via web UI (${Object.keys(routes).length} providers) \u2014 restart to apply`);
1698
+ res.writeHead(200, { "content-type": "application/json" });
1699
+ res.end(JSON.stringify({ ok: true, count: Object.keys(routes).length, note: "restart bili to apply" }));
1700
+ }
1701
+ function readJsonBody(req) {
1702
+ return new Promise((resolve) => {
1703
+ const chunks = [];
1704
+ let size = 0;
1705
+ req.on("data", (c) => {
1706
+ size += c.length;
1707
+ if (size > 256 * 1024) {
1708
+ req.destroy();
1709
+ resolve(void 0);
1710
+ return;
1711
+ }
1712
+ chunks.push(c);
1713
+ });
1714
+ req.on("end", () => {
1715
+ try {
1716
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
1717
+ } catch {
1718
+ resolve(void 0);
1719
+ }
1720
+ });
1721
+ req.on("error", () => resolve(void 0));
1722
+ });
1723
+ }
1724
+ function renderUI(origin) {
1725
+ return HTML_UI.replace(/__ORIGIN__/g, origin).replace(/__VERSION__/g, getVersion());
1726
+ }
1727
+ var HTML_UI = `<!DOCTYPE html>
1728
+ <html lang="en">
1729
+ <head>
1730
+ <meta charset="utf-8">
1731
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1732
+ <title>billion-context</title>
1733
+ <style>
1734
+ :root {
1735
+ --bg: #1a1b26; --bg2: #24283b; --bg3: #2f334d;
1736
+ --fg: #c0caf5; --dim: #565f89; --accent: #7aa2f7; --accent2: #bb9af7;
1737
+ --ok: #9ece6a; --warn: #e0af68; --err: #f7768e;
1738
+ --border: #3b4261; --radius: 8px;
1739
+ }
1740
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1741
+ body {
1742
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1743
+ background: var(--bg); color: var(--fg); line-height: 1.5; min-height: 100vh;
1744
+ }
1745
+ .mono { font-family: "SF Mono", "Cascadia Code", Consolas, monospace; }
1746
+ header {
1747
+ display: flex; align-items: center; gap: 16px;
1748
+ padding: 16px 24px; border-bottom: 1px solid var(--border);
1749
+ }
1750
+ header a { color: inherit; text-decoration: none; }
1751
+ header .logo { font-size: 18px; font-weight: 600; cursor: pointer; }
1752
+ header .logo:hover { opacity: 0.85; }
1753
+ header .logo span { color: var(--accent); }
1754
+ header .meta { font-size: 12px; color: var(--dim); }
1755
+ header .meta b { color: var(--fg); }
1756
+ header .gh {
1757
+ margin-left: auto; font-size: 13px; color: var(--dim); text-decoration: none;
1758
+ display: flex; align-items: center; gap: 6px; padding: 6px 12px;
1759
+ border: 1px solid var(--border); border-radius: var(--radius); transition: color .15s, border-color .15s;
1760
+ }
1761
+ header .gh:hover { color: var(--accent); border-color: var(--accent); }
1762
+ nav { display: flex; gap: 4px; padding: 0 24px; border-bottom: 1px solid var(--border); }
1763
+ nav button {
1764
+ background: none; border: none; color: var(--dim); cursor: pointer;
1765
+ padding: 12px 16px; font-size: 14px; border-bottom: 2px solid transparent;
1766
+ font-family: inherit; transition: color .15s;
1767
+ }
1768
+ nav button:hover { color: var(--fg); }
1769
+ nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
1770
+ main { max-width: 800px; margin: 0 auto; padding: 24px; }
1771
+ .tab { display: none; }
1772
+ .tab.active { display: block; }
1773
+
1774
+ .card {
1775
+ background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
1776
+ padding: 16px; margin-bottom: 12px;
1777
+ }
1778
+ .card-head {
1779
+ display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
1780
+ }
1781
+ .card-head .name { font-weight: 600; color: var(--accent); }
1782
+ .card-head .name input { font-weight: 600; color: var(--accent); }
1783
+ input, select {
1784
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 4px;
1785
+ color: var(--fg); padding: 6px 10px; font-size: 13px; font-family: inherit; width: 100%;
1786
+ }
1787
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
1788
+ .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
1789
+ .row label { font-size: 12px; color: var(--dim); min-width: 80px; }
1790
+ .row input { flex: 1; }
1791
+ .sub-card {
1792
+ background: var(--bg3); border-radius: 4px; padding: 10px 12px; margin: 8px 0 8px 20px;
1793
+ border-left: 2px solid var(--accent2);
1794
+ }
1795
+ .model-head { display: flex; justify-content: space-between; align-items: center; }
1796
+ .model-head .mname { color: var(--accent2); font-size: 13px; font-weight: 500; }
1797
+ .model-row { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
1798
+ .model-row label { font-size: 11px; color: var(--dim); min-width: 60px; }
1799
+ .model-row input { flex: 1; font-family: "SF Mono", monospace; font-size: 12px; }
1800
+ .btn {
1801
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 4px;
1802
+ color: var(--fg); padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit;
1803
+ transition: background .15s, border-color .15s;
1804
+ }
1805
+ .btn:hover { background: var(--border); }
1806
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 500; }
1807
+ .btn.primary:hover { background: var(--accent2); border-color: var(--accent2); }
1808
+ .btn.danger { color: var(--err); }
1809
+ .btn.danger:hover { background: rgba(247,118,142,.1); }
1810
+ .btn.small { padding: 4px 8px; font-size: 12px; }
1811
+ .add-bar { display: flex; gap: 8px; margin: 12px 0; }
1812
+ .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
1813
+
1814
+ .snippet {
1815
+ background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
1816
+ padding: 12px; margin-bottom: 12px; position: relative;
1817
+ }
1818
+ .snippet .label { font-size: 12px; color: var(--dim); margin-bottom: 4px; }
1819
+ .snippet .label b { color: var(--fg); }
1820
+ .snippet code { display: block; font-size: 13px; color: var(--ok); white-space: pre-wrap; word-break: break-all; }
1821
+ .snippet .copy { position: absolute; top: 8px; right: 8px; }
1822
+
1823
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
1824
+ th { text-align: left; padding: 8px 12px; color: var(--dim); font-weight: 500; border-bottom: 1px solid var(--border); }
1825
+ td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
1826
+ td.mono { color: var(--accent); font-size: 12px; }
1827
+ .empty { text-align: center; padding: 32px; color: var(--dim); font-size: 14px; }
1828
+ .toast {
1829
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
1830
+ background: var(--ok); color: var(--bg); padding: 10px 20px; border-radius: var(--radius);
1831
+ font-size: 14px; font-weight: 500; opacity: 0; transition: opacity .3s; pointer-events: none;
1832
+ }
1833
+ .toast.show { opacity: 1; }
1834
+ .toast.err { background: var(--err); color: var(--bg); }
1835
+ .notice {
1836
+ background: rgba(224,175,104,.1); border: 1px solid var(--warn); border-radius: var(--radius);
1837
+ color: var(--warn); padding: 10px 14px; margin-bottom: 16px; font-size: 13px;
1838
+ }
1839
+ select { cursor: pointer; }
1840
+ </style>
1841
+ </head>
1842
+ <body>
1843
+ <header>
1844
+ <a class="logo" href="https://github.com/ranxianglei/billion-context" target="_blank" rel="noopener" title="GitHub repo">billion<span>-context</span></a>
1845
+ <div class="meta">v<b>__VERSION__</b> &middot; <b>__ORIGIN__</b></div>
1846
+ <a class="gh" href="https://github.com/ranxianglei/billion-context" target="_blank" rel="noopener">
1847
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
1848
+ GitHub
1849
+ </a>
1850
+ </header>
1851
+ <nav>
1852
+ <button class="active" onclick="showTab('providers')">Providers</button>
1853
+ <button onclick="showTab('setup')">Client Setup</button>
1854
+ <button onclick="showTab('sessions')">Sessions</button>
1855
+ </nav>
1856
+ <main>
1857
+ <!-- Providers tab -->
1858
+ <div id="tab-providers" class="tab active">
1859
+ <div id="restart-notice" class="notice" style="display:none"></div>
1860
+ <div id="providers-list"></div>
1861
+ <div class="add-bar">
1862
+ <button class="btn" onclick="addProvider()">+ Add provider</button>
1863
+ </div>
1864
+ <div class="actions">
1865
+ <button class="btn primary" onclick="saveProviders()">Save</button>
1866
+ </div>
1867
+ </div>
1868
+
1869
+ <!-- Client Setup tab -->
1870
+ <div id="tab-setup" class="tab">
1871
+ <div class="row" style="margin-bottom:16px">
1872
+ <label>Provider</label>
1873
+ <select id="setup-provider" onchange="renderSetup()"></select>
1874
+ </div>
1875
+ <div id="setup-snippets"></div>
1876
+ </div>
1877
+
1878
+ <!-- Sessions tab -->
1879
+ <div id="tab-sessions" class="tab">
1880
+ <div class="row" style="justify-content:space-between;margin-bottom:12px">
1881
+ <span style="font-size:13px;color:var(--dim)">Auto-refreshes every 5s</span>
1882
+ <span id="sess-total" style="font-size:13px;color:var(--dim)"></span>
1883
+ </div>
1884
+ <div id="sessions-table"></div>
1885
+ </div>
1886
+ </main>
1887
+ <div id="toast" class="toast"></div>
1888
+
1889
+ <script>
1890
+ var ORIGIN = "__ORIGIN__";
1891
+ var providers = [];
1892
+ var savedProviders = null;
1893
+
1894
+ // \u2500\u2500 helpers \u2500\u2500
1895
+ function el(id) { return document.getElementById(id); }
1896
+ function esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
1897
+ function fmtTok(n) { if (n >= 1000000) return (n/1000000).toFixed(1)+"M"; if (n >= 1000) return (n/1000).toFixed(1)+"K"; return String(n); }
1898
+ function toast(msg, isErr) {
1899
+ var t = el("toast"); t.textContent = msg; t.className = "toast show" + (isErr ? " err" : "");
1900
+ setTimeout(function(){ t.className = "toast" + (isErr ? " err" : ""); }, 2500);
1901
+ }
1902
+ function showTab(name) {
1903
+ document.querySelectorAll(".tab").forEach(function(t){ t.classList.remove("active"); });
1904
+ document.querySelectorAll("nav button").forEach(function(b){ b.classList.remove("active"); });
1905
+ el("tab-"+name).classList.add("active");
1906
+ event.target.classList.add("active");
1907
+ if (name === "sessions") refreshSessions();
1908
+ if (name === "setup") renderSetup();
1909
+ }
1910
+
1911
+ // \u2500\u2500 load \u2500\u2500
1912
+ async function load() {
1913
+ try {
1914
+ var r = await fetch("/__acp/config");
1915
+ var d = await r.json();
1916
+ providers = entries(d.providers);
1917
+ el("setup-provider").innerHTML = "";
1918
+ savedProviders = JSON.stringify(providers);
1919
+ renderProviders();
1920
+ } catch(e) {
1921
+ el("providers-list").innerHTML = '<div class="empty">Failed to load config: ' + esc(e) + "</div>";
1922
+ }
1923
+ }
1924
+ function entries(obj) {
1925
+ if (!obj || typeof obj !== "object") return [];
1926
+ return Object.keys(obj).map(function(name){
1927
+ var v = obj[name];
1928
+ var url, models = [];
1929
+ if (typeof v === "string") { url = v; }
1930
+ else { url = v.url || ""; models = entries_models(v.models); }
1931
+ return { name: name, url: url, models: models };
1932
+ });
1933
+ }
1934
+ function entries_models(obj) {
1935
+ if (!obj || typeof obj !== "object") return [];
1936
+ return Object.keys(obj).map(function(name){
1937
+ return { name: name, context: obj[name].context||0, output: obj[name].output||0 };
1938
+ });
1939
+ }
1940
+
1941
+ // \u2500\u2500 providers editor \u2500\u2500
1942
+ function renderProviders() {
1943
+ var html = "";
1944
+ if (providers.length === 0) html = '<div class="empty">No providers yet. Click "Add provider" below.</div>';
1945
+ providers.forEach(function(p, i) {
1946
+ html += '<div class="card">';
1947
+ html += '<div class="card-head"><div class="name"><input value="'+esc(p.name)+'" onchange="providers['+i+'].name=this.value" style="background:transparent;border:none;padding:0;width:auto"></div>';
1948
+ html += '<button class="btn danger small" onclick="removeProvider('+i+')">Remove</button></div>';
1949
+ html += '<div class="row"><label>URL</label><input class="mono" value="'+esc(p.url)+'" onchange="providers['+i+'].url=this.value"></div>';
1950
+ p.models.forEach(function(m, j) {
1951
+ html += '<div class="sub-card">';
1952
+ html += '<div class="model-head"><span class="mname mono">'+esc(m.name)+'</span>';
1953
+ html += '<button class="btn danger small" onclick="removeModel('+i+','+j+')">Remove</button></div>';
1954
+ html += '<div class="model-row"><label>context</label><input type="number" value="'+m.context+'" onchange="providers['+i+'].models['+j+'].context=parseInt(this.value)||0"></div>';
1955
+ html += '<div class="model-row"><label>output</label><input type="number" value="'+m.output+'" onchange="providers['+i+'].models['+j+'].output=parseInt(this.value)||0"></div>';
1956
+ html += '<div class="model-row"><label>name</label><input value="'+esc(m.name)+'" onchange="providers['+i+'].models['+j+'].name=this.value"></div>';
1957
+ html += '</div>';
1958
+ });
1959
+ html += '<button class="btn small" onclick="addModel('+i+')">+ Add model</button>';
1960
+ html += '</div>';
1961
+ });
1962
+ el("providers-list").innerHTML = html;
1963
+ checkDirty();
1964
+ }
1965
+ function addProvider() {
1966
+ providers.push({ name: "new-provider", url: "https://", models: [] });
1967
+ renderProviders();
1968
+ }
1969
+ function removeProvider(i) {
1970
+ providers.splice(i, 1);
1971
+ renderProviders();
1972
+ }
1973
+ function addModel(i) {
1974
+ providers[i].models.push({ name: "model-name", context: 200000, output: 8192 });
1975
+ renderProviders();
1976
+ }
1977
+ function removeModel(i, j) {
1978
+ providers[i].models.splice(j, 1);
1979
+ renderProviders();
1980
+ }
1981
+ function checkDirty() {
1982
+ var dirty = savedProviders !== null && JSON.stringify(providers) !== savedProviders;
1983
+ var n = el("restart-notice");
1984
+ if (dirty) { n.style.display = "block"; n.textContent = "Unsaved changes \u2014 click Save to write to the config file, then restart bili to apply."; }
1985
+ else { n.style.display = "none"; }
1986
+ }
1987
+
1988
+ // \u2500\u2500 save \u2500\u2500
1989
+ async function saveProviders() {
1990
+ var obj = {};
1991
+ providers.forEach(function(p) {
1992
+ if (!p.name) return;
1993
+ if (p.models.length === 0) { obj[p.name] = p.url; }
1994
+ else {
1995
+ var models = {};
1996
+ p.models.forEach(function(m){ if(m.name) models[m.name] = { context: m.context, output: m.output }; });
1997
+ obj[p.name] = { url: p.url, models: models };
1998
+ }
1999
+ });
2000
+ try {
2001
+ var r = await fetch("/__acp/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ providers: obj }) });
2002
+ var d = await r.json();
2003
+ if (r.ok) { savedProviders = JSON.stringify(providers); checkDirty(); toast("Saved " + d.count + " providers \u2014 restart bili to apply"); }
2004
+ else { toast("Error: " + (d.error || "unknown"), true); }
2005
+ } catch(e) { toast("Save failed: " + e, true); }
2006
+ }
2007
+
2008
+ // \u2500\u2500 client setup \u2500\u2500
2009
+ function renderSetup() {
2010
+ var sel = el("setup-provider");
2011
+ if (sel.options.length === 0 && providers.length > 0) {
2012
+ providers.forEach(function(p){ sel.options.add(new Option(p.name, p.name)); });
2013
+ }
2014
+ var name = sel.value || (providers[0] && providers[0].name) || "";
2015
+ var p = providers.find(function(x){ return x.name === name; });
2016
+ var box = el("setup-snippets");
2017
+ if (!p) { box.innerHTML = '<div class="empty">Add a provider first (Providers tab).</div>'; return; }
2018
+ var base = ORIGIN + "/" + p.name;
2019
+ // Pi: the original upstream URL minus host \u2192 keep the tail
2020
+ var tail = p.url.replace(/^https?:\\/\\/[^/]+/, "");
2021
+ var full = tail ? base + tail : base;
2022
+ var h = "";
2023
+ h += snippet("Pi (~/.pi/agent/models.json)", '"baseUrl": "' + full + '"');
2024
+ h += snippet("OpenCode (opencode.json)", '"baseURL": "' + full + '"');
2025
+ h += snippet("Codex (config.toml)", 'base_url = "' + full + '"');
2026
+ h += snippet("Full path (any client)", full);
2027
+ box.innerHTML = h;
2028
+ }
2029
+ function snippet(label, code) {
2030
+ var c = code.replace(/"/g, "&quot;");
2031
+ return '<div class="snippet"><div class="label"><b>' + label + '</b></div><code>' + esc(code) + '</code><button class="btn small copy" onclick="copyText(this,\\''+c+'\\')">Copy</button></div>';
2032
+ }
2033
+ function copyText(btn, text) {
2034
+ var t = text.replace(/&quot;/g, '"');
2035
+ navigator.clipboard.writeText(t).then(function(){ toast("Copied"); });
2036
+ }
2037
+
2038
+ // \u2500\u2500 sessions \u2500\u2500
2039
+ async function refreshSessions() {
2040
+ try {
2041
+ var r = await fetch("/__acp/stats");
2042
+ var d = await r.json();
2043
+ var ss = d.sessions || [];
2044
+ el("sess-total").textContent = ss.length + " session" + (ss.length !== 1 ? "s" : "");
2045
+ if (ss.length === 0) { el("sessions-table").innerHTML = '<div class="empty">No sessions yet. Send a request through the proxy.</div>'; return; }
2046
+ var h = '<table><tr><th>ID</th><th>Requests</th><th>Tokens saved</th><th>Last seen</th></tr>';
2047
+ var h = '<table><tr><th>Title</th><th>Protocol</th><th>Label</th><th>Requests</th><th>Context</th><th>Cache hit</th><th>Input</th><th>Output</th><th>Last seen</th></tr>';
2048
+ ss.forEach(function(s) {
2049
+ var title = s.title ? esc(s.title) : "<span class='dim'>\u2014</span>";
2050
+ var proto = s.protocol ? esc(s.protocol) : "<span class='dim'>?</span>";
2051
+ var label = s.label ? "<span class=\\"mono\\">"+esc(s.label.slice(0,24))+"</span>" : "<span class='dim'>\u2014</span>";
2052
+ var ctx = s.contextTokens ? fmtTok(s.contextTokens) : "0";
2053
+ var ch = (s.cacheHitPct !== null && s.cacheHitPct !== undefined) ? s.cacheHitPct + "%" : "<span class='dim'>\u2014</span>";
2054
+ var inp = s.inputTokens ? fmtTok(s.inputTokens) : "0";
2055
+ var out = s.outputTokens ? fmtTok(s.outputTokens) : "0";
2056
+ h += "<tr><td>"+title+"</td><td>"+proto+"</td><td>"+label+"</td><td>"+s.requests+"</td><td>"+ctx+"</td><td>"+ch+"</td><td>"+inp+"</td><td>"+out+"</td><td>"+esc(s.lastSeen)+"</td></tr>";
2057
+ });
2058
+ h += "</table>";
2059
+ el("sessions-table").innerHTML = h;
2060
+ } catch(e) { el("sessions-table").innerHTML = '<div class="empty">Failed to load: ' + esc(e) + "</div>"; }
2061
+ }
2062
+ setInterval(function(){ if (el("tab-sessions").classList.contains("active")) refreshSessions(); }, 5000);
2063
+
2064
+ load();
2065
+ </script>
2066
+ </body>
2067
+ </html>`;
2068
+
1574
2069
  // src/orphan-gc.ts
1575
2070
  var ORPHAN_THRESHOLD = 3;
1576
2071
  var orphanStreaks = /* @__PURE__ */ new WeakMap();
@@ -1608,8 +2103,8 @@ import {
1608
2103
  collectBlockContent as collectBlockContent2,
1609
2104
  deactivateBlock
1610
2105
  } from "acp-kernel";
1611
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
1612
- import { dirname as dirname2, join as join2 } from "path";
2106
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2107
+ import { dirname as dirname4, join as join3 } from "path";
1613
2108
  import { tmpdir } from "os";
1614
2109
  function resolveDecompress(args, ctx) {
1615
2110
  const rawBlockId = args.blockId;
@@ -1638,11 +2133,11 @@ function resolveDecompress(args, ctx) {
1638
2133
  }
1639
2134
  const header = `[Restored block ${blockId} \u2014 ${count} item(s)${full ? ", full" : ""}]`;
1640
2135
  const safeBlockId = blockId.replace(/[^a-zA-Z0-9_-]/g, "-");
1641
- const outPath = body.length > 1e4 ? join2(tmpdir(), `acp-decompress-${safeBlockId}-${Date.now()}.txt`) : null;
2136
+ const outPath = body.length > 1e4 ? join3(tmpdir(), `acp-decompress-${safeBlockId}-${Date.now()}.txt`) : null;
1642
2137
  if (outPath) {
1643
2138
  try {
1644
- mkdirSync3(dirname2(outPath), { recursive: true });
1645
- writeFileSync2(outPath, body, "utf8");
2139
+ mkdirSync5(dirname4(outPath), { recursive: true });
2140
+ writeFileSync4(outPath, body, "utf8");
1646
2141
  return `${header}
1647
2142
  Content (${body.length} chars) written to: ${outPath}
1648
2143
  Use the read tool to access it.`;
@@ -1925,6 +2420,10 @@ async function* compressLoopStream(initialUpstream, ctx, requestBody, requestOpt
1925
2420
  if (typeof prompt === "number") {
1926
2421
  const ch = typeof cached === "number" ? cached : 0;
1927
2422
  log("info", `[acp-usage] round ${loopCount} input=${prompt} cached=${typeof cached === "number" ? cached : "?"} output=${out ?? "?"}${ch > 0 ? ` (cache hit ${Math.round(ch / prompt * 100)}%)` : ""}`);
2423
+ ctx.session.stats.inputTokens += prompt;
2424
+ if (typeof cached === "number") ctx.session.stats.cachedTokens += cached;
2425
+ if (typeof out === "number") ctx.session.stats.outputTokens += out;
2426
+ ctx.session.stats.cacheSamples += 1;
1928
2427
  }
1929
2428
  }
1930
2429
  if (!hasOnlyProxy) {
@@ -2325,6 +2824,12 @@ async function* compressLoopResponsesStream(initialUpstream, ctx, requestBody, r
2325
2824
  const cached = inDet?.cached_tokens ?? prDet?.cached_tokens ?? "?";
2326
2825
  const out = usage.output_tokens ?? "?";
2327
2826
  log("info", `[acp-usage] round ${loopCount} input=${prompt} cached=${cached} output=${out}${cached !== "?" && cached !== 0 && prompt !== "?" ? ` (cache hit ${Math.round(Number(cached) / Number(prompt) * 100)}%)` : ""}`);
2827
+ if (typeof prompt === "number") {
2828
+ ctx.session.stats.inputTokens += prompt;
2829
+ if (typeof cached === "number") ctx.session.stats.cachedTokens += cached;
2830
+ if (typeof out === "number") ctx.session.stats.outputTokens += out;
2831
+ ctx.session.stats.cacheSamples += 1;
2832
+ }
2328
2833
  }
2329
2834
  }
2330
2835
  }
@@ -2650,9 +3155,10 @@ async function startServer(opts) {
2650
3155
  }
2651
3156
  });
2652
3157
  server.listen(opts.port, opts.host, () => {
3158
+ const displayHost = opts.host === "0.0.0.0" ? "localhost" : opts.host;
2653
3159
  log2(
2654
3160
  "info",
2655
- `acp-proxy listening on http://${opts.host}:${opts.port}` + (Object.keys(opts.routes).length ? ` \u2014 routes: ${Object.entries(opts.routes).map(([n, u]) => `${n}=${typeof u === "string" ? u : u.url}`).join(", ")}` : ` \u2192 ${opts.upstream}`)
3161
+ `acp-proxy listening on http://${displayHost}:${opts.port}` + (Object.keys(opts.routes).length ? ` \u2014 routes: ${Object.entries(opts.routes).map(([n, u]) => `${n}=${typeof u === "string" ? u : u.url}`).join(", ")}` : ` \u2192 ${opts.upstream}`) + ` \u2014 web UI: http://${displayHost}:${opts.port}/__acp/`
2656
3162
  );
2657
3163
  });
2658
3164
  server.on("error", (err) => {
@@ -2704,6 +3210,14 @@ async function handle(req, res, opts, core, config, log2) {
2704
3210
  res.end(JSON.stringify({ ok: true, upstream: opts.upstream }));
2705
3211
  return;
2706
3212
  }
3213
+ if (req.method === "GET" && req.url === "/__acp/") {
3214
+ const origin = `http://${opts.host === "0.0.0.0" ? "localhost" : opts.host}:${opts.port}`;
3215
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3216
+ res.end(renderUI(origin));
3217
+ return;
3218
+ }
3219
+ if (req.method === "GET" && req.url === "/__acp/config") return handleConfigGet(res);
3220
+ if (req.method === "PUT" && req.url === "/__acp/config") return handleConfigPut(req, res);
2707
3221
  let bodyBuffer;
2708
3222
  try {
2709
3223
  bodyBuffer = await readBody(req);
@@ -2747,8 +3261,9 @@ async function handle(req, res, opts, core, config, log2) {
2747
3261
  const sessionHeader = headerValue(req, opts.sessionHeader);
2748
3262
  const conversation = protocol === "anthropic" ? conversationSignalAnthropic(parsed, sessionHeader) : protocol === "openai" ? conversationSignalOpenai(parsed, sessionHeader) : conversationSignalResponses(parsed, sessionHeader);
2749
3263
  const sessionId = deriveSessionId(req.headers, protocol, upstreamOrigin, conversation);
2750
- const session = getSession(sessionId, { protocol, upstreamOrigin });
2751
3264
  const affinity = affinityToken(req.headers, conversation);
3265
+ const clientLabel = clientConversationHeader(req.headers);
3266
+ const session = getSession(sessionId, { protocol, upstreamOrigin, label: clientLabel ?? void 0 });
2752
3267
  await withSessionLock(session, async () => {
2753
3268
  prepared = protocol === "anthropic" ? prepareAnthropic(parsed, req, opts, core, reqConfig, log2, session) : protocol === "openai" ? prepareOpenai(parsed, req, opts, core, reqConfig, log2, session) : prepareResponses(parsed, req, opts, core, reqConfig, log2, session);
2754
3269
  acquireInFlight(session);
@@ -2794,7 +3309,7 @@ function diagNudge(turn, sessionId, tokenCount, limit) {
2794
3309
  function prepareAnthropic(parsed, req, opts, core, config, log2, session) {
2795
3310
  const sessionId = session.id;
2796
3311
  const stream2 = parsed.stream === true;
2797
- session.requests++;
3312
+ ++session.stats.requests;
2798
3313
  let processedMessages = [];
2799
3314
  let rebuiltMessages = parsed.messages;
2800
3315
  let systemOut = parsed.system;
@@ -2804,6 +3319,11 @@ function prepareAnthropic(parsed, req, opts, core, config, log2, session) {
2804
3319
  const tokenCount = estimateTokensFast3(msgs.map((m) => m.text ?? "").join("\n"));
2805
3320
  const turn = core.processTurn({ messages: msgs, state: session.state, config, tokenCount, renderTags: "text-only" });
2806
3321
  session.state = turn.state;
3322
+ session.stats.contextTokens = tokenCount;
3323
+ if (!session.meta.title) {
3324
+ const t = deriveTitle(msgs);
3325
+ if (t) session.meta.title = t;
3326
+ }
2807
3327
  log2("info", diagTagSummary(turn.messages, sessionId, "text-only"));
2808
3328
  log2("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit));
2809
3329
  processedMessages = turn.messages;
@@ -2833,7 +3353,7 @@ function prepareAnthropic(parsed, req, opts, core, config, log2, session) {
2833
3353
  function prepareOpenai(parsed, req, opts, core, config, log2, session) {
2834
3354
  const sessionId = session.id;
2835
3355
  const stream2 = parsed.stream === true;
2836
- session.requests++;
3356
+ ++session.stats.requests;
2837
3357
  let processedMessages = [];
2838
3358
  let rebuiltMessages = parsed.messages;
2839
3359
  let toolsOut = parsed.tools;
@@ -2845,6 +3365,11 @@ function prepareOpenai(parsed, req, opts, core, config, log2, session) {
2845
3365
  const tokenCount = estimateTokensFast3(msgs.map((m) => m.text ?? "").join("\n"));
2846
3366
  const turn = core.processTurn({ messages: msgs, state: session.state, config, tokenCount, renderTags: "text-only" });
2847
3367
  session.state = turn.state;
3368
+ session.stats.contextTokens = tokenCount;
3369
+ if (!session.meta.title) {
3370
+ const t = deriveTitle(msgs);
3371
+ if (t) session.meta.title = t;
3372
+ }
2848
3373
  log2("info", diagTagSummary(turn.messages, sessionId, "text-only"));
2849
3374
  log2("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit));
2850
3375
  processedMessages = turn.messages;
@@ -2876,7 +3401,7 @@ function prepareOpenai(parsed, req, opts, core, config, log2, session) {
2876
3401
  function prepareResponses(parsed, req, opts, core, config, log2, session) {
2877
3402
  const sessionId = session.id;
2878
3403
  const stream2 = parsed.stream === true;
2879
- session.requests++;
3404
+ ++session.stats.requests;
2880
3405
  let processedMessages = [];
2881
3406
  let rebuiltInput = parsed.input;
2882
3407
  let toolsOut = parsed.tools;
@@ -2889,6 +3414,11 @@ function prepareResponses(parsed, req, opts, core, config, log2, session) {
2889
3414
  const tokenCount = estimateTokensFast3(msgs.map((m) => m.text ?? "").join("\n"));
2890
3415
  const turn = core.processTurn({ messages: msgs, state: session.state, config, tokenCount, renderTags: process.env.ACP_RENDER_NONE ? "none" : "text-only" });
2891
3416
  session.state = turn.state;
3417
+ session.stats.contextTokens = tokenCount;
3418
+ if (!session.meta.title) {
3419
+ const t = deriveTitle(msgs);
3420
+ if (t) session.meta.title = t;
3421
+ }
2892
3422
  log2("info", diagTagSummary(turn.messages, sessionId, "text-only"));
2893
3423
  log2("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit));
2894
3424
  processedMessages = turn.messages;
@@ -3161,11 +3691,11 @@ async function pipeThrough(stream2, res) {
3161
3691
  }
3162
3692
  }
3163
3693
  async function dumpStreamToFile(stream2, dir, name) {
3164
- const { mkdirSync: mkdirSync4, createWriteStream: createWriteStream2 } = await import("fs");
3165
- const { join: join3 } = await import("path");
3694
+ const { mkdirSync: mkdirSync6, createWriteStream: createWriteStream2 } = await import("fs");
3695
+ const { join: join4 } = await import("path");
3166
3696
  try {
3167
- mkdirSync4(dir, { recursive: true });
3168
- const ws = createWriteStream2(join3(dir, name));
3697
+ mkdirSync6(dir, { recursive: true });
3698
+ const ws = createWriteStream2(join4(dir, name));
3169
3699
  const reader = stream2.getReader();
3170
3700
  try {
3171
3701
  for (; ; ) {
@@ -3180,11 +3710,28 @@ async function dumpStreamToFile(stream2, dir, name) {
3180
3710
  } catch {
3181
3711
  }
3182
3712
  }
3713
+ function deriveTitle(messages) {
3714
+ for (const m of messages) {
3715
+ if (m.role !== "user" || m.contentType !== "text") continue;
3716
+ const clean = (m.text ?? "").replace(/\s+/g, " ").trim();
3717
+ if (clean) return clean.length > 60 ? clean.slice(0, 57) + "\u2026" : clean;
3718
+ }
3719
+ return void 0;
3720
+ }
3183
3721
  function sendStats(res) {
3184
3722
  const sessions2 = listSessions().map((s) => ({
3185
3723
  id: s.id,
3186
- requests: s.requests,
3187
- tokensSaved: s.tokensSaved,
3724
+ protocol: s.meta.protocol,
3725
+ upstream: s.meta.upstreamOrigin,
3726
+ label: s.meta.label,
3727
+ title: s.meta.title,
3728
+ requests: s.stats.requests,
3729
+ contextTokens: s.stats.contextTokens,
3730
+ inputTokens: s.stats.inputTokens,
3731
+ cachedTokens: s.stats.cachedTokens,
3732
+ outputTokens: s.stats.outputTokens,
3733
+ cacheSamples: s.stats.cacheSamples,
3734
+ cacheHitPct: s.stats.cacheSamples > 0 ? Math.round(s.stats.cachedTokens / s.stats.inputTokens * 100) : null,
3188
3735
  lastSeen: new Date(s.lastSeen).toISOString()
3189
3736
  }));
3190
3737
  res.writeHead(200, { "content-type": "application/json" });
@@ -3237,7 +3784,7 @@ function logMsg(opts, level, msg2) {
3237
3784
  import { readFile, writeFile, mkdir, access, constants, rm } from "fs/promises";
3238
3785
  import { execFile } from "child_process";
3239
3786
  import path4 from "path";
3240
- import { fileURLToPath } from "url";
3787
+ import { fileURLToPath as fileURLToPath2 } from "url";
3241
3788
  var REGISTRY_BASE = "https://registry.npmjs.org";
3242
3789
  var CHECK_INTERVAL_MS = 3 * 60 * 1e3;
3243
3790
  var THROTTLE_FILE = path4.join(cacheDir(), ".update-check");
@@ -3276,7 +3823,7 @@ async function writeLastCheck(ts) {
3276
3823
  }
3277
3824
  }
3278
3825
  async function findInstallDir(packageName) {
3279
- let dir = path4.dirname(fileURLToPath(import.meta.url));
3826
+ let dir = path4.dirname(fileURLToPath2(import.meta.url));
3280
3827
  for (; ; ) {
3281
3828
  try {
3282
3829
  const pkg = JSON.parse(await readFile(path4.join(dir, "package.json"), "utf-8"));
@@ -3513,23 +4060,23 @@ function startAutoUpdate(opts) {
3513
4060
  }
3514
4061
 
3515
4062
  // src/cli.ts
3516
- import { readFileSync as readFileSync3 } from "fs";
3517
- import { fileURLToPath as fileURLToPath2 } from "url";
4063
+ import { readFileSync as readFileSync4 } from "fs";
4064
+ import { fileURLToPath as fileURLToPath3 } from "url";
3518
4065
  import path5 from "path";
3519
4066
  var VERSION = (() => {
3520
4067
  try {
3521
- const here = fileURLToPath2(import.meta.url);
4068
+ const here = fileURLToPath3(import.meta.url);
3522
4069
  const pkg = path5.join(path5.dirname(here), "..", "package.json");
3523
- return JSON.parse(readFileSync3(pkg, "utf8")).version ?? "dev";
4070
+ return JSON.parse(readFileSync4(pkg, "utf8")).version ?? "dev";
3524
4071
  } catch {
3525
4072
  return "dev";
3526
4073
  }
3527
4074
  })();
3528
4075
  var PACKAGE_NAME = (() => {
3529
4076
  try {
3530
- const here = fileURLToPath2(import.meta.url);
4077
+ const here = fileURLToPath3(import.meta.url);
3531
4078
  const pkg = path5.join(path5.dirname(here), "..", "package.json");
3532
- return JSON.parse(readFileSync3(pkg, "utf8")).name ?? "billion-context";
4079
+ return JSON.parse(readFileSync4(pkg, "utf8")).name ?? "billion-context";
3533
4080
  } catch {
3534
4081
  return "billion-context";
3535
4082
  }
@@ -3641,6 +4188,7 @@ async function main() {
3641
4188
  for (const [k, v] of Object.entries(overrides)) {
3642
4189
  if (v !== void 0) process.env[k] = v;
3643
4190
  }
4191
+ ensureConfigTemplate();
3644
4192
  const opts = loadOptions();
3645
4193
  await startServer(opts);
3646
4194
  if (opts.autoUpdate) {