Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions FEASIBILITY-REVIEW.md

Large diffs are not rendered by default.

441 changes: 441 additions & 0 deletions VOICE-MEMO-ANALYSIS.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions agents/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Required: LLM provider
ANTHROPIC_API_KEY=sk-ant-...

# Regen Ledger MCP server
LEDGER_MCP_URL=http://localhost:3001
LEDGER_MCP_API_KEY=

# Regen KOI MCP server
KOI_MCP_URL=http://localhost:3002
KOI_MCP_API_KEY=

# Database (PostgreSQL with pgvector)
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/regen_agents

# Redis (event queue)
REDIS_URL=redis://localhost:6379

# Agent selection (governance-analyst | registry-reviewer)
AGENT_CHARACTER=governance-analyst

# Optional: Social integrations
DISCORD_TOKEN=
TWITTER_API_KEY=
4 changes: 4 additions & 0 deletions agents/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.tsbuildinfo
146 changes: 146 additions & 0 deletions agents/__tests__/analyze-proposal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
analyzeProposal,
formatProposalAnalysis,
extractProposalId,
} from "@regen/plugin-ledger-mcp";
import type { LedgerMCPClient } from "@regen/plugin-ledger-mcp";

// Mock proposal data matching real Regen Ledger response shape
const MOCK_PROPOSAL = {
id: "62",
status: "PROPOSAL_STATUS_VOTING_PERIOD",
submit_time: "2026-01-15T12:00:00Z",
voting_end_time: "2026-01-29T12:00:00Z",
content: {
"@type": "/cosmos.params.v1beta1.ParameterChangeProposal",
title: "Enable IBC Transfer Memo Field",
description: "Enables memo field for IBC transfers, required for cross-chain integrations.",
},
final_tally_result: {
yes: "45200000000000",
no: "2100000000000",
abstain: "10400000000000",
no_with_veto: "0",
},
};

function mockClient(): LedgerMCPClient {
return {
getProposal: vi.fn().mockResolvedValue(MOCK_PROPOSAL),
listProposals: vi.fn(),
listVotes: vi.fn(),
listClasses: vi.fn(),
listProjects: vi.fn(),
listBatches: vi.fn(),
listSellOrders: vi.fn(),
listValidators: vi.fn(),
getValidatorRewards: vi.fn(),
getTotalSupply: vi.fn(),
call: vi.fn(),
} as unknown as LedgerMCPClient;
}

describe("extractProposalId", () => {
it("extracts from 'proposal 62'", () => {
expect(extractProposalId("analyze proposal 62")).toBe(62);
});

it("extracts from 'proposal #62'", () => {
expect(extractProposalId("summarize proposal #62")).toBe(62);
});

it("extracts from 'Proposal 3'", () => {
expect(extractProposalId("What is Proposal 3 about?")).toBe(3);
});

it("returns null when no proposal ID found", () => {
expect(extractProposalId("what's new?")).toBeNull();
});
});

describe("analyzeProposal", () => {
let client: LedgerMCPClient;

beforeEach(() => {
client = mockClient();
});

it("returns structured analysis for a valid proposal", async () => {
const analysis = await analyzeProposal(client, 62);

expect(analysis).not.toBeNull();
expect(analysis!.proposalId).toBe(62);
expect(analysis!.title).toBe("Enable IBC Transfer Memo Field");
expect(analysis!.status).toBe("PROPOSAL_STATUS_VOTING_PERIOD");
expect(analysis!.tally.yesPercent).toBe("78.3");
expect(analysis!.tally.vetoPercent).toBe("0.0");
expect(analysis!.impact.technical).toBe("Low - Configuration change");
expect(analysis!.impact.economic).toBe("Low");
});

it("returns null for non-existent proposal", async () => {
(client.getProposal as any).mockResolvedValue(null);
const analysis = await analyzeProposal(client, 999);
expect(analysis).toBeNull();
});

it("handles software upgrade proposals", async () => {
(client.getProposal as any).mockResolvedValue({
...MOCK_PROPOSAL,
content: {
"@type": "/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal",
title: "Regen Ledger v6.0",
description: "Upgrade to v6.0 with CosmWasm support.",
},
});

const analysis = await analyzeProposal(client, 63);
expect(analysis!.impact.technical).toBe("High - Chain upgrade required");
});

it("handles community pool spend proposals", async () => {
(client.getProposal as any).mockResolvedValue({
...MOCK_PROPOSAL,
content: {
"@type": "/cosmos.distribution.v1beta1.CommunityPoolSpendProposal",
title: "Fund Ecosystem Development",
description: "Spend 100k REGEN on ecosystem development.",
},
});

const analysis = await analyzeProposal(client, 64);
expect(analysis!.impact.economic).toBe("Direct - Treasury disbursement");
});

it("handles zero-vote proposals", async () => {
(client.getProposal as any).mockResolvedValue({
...MOCK_PROPOSAL,
final_tally_result: {
yes: "0",
no: "0",
abstain: "0",
no_with_veto: "0",
},
});

const analysis = await analyzeProposal(client, 65);
expect(analysis!.tally.yesPercent).toBe("0.0");
expect(analysis!.tally.totalVoted).toBe("0.0M");
});
});

describe("formatProposalAnalysis", () => {
it("produces markdown with all sections", async () => {
const client = mockClient();
const analysis = await analyzeProposal(client, 62);
const formatted = formatProposalAnalysis(analysis!);

expect(formatted).toContain("## Proposal #62 Analysis");
expect(formatted).toContain("**Title**: Enable IBC Transfer Memo Field");
expect(formatted).toContain("### Current Voting");
expect(formatted).toContain("| Yes |");
expect(formatted).toContain("### Impact Assessment");
expect(formatted).toContain("**Technical**:");
});
});
93 changes: 93 additions & 0 deletions agents/__tests__/koi-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { KOIMCPClient } from "@regen/plugin-koi-mcp";

describe("KOIMCPClient", () => {
let client: KOIMCPClient;
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
client = new KOIMCPClient({
baseUrl: "http://localhost:3002",
apiKey: "test-koi-key",
});

fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ results: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
});

afterEach(() => {
fetchSpy.mockRestore();
});

it("calls search endpoint with correct params", async () => {
await client.search({
query: "regen governance proposal",
intent: "general",
limit: 5,
});

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3002/mcp/tools/search",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
query: "regen governance proposal",
intent: "general",
limit: 5,
}),
})
);
});

it("calls resolveEntity with label and type hint", async () => {
await client.resolveEntity("Regen Network", "organization");

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3002/mcp/tools/resolve_entity",
expect.objectContaining({
body: JSON.stringify({
label: "Regen Network",
type_hint: "organization",
}),
})
);
});

it("calls sparqlQuery with query and limit", async () => {
const sparql = "SELECT ?s WHERE { ?s a <http://example.org/CreditClass> } LIMIT 10";
await client.sparqlQuery(sparql, 10);

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3002/mcp/tools/sparql_query",
expect.objectContaining({
body: JSON.stringify({ query: sparql, limit: 10 }),
})
);
});

it("includes authorization header", async () => {
await client.search({ query: "test" });

const callArgs = fetchSpy.mock.calls[0][1] as RequestInit;
expect((callArgs.headers as Record<string, string>)["Authorization"]).toBe(
"Bearer test-koi-key"
);
});

it("throws on server error", async () => {
fetchSpy.mockResolvedValueOnce(
new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
})
);

await expect(
client.search({ query: "test" })
).rejects.toThrow("KOI MCP call search failed: 500 Internal Server Error");
});
});
93 changes: 93 additions & 0 deletions agents/__tests__/ledger-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LedgerMCPClient } from "@regen/plugin-ledger-mcp";

describe("LedgerMCPClient", () => {
let client: LedgerMCPClient;
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
client = new LedgerMCPClient({
baseUrl: "http://localhost:3001",
apiKey: "test-key",
});

fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
});

afterEach(() => {
fetchSpy.mockRestore();
});

it("calls the correct MCP endpoint for listProposals", async () => {
await client.listProposals({ limit: 5 });

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3001/mcp/tools/list_governance_proposals",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ limit: 5 }),
})
);
});

it("includes authorization header when API key is set", async () => {
await client.listProposals({});

const callArgs = fetchSpy.mock.calls[0][1] as RequestInit;
expect((callArgs.headers as Record<string, string>)["Authorization"]).toBe(
"Bearer test-key"
);
});

it("omits authorization header when API key is empty", async () => {
const noAuthClient = new LedgerMCPClient({
baseUrl: "http://localhost:3001",
apiKey: "",
});
await noAuthClient.listProposals({});

const callArgs = fetchSpy.mock.calls[0][1] as RequestInit;
expect(
(callArgs.headers as Record<string, string>)["Authorization"]
).toBeUndefined();
});

it("throws on non-200 response", async () => {
fetchSpy.mockResolvedValueOnce(
new Response("Not Found", { status: 404, statusText: "Not Found" })
);

await expect(client.getProposal(999)).rejects.toThrow(
"Ledger MCP call get_governance_proposal failed: 404 Not Found"
);
});

it("strips trailing slash from baseUrl", async () => {
const slashClient = new LedgerMCPClient({
baseUrl: "http://localhost:3001/",
apiKey: "",
});
await slashClient.listClasses({});

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3001/mcp/tools/list_classes",
expect.anything()
);
});

it("calls getProposal with correct params", async () => {
await client.getProposal(62);

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3001/mcp/tools/get_governance_proposal",
expect.objectContaining({
body: JSON.stringify({ proposal_id: 62 }),
})
);
});
});
Loading