clinch-core 0.1.0 β 0.3.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 +57 -49
- package/dist/index.d.ts +13 -16
- package/dist/index.js +147 -87
- package/package.json +1 -1
- package/src/index.ts +193 -114
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Clinch is a modular, edge-first protocol designed to mediate autonomous negotiations between a buyer AI agent and a seller AI agent. It allows agents to negotiate price and terms to a mutual, mathematically converged agreement without a human in the loop, and without the seller ever seeing the buyer's full profile.
|
|
6
6
|
|
|
7
|
-
Once an agreement is reached, Clinch issues a **cryptographically co-signed deal artifact** held by both parties. This SDK provides the complete network client, state
|
|
7
|
+
Once an agreement is reached, Clinch issues a **cryptographically co-signed deal artifact** held by both parties. This SDK provides the complete network client, state-isolated session management, cryptographic signing engines, an optional out-of-the-box local LLM bargaining sandbox, and the server-side Seller SDK.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -14,8 +14,8 @@ In the modern web, purchasing, SaaS licensing, transport, and commodity procurem
|
|
|
14
14
|
|
|
15
15
|
* **Buyer Anonymity First**: The seller receives a simplified constraint vector (e.g., budget bracket, target category) rather than the buyerβs identity, private data, or raw history. All counter-offers are routed through the Registry proxy, guaranteeing 100% IP anonymity for the buyer.
|
|
16
16
|
* **Edge-First Execution**: The buyer agent runs locally on-device. This SDK supports dynamically importing and running optimized 1.5B 4-bit quantized models in under **1.3 GB of RAM**, making it compatible with consumer hardware.
|
|
17
|
-
* **
|
|
18
|
-
* **
|
|
17
|
+
* **Enterprise Concurrency**: Complete session isolation allows scaling horizontally. You can run hundreds of concurrent negotiations on a single server, serialize state, and resume negotiations across pod restarts or delayed webhooks.
|
|
18
|
+
* **Cryptographic Accountability**: Identity is proven via Ed25519 keys and Proof-of-Work (PoW). The final co-signed deal artifact is self-verifying against the Registry's daily rotating key chain. Zero-trust machine-to-machine updates are enforced without centralized JWT expirations.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -54,16 +54,16 @@ core.on('status_changed', (status: CoreStatus) => {
|
|
|
54
54
|
console.log(`State transitioned to: ${status}`);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
core.on('
|
|
58
|
-
console.log(`
|
|
57
|
+
core.on('session_started', ({ sessionId, sellerId }) => {
|
|
58
|
+
console.log(`Negotiation initiated with ${sellerId} (Session: ${sessionId})`);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
core.on('
|
|
62
|
-
|
|
61
|
+
core.on('session_closed', ({ sessionId, outcome, finalPrice }) => {
|
|
62
|
+
console.log(`Session ${sessionId} closed. Outcome: ${outcome} at $${finalPrice}`);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
core.on('
|
|
66
|
-
// Fired when
|
|
65
|
+
core.on('callback_received', ({ sessionId, payload }) => {
|
|
66
|
+
// Fired when the seller routes a callback counter-offer via the Registry WS
|
|
67
67
|
});
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -80,13 +80,13 @@ async function startLocalAgent() {
|
|
|
80
80
|
const core = new ClinchCore();
|
|
81
81
|
|
|
82
82
|
core.on('log', (msg) => console.log(msg));
|
|
83
|
-
core.on('
|
|
83
|
+
core.on('session_closed', ({ finalPrice }) => console.log(`Deal secured at $${finalPrice}!`));
|
|
84
84
|
|
|
85
85
|
// 1. Initialize Sandbox: Handles network auth, downloads GGUF, & registers auto-listeners
|
|
86
86
|
await core.sandbox({ downloadLLM: true });
|
|
87
87
|
|
|
88
88
|
// 2. Initiate Negotiation: Session automatically transitions to 'NEGOTIATING'
|
|
89
|
-
const sessionId = await core.negotiate('ANP/
|
|
89
|
+
const sessionId = await core.negotiate('ANP/C.amazon.anp', {
|
|
90
90
|
intent: 'purchase',
|
|
91
91
|
category: 'electronics',
|
|
92
92
|
item: 'Ninja Blender',
|
|
@@ -101,11 +101,9 @@ startLocalAgent();
|
|
|
101
101
|
|
|
102
102
|
---
|
|
103
103
|
|
|
104
|
-
## π Quickstart: Bring Your Own AI
|
|
105
|
-
|
|
106
|
-
If you are running in a cloud environment and want to leverage high-end hosted APIs (e.g. Claude 3.5 Sonnet or OpenAI GPT-4o), bypass the sandbox.
|
|
104
|
+
## π Quickstart: Bring Your Own AI & Webhook Persistence
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
If you are running in a cloud environment (e.g., handling webhooks) and want to leverage high-end hosted APIs (e.g. Claude 3.5 Sonnet or OpenAI GPT-4o), bypass the sandbox. The Core provides **State Serialization** to survive server restarts.
|
|
109
107
|
|
|
110
108
|
```javascript
|
|
111
109
|
import { ClinchCore } from 'clinch-core';
|
|
@@ -114,36 +112,42 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
114
112
|
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
115
113
|
const core = new ClinchCore();
|
|
116
114
|
|
|
117
|
-
await core.initialize();
|
|
115
|
+
await core.initialize();
|
|
118
116
|
|
|
117
|
+
// 1. Start session and save state to DB
|
|
119
118
|
const sessionId = await core.negotiate('ANP/C.amazon.anp', {
|
|
120
119
|
intent: 'purchase',
|
|
121
120
|
item: 'Ninja Blender',
|
|
122
121
|
max_budget: 85.00
|
|
123
122
|
});
|
|
123
|
+
const savedState = core.exportSessionState(sessionId);
|
|
124
|
+
await db.save(sessionId, savedState);
|
|
125
|
+
|
|
126
|
+
// --- LATER, ON A DIFFERENT SERVER OR WEBHOOK --- //
|
|
127
|
+
|
|
128
|
+
const webhookCore = new ClinchCore();
|
|
129
|
+
webhookCore.importSessionState(savedState);
|
|
124
130
|
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
webhookCore.on('callback_received', async (event) => {
|
|
132
|
+
// 2. Let the Core build the perfect state-aware protocol prompt
|
|
133
|
+
const systemPrompt = webhookCore.buildAgentPrompt(event.sessionId, event.payload.message);
|
|
127
134
|
|
|
128
|
-
//
|
|
129
|
-
const systemPrompt = core.buildAgentPrompt(sessionId, sellerCounter.message);
|
|
130
|
-
|
|
131
|
-
// 2. Feed it to Claude/OpenAI
|
|
135
|
+
// 3. Feed it to Claude/OpenAI
|
|
132
136
|
const msg = await anthropic.messages.create({
|
|
133
137
|
model: "claude-3-5-sonnet-20241022",
|
|
134
|
-
|
|
138
|
+
maxTokens: 150,
|
|
135
139
|
system: systemPrompt,
|
|
136
140
|
messages: [{ role: "user", content: "Determine our next protocol move." }]
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
const aiDecision = JSON.parse(msg.content[0].text);
|
|
140
144
|
|
|
141
|
-
//
|
|
145
|
+
// 4. Act on the deterministic JSON response
|
|
142
146
|
if (aiDecision.action === 'accept') {
|
|
143
147
|
console.log("Deal reached!");
|
|
144
|
-
// Proceed to /commit
|
|
145
148
|
} else {
|
|
146
|
-
await
|
|
149
|
+
await webhookCore.sendCounter(event.sessionId, aiDecision.price, aiDecision.message);
|
|
150
|
+
await db.update(event.sessionId, webhookCore.exportSessionState(event.sessionId));
|
|
147
151
|
}
|
|
148
152
|
});
|
|
149
153
|
```
|
|
@@ -152,7 +156,9 @@ core.on('callback_received', async (event) => {
|
|
|
152
156
|
|
|
153
157
|
## πͺ Quickstart: Building a Seller Node
|
|
154
158
|
|
|
155
|
-
`clinch-core` exports the `ClinchSeller` class to make building
|
|
159
|
+
`clinch-core` exports the `ClinchSeller` class to make building a native seller server seamless.
|
|
160
|
+
|
|
161
|
+
**Architecture Note:** Seller Nodes do not use JWTs. You generate a permanent Ed25519 Keypair in the Clinch Dashboard, claim your domain, and pass the private key to your Node. The node securely self-publishes its endpoint and capabilities on boot.
|
|
156
162
|
|
|
157
163
|
```javascript
|
|
158
164
|
import { ClinchSeller } from 'clinch-core';
|
|
@@ -161,35 +167,36 @@ import express from 'express';
|
|
|
161
167
|
const app = express();
|
|
162
168
|
app.use(express.json());
|
|
163
169
|
|
|
164
|
-
|
|
170
|
+
// Initialize with your permanent Dashboard-generated Private Key
|
|
171
|
+
const seller = new ClinchSeller({ privateKeyHex: process.env.SELLER_PRIVATE_KEY });
|
|
165
172
|
|
|
166
|
-
//
|
|
167
|
-
await seller.authenticate('your-supabase-seller-jwt');
|
|
173
|
+
// Publish your routing endpoint to the network on boot
|
|
168
174
|
await seller.registerEndpoint({
|
|
169
175
|
agent_id: 'amazon.anp',
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
supported_modes: ['ANP/A', 'ANP/C'],
|
|
176
|
+
endpoint: 'https://your-seller-api.com/anp/v1',
|
|
177
|
+
supported_modes: ['ANP/C'],
|
|
173
178
|
categories: ['electronics'],
|
|
174
179
|
capabilities: ['price_flex']
|
|
175
180
|
});
|
|
176
181
|
|
|
177
182
|
// Create your counter-offer route
|
|
178
|
-
app.post('/anp/counter', (req, res) => {
|
|
183
|
+
app.post('/anp/v1/counter', (req, res) => {
|
|
179
184
|
const { session_id, turn, price, reason, buyer_sig } = req.body;
|
|
180
|
-
|
|
185
|
+
|
|
181
186
|
// Verify the payload actually came from the buyer who holds the session key
|
|
182
187
|
const isValid = seller.verifyBuyerSignature(
|
|
183
|
-
{ session_id, turn, price, reason },
|
|
184
|
-
buyer_sig,
|
|
188
|
+
{ session_id, turn, price, reason },
|
|
189
|
+
buyer_sig,
|
|
185
190
|
req.headers['x-buyer-pubkey']
|
|
186
191
|
);
|
|
187
192
|
|
|
188
193
|
if (!isValid) return res.status(401).send("Invalid signature");
|
|
189
194
|
|
|
190
195
|
// Process logic and return standard counter JSON...
|
|
191
|
-
res.json({
|
|
196
|
+
res.json({ msg_type: 'counter', price: 95.00, reason: "Best I can do is $95." });
|
|
192
197
|
});
|
|
198
|
+
|
|
199
|
+
app.listen(8080);
|
|
193
200
|
```
|
|
194
201
|
|
|
195
202
|
---
|
|
@@ -204,13 +211,15 @@ app.post('/anp/counter', (req, res) => {
|
|
|
204
211
|
|
|
205
212
|
#### `async initialize(cachedToken?)`
|
|
206
213
|
Authenticates the node, completes Identity-Bound PoW, and connects the WebSocket.
|
|
207
|
-
* `cachedToken` *(string)*: Saved JWT to skip PoW and restore connectivity instantly.
|
|
208
214
|
|
|
209
215
|
#### `async negotiate(address, constraints)`
|
|
210
216
|
Launches a cryptographic session handshake. Returns `sessionId`.
|
|
211
|
-
* `address` *(string)*: e.g. `ANP/
|
|
217
|
+
* `address` *(string)*: e.g. `ANP/C.cloudflare.anp`. (Must include mode prefix).
|
|
212
218
|
* `constraints` *(ConstraintVector)*: Must include `max_budget` (number).
|
|
213
219
|
|
|
220
|
+
#### `exportSessionState(sessionId)` / `importSessionState(serializedData)`
|
|
221
|
+
Serializes the active sessionβincluding the ephemeral cryptographic keys, current turn, and LLM context parametersβto a JSON string. Used to scale instances horizontally or survive pod restarts.
|
|
222
|
+
|
|
214
223
|
#### `buildAgentPrompt(sessionId, incomingMessage)`
|
|
215
224
|
Returns a highly-optimized, state-aware string to pass to an external LLM as a System Prompt. Ensures the LLM outputs strict JSON matching the protocol rules.
|
|
216
225
|
|
|
@@ -222,16 +231,14 @@ Closes the active connection and generates a single-use re-engagement Callback t
|
|
|
222
231
|
|
|
223
232
|
#### `async sandbox(config)`
|
|
224
233
|
Initializes the edge-AI execution context, downloads the GGUF, and auto-listens.
|
|
225
|
-
* `config.downloadLLM` *(boolean)*: Default `true`.
|
|
226
|
-
* `config.maxTurns` *(number)*: Turn limit threshold. Default `6`.
|
|
227
234
|
|
|
228
235
|
### Seller Client (`ClinchSeller`)
|
|
229
236
|
|
|
230
|
-
#### `
|
|
231
|
-
|
|
237
|
+
#### `new ClinchSeller(config)`
|
|
238
|
+
* `config.privateKeyHex` *(string)*: The Ed25519 private key generated from the Clinch Dashboard. Used to cryptographically authenticate endpoint updates.
|
|
232
239
|
|
|
233
240
|
#### `async registerEndpoint(record)`
|
|
234
|
-
Publishes the seller's DNS-style record to the Registry so buyers can discover and route to it.
|
|
241
|
+
Publishes the seller's DNS-style record to the Registry so buyers can discover and route to it. Signed locally via the Ed25519 identity key.
|
|
235
242
|
|
|
236
243
|
#### `verifyBuyerSignature(payload, signatureHex, pubKeyHex)`
|
|
237
244
|
Returns `boolean`. Cryptographically verifies that incoming counter-offers were signed by the exact ephemeral session key generated by the buyer during the handshake.
|
|
@@ -239,7 +246,8 @@ Returns `boolean`. Cryptographically verifies that incoming counter-offers were
|
|
|
239
246
|
---
|
|
240
247
|
|
|
241
248
|
## π Cryptographic Guarantees
|
|
242
|
-
Clinch operates on a strictly zero-trust model.
|
|
243
|
-
1. **Identity-Bound PoW**:
|
|
244
|
-
2. **
|
|
245
|
-
3. **
|
|
249
|
+
Clinch operates on a strictly zero-trust model.
|
|
250
|
+
1. **Identity-Bound PoW**: Buyer challenge hashes include the client's `pubKey`, making outsourcing/bot-farming mathematically impossible.
|
|
251
|
+
2. **Ed25519 Seller Authority**: Seller endpoints and capabilities are updated via Ed25519 signatures, completely decoupling the control plane (Dashboard) from the data plane (Server).
|
|
252
|
+
3. **Ephemeral Sessions**: `negotiate()` generates an ephemeral Session Key. This key signs every individual message. If a session key is compromised, your global identity remains secure, and historical session logs remain completely un-linkable.
|
|
253
|
+
4. **Anonymity Proxy**: Counter-offers are routed strictly through the Registry. The seller never logs the buyer's IP address.
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface SessionState {
|
|
|
23
23
|
status: 'ACTIVE' | 'EXITED' | 'CLOSED';
|
|
24
24
|
exitTokenHash?: string;
|
|
25
25
|
constraints: ConstraintVector;
|
|
26
|
+
currentTurn: number;
|
|
27
|
+
lastKnownPrice: number;
|
|
28
|
+
sandboxSequence?: any;
|
|
29
|
+
sandboxSession?: any;
|
|
26
30
|
}
|
|
27
31
|
export interface SandboxConfig {
|
|
28
32
|
downloadLLM?: boolean;
|
|
@@ -45,13 +49,8 @@ export declare class ClinchCore extends EventEmitter {
|
|
|
45
49
|
private activeSessions;
|
|
46
50
|
private ws;
|
|
47
51
|
private isSandboxMode;
|
|
48
|
-
private
|
|
49
|
-
private sandboxSequence;
|
|
50
|
-
private sandboxSession;
|
|
52
|
+
private sandboxModelContext;
|
|
51
53
|
private sandboxMaxTurns;
|
|
52
|
-
currentTurn: number;
|
|
53
|
-
lastKnownPrice: number;
|
|
54
|
-
activeNegotiationId: string | null;
|
|
55
54
|
constructor(config?: ClinchConfig);
|
|
56
55
|
private setStatus;
|
|
57
56
|
initialize(cachedToken?: string): Promise<void>;
|
|
@@ -61,10 +60,9 @@ export declare class ClinchCore extends EventEmitter {
|
|
|
61
60
|
private connectWebSocket;
|
|
62
61
|
private handleReconnect;
|
|
63
62
|
disconnect(): void;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*/
|
|
63
|
+
exportSessionState(sessionId: string): string;
|
|
64
|
+
importSessionState(serializedData: string): void;
|
|
65
|
+
getSession(sessionId: string): SessionState | undefined;
|
|
68
66
|
buildAgentPrompt(sessionId: string, incomingMessage: string): string;
|
|
69
67
|
search(query: string, mode?: string): Promise<any>;
|
|
70
68
|
negotiate(address: string, constraints: ConstraintVector): Promise<string>;
|
|
@@ -81,7 +79,6 @@ export declare class ClinchCore extends EventEmitter {
|
|
|
81
79
|
}
|
|
82
80
|
export interface SellerRecord {
|
|
83
81
|
agent_id: string;
|
|
84
|
-
display_name: string;
|
|
85
82
|
endpoint: string;
|
|
86
83
|
supported_modes: string[];
|
|
87
84
|
categories: string[];
|
|
@@ -90,12 +87,12 @@ export interface SellerRecord {
|
|
|
90
87
|
export declare class ClinchSeller extends EventEmitter {
|
|
91
88
|
private config;
|
|
92
89
|
private cachedRegistryUrl;
|
|
93
|
-
sellerAuthToken: string | null;
|
|
94
90
|
private identityPrivKey;
|
|
95
91
|
identityPubKey: string;
|
|
96
|
-
constructor(config?: ClinchConfig
|
|
97
|
-
|
|
92
|
+
constructor(config?: ClinchConfig & {
|
|
93
|
+
privateKeyHex?: string;
|
|
94
|
+
});
|
|
95
|
+
private resolveRegistry;
|
|
98
96
|
registerEndpoint(record: SellerRecord): Promise<any>;
|
|
99
|
-
verifyBuyerSignature(payload: any, signatureHex: string,
|
|
100
|
-
private signData;
|
|
97
|
+
verifyBuyerSignature(payload: any, signatureHex: string, buyerPubKeyHex: string): boolean;
|
|
101
98
|
}
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,9 @@ const FIREBASE_CONFIG_URL = "https://clinchprotocol.web.app/network-config.json"
|
|
|
49
49
|
function toHex(arr) {
|
|
50
50
|
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
51
51
|
}
|
|
52
|
+
function fromHex(hex) {
|
|
53
|
+
return new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
54
|
+
}
|
|
52
55
|
// ============================================================================
|
|
53
56
|
// THE CLINCH CORE LIBRARY (Buyer)
|
|
54
57
|
// ============================================================================
|
|
@@ -63,15 +66,10 @@ class ClinchCore extends events_1.EventEmitter {
|
|
|
63
66
|
identityPubKey;
|
|
64
67
|
activeSessions = new Map();
|
|
65
68
|
ws = null;
|
|
66
|
-
// Sandbox Engine
|
|
69
|
+
// Sandbox Engine Base (Model/Context are global, Sequences are per-session)
|
|
67
70
|
isSandboxMode = false;
|
|
68
|
-
|
|
69
|
-
sandboxSequence = null;
|
|
70
|
-
sandboxSession = null;
|
|
71
|
+
sandboxModelContext = null;
|
|
71
72
|
sandboxMaxTurns = 6;
|
|
72
|
-
currentTurn = 0;
|
|
73
|
-
lastKnownPrice = 0;
|
|
74
|
-
activeNegotiationId = null;
|
|
75
73
|
constructor(config = {}) {
|
|
76
74
|
super();
|
|
77
75
|
this.config = { timeoutMs: 5000, ...config };
|
|
@@ -90,8 +88,6 @@ class ClinchCore extends events_1.EventEmitter {
|
|
|
90
88
|
this.emit('log', `π’ [State] ONLINE & IDLE`);
|
|
91
89
|
else if (this.status === 'ERROR')
|
|
92
90
|
this.emit('log', `π΄ [State] ERROR`);
|
|
93
|
-
else if (this.status === 'NEGOTIATING')
|
|
94
|
-
this.emit('log', `β‘ [State] NEGOTIATING (Turn ${this.currentTurn})`);
|
|
95
91
|
else
|
|
96
92
|
this.emit('log', `π‘ [State] ${this.status}`);
|
|
97
93
|
}
|
|
@@ -229,17 +225,51 @@ class ClinchCore extends events_1.EventEmitter {
|
|
|
229
225
|
}
|
|
230
226
|
}
|
|
231
227
|
// --------------------------------------------------------------------------
|
|
228
|
+
// SESSION STATE MANAGEMENT (For Enterprise Horizontal Scaling & Reconnects)
|
|
229
|
+
// --------------------------------------------------------------------------
|
|
230
|
+
exportSessionState(sessionId) {
|
|
231
|
+
const session = this.activeSessions.get(sessionId);
|
|
232
|
+
if (!session)
|
|
233
|
+
throw new Error("Session not found");
|
|
234
|
+
const exportable = {
|
|
235
|
+
sessionId: session.sessionId,
|
|
236
|
+
sellerId: session.sellerId,
|
|
237
|
+
status: session.status,
|
|
238
|
+
exitTokenHash: session.exitTokenHash,
|
|
239
|
+
constraints: session.constraints,
|
|
240
|
+
currentTurn: session.currentTurn,
|
|
241
|
+
lastKnownPrice: session.lastKnownPrice,
|
|
242
|
+
ephemeralSecretKeyHex: toHex(session.keyPair.secretKey)
|
|
243
|
+
};
|
|
244
|
+
return JSON.stringify(exportable);
|
|
245
|
+
}
|
|
246
|
+
importSessionState(serializedData) {
|
|
247
|
+
const data = JSON.parse(serializedData);
|
|
248
|
+
const secretKey = fromHex(data.ephemeralSecretKeyHex);
|
|
249
|
+
const keyPair = tweetnacl_1.default.sign.keyPair.fromSecretKey(secretKey);
|
|
250
|
+
this.activeSessions.set(data.sessionId, {
|
|
251
|
+
sessionId: data.sessionId,
|
|
252
|
+
sellerId: data.sellerId,
|
|
253
|
+
status: data.status,
|
|
254
|
+
exitTokenHash: data.exitTokenHash,
|
|
255
|
+
constraints: data.constraints,
|
|
256
|
+
currentTurn: data.currentTurn,
|
|
257
|
+
lastKnownPrice: data.lastKnownPrice,
|
|
258
|
+
keyPair: keyPair
|
|
259
|
+
});
|
|
260
|
+
this.emit('log', `[State] Rehydrated session ${data.sessionId} pointing at ${data.sellerId}`);
|
|
261
|
+
}
|
|
262
|
+
getSession(sessionId) {
|
|
263
|
+
return this.activeSessions.get(sessionId);
|
|
264
|
+
}
|
|
265
|
+
// --------------------------------------------------------------------------
|
|
232
266
|
// UNIVERSAL PROMPT BUILDER
|
|
233
267
|
// --------------------------------------------------------------------------
|
|
234
|
-
/**
|
|
235
|
-
* Generates a universally formatted System Prompt for external LLMs (Claude, OpenAI, Gemini).
|
|
236
|
-
* Developers can pass this string directly to their AI to ensure protocol-compliant negotiation.
|
|
237
|
-
*/
|
|
238
268
|
buildAgentPrompt(sessionId, incomingMessage) {
|
|
239
269
|
const session = this.activeSessions.get(sessionId);
|
|
240
270
|
if (!session)
|
|
241
271
|
throw new Error("Cannot build prompt: Session not found.");
|
|
242
|
-
const gap =
|
|
272
|
+
const gap = session.lastKnownPrice - session.constraints.max_budget;
|
|
243
273
|
const gapText = gap > 0 ? `-$${gap} (Over budget)` : `+$${Math.abs(gap)} (Under budget)`;
|
|
244
274
|
return `You are a professional AI purchasing agent negotiating via the Clinch Protocol.
|
|
245
275
|
Your only goal is to secure the requested item below the maximum budget.
|
|
@@ -248,8 +278,8 @@ NEGOTIATION STATE:
|
|
|
248
278
|
- Item: ${session.constraints.item}
|
|
249
279
|
- Category: ${session.constraints.category || 'General'}
|
|
250
280
|
- Your absolute max budget: $${session.constraints.max_budget}
|
|
251
|
-
- Current turn: ${
|
|
252
|
-
- Last seller price: $${
|
|
281
|
+
- Current turn: ${session.currentTurn}
|
|
282
|
+
- Last seller price: $${session.lastKnownPrice}
|
|
253
283
|
- Gap to budget: ${gapText}
|
|
254
284
|
|
|
255
285
|
SELLER'S LATEST MESSAGE:
|
|
@@ -302,24 +332,31 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
302
332
|
sellerId: parsed.domain,
|
|
303
333
|
keyPair: ephemeralKeys,
|
|
304
334
|
status: 'ACTIVE',
|
|
305
|
-
constraints
|
|
335
|
+
constraints,
|
|
336
|
+
currentTurn: 1,
|
|
337
|
+
lastKnownPrice: 0
|
|
306
338
|
});
|
|
307
|
-
this.activeNegotiationId = response.session_id;
|
|
308
|
-
this.currentTurn = 1;
|
|
309
339
|
this.setStatus('NEGOTIATING');
|
|
340
|
+
this.emit('session_started', { sessionId: response.session_id, sellerId: parsed.domain });
|
|
310
341
|
return response.session_id;
|
|
311
342
|
}
|
|
312
343
|
async sendCounter(sessionId, price, reason) {
|
|
313
344
|
const session = this.activeSessions.get(sessionId);
|
|
314
345
|
if (!session)
|
|
315
346
|
throw new Error("Active session not found");
|
|
316
|
-
const payload = { session_id: sessionId, turn:
|
|
347
|
+
const payload = { session_id: sessionId, turn: session.currentTurn, price, reason };
|
|
317
348
|
const buyer_sig = toHex(tweetnacl_1.default.sign.detached(new TextEncoder().encode(JSON.stringify(payload)), session.keyPair.secretKey));
|
|
318
|
-
await this.networkRequest(`/api/route/${session.sellerId}/counter`, {
|
|
349
|
+
const response = await this.networkRequest(`/api/route/${session.sellerId}/counter`, {
|
|
319
350
|
method: 'POST',
|
|
320
351
|
headers: { 'Content-Type': 'application/json' },
|
|
321
352
|
body: JSON.stringify({ ...payload, buyer_sig })
|
|
322
353
|
});
|
|
354
|
+
// Sync state if deal reached
|
|
355
|
+
if (response.msg_type === 'accept' || response.status === 'COMMITTED') {
|
|
356
|
+
session.status = 'CLOSED';
|
|
357
|
+
session.lastKnownPrice = response.price || price;
|
|
358
|
+
this.emit('session_closed', { sessionId, outcome: 'deal', finalPrice: session.lastKnownPrice });
|
|
359
|
+
}
|
|
323
360
|
}
|
|
324
361
|
async exitSession(sessionId) {
|
|
325
362
|
const session = this.activeSessions.get(sessionId);
|
|
@@ -350,13 +387,14 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
350
387
|
this.emit('log', "βοΈ [Sandbox] Auto-Negotiation engine bound and active.");
|
|
351
388
|
}
|
|
352
389
|
async setupSandbox(config = {}) {
|
|
390
|
+
if (this.sandboxModelContext)
|
|
391
|
+
return; // Already initialized
|
|
353
392
|
const settings = {
|
|
354
393
|
downloadLLM: true,
|
|
355
394
|
modelUrl: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
356
395
|
modelPath: "./qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
357
396
|
...config
|
|
358
397
|
};
|
|
359
|
-
// DYNAMIC IMPORTS: Won't break Webpack/Metro unless sandbox() is actually called!
|
|
360
398
|
let nodeLlama;
|
|
361
399
|
try {
|
|
362
400
|
nodeLlama = await Promise.resolve().then(() => __importStar(require('node-llama-cpp')));
|
|
@@ -374,7 +412,6 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
374
412
|
this.emit('log', `[Sandbox] Downloading Qwen 1.5B Q4_K_M (1.1GB)...`);
|
|
375
413
|
const { pipeline } = await Promise.resolve().then(() => __importStar(require('stream/promises')));
|
|
376
414
|
const { Readable } = await Promise.resolve().then(() => __importStar(require('stream')));
|
|
377
|
-
// @ts-ignore
|
|
378
415
|
const response = await fetch(settings.modelUrl);
|
|
379
416
|
if (!response.ok)
|
|
380
417
|
throw new Error("Fetch failed: " + response.statusText);
|
|
@@ -384,7 +421,7 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
384
421
|
}
|
|
385
422
|
const llama = await nodeLlama.getLlama();
|
|
386
423
|
const model = await llama.loadModel({ modelPath: resolvedPath });
|
|
387
|
-
this.
|
|
424
|
+
this.sandboxModelContext = await model.createContext({
|
|
388
425
|
contextSize: 2048,
|
|
389
426
|
threads: Math.min(4, Math.max(1, os.cpus().length - 1)),
|
|
390
427
|
batchSize: 512
|
|
@@ -392,46 +429,60 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
392
429
|
}
|
|
393
430
|
async handleAutomaticSandboxTurn(sessionId, payload) {
|
|
394
431
|
const session = this.activeSessions.get(sessionId);
|
|
395
|
-
if (!session)
|
|
432
|
+
if (!session || session.status !== 'ACTIVE')
|
|
396
433
|
return;
|
|
397
|
-
|
|
434
|
+
session.currentTurn++;
|
|
398
435
|
this.setStatus('NEGOTIATING');
|
|
436
|
+
this.emit('log', `β‘ [Sandbox] Analyzing Turn ${session.currentTurn} for ${sessionId}`);
|
|
399
437
|
const incomingPrice = this.extractPrice(payload.message || JSON.stringify(payload), 'Counter');
|
|
400
438
|
if (incomingPrice !== null)
|
|
401
|
-
|
|
402
|
-
if (
|
|
403
|
-
this.
|
|
404
|
-
this.
|
|
439
|
+
session.lastKnownPrice = incomingPrice;
|
|
440
|
+
if (session.lastKnownPrice > 0 && session.lastKnownPrice <= session.constraints.max_budget) {
|
|
441
|
+
this.emit('log', `π [Sandbox] Seller met budget conditions! Closing deal.`);
|
|
442
|
+
await this.sendCounter(sessionId, session.lastKnownPrice, "I accept this offer.");
|
|
405
443
|
return;
|
|
406
444
|
}
|
|
407
|
-
if (
|
|
445
|
+
if (session.currentTurn > this.sandboxMaxTurns) {
|
|
408
446
|
this.setStatus('STALEMATE');
|
|
447
|
+
this.emit('log', `π [Sandbox] Max turns reached. Exiting.`);
|
|
409
448
|
await this.exitSession(sessionId);
|
|
410
449
|
return;
|
|
411
450
|
}
|
|
412
|
-
const modelResponse = await this.sandboxEvaluate(session, payload.message || `The price is $${
|
|
413
|
-
const parsedOffer = this.extractPrice(modelResponse, 'Offer');
|
|
414
|
-
|
|
415
|
-
|
|
451
|
+
const modelResponse = await this.sandboxEvaluate(session, payload.message || `The price is $${session.lastKnownPrice}`);
|
|
452
|
+
const parsedOffer = this.extractPrice(modelResponse, 'Offer') || this.extractPrice(modelResponse, 'price');
|
|
453
|
+
let reason = "Suggesting a fair counter-offer.";
|
|
454
|
+
try {
|
|
455
|
+
const parsedJson = JSON.parse(modelResponse.replace(/```json|```/g, "").trim());
|
|
456
|
+
if (parsedJson.message)
|
|
457
|
+
reason = parsedJson.message;
|
|
458
|
+
else if (parsedJson.reason)
|
|
459
|
+
reason = parsedJson.reason;
|
|
460
|
+
}
|
|
461
|
+
catch (e) { }
|
|
416
462
|
if (parsedOffer !== null) {
|
|
417
463
|
const finalOffer = Math.min(parsedOffer, session.constraints.max_budget);
|
|
418
464
|
await this.sendCounter(sessionId, finalOffer, reason);
|
|
419
465
|
}
|
|
466
|
+
else {
|
|
467
|
+
this.emit('log', `β οΈ [Sandbox] Failed to parse offer. Generating safe counter.`);
|
|
468
|
+
await this.sendCounter(sessionId, session.lastKnownPrice * 0.9, "Can you do slightly better?");
|
|
469
|
+
}
|
|
420
470
|
}
|
|
421
471
|
async sandboxEvaluate(session, incomingOffer) {
|
|
422
472
|
const { LlamaChatSession, ChatMLChatWrapper } = await Promise.resolve().then(() => __importStar(require('node-llama-cpp')));
|
|
423
473
|
const systemPrompt = this.buildAgentPrompt(session.sessionId, incomingOffer);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
474
|
+
// State Isolation: Bind sequence to the session, not the global class!
|
|
475
|
+
if (!session.sandboxSequence) {
|
|
476
|
+
session.sandboxSequence = this.sandboxModelContext.getSequence();
|
|
477
|
+
session.sandboxSession = new LlamaChatSession({
|
|
478
|
+
contextSequence: session.sandboxSequence,
|
|
479
|
+
systemPrompt: systemPrompt,
|
|
480
|
+
chatWrapper: new ChatMLChatWrapper()
|
|
481
|
+
});
|
|
482
|
+
}
|
|
432
483
|
let responseText = "";
|
|
433
|
-
await
|
|
434
|
-
maxTokens:
|
|
484
|
+
await session.sandboxSession.prompt(incomingOffer, {
|
|
485
|
+
maxTokens: 120,
|
|
435
486
|
onTextChunk: (chunk) => { responseText += chunk; }
|
|
436
487
|
});
|
|
437
488
|
return responseText;
|
|
@@ -449,10 +500,14 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
449
500
|
return fallbackMatch ? parseFloat(fallbackMatch[1]) : null;
|
|
450
501
|
}
|
|
451
502
|
parseAddress(address) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
503
|
+
if (!address.includes('.'))
|
|
504
|
+
throw new Error("Invalid Address Format. Expected MODE.domain (e.g. ANP/C.amazon.anp)");
|
|
505
|
+
const firstDotIdx = address.indexOf('.');
|
|
506
|
+
const mode = address.substring(0, firstDotIdx);
|
|
507
|
+
const domain = address.substring(firstDotIdx + 1).toLowerCase();
|
|
508
|
+
if (!mode.startsWith("ANP/"))
|
|
509
|
+
throw new Error(`Invalid protocol mode '${mode}'. Must start with 'ANP/'`);
|
|
510
|
+
return { mode, domain, route: '/' };
|
|
456
511
|
}
|
|
457
512
|
async solvePoW(nonce, difficultyBits) {
|
|
458
513
|
let counter = 0;
|
|
@@ -485,60 +540,65 @@ exports.ClinchCore = ClinchCore;
|
|
|
485
540
|
class ClinchSeller extends events_1.EventEmitter {
|
|
486
541
|
config;
|
|
487
542
|
cachedRegistryUrl = null;
|
|
488
|
-
sellerAuthToken = null;
|
|
489
543
|
identityPrivKey;
|
|
490
544
|
identityPubKey;
|
|
491
545
|
constructor(config = {}) {
|
|
492
546
|
super();
|
|
493
|
-
this.config = { timeoutMs:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
547
|
+
this.config = { timeoutMs: 8000, ...config };
|
|
548
|
+
if (config.privateKeyHex) {
|
|
549
|
+
this.identityPrivKey = fromHex(config.privateKeyHex);
|
|
550
|
+
const kp = tweetnacl_1.default.sign.keyPair.fromSecretKey(this.identityPrivKey);
|
|
551
|
+
this.identityPubKey = toHex(kp.publicKey);
|
|
552
|
+
this.emit('log', `[Seller] Loaded permanent identity. PubKey: ${this.identityPubKey.substring(0, 12)}...`);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
const kp = tweetnacl_1.default.sign.keyPair();
|
|
556
|
+
this.identityPrivKey = kp.secretKey;
|
|
557
|
+
this.identityPubKey = toHex(kp.publicKey);
|
|
558
|
+
console.warn('[Seller] β οΈ No privateKeyHex provided β using ephemeral key. Registry will reject updates unless this key is pre-registered.');
|
|
559
|
+
}
|
|
560
|
+
if (this.config.registryUrl) {
|
|
498
561
|
this.cachedRegistryUrl = this.config.registryUrl;
|
|
499
|
-
}
|
|
500
|
-
async authenticate(authToken) {
|
|
501
|
-
this.sellerAuthToken = authToken;
|
|
502
|
-
if (!this.cachedRegistryUrl) {
|
|
503
|
-
const res = await fetch(FIREBASE_CONFIG_URL);
|
|
504
|
-
const config = JSON.parse(await res.text());
|
|
505
|
-
this.cachedRegistryUrl = config.registry_nodes[PROTOCOL_VERSION];
|
|
506
562
|
}
|
|
507
563
|
}
|
|
564
|
+
async resolveRegistry() {
|
|
565
|
+
if (this.cachedRegistryUrl)
|
|
566
|
+
return this.cachedRegistryUrl;
|
|
567
|
+
const res = await fetch(FIREBASE_CONFIG_URL);
|
|
568
|
+
const cfg = JSON.parse(await res.text());
|
|
569
|
+
this.cachedRegistryUrl = cfg.registry_nodes[PROTOCOL_VERSION];
|
|
570
|
+
return this.cachedRegistryUrl;
|
|
571
|
+
}
|
|
508
572
|
async registerEndpoint(record) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
record_sig: this.signData(record)
|
|
515
|
-
};
|
|
516
|
-
const res = await fetch(`${this.cachedRegistryUrl}/api/dashboard/sellers/register`, {
|
|
573
|
+
const registry = await this.resolveRegistry();
|
|
574
|
+
const payload = { ...record, timestamp: Date.now() };
|
|
575
|
+
const msgUint8 = new TextEncoder().encode(JSON.stringify(payload));
|
|
576
|
+
const signature = toHex(tweetnacl_1.default.sign.detached(msgUint8, this.identityPrivKey));
|
|
577
|
+
const res = await fetch(`${registry}/api/sellers/update-endpoint`, {
|
|
517
578
|
method: 'POST',
|
|
518
|
-
headers: {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
580
|
+
body: JSON.stringify({
|
|
581
|
+
payload,
|
|
582
|
+
public_key: this.identityPubKey,
|
|
583
|
+
signature
|
|
584
|
+
})
|
|
523
585
|
});
|
|
524
586
|
if (!res.ok)
|
|
525
|
-
throw new Error(`
|
|
526
|
-
|
|
587
|
+
throw new Error(`Endpoint registration failed: ${await res.text()}`);
|
|
588
|
+
const data = await res.json();
|
|
589
|
+
this.emit('log', `[Seller] Registered: ${record.agent_id} β ${record.endpoint}`);
|
|
590
|
+
return data;
|
|
527
591
|
}
|
|
528
|
-
verifyBuyerSignature(payload, signatureHex,
|
|
592
|
+
verifyBuyerSignature(payload, signatureHex, buyerPubKeyHex) {
|
|
529
593
|
try {
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
const
|
|
533
|
-
return tweetnacl_1.default.sign.detached.verify(
|
|
594
|
+
const msg = new TextEncoder().encode(JSON.stringify(payload));
|
|
595
|
+
const sig = fromHex(signatureHex);
|
|
596
|
+
const pubKey = fromHex(buyerPubKeyHex);
|
|
597
|
+
return tweetnacl_1.default.sign.detached.verify(msg, sig, pubKey);
|
|
534
598
|
}
|
|
535
|
-
catch
|
|
599
|
+
catch {
|
|
536
600
|
return false;
|
|
537
601
|
}
|
|
538
602
|
}
|
|
539
|
-
signData(data) {
|
|
540
|
-
const msgUint8 = new TextEncoder().encode(JSON.stringify(data));
|
|
541
|
-
return toHex(tweetnacl_1.default.sign.detached(msgUint8, this.identityPrivKey));
|
|
542
|
-
}
|
|
543
603
|
}
|
|
544
604
|
exports.ClinchSeller = ClinchSeller;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -13,18 +13,22 @@ function toHex(arr: Uint8Array | number[]): string {
|
|
|
13
13
|
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function fromHex(hex: string): Uint8Array {
|
|
17
|
+
return new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
// ============================================================================
|
|
17
21
|
// TYPES & INTERFACES
|
|
18
22
|
// ============================================================================
|
|
19
23
|
export type CoreStatus =
|
|
20
24
|
| 'OFFLINE'
|
|
21
25
|
| 'CONNECTING'
|
|
22
|
-
| 'IDLE'
|
|
26
|
+
| 'IDLE'
|
|
23
27
|
| 'RECONNECTING'
|
|
24
28
|
| 'ERROR'
|
|
25
|
-
| 'NEGOTIATING'
|
|
26
|
-
| 'CONVERGED'
|
|
27
|
-
| 'STALEMATE';
|
|
29
|
+
| 'NEGOTIATING'
|
|
30
|
+
| 'CONVERGED'
|
|
31
|
+
| 'STALEMATE';
|
|
28
32
|
|
|
29
33
|
export interface ParsedAddress {
|
|
30
34
|
mode: string;
|
|
@@ -51,6 +55,14 @@ export interface SessionState {
|
|
|
51
55
|
status: 'ACTIVE' | 'EXITED' | 'CLOSED';
|
|
52
56
|
exitTokenHash?: string;
|
|
53
57
|
constraints: ConstraintVector;
|
|
58
|
+
|
|
59
|
+
// Isolated State Tracking (Crucial for concurrency)
|
|
60
|
+
currentTurn: number;
|
|
61
|
+
lastKnownPrice: number;
|
|
62
|
+
|
|
63
|
+
// Local LLM Context Tracking
|
|
64
|
+
sandboxSequence?: any;
|
|
65
|
+
sandboxSession?: any;
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
export interface SandboxConfig {
|
|
@@ -83,17 +95,11 @@ export class ClinchCore extends EventEmitter {
|
|
|
83
95
|
private activeSessions = new Map<string, SessionState>();
|
|
84
96
|
private ws: WebSocket | null = null;
|
|
85
97
|
|
|
86
|
-
// Sandbox Engine
|
|
98
|
+
// Sandbox Engine Base (Model/Context are global, Sequences are per-session)
|
|
87
99
|
private isSandboxMode = false;
|
|
88
|
-
private
|
|
89
|
-
private sandboxSequence: any = null;
|
|
90
|
-
private sandboxSession: any = null;
|
|
100
|
+
private sandboxModelContext: any = null;
|
|
91
101
|
private sandboxMaxTurns = 6;
|
|
92
102
|
|
|
93
|
-
public currentTurn = 0;
|
|
94
|
-
public lastKnownPrice = 0;
|
|
95
|
-
public activeNegotiationId: string | null = null;
|
|
96
|
-
|
|
97
103
|
constructor(config: ClinchConfig = {}) {
|
|
98
104
|
super();
|
|
99
105
|
this.config = { timeoutMs: 5000, ...config };
|
|
@@ -114,7 +120,6 @@ export class ClinchCore extends EventEmitter {
|
|
|
114
120
|
|
|
115
121
|
if (this.status === 'IDLE') this.emit('log', `π’ [State] ONLINE & IDLE`);
|
|
116
122
|
else if (this.status === 'ERROR') this.emit('log', `π΄ [State] ERROR`);
|
|
117
|
-
else if (this.status === 'NEGOTIATING') this.emit('log', `β‘ [State] NEGOTIATING (Turn ${this.currentTurn})`);
|
|
118
123
|
else this.emit('log', `π‘ [State] ${this.status}`);
|
|
119
124
|
}
|
|
120
125
|
}
|
|
@@ -256,18 +261,59 @@ export class ClinchCore extends EventEmitter {
|
|
|
256
261
|
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
257
262
|
}
|
|
258
263
|
|
|
264
|
+
// --------------------------------------------------------------------------
|
|
265
|
+
// SESSION STATE MANAGEMENT (For Enterprise Horizontal Scaling & Reconnects)
|
|
266
|
+
// --------------------------------------------------------------------------
|
|
267
|
+
public exportSessionState(sessionId: string): string {
|
|
268
|
+
const session = this.activeSessions.get(sessionId);
|
|
269
|
+
if (!session) throw new Error("Session not found");
|
|
270
|
+
|
|
271
|
+
const exportable = {
|
|
272
|
+
sessionId: session.sessionId,
|
|
273
|
+
sellerId: session.sellerId,
|
|
274
|
+
status: session.status,
|
|
275
|
+
exitTokenHash: session.exitTokenHash,
|
|
276
|
+
constraints: session.constraints,
|
|
277
|
+
currentTurn: session.currentTurn,
|
|
278
|
+
lastKnownPrice: session.lastKnownPrice,
|
|
279
|
+
ephemeralSecretKeyHex: toHex(session.keyPair.secretKey)
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return JSON.stringify(exportable);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public importSessionState(serializedData: string): void {
|
|
286
|
+
const data = JSON.parse(serializedData);
|
|
287
|
+
|
|
288
|
+
const secretKey = fromHex(data.ephemeralSecretKeyHex);
|
|
289
|
+
const keyPair = nacl.sign.keyPair.fromSecretKey(secretKey);
|
|
290
|
+
|
|
291
|
+
this.activeSessions.set(data.sessionId, {
|
|
292
|
+
sessionId: data.sessionId,
|
|
293
|
+
sellerId: data.sellerId,
|
|
294
|
+
status: data.status,
|
|
295
|
+
exitTokenHash: data.exitTokenHash,
|
|
296
|
+
constraints: data.constraints,
|
|
297
|
+
currentTurn: data.currentTurn,
|
|
298
|
+
lastKnownPrice: data.lastKnownPrice,
|
|
299
|
+
keyPair: keyPair
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.emit('log', `[State] Rehydrated session ${data.sessionId} pointing at ${data.sellerId}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
public getSession(sessionId: string): SessionState | undefined {
|
|
306
|
+
return this.activeSessions.get(sessionId);
|
|
307
|
+
}
|
|
308
|
+
|
|
259
309
|
// --------------------------------------------------------------------------
|
|
260
310
|
// UNIVERSAL PROMPT BUILDER
|
|
261
311
|
// --------------------------------------------------------------------------
|
|
262
|
-
/**
|
|
263
|
-
* Generates a universally formatted System Prompt for external LLMs (Claude, OpenAI, Gemini).
|
|
264
|
-
* Developers can pass this string directly to their AI to ensure protocol-compliant negotiation.
|
|
265
|
-
*/
|
|
266
312
|
public buildAgentPrompt(sessionId: string, incomingMessage: string): string {
|
|
267
313
|
const session = this.activeSessions.get(sessionId);
|
|
268
314
|
if (!session) throw new Error("Cannot build prompt: Session not found.");
|
|
269
315
|
|
|
270
|
-
const gap =
|
|
316
|
+
const gap = session.lastKnownPrice - session.constraints.max_budget;
|
|
271
317
|
const gapText = gap > 0 ? `-$${gap} (Over budget)` : `+$${Math.abs(gap)} (Under budget)`;
|
|
272
318
|
|
|
273
319
|
return `You are a professional AI purchasing agent negotiating via the Clinch Protocol.
|
|
@@ -277,8 +323,8 @@ NEGOTIATION STATE:
|
|
|
277
323
|
- Item: ${session.constraints.item}
|
|
278
324
|
- Category: ${session.constraints.category || 'General'}
|
|
279
325
|
- Your absolute max budget: $${session.constraints.max_budget}
|
|
280
|
-
- Current turn: ${
|
|
281
|
-
- Last seller price: $${
|
|
326
|
+
- Current turn: ${session.currentTurn}
|
|
327
|
+
- Last seller price: $${session.lastKnownPrice}
|
|
282
328
|
- Gap to budget: ${gapText}
|
|
283
329
|
|
|
284
330
|
SELLER'S LATEST MESSAGE:
|
|
@@ -337,12 +383,13 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
337
383
|
sellerId: parsed.domain,
|
|
338
384
|
keyPair: ephemeralKeys,
|
|
339
385
|
status: 'ACTIVE',
|
|
340
|
-
constraints
|
|
386
|
+
constraints,
|
|
387
|
+
currentTurn: 1,
|
|
388
|
+
lastKnownPrice: 0
|
|
341
389
|
});
|
|
342
390
|
|
|
343
|
-
this.activeNegotiationId = response.session_id;
|
|
344
|
-
this.currentTurn = 1;
|
|
345
391
|
this.setStatus('NEGOTIATING');
|
|
392
|
+
this.emit('session_started', { sessionId: response.session_id, sellerId: parsed.domain });
|
|
346
393
|
return response.session_id;
|
|
347
394
|
}
|
|
348
395
|
|
|
@@ -350,17 +397,24 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
350
397
|
const session = this.activeSessions.get(sessionId);
|
|
351
398
|
if (!session) throw new Error("Active session not found");
|
|
352
399
|
|
|
353
|
-
const payload = { session_id: sessionId, turn:
|
|
400
|
+
const payload = { session_id: sessionId, turn: session.currentTurn, price, reason };
|
|
354
401
|
const buyer_sig = toHex(nacl.sign.detached(
|
|
355
402
|
new TextEncoder().encode(JSON.stringify(payload)),
|
|
356
403
|
session.keyPair.secretKey
|
|
357
404
|
));
|
|
358
405
|
|
|
359
|
-
await this.networkRequest(`/api/route/${session.sellerId}/counter`, {
|
|
406
|
+
const response = await this.networkRequest(`/api/route/${session.sellerId}/counter`, {
|
|
360
407
|
method: 'POST',
|
|
361
408
|
headers: { 'Content-Type': 'application/json' },
|
|
362
409
|
body: JSON.stringify({ ...payload, buyer_sig })
|
|
363
410
|
});
|
|
411
|
+
|
|
412
|
+
// Sync state if deal reached
|
|
413
|
+
if (response.msg_type === 'accept' || response.status === 'COMMITTED') {
|
|
414
|
+
session.status = 'CLOSED';
|
|
415
|
+
session.lastKnownPrice = response.price || price;
|
|
416
|
+
this.emit('session_closed', { sessionId, outcome: 'deal', finalPrice: session.lastKnownPrice });
|
|
417
|
+
}
|
|
364
418
|
}
|
|
365
419
|
|
|
366
420
|
async exitSession(sessionId: string): Promise<string> {
|
|
@@ -396,6 +450,8 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
396
450
|
}
|
|
397
451
|
|
|
398
452
|
private async setupSandbox(config: SandboxConfig = {}): Promise<void> {
|
|
453
|
+
if (this.sandboxModelContext) return; // Already initialized
|
|
454
|
+
|
|
399
455
|
const settings = {
|
|
400
456
|
downloadLLM: true,
|
|
401
457
|
modelUrl: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
@@ -403,7 +459,6 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
403
459
|
...config
|
|
404
460
|
};
|
|
405
461
|
|
|
406
|
-
// DYNAMIC IMPORTS: Won't break Webpack/Metro unless sandbox() is actually called!
|
|
407
462
|
let nodeLlama;
|
|
408
463
|
try { nodeLlama = await import('node-llama-cpp'); }
|
|
409
464
|
catch (e) { throw new Error("Sandbox requires 'node-llama-cpp'. Run: npm install node-llama-cpp"); }
|
|
@@ -419,8 +474,7 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
419
474
|
this.emit('log', `[Sandbox] Downloading Qwen 1.5B Q4_K_M (1.1GB)...`);
|
|
420
475
|
const { pipeline } = await import('stream/promises');
|
|
421
476
|
const { Readable } = await import('stream');
|
|
422
|
-
|
|
423
|
-
// @ts-ignore
|
|
477
|
+
|
|
424
478
|
const response = await fetch(settings.modelUrl);
|
|
425
479
|
if (!response.ok) throw new Error("Fetch failed: " + response.statusText);
|
|
426
480
|
|
|
@@ -432,7 +486,7 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
432
486
|
const llama = await nodeLlama.getLlama();
|
|
433
487
|
const model = await llama.loadModel({ modelPath: resolvedPath });
|
|
434
488
|
|
|
435
|
-
this.
|
|
489
|
+
this.sandboxModelContext = await model.createContext({
|
|
436
490
|
contextSize: 2048,
|
|
437
491
|
threads: Math.min(4, Math.max(1, os.cpus().length - 1)),
|
|
438
492
|
batchSize: 512
|
|
@@ -441,34 +495,44 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
441
495
|
|
|
442
496
|
private async handleAutomaticSandboxTurn(sessionId: string, payload: any) {
|
|
443
497
|
const session = this.activeSessions.get(sessionId);
|
|
444
|
-
if (!session) return;
|
|
498
|
+
if (!session || session.status !== 'ACTIVE') return;
|
|
445
499
|
|
|
446
|
-
|
|
500
|
+
session.currentTurn++;
|
|
447
501
|
this.setStatus('NEGOTIATING');
|
|
502
|
+
this.emit('log', `β‘ [Sandbox] Analyzing Turn ${session.currentTurn} for ${sessionId}`);
|
|
448
503
|
|
|
449
504
|
const incomingPrice = this.extractPrice(payload.message || JSON.stringify(payload), 'Counter');
|
|
450
|
-
if (incomingPrice !== null)
|
|
505
|
+
if (incomingPrice !== null) session.lastKnownPrice = incomingPrice;
|
|
451
506
|
|
|
452
|
-
if (
|
|
453
|
-
this.
|
|
454
|
-
this.
|
|
507
|
+
if (session.lastKnownPrice > 0 && session.lastKnownPrice <= session.constraints.max_budget) {
|
|
508
|
+
this.emit('log', `π [Sandbox] Seller met budget conditions! Closing deal.`);
|
|
509
|
+
await this.sendCounter(sessionId, session.lastKnownPrice, "I accept this offer.");
|
|
455
510
|
return;
|
|
456
511
|
}
|
|
457
512
|
|
|
458
|
-
if (
|
|
513
|
+
if (session.currentTurn > this.sandboxMaxTurns) {
|
|
459
514
|
this.setStatus('STALEMATE');
|
|
515
|
+
this.emit('log', `π [Sandbox] Max turns reached. Exiting.`);
|
|
460
516
|
await this.exitSession(sessionId);
|
|
461
517
|
return;
|
|
462
518
|
}
|
|
463
519
|
|
|
464
|
-
const modelResponse = await this.sandboxEvaluate(session, payload.message || `The price is $${
|
|
465
|
-
const parsedOffer = this.extractPrice(modelResponse, 'Offer');
|
|
466
|
-
|
|
467
|
-
|
|
520
|
+
const modelResponse = await this.sandboxEvaluate(session, payload.message || `The price is $${session.lastKnownPrice}`);
|
|
521
|
+
const parsedOffer = this.extractPrice(modelResponse, 'Offer') || this.extractPrice(modelResponse, 'price');
|
|
522
|
+
|
|
523
|
+
let reason = "Suggesting a fair counter-offer.";
|
|
524
|
+
try {
|
|
525
|
+
const parsedJson = JSON.parse(modelResponse.replace(/```json|```/g, "").trim());
|
|
526
|
+
if (parsedJson.message) reason = parsedJson.message;
|
|
527
|
+
else if (parsedJson.reason) reason = parsedJson.reason;
|
|
528
|
+
} catch(e) {}
|
|
468
529
|
|
|
469
530
|
if (parsedOffer !== null) {
|
|
470
531
|
const finalOffer = Math.min(parsedOffer, session.constraints.max_budget);
|
|
471
532
|
await this.sendCounter(sessionId, finalOffer, reason);
|
|
533
|
+
} else {
|
|
534
|
+
this.emit('log', `β οΈ [Sandbox] Failed to parse offer. Generating safe counter.`);
|
|
535
|
+
await this.sendCounter(sessionId, session.lastKnownPrice * 0.9, "Can you do slightly better?");
|
|
472
536
|
}
|
|
473
537
|
}
|
|
474
538
|
|
|
@@ -477,18 +541,19 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
477
541
|
|
|
478
542
|
const systemPrompt = this.buildAgentPrompt(session.sessionId, incomingOffer);
|
|
479
543
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
544
|
+
// State Isolation: Bind sequence to the session, not the global class!
|
|
545
|
+
if (!session.sandboxSequence) {
|
|
546
|
+
session.sandboxSequence = this.sandboxModelContext.getSequence();
|
|
547
|
+
session.sandboxSession = new LlamaChatSession({
|
|
548
|
+
contextSequence: session.sandboxSequence,
|
|
549
|
+
systemPrompt: systemPrompt,
|
|
550
|
+
chatWrapper: new ChatMLChatWrapper()
|
|
551
|
+
});
|
|
552
|
+
}
|
|
488
553
|
|
|
489
554
|
let responseText = "";
|
|
490
|
-
await
|
|
491
|
-
maxTokens:
|
|
555
|
+
await session.sandboxSession.prompt(incomingOffer, {
|
|
556
|
+
maxTokens: 120,
|
|
492
557
|
onTextChunk: (chunk: string) => { responseText += chunk; }
|
|
493
558
|
});
|
|
494
559
|
|
|
@@ -502,16 +567,22 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
502
567
|
const regex = new RegExp(`${prefix}\\s*\\:?\\s*\\$?(\\d+(?:\\.\\d{2})?)`, 'i');
|
|
503
568
|
const match = text.match(regex);
|
|
504
569
|
if (match) return parseFloat(match[1]);
|
|
505
|
-
|
|
570
|
+
|
|
506
571
|
const fallbackRegex = /"price"\s*:\s*(\d+(?:\.\d{2})?)/i;
|
|
507
572
|
const fallbackMatch = text.match(fallbackRegex);
|
|
508
573
|
return fallbackMatch ? parseFloat(fallbackMatch[1]) : null;
|
|
509
574
|
}
|
|
510
575
|
|
|
511
576
|
public parseAddress(address: string): ParsedAddress {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
577
|
+
if (!address.includes('.')) throw new Error("Invalid Address Format. Expected MODE.domain (e.g. ANP/C.amazon.anp)");
|
|
578
|
+
|
|
579
|
+
const firstDotIdx = address.indexOf('.');
|
|
580
|
+
const mode = address.substring(0, firstDotIdx);
|
|
581
|
+
const domain = address.substring(firstDotIdx + 1).toLowerCase();
|
|
582
|
+
|
|
583
|
+
if (!mode.startsWith("ANP/")) throw new Error(`Invalid protocol mode '${mode}'. Must start with 'ANP/'`);
|
|
584
|
+
|
|
585
|
+
return { mode, domain, route: '/' };
|
|
515
586
|
}
|
|
516
587
|
|
|
517
588
|
private async solvePoW(nonce: string, difficultyBits: number): Promise<string> {
|
|
@@ -542,71 +613,79 @@ You MUST respond ONLY in valid JSON matching this exact schema. Do not include m
|
|
|
542
613
|
// THE CLINCH SELLER LIBRARY (Server-Side)
|
|
543
614
|
// ============================================================================
|
|
544
615
|
export interface SellerRecord {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
capabilities: string[];
|
|
616
|
+
agent_id: string;
|
|
617
|
+
endpoint: string;
|
|
618
|
+
supported_modes: string[];
|
|
619
|
+
categories: string[];
|
|
620
|
+
capabilities: string[];
|
|
551
621
|
}
|
|
552
622
|
|
|
553
623
|
export class ClinchSeller extends EventEmitter {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const config = JSON.parse(await res.text());
|
|
574
|
-
this.cachedRegistryUrl = config.registry_nodes[PROTOCOL_VERSION];
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
async registerEndpoint(record: SellerRecord): Promise<any> {
|
|
579
|
-
if (!this.sellerAuthToken) throw new Error("Must call authenticate() first.");
|
|
580
|
-
const payload = {
|
|
581
|
-
...record,
|
|
582
|
-
public_key: this.identityPubKey,
|
|
583
|
-
record_sig: this.signData(record)
|
|
584
|
-
};
|
|
585
|
-
const res = await fetch(`${this.cachedRegistryUrl}/api/dashboard/sellers/register`, {
|
|
586
|
-
method: 'POST',
|
|
587
|
-
headers: {
|
|
588
|
-
'Content-Type': 'application/json',
|
|
589
|
-
'Authorization': `Bearer ${this.sellerAuthToken}`
|
|
590
|
-
},
|
|
591
|
-
body: JSON.stringify(payload)
|
|
592
|
-
});
|
|
593
|
-
if (!res.ok) throw new Error(`Registration failed: ${await res.text()}`);
|
|
594
|
-
return await res.json();
|
|
624
|
+
private config: ClinchConfig;
|
|
625
|
+
private cachedRegistryUrl: string | null = null;
|
|
626
|
+
private identityPrivKey: Uint8Array;
|
|
627
|
+
public identityPubKey: string;
|
|
628
|
+
|
|
629
|
+
constructor(config: ClinchConfig & { privateKeyHex?: string } = {}) {
|
|
630
|
+
super();
|
|
631
|
+
this.config = { timeoutMs: 8000, ...config };
|
|
632
|
+
|
|
633
|
+
if (config.privateKeyHex) {
|
|
634
|
+
this.identityPrivKey = fromHex(config.privateKeyHex);
|
|
635
|
+
const kp = nacl.sign.keyPair.fromSecretKey(this.identityPrivKey);
|
|
636
|
+
this.identityPubKey = toHex(kp.publicKey);
|
|
637
|
+
this.emit('log', `[Seller] Loaded permanent identity. PubKey: ${this.identityPubKey.substring(0, 12)}...`);
|
|
638
|
+
} else {
|
|
639
|
+
const kp = nacl.sign.keyPair();
|
|
640
|
+
this.identityPrivKey = kp.secretKey;
|
|
641
|
+
this.identityPubKey = toHex(kp.publicKey);
|
|
642
|
+
console.warn('[Seller] β οΈ No privateKeyHex provided β using ephemeral key. Registry will reject updates unless this key is pre-registered.');
|
|
595
643
|
}
|
|
596
644
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const msgUint8 = new TextEncoder().encode(JSON.stringify(payload));
|
|
600
|
-
const sigUint8 = new Uint8Array(signatureHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
|
601
|
-
const pubKeyUint8 = new Uint8Array(buyerSessionPubKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
|
602
|
-
return nacl.sign.detached.verify(msgUint8, sigUint8, pubKeyUint8);
|
|
603
|
-
} catch (e) {
|
|
604
|
-
return false;
|
|
605
|
-
}
|
|
645
|
+
if (this.config.registryUrl) {
|
|
646
|
+
this.cachedRegistryUrl = this.config.registryUrl;
|
|
606
647
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private async resolveRegistry(): Promise<string> {
|
|
651
|
+
if (this.cachedRegistryUrl) return this.cachedRegistryUrl;
|
|
652
|
+
const res = await fetch(FIREBASE_CONFIG_URL);
|
|
653
|
+
const cfg = JSON.parse(await res.text());
|
|
654
|
+
this.cachedRegistryUrl = cfg.registry_nodes[PROTOCOL_VERSION];
|
|
655
|
+
return this.cachedRegistryUrl!;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async registerEndpoint(record: SellerRecord): Promise<any> {
|
|
659
|
+
const registry = await this.resolveRegistry();
|
|
660
|
+
|
|
661
|
+
const payload = { ...record, timestamp: Date.now() };
|
|
662
|
+
const msgUint8 = new TextEncoder().encode(JSON.stringify(payload));
|
|
663
|
+
const signature = toHex(nacl.sign.detached(msgUint8, this.identityPrivKey));
|
|
664
|
+
|
|
665
|
+
const res = await fetch(`${registry}/api/sellers/update-endpoint`, {
|
|
666
|
+
method: 'POST',
|
|
667
|
+
headers: { 'Content-Type': 'application/json' },
|
|
668
|
+
body: JSON.stringify({
|
|
669
|
+
payload,
|
|
670
|
+
public_key: this.identityPubKey,
|
|
671
|
+
signature
|
|
672
|
+
})
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
if (!res.ok) throw new Error(`Endpoint registration failed: ${await res.text()}`);
|
|
676
|
+
const data = await res.json();
|
|
677
|
+
this.emit('log', `[Seller] Registered: ${record.agent_id} β ${record.endpoint}`);
|
|
678
|
+
return data;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
verifyBuyerSignature(payload: any, signatureHex: string, buyerPubKeyHex: string): boolean {
|
|
682
|
+
try {
|
|
683
|
+
const msg = new TextEncoder().encode(JSON.stringify(payload));
|
|
684
|
+
const sig = fromHex(signatureHex);
|
|
685
|
+
const pubKey = fromHex(buyerPubKeyHex);
|
|
686
|
+
return nacl.sign.detached.verify(msg, sig, pubKey);
|
|
687
|
+
} catch {
|
|
688
|
+
return false;
|
|
611
689
|
}
|
|
690
|
+
}
|
|
612
691
|
}
|