@jayarrowz/mcp-arsr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,480 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { DEFAULT_CONFIG } from "../types.js";
3
+ import type { ARSRConfig, Claim, ScoredClaim, EvidenceDoc, ClaimEvidence } from "../types.js";
4
+
5
+ let client: Anthropic | null = null;
6
+
7
+ function getClient(): Anthropic {
8
+ if (!client) {
9
+ client = new Anthropic(); // Uses ANTHROPIC_API_KEY env var
10
+ }
11
+ return client;
12
+ }
13
+
14
+ async function askInner(
15
+ system: string,
16
+ user: string,
17
+ config: ARSRConfig = DEFAULT_CONFIG
18
+ ): Promise<string> {
19
+ const api = getClient();
20
+ const response = await api.messages.create({
21
+ model: config.inner_model,
22
+ max_tokens: 4096,
23
+ system,
24
+ messages: [{ role: "user", content: user }],
25
+ });
26
+
27
+ const textBlock = response.content.find((b) => b.type === "text");
28
+ return textBlock ? textBlock.text : "";
29
+ }
30
+
31
+ async function askInnerWithSearch(
32
+ system: string,
33
+ user: string,
34
+ config: ARSRConfig = DEFAULT_CONFIG
35
+ ): Promise<{ text: string; citations: Array<{ url: string; title: string }> }> {
36
+ const api = getClient();
37
+ const response = await api.messages.create({
38
+ model: config.inner_model,
39
+ max_tokens: 4096,
40
+ system,
41
+ messages: [{ role: "user", content: user }],
42
+ tools: [{ type: "web_search_20250305", name: "web_search" } as unknown as Anthropic.Messages.Tool],
43
+ });
44
+
45
+ // Extract text and citations from the response
46
+ let text = "";
47
+ const citations: Array<{ url: string; title: string }> = [];
48
+
49
+ for (const block of response.content) {
50
+ if (block.type === "text") {
51
+ text += block.text;
52
+ // Extract any inline citations
53
+ if ("citations" in block && Array.isArray(block.citations)) {
54
+ for (const cite of block.citations) {
55
+ if ("url" in cite && "title" in cite) {
56
+ citations.push({
57
+ url: cite.url as string,
58
+ title: cite.title as string,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return { text, citations };
67
+ }
68
+
69
+ function extractJSON<T>(raw: string): T {
70
+ // Strip markdown fences if present
71
+ const cleaned = raw
72
+ .replace(/```json\s*/gi, "")
73
+ .replace(/```\s*/g, "")
74
+ .trim();
75
+
76
+ // Try to find JSON object or array
77
+ const jsonMatch = cleaned.match(/[\[{][\s\S]*[\]}]/);
78
+ if (!jsonMatch) {
79
+ throw new Error(`No JSON found in LLM output: ${cleaned.slice(0, 200)}`);
80
+ }
81
+ return JSON.parse(jsonMatch[0]) as T;
82
+ }
83
+
84
+ /**
85
+ * Classify whether a draft is a refusal/non-answer using the inner LLM.
86
+ * Returns true if the draft deflects, redirects, or refuses to answer.
87
+ */
88
+ async function classifyRefusal(
89
+ draft: string,
90
+ config: ARSRConfig = DEFAULT_CONFIG
91
+ ): Promise<boolean> {
92
+ const system = `You are a response classifier. Determine whether the given text is a REFUSAL or NON-ANSWER.
93
+
94
+ A refusal is any response that:
95
+ - Says it cannot, does not, or is unable to provide the information
96
+ - Redirects the user to check another source instead of answering
97
+ - Provides only generic suggestions instead of a direct answer
98
+ - Hedges so heavily that no substantive information is conveyed
99
+
100
+ Respond with ONLY a JSON object: { "is_refusal": true } or { "is_refusal": false }`;
101
+
102
+ const raw = await askInner(system, `Classify this response:\n\n${draft}`, config);
103
+ try {
104
+ const result = extractJSON<{ is_refusal: boolean }>(raw);
105
+ return result.is_refusal === true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Generate an initial draft response to a query.
113
+ * Uses web search to ground the draft in real data — this prevents
114
+ * the "I don't know" refusal problem where the inner model punts
115
+ * on questions outside its training data.
116
+ *
117
+ * Returns the draft text and a structured is_refusal flag classified
118
+ * by the inner LLM (replacing brittle string matching).
119
+ */
120
+ export async function generateDraft(
121
+ query: string,
122
+ context?: string,
123
+ config: ARSRConfig = DEFAULT_CONFIG
124
+ ): Promise<{ draft: string; is_refusal: boolean }> {
125
+ const system = `You are a helpful, accurate research assistant. Your job is to ANSWER the user's question with specific, concrete facts.
126
+
127
+ CRITICAL RULES:
128
+ - You MUST search the web to find the answer. Do NOT rely on memory alone.
129
+ - You MUST provide a direct, substantive answer with specific facts, numbers, and details.
130
+ - NEVER say "I don't have access to" or "I recommend checking" or "I cannot provide". These are failures.
131
+ - NEVER give a list of places to look instead of the answer. FIND the answer yourself.
132
+ - If the question asks about specific data (vote counts, statistics, dates), SEARCH FOR IT and REPORT IT.
133
+ - It's OK to be wrong — your answer will be fact-checked and corrected later. A wrong answer is better than no answer.
134
+ - Include your best understanding even if uncertain. The refinement loop will fix errors.
135
+ ${context ? `\nAdditional context:\n${context}` : ""}`;
136
+
137
+ const { text } = await askInnerWithSearch(system, query, config);
138
+ const is_refusal = await classifyRefusal(text, config);
139
+ return { draft: text, is_refusal };
140
+ }
141
+
142
+ /**
143
+ * Decompose a draft into individually verifiable atomic claims.
144
+ * If the draft is a refusal/non-answer, extracts claims from the
145
+ * original query context instead so the loop can still retrieve evidence.
146
+ */
147
+ export async function decomposeClaims(
148
+ draft: string,
149
+ originalQuery?: string,
150
+ isRefusal: boolean = false,
151
+ config: ARSRConfig = DEFAULT_CONFIG
152
+ ): Promise<Claim[]> {
153
+
154
+ let textToDecompose = draft;
155
+ let systemAddendum = "";
156
+
157
+ if (isRefusal && originalQuery) {
158
+ // The draft was classified as a refusal/non-answer by the LLM.
159
+ // Extract claims from the user's question instead.
160
+ textToDecompose = originalQuery;
161
+ systemAddendum = `
162
+ IMPORTANT: The original draft was a non-answer/refusal. You are now extracting the factual claims
163
+ embedded in the USER'S QUESTION instead. These are the claims that need to be verified.
164
+ For example, if the user says "Is it true that X got Y votes?", extract "X got Y votes" as a claim.`;
165
+ }
166
+
167
+ const system = `You are a claim extraction engine. Given a text, extract every distinct factual claim as a separate item.
168
+
169
+ Rules:
170
+ - Each claim must be a single, independently verifiable statement
171
+ - Preserve the original meaning precisely
172
+ - Skip opinions, hedges ("I think"), and meta-commentary
173
+ - Include the source_span (the exact substring from the original text)
174
+ - Give each claim a short id like "c1", "c2", etc.
175
+ ${systemAddendum}
176
+
177
+ Respond ONLY with a JSON array:
178
+ [{ "id": "c1", "text": "The claim as a standalone statement", "source_span": "exact quote from draft" }, ...]`;
179
+
180
+ const raw = await askInner(system, `Extract all factual claims from:\n\n${textToDecompose}`, config);
181
+ return extractJSON<Claim[]>(raw);
182
+ }
183
+
184
+ /**
185
+ * Score claims by generating multiple rephrasings and measuring agreement.
186
+ * Uses semantic entropy: low agreement across rephrasings = high uncertainty.
187
+ */
188
+ export async function scoreClaims(
189
+ claims: Claim[],
190
+ config: ARSRConfig = DEFAULT_CONFIG
191
+ ): Promise<ScoredClaim[]> {
192
+ const system = `You are an uncertainty estimation engine. For each claim, assess how likely it is to be factually correct.
193
+
194
+ Consider:
195
+ - Is this common knowledge or obscure?
196
+ - Are there well-known disputes about this?
197
+ - Does the specificity (exact numbers, dates, names) increase risk of error?
198
+ - Could this be a common misconception?
199
+
200
+ Respond ONLY with a JSON array:
201
+ [{
202
+ "id": "c1",
203
+ "confidence": 0.92,
204
+ "entropy": 0.15,
205
+ "method": "semantic_entropy",
206
+ "reasoning": "brief explanation"
207
+ }, ...]
208
+
209
+ confidence: 0.0 = certainly wrong, 1.0 = certainly correct
210
+ entropy: 0.0 = very certain, 1.0 = highly uncertain`;
211
+
212
+ const claimsText = claims
213
+ .map((c) => `[${c.id}] ${c.text}`)
214
+ .join("\n");
215
+
216
+ const raw = await askInner(
217
+ system,
218
+ `Score the uncertainty of each claim:\n\n${claimsText}`,
219
+ config
220
+ );
221
+
222
+ const scored = extractJSON<Array<{
223
+ id: string;
224
+ confidence: number;
225
+ entropy: number;
226
+ method: string;
227
+ reasoning?: string;
228
+ }>>(raw);
229
+ return scored.map((s) => {
230
+ const original = claims.find((c) => c.id === s.id);
231
+ return {
232
+ id: s.id,
233
+ text: original?.text ?? "",
234
+ source_span: original?.source_span ?? "",
235
+ confidence: Math.max(0, Math.min(1, s.confidence)),
236
+ entropy: Math.max(0, Math.min(1, s.entropy)),
237
+ method: "semantic_entropy" as const,
238
+ };
239
+ });
240
+ }
241
+
242
+ /**
243
+ * For low-confidence claims, generate adversarial search queries and retrieve evidence.
244
+ * Uses the inner LLM + web search to find supporting/contradicting sources.
245
+ */
246
+ export async function retrieveEvidence(
247
+ claims: ScoredClaim[],
248
+ strategy: string = "adversarial",
249
+ config: ARSRConfig = DEFAULT_CONFIG
250
+ ): Promise<ClaimEvidence[]> {
251
+ const results: ClaimEvidence[] = [];
252
+
253
+ for (const claim of claims) {
254
+ const queryGenSystem = `You are a search query generator for fact-checking.
255
+ Strategy: ${strategy}
256
+
257
+ For "adversarial": generate queries designed to DISPROVE the claim. Search for counterexamples, corrections, or the actual facts.
258
+ For "confirmatory": generate queries to find authoritative sources that confirm the claim.
259
+ For "balanced": generate both supporting and challenging queries.
260
+
261
+ Respond ONLY with a JSON array of 2-3 search queries:
262
+ ["query 1", "query 2", "query 3"]`;
263
+
264
+ const queriesRaw = await askInner(
265
+ queryGenSystem,
266
+ `Generate search queries to fact-check: "${claim.text}"`,
267
+ config
268
+ );
269
+
270
+ let queries: string[];
271
+ try {
272
+ queries = extractJSON<string[]>(queriesRaw);
273
+ } catch {
274
+ queries = [claim.text];
275
+ }
276
+
277
+ const allDocs: EvidenceDoc[] = [];
278
+
279
+ for (const query of queries.slice(0, 3)) {
280
+ try {
281
+ const searchSystem = `You are a fact-checking research assistant. Search for information about the given query and evaluate what you find relative to this claim: "${claim.text}"
282
+
283
+ After searching, respond with a JSON array of the most relevant results:
284
+ [{
285
+ "title": "Page title",
286
+ "url": "https://...",
287
+ "snippet": "The relevant excerpt (max 100 words)",
288
+ "stance": "supports" | "contradicts" | "neutral" | "unclear"
289
+ }]
290
+
291
+ Respond ONLY with the JSON array.`;
292
+
293
+ const { text } = await askInnerWithSearch(
294
+ searchSystem,
295
+ `Search and evaluate: ${query}`,
296
+ config
297
+ );
298
+
299
+ try {
300
+ const docs = extractJSON<EvidenceDoc[]>(text);
301
+ allDocs.push(...docs);
302
+ } catch {
303
+ // If parsing fails, still capture as a single doc
304
+ allDocs.push({
305
+ title: "Search result",
306
+ url: "",
307
+ snippet: text.slice(0, 300),
308
+ stance: "unclear",
309
+ });
310
+ }
311
+ } catch (err) {
312
+ console.error(`Search failed for query "${query}":`, err);
313
+ }
314
+ }
315
+
316
+ // Step 3: Summarize the evidence stance
317
+ const supports = allDocs.filter((d) => d.stance === "supports").length;
318
+ const contradicts = allDocs.filter((d) => d.stance === "contradicts").length;
319
+
320
+ let overall_stance: ClaimEvidence["overall_stance"];
321
+ if (allDocs.length === 0) overall_stance = "insufficient";
322
+ else if (supports > 0 && contradicts > 0) overall_stance = "mixed";
323
+ else if (contradicts > supports) overall_stance = "contradicted";
324
+ else overall_stance = "supported";
325
+
326
+ // Step 4: Generate a concise summary
327
+ const summarySystem = `Summarize the evidence for/against this claim in 1-2 sentences. Be direct.`;
328
+ const summaryInput = `Claim: "${claim.text}"\nEvidence:\n${allDocs.map((d) => `- [${d.stance}] ${d.snippet}`).join("\n")}`;
329
+ const summary = allDocs.length > 0
330
+ ? await askInner(summarySystem, summaryInput, config)
331
+ : "No evidence found.";
332
+
333
+ results.push({
334
+ claim_id: claim.id,
335
+ claim_text: claim.text,
336
+ docs: allDocs,
337
+ overall_stance,
338
+ summary,
339
+ });
340
+ }
341
+
342
+ return results;
343
+ }
344
+
345
+ /**
346
+ * Revise the draft based on evidence, returning the new text + change log.
347
+ * If the original draft was a refusal/non-answer, writes a completely new
348
+ * response from the evidence instead of trying to edit the refusal.
349
+ */
350
+ export async function reviseDraft(
351
+ draft: string,
352
+ evidence: ClaimEvidence[],
353
+ scored: ScoredClaim[],
354
+ originalQuery?: string,
355
+ isRefusal: boolean = false,
356
+ config: ARSRConfig = DEFAULT_CONFIG
357
+ ): Promise<{
358
+ revised: string;
359
+ changes: Array<{
360
+ claim_id: string;
361
+ action: string;
362
+ original: string;
363
+ revised: string;
364
+ reason: string;
365
+ }>;
366
+ conflicts: Array<{
367
+ claim_id: string;
368
+ description: string;
369
+ }>;
370
+ }> {
371
+ const evidenceSummary = evidence
372
+ .map(
373
+ (e) =>
374
+ `[${e.claim_id}] "${e.claim_text}" → ${e.overall_stance}\n Evidence: ${e.summary}`
375
+ )
376
+ .join("\n\n");
377
+
378
+ let system: string;
379
+
380
+ if (isRefusal && originalQuery) {
381
+ // The draft was a non-answer. Write a NEW response from the evidence.
382
+ system = `You are a response generation engine. The original draft FAILED to answer the user's question — it was a refusal or redirect.
383
+
384
+ Your job: Write a COMPLETELY NEW response that DIRECTLY ANSWERS the user's question using the evidence provided.
385
+
386
+ User's original question: "${originalQuery}"
387
+
388
+ Rules:
389
+ - DIRECTLY answer the question using the evidence gathered
390
+ - Include specific facts, numbers, and details from the evidence
391
+ - If the evidence contradicts what the user claimed, say so clearly
392
+ - If the evidence supports what the user claimed, confirm it
393
+ - Add hedging ("reportedly", "according to...") only for genuinely mixed evidence
394
+ - Do NOT say "I don't have access" or redirect to other sources
395
+
396
+ Respond with JSON:
397
+ {
398
+ "revised": "The full NEW response that directly answers the question",
399
+ "changes": [
400
+ { "claim_id": "c1", "action": "generated_from_evidence", "original": "N/A - draft was a refusal", "revised": "the new claim", "reason": "Evidence shows..." }
401
+ ],
402
+ "conflicts": [
403
+ { "claim_id": "c2", "description": "Sources disagree about..." }
404
+ ]
405
+ }`;
406
+ } else {
407
+ system = `You are a response revision engine. Given an original draft and fact-checking evidence, produce a corrected version.
408
+
409
+ Rules:
410
+ - Fix claims that were contradicted by evidence
411
+ - Add hedging language ("reportedly", "according to...") for mixed evidence
412
+ - Remove claims with no supporting evidence if they are central to the answer
413
+ - Keep claims that were supported — don't weaken what's already correct
414
+ - Preserve the original tone and structure as much as possible
415
+
416
+ Respond with JSON:
417
+ {
418
+ "revised": "The full revised response text",
419
+ "changes": [
420
+ { "claim_id": "c1", "action": "corrected|removed|hedged|kept", "original": "...", "revised": "...", "reason": "..." }
421
+ ],
422
+ "conflicts": [
423
+ { "claim_id": "c2", "description": "Sources disagree about..." }
424
+ ]
425
+ }`;
426
+ }
427
+
428
+ const raw = await askInner(
429
+ system,
430
+ `Original draft:\n${draft}\n\nEvidence report:\n${evidenceSummary}`,
431
+ config
432
+ );
433
+
434
+ return extractJSON(raw);
435
+ }
436
+
437
+ /**
438
+ * Decide whether to continue the refinement loop.
439
+ */
440
+ export async function shouldContinue(
441
+ iteration: number,
442
+ scored: ScoredClaim[],
443
+ maxIterations: number,
444
+ confidenceThreshold: number,
445
+ previousAvgConfidence: number | null
446
+ ): Promise<{ decision: "continue" | "stop"; reason: string }> {
447
+ if (iteration >= maxIterations) {
448
+ return { decision: "stop", reason: `Budget exhausted (${maxIterations} iterations)` };
449
+ }
450
+
451
+ const avgConfidence =
452
+ scored.length > 0
453
+ ? scored.reduce((sum, c) => sum + c.confidence, 0) / scored.length
454
+ : 1;
455
+
456
+ const lowConfidence = scored.filter(
457
+ (c) => c.confidence < confidenceThreshold
458
+ );
459
+ if (lowConfidence.length === 0) {
460
+ return {
461
+ decision: "stop",
462
+ reason: `All ${scored.length} claims above confidence threshold (${confidenceThreshold})`,
463
+ };
464
+ }
465
+
466
+ if (previousAvgConfidence !== null) {
467
+ const improvement = avgConfidence - previousAvgConfidence;
468
+ if (improvement < 0.02) {
469
+ return {
470
+ decision: "stop",
471
+ reason: `Confidence converged (Δ=${improvement.toFixed(3)}, threshold=0.02). ${lowConfidence.length} claims remain below threshold.`,
472
+ };
473
+ }
474
+ }
475
+
476
+ return {
477
+ decision: "continue",
478
+ reason: `${lowConfidence.length}/${scored.length} claims below threshold. Avg confidence: ${avgConfidence.toFixed(3)}. Continuing refinement.`,
479
+ };
480
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ export interface Claim {
2
+ id: string;
3
+ text: string;
4
+ source_span: string; // The substring of the draft this claim came from
5
+ }
6
+
7
+ export interface ScoredClaim extends Claim {
8
+ confidence: number; // 0-1, higher = more confident
9
+ entropy: number; // Semantic entropy across rephrasings
10
+ method: "semantic_entropy" | "consistency_vote";
11
+ variants?: string[]; // The rephrasings used to compute entropy
12
+ }
13
+
14
+ export interface EvidenceDoc {
15
+ title: string;
16
+ url: string;
17
+ snippet: string;
18
+ stance: "supports" | "contradicts" | "neutral" | "unclear";
19
+ }
20
+
21
+ export interface ClaimEvidence {
22
+ claim_id: string;
23
+ claim_text: string;
24
+ docs: EvidenceDoc[];
25
+ overall_stance: "supported" | "contradicted" | "mixed" | "insufficient";
26
+ summary: string;
27
+ }
28
+
29
+ export interface RevisionChange {
30
+ claim_id: string;
31
+ action: "kept" | "corrected" | "removed" | "hedged";
32
+ original: string;
33
+ revised: string;
34
+ reason: string;
35
+ }
36
+
37
+ export interface Conflict {
38
+ claim_id: string;
39
+ description: string;
40
+ sources_for: string[];
41
+ sources_against: string[];
42
+ }
43
+
44
+ export interface LoopState {
45
+ iteration: number;
46
+ max_iterations: number;
47
+ confidence_threshold: number;
48
+ previous_avg_confidence: number | null;
49
+ claims_improved: number;
50
+ claims_degraded: number;
51
+ }
52
+
53
+ export interface ARSRConfig {
54
+ max_iterations: number;
55
+ confidence_threshold: number;
56
+ entropy_samples: number;
57
+ retrieval_strategy: "adversarial" | "confirmatory" | "balanced";
58
+ inner_model: string;
59
+ }
60
+
61
+ export const DEFAULT_CONFIG: ARSRConfig = {
62
+ max_iterations: parseInt(process.env.ARSR_MAX_ITERATIONS || "3", 10),
63
+ confidence_threshold: parseFloat(process.env.ARSR_CONFIDENCE_THRESHOLD || "0.85"),
64
+ entropy_samples: parseInt(process.env.ARSR_ENTROPY_SAMPLES || "3", 10),
65
+ retrieval_strategy: (process.env.ARSR_RETRIEVAL_STRATEGY as ARSRConfig["retrieval_strategy"]) || "adversarial",
66
+ inner_model: process.env.ARSR_INNER_MODEL || "claude-haiku-4-5-20251001",
67
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "outDir": "./dist",
8
+ "rootDir": ".",
9
+ "strict": true,
10
+ "declaration": true,
11
+ "skipLibCheck": true,
12
+ "allowSyntheticDefaultImports": true
13
+ },
14
+ "include": ["./src/**/*.ts"],
15
+ "exclude": ["node_modules", "**/*.test.ts"]
16
+ }