@staix/agent-research 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, mkdtempSync, opendirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, posix } from "node:path";
|
|
5
|
+
import { runOnExecutor, spawnRunner, validateExecutor, type Executor, type ProcessResult, type ProcessRunner } from "./executor.ts";
|
|
6
|
+
import { TASK_BYTES, TASK_FILES } from "./protocol.ts";
|
|
7
|
+
import { readBytesBounded, sha256 } from "../study.ts";
|
|
8
|
+
|
|
9
|
+
export const SNAPSHOT_BYTES = 256 * 1024 * 1024;
|
|
10
|
+
function fullCommit(value: string): string {
|
|
11
|
+
if (!/^[a-fA-F0-9]{40}$/.test(value)) throw new Error("snapshot commit: full 40-hex SHA required; pass the commit SHA from git rev-parse HEAD");
|
|
12
|
+
return value.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
function repository(value: string): string {
|
|
15
|
+
if (!value || value.length > 4096 || value.includes("\0")) throw new Error("snapshot repo: invalid path; pass a local Git repository path of 1..4096 characters without NUL");
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function passed(result: ProcessResult): boolean { return result.exitCode === 0 && !result.timedOut && !result.truncated; }
|
|
19
|
+
function gitFailure(step: string): Error { return new Error(`snapshot Git ${step} failed; check --repo and that --commit names an existing commit, then retry ares attempt run`); }
|
|
20
|
+
|
|
21
|
+
/** One pending chunk, with explicit cancellation instead of stream callback ordering. */
|
|
22
|
+
class ArchiveChannel implements AsyncIterableIterator<Uint8Array> {
|
|
23
|
+
private pending?: { chunk: Uint8Array; resolve: () => void; reject: (error: Error) => void };
|
|
24
|
+
private waiting?: { resolve: (value: IteratorResult<Uint8Array>) => void; reject: (error: Error) => void };
|
|
25
|
+
private ended = false;
|
|
26
|
+
private failure?: Error;
|
|
27
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<Uint8Array> { return this; }
|
|
28
|
+
write(chunk: Uint8Array): Promise<void> {
|
|
29
|
+
if (this.failure || this.ended) return Promise.reject(this.failure ?? new Error("archive channel closed"));
|
|
30
|
+
if (this.waiting) { const waiting = this.waiting; this.waiting = undefined; waiting.resolve({ value: chunk, done: false }); return Promise.resolve(); }
|
|
31
|
+
return new Promise((resolve, reject) => { this.pending = { chunk, resolve, reject }; });
|
|
32
|
+
}
|
|
33
|
+
next(): Promise<IteratorResult<Uint8Array>> {
|
|
34
|
+
if (this.pending) { const pending = this.pending; this.pending = undefined; pending.resolve(); return Promise.resolve({ value: pending.chunk, done: false }); }
|
|
35
|
+
if (this.failure) return Promise.reject(this.failure);
|
|
36
|
+
if (this.ended) return Promise.resolve({ value: undefined, done: true });
|
|
37
|
+
return new Promise((resolve, reject) => { this.waiting = { resolve, reject }; });
|
|
38
|
+
}
|
|
39
|
+
end(error?: Error): void {
|
|
40
|
+
if (this.ended) return;
|
|
41
|
+
this.ended = true; this.failure = error;
|
|
42
|
+
if (this.pending) { this.pending.reject(error ?? new Error("archive channel closed")); this.pending = undefined; }
|
|
43
|
+
if (this.waiting) { if (error) this.waiting.reject(error); else this.waiting.resolve({ value: undefined, done: true }); this.waiting = undefined; }
|
|
44
|
+
}
|
|
45
|
+
return(): Promise<IteratorResult<Uint8Array>> { this.end(new Error("archive consumer stopped")); return Promise.resolve({ value: undefined, done: true }); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve only an existing commit object, independent of the worktree or an annotated tag. */
|
|
49
|
+
export async function resolveSnapshot(repoArg: string, commitArg: string, runner: ProcessRunner = spawnRunner): Promise<{ commit: string; treeId: string }> {
|
|
50
|
+
const repo = repository(repoArg), commit = fullCommit(commitArg);
|
|
51
|
+
let type: ProcessResult, tree: ProcessResult;
|
|
52
|
+
try {
|
|
53
|
+
type = await runner({ command: "git", args: ["-C", repo, "cat-file", "-t", commit] });
|
|
54
|
+
if (!passed(type) || type.stdout.trim() !== "commit") throw gitFailure("commit validation");
|
|
55
|
+
} catch { throw gitFailure("commit validation"); }
|
|
56
|
+
try { tree = await runner({ command: "git", args: ["-C", repo, "rev-parse", "--verify", `${commit}^{tree}`] }); }
|
|
57
|
+
catch { throw gitFailure("tree resolution"); }
|
|
58
|
+
const treeId = tree.stdout.trim();
|
|
59
|
+
if (!passed(tree) || !/^[a-f0-9]{40}$/.test(treeId)) throw gitFailure("tree resolution");
|
|
60
|
+
return { commit, treeId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stream raw archive bytes with backpressure; nothing buffers or decodes the whole archive. */
|
|
64
|
+
export async function transferSnapshot(repoArg: string, commitArg: string, executorArg: Executor, destination: string, runner: ProcessRunner = spawnRunner): Promise<{ sha256: string; bytes: number }> {
|
|
65
|
+
const repo = repository(repoArg), commit = fullCommit(commitArg);
|
|
66
|
+
return transferArchive("snapshot", { command: "git", args: ["-C", repo, "archive", "--format=tar", commit] }, SNAPSHOT_BYTES, executorArg, destination, runner);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Capture each file once; the hash binds the same bytes copied into private immutable evaluator staging. */
|
|
70
|
+
export async function transferTask(taskDir: string, executor: Executor, destination: string, runner: ProcessRunner = spawnRunner, expectedTreeHash?: string): Promise<{ sha256: string; bytes: number; treeHash: string }> {
|
|
71
|
+
repository(taskDir);
|
|
72
|
+
if (expectedTreeHash !== undefined && !/^[a-f0-9]{64}$/.test(expectedTreeHash)) throw new Error("task tree hash: invalid; use the approved taskTreeHash before transferring evaluator files");
|
|
73
|
+
const staging = mkdtempSync(join(tmpdir(), "ares-task-")), entries: [string, string][] = [];
|
|
74
|
+
let total = 0, directories = 0;
|
|
75
|
+
try {
|
|
76
|
+
const evaluator = lstatSync(join(taskDir, "evaluator"));
|
|
77
|
+
if (!evaluator.isDirectory() || evaluator.isSymbolicLink()) throw new Error("task/evaluator: regular directory required; restore evaluator files before ares attempt run");
|
|
78
|
+
mkdirSync(join(staging, "evaluator"), { mode: 0o700 });
|
|
79
|
+
function capture(relative: string, depth: number): void {
|
|
80
|
+
const source = relative ? join(taskDir, relative) : taskDir, label = relative ? `task/${relative}` : "task/", stat = lstatSync(source);
|
|
81
|
+
if (stat.isSymbolicLink()) throw new Error(`${label}: symlink refused; replace it with regular task files before ares attempt run`);
|
|
82
|
+
if (stat.isDirectory()) {
|
|
83
|
+
if (++directories > 1000 || depth > 64) throw new Error(`${label}: task traversal limit exceeded; reduce task/ to 1000 directories and 64 levels`);
|
|
84
|
+
if (relative.startsWith("evaluator/")) mkdirSync(join(staging, relative), { recursive: true, mode: 0o700 });
|
|
85
|
+
const directory = opendirSync(source);
|
|
86
|
+
try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) capture(relative ? `${relative}/${entry.name}` : entry.name, depth + 1); }
|
|
87
|
+
finally { directory.closeSync(); }
|
|
88
|
+
} else {
|
|
89
|
+
if (!stat.isFile()) throw new Error(`${label}: regular file required; remove special files before ares attempt run`);
|
|
90
|
+
if (entries.length >= TASK_FILES || stat.size > TASK_BYTES - total) throw new Error("task/: task size limit exceeded; reduce task/ to 500 files and 64 MiB before ares attempt run");
|
|
91
|
+
let content: Buffer;
|
|
92
|
+
try { content = readBytesBounded(source, TASK_BYTES - total); }
|
|
93
|
+
catch { throw new Error(`${label}: bounded read failed; restore a readable regular file within the 64 MiB task limit`); }
|
|
94
|
+
total += content.length; entries.push([relative, sha256(content)]);
|
|
95
|
+
if (relative.startsWith("evaluator/")) {
|
|
96
|
+
const target = join(staging, relative); mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
97
|
+
writeFileSync(target, content, { mode: stat.mode & 0o777 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
capture("", 0);
|
|
102
|
+
const treeHash = sha256(JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
|
|
103
|
+
if (expectedTreeHash !== undefined && treeHash !== expectedTreeHash) throw new Error("task/: content changed since approval; set the protocol, approve design again, and retry ares attempt run");
|
|
104
|
+
// Bounded tar headers and long-path metadata supplement the 64 MiB task payload cap.
|
|
105
|
+
const transferred = await transferArchive("task", { command: "tar", args: ["-C", staging, "-cf", "-", "evaluator"] }, TASK_BYTES + 16 * 1024 * 1024, executor, destination, runner);
|
|
106
|
+
return { ...transferred, treeHash };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof Error && (error.message.startsWith("task/") || error.message.startsWith("executor "))) throw error;
|
|
109
|
+
throw new Error("task archive capture failed; restore readable task/evaluator files, then retry ares attempt run");
|
|
110
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function transferArchive(kind: "snapshot" | "task", source: { command: string; args: string[] }, limit: number, executorArg: Executor, destination: string, runner: ProcessRunner): Promise<{ sha256: string; bytes: number }> {
|
|
114
|
+
const executor = validateExecutor(executorArg);
|
|
115
|
+
if (!destination.startsWith("/") || destination.length > 4096 || /[\0\r\n,"]/.test(destination) || posix.normalize(destination) === "/") throw new Error("snapshot destination: invalid directory; use a private absolute non-root directory without NUL, newlines, commas or double quotes");
|
|
116
|
+
const stream = new ArchiveChannel(), controller = new AbortController(), hash = createHash("sha256");
|
|
117
|
+
let bytes = 0, oversized = false, failedSide: "archive" | "extract" | undefined;
|
|
118
|
+
const archive = (async () => {
|
|
119
|
+
try {
|
|
120
|
+
const result = await runner({ ...source, signal: controller.signal, timeoutMs: 300_000,
|
|
121
|
+
stdoutSink: async chunk => {
|
|
122
|
+
if (bytes + chunk.byteLength > limit) { oversized = true; throw new Error("archive size limit"); }
|
|
123
|
+
bytes += chunk.byteLength; hash.update(chunk);
|
|
124
|
+
await stream.write(chunk);
|
|
125
|
+
} });
|
|
126
|
+
if (!passed(result)) throw gitFailure("archive");
|
|
127
|
+
stream.end();
|
|
128
|
+
} catch (error) { failedSide ??= "archive"; stream.end(error as Error); throw error; }
|
|
129
|
+
})();
|
|
130
|
+
const extract = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const result = await runOnExecutor(executor, "tar", ["--no-same-owner", "-xf", "-", "-C", destination], runner, { stdin: stream, signal: controller.signal, timeoutMs: 300_000 });
|
|
133
|
+
if (!passed(result)) throw new Error("archive extraction failed");
|
|
134
|
+
} catch (error) { failedSide ??= "extract"; throw error; }
|
|
135
|
+
})();
|
|
136
|
+
try {
|
|
137
|
+
await Promise.all([archive, extract]);
|
|
138
|
+
if (bytes === 0) { failedSide = "archive"; throw gitFailure("empty archive"); }
|
|
139
|
+
return { sha256: hash.digest("hex"), bytes };
|
|
140
|
+
} catch {
|
|
141
|
+
controller.abort(); stream.end(new Error("archive transfer cancelled"));
|
|
142
|
+
await Promise.allSettled([archive, extract]);
|
|
143
|
+
if (oversized) throw new Error(`${kind} archive: larger than ${limit} bytes; reduce the ${kind === "snapshot" ? "committed snapshot" : "task evaluator"} before ares attempt run`);
|
|
144
|
+
if (failedSide === "archive") throw kind === "snapshot" ? gitFailure("archive") : new Error("task archive failed; restore readable task/evaluator files, then retry ares attempt run");
|
|
145
|
+
throw new Error(`executor ${executor.kind === "ssh" ? executor.host : "local"}: snapshot extraction failed; check the private destination directory and tar on the executor host, then retry ares attempt run`);
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { version } from "../package.json";
|
|
2
|
+
|
|
3
|
+
export type FetchLike = (url: string, init?: { headers?: Record<string, string>; signal?: AbortSignal }) => Promise<Response>;
|
|
4
|
+
export type HttpErrorKind = "network" | "rate" | "server" | "client" | "format";
|
|
5
|
+
/** Errors name the host and status only: request URLs can carry API keys. */
|
|
6
|
+
export class HttpError extends Error {
|
|
7
|
+
constructor(readonly kind: HttpErrorKind, readonly host: string, readonly status?: number) { super(`${host}: ${kind}${status ? ` ${status}` : ""}`); }
|
|
8
|
+
}
|
|
9
|
+
export interface HttpResult<T> { status: number; body: T | undefined }
|
|
10
|
+
export interface Http {
|
|
11
|
+
json(url: string, headers?: Record<string, string>): Promise<HttpResult<unknown>>;
|
|
12
|
+
text(url: string, headers?: Record<string, string>): Promise<HttpResult<string>>;
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
}
|
|
15
|
+
const TIMEOUT_MS = 20_000, MAX_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
const EMAIL = /^[^\s@<>()]{1,64}@[^\s@<>()]{1,190}$/;
|
|
17
|
+
|
|
18
|
+
export const S2_START_INTERVAL_MS = 1_100;
|
|
19
|
+
export interface HttpPacingClock {
|
|
20
|
+
now(): number;
|
|
21
|
+
sleep(ms: number): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface HttpPacing {
|
|
24
|
+
start<T>(request: () => Promise<T>): Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Serialize admissions only: an in-flight response never holds the next start. */
|
|
28
|
+
export function createHttpPacing(clock: HttpPacingClock = {
|
|
29
|
+
now: () => performance.now(),
|
|
30
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
31
|
+
}): HttpPacing {
|
|
32
|
+
let tail = Promise.resolve(), lastStart: number | undefined;
|
|
33
|
+
return {
|
|
34
|
+
start<T>(request: () => Promise<T>): Promise<T> {
|
|
35
|
+
const admitted = tail.then(async () => {
|
|
36
|
+
if (lastStart !== undefined) {
|
|
37
|
+
let remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
|
|
38
|
+
while (remaining > 0) {
|
|
39
|
+
await clock.sleep(remaining);
|
|
40
|
+
remaining = S2_START_INTERVAL_MS - (clock.now() - lastStart);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Measure after invocation, conservatively spacing actual fetch starts.
|
|
44
|
+
let response: Promise<T>;
|
|
45
|
+
try { response = request(); } finally { lastStart = clock.now(); }
|
|
46
|
+
// Box the promise so admission releases without waiting for the response.
|
|
47
|
+
// A later admission must not delay handling a rejected response.
|
|
48
|
+
void response.catch(() => {});
|
|
49
|
+
return { response };
|
|
50
|
+
});
|
|
51
|
+
tail = admitted.then(() => {}, () => {});
|
|
52
|
+
return admitted.then(({ response }) => response);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const processS2Pacing = createHttpPacing();
|
|
58
|
+
|
|
59
|
+
/** Read the body while counting bytes; past the limit the stream is cancelled, never buffered whole. */
|
|
60
|
+
async function readBounded(response: Response, host: string): Promise<string> {
|
|
61
|
+
if (!response.body) return "";
|
|
62
|
+
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
|
|
63
|
+
let size = 0;
|
|
64
|
+
try {
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
size += value.byteLength;
|
|
69
|
+
if (size > MAX_BYTES) { await reader.cancel().catch(() => {}); throw new HttpError("format", host); }
|
|
70
|
+
chunks.push(value);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) { if (error instanceof HttpError) throw error; throw new HttpError("network", host); }
|
|
73
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); } catch { throw new HttpError("format", host); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function userAgent(env: NodeJS.ProcessEnv): string {
|
|
77
|
+
const email = env.ARES_CONTACT_EMAIL?.trim();
|
|
78
|
+
return `agent-research/${version}${email && EMAIL.test(email) ? ` (mailto:${email})` : ""}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 200 returns the body, 404 returns `body: undefined`; 429, 5xx, other statuses and network failures throw HttpError. */
|
|
82
|
+
export function createHttp(env: NodeJS.ProcessEnv = process.env, fetchImpl: FetchLike = fetch as FetchLike, pacing: HttpPacing = processS2Pacing): Http {
|
|
83
|
+
async function get(url: string, headers: Record<string, string> = {}): Promise<HttpResult<string>> {
|
|
84
|
+
const host = new URL(url).host;
|
|
85
|
+
let response: Response;
|
|
86
|
+
try {
|
|
87
|
+
const request = () => fetchImpl(url, { headers: { "user-agent": userAgent(env), ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
88
|
+
response = await (new URL(url).hostname === "api.semanticscholar.org" ? pacing.start(request) : request());
|
|
89
|
+
}
|
|
90
|
+
catch { throw new HttpError("network", host); }
|
|
91
|
+
if (response.status === 404) return { status: 404, body: undefined };
|
|
92
|
+
if (response.status === 429) throw new HttpError("rate", host, 429);
|
|
93
|
+
if (response.status >= 500) throw new HttpError("server", host, response.status);
|
|
94
|
+
if (response.status !== 200) throw new HttpError("client", host, response.status);
|
|
95
|
+
return { status: 200, body: await readBounded(response, host) };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
env,
|
|
99
|
+
text: get,
|
|
100
|
+
async json(url, headers) {
|
|
101
|
+
const result = await get(url, headers);
|
|
102
|
+
if (result.body === undefined) return { status: result.status, body: undefined };
|
|
103
|
+
try { return { status: 200, body: JSON.parse(result.body) }; } catch { throw new HttpError("format", new URL(url).host); }
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * as ares from "./commands.ts";
|
|
2
|
+
export { createHttp, HttpError, type Http, type FetchLike } from "./http.ts";
|
|
3
|
+
export { detectActor, type Actor, type Study, type LedgerEvent, type GateState } from "./study.ts";
|
|
4
|
+
export type { Candidate, Verification, Registry } from "./lit/candidates.ts";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isVerified, type Candidate } from "./candidates.ts";
|
|
2
|
+
|
|
3
|
+
const STOP = new Set(["the", "a", "an", "on", "of", "for", "and", "in", "to", "with", "towards", "toward", "from", "via", "by", "at", "is", "are"]);
|
|
4
|
+
const ascii = (text: string) => text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z]/g, "").toLowerCase();
|
|
5
|
+
|
|
6
|
+
/** "Family, Given" stays; "Given Family" becomes "Family, Given". */
|
|
7
|
+
export function bibName(name: string): string {
|
|
8
|
+
if (name.includes(",")) return name.trim();
|
|
9
|
+
const parts = name.trim().split(/\s+/);
|
|
10
|
+
return parts.length < 2 ? parts[0] ?? "" : `${parts.at(-1)}, ${parts.slice(0, -1).join(" ")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function baseKey(c: Candidate): string {
|
|
14
|
+
const meta = c.verify?.registry, first = (meta?.authors ?? c.authors)[0];
|
|
15
|
+
const family = first ? ascii(bibName(first).split(",")[0]!) : "";
|
|
16
|
+
const year = meta?.year ?? c.year;
|
|
17
|
+
const word = (meta?.title ?? c.title).split(/\s+/).map(ascii).find(w => w.length >= 3 && !STOP.has(w)) ?? "";
|
|
18
|
+
const key = `${family}${year ?? ""}${word}`;
|
|
19
|
+
return /[a-z]/.test(key) ? key : `ref${year ?? ""}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Assign keys once, in id order, with a, b, ... suffixes on collision. Assigned keys never change. */
|
|
23
|
+
export function assignCitekeys(list: Candidate[], eligible: Candidate[]): number {
|
|
24
|
+
const taken = new Set(list.map(c => c.citekey).filter((k): k is string => !!k));
|
|
25
|
+
let assigned = 0;
|
|
26
|
+
for (const c of [...eligible].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
|
|
27
|
+
if (c.citekey) continue;
|
|
28
|
+
const base = baseKey(c);
|
|
29
|
+
let key = base;
|
|
30
|
+
for (let n = 0; taken.has(key); n++) key = `${base}${n < 26 ? String.fromCharCode(97 + n) : n}`;
|
|
31
|
+
c.citekey = key; taken.add(key); assigned++;
|
|
32
|
+
}
|
|
33
|
+
return assigned;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function escapeBib(text: string): string {
|
|
37
|
+
// A private-use placeholder keeps the backslash replacement from being escaped again.
|
|
38
|
+
return text.replace(/\uE000/g, "").replace(/\\/g, "\uE000").replace(/([{}&%$#_])/g, "\\$1").replace(/~/g, "\\textasciitilde{}").replace(/\^/g, "\\textasciicircum{}").replace(/\uE000/g, "\\textbackslash{}");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function bibEntry(c: Candidate): string {
|
|
42
|
+
const meta = c.verify?.registry, type = meta?.type ?? "";
|
|
43
|
+
const kind = type === "journal-article" ? "article" : type === "proceedings-article" ? "inproceedings" : "misc";
|
|
44
|
+
const fields: [string, string][] = [];
|
|
45
|
+
const authors = meta?.authors.length ? meta.authors : c.authors;
|
|
46
|
+
if (authors.length) fields.push(["author", authors.map(a => escapeBib(bibName(a))).join(" and ")]);
|
|
47
|
+
fields.push(["title", `{${escapeBib(meta?.title ?? c.title)}}`]);
|
|
48
|
+
const year = meta?.year ?? c.year;
|
|
49
|
+
if (year !== undefined) fields.push(["year", String(year)]);
|
|
50
|
+
const venue = meta?.venue ?? c.venue;
|
|
51
|
+
if (venue) fields.push([kind === "article" ? "journal" : kind === "inproceedings" ? "booktitle" : "publisher", escapeBib(venue)]);
|
|
52
|
+
if (c.doi) fields.push(["doi", escapeBib(c.doi)]);
|
|
53
|
+
if (c.arxivId) { fields.push(["eprint", escapeBib(c.arxivId)], ["archiveprefix", "arXiv"]); if (!c.doi) fields.push(["url", `https://arxiv.org/abs/${c.arxivId}`]); }
|
|
54
|
+
return `@${kind}{${c.citekey},\n${fields.map(([k, v]) => ` ${k} = {${v}}`).join(",\n")}\n}\n`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Only included and verified candidates are written. */
|
|
58
|
+
export function renderBib(list: Candidate[]): { text: string; entries: number } {
|
|
59
|
+
const eligible = list.filter(c => c.status === "included" && isVerified(c) && c.citekey).sort((a, b) => (a.citekey! < b.citekey! ? -1 : 1));
|
|
60
|
+
return { text: `% Generated by ares (agent-research). Do not edit: rerun ares lit bib.\n\n${eligible.map(bibEntry).join("\n")}`, entries: eligible.length };
|
|
61
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { plain, readJsonFile, sha256, writeAtomic, type Actor, type FileWrite } from "../study.ts";
|
|
4
|
+
|
|
5
|
+
export interface Registry { title: string; authors: string[]; year?: number; venue?: string; type?: string }
|
|
6
|
+
export interface Verification {
|
|
7
|
+
syntax: "ok" | "none" | "bad"; exists: "yes" | "no" | "unknown"; match: "yes" | "partial" | "no" | "unknown";
|
|
8
|
+
via?: "crossref" | "datacite" | "arxiv"; checkedAt: string; error?: string; registry?: Registry;
|
|
9
|
+
/** Hash of the identifiers, title and year that were checked; see verifySubject. */
|
|
10
|
+
subject?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RawCandidate { title: string; authors: string[]; year?: number; venue?: string; doi?: string; arxivId?: string; url?: string; abstract?: string; source: string }
|
|
13
|
+
export interface Candidate extends Omit<RawCandidate, "source"> {
|
|
14
|
+
id: string; sources: string[]; addedAt: string;
|
|
15
|
+
status: "new" | "included" | "excluded"; reason?: string; decidedBy?: Actor; decidedAt?: string;
|
|
16
|
+
citekey?: string; verify?: Verification;
|
|
17
|
+
/** Other candidates that a record linked to this one but could not be absorbed (screened or keyed). */
|
|
18
|
+
duplicates?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ABSTRACT_MAX = 2000, TITLE_MAX = 1000, AUTHORS_MAX = 100, AUTHOR_MAX = 300, CANDIDATES_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
const ARXIV_DOI = /^10\.48550\/arxiv\.(.+)$/i;
|
|
23
|
+
|
|
24
|
+
export function normTitle(title: string): string {
|
|
25
|
+
return title.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
26
|
+
}
|
|
27
|
+
export function normDoi(doi: string | undefined): string | undefined {
|
|
28
|
+
if (!doi) return undefined;
|
|
29
|
+
const value = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, "").replace(/^doi:/i, "").toLowerCase();
|
|
30
|
+
return value || undefined;
|
|
31
|
+
}
|
|
32
|
+
export function normArxiv(id: string | undefined): string | undefined {
|
|
33
|
+
if (!id) return undefined;
|
|
34
|
+
const value = id.trim().replace(/^https?:\/\/arxiv\.org\/abs\//i, "").replace(/^arxiv:/i, "").replace(/v\d+$/, "");
|
|
35
|
+
return value || undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Clean one source record: bounded fields, arXiv DOIs mapped to the arXiv id. */
|
|
39
|
+
export function cleanRaw(raw: RawCandidate): RawCandidate | undefined {
|
|
40
|
+
const title = raw.title.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
|
|
41
|
+
if (!title) return undefined;
|
|
42
|
+
let doi = normDoi(raw.doi), arxivId = normArxiv(raw.arxivId);
|
|
43
|
+
const arxivDoi = doi && ARXIV_DOI.exec(doi);
|
|
44
|
+
if (arxivDoi) { arxivId ??= normArxiv(arxivDoi[1]); doi = undefined; }
|
|
45
|
+
return {
|
|
46
|
+
title, authors: raw.authors.map(a => a.replace(/\s+/g, " ").trim().slice(0, AUTHOR_MAX)).filter(Boolean).slice(0, AUTHORS_MAX), source: raw.source,
|
|
47
|
+
...(raw.year !== undefined && Number.isSafeInteger(raw.year) ? { year: raw.year } : {}),
|
|
48
|
+
...(raw.venue?.trim() ? { venue: raw.venue.trim().slice(0, 500) } : {}),
|
|
49
|
+
...(doi ? { doi } : {}), ...(arxivId ? { arxivId } : {}),
|
|
50
|
+
...(raw.url?.trim() ? { url: raw.url.trim().slice(0, 2000) } : {}),
|
|
51
|
+
...(raw.abstract?.trim() ? { abstract: raw.abstract.replace(/\s+/g, " ").trim().slice(0, ABSTRACT_MAX) } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function candidateId(raw: RawCandidate): string {
|
|
56
|
+
if (raw.doi) return `doi:${raw.doi}`;
|
|
57
|
+
if (raw.arxivId) return `arxiv:${raw.arxivId}`;
|
|
58
|
+
return `title:${sha256(`${normTitle(raw.title)}|${raw.year ?? ""}`).slice(0, 12)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const titleKey = (c: { title: string; year?: number }) => `${normTitle(c.title)}|${c.year ?? ""}`;
|
|
62
|
+
|
|
63
|
+
/** Groups of candidates that are the same work by DOI, arXiv id, title plus year, or a recorded duplicate link. */
|
|
64
|
+
export function sameWork(list: Candidate[]): string[][] {
|
|
65
|
+
const parent = new Map(list.map(c => [c.id, c.id]));
|
|
66
|
+
const root = (id: string): string => { const p = parent.get(id)!; return p === id ? id : root(p); };
|
|
67
|
+
const join = (a: string, b: string) => { if (parent.has(a) && parent.has(b)) parent.set(root(a), root(b)); };
|
|
68
|
+
const seen = new Map<string, string>();
|
|
69
|
+
for (const c of list) {
|
|
70
|
+
for (const key of [c.doi && `doi:${c.doi}`, c.arxivId && `arxiv:${c.arxivId}`, `title:${titleKey(c)}`]) {
|
|
71
|
+
if (!key) continue;
|
|
72
|
+
const first = seen.get(key);
|
|
73
|
+
if (first) join(first, c.id); else seen.set(key, c.id);
|
|
74
|
+
}
|
|
75
|
+
for (const d of c.duplicates ?? []) join(c.id, d);
|
|
76
|
+
}
|
|
77
|
+
const groups = new Map<string, string[]>();
|
|
78
|
+
for (const c of list) { const r = root(c.id); groups.set(r, [...(groups.get(r) ?? []), c.id]); }
|
|
79
|
+
return [...groups.values()].filter(g => g.length > 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Merge source records into the list. A record joins an existing candidate on DOI, arXiv id or normalized
|
|
84
|
+
* title plus year; the candidate keeps its first id so screening decisions and citekeys stay attached.
|
|
85
|
+
*/
|
|
86
|
+
function absorb(into: Candidate, from: Omit<RawCandidate, "source"> & { sources?: string[]; source?: string }): void {
|
|
87
|
+
for (const source of from.sources ?? (from.source ? [from.source] : [])) if (!into.sources.includes(source)) into.sources.push(source);
|
|
88
|
+
for (const key of ["doi", "arxivId", "year", "venue", "url", "abstract"] as const) if (into[key] === undefined && from[key] !== undefined) (into as unknown as Record<string, unknown>)[key] = from[key];
|
|
89
|
+
if (!into.authors.length && from.authors.length) into.authors = from.authors;
|
|
90
|
+
}
|
|
91
|
+
function link(a: Candidate, b: Candidate): void {
|
|
92
|
+
a.duplicates = [...new Set([...(a.duplicates ?? []), b.id])];
|
|
93
|
+
b.duplicates = [...new Set([...(b.duplicates ?? []), a.id])];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function mergeCandidates(list: Candidate[], incoming: RawCandidate[], limit: number, now = new Date().toISOString()): { added: number; merged: number; dropped: number; linked: number } {
|
|
97
|
+
let added = 0, merged = 0, dropped = 0, linked = 0;
|
|
98
|
+
for (const raw of incoming) {
|
|
99
|
+
const clean = cleanRaw(raw);
|
|
100
|
+
if (!clean) { dropped++; continue; }
|
|
101
|
+
const matches = list.filter(c => (clean.doi && c.doi === clean.doi) || (clean.arxivId && c.arxivId === clean.arxivId) || titleKey(c) === titleKey(clean));
|
|
102
|
+
const [primary, ...others] = matches;
|
|
103
|
+
if (primary) {
|
|
104
|
+
// A record that links several candidates folds the unscreened, unkeyed ones into the first; the rest are flagged.
|
|
105
|
+
for (const other of others) {
|
|
106
|
+
if (other.status === "new" && !other.citekey) {
|
|
107
|
+
absorb(primary, other);
|
|
108
|
+
for (const twin of other.duplicates ?? []) { const t = list.find(c => c.id === twin); if (t && t !== primary) { t.duplicates = t.duplicates!.filter(d => d !== other.id); link(primary, t); } }
|
|
109
|
+
list.splice(list.indexOf(other), 1);
|
|
110
|
+
}
|
|
111
|
+
else { link(primary, other); linked++; }
|
|
112
|
+
}
|
|
113
|
+
absorb(primary, clean);
|
|
114
|
+
merged++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (list.length >= limit) { dropped++; continue; }
|
|
118
|
+
const { source, ...rest } = clean;
|
|
119
|
+
list.push({ ...rest, id: candidateId(clean), sources: [source], addedAt: now, status: "new" });
|
|
120
|
+
added++;
|
|
121
|
+
}
|
|
122
|
+
return { added, merged, dropped, linked };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function candidatesPath(dir: string): string { return join(dir, "literature", "candidates.json"); }
|
|
126
|
+
export function loadCandidates(dir: string): Candidate[] {
|
|
127
|
+
const path = candidatesPath(dir);
|
|
128
|
+
if (!existsSync(path)) return [];
|
|
129
|
+
const raw = readJsonFile(path, CANDIDATES_BYTES);
|
|
130
|
+
if (!Array.isArray(raw) || !raw.every(c => plain(c) && typeof c.id === "string" && typeof c.title === "string" && Array.isArray(c.sources)))
|
|
131
|
+
throw new Error("literature/candidates.json: malformed");
|
|
132
|
+
return raw as Candidate[];
|
|
133
|
+
}
|
|
134
|
+
export function candidatesFile(dir: string, list: Candidate[]): FileWrite { return [candidatesPath(dir), JSON.stringify(list, null, 1) + "\n", CANDIDATES_BYTES]; }
|
|
135
|
+
export function saveCandidates(dir: string, list: Candidate[]): void { writeAtomic(...candidatesFile(dir, list)); }
|
|
136
|
+
|
|
137
|
+
/** The fields a verification checked. A later merge that adds a DOI, arXiv id or year makes an older verification stale. */
|
|
138
|
+
export function verifySubject(c: Pick<Candidate, "doi" | "arxivId" | "title" | "year">): string {
|
|
139
|
+
return sha256(JSON.stringify([c.doi ?? null, c.arxivId ?? null, normTitle(c.title), c.year ?? null]));
|
|
140
|
+
}
|
|
141
|
+
export function isVerified(c: Candidate): boolean {
|
|
142
|
+
return c.verify?.syntax === "ok" && c.verify.exists === "yes" && c.verify.match === "yes" && c.verify.subject === verifySubject(c);
|
|
143
|
+
}
|
|
144
|
+
/** The screen gate binds to the included set. */
|
|
145
|
+
export function includedHash(list: Candidate[]): string | undefined {
|
|
146
|
+
const ids = list.filter(c => c.status === "included").map(c => c.id).sort();
|
|
147
|
+
return ids.length ? sha256(ids.join("\n")) : undefined;
|
|
148
|
+
}
|
package/src/lit/cards.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { boundedText, onlyKeys, plain, sha256 } from "../study.ts";
|
|
3
|
+
|
|
4
|
+
export interface Card { summary: string; claims: string[]; methods?: string; findings?: string; limitations?: string; relevance?: string }
|
|
5
|
+
const OPTIONAL = ["methods", "findings", "limitations", "relevance"] as const;
|
|
6
|
+
|
|
7
|
+
export function validateCard(raw: unknown): Card {
|
|
8
|
+
if (!plain(raw)) throw new Error("card: JSON object required");
|
|
9
|
+
onlyKeys(raw, ["summary", "claims", ...OPTIONAL], "card");
|
|
10
|
+
const claims = raw.claims ?? [];
|
|
11
|
+
if (!Array.isArray(claims) || claims.length > 50) throw new Error("card.claims: list of at most 50");
|
|
12
|
+
const card: Card = { summary: boundedText(raw.summary, "card.summary", 4000), claims: claims.map((c, i) => boundedText(c, `card.claims[${i}]`, 1000)) };
|
|
13
|
+
for (const key of OPTIONAL) if (raw[key] !== undefined) card[key] = boundedText(raw[key], `card.${key}`, 4000);
|
|
14
|
+
return card;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A readable, collision-free file name for a candidate id such as `doi:10.1/x`. */
|
|
18
|
+
export function cardPath(dir: string, id: string): string {
|
|
19
|
+
const readable = id.toLowerCase().replace(/[^a-z0-9.-]+/g, "_").slice(0, 80);
|
|
20
|
+
return join(dir, "literature", "cards", `${readable}-${sha256(id).slice(0, 8)}.json`);
|
|
21
|
+
}
|
package/src/lit/cite.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isVerified, type Candidate } from "./candidates.ts";
|
|
2
|
+
|
|
3
|
+
/** Keys cited as `[@key]` or `[see @a, p. 3; @b]` (Pandoc style). */
|
|
4
|
+
export function citationOccurrences(text: string): { key: string; start: number; end: number; groupStart: number; groupEnd: number }[] {
|
|
5
|
+
const result: { key: string; start: number; end: number; groupStart: number; groupEnd: number }[] = [];
|
|
6
|
+
for (const group of text.matchAll(/\[([^\[\]]*?@[^\[\]]*?)\]/g)) {
|
|
7
|
+
for (const match of group[1]!.matchAll(/(?:^|[\s;,(-])@([A-Za-z0-9_](?:[A-Za-z0-9_:.#$%&+?<>~/-]*[A-Za-z0-9_])?)/g)) {
|
|
8
|
+
const start = group.index + 1 + match.index + match[0].indexOf("@");
|
|
9
|
+
result.push({ key: match[1]!, start, end: start + match[1]!.length + 1, groupStart: group.index, groupEnd: group.index + group[0].length });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
export function citedKeys(text: string): { count: number; keys: string[] } {
|
|
15
|
+
const occurrences = citationOccurrences(text);
|
|
16
|
+
return { count: occurrences.length, keys: [...new Set(occurrences.map(hit => hit.key))] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CiteReport { ok: boolean; citations: number; keys: string[]; unknown: string[]; notVerified: string[] }
|
|
20
|
+
export function checkCitations(text: string, list: Candidate[]): CiteReport {
|
|
21
|
+
const { count, keys } = citedKeys(text);
|
|
22
|
+
const byKey = new Map(list.filter(c => c.citekey).map(c => [c.citekey!, c]));
|
|
23
|
+
const unknown = keys.filter(k => !byKey.has(k));
|
|
24
|
+
const notVerified = keys.filter(k => { const c = byKey.get(k); return !!c && !(c.status === "included" && isVerified(c)); });
|
|
25
|
+
return { ok: !unknown.length && !notVerified.length, citations: count, keys, unknown, notVerified };
|
|
26
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { HttpError, type Http } from "../http.ts";
|
|
2
|
+
import type { RawCandidate } from "./candidates.ts";
|
|
3
|
+
|
|
4
|
+
export type SourceName = "openalex" | "s2" | "arxiv";
|
|
5
|
+
export const SOURCES: readonly SourceName[] = ["openalex", "s2", "arxiv"];
|
|
6
|
+
export interface SearchOptions { limit: number; yearFrom?: number; yearTo?: number }
|
|
7
|
+
export interface SourceResult { source: SourceName; ok: boolean; records: RawCandidate[]; error?: string }
|
|
8
|
+
|
|
9
|
+
const record = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {});
|
|
10
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined);
|
|
11
|
+
const int = (v: unknown): number | undefined => (Number.isSafeInteger(v) ? v as number : typeof v === "string" && /^\d{4}$/.test(v) ? Number(v) : undefined);
|
|
12
|
+
|
|
13
|
+
/** OpenAlex returns abstracts as an inverted index: word -> positions. */
|
|
14
|
+
export function invertedAbstract(index: unknown): string | undefined {
|
|
15
|
+
const words: string[] = [];
|
|
16
|
+
for (const [word, positions] of Object.entries(record(index))) {
|
|
17
|
+
if (!Array.isArray(positions)) continue;
|
|
18
|
+
for (const p of positions) if (Number.isSafeInteger(p) && p >= 0 && p < 5000) words[p] = word;
|
|
19
|
+
}
|
|
20
|
+
const text = words.filter(Boolean).join(" ");
|
|
21
|
+
return text || undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseOpenAlex(body: unknown): RawCandidate[] {
|
|
25
|
+
const results = record(body).results;
|
|
26
|
+
if (!Array.isArray(results)) throw new Error("openalex: unexpected response");
|
|
27
|
+
return results.map(raw => {
|
|
28
|
+
const r = record(raw), ids = record(r.ids), location = record(r.primary_location), source = record(location.source);
|
|
29
|
+
const doi = str(r.doi), title = str(r.title) ?? str(r.display_name) ?? "";
|
|
30
|
+
const authors = Array.isArray(r.authorships) ? r.authorships.map(a => str(record(record(a).author).display_name)).filter((a): a is string => !!a) : [];
|
|
31
|
+
const arxiv = str(ids.arxiv);
|
|
32
|
+
return {
|
|
33
|
+
source: "openalex", title, authors, year: int(r.publication_year), venue: str(source.display_name), doi, arxivId: arxiv,
|
|
34
|
+
url: str(location.landing_page_url) ?? str(r.id), abstract: invertedAbstract(r.abstract_inverted_index),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseS2(body: unknown): RawCandidate[] {
|
|
40
|
+
const data = record(body).data;
|
|
41
|
+
if (!Array.isArray(data)) throw new Error("s2: unexpected response");
|
|
42
|
+
return data.map(raw => {
|
|
43
|
+
const r = record(raw), ext = record(r.externalIds);
|
|
44
|
+
return {
|
|
45
|
+
source: "s2", title: str(r.title) ?? "", authors: Array.isArray(r.authors) ? r.authors.map(a => str(record(a).name)).filter((a): a is string => !!a) : [],
|
|
46
|
+
year: int(r.year), venue: str(r.venue), doi: str(ext.DOI), arxivId: str(ext.ArXiv), url: str(r.url), abstract: str(r.abstract),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ENTITY: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
52
|
+
export function xmlText(text: string): string {
|
|
53
|
+
return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
|
|
54
|
+
if (name[0] === "#") { const code = name[1]?.toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; }
|
|
55
|
+
return ENTITY[name.toLowerCase()] ?? whole;
|
|
56
|
+
}).replace(/\s+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
const tag = (xml: string, name: string) => { const m = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(xml); return m ? xmlText(m[1]!) : undefined; };
|
|
59
|
+
|
|
60
|
+
export interface ArxivEntry extends RawCandidate { arxivId: string }
|
|
61
|
+
/**
|
|
62
|
+
* Parse an arXiv Atom feed. `complete` means the feed closed and every entry closed; `errors` counts the error
|
|
63
|
+
* entries arXiv reports under /api/errors. Absence may be concluded only from a complete, error-free feed.
|
|
64
|
+
*/
|
|
65
|
+
export function parseArxiv(xml: string): { total: number | undefined; entries: ArxivEntry[]; errors: number; complete: boolean } {
|
|
66
|
+
if (!/<feed[\s>]/.test(xml)) throw new Error("arxiv: unexpected response");
|
|
67
|
+
const totalText = /<opensearch:totalResults[^>]*>(\d+)</.exec(xml)?.[1];
|
|
68
|
+
const entries: ArxivEntry[] = [];
|
|
69
|
+
let errors = 0;
|
|
70
|
+
for (const m of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
|
|
71
|
+
const body = m[1]!, idUrl = tag(body, "id") ?? "";
|
|
72
|
+
const id = /arxiv\.org\/abs\/(.+?)(v\d+)?$/.exec(idUrl)?.[1];
|
|
73
|
+
if (!id) { errors++; continue; }
|
|
74
|
+
const authors = [...body.matchAll(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/g)].map(a => xmlText(a[1]!));
|
|
75
|
+
entries.push({
|
|
76
|
+
source: "arxiv", arxivId: id, title: tag(body, "title") ?? "", authors, year: int(tag(body, "published")?.slice(0, 4)),
|
|
77
|
+
venue: tag(body, "arxiv:journal_ref"), doi: tag(body, "arxiv:doi"), url: `https://arxiv.org/abs/${id}`, abstract: tag(body, "summary"),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const opened = xml.match(/<entry>/g)?.length ?? 0, closed = xml.match(/<\/entry>/g)?.length ?? 0;
|
|
81
|
+
const complete = /<\/feed>\s*$/.test(xml) && opened === closed;
|
|
82
|
+
return { total: totalText === undefined ? undefined : Number(totalText), entries, errors, complete };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function terms(query: string): string[] { return query.split(/\s+/).map(t => t.replace(/[^\p{L}\p{N}-]/gu, "")).filter(Boolean).slice(0, 20); }
|
|
86
|
+
|
|
87
|
+
export function sourceUrl(source: SourceName, query: string, options: SearchOptions, env: NodeJS.ProcessEnv): string {
|
|
88
|
+
const q = encodeURIComponent(query);
|
|
89
|
+
if (source === "openalex") {
|
|
90
|
+
const filters = [options.yearFrom ? `from_publication_date:${options.yearFrom}-01-01` : "", options.yearTo ? `to_publication_date:${options.yearTo}-12-31` : ""].filter(Boolean);
|
|
91
|
+
const key = env.OPENALEX_API_KEY?.trim();
|
|
92
|
+
return `https://api.openalex.org/works?search=${q}&per-page=${options.limit}${filters.length ? `&filter=${filters.join(",")}` : ""}${key ? `&api_key=${encodeURIComponent(key)}` : ""}`;
|
|
93
|
+
}
|
|
94
|
+
if (source === "s2") {
|
|
95
|
+
const years = options.yearFrom || options.yearTo ? `&year=${options.yearFrom ?? ""}-${options.yearTo ?? ""}` : "";
|
|
96
|
+
return `https://api.semanticscholar.org/graph/v1/paper/search?query=${q}&limit=${options.limit}&fields=title,year,authors,venue,externalIds,abstract,url${years}`;
|
|
97
|
+
}
|
|
98
|
+
const parts = terms(query).map(t => `all:${t}`);
|
|
99
|
+
if (options.yearFrom || options.yearTo) parts.push(`submittedDate:[${options.yearFrom ?? 1991}01010000 TO ${options.yearTo ?? 2100}12312359]`);
|
|
100
|
+
return `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(parts.join(" AND "))}&max_results=${options.limit}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Search one source. Failures are reported per source and never abort the others. */
|
|
104
|
+
export async function searchSource(http: Http, source: SourceName, query: string, options: SearchOptions): Promise<SourceResult> {
|
|
105
|
+
const url = sourceUrl(source, query, options, http.env);
|
|
106
|
+
try {
|
|
107
|
+
if (source === "arxiv") {
|
|
108
|
+
const result = await http.text(url);
|
|
109
|
+
const feed = result.body === undefined ? undefined : parseArxiv(result.body);
|
|
110
|
+
if (feed?.errors) throw new Error("arxiv: query error");
|
|
111
|
+
if (feed && !feed.complete) throw new Error("arxiv: incomplete response");
|
|
112
|
+
return { source, ok: true, records: feed?.entries ?? [] };
|
|
113
|
+
}
|
|
114
|
+
const key = http.env.S2_API_KEY?.trim();
|
|
115
|
+
const result = await http.json(url, source === "s2" && key ? { "x-api-key": key } : {});
|
|
116
|
+
if (result.body === undefined) return { source, ok: true, records: [] };
|
|
117
|
+
return { source, ok: true, records: source === "openalex" ? parseOpenAlex(result.body) : parseS2(result.body) };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const message = error instanceof HttpError ? error.message : error instanceof Error ? error.message.slice(0, 200) : "failed";
|
|
120
|
+
return { source, ok: false, records: [], error: message };
|
|
121
|
+
}
|
|
122
|
+
}
|