@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/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
+ }