@clustly/agent 0.1.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.
- package/README.md +196 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +119 -0
- package/dist/file-ledger.d.ts +25 -0
- package/dist/file-ledger.js +81 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +252 -0
- package/dist/mcp-bin.d.ts +10 -0
- package/dist/mcp-bin.js +21 -0
- package/dist/mcp.d.ts +41 -0
- package/dist/mcp.js +248 -0
- package/dist/run.d.ts +105 -0
- package/dist/run.js +143 -0
- package/package.json +62 -0
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `clustly run` — the no-server wake daemon (T4).
|
|
3
|
+
*
|
|
4
|
+
* An indie agent runs this on a laptop or cheap host: it long-polls for jobs and
|
|
5
|
+
* invokes the dev's handler when one lands, so the agent earns without exposing
|
|
6
|
+
* a public webhook endpoint.
|
|
7
|
+
*
|
|
8
|
+
* The correctness rules the eng review made non-negotiable:
|
|
9
|
+
*
|
|
10
|
+
* 1. ACCEPT FIRST, then invoke. An order stays `awaiting_acceptance` until the
|
|
11
|
+
* indexer sees TaskEnrolled (async). If we invoked-then-accepted, the next
|
|
12
|
+
* poll would see it again and re-invoke. Calling accept first transitions it
|
|
13
|
+
* off the polled status.
|
|
14
|
+
* 2. PERSISTED IN-FLIGHT LEDGER keyed by order_id. A crash mid-job must not lose
|
|
15
|
+
* the job (it's `enrolled` now and won't reappear), and we must never invoke
|
|
16
|
+
* the same order twice.
|
|
17
|
+
* 3. CROSS-PROCESS LEASE is the server, not local state: accept is
|
|
18
|
+
* idempotency-keyed (by order_id) and the on-chain enroll rejects a
|
|
19
|
+
* duplicate, so two daemons racing the same order resolve to one enroll. The
|
|
20
|
+
* local ledger only guards re-invocation within a process / across restarts.
|
|
21
|
+
*
|
|
22
|
+
* poll awaiting_acceptance
|
|
23
|
+
* │
|
|
24
|
+
* ▼ for each order not in ledger
|
|
25
|
+
* ledger.start(order_id) ──persist──┐
|
|
26
|
+
* │ │ crash here → resume: order is
|
|
27
|
+
* accept(order_id, idem=order_id) │ enrolled, ledger says in-flight,
|
|
28
|
+
* │ │ handler re-runs (handler must be
|
|
29
|
+
* invoke({ order, agent }) │ idempotent on its own work)
|
|
30
|
+
* │ │
|
|
31
|
+
* ledger.finish(order_id) ──persist──┘
|
|
32
|
+
*/
|
|
33
|
+
import type { ClustlyAgent, Order } from "./index";
|
|
34
|
+
/** Tracks which orders this daemon has started/finished. Persist for crash-resume. */
|
|
35
|
+
export interface Ledger {
|
|
36
|
+
/** True if we've already started (in-flight) or finished/abandoned this order. */
|
|
37
|
+
seen(orderId: string): boolean;
|
|
38
|
+
start(orderId: string): void;
|
|
39
|
+
finish(orderId: string): void;
|
|
40
|
+
/** Drop an in-flight mark after a failure so the order can be retried. */
|
|
41
|
+
release(orderId: string): void;
|
|
42
|
+
/** Record a failed attempt; returns the running attempt count for this order. */
|
|
43
|
+
fail(orderId: string): number;
|
|
44
|
+
/** Earliest epoch-ms this order may be retried (0 = eligible now). */
|
|
45
|
+
notBefore(orderId: string): number;
|
|
46
|
+
/** Set the backoff deadline (epoch-ms) before which this order is skipped. */
|
|
47
|
+
backoff(orderId: string, untilMs: number): void;
|
|
48
|
+
}
|
|
49
|
+
/** In-memory ledger (used by tests; the CLI wraps a file-backed one). */
|
|
50
|
+
export declare class MemoryLedger implements Ledger {
|
|
51
|
+
private active;
|
|
52
|
+
private done;
|
|
53
|
+
private failures;
|
|
54
|
+
private retryAt;
|
|
55
|
+
seen(id: string): boolean;
|
|
56
|
+
start(id: string): void;
|
|
57
|
+
finish(id: string): void;
|
|
58
|
+
release(id: string): void;
|
|
59
|
+
fail(id: string): number;
|
|
60
|
+
notBefore(id: string): number;
|
|
61
|
+
backoff(id: string, untilMs: number): void;
|
|
62
|
+
}
|
|
63
|
+
export type InvokeFn = (ctx: {
|
|
64
|
+
order: Order;
|
|
65
|
+
agent: ClustlyAgent;
|
|
66
|
+
}) => Promise<void>;
|
|
67
|
+
/** Retry policy for a failing order. Defaults preserve legacy behavior (retry
|
|
68
|
+
* forever, no backoff) so a caller that wants the old loop changes nothing. */
|
|
69
|
+
export interface RetryPolicy {
|
|
70
|
+
/** Give up after this many failed attempts (default: Infinity — never give up). */
|
|
71
|
+
maxAttempts?: number;
|
|
72
|
+
/** Backoff before the next retry, by attempt number (default: 0 — retry next tick). */
|
|
73
|
+
backoffMs?: (attempt: number) => number;
|
|
74
|
+
/** Clock seam for tests. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
export interface TickDeps extends RetryPolicy {
|
|
78
|
+
agent: Pick<ClustlyAgent, "listOrders" | "accept">;
|
|
79
|
+
ledger: Ledger;
|
|
80
|
+
invoke: InvokeFn;
|
|
81
|
+
/** Surface per-order errors without killing the loop (transient — will retry). */
|
|
82
|
+
onError?: (orderId: string, err: unknown) => void;
|
|
83
|
+
/** Called once when an order is abandoned after maxAttempts (terminal — no retry). */
|
|
84
|
+
onGiveUp?: (orderId: string, attempts: number, err: unknown) => void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One poll cycle. Returns the order_ids it handled this tick. Each order is
|
|
88
|
+
* processed independently — one failure never blocks the others. A failed order
|
|
89
|
+
* is released for a later retry (after its backoff), unless it has hit
|
|
90
|
+
* `maxAttempts`, in which case it is abandoned so it never tight-loops forever.
|
|
91
|
+
*/
|
|
92
|
+
export declare function tick(deps: TickDeps): Promise<string[]>;
|
|
93
|
+
export interface RunOptions extends RetryPolicy {
|
|
94
|
+
agent: ClustlyAgent;
|
|
95
|
+
invoke: InvokeFn;
|
|
96
|
+
ledger?: Ledger;
|
|
97
|
+
intervalMs?: number;
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
onError?: (orderId: string, err: unknown) => void;
|
|
100
|
+
onGiveUp?: (orderId: string, attempts: number, err: unknown) => void;
|
|
101
|
+
}
|
|
102
|
+
/** Poll-and-invoke forever (until aborted). Default cadence 5s. */
|
|
103
|
+
export declare function run(opts: RunOptions): Promise<void>;
|
|
104
|
+
/** Exponential backoff: base·2^(attempt-1), capped. The CLI's default policy. */
|
|
105
|
+
export declare function expBackoff(baseMs?: number, capMs?: number): (attempt: number) => number;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `clustly run` — the no-server wake daemon (T4).
|
|
4
|
+
*
|
|
5
|
+
* An indie agent runs this on a laptop or cheap host: it long-polls for jobs and
|
|
6
|
+
* invokes the dev's handler when one lands, so the agent earns without exposing
|
|
7
|
+
* a public webhook endpoint.
|
|
8
|
+
*
|
|
9
|
+
* The correctness rules the eng review made non-negotiable:
|
|
10
|
+
*
|
|
11
|
+
* 1. ACCEPT FIRST, then invoke. An order stays `awaiting_acceptance` until the
|
|
12
|
+
* indexer sees TaskEnrolled (async). If we invoked-then-accepted, the next
|
|
13
|
+
* poll would see it again and re-invoke. Calling accept first transitions it
|
|
14
|
+
* off the polled status.
|
|
15
|
+
* 2. PERSISTED IN-FLIGHT LEDGER keyed by order_id. A crash mid-job must not lose
|
|
16
|
+
* the job (it's `enrolled` now and won't reappear), and we must never invoke
|
|
17
|
+
* the same order twice.
|
|
18
|
+
* 3. CROSS-PROCESS LEASE is the server, not local state: accept is
|
|
19
|
+
* idempotency-keyed (by order_id) and the on-chain enroll rejects a
|
|
20
|
+
* duplicate, so two daemons racing the same order resolve to one enroll. The
|
|
21
|
+
* local ledger only guards re-invocation within a process / across restarts.
|
|
22
|
+
*
|
|
23
|
+
* poll awaiting_acceptance
|
|
24
|
+
* │
|
|
25
|
+
* ▼ for each order not in ledger
|
|
26
|
+
* ledger.start(order_id) ──persist──┐
|
|
27
|
+
* │ │ crash here → resume: order is
|
|
28
|
+
* accept(order_id, idem=order_id) │ enrolled, ledger says in-flight,
|
|
29
|
+
* │ │ handler re-runs (handler must be
|
|
30
|
+
* invoke({ order, agent }) │ idempotent on its own work)
|
|
31
|
+
* │ │
|
|
32
|
+
* ledger.finish(order_id) ──persist──┘
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.MemoryLedger = void 0;
|
|
36
|
+
exports.tick = tick;
|
|
37
|
+
exports.run = run;
|
|
38
|
+
exports.expBackoff = expBackoff;
|
|
39
|
+
/** In-memory ledger (used by tests; the CLI wraps a file-backed one). */
|
|
40
|
+
class MemoryLedger {
|
|
41
|
+
active = new Set();
|
|
42
|
+
done = new Set();
|
|
43
|
+
failures = new Map();
|
|
44
|
+
retryAt = new Map();
|
|
45
|
+
seen(id) {
|
|
46
|
+
return this.active.has(id) || this.done.has(id);
|
|
47
|
+
}
|
|
48
|
+
start(id) {
|
|
49
|
+
this.active.add(id);
|
|
50
|
+
}
|
|
51
|
+
finish(id) {
|
|
52
|
+
this.active.delete(id);
|
|
53
|
+
this.done.add(id);
|
|
54
|
+
this.failures.delete(id);
|
|
55
|
+
this.retryAt.delete(id);
|
|
56
|
+
}
|
|
57
|
+
release(id) {
|
|
58
|
+
this.active.delete(id);
|
|
59
|
+
}
|
|
60
|
+
fail(id) {
|
|
61
|
+
const n = (this.failures.get(id) ?? 0) + 1;
|
|
62
|
+
this.failures.set(id, n);
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
notBefore(id) {
|
|
66
|
+
return this.retryAt.get(id) ?? 0;
|
|
67
|
+
}
|
|
68
|
+
backoff(id, untilMs) {
|
|
69
|
+
this.retryAt.set(id, untilMs);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.MemoryLedger = MemoryLedger;
|
|
73
|
+
/**
|
|
74
|
+
* One poll cycle. Returns the order_ids it handled this tick. Each order is
|
|
75
|
+
* processed independently — one failure never blocks the others. A failed order
|
|
76
|
+
* is released for a later retry (after its backoff), unless it has hit
|
|
77
|
+
* `maxAttempts`, in which case it is abandoned so it never tight-loops forever.
|
|
78
|
+
*/
|
|
79
|
+
async function tick(deps) {
|
|
80
|
+
const { agent, ledger, invoke, onError, onGiveUp } = deps;
|
|
81
|
+
const maxAttempts = deps.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
82
|
+
const backoffMs = deps.backoffMs ?? (() => 0);
|
|
83
|
+
const now = deps.now ?? Date.now;
|
|
84
|
+
const orders = await agent.listOrders("awaiting_acceptance");
|
|
85
|
+
const handled = [];
|
|
86
|
+
for (const order of orders) {
|
|
87
|
+
if (ledger.seen(order.order_id))
|
|
88
|
+
continue; // already in-flight or done/abandoned
|
|
89
|
+
if (ledger.notBefore(order.order_id) > now())
|
|
90
|
+
continue; // still backing off
|
|
91
|
+
ledger.start(order.order_id); // persist BEFORE any side effect
|
|
92
|
+
try {
|
|
93
|
+
// Accept FIRST so the order leaves awaiting_acceptance (no re-poll/re-invoke),
|
|
94
|
+
// idempotency-keyed by order_id so a retry can't double-enroll.
|
|
95
|
+
await agent.accept(order.order_id, order.order_id);
|
|
96
|
+
await invoke({ order, agent: agent });
|
|
97
|
+
ledger.finish(order.order_id);
|
|
98
|
+
handled.push(order.order_id);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const attempts = ledger.fail(order.order_id);
|
|
102
|
+
if (attempts >= maxAttempts) {
|
|
103
|
+
ledger.finish(order.order_id); // abandon: mark done so it never retries
|
|
104
|
+
onGiveUp?.(order.order_id, attempts, err);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
ledger.backoff(order.order_id, now() + backoffMs(attempts));
|
|
108
|
+
ledger.release(order.order_id); // allow a later retry (after backoff)
|
|
109
|
+
onError?.(order.order_id, err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return handled;
|
|
114
|
+
}
|
|
115
|
+
/** Poll-and-invoke forever (until aborted). Default cadence 5s. */
|
|
116
|
+
// react-doctor-disable-next-line deslop/unused-export -- public CLI/test entry (sdk/cli.ts, run.test.ts)
|
|
117
|
+
async function run(opts) {
|
|
118
|
+
const ledger = opts.ledger ?? new MemoryLedger();
|
|
119
|
+
const interval = opts.intervalMs ?? 5000;
|
|
120
|
+
while (!opts.signal?.aborted) {
|
|
121
|
+
try {
|
|
122
|
+
await tick({
|
|
123
|
+
agent: opts.agent,
|
|
124
|
+
ledger,
|
|
125
|
+
invoke: opts.invoke,
|
|
126
|
+
onError: opts.onError,
|
|
127
|
+
onGiveUp: opts.onGiveUp,
|
|
128
|
+
maxAttempts: opts.maxAttempts,
|
|
129
|
+
backoffMs: opts.backoffMs,
|
|
130
|
+
now: opts.now,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
opts.onError?.("<poll>", err); // listOrders failed — keep looping
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Exponential backoff: base·2^(attempt-1), capped. The CLI's default policy. */
|
|
140
|
+
// react-doctor-disable-next-line deslop/unused-export -- public CLI/test entry (sdk/cli.ts, run.test.ts)
|
|
141
|
+
function expBackoff(baseMs = 2000, capMs = 5 * 60 * 1000) {
|
|
142
|
+
return (attempt) => Math.min(baseMs * 2 ** Math.max(0, attempt - 1), capMs);
|
|
143
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clustly/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript SDK, CLI, and MCP server for running an AI agent as a seller on Clustly — a USDC-escrow agent marketplace on Solana. Dependency-light (Node crypto + fetch).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Clustly",
|
|
7
|
+
"homepage": "https://clustly-v2.vercel.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/proxyo1/clustly-v2.git",
|
|
11
|
+
"directory": "app/src/sdk"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/proxyo1/clustly-v2/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"clustly",
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"agent",
|
|
22
|
+
"solana",
|
|
23
|
+
"usdc",
|
|
24
|
+
"escrow",
|
|
25
|
+
"marketplace",
|
|
26
|
+
"sdk",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"bin": {
|
|
35
|
+
"clustly": "./dist/cli.js",
|
|
36
|
+
"clustly-mcp": "./dist/mcp-bin.js"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"prepack": "npm run build"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^20.19.0",
|
|
57
|
+
"typescript": "^5.6.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|