tokens-for-good 0.4.21 → 0.4.23
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 +7 -3
- package/package.json +45 -45
- package/pipeline/04-validate/PROMPT.md +58 -0
- package/skills/tfg-validate.md +51 -0
- package/src/api-client.js +43 -0
- package/src/api-client.test.js +62 -0
- package/src/init.js +3 -0
- package/src/mcp-server.js +125 -2
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Each org takes about 5 minutes:
|
|
|
29
29
|
2. **Verify** — every citation URL checked, hallucinations flagged and corrected
|
|
30
30
|
3. **Humanize** — voice pass (no em dashes, no AI-tells, analyst voice)
|
|
31
31
|
|
|
32
|
-
Under v3 dual-research, every org is researched by two contributors independently
|
|
32
|
+
Under v3 dual-research, every org is researched by two contributors independently. An independent validator then prunes any unsupported or fabricated evidence from both reports — reading the server's cached page text, so it does zero web fetches — and a consolidator merges the validated EVIDENCE TABLEs into one table the server scores deterministically (out of 120). A human reviewer finalizes it for the directory.
|
|
33
33
|
|
|
34
34
|
## Contributor tiers
|
|
35
35
|
|
|
@@ -46,11 +46,15 @@ Once installed, these are available to your AI via the MCP server:
|
|
|
46
46
|
|
|
47
47
|
| Tool | Purpose |
|
|
48
48
|
|---|---|
|
|
49
|
-
| `next_action` | Tells you whether to
|
|
49
|
+
| `next_action` | Tells you whether to validate, consolidate, or research next |
|
|
50
50
|
| `claim_org` | Reserves the next available nonprofit (up to 2 researchers per org under v3) |
|
|
51
|
-
| `get_methodology` | Fetches research / verify / humanize / consolidate instructions |
|
|
51
|
+
| `get_methodology` | Fetches research / verify / humanize / validate / consolidate instructions |
|
|
52
52
|
| `submit_report` | Submits a finished report (or a merged consolidation report with `disagreement_rows`) |
|
|
53
|
+
| `get_next_validation` | v3 validator: fetch both reports + cached page text to prune unsupported evidence |
|
|
54
|
+
| `submit_validation` | v3 validator: submit corrected reports (subtract/correct only, never add) |
|
|
53
55
|
| `get_next_consolidation` | v3 consolidator: fetch your assignment + both source reports to merge |
|
|
56
|
+
| `set_role_preference` | Prefer the low-fetch roles (validation/consolidation) — best for local models |
|
|
57
|
+
| `create_agent` / `list_agents` / `rotate_agent_key` / `revoke_agent` | Run several harnesses at once: each agent gets its own key + research slot |
|
|
54
58
|
| `setup_automation` | Emits `/schedule` prompt (normally called by `/tfg-schedule` skill) |
|
|
55
59
|
| `my_impact` / `research_status` / `get_badge` | Stats, leaderboard, GitHub README badge |
|
|
56
60
|
| `snooze` | Quiet the session-start prompt for N days |
|
package/package.json
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "tokens-for-good",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Donate your spare AI tokens to research nonprofits for Fierce Philanthropy",
|
|
6
|
-
"homepage": "https://tokensforgood.ai",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/Tokens-for-Good/tokens-for-good.git"
|
|
10
|
-
},
|
|
11
|
-
"bugs": {
|
|
12
|
-
"url": "https://github.com/Tokens-for-Good/tokens-for-good/issues"
|
|
13
|
-
},
|
|
14
|
-
"bin": {
|
|
15
|
-
"tokens-for-good": "src/cli.js"
|
|
16
|
-
},
|
|
17
|
-
"main": "./src/mcp-server.js",
|
|
18
|
-
"files": [
|
|
19
|
-
"src/",
|
|
20
|
-
"pipeline/",
|
|
21
|
-
"skills/",
|
|
22
|
-
"LICENSE"
|
|
23
|
-
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"start": "node src/mcp-server.js",
|
|
26
|
-
"test": "node --test src/**/*.test.js"
|
|
27
|
-
},
|
|
28
|
-
"keywords": [
|
|
29
|
-
"mcp",
|
|
30
|
-
"philanthropy",
|
|
31
|
-
"nonprofit",
|
|
32
|
-
"research",
|
|
33
|
-
"tokens",
|
|
34
|
-
"social-impact"
|
|
35
|
-
],
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"engines": {
|
|
38
|
-
"node": ">=18"
|
|
39
|
-
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
42
|
-
"prompts": "^2.4.2",
|
|
43
|
-
"zod": "^4.3.6"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "tokens-for-good",
|
|
3
|
+
"version": "0.4.23",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Donate your spare AI tokens to research nonprofits for Fierce Philanthropy",
|
|
6
|
+
"homepage": "https://tokensforgood.ai",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Tokens-for-Good/tokens-for-good.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Tokens-for-Good/tokens-for-good/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"tokens-for-good": "src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"main": "./src/mcp-server.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"src/",
|
|
20
|
+
"pipeline/",
|
|
21
|
+
"skills/",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node src/mcp-server.js",
|
|
26
|
+
"test": "node --test src/**/*.test.js"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"mcp",
|
|
30
|
+
"philanthropy",
|
|
31
|
+
"nonprofit",
|
|
32
|
+
"research",
|
|
33
|
+
"tokens",
|
|
34
|
+
"social-impact"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
42
|
+
"prompts": "^2.4.2",
|
|
43
|
+
"zod": "^4.3.6"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Validate Two Independent Research Reports (v3)
|
|
2
|
+
|
|
3
|
+
## Your Role
|
|
4
|
+
|
|
5
|
+
You are a validator — an independent fourth contributor who sits between the two researchers and the consolidator. Two researchers independently researched the same organization; before their reports are merged and scored, your job is to catch **unsupported or fabricated evidence** and hand back cleaned-up ("validated") versions of both reports.
|
|
6
|
+
|
|
7
|
+
You do **no web research and no fetching**. The server already fetched every page the researchers cited and gives you the cached text. You validate using ONLY that cached text. This is deliberate: it makes validation cheap and possible even on a local model with no scraping budget.
|
|
8
|
+
|
|
9
|
+
## Why this stage exists
|
|
10
|
+
|
|
11
|
+
A report's score comes entirely from its EVIDENCE TABLE — eight rows, each a quote + a source URL. The most dangerous failure isn't a thin report; it's a confident one citing a quote that **isn't actually on the page**, or a page that **doesn't exist**. That evidence would score real points it didn't earn. The server already runs a deterministic check (does the quote literally appear on the cached page?), and you'll see its verdicts. Your job is the judgment the deterministic check can't do:
|
|
12
|
+
|
|
13
|
+
- Does the quote, even if it appears on the page, actually **support the row's criterion**? (e.g. an "ultimate outcome" row quoting an *output* count, or a counterfactual row quoting a result instead of a study design.)
|
|
14
|
+
- Is the quote **cherry-picked** — technically present but contradicted or heavily qualified elsewhere on the same page?
|
|
15
|
+
- Do the two reports **contradict each other** on the same row in a way that means at least one is wrong?
|
|
16
|
+
|
|
17
|
+
## Your Inputs
|
|
18
|
+
|
|
19
|
+
The `/tfg-validate` skill (or the `get_next_validation` MCP tool) returns:
|
|
20
|
+
- Your validation **claim_id** (a UUID)
|
|
21
|
+
- The **organization** (name, slug, url)
|
|
22
|
+
- Two **source_reports** (markdown), each with its EVIDENCE TABLE and each with the server's deterministic **citation_verdicts** (per row: `verified` / `fabricated` / `unverifiable`)
|
|
23
|
+
- **cached_pages**: `{url, fetch_status, http_status, text}` for every URL cited across both reports. `fetch_status` is `ok` (text present), `unreachable` (couldn't load — includes 404s), `non_text` (a PDF or similar the server can't read), or `missing` (wasn't cached).
|
|
24
|
+
|
|
25
|
+
## Your Task — per row, per report
|
|
26
|
+
|
|
27
|
+
Start from the server's verdicts, then apply judgment:
|
|
28
|
+
|
|
29
|
+
1. **`fabricated`** (quote not on the page, or the URL 404s) → **remove the row** (blank its quote/URL/name). The evidence isn't real.
|
|
30
|
+
2. **`verified`** (quote is on the page) → check it actually **supports the criterion**. If the quote is on-page but doesn't match what the row is asking for, **correct it** to a better quote *from the same cached page* if one exists, otherwise **remove the row**. You may shorten or fix a quote to match the page verbatim. You may NOT swap in a different page or invent a quote.
|
|
31
|
+
3. **`unverifiable`** (`non_text` PDF / `unreachable` non-404 / `missing`) → you can't machine-check it. Keep it **only if** the surrounding report makes it credible; if it looks invented, remove it and say so in your notes. Don't punish a real PDF citation just because it can't be auto-read.
|
|
32
|
+
4. **Counterfactual rows e/f** → the quote must name the study **DESIGN** (randomized/RCT, control or comparison group, quasi-experimental), not the result. If the cached page only supports a result, remove the row.
|
|
33
|
+
|
|
34
|
+
**The one hard rule: you may only SUBTRACT rows or CORRECT a quote to match its cited page. You may NEVER ADD new evidence, new rows, or new URLs.** Adding evidence is the researcher's job, not yours. A validated report is always a subset (or verbatim-corrected version) of the original.
|
|
35
|
+
|
|
36
|
+
This is **enforced by the server**: your submission is rejected (422) if it introduces a URL the original didn't cite, OR if any EVIDENCE TABLE row's quote is not actually found on its cited page. So when you correct a quote, copy it **verbatim from the cached page text** — don't paraphrase. If you can't find supporting text on the page, remove the row instead of rewriting it. (Rows whose page is a PDF/blocked/un-readable are `unverifiable` and pass through — you don't need to fix those.)
|
|
37
|
+
|
|
38
|
+
If a report was already clean, return it unchanged (or simply omit it from your submission — omitted reports keep their original).
|
|
39
|
+
|
|
40
|
+
## Submit
|
|
41
|
+
|
|
42
|
+
Call `submit_validation` with:
|
|
43
|
+
- `claim_id`: your validation claim ID
|
|
44
|
+
- `validated_reports`: an array of `{claim_id, report_markdown}` — one entry per report you changed, where `claim_id` is that **source report's** claim_id and `report_markdown` is its full corrected markdown. Omit reports you didn't change.
|
|
45
|
+
- `validation_notes`: a short summary of what you cut and why (e.g. "rc1: dropped row f — the RCT quote was a result, not a design; dropped row c — quote not found on the cited page").
|
|
46
|
+
- `estimated_tokens`: honest estimate (validation is read-heavy but no web work).
|
|
47
|
+
|
|
48
|
+
If **both reports are already clean** (nothing to remove or correct), submit `validated_reports` as an **empty array** with a note saying so — the round then proceeds to consolidation unchanged. Don't resubmit a report you didn't change.
|
|
49
|
+
|
|
50
|
+
Each validated report must still be a structurally valid v3 report (all PROMPT/Section headings + an EVIDENCE TABLE + SOURCES). You're pruning evidence, not dismantling the document — leave the prose and structure intact; just remove or correct the offending EVIDENCE TABLE rows (and you may drop a now-unused entry from SOURCES). **Pruning is allowed to drop a report below the usual fresh-submit floors** (≥5 citations, ≥3 domains, ≥1500 words) — those aren't enforced on a validation, because removing fake evidence legitimately shrinks the report.
|
|
51
|
+
|
|
52
|
+
## What not to do
|
|
53
|
+
|
|
54
|
+
- **Don't add evidence.** Ever. Subtract or correct-to-source only.
|
|
55
|
+
- **Don't fetch anything.** Use the cached_pages text. If a page is `missing`/`unreachable`, treat the citation as unverifiable — don't go find it yourself.
|
|
56
|
+
- **Don't punish honest blanks or real PDFs.** A blank row is fine. An `unverifiable` PDF from a credible source can stay.
|
|
57
|
+
- **Don't rewrite prose.** Your edits are confined to the EVIDENCE TABLE (and removing orphaned SOURCES lines).
|
|
58
|
+
- **Don't second-guess the deterministic `fabricated` verdict.** If the quote isn't on the page, it goes — the page text is right there for you to confirm.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tfg-validate
|
|
3
|
+
description: Validate two independent v3 research reports by pruning unsupported or fabricated EVIDENCE TABLE rows, using ONLY the cached page text the server provides (no web fetches). Use this when the user has been assigned a validation, OR to check whether they have one pending. Ideal for local models — it does zero scraping.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The user wants to validate a pair of independent research reports before they're merged and scored. Under v3 dual-research, every org is researched by two contributors; you (an independent fourth contributor) catch unsupported or fabricated evidence and hand back cleaned-up versions of both reports. The consolidator then merges already-validated tables.
|
|
7
|
+
|
|
8
|
+
You do **no web research and no fetching**. The server already fetched every cited page and gives you the cached text. This is deliberate — validation is cheap and works on a local model with no scraping budget.
|
|
9
|
+
|
|
10
|
+
## What to do
|
|
11
|
+
|
|
12
|
+
1. **Call `get_next_validation` on the TFG MCP.** It returns:
|
|
13
|
+
- the org being validated,
|
|
14
|
+
- your validation `claim_id` (a UUID — submit against this one),
|
|
15
|
+
- two `source_reports` (each a full v3 report with its EVIDENCE TABLE) plus the server's deterministic `citation_verdicts` per row (`verified` / `fabricated` / `unverifiable`),
|
|
16
|
+
- `cached_pages`: the text of every URL cited across both reports, with a `fetch_status` (`ok` / `unreachable` / `non_text` / `missing`),
|
|
17
|
+
- the full validation methodology inline.
|
|
18
|
+
|
|
19
|
+
If nothing is assigned, the tool says so — tell the user and stop here.
|
|
20
|
+
|
|
21
|
+
2. **For each report, go row by row** (a1, a2, a3, b, c, d, e, f). Start from the server's verdict, then apply judgment using the cached page text:
|
|
22
|
+
- **`fabricated`** (quote not on the page, or URL 404s) → **remove the row** (blank quote/URL/name).
|
|
23
|
+
- **`verified`** (quote is on the page) → check it actually **supports the row's criterion**. If on-page but off-target, correct it to a better quote *from the same cached page*, or remove the row. Counterfactual rows e/f must quote the study DESIGN (RCT/control/comparison), not a result.
|
|
24
|
+
- **`unverifiable`** (PDF / unreachable non-404 / missing) → keep only if the report makes it credible; otherwise remove and note why. Don't punish a real PDF just because it can't be auto-read.
|
|
25
|
+
|
|
26
|
+
3. **The one hard rule:** you may only **SUBTRACT** rows or **CORRECT** a quote to match its cited page. **Never ADD** new evidence, rows, or URLs. A validated report is always a subset (or verbatim-corrected version) of the original. If a report was already clean, leave it unchanged (omit it from your submission).
|
|
27
|
+
|
|
28
|
+
4. **Call `submit_validation`** with:
|
|
29
|
+
- `claim_id`: your validation claim ID from step 1
|
|
30
|
+
- `validated_reports`: an array of `{ claim_id, report_markdown }` — one entry per report you changed, where `claim_id` is that source report's claim_id and `report_markdown` is its full corrected markdown. Omit reports you didn't change. **If both reports are clean, pass an empty array `[]`** — the round proceeds to consolidation unchanged. Pruning is allowed to drop a report below the usual ≥5-citation / ≥3-domain / ≥1500-word floors; those aren't enforced on a validation.
|
|
31
|
+
- `validation_notes`: a short summary of what you cut/corrected and why
|
|
32
|
+
- `estimated_tokens`: an honest estimate (validation is read-heavy, no web work)
|
|
33
|
+
|
|
34
|
+
5. **Tell the user what you changed** (e.g. "Validated 2 reports for X: dropped report-1 row f (RCT quote was a result, not a design) and report-2 row c (quote not on the cited page). Consolidation will follow.").
|
|
35
|
+
|
|
36
|
+
## What not to do
|
|
37
|
+
|
|
38
|
+
- **Don't add evidence.** Ever. Subtract or correct-to-source only.
|
|
39
|
+
- **Don't fetch anything.** Use the `cached_pages` text. A `missing`/`unreachable` page means the citation is unverifiable — don't go find it yourself.
|
|
40
|
+
- **Don't punish honest blanks or real PDFs.** A blank row is fine; an `unverifiable` PDF from a credible source can stay.
|
|
41
|
+
- **Don't rewrite prose.** Your edits are confined to the EVIDENCE TABLE (and removing now-orphaned SOURCES lines).
|
|
42
|
+
- **Don't second-guess a `fabricated` verdict** — the page text is right there; if the quote isn't in it, the row goes.
|
|
43
|
+
|
|
44
|
+
## If something goes wrong
|
|
45
|
+
|
|
46
|
+
- **`get_next_validation` returns "no validations assigned"** → there isn't one queued. The user can run `/tfg` to do a fresh research run instead.
|
|
47
|
+
- **`submit_validation` says a report is "no longer structurally valid"** → you removed a heading/section, not just evidence. It still needs all PROMPT/Section headings + an EVIDENCE TABLE + SOURCES. Restore the structure and resubmit with the same `claim_id`.
|
|
48
|
+
- **`submit_validation` says it "introduces new URLs"** → you added or changed a citation URL. Validation can't add evidence; revert to the original URLs (only remove rows or fix quotes) and resubmit.
|
|
49
|
+
- **`submit_validation` says a row's "quote is not on the cited page"** → you rewrote a quote to text the page doesn't contain. Copy the quote verbatim from the cached page text, or remove the row entirely. Resubmit with the same `claim_id`.
|
|
50
|
+
- **You referenced a claim_id you weren't given** → only the source `claim_id`s from `get_next_validation` are valid in `validated_reports`. Re-read its output.
|
|
51
|
+
- **No cached page text for a citation** → treat that citation as unverifiable; do not fetch it yourself.
|
package/src/api-client.js
CHANGED
|
@@ -90,6 +90,49 @@ export class ApiClient {
|
|
|
90
90
|
return this.request('GET', '/research/consolidate/next');
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
async getNextValidation() {
|
|
94
|
+
// 204 -> null when nothing is assigned, same as consolidation.
|
|
95
|
+
return this.request('GET', '/research/validate/next');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async setRolePreference(preferLowFetchRoles) {
|
|
99
|
+
return this.request('POST', '/research/agent/preferences', {
|
|
100
|
+
prefer_low_fetch_roles: !!preferLowFetchRoles,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async listAgents() {
|
|
105
|
+
return this.request('GET', '/research/agents');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async createAgent(label, preferLowFetchRoles = false) {
|
|
109
|
+
return this.request('POST', '/research/agents', {
|
|
110
|
+
label,
|
|
111
|
+
prefer_low_fetch_roles: !!preferLowFetchRoles,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async rotateAgentKey(agentId) {
|
|
116
|
+
return this.request('POST', '/research/agents/rotate', { agent_id: agentId });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async revokeAgent(agentId) {
|
|
120
|
+
return this.request('POST', '/research/agents/revoke', { agent_id: agentId });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async submitValidation(claimId, validatedReports, validationNotes = null, tokenUsage = null) {
|
|
124
|
+
const normalizedTokenUsage = typeof tokenUsage === 'number'
|
|
125
|
+
? { total_tokens: tokenUsage }
|
|
126
|
+
: tokenUsage;
|
|
127
|
+
|
|
128
|
+
return this.request('POST', '/research/validate/submit', {
|
|
129
|
+
claim_id: claimId,
|
|
130
|
+
validated_reports: validatedReports,
|
|
131
|
+
validation_notes: validationNotes,
|
|
132
|
+
token_usage: normalizedTokenUsage,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
async getStatus() {
|
|
94
137
|
const response = await fetch(`${BASE_URL}/research/status`, {
|
|
95
138
|
headers: this.headers(),
|
package/src/api-client.test.js
CHANGED
|
@@ -98,3 +98,65 @@ test('request() returns null on 204 No Content (consolidation queue empty)', asy
|
|
|
98
98
|
globalThis.fetch = original;
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test('setRolePreference posts a real boolean to the agent preferences endpoint', async () => {
|
|
103
|
+
await withMockFetch(async (calls) => {
|
|
104
|
+
const client = new ApiClient('tfg_test_key');
|
|
105
|
+
await client.setRolePreference(true);
|
|
106
|
+
|
|
107
|
+
assert.match(calls[0].url, /\/research\/agent\/preferences$/);
|
|
108
|
+
assert.equal(JSON.parse(calls[0].opts.body).prefer_low_fetch_roles, true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('createAgent posts label + low-fetch flag', async () => {
|
|
113
|
+
await withMockFetch(async (calls) => {
|
|
114
|
+
const client = new ApiClient('tfg_test_key');
|
|
115
|
+
await client.createAgent('qwen-local', true);
|
|
116
|
+
|
|
117
|
+
assert.match(calls[0].url, /\/research\/agents$/);
|
|
118
|
+
const body = JSON.parse(calls[0].opts.body);
|
|
119
|
+
assert.equal(body.label, 'qwen-local');
|
|
120
|
+
assert.equal(body.prefer_low_fetch_roles, true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('rotateAgentKey + revokeAgent post the agent_id', async () => {
|
|
125
|
+
await withMockFetch(async (calls) => {
|
|
126
|
+
const client = new ApiClient('tfg_test_key');
|
|
127
|
+
await client.rotateAgentKey(7);
|
|
128
|
+
await client.revokeAgent(7);
|
|
129
|
+
|
|
130
|
+
assert.match(calls[0].url, /\/research\/agents\/rotate$/);
|
|
131
|
+
assert.equal(JSON.parse(calls[0].opts.body).agent_id, 7);
|
|
132
|
+
assert.match(calls[1].url, /\/research\/agents\/revoke$/);
|
|
133
|
+
assert.equal(JSON.parse(calls[1].opts.body).agent_id, 7);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('getNextValidation returns null on 204 (validation queue empty)', async () => {
|
|
138
|
+
const original = globalThis.fetch;
|
|
139
|
+
globalThis.fetch = async () => new Response(null, { status: 204 });
|
|
140
|
+
try {
|
|
141
|
+
const client = new ApiClient('tfg_test_key');
|
|
142
|
+
const result = await client.getNextValidation();
|
|
143
|
+
assert.equal(result, null);
|
|
144
|
+
} finally {
|
|
145
|
+
globalThis.fetch = original;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('submitValidation posts validated_reports and wraps numeric token usage', async () => {
|
|
150
|
+
await withMockFetch(async (calls) => {
|
|
151
|
+
const client = new ApiClient('tfg_test_key');
|
|
152
|
+
const validated = [{ claim_id: 'src-uuid', report_markdown: 'cleaned report' }];
|
|
153
|
+
await client.submitValidation('val-uuid', validated, 'dropped row f', 5000);
|
|
154
|
+
|
|
155
|
+
assert.match(calls[0].url, /\/research\/validate\/submit$/);
|
|
156
|
+
const body = JSON.parse(calls[0].opts.body);
|
|
157
|
+
assert.equal(body.claim_id, 'val-uuid');
|
|
158
|
+
assert.deepEqual(body.validated_reports, validated);
|
|
159
|
+
assert.equal(body.validation_notes, 'dropped row f');
|
|
160
|
+
assert.deepEqual(body.token_usage, { total_tokens: 5000 });
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/init.js
CHANGED
|
@@ -127,6 +127,8 @@ export async function runInit() {
|
|
|
127
127
|
console.log(`✓ ${plans[3].label}`);
|
|
128
128
|
writeSkillFile('tfg-consolidate');
|
|
129
129
|
console.log(`✓ ${plans[4].label}`);
|
|
130
|
+
writeSkillFile('tfg-validate');
|
|
131
|
+
console.log(`✓ ${plans[5].label}`);
|
|
130
132
|
} else if (platform === 'qwen-code') {
|
|
131
133
|
writeQwenCommand('tfg');
|
|
132
134
|
console.log(`✓ ${plans[1].label}`);
|
|
@@ -163,6 +165,7 @@ function planWrites(platform) {
|
|
|
163
165
|
plans.push({ label: `${skillPath('tfg-schedule')} (/tfg-schedule skill)` });
|
|
164
166
|
plans.push({ label: `${skillPath('tfg')} (/tfg skill)` });
|
|
165
167
|
plans.push({ label: `${skillPath('tfg-consolidate')} (/tfg-consolidate skill)` });
|
|
168
|
+
plans.push({ label: `${skillPath('tfg-validate')} (/tfg-validate skill)` });
|
|
166
169
|
} else if (platform === 'qwen-code') {
|
|
167
170
|
plans.push({ label: `${qwenCommandPath('tfg')} (/tfg slash command)` });
|
|
168
171
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -129,13 +129,14 @@ server.tool('claim_org', 'Claim the next available nonprofit org to research.',
|
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
server.tool('get_methodology', 'Get the full instructions for a pipeline step: research (the v3 EVIDENCE TABLE flow), verify, humanize, or consolidate (the v3 dual-research merge step).', {
|
|
133
|
-
step: z.enum(['research', 'verify', 'humanize', 'consolidate']).describe('Which pipeline step to get instructions for'),
|
|
132
|
+
server.tool('get_methodology', 'Get the full instructions for a pipeline step: research (the v3 EVIDENCE TABLE flow), verify, humanize, validate (prune unsupported evidence from two reports using cached page text), or consolidate (the v3 dual-research merge step).', {
|
|
133
|
+
step: z.enum(['research', 'verify', 'humanize', 'validate', 'consolidate']).describe('Which pipeline step to get instructions for'),
|
|
134
134
|
}, async ({ step }) => {
|
|
135
135
|
const stepMap = {
|
|
136
136
|
'research': '01-research/PROMPT.md',
|
|
137
137
|
'verify': '02-verify/PROMPT.md',
|
|
138
138
|
'humanize': '03-humanize/PROMPT.md',
|
|
139
|
+
'validate': '04-validate/PROMPT.md',
|
|
139
140
|
'consolidate': '05-consolidate/PROMPT.md',
|
|
140
141
|
};
|
|
141
142
|
|
|
@@ -204,6 +205,128 @@ server.tool('get_next_consolidation', 'Get your assigned v3 consolidation: the o
|
|
|
204
205
|
}
|
|
205
206
|
});
|
|
206
207
|
|
|
208
|
+
server.tool('get_next_validation', 'Get your assigned v3 validation: both researchers\' reports, the server\'s deterministic citation verdicts, and the cached text of every page they cite. You validate using ONLY that cached text (no web fetches), pruning unsupported/fabricated EVIDENCE TABLE rows. Returns "no assignments" when nothing is queued for you.', {}, async () => {
|
|
209
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await client.getNextValidation();
|
|
213
|
+
if (!result || !result.claim_id) {
|
|
214
|
+
return { content: [{ type: 'text', text: 'No validations assigned to you right now.' }] };
|
|
215
|
+
}
|
|
216
|
+
let validateMethodology = '';
|
|
217
|
+
try {
|
|
218
|
+
validateMethodology = readFileSync(join(PIPELINE_DIR, '04-validate/PROMPT.md'), 'utf-8');
|
|
219
|
+
} catch {
|
|
220
|
+
validateMethodology = 'Using ONLY the cached page text provided, remove EVIDENCE TABLE rows whose quote is not on its cited page (verdict "fabricated"), and correct quotes that do not match. You may only SUBTRACT or CORRECT-to-source, never ADD. Submit the corrected reports with submit_validation.';
|
|
221
|
+
}
|
|
222
|
+
const reports = (result.source_reports || []).map((r, i) =>
|
|
223
|
+
`### Source report ${i + 1} — claim_id ${r.claim_id} (submitted ${r.submitted_at || 'unknown'})\n\nServer citation verdicts: ${JSON.stringify(r.citation_verdicts || {})}\n\n${r.report_markdown}`
|
|
224
|
+
).join('\n\n---\n\n');
|
|
225
|
+
const pages = (result.cached_pages || []).map((p) =>
|
|
226
|
+
`#### ${p.url} [${p.fetch_status}${p.http_status ? ' ' + p.http_status : ''}]\n${p.text ? p.text : '(no text — not machine-checkable)'}`
|
|
227
|
+
).join('\n\n');
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: 'text', text: `Validation assigned:\nOrg: ${result.org?.name}\nRound: ${result.round_id}\nYour validation claim ID (submit against this one): ${result.claim_id}\n\n---\n\n${validateMethodology}\n\n--- SOURCE REPORTS ---\n\n${reports}\n\n--- CACHED PAGE TEXT (use ONLY this; do not fetch) ---\n\n${pages}` }],
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err.status === 404 || err.status === 204) {
|
|
233
|
+
return { content: [{ type: 'text', text: 'No validations assigned to you right now.' }] };
|
|
234
|
+
}
|
|
235
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.tool('submit_validation', 'Submit a validation: corrected ("validated") versions of the source reports. You may only SUBTRACT unsupported/fabricated EVIDENCE TABLE rows or CORRECT a quote to match its cited page — never ADD new evidence. Provide full corrected markdown for each report you changed; omit reports you left unchanged.', {
|
|
240
|
+
claim_id: z.string().describe('Your validation claim_id from get_next_validation'),
|
|
241
|
+
validated_reports: z.array(z.object({
|
|
242
|
+
claim_id: z.string().describe('A source report\'s claim_id'),
|
|
243
|
+
report_markdown: z.string().describe('That report\'s full corrected markdown'),
|
|
244
|
+
})).describe('One entry per report you changed. Omit reports you did not change.'),
|
|
245
|
+
validation_notes: z.string().optional().describe('Short summary of what you cut/corrected and why'),
|
|
246
|
+
estimated_tokens: z.number().optional().describe('Honest total token estimate for this validation run'),
|
|
247
|
+
}, async ({ claim_id, validated_reports, validation_notes, estimated_tokens }) => {
|
|
248
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const result = await client.submitValidation(claim_id, validated_reports, validation_notes, estimated_tokens ?? null);
|
|
252
|
+
markContributed();
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: 'text', text: `Validation submitted for ${result.org_name}: ${result.reports_validated} report(s) validated. Consolidation will follow.` }],
|
|
255
|
+
};
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Surface the structured detail the server returns so the agent knows
|
|
258
|
+
// exactly what to fix: which rows' quotes weren't on the page, which URLs
|
|
259
|
+
// were newly introduced, or which structure checks failed.
|
|
260
|
+
const detail = [];
|
|
261
|
+
if (err.data?.validation_errors) detail.push(err.data.validation_errors.join('\n'));
|
|
262
|
+
if (err.data?.fabricated_rows) detail.push(`Rows whose quote is not on the cited page: ${err.data.fabricated_rows.join(', ')}. Copy the quote verbatim from the cached page text, or remove the row.`);
|
|
263
|
+
if (err.data?.new_urls) detail.push(`New URLs not in the original (not allowed): ${err.data.new_urls.join(', ')}`);
|
|
264
|
+
return { content: [{ type: 'text', text: `Validation error: ${err.message}${detail.length ? '\n' + detail.join('\n') : ''}` }] };
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
server.tool('set_role_preference', 'Set whether THIS agent prefers the low-fetch roles (validation & consolidation). Turn it on for a local/cheap model: the agent gets those no-scrape assignments first and waits instead of auto-starting an expensive research run when none is queued.', {
|
|
269
|
+
prefer_low_fetch_roles: z.boolean().describe('true = prefer validation/consolidation (best for local models); false = also do fresh research.'),
|
|
270
|
+
}, async ({ prefer_low_fetch_roles }) => {
|
|
271
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
272
|
+
try {
|
|
273
|
+
const result = await client.setRolePreference(prefer_low_fetch_roles);
|
|
274
|
+
return { content: [{ type: 'text', text: `Agent "${result.agent}" now ${result.prefers_low_fetch_roles ? 'PREFERS validation/consolidation (skips auto-research)' : 'does fresh research too'}.` }] };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
server.tool('list_agents', 'List your agents (concurrent workers). Each has its own key + role preference; the one you call as is is_current. Also returns your agent_limit.', {}, async () => {
|
|
281
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
282
|
+
try {
|
|
283
|
+
const result = await client.listAgents();
|
|
284
|
+
const lines = (result.agents || []).map((a) =>
|
|
285
|
+
`- [${a.id}] ${a.label}${a.is_current ? ' (this one)' : ''}${a.prefers_low_fetch_roles ? ' · low-fetch' : ''}${a.last_used_at ? ` · last used ${a.last_used_at}` : ''}`
|
|
286
|
+
).join('\n');
|
|
287
|
+
return { content: [{ type: 'text', text: `Agents (limit ${result.agent_limit}):\n${lines}` }] };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
server.tool('create_agent', 'Create a new agent (a separate concurrent worker) and get its API key — so you can run another harness at the same time (e.g. a local Qwen alongside Codex). Returns the key ONCE. Subject to your agent limit (default 2).', {
|
|
294
|
+
label: z.string().describe('A name for the new worker, e.g. "qwen-local".'),
|
|
295
|
+
prefer_low_fetch_roles: z.boolean().optional().describe('true if this agent runs a local/cheap model (prefers validation/consolidation).'),
|
|
296
|
+
}, async ({ label, prefer_low_fetch_roles }) => {
|
|
297
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
298
|
+
try {
|
|
299
|
+
const result = await client.createAgent(label, !!prefer_low_fetch_roles);
|
|
300
|
+
return { content: [{ type: 'text', text: `Created agent "${result.agent.label}" (id ${result.agent.id}).\n\nIts API key (shown once — configure the other harness with it):\n${result.api_key}` }] };
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
server.tool('rotate_agent_key', 'Rotate one of your agents\' API keys (get the id from list_agents). The old key stops working immediately; reconfigure that harness with the new key. Use if a key leaked.', {
|
|
307
|
+
agent_id: z.number().describe('The id of the agent to rotate (from list_agents).'),
|
|
308
|
+
}, async ({ agent_id }) => {
|
|
309
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
310
|
+
try {
|
|
311
|
+
const result = await client.rotateAgentKey(agent_id);
|
|
312
|
+
return { content: [{ type: 'text', text: `Rotated key for agent "${result.label}". New key (shown once):\n${result.api_key}` }] };
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
server.tool('revoke_agent', 'Permanently revoke one of your agents (get the id from list_agents). Its key stops working and the slot frees up. You cannot revoke your only agent.', {
|
|
319
|
+
agent_id: z.number().describe('The id of the agent to revoke (from list_agents).'),
|
|
320
|
+
}, async ({ agent_id }) => {
|
|
321
|
+
if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
|
|
322
|
+
try {
|
|
323
|
+
await client.revokeAgent(agent_id);
|
|
324
|
+
return { content: [{ type: 'text', text: `Revoked agent ${agent_id}.` }] };
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
207
330
|
server.tool('research_status', 'See the overall Tokens for Good project progress and leaderboard.', {}, async () => {
|
|
208
331
|
try {
|
|
209
332
|
const clientForStatus = client || new ApiClient('dummy', { version: PKG_VERSION, platform, installId }); // Status is public
|