Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,8 @@ PRAVA_MCP_SCOPES_JSON=[]
# Optional client/tooling defaults for apps/mcp-demo.
CASCADE_MCP_ENDPOINT=http://127.0.0.1:3001/mcp
CASCADE_MCP_TOKEN=cascade-local-dev-token

# Visual marketplace capture (apps/web/src/marketplace, apps/server/src/marketplace-capture.ts).
# Feature is off unless WEB_SESSION_SECRET (above) and OPENAI_API_KEY (above) are both set.
MARKETPLACE_CAPTURE_MAX_BYTES=5000000
MARKETPLACE_CAPTURE_RETENTION_MS=86400000
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,40 @@ Signature path:

Cascade returns one best valid offer, explains why it won, discloses material trade-offs, and presents BUY. Passkey approval scopes merchant and amount. Checkout runs through a tested path; the receipt and audit trail close the mission.

### Visual marketplace + capture (`/marketplace`)

A demo entry point that lets a shopper browse Cascade's sandbox catalog visually, capture a product
card, and create a mission from it instead of typing a prompt. Nothing new is invented here: the
marketplace reads the same `MOCK_INDIA_LISTINGS` registry that text-originated missions already
search, and a captured product flows through the exact same kernel → mission search → mock-vendor
quote → `requestSpend` → Prava REST sandbox → checkout pipeline as any other mission — there is no
capture-specific quote, payment, or checkout code path.

```
apps/web/src/marketplace/* Product grid, capture shortcut (⌘/Ctrl+Shift+K), review dialog
apps/server/src/marketplace-*.ts Catalog read, capture API, screenshot storage, vision analysis job
packages/db/migrations/0016-0017 marketplace_captures table
packages/contracts Marketplace/capture Zod schemas, capture.create_marketplace_mission MCP tool
```

Capture flow: select a product card → capture (behind explicit confirmation in a local review dialog;
Cancel makes no network call) → server reloads the product/vendor/variant from the registry
(client-claimed price/vendor/stock is never trusted) → vision model extracts facts for display only,
never for identity — a conflict blocks mission creation rather than overriding the registry →
Purchase Contract compiled with `source: "visual_capture"` and a budget grounded in the trusted price,
capped at any explicit ceiling the shopper stated in free text → `CascadeKernel.createMission` → the
shared mission pipeline takes over.

Every screen carries a sandbox notice; no real payment or merchant order is ever created. Screenshots
are stored privately and deleted after `MARKETPLACE_CAPTURE_RETENTION_MS` (default 24h) regardless of
mission outcome. The feature is inert unless both `WEB_SESSION_SECRET` and `OPENAI_API_KEY` are set.

```bash
pnpm --filter @cascade/server exec vitest run src/marketplace-capture.test.ts
pnpm --filter @cascade/server exec vitest run src/marketplace-capture.integration.test.ts
pnpm --filter @cascade/web test
```

---

## Architecture
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp-demo/src/mcp-demo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,12 @@ describe("external MCP demo client", () => {

it("validates the governed tool catalog and reports missing tools", async () => {
const client = new ExternalMcpClient({ transport: fixtureTransport() });
await expect(client.listTools()).resolves.toHaveLength(11);
await expect(client.listTools()).resolves.toHaveLength(13);

const catalog = validateToolCatalog(toolCatalogResponse);
expect(catalog.missing).toEqual([]);
expect(catalog.mismatched).toEqual([]);
expect(catalog.tools).toHaveLength(11);
expect(catalog.tools).toHaveLength(13);

const partial = validateToolCatalog(toolCatalogResponse.slice(0, 1));
expect(partial.missing).toContain("spend.request");
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --exclude '**/*.integration.test.ts'",
"test:integration": "vitest run src/app.integration.test.ts src/workflow.integration.test.ts --maxWorkers=1",
"test:integration": "vitest run src/app.integration.test.ts src/workflow.integration.test.ts src/marketplace-capture.integration.test.ts --maxWorkers=1",
"start": "node dist/index.js",
"start:worker": "node dist/worker.js"
},
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/app.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ describe("MCP v2 kernel adapter", () => {
transport: "streamable-http",
audience: "http://127.0.0.1:3001/mcp",
shopping: false,
commerce: {
mockIndiaMerchants: false,
},
checkout: {
pravaMcp: false,
pravaRest: false,
Expand Down Expand Up @@ -604,7 +607,7 @@ describe("MCP v2 kernel adapter", () => {
token: TOKEN,
});
try {
await expect(live.client.listTools()).resolves.toHaveLength(11);
await expect(live.client.listTools()).resolves.toHaveLength(13);
const created = await live.client.createMission({
commandId: randomUUID(),
contractCommandId: randomUUID(),
Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ export type ServerConfig = {
marketplaceCaptureRetentionMs: number;
};

function positiveFiniteNumber(
raw: string | undefined,
fallback: number,
): number {
const parsed = Number(raw);
return raw !== undefined && Number.isFinite(parsed) && parsed > 0
? parsed
: fallback;
}

function jsonEnvironment<T>(
name: string,
value: string | undefined,
Expand Down Expand Up @@ -633,11 +643,13 @@ export function loadServerConfig(
? { webAppBaseUrl: webAppBaseUrl.replace(/\/$/, "") }
: {}),
...(capsuleMasterKey ? { capsuleMasterKey } : {}),
marketplaceCaptureMaxBytes: Number(
environment["MARKETPLACE_CAPTURE_MAX_BYTES"] ?? 5 * 1024 * 1024,
marketplaceCaptureMaxBytes: positiveFiniteNumber(
environment["MARKETPLACE_CAPTURE_MAX_BYTES"],
5 * 1024 * 1024,
),
marketplaceCaptureRetentionMs: Number(
environment["MARKETPLACE_CAPTURE_RETENTION_MS"] ?? 24 * 60 * 60_000,
marketplaceCaptureRetentionMs: positiveFiniteNumber(
environment["MARKETPLACE_CAPTURE_RETENTION_MS"],
24 * 60 * 60_000,
),
};
}
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/chat-pitch-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,9 @@ async function* waitForPravaReceipt(input: {
type: "message",
role: "cascade",
text: `Receipt ready. Mission completed after Prava sandbox approval${
amount != null ? ` for ₹${(amount / 100).toLocaleString("en-IN")}` : ""
amount != null
? ` for ₹${(amount / 100).toLocaleString("en-IN")}`
: ""
}.`,
};
return;
Expand Down Expand Up @@ -313,7 +315,10 @@ async function* waitForPravaReceipt(input: {
);
}
} catch (error) {
if (error instanceof DomainError && error.code === "DEMO_MISSION_FAILED") {
if (
error instanceof DomainError &&
error.code === "DEMO_MISSION_FAILED"
) {
throw error;
}
if (
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ const marketplaceCaptureRetentionTimer =
void runtime.marketplaceCaptures?.worker
.runRetentionSweep()
.catch((error) =>
console.error("Marketplace capture retention sweep failed", error),
console.error(
"Marketplace capture retention sweep failed",
error,
),
),
3_600_000,
)
Expand Down Expand Up @@ -124,7 +127,8 @@ async function close(): Promise<void> {
stopLinqRuntime();
if (watchTimer) clearInterval(watchTimer);
if (capsuleTimer) clearInterval(capsuleTimer);
if (marketplaceCaptureRetentionTimer) clearInterval(marketplaceCaptureRetentionTimer);
if (marketplaceCaptureRetentionTimer)
clearInterval(marketplaceCaptureRetentionTimer);
await app.close();
await runtime.jobs.stop();
await runtime.pool.end();
Expand Down
Loading
Loading