agent-sh 0.14.9 → 0.14.10

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.
@@ -14,6 +14,9 @@ import activateOpenrouter from "./providers/openrouter.js";
14
14
  import activateOpenai from "./providers/openai.js";
15
15
  import activateOpenaiCompatible from "./providers/openai-compatible.js";
16
16
  import activateDeepseek from "./providers/deepseek.js";
17
+ import activateOllama from "./providers/ollama.js";
18
+ import activateZaiCodingPlan from "./providers/zai-coding-plan.js";
19
+ import activateOpencode from "./providers/opencode.js";
17
20
  import { findBash } from "../utils/executor.js";
18
21
  import { createBashTool } from "./tools/bash.js";
19
22
  import { createPwshTool } from "./tools/pwsh.js";
@@ -501,4 +504,7 @@ export function activateAgent(ctx) {
501
504
  if (process.env.OPENAI_BASE_URL)
502
505
  activateOpenaiCompatible(agentCtx);
503
506
  activateDeepseek(agentCtx);
507
+ activateOllama(agentCtx);
508
+ activateZaiCodingPlan(agentCtx);
509
+ activateOpencode(agentCtx);
504
510
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Ollama provider — local daemon or Ollama Cloud.
3
+ *
4
+ * Cloud auth: agent-sh auth login ollama-cloud
5
+ * Local host: OLLAMA_HOST (default http://localhost:11434)
6
+ *
7
+ * Catalog comes from /api/tags; per-model context length is fetched
8
+ * from /api/show. Chat goes through the OpenAI-compatible /v1 shim.
9
+ */
10
+ import type { AgentContext } from "../host-types.js";
11
+ export default function activate(ctx: AgentContext): void;
@@ -0,0 +1,72 @@
1
+ import { resolveApiKey } from "../../cli/auth/keys.js";
2
+ const ECHO_REASONING_PATTERNS = [/deepseek/i];
3
+ export default function activate(ctx) {
4
+ const cloudKey = resolveApiKey("ollama-cloud").key ?? process.env.OLLAMA_API_KEY;
5
+ const host = cloudKey
6
+ ? "https://ollama.com"
7
+ : (process.env.OLLAMA_HOST ?? "http://localhost:11434").replace(/\/$/, "");
8
+ const id = cloudKey ? "ollama-cloud" : "ollama";
9
+ const sdkKey = cloudKey || "no-key";
10
+ const noAuth = !cloudKey;
11
+ const baseURL = `${host}/v1`;
12
+ const headers = {};
13
+ if (cloudKey)
14
+ headers.Authorization = `Bearer ${cloudKey}`;
15
+ ctx.agent.providers.configure(id, {
16
+ reasoningParams: (level) => {
17
+ if (level === "off")
18
+ return { reasoning_effort: "none" };
19
+ return { reasoning_effort: level === "xhigh" ? "high" : level };
20
+ },
21
+ });
22
+ ctx.agent.providers.register({ id, apiKey: sdkKey, baseURL, models: [], noAuth });
23
+ fetchCatalog(host, headers).then((models) => {
24
+ if (models.length === 0)
25
+ return;
26
+ ctx.agent.providers.register({
27
+ id,
28
+ apiKey: sdkKey,
29
+ baseURL,
30
+ defaultModel: models[0].id,
31
+ models,
32
+ noAuth,
33
+ });
34
+ }).catch(() => { });
35
+ }
36
+ async function fetchCatalog(host, headers) {
37
+ const tagsRes = await fetch(`${host}/api/tags`, { headers });
38
+ if (!tagsRes.ok)
39
+ return [];
40
+ const tagsData = await tagsRes.json();
41
+ const names = (tagsData.models ?? []).map((m) => m.name);
42
+ if (names.length === 0)
43
+ return [];
44
+ const ctxs = await Promise.all(names.map((name) => fetchContextLength(host, headers, name).catch(() => undefined)));
45
+ return names.map((name, i) => ({
46
+ id: name,
47
+ contextWindow: ctxs[i],
48
+ echoReasoning: ECHO_REASONING_PATTERNS.some((re) => re.test(name)),
49
+ }));
50
+ }
51
+ async function fetchContextLength(host, headers, name) {
52
+ const res = await fetch(`${host}/api/show`, {
53
+ method: "POST",
54
+ headers: { ...headers, "Content-Type": "application/json" },
55
+ body: JSON.stringify({ name }),
56
+ });
57
+ if (!res.ok)
58
+ return undefined;
59
+ const data = await res.json();
60
+ const info = data.model_info ?? {};
61
+ const arch = info["general.architecture"];
62
+ if (arch) {
63
+ const ctx = info[`${arch}.context_length`];
64
+ if (typeof ctx === "number")
65
+ return ctx;
66
+ }
67
+ for (const [k, v] of Object.entries(info)) {
68
+ if (k.endsWith(".context_length") && typeof v === "number")
69
+ return v;
70
+ }
71
+ return undefined;
72
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * OpenCode Zen & Go providers — runtime model discovery via /models +
3
+ * models.dev metadata enrichment.
4
+ *
5
+ * Registers two providers:
6
+ * - opencode — Zen tier (https://opencode.ai/zen/v1)
7
+ * - opencode-go — Go tier (https://opencode.ai/zen/go/v1)
8
+ */
9
+ import type { AgentContext } from "../host-types.js";
10
+ export default function activate(ctx: AgentContext): void;
@@ -0,0 +1,112 @@
1
+ import { resolveApiKey } from "../../cli/auth/keys.js";
2
+ const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
3
+ const GO_BASE_URL = "https://opencode.ai/zen/go/v1";
4
+ const MODELS_DEV_ENDPOINT = "https://models.dev/api.json";
5
+ const DEFAULT_CTX = 128_000;
6
+ const DEFAULT_MAX_TOKENS = 16_384;
7
+ const ZEN_FALLBACK = ["claude-sonnet-4-6"];
8
+ const GO_FALLBACK = ["gpt-5.2"];
9
+ // ── Helpers ──────────────────────────────────────────────────────
10
+ async function fetchJson(url) {
11
+ const res = await fetch(url, {
12
+ headers: { Accept: "application/json" },
13
+ signal: AbortSignal.timeout(15_000),
14
+ });
15
+ if (!res.ok)
16
+ throw new Error(`HTTP ${res.status}`);
17
+ return res.json();
18
+ }
19
+ function findEntry(provider, id) {
20
+ const direct = provider?.models?.[id];
21
+ if (direct)
22
+ return direct;
23
+ if (!provider?.models)
24
+ return undefined;
25
+ return Object.values(provider.models).find((m) => m.id === id);
26
+ }
27
+ function resolveModel(id, meta) {
28
+ const raw = meta?.modalities?.input;
29
+ const modalities = Array.isArray(raw)
30
+ ? raw.filter((v) => v === "text" || v === "image")
31
+ : ["text"];
32
+ return {
33
+ id,
34
+ reasoning: meta?.reasoning ?? false,
35
+ contextWindow: (typeof meta?.limit?.context === "number" && meta.limit.context > 0)
36
+ ? meta.limit.context : DEFAULT_CTX,
37
+ maxTokens: (typeof meta?.limit?.output === "number" && meta.limit.output > 0)
38
+ ? meta.limit.output : DEFAULT_MAX_TOKENS,
39
+ modalities,
40
+ };
41
+ }
42
+ function reasoningParams(level) {
43
+ if (level === "off")
44
+ return { reasoning_effort: "none" };
45
+ return { reasoning_effort: level === "xhigh" ? "high" : level };
46
+ }
47
+ // ── Activation ───────────────────────────────────────────────────
48
+ export default function activate(ctx) {
49
+ const apiKey = process.env.OPENCODE_API_KEY ??
50
+ resolveApiKey("opencode").key ?? undefined;
51
+ ctx.agent.providers.configure("opencode", { reasoningParams });
52
+ ctx.agent.providers.register({
53
+ id: "opencode", apiKey, baseURL: ZEN_BASE_URL,
54
+ defaultModel: ZEN_FALLBACK[0], models: ZEN_FALLBACK,
55
+ supportsReasoningEffort: true,
56
+ });
57
+ ctx.agent.providers.configure("opencode-go", { reasoningParams });
58
+ ctx.agent.providers.register({
59
+ id: "opencode-go", apiKey, baseURL: GO_BASE_URL,
60
+ defaultModel: GO_FALLBACK[0], models: GO_FALLBACK,
61
+ supportsReasoningEffort: true,
62
+ });
63
+ if (!apiKey)
64
+ return;
65
+ fetchModelsDev()
66
+ .then(async (md) => {
67
+ const zenIds = await fetchModelIds(ZEN_BASE_URL);
68
+ const goIds = await fetchModelIds(GO_BASE_URL);
69
+ const resolve = (ids, prov, fb) => (ids.length > 0 ? ids : fb).map((id) => resolveModel(id, findEntry(prov, id)));
70
+ const zen = resolve(zenIds, md?.opencode, ZEN_FALLBACK);
71
+ const go = resolve(goIds, md?.["opencode-go"], GO_FALLBACK);
72
+ ctx.agent.providers.register({
73
+ id: "opencode", apiKey, baseURL: ZEN_BASE_URL,
74
+ defaultModel: zen[0]?.id ?? ZEN_FALLBACK[0], models: zen,
75
+ supportsReasoningEffort: true,
76
+ });
77
+ ctx.agent.providers.register({
78
+ id: "opencode-go", apiKey, baseURL: GO_BASE_URL,
79
+ defaultModel: go[0]?.id ?? GO_FALLBACK[0], models: go,
80
+ supportsReasoningEffort: true,
81
+ });
82
+ })
83
+ .catch(() => { });
84
+ }
85
+ async function fetchModelsDev() {
86
+ try {
87
+ const payload = await fetchJson(MODELS_DEV_ENDPOINT);
88
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
89
+ return undefined;
90
+ return payload;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ async function fetchModelIds(baseURL) {
97
+ try {
98
+ const res = await fetch(`${baseURL}/models`, {
99
+ headers: { Accept: "application/json" },
100
+ signal: AbortSignal.timeout(10_000),
101
+ });
102
+ if (!res.ok)
103
+ return [];
104
+ const payload = await res.json();
105
+ if (!Array.isArray(payload.data))
106
+ return [];
107
+ return [...new Set(payload.data.map((e) => e.id).filter(Boolean))];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Z.AI Coding Plan — Zhipu AI's subscription GLM models for coding.
3
+ */
4
+ import type { AgentContext } from "../host-types.js";
5
+ export default function activate(ctx: AgentContext): void;
@@ -0,0 +1,26 @@
1
+ import { resolveApiKey } from "../../cli/auth/keys.js";
2
+ const BASE_URL = "https://api.z.ai/api/coding/paas/v4";
3
+ const ID = "zai-coding-plan";
4
+ const DEFAULT_MODELS = [
5
+ { id: "glm-5.1", reasoning: true, contextWindow: 200_000 },
6
+ { id: "glm-5-turbo", reasoning: true, contextWindow: 200_000 },
7
+ { id: "glm-4.7", reasoning: true, contextWindow: 204_800 },
8
+ { id: "glm-4.5-air", reasoning: true, contextWindow: 131_072 },
9
+ ];
10
+ function buildReasoningParams(level, _model) {
11
+ if (level === "off")
12
+ return { thinking: { type: "disabled" } };
13
+ const effort = level === "xhigh" ? "high" : level;
14
+ return { thinking: { type: "enabled" }, reasoning_effort: effort };
15
+ }
16
+ export default function activate(ctx) {
17
+ const { key } = resolveApiKey(ID);
18
+ ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
19
+ ctx.agent.providers.register({
20
+ id: ID,
21
+ apiKey: key ?? process.env.ZAI_API_KEY ?? process.env.ZHIPU_API_KEY,
22
+ baseURL: BASE_URL,
23
+ defaultModel: DEFAULT_MODELS[0].id,
24
+ models: DEFAULT_MODELS,
25
+ });
26
+ }
package/dist/cli/args.js CHANGED
@@ -53,8 +53,8 @@ export function parseArgs(argv, env = process.env) {
53
53
  let provider;
54
54
  let backend;
55
55
  let shell = env.SHELL || "/bin/bash";
56
- let apiKey = env.OPENAI_API_KEY;
57
- let baseURL = env.OPENAI_BASE_URL;
56
+ let apiKey;
57
+ let baseURL;
58
58
  for (let i = 0; i < argv.length; i++) {
59
59
  const arg = argv[i];
60
60
  if (arg === "--model" && argv[i + 1]) {
@@ -0,0 +1,695 @@
1
+ /**
2
+ * ADS extension — NASA Astrophysics Data System tools for agent-sh.
3
+ *
4
+ * Provides four tools:
5
+ * - ads_search: Search ADS for papers
6
+ * - ads_paper: Fetch details of a specific paper by bibcode/DOI/arXiv ID
7
+ * - ads_citations: Find citing or referenced papers
8
+ * - ads_download_pdf: Download paper PDFs
9
+ *
10
+ * Requires: ADS_API_TOKEN environment variable
11
+ * Get one at https://ui.adsabs.harvard.edu/#user/settings/token
12
+ *
13
+ * Configuration (~/.agent-sh/settings.json):
14
+ * {
15
+ * "ads": { "pdfDownloadDir": "~/papers" }
16
+ * }
17
+ */
18
+ import type { AgentContext } from "agent-sh/types";
19
+ import * as path from "path";
20
+ import * as fs from "fs/promises";
21
+ import { homedir } from "os";
22
+ import { fileURLToPath } from "url";
23
+
24
+ // ── Constants ────────────────────────────────────────────────────────
25
+
26
+ const ADS_SEARCH_API = "https://api.adsabs.harvard.edu/v1/search/query";
27
+ const ADS_EXPORT_API = "https://api.adsabs.harvard.edu/v1/export";
28
+ const ADS_LINK_GATEWAY = "https://ui.adsabs.harvard.edu/link_gateway";
29
+
30
+ const SEARCH_FIELDS = [
31
+ "bibcode", "title", "author", "abstract", "pubdate", "year",
32
+ "citation_count", "read_count", "doi", "identifier", "pub",
33
+ "arxiv_class", "keyword", "database", "doctype", "aff",
34
+ "volume", "issue", "page", "bibstem",
35
+ ].join(",");
36
+
37
+ const PDF_SOURCES = {
38
+ publisher: "PUB_PDF",
39
+ ads: "ADS_PDF",
40
+ arxiv: "EPRINT_PDF",
41
+ } as const;
42
+
43
+ type PDFSource = keyof typeof PDF_SOURCES;
44
+
45
+ const PDF_UA =
46
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " +
47
+ "(KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36";
48
+
49
+ // ── Types ────────────────────────────────────────────────────────────
50
+
51
+ interface ADSPaper {
52
+ bibcode: string;
53
+ title: string;
54
+ authors: string[];
55
+ affiliations: string[];
56
+ abstract: string;
57
+ pubdate: string;
58
+ year: string;
59
+ citationCount: number;
60
+ readCount: number;
61
+ doi: string[];
62
+ identifier: string[];
63
+ pub: string;
64
+ bibstem: string;
65
+ volume: string;
66
+ issue: string;
67
+ page: string;
68
+ arxivClass: string[];
69
+ keywords: string[];
70
+ database: string[];
71
+ doctype: string;
72
+ }
73
+
74
+ // ── Helpers ──────────────────────────────────────────────────────────
75
+
76
+ function parseDoc(doc: any): ADSPaper {
77
+ return {
78
+ bibcode: doc.bibcode ?? "",
79
+ title: Array.isArray(doc.title) ? doc.title.join(" ") : (doc.title ?? ""),
80
+ authors: Array.isArray(doc.author) ? doc.author : doc.author ? [doc.author] : [],
81
+ affiliations: Array.isArray(doc.aff) ? doc.aff : doc.aff ? [doc.aff] : [],
82
+ abstract: doc.abstract ?? "",
83
+ pubdate: doc.pubdate ?? "",
84
+ year: doc.year ?? "",
85
+ citationCount: doc.citation_count ?? 0,
86
+ readCount: doc.read_count ?? 0,
87
+ doi: Array.isArray(doc.doi) ? doc.doi : doc.doi ? [doc.doi] : [],
88
+ identifier: Array.isArray(doc.identifier) ? doc.identifier : doc.identifier ? [doc.identifier] : [],
89
+ pub: doc.pub ?? "",
90
+ bibstem: Array.isArray(doc.bibstem) ? doc.bibstem[0] ?? "" : (doc.bibstem ?? ""),
91
+ volume: doc.volume ?? "",
92
+ issue: doc.issue ?? "",
93
+ page: doc.page ?? "",
94
+ arxivClass: Array.isArray(doc.arxiv_class) ? doc.arxiv_class : doc.arxiv_class ? [doc.arxiv_class] : [],
95
+ keywords: Array.isArray(doc.keyword) ? doc.keyword : doc.keyword ? [doc.keyword] : [],
96
+ database: Array.isArray(doc.database) ? doc.database : doc.database ? [doc.database] : [],
97
+ doctype: doc.doctype ?? "",
98
+ };
99
+ }
100
+
101
+ function truncateAuthors(authors: string[], maxAuthors = 10): string {
102
+ if (authors.length <= maxAuthors) return authors.join("; ");
103
+ return `${authors.slice(0, maxAuthors).join("; ")}; ... +${authors.length - maxAuthors} more`;
104
+ }
105
+
106
+ function formatPaper(p: ADSPaper, index?: number): string {
107
+ const prefix = index !== undefined ? `[${index + 1}] ` : "";
108
+ const lines: string[] = [
109
+ `${prefix}${p.title}`,
110
+ ` Bibcode: ${p.bibcode}`,
111
+ ` Authors: ${truncateAuthors(p.authors)}`,
112
+ ` Published: ${p.pubdate} Year: ${p.year}`,
113
+ ` Journal: ${p.pub}`,
114
+ ];
115
+ if (p.doi.length > 0) lines.push(` DOI: ${p.doi.join(", ")}`);
116
+ if (p.arxivClass.length > 0) lines.push(` arXiv: ${p.arxivClass.join(", ")}`);
117
+ if (p.keywords.length > 0) lines.push(` Keywords: ${p.keywords.slice(0, 10).join(", ")}`);
118
+ lines.push(` Citations: ${p.citationCount} Reads: ${p.readCount}`);
119
+ lines.push(` ADS URL: ${ADS_LINK_GATEWAY}/${p.bibcode}`);
120
+ if (p.abstract) lines.push(` Abstract: ${p.abstract}`);
121
+ return lines.join("\n");
122
+ }
123
+
124
+ function formatPaperCompact(p: ADSPaper, index?: number): string {
125
+ const prefix = index !== undefined ? `[${index + 1}] ` : "";
126
+ const authorStr = p.authors.length > 0
127
+ ? p.authors.length === 1 ? p.authors[0] : `${p.authors[0]} et al.`
128
+ : "Unknown";
129
+ return `${prefix}${p.title}\n ${p.bibcode} | ${authorStr} | ${p.year} | ${p.bibstem || p.pub} | ${p.citationCount} cit`;
130
+ }
131
+
132
+ function getToken(): string {
133
+ const token = process.env.ADS_API_TOKEN ?? "";
134
+ if (!token) {
135
+ throw new Error(
136
+ "ADS_API_TOKEN environment variable not set. " +
137
+ "Get a token from https://ui.adsabs.harvard.edu/#user/settings/token"
138
+ );
139
+ }
140
+ return token;
141
+ }
142
+
143
+ async function adsFetch(url: string, options?: RequestInit): Promise<any> {
144
+ const token = getToken();
145
+ const resp = await fetch(url, {
146
+ ...options,
147
+ headers: {
148
+ Authorization: `Bearer ${token}`,
149
+ "Content-Type": "application/json",
150
+ ...(options?.headers ?? {}),
151
+ },
152
+ });
153
+ if (!resp.ok) {
154
+ const body = await resp.text().catch(() => "");
155
+ throw new Error(`ADS API error: ${resp.status} ${resp.statusText}${body ? ` - ${body}` : ""}`);
156
+ }
157
+ return resp.json();
158
+ }
159
+
160
+ function buildIdQuery(id: string): string {
161
+ const trimmed = id.trim();
162
+ if (trimmed.startsWith("10.") || trimmed.startsWith("doi:")) {
163
+ return `doi:"${trimmed.replace(/^doi:/, "")}"`;
164
+ } else if (trimmed.toLowerCase().startsWith("arxiv:") || /^\d{4}\.\d{4,5}/.test(trimmed)) {
165
+ return `arxiv:"${trimmed.replace(/^arxiv:/i, "")}"`;
166
+ }
167
+ return `bibcode:${trimmed}`;
168
+ }
169
+
170
+ async function resolveBibcode(id: string): Promise<{ bibcode: string; title: string }> {
171
+ const query = buildIdQuery(id);
172
+ const url = `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}&fl=bibcode,title&rows=1`;
173
+ const data = await adsFetch(url);
174
+ const docs: any[] = (data.response ?? data).docs ?? [];
175
+ if (docs.length === 0) throw new Error(`Paper not found: ${id}`);
176
+ return {
177
+ bibcode: docs[0].bibcode ?? "",
178
+ title: Array.isArray(docs[0].title) ? docs[0].title.join(" ") : (docs[0].title ?? ""),
179
+ };
180
+ }
181
+
182
+ function buildSortParam(sortBy: string): string {
183
+ const map: Record<string, string> = {
184
+ relevance: "score desc",
185
+ date: "date desc",
186
+ citation_count: "citation_count desc",
187
+ read_count: "read_count desc",
188
+ };
189
+ return map[sortBy] ?? "score desc";
190
+ }
191
+
192
+ function isBibcode(id: string): boolean {
193
+ return /^\d{4}.{15}$/.test(id);
194
+ }
195
+
196
+ function bibcodeToFilename(bibcode: string): string {
197
+ return bibcode.replace(/\./g, "_");
198
+ }
199
+
200
+ function formatFileSize(bytes: number): string {
201
+ if (bytes < 1024) return `${bytes} B`;
202
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
203
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
204
+ }
205
+
206
+ async function getPdfDownloadDir(): Promise<string> {
207
+ const settingsPath = path.join(homedir(), ".agent-sh", "settings.json");
208
+ try {
209
+ const raw = await fs.readFile(settingsPath, "utf8");
210
+ const settings = JSON.parse(raw);
211
+ const dir = settings?.ads?.pdfDownloadDir;
212
+ if (typeof dir === "string" && dir.length > 0) {
213
+ return dir.replace(/^~/, homedir());
214
+ }
215
+ } catch { /* settings may not exist */ }
216
+ // Default: ~/.agent-sh/papers
217
+ return path.join(homedir(), ".agent-sh", "papers");
218
+ }
219
+
220
+ async function fetchPDF(bibcode: string, sourceKey: PDFSource): Promise<ArrayBuffer> {
221
+ const url = `${ADS_LINK_GATEWAY}/${encodeURIComponent(bibcode)}/${PDF_SOURCES[sourceKey]}`;
222
+ const resp = await fetch(url, {
223
+ redirect: "follow",
224
+ headers: { "User-Agent": PDF_UA },
225
+ });
226
+
227
+ if (!resp.ok) {
228
+ throw new Error(`HTTP ${resp.status} ${resp.statusText} from ${sourceKey} source`);
229
+ }
230
+
231
+ const contentType = resp.headers.get("content-type") ?? "";
232
+ if (contentType.includes("text/html")) {
233
+ const body = await resp.text();
234
+ if (body.includes("CAPTCHA")) {
235
+ throw new Error(`CAPTCHA detected from ${sourceKey} source. Try a different source.`);
236
+ }
237
+ throw new Error(`Expected PDF but got HTML from ${sourceKey} source. The paper may be behind a paywall. Try source='arxiv'.`);
238
+ }
239
+ if (!contentType.includes("application/pdf")) {
240
+ throw new Error(`Unexpected Content-Type '${contentType}' from ${sourceKey} source.`);
241
+ }
242
+
243
+ return resp.arrayBuffer();
244
+ }
245
+
246
+ // ── Extension entry point ────────────────────────────────────────────
247
+
248
+ export default function activate(ctx: AgentContext): void {
249
+ // Register the ADS query syntax skill — grouped with our tools in the system prompt
250
+ const skillPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "SKILL.md");
251
+ ctx.agent.registerSkill(
252
+ "ads",
253
+ "Full ADS query syntax reference and search strategy. Load before composing ADS queries — the tool descriptions only cover basic field prefixes.",
254
+ skillPath,
255
+ );
256
+
257
+ // ── ads_search ─────────────────────────────────────────────────────
258
+
259
+ ctx.agent.registerTool({
260
+ name: "ads_search",
261
+ displayName: "search",
262
+ description:
263
+ "Search astronomy/physics papers (ADS) — preferred over web_search for literature queries.\n" +
264
+ "Supports fielded syntax (title:, author:, year:, bibcode:, doi:), database filters, sort options.\n" +
265
+ "⚠️ Load the ads skill BEFORE using this tool for advanced syntax and search strategies.\n" +
266
+ "Requires ADS_API_TOKEN.",
267
+ input_schema: {
268
+ type: "object",
269
+ properties: {
270
+ query: {
271
+ type: "string",
272
+ description:
273
+ 'Search query. Supports fielded searches like "title:exoplanets", ' +
274
+ '"author:\\"Hubble, E\\"", "keyword:dark matter", "year:2023". ' +
275
+ 'Unfielded terms search all metadata. Use quotes for phrases, +/- for inclusion/exclusion.',
276
+ },
277
+ database: {
278
+ type: "string",
279
+ enum: ["astronomy", "physics", "general"],
280
+ description: "Filter by ADS database. Default: searches all databases.",
281
+ },
282
+ doctype: {
283
+ type: "string",
284
+ enum: ["article", "eprint", "inproceedings", "proceedings", "inbook", "book", "phdthesis", "mastersthesis", "catalog", "software", "proposal"],
285
+ description: "Filter by document type.",
286
+ },
287
+ refereed: {
288
+ type: "boolean",
289
+ description: "Filter to only refereed (peer-reviewed) papers.",
290
+ },
291
+ year_from: {
292
+ type: "string",
293
+ description: "Start year for date range filter, e.g. '2020'.",
294
+ },
295
+ year_to: {
296
+ type: "string",
297
+ description: "End year for date range filter, e.g. '2024'.",
298
+ },
299
+ max_results: {
300
+ type: "number",
301
+ description: "Max papers to return (default 10, max 50).",
302
+ },
303
+ sort_by: {
304
+ type: "string",
305
+ enum: ["relevance", "date", "citation_count", "read_count"],
306
+ description: "Sort order (default: relevance).",
307
+ },
308
+ start: {
309
+ type: "number",
310
+ description: "Start index for pagination (default 0).",
311
+ },
312
+ detail: {
313
+ type: "string",
314
+ enum: ["compact", "full"],
315
+ description: "Output detail level. 'compact' (default): title, first author, year, bibcode, citations. 'full': includes abstract, all authors, keywords, DOI.",
316
+ },
317
+ },
318
+ required: ["query"],
319
+ },
320
+
321
+ async execute(args) {
322
+ const query = args.query as string;
323
+ const maxResults = Math.min((args.max_results as number) ?? 10, 50);
324
+ const start = (args.start as number) ?? 0;
325
+ const sort = buildSortParam((args.sort_by as string) ?? "relevance");
326
+ const detail = (args.detail as string) ?? "compact";
327
+
328
+ const fq: string[] = [];
329
+ if (args.database) fq.push(`database:${args.database}`);
330
+ if (args.doctype) fq.push(`doctype:${args.doctype}`);
331
+ if (args.refereed) fq.push("property:refereed");
332
+ if (args.year_from || args.year_to) {
333
+ fq.push(`year:[${args.year_from ?? "*"} TO ${args.year_to ?? "*"}]`);
334
+ }
335
+
336
+ const fqParam = fq.map(f => `&fq=${encodeURIComponent(f)}`).join("");
337
+ const url =
338
+ `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}` +
339
+ `&fl=${SEARCH_FIELDS}&rows=${maxResults}&start=${start}` +
340
+ `&sort=${encodeURIComponent(sort)}${fqParam}`;
341
+
342
+ const data = await adsFetch(url);
343
+ const response = data.response ?? data;
344
+ const docs: any[] = response.docs ?? [];
345
+ const totalResults = response.numFound ?? 0;
346
+
347
+ if (docs.length === 0) {
348
+ return { content: `No papers found for query: ${query}`, exitCode: 0, isError: false };
349
+ }
350
+
351
+ const papers = docs.map(parseDoc);
352
+ const formatter = detail === "full" ? formatPaper : formatPaperCompact;
353
+ let text = `Found ${totalResults} papers (showing ${start + 1}-${start + papers.length}):\n`;
354
+ text += papers.map((p, i) => formatter(p, i)).join("\n\n");
355
+
356
+ const nextStart = start + papers.length;
357
+ if (nextStart < totalResults) {
358
+ text += `\n\n→ Use start=${nextStart} to see the next page of results.`;
359
+ }
360
+
361
+ return { content: text, exitCode: 0, isError: false };
362
+ },
363
+
364
+ getDisplayInfo(args) {
365
+ return { kind: "search" };
366
+ },
367
+
368
+ formatCall(args) {
369
+ const parts: string[] = [];
370
+ if (args.query) parts.push(String(args.query));
371
+ if (args.year_from || args.year_to) parts.push(`${args.year_from ?? "*"}–${args.year_to ?? "*"}`);
372
+ if (args.database) parts.push(`db:${args.database}`);
373
+ if (args.sort_by && args.sort_by !== "relevance") parts.push(`sort:${args.sort_by}`);
374
+ return parts.join(" ");
375
+ },
376
+ });
377
+
378
+ // ── ads_paper ──────────────────────────────────────────────────────
379
+
380
+ ctx.agent.registerTool({
381
+ name: "ads_paper",
382
+ displayName: "paper",
383
+ description:
384
+ "Fetch a paper's details from ADS by bibcode, DOI, or arXiv ID.\n" +
385
+ "Optional BibTeX return. ⚠️ Load the ads skill BEFORE using for field syntax help.\n" +
386
+ "Requires ADS_API_TOKEN.",
387
+ input_schema: {
388
+ type: "object",
389
+ properties: {
390
+ id: {
391
+ type: "string",
392
+ description:
393
+ 'Paper identifier: ADS bibcode (e.g. "2023ApJ...950L..12A"), ' +
394
+ 'DOI (e.g. "10.3847/2041-8213/acb7e0"), or arXiv ID (e.g. "arXiv:2301.01234").',
395
+ },
396
+ bibtex: {
397
+ type: "boolean",
398
+ description: "Include BibTeX citation (default: false).",
399
+ },
400
+ },
401
+ required: ["id"],
402
+ },
403
+
404
+ async execute(args) {
405
+ const id = (args.id as string).trim();
406
+ const query = buildIdQuery(id);
407
+ const url = `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}&fl=${SEARCH_FIELDS}&rows=1`;
408
+
409
+ const data = await adsFetch(url);
410
+ const docs: any[] = (data.response ?? data).docs ?? [];
411
+
412
+ if (docs.length === 0) {
413
+ return { content: `Paper not found: ${id}`, exitCode: 1, isError: true };
414
+ }
415
+
416
+ const paper = parseDoc(docs[0]);
417
+ let text = formatPaper(paper);
418
+
419
+ if (args.bibtex && paper.bibcode) {
420
+ try {
421
+ const exportData = await adsFetch(`${ADS_EXPORT_API}/bibtex`, {
422
+ method: "POST",
423
+ body: JSON.stringify({ bibcode: [paper.bibcode] }),
424
+ });
425
+ const bibtex = exportData.export ?? "";
426
+ if (bibtex) text += `\n\nBibTeX:\n${bibtex}`;
427
+ } catch {
428
+ text += "\n\n[Failed to fetch BibTeX]";
429
+ }
430
+ }
431
+
432
+ return { content: text, exitCode: 0, isError: false };
433
+ },
434
+
435
+ getDisplayInfo(args) {
436
+ return { kind: "read" };
437
+ },
438
+
439
+ formatCall(args) {
440
+ return String(args.id ?? "");
441
+ },
442
+ });
443
+
444
+ // ── ads_citations ──────────────────────────────────────────────────
445
+
446
+ ctx.agent.registerTool({
447
+ name: "ads_citations",
448
+ displayName: "citations",
449
+ description:
450
+ "List citations or references of an ADS bibcode.\n" +
451
+ "detail='compact'|'full'. ⚠️ Load the ads skill BEFORE using for syntax help.\n" +
452
+ "Requires ADS_API_TOKEN.",
453
+ input_schema: {
454
+ type: "object",
455
+ properties: {
456
+ bibcode: {
457
+ type: "string",
458
+ description: 'ADS bibcode of the paper, e.g. "2023ApJ...950L..12A".',
459
+ },
460
+ direction: {
461
+ type: "string",
462
+ enum: ["citations", "references"],
463
+ description: '"citations" = papers that cite this paper (default). "references" = papers this paper cites.',
464
+ },
465
+ max_results: {
466
+ type: "number",
467
+ description: "Max papers to return (default 10, max 50).",
468
+ },
469
+ sort_by: {
470
+ type: "string",
471
+ enum: ["date", "citation_count", "read_count"],
472
+ description: "Sort order (default: date).",
473
+ },
474
+ start: {
475
+ type: "number",
476
+ description: "Start index for pagination (default 0).",
477
+ },
478
+ detail: {
479
+ type: "string",
480
+ enum: ["compact", "full"],
481
+ description: "Output detail level. 'compact' (default) or 'full' with abstracts.",
482
+ },
483
+ },
484
+ required: ["bibcode"],
485
+ },
486
+
487
+ async execute(args) {
488
+ const bibcode = args.bibcode as string;
489
+ const direction = (args.direction as string) ?? "citations";
490
+ const maxResults = Math.min((args.max_results as number) ?? 10, 50);
491
+ const start = (args.start as number) ?? 0;
492
+ const sort = buildSortParam((args.sort_by as string) ?? "date");
493
+ const detail = (args.detail as string) ?? "compact";
494
+
495
+ const queryFunc = direction === "citations" ? "citations" : "references";
496
+ const query = `${queryFunc}(${bibcode})`;
497
+
498
+ const url =
499
+ `${ADS_SEARCH_API}?q=${encodeURIComponent(query)}` +
500
+ `&fl=${SEARCH_FIELDS}&rows=${maxResults}&start=${start}` +
501
+ `&sort=${encodeURIComponent(sort)}`;
502
+
503
+ const data = await adsFetch(url);
504
+ const response = data.response ?? data;
505
+ const docs: any[] = response.docs ?? [];
506
+ const totalResults = response.numFound ?? 0;
507
+
508
+ if (docs.length === 0) {
509
+ const label = direction === "citations" ? "citing" : "referenced by";
510
+ return { content: `No papers ${label} ${bibcode}`, exitCode: 0, isError: false };
511
+ }
512
+
513
+ const papers = docs.map(parseDoc);
514
+ const formatter = detail === "full" ? formatPaper : formatPaperCompact;
515
+ const dirLabel = direction === "citations" ? "citing papers" : "referenced papers";
516
+ let text = `Found ${totalResults} ${dirLabel} (showing ${start + 1}-${start + papers.length}):\n`;
517
+ text += papers.map((p, i) => formatter(p, i)).join("\n\n");
518
+
519
+ const nextStart = start + papers.length;
520
+ if (nextStart < totalResults) {
521
+ text += `\n\n→ Use start=${nextStart} to see the next page of results.`;
522
+ }
523
+
524
+ return { content: text, exitCode: 0, isError: false };
525
+ },
526
+
527
+ getDisplayInfo(args) {
528
+ return { kind: "search" };
529
+ },
530
+
531
+ formatCall(args) {
532
+ const dir = (args.direction as string) ?? "citations";
533
+ return `${args.bibcode} ${dir}`;
534
+ },
535
+ });
536
+
537
+ // ── ads_download_pdf ───────────────────────────────────────────────
538
+
539
+ ctx.agent.registerTool({
540
+ name: "ads_download_pdf",
541
+ displayName: "download",
542
+ description:
543
+ "Download a paper's PDF (bibcode/DOI/arXiv ID).\n" +
544
+ "Fallback: publisher → ADS → arXiv. ⚠️ Load the ads skill BEFORE using.\n" +
545
+ "Requires ADS_API_TOKEN.",
546
+ input_schema: {
547
+ type: "object",
548
+ properties: {
549
+ id: {
550
+ type: "string",
551
+ description:
552
+ 'Paper identifier: ADS bibcode (e.g. "2023ApJ...950L..12A"), ' +
553
+ 'DOI (e.g. "10.3847/2041-8213/acb7e0"), or arXiv ID (e.g. "arXiv:2301.01234").',
554
+ },
555
+ output_path: {
556
+ type: "string",
557
+ description:
558
+ "File path to save the PDF. Defaults to ~/.agent-sh/papers/{bibcode}.pdf. " +
559
+ "If a directory path is given, saves as {dir}/{bibcode}.pdf.",
560
+ },
561
+ source: {
562
+ type: "string",
563
+ enum: ["auto", "publisher", "ads", "arxiv"],
564
+ description:
565
+ "Preferred PDF source. 'auto' (default) tries publisher → ADS → arXiv. " +
566
+ "'publisher' = PUB_PDF, 'ads' = ADS_PDF, 'arxiv' = EPRINT_PDF.",
567
+ },
568
+ },
569
+ required: ["id"],
570
+ },
571
+ modifiesFiles: true,
572
+
573
+ async execute(args) {
574
+ const sourceParam = (args.source as string) ?? "auto";
575
+ const trimmedId = (args.id as string).trim();
576
+ const idIsBibcode = isBibcode(trimmedId);
577
+ const downloadDir = await getPdfDownloadDir();
578
+
579
+ // Fast path: check if already downloaded (bibcode only)
580
+ if (!args.output_path && idIsBibcode) {
581
+ const candidatePath = downloadDir
582
+ ? path.join(downloadDir, `${bibcodeToFilename(trimmedId)}.pdf`)
583
+ : path.join(process.cwd(), `${bibcodeToFilename(trimmedId)}.pdf`);
584
+
585
+ try {
586
+ const stat = await fs.stat(candidatePath);
587
+ return {
588
+ content:
589
+ `PDF already exists: ${candidatePath}\n` +
590
+ ` Bibcode: ${trimmedId}\n` +
591
+ ` Size: ${formatFileSize(stat.size)}\n` +
592
+ ` Modified: ${stat.mtime.toISOString()}\n\n` +
593
+ `To re-download, delete the file first or specify a different output_path.`,
594
+ exitCode: 0,
595
+ isError: false,
596
+ };
597
+ } catch { /* not cached, continue */ }
598
+ }
599
+
600
+ // Resolve identifier to bibcode
601
+ let resolved: { bibcode: string; title: string };
602
+ try {
603
+ resolved = await resolveBibcode(args.id as string);
604
+ } catch {
605
+ return { content: `Paper not found: ${args.id}`, exitCode: 1, isError: true };
606
+ }
607
+
608
+ const { bibcode, title } = resolved;
609
+ const safeName = `${bibcodeToFilename(bibcode)}.pdf`;
610
+
611
+ let outputPath: string;
612
+ if (!args.output_path) {
613
+ outputPath = downloadDir
614
+ ? path.join(downloadDir, safeName)
615
+ : path.join(process.cwd(), safeName);
616
+ } else {
617
+ const op = args.output_path as string;
618
+ outputPath = (op.endsWith("/") || !path.extname(op))
619
+ ? path.join(op, safeName)
620
+ : op;
621
+ }
622
+
623
+ // Check if already exists (post-resolution)
624
+ try {
625
+ const stat = await fs.stat(outputPath);
626
+ return {
627
+ content:
628
+ `PDF already exists: ${outputPath}\n` +
629
+ ` Bibcode: ${bibcode}\n` +
630
+ ` Title: ${title}\n` +
631
+ ` Size: ${formatFileSize(stat.size)}\n` +
632
+ ` Modified: ${stat.mtime.toISOString()}\n\n` +
633
+ `To re-download, delete the file first or specify a different output_path.`,
634
+ exitCode: 0,
635
+ isError: false,
636
+ };
637
+ } catch { /* proceed with download */ }
638
+
639
+ // Try sources in order
640
+ const sourceOrder: PDFSource[] =
641
+ sourceParam === "auto"
642
+ ? ["publisher", "ads", "arxiv"]
643
+ : [sourceParam as PDFSource];
644
+
645
+ let pdfBuffer: ArrayBuffer | undefined;
646
+ let usedSource: PDFSource | undefined;
647
+ let lastError = "";
648
+
649
+ for (const src of sourceOrder) {
650
+ try {
651
+ pdfBuffer = await fetchPDF(bibcode, src);
652
+ usedSource = src;
653
+ break;
654
+ } catch (err: any) {
655
+ lastError = err.message ?? String(err);
656
+ }
657
+ }
658
+
659
+ if (!pdfBuffer || !usedSource) {
660
+ return {
661
+ content:
662
+ `Failed to download PDF for ${bibcode} (${title}).\n` +
663
+ `Tried sources: ${sourceOrder.join(" → ")}.\n` +
664
+ `Last error: ${lastError}\n\n` +
665
+ `Try downloading manually: ${ADS_LINK_GATEWAY}/${bibcode}/EPRINT_PDF`,
666
+ exitCode: 1,
667
+ isError: true,
668
+ };
669
+ }
670
+
671
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
672
+ await fs.writeFile(outputPath, Buffer.from(pdfBuffer));
673
+
674
+ const sourceLabel = { publisher: "publisher", ads: "ADS", arxiv: "arXiv" }[usedSource];
675
+ return {
676
+ content:
677
+ `Downloaded PDF for: ${title}\n` +
678
+ ` Bibcode: ${bibcode}\n` +
679
+ ` Source: ${sourceLabel}\n` +
680
+ ` Saved to: ${outputPath}\n` +
681
+ ` Size: ${formatFileSize(pdfBuffer.byteLength)}`,
682
+ exitCode: 0,
683
+ isError: false,
684
+ };
685
+ },
686
+
687
+ getDisplayInfo(args) {
688
+ return { kind: "write" };
689
+ },
690
+
691
+ formatCall(args) {
692
+ return String(args.id ?? "");
693
+ },
694
+ });
695
+ }
@@ -319,6 +319,36 @@ function format(v: unknown): string {
319
319
  }
320
320
 
321
321
  // ── evaluator ─────────────────────────────────────────────────────
322
+ // LIPS 0.20.x has no character type, so a `#\…` literal reads as an unbound
323
+ // symbol. preprocessSchemeSource expands each to a 1-char string instead.
324
+ const NAMED_CHARS: Record<string, string> = {
325
+ newline: "\n", linefeed: "\n", nl: "\n",
326
+ space: " ", tab: "\t", return: "\r",
327
+ null: "\u0000", nul: "\u0000",
328
+ delete: "\u007f", rubout: "\u007f",
329
+ escape: "\u001b", altmode: "\u001b",
330
+ backspace: "\u0008", alarm: "\u0007", page: "\u000c",
331
+ };
332
+
333
+ // The char after `#\` as a 1-char string + its span, or null for an unknown
334
+ // multi-char name (left untranslated so it surfaces as an error).
335
+ function resolveCharLiteral(source: string, start: number): { ch: string; span: number } | null {
336
+ let j = start;
337
+ while (j < source.length && /[A-Za-z0-9]/.test(source[j])) j++;
338
+ const run = source.slice(start, j);
339
+ if (run.length <= 1) {
340
+ const ch = source[start];
341
+ return ch === undefined ? null : { ch, span: 1 };
342
+ }
343
+ const hex = /^[xX]([0-9a-fA-F]+)$/.exec(run);
344
+ if (hex) {
345
+ const cp = parseInt(hex[1], 16);
346
+ if (cp >= 0 && cp <= 0x10ffff) return { ch: String.fromCodePoint(cp), span: run.length };
347
+ }
348
+ const named = NAMED_CHARS[run.toLowerCase()];
349
+ return named === undefined ? null : { ch: named, span: run.length };
350
+ }
351
+
322
352
  // LIPS implements string literals via JSON.parse, which rejects backslash
323
353
  // escapes outside JSON's tiny set (\" \\ \/ \b \f \n \r \t \uXXXX). Models
324
354
  // routinely write \s \w \d etc. in regex strings. Pre-process: promote any
@@ -339,6 +369,14 @@ function preprocessSchemeSource(source: string): string {
339
369
  if (!inStr) {
340
370
  if (c === ";") { inComment = true; out += c; continue; }
341
371
  if (c === '"') { inStr = true; out += c; continue; }
372
+ if (c === "#" && source[i + 1] === "\\") {
373
+ const lit = resolveCharLiteral(source, i + 2);
374
+ if (lit) {
375
+ out += JSON.stringify(lit.ch);
376
+ i += 1 + lit.span; // skip '\' + the name/char; loop's i++ skips '#'
377
+ continue;
378
+ }
379
+ }
342
380
  out += c;
343
381
  continue;
344
382
  }
@@ -518,6 +556,11 @@ async function evaluate(env: any, source: string, timeoutMs: number) {
518
556
  msg = formatParenDiagnostic(source, msg);
519
557
  } else if (/Bad escaped character in JSON|Unexpected.*JSON|JSON at position/.test(msg)) {
520
558
  msg = formatStringEscapeDiagnostic(source, msg);
559
+ } else if (msg.includes("Unbound variable `#\\")) {
560
+ msg += "\n This runtime has no character type — characters are 1-char strings." +
561
+ " `#\\newline`, `#\\space`, `#\\tab`, `#\\return`, `#\\xNN`, and `#\\<char>`" +
562
+ " are accepted (read as strings); other `#\\<name>` forms are not." +
563
+ " Use a string literal instead, e.g. \"\\n\".";
521
564
  }
522
565
  return { ok: false as const, error: msg };
523
566
  } finally {
@@ -1370,6 +1413,25 @@ function installStdShims(env: any): void {
1370
1413
  });
1371
1414
  defineIfMissing("char-upcase", (c: any) => String(c).toUpperCase());
1372
1415
  defineIfMissing("char-downcase", (c: any) => String(c).toLowerCase());
1416
+ // Characters are 1-char strings; char? therefore can't tell a char from a
1417
+ // length-1 string.
1418
+ const charStr = (c: any) => (typeof c === "string" ? c : String(c));
1419
+ const codeOf = (c: any) => charStr(c).codePointAt(0) ?? 0;
1420
+ defineIfMissing("char->integer", (c: any) => codeOf(c));
1421
+ defineIfMissing("integer->char", (n: any) => String.fromCodePoint(Math.max(0, Math.floor(Number(n) || 0))));
1422
+ defineIfMissing("char?", (x: any) => typeof x === "string" && Array.from(x).length === 1);
1423
+ defineIfMissing("char=?", (a: any, b: any) => charStr(a) === charStr(b));
1424
+ defineIfMissing("char<?", (a: any, b: any) => codeOf(a) < codeOf(b));
1425
+ defineIfMissing("char>?", (a: any, b: any) => codeOf(a) > codeOf(b));
1426
+ defineIfMissing("char<=?", (a: any, b: any) => codeOf(a) <= codeOf(b));
1427
+ defineIfMissing("char>=?", (a: any, b: any) => codeOf(a) >= codeOf(b));
1428
+ defineIfMissing("char-ci=?", (a: any, b: any) => charStr(a).toLowerCase() === charStr(b).toLowerCase());
1429
+ defineIfMissing("char-alphabetic?", (c: any) => /^\p{L}$/u.test(charStr(c)));
1430
+ defineIfMissing("char-numeric?", (c: any) => /^\p{Nd}$/u.test(charStr(c)));
1431
+ defineIfMissing("char-whitespace?", (c: any) => /^\s$/.test(charStr(c)));
1432
+ defineIfMissing("char-upper-case?", (c: any) => { const s = charStr(c); return s.length === 1 && s === s.toUpperCase() && s !== s.toLowerCase(); });
1433
+ defineIfMissing("char-lower-case?", (c: any) => { const s = charStr(c); return s.length === 1 && s === s.toLowerCase() && s !== s.toUpperCase(); });
1434
+ defineIfMissing("digit-value", (c: any) => { const s = charStr(c); return /^[0-9]$/.test(s) ? Number(s) : false; });
1373
1435
  defineIfMissing("string-normalize-spaces", (s: any, sep?: any, repl?: any) => {
1374
1436
  const str = String(s).trim();
1375
1437
  const splitOn = sep === undefined ? /\s+/ : (sep instanceof RegExp ? sep : new RegExp(String(sep)));
@@ -1500,9 +1562,9 @@ function installStdShims(env: any): void {
1500
1562
  }
1501
1563
 
1502
1564
  // Canonical names we claim coverage for. R = R7RS small base; S = SRFI-1;
1503
- // K = Racket racket/base/list/string/format. Continuations, ports, bytevectors,
1504
- // and char predicates are intentionally omitted — they'd be misleading
1505
- // "coverage" without real functionality.
1565
+ // K = Racket racket/base/list/string/format. Continuations, ports, and
1566
+ // bytevectors are intentionally omitted — they'd be misleading "coverage"
1567
+ // without real functionality. Characters are modeled as 1-char strings.
1506
1568
  const COVERAGE_CHECKLIST: string[] = [
1507
1569
  // R7RS § 6.1 equivalence
1508
1570
  "eq?", "eqv?", "equal?",
@@ -1584,6 +1646,10 @@ const COVERAGE_CHECKLIST: string[] = [
1584
1646
  // Racket strings & chars (gaps)
1585
1647
  "string-titlecase", "string-pad", "string-pad-right",
1586
1648
  "char-upcase", "char-downcase",
1649
+ "char->integer", "integer->char", "char?",
1650
+ "char=?", "char<?", "char>?", "char<=?", "char>=?", "char-ci=?",
1651
+ "char-alphabetic?", "char-numeric?", "char-whitespace?",
1652
+ "char-upper-case?", "char-lower-case?", "digit-value",
1587
1653
  "string-normalize-spaces", "build-string",
1588
1654
  // Racket hash
1589
1655
  "make-hash", "hash", "hash?", "hash-ref", "hash-set!", "hash-set",
@@ -1841,6 +1907,10 @@ function installBindings(
1841
1907
  env.set("string-contains", stringContains);
1842
1908
  env.set("string-append", (...parts: unknown[]) =>
1843
1909
  parts.map((p) => (p === undefined || p === null ? "" : String(p))).join(""));
1910
+ // LIPS' native `string` returns only its first argument; override to actually
1911
+ // concatenate (chars are 1-char strings here, so that's the right semantics).
1912
+ env.set("string", (...parts: unknown[]) =>
1913
+ parts.map((p) => (p === undefined || p === null ? "" : String(p))).join(""));
1844
1914
  env.set("number->string", (n: unknown) => String(n));
1845
1915
  env.set("string->number", (s: unknown) => {
1846
1916
  if (typeof s !== "string") return false;
@@ -1949,6 +2019,10 @@ const DESCRIPTION = [
1949
2019
  " - R7RS truthy semantics: anything that isn't `#f` is true. `(if str …)`,",
1950
2020
  " `(if 0 …)`, `(if '() …)` all take the then-branch.",
1951
2021
  " - `#t`/`#f` work as expected. `equal?`, `eq?`, `eqv?`, `string=?` all work.",
2022
+ " - Characters are 1-char strings (no separate char type). `#\\newline`,",
2023
+ " `#\\space`, `#\\tab`, `#\\return`, `#\\xNN`, and `#\\<char>` literals read as",
2024
+ " the equivalent string; `char->integer`/`integer->char`/`char?`/`char=?`/",
2025
+ " `char-whitespace?` etc. operate on them. A bare newline is just \"\\n\".",
1952
2026
  " - SRFI-1: `member`, `assq`/`assv`/`assoc`, `delete-duplicates`, `first`",
1953
2027
  " through `fifth`, `last`, `take`, `drop`, `iota`, `any`, `every`, `count`,",
1954
2028
  " `find`, `filter-map`, `append-map`, `concatenate`, `partition`, `remove`,",
@@ -36,8 +36,8 @@ import * as path from "node:path";
36
36
 
37
37
  function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean } {
38
38
  let model: string | undefined;
39
- let apiKey: string | undefined = process.env.OPENAI_API_KEY ?? process.env.OPENROUTER_API_KEY;
40
- let baseURL: string | undefined = process.env.OPENAI_BASE_URL;
39
+ let apiKey: string | undefined;
40
+ let baseURL: string | undefined;
41
41
  let provider: string | undefined;
42
42
  let backend: string | undefined;
43
43
  let continueLast = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.14.9",
3
+ "version": "0.14.10",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "workspaces": [