pi-webfind 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/net.ts ADDED
@@ -0,0 +1,93 @@
1
+ // Host-level state shared by engines and the fetcher: per-host cooldowns
2
+ // (HTTP 429), the process-wide offline flag (ENOTFOUND/ENETUNREACH), and the
3
+ // single rate-limit gap for the r.jina.ai relay.
4
+
5
+ const cooldowns = new Map<string, number>();
6
+
7
+ /** Put `host` on cooldown for `ms` milliseconds (e.g. after an HTTP 429). */
8
+ export function setHostCooldown(host: string, ms: number): void {
9
+ cooldowns.set(host, Date.now() + ms);
10
+ }
11
+
12
+ /** Epoch ms until which `host` is cooling down (0 = not cooling). */
13
+ export function hostCooldownUntil(host: string): number {
14
+ return cooldowns.get(host) ?? 0;
15
+ }
16
+
17
+ // ------------------------------------------------------------- offline flag
18
+
19
+ const OFFLINE_MS = 10_000;
20
+ let offlineUntil = 0;
21
+ let offlineCode = "";
22
+
23
+ /**
24
+ * Mark the network as offline for 10 s. Called on ENOTFOUND/ENETUNREACH from
25
+ * the search hosts (a typo'd target host must not black out the network —
26
+ * `markOfflineIfSearchHost` guards the single-host case).
27
+ */
28
+ export function markOffline(code: string): void {
29
+ offlineUntil = Date.now() + OFFLINE_MS;
30
+ offlineCode = code;
31
+ }
32
+
33
+ /** Any successful/answered request clears the flag. */
34
+ export function markOnline(): void {
35
+ offlineUntil = 0;
36
+ }
37
+
38
+ /** Throws while the offline flag is set (called at the top of every network fn). */
39
+ export function assertOnline(): void {
40
+ if (Date.now() < offlineUntil) throw new Error(`network unavailable (${offlineCode})`);
41
+ }
42
+
43
+ /** True while the offline flag is set (tools surface "Network appears offline"). */
44
+ export function isOffline(): boolean {
45
+ return Date.now() < offlineUntil;
46
+ }
47
+
48
+ /**
49
+ * ENOTFOUND from a single non-search host is usually a typo'd URL, not an
50
+ * outage — only search/archive infrastructure trips the process-wide flag on
51
+ * its own (or any two distinct hosts failing within 2 s).
52
+ */
53
+ const SEARCH_HOSTS = /^(html|lite)\.duckduckgo\.com$|^(www\.)?bing\.com$|^search\.brave\.com$|^archive\.org$|^r\.jina\.ai$/;
54
+ const recentNotFound: Array<{ host: string; at: number }> = [];
55
+
56
+ export function noteNotFound(host: string): void {
57
+ if (SEARCH_HOSTS.test(host)) {
58
+ markOffline("ENOTFOUND");
59
+ return;
60
+ }
61
+ const now = Date.now();
62
+ recentNotFound.push({ host, at: now });
63
+ while (recentNotFound.length > 0 && now - recentNotFound[0]!.at > 2_000) recentNotFound.shift();
64
+ const distinct = new Set(recentNotFound.map((r) => r.host));
65
+ if (distinct.size >= 2) markOffline("ENOTFOUND");
66
+ }
67
+
68
+ /** Wait so that consecutive r.jina.ai requests stay ≥ `JINA_RATE_MS` apart. Single shared gap. */
69
+ let lastJinaCall = 0;
70
+ /** With a free JINA_API_KEY the reader relay allows ~200 rpm instead of ~20 — throttle accordingly. */
71
+ export const JINA_RATE_MS = process.env.JINA_API_KEY ? 300 : 3_500;
72
+ export async function jinaGap(signal?: AbortSignal): Promise<void> {
73
+ const wait = lastJinaCall + JINA_RATE_MS - Date.now();
74
+ if (wait > 0) await sleep(wait, signal);
75
+ lastJinaCall = Date.now();
76
+ }
77
+
78
+ /** Authorization header for r.jina.ai when JINA_API_KEY is set (raises the rate cap). */
79
+ export function jinaAuth(): Record<string, string> {
80
+ const key = process.env.JINA_API_KEY;
81
+ return key ? { Authorization: `Bearer ${key}` } : {};
82
+ }
83
+
84
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
85
+ return new Promise((resolve) => {
86
+ if (signal?.aborted) return resolve();
87
+ const t = setTimeout(resolve, ms);
88
+ signal?.addEventListener("abort", () => {
89
+ clearTimeout(t);
90
+ resolve();
91
+ }, { once: true });
92
+ });
93
+ }
package/lib/rank.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * Zero dependencies, no model calls.
7
7
  */
8
8
 
9
- const WORD_RE = /[a-z0-9_#+.-]+/g;
9
+ const WORD_RE = /[\p{L}\p{N}_#+-]+(?:\.[\p{L}\p{N}_#+-]+)*/gu;
10
+ const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
10
11
 
11
12
  function stem(t: string): string {
12
13
  if (t.length > 4 && t.endsWith("ing")) return t.slice(0, -3);
@@ -15,8 +16,26 @@ function stem(t: string): string {
15
16
  return t;
16
17
  }
17
18
 
18
- function tokenize(s: string): string[] {
19
- return (s.toLowerCase().match(WORD_RE) ?? []).map(stem).filter((t) => t.length > 0);
19
+ export function tokenize(s: string): string[] {
20
+ const out: string[] = [];
21
+ for (const m of s.toLowerCase().matchAll(WORD_RE)) {
22
+ const w = m[0];
23
+ if (CJK_RE.test(w)) {
24
+ CJK_RE.lastIndex = 0;
25
+ for (const run of w.match(CJK_RE) ?? []) {
26
+ if (run.length === 1) out.push(run);
27
+ for (let i = 0; i + 1 < run.length; i++) out.push(run.slice(i, i + 2));
28
+ }
29
+ for (const r of w.replace(CJK_RE, " ").trim().split(/\s+/)) if (r) out.push(stem(r));
30
+ continue;
31
+ }
32
+ out.push(stem(w));
33
+ // dotted identifiers: also emit the last segment (react.useEffect -> useeffect)
34
+ const dot = w.lastIndexOf(".");
35
+ const tail = dot > 0 ? w.slice(dot + 1) : "";
36
+ if (tail && /\p{L}/u.test(tail)) out.push(stem(tail));
37
+ }
38
+ return out;
20
39
  }
21
40
 
22
41
  export interface Passage {
@@ -25,6 +44,8 @@ export interface Passage {
25
44
  heading: string;
26
45
  /** index in document order */
27
46
  pos: number;
47
+ /** "code" = a whole fenced block (or chunk of one); exempt from MIN_PASSAGE */
48
+ kind: "prose" | "code";
28
49
  }
29
50
 
30
51
  const MIN_PASSAGE = 24;
@@ -35,7 +56,23 @@ export function splitPassages(text: string): Passage[] {
35
56
  const lines = text.split("\n");
36
57
  let heading = "";
37
58
  let buf: string[] = [];
59
+ const FENCE_RE = /^\s*(```|~~~)/;
60
+ let fence: string[] | null = null;
38
61
  for (const line of lines) {
62
+ if (fence) {
63
+ fence.push(line);
64
+ if (FENCE_RE.test(line)) {
65
+ pushFence(fence);
66
+ fence = null;
67
+ }
68
+ continue;
69
+ }
70
+ if (FENCE_RE.test(line)) {
71
+ push(buf.join("\n"));
72
+ buf = [];
73
+ fence = [line];
74
+ continue;
75
+ }
39
76
  if (line.trim() === "") {
40
77
  push(buf.join("\n"));
41
78
  buf = [];
@@ -50,28 +87,50 @@ export function splitPassages(text: string): Passage[] {
50
87
  }
51
88
  buf.push(line);
52
89
  }
90
+ if (fence) pushFence([...fence, fence[0].match(FENCE_RE)![1]!]); // unterminated fence at EOF
53
91
  push(buf.join("\n").trim());
54
92
  return passages;
55
93
 
94
+ /** Whole fenced block as one code passage (or ~900-char line-chunks when huge). */
95
+ function pushFence(fl: string[]) {
96
+ const opener = fl[0]!, closer = fl[fl.length - 1]!, body = fl.slice(1, -1);
97
+ if (body.join("\n").trim().length === 0) return;
98
+ const emit = (chunk: string[]) =>
99
+ passages.push({ text: [opener, ...chunk, closer].join("\n"), heading, pos: passages.length, kind: "code" });
100
+ if (body.join("\n").length <= 1200) return emit(body);
101
+ let chunk: string[] = [];
102
+ let size = 0;
103
+ for (const l of body) {
104
+ if (size + l.length > 900 && chunk.length) {
105
+ emit(chunk);
106
+ chunk = [];
107
+ size = 0;
108
+ }
109
+ chunk.push(l);
110
+ size += l.length + 1;
111
+ }
112
+ if (chunk.length) emit(chunk);
113
+ }
114
+
56
115
  /** Long blobs (infoboxes, template junk, minified docs) split on sentence boundaries so scoring can discriminate. */
57
116
  function push(raw: string) {
58
117
  const t = raw.trim();
59
118
  if (t.length < MIN_PASSAGE) return;
60
119
  if (t.length <= 1200) {
61
- passages.push({ text: t, heading, pos: passages.length });
120
+ passages.push({ text: t, heading, pos: passages.length, kind: "prose" });
62
121
  return;
63
122
  }
64
123
  const sentences = t.match(/[^.!?\n]+[.!?]?\s*/g) ?? [t];
65
124
  let cur = "";
66
125
  for (const sen of sentences) {
67
126
  if (cur.length + sen.length > 900 && cur.length >= MIN_PASSAGE) {
68
- passages.push({ text: cur.trim(), heading, pos: passages.length });
127
+ passages.push({ text: cur.trim(), heading, pos: passages.length, kind: "prose" });
69
128
  cur = sen;
70
129
  } else {
71
130
  cur += sen;
72
131
  }
73
132
  }
74
- if (cur.trim().length >= MIN_PASSAGE) passages.push({ text: cur.trim(), heading, pos: passages.length });
133
+ if (cur.trim().length >= MIN_PASSAGE) passages.push({ text: cur.trim(), heading, pos: passages.length, kind: "prose" });
75
134
  }
76
135
  }
77
136
 
@@ -89,28 +148,33 @@ export function scorePassages(passages: Passage[], query: string): Array<{ p: Pa
89
148
  const qTokens = [...new Set(tokenize(query))];
90
149
  if (qTokens.length === 0) return passages.map((p) => ({ p, score: 0 }));
91
150
 
92
- const tokenSets: Array<Set<string>> = passages.map((p) => new Set(tokenize(p.text)));
151
+ const tfs: Array<Map<string, number>> = passages.map((p) => termFreq(tokenize(p.text)));
93
152
  const N = passages.length;
94
153
  const avgLen = passages.reduce((a, p) => a + p.text.length, 0) / Math.max(N, 1);
95
154
  const k1 = 1.5;
96
155
  const b = 0.75;
97
156
  const wantCode = queryCodeish(query);
157
+ // document frequency per query token, computed once
158
+ const df = new Map<string, number>(qTokens.map((q) => [q, tfs.reduce((n, m) => n + (m.has(q) ? 1 : 0), 0)]));
159
+ // phrase bonus: multi-token query occurring verbatim in the passage
160
+ const phrase = query.toLowerCase().replace(/\s+/g, " ").trim();
98
161
 
99
162
  return passages.map((p, i) => {
100
- const tokens = tokenSets[i];
163
+ const tf = tfs[i];
101
164
  const len = p.text.length;
102
165
  // junk penalty: template/URL soup — real prose has few of these per 100 chars
103
166
  const junk = (p.text.match(/\{\{|\}\}|\[\[|\]\]|https?:\/\//g) ?? []).length;
104
167
  const junkDensity = junk / Math.max(len / 100, 1);
105
168
  let score = 0;
106
169
  for (const q of qTokens) {
107
- if (!tokens.has(q)) continue;
108
- let dfq = 0;
109
- for (const ts of tokenSets) if (ts.has(q)) dfq++;
170
+ const n = tf.get(q) ?? 0;
171
+ if (!n) continue;
172
+ const dfq = df.get(q)!;
110
173
  const idf = Math.log(1 + (N - dfq + 0.5) / (dfq + 0.5));
111
- score += (idf * 1 * (k1 + 1)) / (1 + k1 * (1 - b + b * (len / avgLen)));
174
+ score += (idf * n * (k1 + 1)) / (n + k1 * (1 - b + b * (len / avgLen)));
112
175
  }
113
176
  if (p.heading && qTokens.some((q) => p.heading.toLowerCase().includes(q))) score *= 1.5;
177
+ if (qTokens.length >= 2 && phrase.length >= 6 && p.text.toLowerCase().replace(/\s+/g, " ").includes(phrase)) score *= 1.5;
114
178
  if (wantCode && /```|\t|=>|function |const |def |class /.test(p.text)) score *= 1.2;
115
179
  if (junkDensity > 1) score *= 0.3;
116
180
  else if (junkDensity > 0.4) score *= 0.6;
@@ -118,6 +182,12 @@ export function scorePassages(passages: Passage[], query: string): Array<{ p: Pa
118
182
  });
119
183
  }
120
184
 
185
+ export function termFreq(tokens: string[]): Map<string, number> {
186
+ const m = new Map<string, number>();
187
+ for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
188
+ return m;
189
+ }
190
+
121
191
  export interface PickedPassage {
122
192
  heading: string;
123
193
  text: string;
@@ -128,17 +198,20 @@ export interface PickedPassage {
128
198
  * Pick the passages worth showing for a query within a char budget:
129
199
  * document intro first, then top-scoring passages in original order.
130
200
  * `total` = passages in the full document (for the "N of M shown" footer).
201
+ * `passages` = the picked set incl. scores — lets callers re-rank (e.g. deep
202
+ * mode picks the single best-scoring passage instead of the intro).
131
203
  */
132
204
  export function topPassages(
133
205
  text: string,
134
206
  query: string,
135
207
  budgetChars = 6000,
136
208
  introChars = 600,
137
- ): { picked: PickedPassage[]; total: number } {
209
+ ): { picked: PickedPassage[]; total: number; passages: PickedPassage[] } {
138
210
  const all = splitPassages(text);
139
- if (all.length === 0) return { picked: [], total: 0 };
211
+ if (all.length === 0) return { picked: [], total: 0, passages: [] };
140
212
  if (!query.trim()) {
141
- return { picked: [{ heading: all[0].heading, text: text.slice(0, budgetChars), score: 0 }], total: all.length };
213
+ const introOnly = { heading: all[0].heading, text: text.slice(0, budgetChars), score: 0 };
214
+ return { picked: [introOnly], total: all.length, passages: [introOnly] };
142
215
  }
143
216
 
144
217
  const scored = scorePassages(all, query);
@@ -165,5 +238,5 @@ export function topPassages(
165
238
  .sort((a, b) => a[0] - b[0])
166
239
  .map(([, v]) => v);
167
240
  picked.unshift({ heading: intro.heading, text: intro.text.slice(0, introChars), score: 0 });
168
- return { picked, total: all.length };
241
+ return { picked, total: all.length, passages: picked };
169
242
  }
package/lib/safe.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SSRF-safe URL validation: protocol/credential/name checks plus DNS
3
+ * resolution — a public-looking name that resolves to a private address is
4
+ * rejected (nip.io / lvh.me style bypasses), and redirect hops are re-checked
5
+ * by the fetcher before each new connection.
6
+ *
7
+ * Residual risk (documented, deferred to WP-10): the address is validated at
8
+ * lookup time, not pinned into the TCP connection, so a DNS-rebinding race
9
+ * between resolveSafe() and connect() remains theoretically open. Node's
10
+ * global fetch exposes no supported way to inject a custom dns.lookup.
11
+ */
12
+ import { BlockList, isIP } from "node:net";
13
+ import { lookup as dnsLookup } from "node:dns/promises";
14
+
15
+ export type Lookup = (host: string) => Promise<Array<{ address: string; family: number }>>;
16
+
17
+ /** Test seam: tests replace `lookup` with a table; production leaves it alone. */
18
+ export const safeConfig: { lookup: Lookup } = { lookup: (h) => dnsLookup(h, { all: true, verbatim: true }) };
19
+
20
+ const PRIVATE = new BlockList();
21
+ for (const [net, bits] of [
22
+ ["127.0.0.0", 8],
23
+ ["10.0.0.0", 8],
24
+ ["172.16.0.0", 12],
25
+ ["192.168.0.0", 16],
26
+ ["169.254.0.0", 16],
27
+ ["100.64.0.0", 10],
28
+ ["0.0.0.0", 8],
29
+ ] as const)
30
+ PRIVATE.addSubnet(net, bits, "ipv4");
31
+ for (const [net, bits] of [
32
+ ["::1", 128],
33
+ ["::", 128],
34
+ ["fc00::", 7],
35
+ ["fe80::", 10],
36
+ ["64:ff9b::", 96],
37
+ ] as const)
38
+ PRIVATE.addSubnet(net, bits, "ipv6");
39
+ // NOTE: deliberately NOT adding ::ffff:0:0/96 — Node compares v4-mapped IPv6
40
+ // against the v4 rules above (8.8.8.8 stays allowed, ::ffff:127.0.0.1 blocked).
41
+
42
+ const BLOCKED_NAME = /^(localhost|localhost\.localdomain)$|\.(local|internal|localhost|home\.arpa)$/i;
43
+
44
+ export function isPrivateAddress(addr: string): boolean {
45
+ const fam = isIP(addr);
46
+ if (fam === 0) return true; // unparseable addresses fail closed
47
+ return PRIVATE.check(addr, fam === 6 ? "ipv6" : "ipv4");
48
+ }
49
+
50
+ /** Protocol, credentials, name and *resolved address* check. Throws `Blocked …`; returns the parsed URL. */
51
+ export async function resolveSafe(input: string | URL, lookup: Lookup = safeConfig.lookup): Promise<URL> {
52
+ let url: URL;
53
+ try {
54
+ url = typeof input === "string" ? new URL(input) : input;
55
+ } catch {
56
+ throw new Error(`Invalid URL: ${String(input)}`);
57
+ }
58
+ if (!/^https?:$/.test(url.protocol)) throw new Error(`Blocked protocol: ${url.protocol} (use http/https)`);
59
+ if (url.username || url.password) throw new Error("Blocked URL: credentials in URL");
60
+ const host = url.hostname.replace(/\.$/, "").toLowerCase();
61
+ const literal = host.startsWith("[") ? host.slice(1, -1) : host;
62
+ if (BLOCKED_NAME.test(host)) throw new Error(`Blocked host (SSRF protection): ${host}`);
63
+ if (isIP(literal)) {
64
+ if (isPrivateAddress(literal)) throw new Error(`Blocked host (SSRF protection): ${host}`);
65
+ return url;
66
+ }
67
+ let addrs: Array<{ address: string; family: number }>;
68
+ try {
69
+ addrs = await lookup(host);
70
+ } catch (e) {
71
+ throw new Error(`DNS lookup failed for ${host} (${(e as NodeJS.ErrnoException)?.code ?? "unknown"})`);
72
+ }
73
+ if (addrs.length === 0) throw new Error(`DNS lookup failed for ${host} (no addresses)`);
74
+ const bad = addrs.find((a) => isPrivateAddress(a.address));
75
+ if (bad) throw new Error(`Blocked host (SSRF protection): ${host} resolves to ${bad.address}`);
76
+ return url;
77
+ }
78
+
79
+ /** Synchronous subset (protocol, credentials, blocked names, literal IPs) — used for cache keys and adapter routing. */
80
+ export function assertSafeUrl(rawUrl: string): URL {
81
+ let url: URL;
82
+ try {
83
+ url = new URL(rawUrl);
84
+ } catch {
85
+ throw new Error(`Invalid URL: ${rawUrl}`);
86
+ }
87
+ if (!/^https?:$/.test(url.protocol)) throw new Error(`Blocked protocol: ${url.protocol} (use http/https)`);
88
+ if (url.username || url.password) throw new Error("Blocked URL: credentials in URL");
89
+ const host = url.hostname.replace(/\.$/, "").toLowerCase();
90
+ const literal = host.startsWith("[") ? host.slice(1, -1) : host;
91
+ if (BLOCKED_NAME.test(host)) throw new Error(`Blocked host (SSRF protection): ${host}`);
92
+ if (isIP(literal) && isPrivateAddress(literal)) throw new Error(`Blocked host (SSRF protection): ${host}`);
93
+ return url;
94
+ }
package/lib/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // Single source of truth for the tool's version, used in UA strings.
2
2
  // Kept in sync with package.json manually on release (release checklist item).
3
- export const VERSION = "0.5.2";
3
+ export const VERSION = "0.6.0";
4
4
  export const TOOL_UA = `pi-webfind/${VERSION} (free web research toolkit for pi coding agent; +https://github.com/jawwadzafar/pi-webfind)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-webfind",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code-style web research for the pi coding agent \u2014 WebSearch, query-aware fetch (PDFs, Wayback, site adapters, markdown extraction), Stack Overflow, GitHub, HN, Wikipedia, npm. 100% free, no API keys.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -20,37 +20,41 @@
20
20
  "type": "git",
21
21
  "url": "git+https://github.com/jawwadzafar/pi-webfind.git"
22
22
  },
23
- "pi": {
24
- "extensions": [
25
- "./extensions"
26
- ],
27
- "image": "https://jawwadzafar.github.io/pi-webfind/screenshot.png",
28
- "video": "https://jawwadzafar.github.io/pi-webfind/demo.mp4"
23
+ "scripts": {
24
+ "test": "node --test tests/*.test.ts",
25
+ "typecheck": "tsc --noEmit",
26
+ "docs:dev": "vitepress dev docs",
27
+ "docs:build": "vitepress build docs",
28
+ "docs:preview": "vitepress preview docs"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@earendil-works/pi-coding-agent": "*",
32
32
  "typebox": "*"
33
33
  },
34
+ "devDependencies": {
35
+ "@earendil-works/pi-coding-agent": "0.85.0",
36
+ "@earendil-works/pi-tui": "0.85.0",
37
+ "typebox": "1.3.7",
38
+ "vitepress": "^1.6.3"
39
+ },
34
40
  "files": [
35
41
  "extensions/",
36
42
  "lib/",
37
43
  "README.md",
38
- "LICENSE"
44
+ "LICENSE",
45
+ "themes/"
39
46
  ],
40
47
  "engines": {
41
48
  "node": ">=20"
42
49
  },
43
50
  "author": "Jawwad Zafar <zafarjawwad@gmail.com>",
44
51
  "homepage": "https://jawwadzafar.github.io/pi-webfind",
45
- "scripts": {
46
- "docs:dev": "vitepress dev docs",
47
- "docs:build": "vitepress build docs",
48
- "docs:preview": "vitepress preview docs"
49
- },
50
- "devDependencies": {
51
- "vitepress": "^1.6.3"
52
- },
53
52
  "bugs": {
54
53
  "url": "https://github.com/jawwadzafar/pi-webfind/issues"
54
+ },
55
+ "pi": {
56
+ "themes": [
57
+ "./themes"
58
+ ]
55
59
  }
56
60
  }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "Claude Dark",
4
+ "vars": {
5
+ "cyan": "#d7c197",
6
+ "blue": "#7aa2f7",
7
+ "green": "#6ec876",
8
+ "red": "#ff6b80",
9
+ "yellow": "#d9a05b",
10
+ "text": "#e8e4dd",
11
+ "gray": "#8a857c",
12
+ "dimGray": "#5e594f",
13
+ "darkGray": "#3e3a34",
14
+ "accent": "#d7c197",
15
+ "selectedBg": "#3a3630",
16
+ "userMsgBg": "#2d2a26",
17
+ "toolPendingBg": "#262320",
18
+ "toolSuccessBg": "#232a23",
19
+ "toolErrorBg": "#33241f",
20
+ "customMsgBg": "#2a2724"
21
+ },
22
+ "colors": {
23
+ "accent": "accent",
24
+ "border": "blue",
25
+ "borderAccent": "cyan",
26
+ "borderMuted": "darkGray",
27
+ "success": "green",
28
+ "error": "red",
29
+ "warning": "yellow",
30
+ "muted": "gray",
31
+ "dim": "dimGray",
32
+ "text": "text",
33
+ "thinkingText": "gray",
34
+ "selectedBg": "selectedBg",
35
+ "scrollbarTrack": "darkGray",
36
+ "scrollbarThumb": "text",
37
+ "searchMatchBg": "selectedBg",
38
+ "searchMatchText": "text",
39
+ "userMessageBg": "userMsgBg",
40
+ "userMessageText": "text",
41
+ "customMessageBg": "customMsgBg",
42
+ "customMessageText": "text",
43
+ "customMessageLabel": "#d7c197",
44
+ "toolPendingBg": "toolPendingBg",
45
+ "toolSuccessBg": "toolSuccessBg",
46
+ "toolErrorBg": "toolErrorBg",
47
+ "toolTitle": "#e8e4dd",
48
+ "toolOutput": "gray",
49
+ "mdHeading": "#e8e4dd",
50
+ "mdLink": "#d7c197",
51
+ "mdLinkUrl": "#8a857c",
52
+ "mdCode": "accent",
53
+ "mdCodeBlock": "green",
54
+ "mdCodeBlockBorder": "gray",
55
+ "mdQuote": "gray",
56
+ "mdQuoteBorder": "gray",
57
+ "mdHr": "gray",
58
+ "mdListBullet": "accent",
59
+ "toolDiffAdded": "green",
60
+ "toolDiffRemoved": "red",
61
+ "toolDiffContext": "gray",
62
+ "syntaxComment": "#6A9955",
63
+ "syntaxKeyword": "#569CD6",
64
+ "syntaxFunction": "#DCDCAA",
65
+ "syntaxVariable": "#9CDCFE",
66
+ "syntaxString": "#CE9178",
67
+ "syntaxNumber": "#B5CEA8",
68
+ "syntaxType": "#4EC9B0",
69
+ "syntaxOperator": "#D4D4D4",
70
+ "syntaxPunctuation": "#D4D4D4",
71
+ "thinkingOff": "darkGray",
72
+ "thinkingMinimal": "#6e6e6e",
73
+ "thinkingLow": "#5f87af",
74
+ "thinkingMedium": "#81a2be",
75
+ "thinkingHigh": "#b294bb",
76
+ "thinkingXhigh": "#d183e8",
77
+ "thinkingMax": "#ff5fff",
78
+ "bashMode": "green"
79
+ }
80
+ }