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.
- package/dist/agent/index.js +6 -0
- package/dist/agent/providers/ollama.d.ts +11 -0
- package/dist/agent/providers/ollama.js +72 -0
- package/dist/agent/providers/opencode.d.ts +10 -0
- package/dist/agent/providers/opencode.js +112 -0
- package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
- package/dist/agent/providers/zai-coding-plan.js +26 -0
- package/dist/cli/args.js +2 -2
- package/examples/extensions/ads/index.ts +695 -0
- package/examples/extensions/ash-scheme/index.ts +77 -3
- package/examples/extensions/ashi/src/cli.ts +2 -2
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -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,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
|
|
57
|
-
let baseURL
|
|
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,
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
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
|
|
40
|
-
let baseURL: string | undefined
|
|
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;
|