wtf-p 0.6.0 → 0.7.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/README.md +18 -11
- package/bin/lib/adapter-compiler.js +28 -10
- package/bin/lib/citation-fetcher.js +4 -1
- package/bin/lib/cite-nexus-client.js +120 -0
- package/package.json +2 -2
- package/vendors/antigravity/.wtfp-generated.json +9 -5
- package/vendors/antigravity/plugin.json +1 -1
- package/vendors/antigravity/tools/README.md +5 -2
- package/vendors/antigravity/tools/citation/fetch.js +4 -1
- package/vendors/antigravity/tools/support/cite-nexus-client.js +120 -0
- package/vendors/antigravity/tools/wtfp-tool.js +18 -7
- package/vendors/claude/.claude-plugin/marketplace.json +2 -2
- package/vendors/claude/.claude-plugin/plugin.json +1 -1
- package/vendors/claude/.wtfp-generated.json +12 -8
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/help.md +1 -1
- package/vendors/claude/plugin.json +1 -1
- package/vendors/claude/tools/README.md +5 -2
- package/vendors/claude/tools/citation/fetch.js +4 -1
- package/vendors/claude/tools/support/cite-nexus-client.js +120 -0
- package/vendors/claude/tools/wtfp-tool.js +18 -7
- package/vendors/codex/plugins/wtfp/.codex-plugin/plugin.json +1 -1
- package/vendors/codex/plugins/wtfp/.wtfp-generated.json +10 -6
- package/vendors/codex/plugins/wtfp/plugin.json +1 -1
- package/vendors/codex/plugins/wtfp/tools/README.md +5 -2
- package/vendors/codex/plugins/wtfp/tools/citation/fetch.js +4 -1
- package/vendors/codex/plugins/wtfp/tools/support/cite-nexus-client.js +120 -0
- package/vendors/codex/plugins/wtfp/tools/wtfp-tool.js +18 -7
- package/vendors/copilot/.wtfp-generated.json +2 -2
- package/vendors/copilot/marketplace.json +2 -2
- package/vendors/copilot/plugins/wtfp/.claude-plugin/plugin.json +1 -1
- package/vendors/copilot/plugins/wtfp/.wtfp-generated.json +9 -5
- package/vendors/copilot/plugins/wtfp/tools/README.md +5 -2
- package/vendors/copilot/plugins/wtfp/tools/citation/fetch.js +4 -1
- package/vendors/copilot/plugins/wtfp/tools/support/cite-nexus-client.js +120 -0
- package/vendors/copilot/plugins/wtfp/tools/wtfp-tool.js +18 -7
- package/vendors/gemini/.wtfp-generated.json +9 -5
- package/vendors/gemini/gemini-extension.json +1 -1
- package/vendors/gemini/tools/README.md +5 -2
- package/vendors/gemini/tools/citation/fetch.js +4 -1
- package/vendors/gemini/tools/support/cite-nexus-client.js +120 -0
- package/vendors/gemini/tools/wtfp-tool.js +18 -7
- package/vendors/opencode/.wtfp-generated.json +8 -4
- package/vendors/opencode/tools/README.md +5 -2
- package/vendors/opencode/tools/citation/fetch.js +4 -1
- package/vendors/opencode/tools/support/cite-nexus-client.js +120 -0
- package/vendors/opencode/tools/wtfp-tool.js +18 -7
- package/vendors/plugin/.wtfp-generated.json +10 -6
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/help.md +1 -1
- package/vendors/plugin/plugin.json +1 -1
- package/vendors/plugin/tools/README.md +5 -2
- package/vendors/plugin/tools/citation/fetch.js +4 -1
- package/vendors/plugin/tools/support/cite-nexus-client.js +120 -0
- package/vendors/plugin/tools/wtfp-tool.js +18 -7
|
@@ -54,7 +54,7 @@ const COMMANDS = [
|
|
|
54
54
|
{
|
|
55
55
|
"command": "citation-search",
|
|
56
56
|
"tool": "citation.fetch",
|
|
57
|
-
"usage": "citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
|
|
57
|
+
"usage": "citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
|
|
58
58
|
"effects": [
|
|
59
59
|
"network.search"
|
|
60
60
|
]
|
|
@@ -96,9 +96,9 @@ function offlineRequested(argv) {
|
|
|
96
96
|
return argv.includes('--offline') || flag === '1' || flag === 'true';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function fail(message) {
|
|
99
|
+
function fail(message, status = 1) {
|
|
100
100
|
process.stderr.write(`${JSON.stringify({ error: message })}\n`);
|
|
101
|
-
process.exit(
|
|
101
|
+
process.exit(status);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function emit(value) {
|
|
@@ -153,7 +153,7 @@ function requirePath(candidate) {
|
|
|
153
153
|
|
|
154
154
|
function timeoutOf(flags) {
|
|
155
155
|
if (!flags.has('timeout')) return DEFAULT_TIMEOUT_SECONDS;
|
|
156
|
-
const seconds =
|
|
156
|
+
const seconds = /^\d+$/.test(flags.get('timeout')) ? Number(flags.get('timeout')) : NaN;
|
|
157
157
|
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
158
158
|
fail(`--timeout must be an integer number of seconds between 1 and ${MAX_TIMEOUT_SECONDS}`);
|
|
159
159
|
}
|
|
@@ -184,7 +184,7 @@ function uniqueEntry(bib, content, key) {
|
|
|
184
184
|
|
|
185
185
|
function limitOf(flags) {
|
|
186
186
|
if (!flags.has('limit')) return 10;
|
|
187
|
-
const limit =
|
|
187
|
+
const limit = /^\d+$/.test(flags.get('limit')) ? Number(flags.get('limit')) : NaN;
|
|
188
188
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
189
189
|
return limit;
|
|
190
190
|
}
|
|
@@ -221,6 +221,13 @@ async function main(rawArgv) {
|
|
|
221
221
|
fail(`${command} is refused in offline mode: it declares ${networkEffects.join(', ')}`);
|
|
222
222
|
}
|
|
223
223
|
const { flags, positional } = parseArguments(argv.slice(1));
|
|
224
|
+
const allowedFlags = {
|
|
225
|
+
'bib-index': ['key', 'query'], 'bib-format': ['key', 'style'],
|
|
226
|
+
'bib-impact': ['timeout'], 'citation-search': ['query', 'backend', 'providers', 'limit', 'intent', 'year', 'timeout'],
|
|
227
|
+
'scholar-search': ['query', 'limit', 'timeout'], 's2-search': ['query', 'limit', 'year', 'timeout'],
|
|
228
|
+
'rank': ['intent']
|
|
229
|
+
};
|
|
230
|
+
for (const key of flags.keys()) if (!allowedFlags[command].includes(key)) fail(`unknown --${key} for ${command}`);
|
|
224
231
|
if (positional.length > 1) fail(`${command} accepts at most one positional argument: ${declared.usage}`);
|
|
225
232
|
const load = () => require(MODULES[declared.tool]);
|
|
226
233
|
|
|
@@ -255,7 +262,11 @@ async function main(rawArgv) {
|
|
|
255
262
|
const query = requireText(flags.get('query'), '--query');
|
|
256
263
|
const year = yearOf(flags);
|
|
257
264
|
const seconds = timeoutOf(flags);
|
|
258
|
-
|
|
265
|
+
const backend = flags.get('backend') || 'legacy';
|
|
266
|
+
if (!['legacy', 'cite-nexus'].includes(backend)) fail('--backend must be legacy or cite-nexus');
|
|
267
|
+
const providers = flags.has('providers') ? flags.get('providers').split(',').map((p) => p.trim()) : undefined;
|
|
268
|
+
if (providers && backend !== 'cite-nexus') fail('--providers requires --backend=cite-nexus');
|
|
269
|
+
return emit(await withTimeout(load().search(query, { backend, providers, timeoutSeconds: seconds, limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
|
|
259
270
|
}
|
|
260
271
|
if (command === 'scholar-search') {
|
|
261
272
|
if (positional.length > 0) fail(`${command} takes its query through --query: ${declared.usage}`);
|
|
@@ -282,5 +293,5 @@ async function main(rawArgv) {
|
|
|
282
293
|
// config root as a custom-tool module; an unconditional main() printed a
|
|
283
294
|
// dispatcher error and exited the host process at session start.
|
|
284
295
|
if (require.main === module) {
|
|
285
|
-
main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error)));
|
|
296
|
+
main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error), error && error.code === 'WTFP_TIMEOUT' ? EXIT_TIMEOUT : 1));
|
|
286
297
|
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"schema": "wtfp.generated-adapter/v1",
|
|
3
3
|
"generatorVersion": 5,
|
|
4
4
|
"target": "copilot-marketplace",
|
|
5
|
-
"sourceHash": "
|
|
5
|
+
"sourceHash": "24984d4ade4b82d105e79b6572ff712dbde8d69506d0b1164db3e6d3725f45ed",
|
|
6
6
|
"files": [
|
|
7
7
|
{
|
|
8
8
|
"path": "marketplace.json",
|
|
9
|
-
"sha256": "
|
|
9
|
+
"sha256": "b04ff9ad215681d9aca99b3c3b73cb363b61ea97db886ad9dd8536a42888b1c8"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"path": "project/.github/agents/wtfp-argument-verifier.agent.md",
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "WTF-P research workflow plugins",
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.7.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "wtfp",
|
|
13
13
|
"source": "./plugins/wtfp",
|
|
14
14
|
"description": "Portable academic research and writing workflows.",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.7.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "akougkas"
|
|
18
18
|
},
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"schema": "wtfp.generated-adapter/v1",
|
|
3
3
|
"generatorVersion": 5,
|
|
4
4
|
"target": "copilot",
|
|
5
|
-
"sourceHash": "
|
|
5
|
+
"sourceHash": "7dd8faa838b3daea55443365532a761a68b3800a7270e46ec564c452b63ed95f",
|
|
6
6
|
"files": [
|
|
7
7
|
{
|
|
8
8
|
"path": ".claude-plugin/plugin.json",
|
|
9
|
-
"sha256": "
|
|
9
|
+
"sha256": "1f77fce4cb2bdb2ec25d7dd8a8fa4fb65d7f58beab887e657b73f6d9fcf44d44"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"path": "actions/add-todo.json",
|
|
@@ -646,7 +646,7 @@
|
|
|
646
646
|
},
|
|
647
647
|
{
|
|
648
648
|
"path": "tools/citation/fetch.js",
|
|
649
|
-
"sha256": "
|
|
649
|
+
"sha256": "fa24cc676bdad1699c1ef3ca56ac358268ec516a1aa1305ffe0760941ec8d756"
|
|
650
650
|
},
|
|
651
651
|
{
|
|
652
652
|
"path": "tools/citation/rank.js",
|
|
@@ -662,11 +662,15 @@
|
|
|
662
662
|
},
|
|
663
663
|
{
|
|
664
664
|
"path": "tools/README.md",
|
|
665
|
-
"sha256": "
|
|
665
|
+
"sha256": "997889703a61599a3d6267b1ddbd571ac354b55e7fe3a9dc2ebbf00b6c6d2106"
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
"path": "tools/support/cite-nexus-client.js",
|
|
669
|
+
"sha256": "9a7a9a548477b14cadda2be40ed95e579d809956e63ccf19092edde8de46d2dc"
|
|
666
670
|
},
|
|
667
671
|
{
|
|
668
672
|
"path": "tools/wtfp-tool.js",
|
|
669
|
-
"sha256": "
|
|
673
|
+
"sha256": "d7fe8278161d5a18f33be87a41c374e2a9c90657ac2315c24a1697793f750e19"
|
|
670
674
|
},
|
|
671
675
|
{
|
|
672
676
|
"path": "workflows/add-todo.md",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Generated by WTF-P adapter compiler v5 from protocol/tools.json; do not edit. -->
|
|
4
4
|
|
|
5
|
-
Only implementations declared by `tools.json
|
|
5
|
+
Only implementations declared by `tools.json`, the dispatcher, and its private CiteNexus companion dependency are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.
|
|
6
6
|
|
|
7
7
|
- `wtfp://tools/bibliography/analyze-impact` → `tools/bibliography/analyze-impact.js` (legacy module `analyze-impact.js`)
|
|
8
8
|
- `wtfp://tools/bibliography/format` → `tools/bibliography/format.js` (legacy module `bib-format.js`)
|
|
@@ -11,6 +11,7 @@ Only implementations declared by `tools.json` are packaged here. Resolve each lo
|
|
|
11
11
|
- `wtfp://tools/citation/rank` → `tools/citation/rank.js` (legacy module `citation-ranker.js`)
|
|
12
12
|
- `wtfp://tools/citation/scholar-lookup` → `tools/citation/scholar-lookup.js` (legacy module `scholar-lookup.js`)
|
|
13
13
|
- `wtfp://tools/citation/semantic-scholar` → `tools/citation/semantic-scholar.js` (legacy module `semantic-scholar.js`)
|
|
14
|
+
- Private dependency: `tools/support/cite-nexus-client.js`; reached only through `citation.fetch`, never executed directly.
|
|
14
15
|
|
|
15
16
|
## Executing a bundled tool
|
|
16
17
|
|
|
@@ -25,9 +26,11 @@ Run it with no argument, or with `list`, to print the declared command set as JS
|
|
|
25
26
|
- `bib-index <bib-file> [--key=<citation-key>] [--query=<text>]` → `bibliography.index` (filesystem.read)
|
|
26
27
|
- `bib-format <bib-file> --key=<citation-key> [--style=<bibtex|al-folio>]` → `bibliography.format` (filesystem.read)
|
|
27
28
|
- `bib-impact <bib-file> [--timeout=<seconds>]` → `bibliography.analyze-impact` (filesystem.read, network.search)
|
|
28
|
-
- `citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
|
|
29
|
+
- `citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
|
|
29
30
|
- `scholar-search --query=<text> [--limit=<1-25>] [--timeout=<seconds>]` → `citation.scholar-lookup` (network.search)
|
|
30
31
|
- `s2-search --query=<text> [--limit=<1-25>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.semantic-scholar` (network.fetch, network.search)
|
|
31
32
|
- `rank <papers.json> [--intent=<seminal|recent|balanced>]` → `citation.rank` (no declared effects)
|
|
32
33
|
|
|
34
|
+
For approved scholarly discovery, `citation-search --backend=cite-nexus --query="<topic>"` uses the separately installed `cite-nexus-wtfp` companion and its real MCP stdio server. Defaults are Crossref, DataCite and Europe PMC. Select optional vendors explicitly with `--providers`; include selected providers and query scope in the action approval. CiteNexus supports only balanced provider ordering, so omit `--intent` or use `--intent=balanced`. Results remain candidates: retain `citeNexus.sources`, field attribution, metrics, warnings and `metadata.errors`; do not infer verification or combine citation counts. The result limit is a displayed total; `metadata.total` counts the fetched deduplicated page, not the full corpus. Unavailable enrichment fails explicitly without falling back to another vendor. `WTFP_CITE_NEXUS_COMMAND` may name an absolute installed companion executable; it is operator configuration, never source content. No package is installed, server registered, or user profile changed by a tool call. Offline mode refuses this backend before process launch. Host capability blockers still apply.
|
|
35
|
+
|
|
33
36
|
Every command prints one JSON document on stdout and reports failures as `{"error": "..."}` on stderr with exit status 1. Queries are capped at 512 characters, file paths at 4096, and result limits at 25. A symlinked file is accepted and read through its resolved target, which must be a regular file. Commands whose effects include `network.*` perform outbound requests to the declared scholarly indexes; pass `--offline` or set `WTFP_TOOL_OFFLINE=1` to refuse them, which is the mechanical form of "do not invoke a network-capable bibliography tool through a filesystem-only permission path". Each network command has a hard wall clock, `--timeout=<seconds>` (default 20, maximum 600); on expiry it reports `{"error": "<command> timed out after N s"}` on stderr and exits 124. `bib-impact` reports batch progress on stderr. `bib-index` flags repeated keys with `duplicate: true` and lists them under `duplicates`; `--key` refuses an ambiguous key. `bib-format` emits a standard BibTeX entry (`@article`, `@inproceedings`, ...) by default; `--style=al-folio` selects the Jekyll al-folio projection, which is not valid BibTeX. Do not execute any other module in this package directly, and do not pass a logical `project://` or `wtfp://` URI as a shell argument.
|
|
@@ -160,6 +160,9 @@ function deduplicatePapers(papers) {
|
|
|
160
160
|
// --- Main Search Logic ---
|
|
161
161
|
|
|
162
162
|
async function search(query, options = {}) {
|
|
163
|
+
if (options.backend === 'cite-nexus') return require('../support/cite-nexus-client.js').search(query, options);
|
|
164
|
+
if (options.backend && options.backend !== 'legacy') throw new Error('Unknown citation backend');
|
|
165
|
+
if (options.providers) throw new Error('Provider selection requires --backend=cite-nexus');
|
|
163
166
|
const limit = options.limit || 10;
|
|
164
167
|
const intent = options.intent || 'balanced';
|
|
165
168
|
|
|
@@ -299,4 +302,4 @@ if (require.main === module) {
|
|
|
299
302
|
});
|
|
300
303
|
}
|
|
301
304
|
|
|
302
|
-
module.exports = { search };
|
|
305
|
+
module.exports = { search };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Optional companion to citation.fetch, never a separately executable tool.
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const SCHEMA = 'cite-nexus.wtfp/v1';
|
|
7
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const PROVIDERS = Object.freeze({
|
|
9
|
+
crossref: ['CITE_NEXUS_CONTACT_EMAIL'], datacite: [], europe_pmc: [], arxiv: [],
|
|
10
|
+
semantic_scholar: ['SEMANTIC_SCHOLAR_API_KEY'], openalex: ['OPENALEX_API_KEY'],
|
|
11
|
+
serpapi: ['SERPAPI_API_KEY'], scopus: ['SCOPUS_API_KEY', 'SCOPUS_INSTTOKEN'], wos: ['WOS_API_KEY']
|
|
12
|
+
});
|
|
13
|
+
const RUNTIME_ENV = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP'];
|
|
14
|
+
|
|
15
|
+
function timedOut() {
|
|
16
|
+
return Object.assign(new Error('CiteNexus request timed out'), { code: 'WTFP_TIMEOUT' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requestFor(query, options) {
|
|
20
|
+
if (process.env.WTFP_TOOL_OFFLINE === '1' || process.env.WTFP_TOOL_OFFLINE === 'true') {
|
|
21
|
+
throw new Error('CiteNexus is unavailable in offline mode');
|
|
22
|
+
}
|
|
23
|
+
if (typeof query !== 'string' || !query.trim() || query.length > 512) throw new Error('CiteNexus query must contain 1-512 characters');
|
|
24
|
+
const limit = options.limit === undefined ? 10 : options.limit;
|
|
25
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 25) throw new Error('CiteNexus limit must be an integer between 1 and 25');
|
|
26
|
+
const providers = options.providers || ['crossref', 'datacite', 'europe_pmc'];
|
|
27
|
+
if (!Array.isArray(providers) || providers.length < 1 || providers.length > 9 ||
|
|
28
|
+
providers.some((p) => typeof p !== 'string' || !Object.hasOwn(PROVIDERS, p))) {
|
|
29
|
+
throw new Error('CiteNexus providers must be a nonempty list of supported provider IDs');
|
|
30
|
+
}
|
|
31
|
+
const timeout = options.timeoutSeconds === undefined ? 20 : options.timeoutSeconds;
|
|
32
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new Error('CiteNexus timeout must be an integer between 1 and 600 seconds');
|
|
33
|
+
const request = { schema_version: SCHEMA, operation: 'search', query, limit, providers: [...new Set(providers)], timeout_seconds: timeout };
|
|
34
|
+
if (options.year != null) {
|
|
35
|
+
if (!/^\d{4}$/.test(String(options.year)) || Number(options.year) < 1000 || Number(options.year) > 2200) throw new Error('CiteNexus year must be between 1000 and 2200');
|
|
36
|
+
request.year = Number(options.year);
|
|
37
|
+
}
|
|
38
|
+
if (options.intent && options.intent !== 'balanced') throw new Error('CiteNexus preserves provider ordering; use --intent=balanced');
|
|
39
|
+
return request;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateResponse(text, limit) {
|
|
43
|
+
let data;
|
|
44
|
+
try { data = JSON.parse(text); } catch { throw new Error('CiteNexus returned malformed JSON'); }
|
|
45
|
+
const stack = [[data, 0]];
|
|
46
|
+
while (stack.length) {
|
|
47
|
+
const [value, depth] = stack.pop();
|
|
48
|
+
if (depth > 32) throw new Error('CiteNexus response nesting exceeds 32 levels');
|
|
49
|
+
if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push([child, depth + 1]);
|
|
50
|
+
}
|
|
51
|
+
if (!data || data.schema_version !== SCHEMA || !Array.isArray(data.results) || data.results.length > limit ||
|
|
52
|
+
!data.metadata || data.metadata.backend !== 'cite-nexus' || !Array.isArray(data.metadata.errors) ||
|
|
53
|
+
data.metadata.returned !== data.results.length ||
|
|
54
|
+
data.results.some((p) => !p || p.verification !== 'candidate' || typeof p.title !== 'string' ||
|
|
55
|
+
typeof p.bibtex !== 'string' || !p.citeNexus || !Array.isArray(p.citeNexus.sources) || !Array.isArray(p.citeNexus.metrics))) {
|
|
56
|
+
throw new Error('CiteNexus returned an incompatible response; update both companions');
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function search(query, options = {}) {
|
|
62
|
+
const request = requestFor(query, options);
|
|
63
|
+
if (options.signal && options.signal.aborted) throw new Error('CiteNexus request cancelled');
|
|
64
|
+
const command = process.env.WTFP_CITE_NEXUS_COMMAND || 'cite-nexus-wtfp';
|
|
65
|
+
if (command !== 'cite-nexus-wtfp' && !path.isAbsolute(command)) {
|
|
66
|
+
throw new Error('WTFP_CITE_NEXUS_COMMAND must name an absolute installed executable');
|
|
67
|
+
}
|
|
68
|
+
const allowed = [...RUNTIME_ENV, ...request.providers.flatMap((p) => PROVIDERS[p])];
|
|
69
|
+
const env = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]));
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
// No shell, command arguments, URL or executable can arrive from source data.
|
|
72
|
+
const child = spawn(command, [], { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
73
|
+
let settled = false, bytes = 0;
|
|
74
|
+
const chunks = [];
|
|
75
|
+
const stop = () => { if (child.exitCode === null) child.kill('SIGTERM'); };
|
|
76
|
+
const onExit = () => stop();
|
|
77
|
+
const onSignal = () => { stop(); process.exit(130); };
|
|
78
|
+
const finish = (error, data) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
process.removeListener('exit', onExit);
|
|
83
|
+
process.removeListener('SIGTERM', onSignal);
|
|
84
|
+
process.removeListener('SIGINT', onSignal);
|
|
85
|
+
if (options.signal) options.signal.removeEventListener('abort', cancel);
|
|
86
|
+
if (error) {
|
|
87
|
+
stop();
|
|
88
|
+
// The Python bridge cancels MCP and closes the SDK-owned server group.
|
|
89
|
+
const kill = setTimeout(() => child.kill('SIGKILL'), 7000);
|
|
90
|
+
kill.unref();
|
|
91
|
+
child.once('close', () => clearTimeout(kill));
|
|
92
|
+
reject(error);
|
|
93
|
+
} else resolve(data);
|
|
94
|
+
};
|
|
95
|
+
const cancel = () => finish(new Error('CiteNexus request cancelled'));
|
|
96
|
+
const timer = setTimeout(() => finish(timedOut()), request.timeout_seconds * 1000);
|
|
97
|
+
process.once('exit', onExit);
|
|
98
|
+
process.once('SIGTERM', onSignal);
|
|
99
|
+
process.once('SIGINT', onSignal);
|
|
100
|
+
if (options.signal) options.signal.addEventListener('abort', cancel, { once: true });
|
|
101
|
+
child.on('error', () => finish(new Error('CiteNexus companion unavailable; install cite-nexus-mcp 0.2.0 separately and run cite-nexus-wtfp --check')));
|
|
102
|
+
child.stdin.on('error', () => finish(new Error('CiteNexus companion closed its input')));
|
|
103
|
+
child.stdout.on('data', (chunk) => {
|
|
104
|
+
bytes += chunk.length;
|
|
105
|
+
if (bytes > MAX_BYTES) return finish(new Error('CiteNexus response exceeds 2 MiB'));
|
|
106
|
+
if (!settled) chunks.push(chunk);
|
|
107
|
+
});
|
|
108
|
+
// Drain, but never relay child stderr (it may contain sensitive diagnostics).
|
|
109
|
+
child.stderr.on('data', () => {});
|
|
110
|
+
child.on('close', (code) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
if (code !== 0) return finish(code === 124 ? timedOut() : new Error('CiteNexus companion failed; run cite-nexus-wtfp --check'));
|
|
113
|
+
try { finish(null, validateResponse(Buffer.concat(chunks).toString('utf8'), request.limit)); }
|
|
114
|
+
catch (error) { finish(error); }
|
|
115
|
+
});
|
|
116
|
+
child.stdin.end(JSON.stringify(request));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { search, requestFor, validateResponse };
|
|
@@ -54,7 +54,7 @@ const COMMANDS = [
|
|
|
54
54
|
{
|
|
55
55
|
"command": "citation-search",
|
|
56
56
|
"tool": "citation.fetch",
|
|
57
|
-
"usage": "citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
|
|
57
|
+
"usage": "citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]",
|
|
58
58
|
"effects": [
|
|
59
59
|
"network.search"
|
|
60
60
|
]
|
|
@@ -96,9 +96,9 @@ function offlineRequested(argv) {
|
|
|
96
96
|
return argv.includes('--offline') || flag === '1' || flag === 'true';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function fail(message) {
|
|
99
|
+
function fail(message, status = 1) {
|
|
100
100
|
process.stderr.write(`${JSON.stringify({ error: message })}\n`);
|
|
101
|
-
process.exit(
|
|
101
|
+
process.exit(status);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function emit(value) {
|
|
@@ -153,7 +153,7 @@ function requirePath(candidate) {
|
|
|
153
153
|
|
|
154
154
|
function timeoutOf(flags) {
|
|
155
155
|
if (!flags.has('timeout')) return DEFAULT_TIMEOUT_SECONDS;
|
|
156
|
-
const seconds =
|
|
156
|
+
const seconds = /^\d+$/.test(flags.get('timeout')) ? Number(flags.get('timeout')) : NaN;
|
|
157
157
|
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
158
158
|
fail(`--timeout must be an integer number of seconds between 1 and ${MAX_TIMEOUT_SECONDS}`);
|
|
159
159
|
}
|
|
@@ -184,7 +184,7 @@ function uniqueEntry(bib, content, key) {
|
|
|
184
184
|
|
|
185
185
|
function limitOf(flags) {
|
|
186
186
|
if (!flags.has('limit')) return 10;
|
|
187
|
-
const limit =
|
|
187
|
+
const limit = /^\d+$/.test(flags.get('limit')) ? Number(flags.get('limit')) : NaN;
|
|
188
188
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) fail(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
189
189
|
return limit;
|
|
190
190
|
}
|
|
@@ -221,6 +221,13 @@ async function main(rawArgv) {
|
|
|
221
221
|
fail(`${command} is refused in offline mode: it declares ${networkEffects.join(', ')}`);
|
|
222
222
|
}
|
|
223
223
|
const { flags, positional } = parseArguments(argv.slice(1));
|
|
224
|
+
const allowedFlags = {
|
|
225
|
+
'bib-index': ['key', 'query'], 'bib-format': ['key', 'style'],
|
|
226
|
+
'bib-impact': ['timeout'], 'citation-search': ['query', 'backend', 'providers', 'limit', 'intent', 'year', 'timeout'],
|
|
227
|
+
'scholar-search': ['query', 'limit', 'timeout'], 's2-search': ['query', 'limit', 'year', 'timeout'],
|
|
228
|
+
'rank': ['intent']
|
|
229
|
+
};
|
|
230
|
+
for (const key of flags.keys()) if (!allowedFlags[command].includes(key)) fail(`unknown --${key} for ${command}`);
|
|
224
231
|
if (positional.length > 1) fail(`${command} accepts at most one positional argument: ${declared.usage}`);
|
|
225
232
|
const load = () => require(MODULES[declared.tool]);
|
|
226
233
|
|
|
@@ -255,7 +262,11 @@ async function main(rawArgv) {
|
|
|
255
262
|
const query = requireText(flags.get('query'), '--query');
|
|
256
263
|
const year = yearOf(flags);
|
|
257
264
|
const seconds = timeoutOf(flags);
|
|
258
|
-
|
|
265
|
+
const backend = flags.get('backend') || 'legacy';
|
|
266
|
+
if (!['legacy', 'cite-nexus'].includes(backend)) fail('--backend must be legacy or cite-nexus');
|
|
267
|
+
const providers = flags.has('providers') ? flags.get('providers').split(',').map((p) => p.trim()) : undefined;
|
|
268
|
+
if (providers && backend !== 'cite-nexus') fail('--providers requires --backend=cite-nexus');
|
|
269
|
+
return emit(await withTimeout(load().search(query, { backend, providers, timeoutSeconds: seconds, limit: limitOf(flags), intent: intentOf(flags), ...(year ? { year } : {}) }), seconds, command));
|
|
259
270
|
}
|
|
260
271
|
if (command === 'scholar-search') {
|
|
261
272
|
if (positional.length > 0) fail(`${command} takes its query through --query: ${declared.usage}`);
|
|
@@ -282,5 +293,5 @@ async function main(rawArgv) {
|
|
|
282
293
|
// config root as a custom-tool module; an unconditional main() printed a
|
|
283
294
|
// dispatcher error and exited the host process at session start.
|
|
284
295
|
if (require.main === module) {
|
|
285
|
-
main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error)));
|
|
296
|
+
main(process.argv.slice(2)).catch((error) => fail(error && error.message ? error.message : String(error), error && error.code === 'WTFP_TIMEOUT' ? EXIT_TIMEOUT : 1));
|
|
286
297
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "wtfp.generated-adapter/v1",
|
|
3
3
|
"generatorVersion": 5,
|
|
4
4
|
"target": "gemini",
|
|
5
|
-
"sourceHash": "
|
|
5
|
+
"sourceHash": "7149d6dd2e818f9edf625102fd0c2828496897a43cd8c3fc33557462a681c90a",
|
|
6
6
|
"files": [
|
|
7
7
|
{
|
|
8
8
|
"path": "actions/add-todo.json",
|
|
@@ -362,7 +362,7 @@
|
|
|
362
362
|
},
|
|
363
363
|
{
|
|
364
364
|
"path": "gemini-extension.json",
|
|
365
|
-
"sha256": "
|
|
365
|
+
"sha256": "7e5535f59e9dc15f2f18d31812ea08a65ed7f3368126f0c941974a8bdd4fc54d"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "GEMINI.md",
|
|
@@ -650,7 +650,7 @@
|
|
|
650
650
|
},
|
|
651
651
|
{
|
|
652
652
|
"path": "tools/citation/fetch.js",
|
|
653
|
-
"sha256": "
|
|
653
|
+
"sha256": "fa24cc676bdad1699c1ef3ca56ac358268ec516a1aa1305ffe0760941ec8d756"
|
|
654
654
|
},
|
|
655
655
|
{
|
|
656
656
|
"path": "tools/citation/rank.js",
|
|
@@ -666,11 +666,15 @@
|
|
|
666
666
|
},
|
|
667
667
|
{
|
|
668
668
|
"path": "tools/README.md",
|
|
669
|
-
"sha256": "
|
|
669
|
+
"sha256": "997889703a61599a3d6267b1ddbd571ac354b55e7fe3a9dc2ebbf00b6c6d2106"
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
"path": "tools/support/cite-nexus-client.js",
|
|
673
|
+
"sha256": "9a7a9a548477b14cadda2be40ed95e579d809956e63ccf19092edde8de46d2dc"
|
|
670
674
|
},
|
|
671
675
|
{
|
|
672
676
|
"path": "tools/wtfp-tool.js",
|
|
673
|
-
"sha256": "
|
|
677
|
+
"sha256": "d7fe8278161d5a18f33be87a41c374e2a9c90657ac2315c24a1697793f750e19"
|
|
674
678
|
},
|
|
675
679
|
{
|
|
676
680
|
"path": "workflows/add-todo.md",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Generated by WTF-P adapter compiler v5 from protocol/tools.json; do not edit. -->
|
|
4
4
|
|
|
5
|
-
Only implementations declared by `tools.json
|
|
5
|
+
Only implementations declared by `tools.json`, the dispatcher, and its private CiteNexus companion dependency are packaged here. Resolve each logical implementation URI through this exact mapping; do not search for or execute undeclared installer/compiler modules.
|
|
6
6
|
|
|
7
7
|
- `wtfp://tools/bibliography/analyze-impact` → `tools/bibliography/analyze-impact.js` (legacy module `analyze-impact.js`)
|
|
8
8
|
- `wtfp://tools/bibliography/format` → `tools/bibliography/format.js` (legacy module `bib-format.js`)
|
|
@@ -11,6 +11,7 @@ Only implementations declared by `tools.json` are packaged here. Resolve each lo
|
|
|
11
11
|
- `wtfp://tools/citation/rank` → `tools/citation/rank.js` (legacy module `citation-ranker.js`)
|
|
12
12
|
- `wtfp://tools/citation/scholar-lookup` → `tools/citation/scholar-lookup.js` (legacy module `scholar-lookup.js`)
|
|
13
13
|
- `wtfp://tools/citation/semantic-scholar` → `tools/citation/semantic-scholar.js` (legacy module `semantic-scholar.js`)
|
|
14
|
+
- Private dependency: `tools/support/cite-nexus-client.js`; reached only through `citation.fetch`, never executed directly.
|
|
14
15
|
|
|
15
16
|
## Executing a bundled tool
|
|
16
17
|
|
|
@@ -25,9 +26,11 @@ Run it with no argument, or with `list`, to print the declared command set as JS
|
|
|
25
26
|
- `bib-index <bib-file> [--key=<citation-key>] [--query=<text>]` → `bibliography.index` (filesystem.read)
|
|
26
27
|
- `bib-format <bib-file> --key=<citation-key> [--style=<bibtex|al-folio>]` → `bibliography.format` (filesystem.read)
|
|
27
28
|
- `bib-impact <bib-file> [--timeout=<seconds>]` → `bibliography.analyze-impact` (filesystem.read, network.search)
|
|
28
|
-
- `citation-search --query=<text> [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
|
|
29
|
+
- `citation-search --query=<text> [--backend=<legacy|cite-nexus>] [--providers=<comma-separated-IDs>] [--limit=<1-25>] [--intent=<seminal|recent|balanced>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.fetch` (network.search)
|
|
29
30
|
- `scholar-search --query=<text> [--limit=<1-25>] [--timeout=<seconds>]` → `citation.scholar-lookup` (network.search)
|
|
30
31
|
- `s2-search --query=<text> [--limit=<1-25>] [--year=<yyyy>] [--timeout=<seconds>]` → `citation.semantic-scholar` (network.fetch, network.search)
|
|
31
32
|
- `rank <papers.json> [--intent=<seminal|recent|balanced>]` → `citation.rank` (no declared effects)
|
|
32
33
|
|
|
34
|
+
For approved scholarly discovery, `citation-search --backend=cite-nexus --query="<topic>"` uses the separately installed `cite-nexus-wtfp` companion and its real MCP stdio server. Defaults are Crossref, DataCite and Europe PMC. Select optional vendors explicitly with `--providers`; include selected providers and query scope in the action approval. CiteNexus supports only balanced provider ordering, so omit `--intent` or use `--intent=balanced`. Results remain candidates: retain `citeNexus.sources`, field attribution, metrics, warnings and `metadata.errors`; do not infer verification or combine citation counts. The result limit is a displayed total; `metadata.total` counts the fetched deduplicated page, not the full corpus. Unavailable enrichment fails explicitly without falling back to another vendor. `WTFP_CITE_NEXUS_COMMAND` may name an absolute installed companion executable; it is operator configuration, never source content. No package is installed, server registered, or user profile changed by a tool call. Offline mode refuses this backend before process launch. Host capability blockers still apply.
|
|
35
|
+
|
|
33
36
|
Every command prints one JSON document on stdout and reports failures as `{"error": "..."}` on stderr with exit status 1. Queries are capped at 512 characters, file paths at 4096, and result limits at 25. A symlinked file is accepted and read through its resolved target, which must be a regular file. Commands whose effects include `network.*` perform outbound requests to the declared scholarly indexes; pass `--offline` or set `WTFP_TOOL_OFFLINE=1` to refuse them, which is the mechanical form of "do not invoke a network-capable bibliography tool through a filesystem-only permission path". Each network command has a hard wall clock, `--timeout=<seconds>` (default 20, maximum 600); on expiry it reports `{"error": "<command> timed out after N s"}` on stderr and exits 124. `bib-impact` reports batch progress on stderr. `bib-index` flags repeated keys with `duplicate: true` and lists them under `duplicates`; `--key` refuses an ambiguous key. `bib-format` emits a standard BibTeX entry (`@article`, `@inproceedings`, ...) by default; `--style=al-folio` selects the Jekyll al-folio projection, which is not valid BibTeX. Do not execute any other module in this package directly, and do not pass a logical `project://` or `wtfp://` URI as a shell argument.
|
|
@@ -160,6 +160,9 @@ function deduplicatePapers(papers) {
|
|
|
160
160
|
// --- Main Search Logic ---
|
|
161
161
|
|
|
162
162
|
async function search(query, options = {}) {
|
|
163
|
+
if (options.backend === 'cite-nexus') return require('../support/cite-nexus-client.js').search(query, options);
|
|
164
|
+
if (options.backend && options.backend !== 'legacy') throw new Error('Unknown citation backend');
|
|
165
|
+
if (options.providers) throw new Error('Provider selection requires --backend=cite-nexus');
|
|
163
166
|
const limit = options.limit || 10;
|
|
164
167
|
const intent = options.intent || 'balanced';
|
|
165
168
|
|
|
@@ -299,4 +302,4 @@ if (require.main === module) {
|
|
|
299
302
|
});
|
|
300
303
|
}
|
|
301
304
|
|
|
302
|
-
module.exports = { search };
|
|
305
|
+
module.exports = { search };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Optional companion to citation.fetch, never a separately executable tool.
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const SCHEMA = 'cite-nexus.wtfp/v1';
|
|
7
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const PROVIDERS = Object.freeze({
|
|
9
|
+
crossref: ['CITE_NEXUS_CONTACT_EMAIL'], datacite: [], europe_pmc: [], arxiv: [],
|
|
10
|
+
semantic_scholar: ['SEMANTIC_SCHOLAR_API_KEY'], openalex: ['OPENALEX_API_KEY'],
|
|
11
|
+
serpapi: ['SERPAPI_API_KEY'], scopus: ['SCOPUS_API_KEY', 'SCOPUS_INSTTOKEN'], wos: ['WOS_API_KEY']
|
|
12
|
+
});
|
|
13
|
+
const RUNTIME_ENV = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP'];
|
|
14
|
+
|
|
15
|
+
function timedOut() {
|
|
16
|
+
return Object.assign(new Error('CiteNexus request timed out'), { code: 'WTFP_TIMEOUT' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requestFor(query, options) {
|
|
20
|
+
if (process.env.WTFP_TOOL_OFFLINE === '1' || process.env.WTFP_TOOL_OFFLINE === 'true') {
|
|
21
|
+
throw new Error('CiteNexus is unavailable in offline mode');
|
|
22
|
+
}
|
|
23
|
+
if (typeof query !== 'string' || !query.trim() || query.length > 512) throw new Error('CiteNexus query must contain 1-512 characters');
|
|
24
|
+
const limit = options.limit === undefined ? 10 : options.limit;
|
|
25
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 25) throw new Error('CiteNexus limit must be an integer between 1 and 25');
|
|
26
|
+
const providers = options.providers || ['crossref', 'datacite', 'europe_pmc'];
|
|
27
|
+
if (!Array.isArray(providers) || providers.length < 1 || providers.length > 9 ||
|
|
28
|
+
providers.some((p) => typeof p !== 'string' || !Object.hasOwn(PROVIDERS, p))) {
|
|
29
|
+
throw new Error('CiteNexus providers must be a nonempty list of supported provider IDs');
|
|
30
|
+
}
|
|
31
|
+
const timeout = options.timeoutSeconds === undefined ? 20 : options.timeoutSeconds;
|
|
32
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new Error('CiteNexus timeout must be an integer between 1 and 600 seconds');
|
|
33
|
+
const request = { schema_version: SCHEMA, operation: 'search', query, limit, providers: [...new Set(providers)], timeout_seconds: timeout };
|
|
34
|
+
if (options.year != null) {
|
|
35
|
+
if (!/^\d{4}$/.test(String(options.year)) || Number(options.year) < 1000 || Number(options.year) > 2200) throw new Error('CiteNexus year must be between 1000 and 2200');
|
|
36
|
+
request.year = Number(options.year);
|
|
37
|
+
}
|
|
38
|
+
if (options.intent && options.intent !== 'balanced') throw new Error('CiteNexus preserves provider ordering; use --intent=balanced');
|
|
39
|
+
return request;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateResponse(text, limit) {
|
|
43
|
+
let data;
|
|
44
|
+
try { data = JSON.parse(text); } catch { throw new Error('CiteNexus returned malformed JSON'); }
|
|
45
|
+
const stack = [[data, 0]];
|
|
46
|
+
while (stack.length) {
|
|
47
|
+
const [value, depth] = stack.pop();
|
|
48
|
+
if (depth > 32) throw new Error('CiteNexus response nesting exceeds 32 levels');
|
|
49
|
+
if (value && typeof value === 'object') for (const child of Object.values(value)) stack.push([child, depth + 1]);
|
|
50
|
+
}
|
|
51
|
+
if (!data || data.schema_version !== SCHEMA || !Array.isArray(data.results) || data.results.length > limit ||
|
|
52
|
+
!data.metadata || data.metadata.backend !== 'cite-nexus' || !Array.isArray(data.metadata.errors) ||
|
|
53
|
+
data.metadata.returned !== data.results.length ||
|
|
54
|
+
data.results.some((p) => !p || p.verification !== 'candidate' || typeof p.title !== 'string' ||
|
|
55
|
+
typeof p.bibtex !== 'string' || !p.citeNexus || !Array.isArray(p.citeNexus.sources) || !Array.isArray(p.citeNexus.metrics))) {
|
|
56
|
+
throw new Error('CiteNexus returned an incompatible response; update both companions');
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function search(query, options = {}) {
|
|
62
|
+
const request = requestFor(query, options);
|
|
63
|
+
if (options.signal && options.signal.aborted) throw new Error('CiteNexus request cancelled');
|
|
64
|
+
const command = process.env.WTFP_CITE_NEXUS_COMMAND || 'cite-nexus-wtfp';
|
|
65
|
+
if (command !== 'cite-nexus-wtfp' && !path.isAbsolute(command)) {
|
|
66
|
+
throw new Error('WTFP_CITE_NEXUS_COMMAND must name an absolute installed executable');
|
|
67
|
+
}
|
|
68
|
+
const allowed = [...RUNTIME_ENV, ...request.providers.flatMap((p) => PROVIDERS[p])];
|
|
69
|
+
const env = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]));
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
// No shell, command arguments, URL or executable can arrive from source data.
|
|
72
|
+
const child = spawn(command, [], { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
73
|
+
let settled = false, bytes = 0;
|
|
74
|
+
const chunks = [];
|
|
75
|
+
const stop = () => { if (child.exitCode === null) child.kill('SIGTERM'); };
|
|
76
|
+
const onExit = () => stop();
|
|
77
|
+
const onSignal = () => { stop(); process.exit(130); };
|
|
78
|
+
const finish = (error, data) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
process.removeListener('exit', onExit);
|
|
83
|
+
process.removeListener('SIGTERM', onSignal);
|
|
84
|
+
process.removeListener('SIGINT', onSignal);
|
|
85
|
+
if (options.signal) options.signal.removeEventListener('abort', cancel);
|
|
86
|
+
if (error) {
|
|
87
|
+
stop();
|
|
88
|
+
// The Python bridge cancels MCP and closes the SDK-owned server group.
|
|
89
|
+
const kill = setTimeout(() => child.kill('SIGKILL'), 7000);
|
|
90
|
+
kill.unref();
|
|
91
|
+
child.once('close', () => clearTimeout(kill));
|
|
92
|
+
reject(error);
|
|
93
|
+
} else resolve(data);
|
|
94
|
+
};
|
|
95
|
+
const cancel = () => finish(new Error('CiteNexus request cancelled'));
|
|
96
|
+
const timer = setTimeout(() => finish(timedOut()), request.timeout_seconds * 1000);
|
|
97
|
+
process.once('exit', onExit);
|
|
98
|
+
process.once('SIGTERM', onSignal);
|
|
99
|
+
process.once('SIGINT', onSignal);
|
|
100
|
+
if (options.signal) options.signal.addEventListener('abort', cancel, { once: true });
|
|
101
|
+
child.on('error', () => finish(new Error('CiteNexus companion unavailable; install cite-nexus-mcp 0.2.0 separately and run cite-nexus-wtfp --check')));
|
|
102
|
+
child.stdin.on('error', () => finish(new Error('CiteNexus companion closed its input')));
|
|
103
|
+
child.stdout.on('data', (chunk) => {
|
|
104
|
+
bytes += chunk.length;
|
|
105
|
+
if (bytes > MAX_BYTES) return finish(new Error('CiteNexus response exceeds 2 MiB'));
|
|
106
|
+
if (!settled) chunks.push(chunk);
|
|
107
|
+
});
|
|
108
|
+
// Drain, but never relay child stderr (it may contain sensitive diagnostics).
|
|
109
|
+
child.stderr.on('data', () => {});
|
|
110
|
+
child.on('close', (code) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
if (code !== 0) return finish(code === 124 ? timedOut() : new Error('CiteNexus companion failed; run cite-nexus-wtfp --check'));
|
|
113
|
+
try { finish(null, validateResponse(Buffer.concat(chunks).toString('utf8'), request.limit)); }
|
|
114
|
+
catch (error) { finish(error); }
|
|
115
|
+
});
|
|
116
|
+
child.stdin.end(JSON.stringify(request));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { search, requestFor, validateResponse };
|