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 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 machine, cryptographic signing engine, an optional out-of-the-box local LLM bargaining sandbox, and the server-side Seller SDK.
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
- * **Cryptographic Accountability**: Identity is proven via an Ed25519 identity key and Proof-of-Work (PoW). Every session uses throwaway, ephemeral session keys. The final co-signed deal artifact is self-verifying against the Registry's daily rotating key chain.
18
- * **Deterministic Hybrid Architecture**: The SDK decouples conversational language from protocol math. The LLM strictly generates persuasive copy while the core SDK evaluates numerical convergence, preventing AI hallucination or rule-bypassing.
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('log', (message: string) => {
58
- console.log(`[LOG] ${message}`);
57
+ core.on('session_started', ({ sessionId, sellerId }) => {
58
+ console.log(`Negotiation initiated with ${sellerId} (Session: ${sessionId})`);
59
59
  });
60
60
 
61
- core.on('callback_received', (data: { sessionId: string, payload: any }) => {
62
- // Fired when the seller routes a callback counter-offer via the Registry WS
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('token_issued', (data: { token: string }) => {
66
- // Fired when PoW completes. Save this JWT to disk to skip PoW on next boot!
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('status_changed', (status) => console.log(`πŸ‘‰ State: ${status}`));
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/A.amazon.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 (Universal Prompt Builder)
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
- `clinch-core` includes a **Universal Prompt Builder** (`buildAgentPrompt`) that automatically injects the current negotiation state, budget gaps, and strict JSON output schemas into a perfect system prompt for any LLM.
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(); // Completes PoW and connects to WS
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
- core.on('callback_received', async (event) => {
126
- const sellerCounter = event.payload;
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
- // 1. Let the Core build the perfect state-aware protocol prompt
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
- max_tokens: 150,
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
- // 3. Act on the deterministic JSON response
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 core.sendCounter(sessionId, aiDecision.price, aiDecision.message);
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 an Adapter Node or native seller server seamless. It handles dashboard authentication, endpoint registration, and cryptographic signature verification of buyer requests.
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
- const seller = new ClinchSeller();
170
+ // Initialize with your permanent Dashboard-generated Private Key
171
+ const seller = new ClinchSeller({ privateKeyHex: process.env.SELLER_PRIVATE_KEY });
165
172
 
166
- // Authenticate with your dashboard token and register your endpoint
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
- display_name: 'Amazon Adapter',
171
- endpoint: 'https://your-seller-api.com/anp',
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({ action: 'counter', price: 95.00, message: "Best I can do is $95." });
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/A.cloudflare.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
- #### `async authenticate(authToken)`
231
- Logs the seller node into the registry using a Dashboard JWT.
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**: Challenge hashes include the client's `pubKey`, making outsourcing/bot-farming mathematically impossible.
244
- 2. **Ephemeral Sessions**: `negotiate()` generates an ephemeral Session Key (`nacl.sign.keyPair()`). 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.
245
- 3. **Anonymity Proxy**: Counter-offers are routed strictly through the Registry. The seller never logs the buyer's IP address.
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 sandboxContext;
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
- * Generates a universally formatted System Prompt for external LLMs (Claude, OpenAI, Gemini).
66
- * Developers can pass this string directly to their AI to ensure protocol-compliant negotiation.
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
- authenticate(authToken: string): Promise<void>;
92
+ constructor(config?: ClinchConfig & {
93
+ privateKeyHex?: string;
94
+ });
95
+ private resolveRegistry;
98
96
  registerEndpoint(record: SellerRecord): Promise<any>;
99
- verifyBuyerSignature(payload: any, signatureHex: string, buyerSessionPubKeyHex: string): boolean;
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
- sandboxContext = null;
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 = this.lastKnownPrice - session.constraints.max_budget;
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: ${this.currentTurn}
252
- - Last seller price: $${this.lastKnownPrice}
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: this.currentTurn, price, reason };
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.sandboxContext = await model.createContext({
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
- this.currentTurn++;
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
- this.lastKnownPrice = incomingPrice;
402
- if (this.lastKnownPrice <= session.constraints.max_budget) {
403
- this.setStatus('CONVERGED');
404
- this.activeSessions.delete(sessionId);
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 (this.currentTurn > this.sandboxMaxTurns) {
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 $${this.lastKnownPrice}`);
413
- const parsedOffer = this.extractPrice(modelResponse, 'Offer');
414
- const reasonLine = modelResponse.split('\n').find(l => l.startsWith('Reason:'));
415
- const reason = reasonLine ? reasonLine.replace('Reason:', '').trim() : "Suggesting a fair counter-offer.";
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
- if (this.sandboxSequence)
425
- this.sandboxSequence.dispose();
426
- this.sandboxSequence = this.sandboxContext.getSequence();
427
- this.sandboxSession = new LlamaChatSession({
428
- contextSequence: this.sandboxSequence,
429
- systemPrompt: systemPrompt,
430
- chatWrapper: new ChatMLChatWrapper()
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 this.sandboxSession.prompt(incomingOffer, {
434
- maxTokens: 80,
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
- const parts = address.split('.');
453
- if (parts.length < 2)
454
- throw new Error("Invalid Address Format");
455
- return { mode: parts[0], domain: parts.slice(1).join('.').toLowerCase(), route: '/' };
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: 5000, ...config };
494
- const keyPair = tweetnacl_1.default.sign.keyPair();
495
- this.identityPrivKey = keyPair.secretKey;
496
- this.identityPubKey = toHex(keyPair.publicKey);
497
- if (this.config.registryUrl)
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
- if (!this.sellerAuthToken)
510
- throw new Error("Must call authenticate() first.");
511
- const payload = {
512
- ...record,
513
- public_key: this.identityPubKey,
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
- 'Content-Type': 'application/json',
520
- 'Authorization': `Bearer ${this.sellerAuthToken}`
521
- },
522
- body: JSON.stringify(payload)
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(`Registration failed: ${await res.text()}`);
526
- return await res.json();
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, buyerSessionPubKeyHex) {
592
+ verifyBuyerSignature(payload, signatureHex, buyerPubKeyHex) {
529
593
  try {
530
- const msgUint8 = new TextEncoder().encode(JSON.stringify(payload));
531
- const sigUint8 = new Uint8Array(signatureHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
532
- const pubKeyUint8 = new Uint8Array(buyerSessionPubKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
533
- return tweetnacl_1.default.sign.detached.verify(msgUint8, sigUint8, pubKeyUint8);
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 (e) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clinch-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
 
5
5
  "description": "Clinch Protocol Edge Client",
6
6
  "main": "dist/index.js",
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 sandboxContext: any = null;
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 = this.lastKnownPrice - session.constraints.max_budget;
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: ${this.currentTurn}
281
- - Last seller price: $${this.lastKnownPrice}
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: this.currentTurn, price, reason };
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.sandboxContext = await model.createContext({
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
- this.currentTurn++;
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) this.lastKnownPrice = incomingPrice;
505
+ if (incomingPrice !== null) session.lastKnownPrice = incomingPrice;
451
506
 
452
- if (this.lastKnownPrice <= session.constraints.max_budget) {
453
- this.setStatus('CONVERGED');
454
- this.activeSessions.delete(sessionId);
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 (this.currentTurn > this.sandboxMaxTurns) {
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 $${this.lastKnownPrice}`);
465
- const parsedOffer = this.extractPrice(modelResponse, 'Offer');
466
- const reasonLine = modelResponse.split('\n').find(l => l.startsWith('Reason:'));
467
- const reason = reasonLine ? reasonLine.replace('Reason:', '').trim() : "Suggesting a fair counter-offer.";
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
- if (this.sandboxSequence) this.sandboxSequence.dispose();
481
- this.sandboxSequence = this.sandboxContext.getSequence();
482
-
483
- this.sandboxSession = new LlamaChatSession({
484
- contextSequence: this.sandboxSequence,
485
- systemPrompt: systemPrompt,
486
- chatWrapper: new ChatMLChatWrapper()
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 this.sandboxSession.prompt(incomingOffer, {
491
- maxTokens: 80,
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
- const parts = address.split('.');
513
- if (parts.length < 2) throw new Error("Invalid Address Format");
514
- return { mode: parts[0], domain: parts.slice(1).join('.').toLowerCase(), route: '/' };
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
- agent_id: string;
546
- display_name: string;
547
- endpoint: string;
548
- supported_modes: string[];
549
- categories: string[];
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
- private config: ClinchConfig;
555
- private cachedRegistryUrl: string | null = null;
556
- public sellerAuthToken: string | null = null;
557
- private identityPrivKey: Uint8Array;
558
- public identityPubKey: string;
559
-
560
- constructor(config: ClinchConfig = {}) {
561
- super();
562
- this.config = { timeoutMs: 5000, ...config };
563
- const keyPair = nacl.sign.keyPair();
564
- this.identityPrivKey = keyPair.secretKey;
565
- this.identityPubKey = toHex(keyPair.publicKey);
566
- if (this.config.registryUrl) this.cachedRegistryUrl = this.config.registryUrl;
567
- }
568
-
569
- async authenticate(authToken: string): Promise<void> {
570
- this.sellerAuthToken = authToken;
571
- if (!this.cachedRegistryUrl) {
572
- const res = await fetch(FIREBASE_CONFIG_URL);
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
- verifyBuyerSignature(payload: any, signatureHex: string, buyerSessionPubKeyHex: string): boolean {
598
- try {
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
- private signData(data: any): string {
609
- const msgUint8 = new TextEncoder().encode(JSON.stringify(data));
610
- return toHex(nacl.sign.detached(msgUint8, this.identityPrivKey));
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
  }