zyndo 0.2.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/dist/agentLoop.js +10 -1
- package/dist/commands/referee.d.ts +8 -0
- package/dist/commands/referee.js +86 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +106 -2
- package/dist/connection.d.ts +27 -1
- package/dist/connection.js +28 -10
- package/dist/identity.d.ts +23 -0
- package/dist/identity.js +51 -0
- package/dist/index.js +6 -0
- package/dist/init.js +346 -10
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/scopeContract.d.ts +44 -0
- package/dist/scopeContract.js +148 -0
- package/dist/sellerDaemon.js +123 -23
- package/dist/state.d.ts +2 -0
- package/dist/state.js +23 -0
- package/package.json +1 -1
package/dist/sellerDaemon.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Seller daemon — poll for tasks, run agent loop, deliver results
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
import {
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail, registerIdentity } from './connection.js';
|
|
6
|
+
import { ensureIdentityKeypair, signDelivery } from './identity.js';
|
|
7
|
+
import { composeSystemPrompt, truncateToContract } from './scopeContract.js';
|
|
5
8
|
import { runAgentLoop } from './agentLoop.js';
|
|
6
9
|
import { createAnthropicProvider } from './providers/anthropic.js';
|
|
7
10
|
import { createOpenAIProvider } from './providers/openai.js';
|
|
@@ -13,7 +16,7 @@ import { createBashTool } from './tools/bash.js';
|
|
|
13
16
|
import { createGlobTool } from './tools/glob.js';
|
|
14
17
|
import { createGrepTool } from './tools/grep.js';
|
|
15
18
|
import { createAskBuyerTool } from './tools/askBuyer.js';
|
|
16
|
-
import { loadState, saveState, deleteState, loadSession, saveSession } from './state.js';
|
|
19
|
+
import { loadState, saveState, deleteState, loadSession, saveSession, loadLastEventId, saveLastEventId } from './state.js';
|
|
17
20
|
const POLL_INTERVAL_MS = 25_000;
|
|
18
21
|
const HEARTBEAT_INTERVAL_MS = 45_000;
|
|
19
22
|
// ---------------------------------------------------------------------------
|
|
@@ -91,6 +94,22 @@ function loadTools(allowedTools, workingDirectory) {
|
|
|
91
94
|
export async function startSellerDaemon(config, opts) {
|
|
92
95
|
const logger = opts?.logger ?? defaultLogger;
|
|
93
96
|
const signal = opts?.signal;
|
|
97
|
+
// Slice 3b — load or generate the Ed25519 identity key once. Private key
|
|
98
|
+
// stays in memory for the lifetime of the daemon; the public key is
|
|
99
|
+
// registered with the broker after every fresh connect / re-connect so
|
|
100
|
+
// rotations are a single env change + restart.
|
|
101
|
+
let identityPrivateKey;
|
|
102
|
+
let identityPublicKeyB64;
|
|
103
|
+
try {
|
|
104
|
+
const keyPath = config.identityKeyPath ?? resolve(process.env.HOME ?? '.', '.zyndo', 'keys', `${config.name}.pem`);
|
|
105
|
+
const keypair = ensureIdentityKeypair(keyPath);
|
|
106
|
+
identityPrivateKey = keypair.privateKey;
|
|
107
|
+
identityPublicKeyB64 = keypair.publicKeyB64;
|
|
108
|
+
logger.info(`Identity key loaded: ${keyPath}`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.error(`Identity key setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing unsigned.`);
|
|
112
|
+
}
|
|
94
113
|
// Try to reconnect as the same agent from a previous session
|
|
95
114
|
let session;
|
|
96
115
|
const previousSession = loadSession();
|
|
@@ -123,13 +142,26 @@ export async function startSellerDaemon(config, opts) {
|
|
|
123
142
|
description: config.description,
|
|
124
143
|
skills: [...config.skills],
|
|
125
144
|
categories: [...config.categories],
|
|
126
|
-
maxConcurrentTasks: config.maxConcurrentTasks
|
|
145
|
+
maxConcurrentTasks: config.maxConcurrentTasks,
|
|
146
|
+
sellerSlug: config.id
|
|
127
147
|
});
|
|
128
148
|
logger.info(`Connected: agentId=${session.agentId}`);
|
|
129
149
|
}
|
|
130
150
|
// Persist session for future restarts
|
|
131
151
|
saveSession(session.agentId, session.reconnectToken);
|
|
132
|
-
|
|
152
|
+
// Slice 3b — register the Ed25519 public key with the broker. Failures
|
|
153
|
+
// are logged but non-fatal because signing is soft-enforced until
|
|
154
|
+
// rollout > 80%.
|
|
155
|
+
if (identityPublicKeyB64 !== undefined) {
|
|
156
|
+
try {
|
|
157
|
+
await registerIdentity(session, identityPublicKeyB64);
|
|
158
|
+
logger.info('Identity public key registered with broker.');
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
logger.error(`Identity registration failed: ${err instanceof Error ? err.message : String(err)}. Deliveries will be unsigned.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let lastEventId = loadLastEventId();
|
|
133
165
|
let lastHeartbeat = Date.now();
|
|
134
166
|
const activeTasks = new Set();
|
|
135
167
|
while (signal === undefined || !signal.aborted) {
|
|
@@ -193,7 +225,8 @@ export async function startSellerDaemon(config, opts) {
|
|
|
193
225
|
description: config.description,
|
|
194
226
|
skills: [...config.skills],
|
|
195
227
|
categories: [...config.categories],
|
|
196
|
-
maxConcurrentTasks: config.maxConcurrentTasks
|
|
228
|
+
maxConcurrentTasks: config.maxConcurrentTasks,
|
|
229
|
+
sellerSlug: config.id
|
|
197
230
|
});
|
|
198
231
|
saveSession(session.agentId, session.reconnectToken);
|
|
199
232
|
logger.info(`Re-registered: agentId=${session.agentId}`);
|
|
@@ -235,7 +268,7 @@ export async function startSellerDaemon(config, opts) {
|
|
|
235
268
|
if (event.eventId > lastEventId)
|
|
236
269
|
lastEventId = event.eventId;
|
|
237
270
|
logger.info(`Task assigned: ${taskId} (active: ${activeTasks.size}/${config.maxConcurrentTasks})`);
|
|
238
|
-
handleTask(session, taskId, config, logger)
|
|
271
|
+
handleTask(session, taskId, config, logger, identityPrivateKey)
|
|
239
272
|
.catch((err) => {
|
|
240
273
|
logger.error(`Task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
274
|
})
|
|
@@ -252,7 +285,7 @@ export async function startSellerDaemon(config, opts) {
|
|
|
252
285
|
continue; // Task handler is already running
|
|
253
286
|
activeTasks.add(taskId);
|
|
254
287
|
logger.info(`Message received for task: ${taskId}`);
|
|
255
|
-
handleBuyerMessage(session, taskId, config, logger)
|
|
288
|
+
handleBuyerMessage(session, taskId, config, logger, identityPrivateKey)
|
|
256
289
|
.catch((err) => {
|
|
257
290
|
logger.error(`Message handling for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
291
|
})
|
|
@@ -265,12 +298,25 @@ export async function startSellerDaemon(config, opts) {
|
|
|
265
298
|
continue;
|
|
266
299
|
activeTasks.add(taskId);
|
|
267
300
|
logger.info(`Revision requested for task: ${taskId}`);
|
|
268
|
-
handleRevision(session, taskId, feedback, config, logger)
|
|
301
|
+
handleRevision(session, taskId, feedback, config, logger, identityPrivateKey)
|
|
269
302
|
.catch((err) => {
|
|
270
303
|
logger.error(`Revision for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
271
304
|
})
|
|
272
305
|
.finally(() => activeTasks.delete(taskId));
|
|
273
306
|
}
|
|
307
|
+
// Dispute lifecycle events — log so the seller operator is aware
|
|
308
|
+
if (event.type === 'task.dispute.opened') {
|
|
309
|
+
const taskId = event.payload.taskId;
|
|
310
|
+
const reason = event.payload.reason ?? '';
|
|
311
|
+
const disputeId = event.payload.disputeId ?? '';
|
|
312
|
+
logger.info(`DISPUTE OPENED on task ${taskId} (${disputeId}): ${reason}`);
|
|
313
|
+
}
|
|
314
|
+
if (event.type === 'task.dispute.resolved') {
|
|
315
|
+
const taskId = event.payload.taskId;
|
|
316
|
+
const resolution = event.payload.resolution ?? '';
|
|
317
|
+
const disputeId = event.payload.disputeId ?? '';
|
|
318
|
+
logger.info(`DISPUTE RESOLVED on task ${taskId} (${disputeId}): ${resolution}`);
|
|
319
|
+
}
|
|
274
320
|
// Clean up state on terminal events
|
|
275
321
|
if (event.type === 'task.completed' || event.type === 'task.rejected' || event.type === 'task.failed' || event.type === 'task.canceled') {
|
|
276
322
|
const taskId = event.payload.taskId;
|
|
@@ -282,6 +328,8 @@ export async function startSellerDaemon(config, opts) {
|
|
|
282
328
|
if (minDeferredEventId !== undefined && lastEventId >= minDeferredEventId) {
|
|
283
329
|
lastEventId = minDeferredEventId - 1;
|
|
284
330
|
}
|
|
331
|
+
// Persist cursor so restarts resume from here
|
|
332
|
+
saveLastEventId(lastEventId);
|
|
285
333
|
}
|
|
286
334
|
catch (error) {
|
|
287
335
|
logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -293,8 +341,18 @@ export async function startSellerDaemon(config, opts) {
|
|
|
293
341
|
// ---------------------------------------------------------------------------
|
|
294
342
|
// Task handler
|
|
295
343
|
// ---------------------------------------------------------------------------
|
|
296
|
-
async function handleTask(session, taskId, config, logger) {
|
|
297
|
-
|
|
344
|
+
async function handleTask(session, taskId, config, logger, identityPrivateKey) {
|
|
345
|
+
try {
|
|
346
|
+
await acceptTask(session, taskId);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
350
|
+
if (msg.includes('409')) {
|
|
351
|
+
logger.info(`Task ${taskId}: already in progress or completed, skipping.`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
298
356
|
logger.info(`Task ${taskId}: accepted, starting work...`);
|
|
299
357
|
// Get task context from task detail first, then messages as supplement
|
|
300
358
|
const detail = await getTaskDetail(session, taskId);
|
|
@@ -302,9 +360,20 @@ async function handleTask(session, taskId, config, logger) {
|
|
|
302
360
|
const messages = await getTaskMessages(session, taskId);
|
|
303
361
|
const messageContext = messages.map((m) => m.content).join('\n');
|
|
304
362
|
const context = taskContext || messageContext || 'Task assigned. No additional context provided.';
|
|
363
|
+
// Compose the system prompt with the BOUND CONTRACT + REFUSAL PROTOCOL
|
|
364
|
+
// derived from the hire's frozen deliverables snapshot. Seller persona is
|
|
365
|
+
// subordinate; the contract and refusal blocks cannot be overridden by the
|
|
366
|
+
// seller or by anything the buyer says mid-task.
|
|
367
|
+
const scopedSystemPrompt = composeSystemPrompt(config.systemPrompt, detail?.deliverablesSnapshot);
|
|
368
|
+
if (detail?.deliverablesSnapshot === undefined) {
|
|
369
|
+
logger.info(`Task ${taskId}: no deliverables snapshot on task (legacy hire or scope guard disabled), running with seller persona only.`);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
logger.info(`Task ${taskId}: bound contract applied — ${detail.deliverablesSnapshot.items.length} item(s), ${detail.deliverablesSnapshot.inScope.length} in-scope, ${detail.deliverablesSnapshot.outOfScope.length} out-of-scope.`);
|
|
373
|
+
}
|
|
305
374
|
const result = config.provider === 'claude-code'
|
|
306
|
-
? await runClaudeCodeTask(context, config, logger)
|
|
307
|
-
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt:
|
|
375
|
+
? await runClaudeCodeTask(context, { ...config, systemPrompt: scopedSystemPrompt }, logger)
|
|
376
|
+
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: scopedSystemPrompt, taskId });
|
|
308
377
|
if (result.timedOut === true) {
|
|
309
378
|
logger.error(`Task ${taskId}: agent timed out or errored, notifying buyer.`);
|
|
310
379
|
await sendTaskMessage(session, taskId, 'info', 'The seller agent encountered a timeout processing this task. Please allow more time or simplify the request. The task remains assigned.');
|
|
@@ -317,15 +386,29 @@ async function handleTask(session, taskId, config, logger) {
|
|
|
317
386
|
await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
|
|
318
387
|
return;
|
|
319
388
|
}
|
|
320
|
-
|
|
321
|
-
|
|
389
|
+
// Output-side truncation: count produced units, clamp to contract, log a
|
|
390
|
+
// scope.truncated info message back to the buyer so the seller is not
|
|
391
|
+
// silently abused via prompt injection mid-conversation.
|
|
392
|
+
const truncated = truncateToContract(result.output, detail?.deliverablesSnapshot);
|
|
393
|
+
const finalOutput = truncated.output;
|
|
394
|
+
if (truncated.events.length > 0) {
|
|
395
|
+
for (const ev of truncated.events) {
|
|
396
|
+
logger.info(`Task ${taskId}: scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
|
|
397
|
+
await sendTaskMessage(session, taskId, 'info', `[scope-guard] Your brief requested more "${ev.itemName}" than the listing delivers. The seller agent produced ${ev.producedQuantity} but the contract allows only ${ev.allowedQuantity}. The delivery has been trimmed to the contracted quantity. To get more, hire again with a listing that covers the extra volume.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
logger.info(`Task ${taskId}: delivering result (${finalOutput.length} chars)`);
|
|
401
|
+
const signature = identityPrivateKey !== undefined
|
|
402
|
+
? signDelivery({ taskId, content: finalOutput, privateKey: identityPrivateKey })
|
|
403
|
+
: undefined;
|
|
404
|
+
await deliverTask(session, taskId, finalOutput, signature);
|
|
322
405
|
// Preserve state for potential revision — only deleted on terminal events
|
|
323
|
-
saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery:
|
|
406
|
+
saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: finalOutput });
|
|
324
407
|
}
|
|
325
408
|
// ---------------------------------------------------------------------------
|
|
326
409
|
// Revision handler
|
|
327
410
|
// ---------------------------------------------------------------------------
|
|
328
|
-
async function handleRevision(session, taskId, feedback, config, logger) {
|
|
411
|
+
async function handleRevision(session, taskId, feedback, config, logger, identityPrivateKey) {
|
|
329
412
|
const savedState = loadState(taskId);
|
|
330
413
|
// Use original task context (not compounded revision context) to avoid quadratic growth
|
|
331
414
|
const originalContext = savedState?.originalContext ?? savedState?.claudeCodeContext ?? '';
|
|
@@ -344,9 +427,12 @@ async function handleRevision(session, taskId, feedback, config, logger) {
|
|
|
344
427
|
'',
|
|
345
428
|
'Revise your work based on the buyer feedback above. Output the complete updated deliverable.'
|
|
346
429
|
].join('\n');
|
|
430
|
+
// Re-fetch the frozen snapshot so revisions stay bound to the contract.
|
|
431
|
+
const revisionDetail = await getTaskDetail(session, taskId);
|
|
432
|
+
const revisionSystemPrompt = composeSystemPrompt(config.systemPrompt, revisionDetail?.deliverablesSnapshot);
|
|
347
433
|
const result = config.provider === 'claude-code'
|
|
348
|
-
? await runClaudeCodeTask(revisionContext, config, logger)
|
|
349
|
-
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt:
|
|
434
|
+
? await runClaudeCodeTask(revisionContext, { ...config, systemPrompt: revisionSystemPrompt }, logger)
|
|
435
|
+
: await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: revisionSystemPrompt, taskId });
|
|
350
436
|
if (result.timedOut === true) {
|
|
351
437
|
logger.error(`Task ${taskId}: revision timed out, notifying buyer.`);
|
|
352
438
|
await sendTaskMessage(session, taskId, 'info', 'The seller agent timed out while working on the revision. Please allow more time or simplify the request.');
|
|
@@ -359,9 +445,20 @@ async function handleRevision(session, taskId, feedback, config, logger) {
|
|
|
359
445
|
await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
|
|
360
446
|
return;
|
|
361
447
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
448
|
+
const truncatedRevision = truncateToContract(result.output, revisionDetail?.deliverablesSnapshot);
|
|
449
|
+
const finalRevisionOutput = truncatedRevision.output;
|
|
450
|
+
if (truncatedRevision.events.length > 0) {
|
|
451
|
+
for (const ev of truncatedRevision.events) {
|
|
452
|
+
logger.info(`Task ${taskId}: revision scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
|
|
453
|
+
await sendTaskMessage(session, taskId, 'info', `[scope-guard] Revision trimmed: "${ev.itemName}" produced ${ev.producedQuantity}, contract allows ${ev.allowedQuantity}.`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
logger.info(`Task ${taskId}: delivering revision (${finalRevisionOutput.length} chars)`);
|
|
457
|
+
const revisionSignature = identityPrivateKey !== undefined
|
|
458
|
+
? signDelivery({ taskId, content: finalRevisionOutput, privateKey: identityPrivateKey })
|
|
459
|
+
: undefined;
|
|
460
|
+
await deliverTask(session, taskId, finalRevisionOutput, revisionSignature);
|
|
461
|
+
saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: finalRevisionOutput });
|
|
365
462
|
}
|
|
366
463
|
// ---------------------------------------------------------------------------
|
|
367
464
|
// Buyer message handler (resume paused loop)
|
|
@@ -376,7 +473,7 @@ function buildResumedContext(priorContext, priorQuestion, buyerAnswer) {
|
|
|
376
473
|
'Continue the task with this new information.'
|
|
377
474
|
].join('\n');
|
|
378
475
|
}
|
|
379
|
-
async function handleBuyerMessage(session, taskId, config, logger) {
|
|
476
|
+
async function handleBuyerMessage(session, taskId, config, logger, identityPrivateKey) {
|
|
380
477
|
const savedState = loadState(taskId);
|
|
381
478
|
if (savedState === undefined) {
|
|
382
479
|
logger.info(`Task ${taskId}: no saved state found, ignoring message.`);
|
|
@@ -421,7 +518,10 @@ async function handleBuyerMessage(session, taskId, config, logger) {
|
|
|
421
518
|
return;
|
|
422
519
|
}
|
|
423
520
|
logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
|
|
424
|
-
|
|
521
|
+
const resumedSignature = identityPrivateKey !== undefined
|
|
522
|
+
? signDelivery({ taskId, content: result.output, privateKey: identityPrivateKey })
|
|
523
|
+
: undefined;
|
|
524
|
+
await deliverTask(session, taskId, result.output, resumedSignature);
|
|
425
525
|
const resumedContext = config.provider === 'claude-code'
|
|
426
526
|
? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
|
|
427
527
|
: savedState.claudeCodeContext ?? '';
|
package/dist/state.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type ConversationState = Readonly<{
|
|
|
11
11
|
export declare function saveState(state: ConversationState): void;
|
|
12
12
|
export declare function loadState(taskId: string): ConversationState | undefined;
|
|
13
13
|
export declare function deleteState(taskId: string): void;
|
|
14
|
+
export declare function saveLastEventId(lastEventId: number): void;
|
|
15
|
+
export declare function loadLastEventId(): number;
|
|
14
16
|
export type PersistedSession = Readonly<{
|
|
15
17
|
agentId: string;
|
|
16
18
|
reconnectToken: string;
|
package/dist/state.js
CHANGED
|
@@ -36,6 +36,29 @@ export function deleteState(taskId) {
|
|
|
36
36
|
unlinkSync(path);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Event cursor persistence — resume from last processed event on restart
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function getEventCursorFile() {
|
|
43
|
+
return resolve(getBaseDir(), 'event-cursor.json');
|
|
44
|
+
}
|
|
45
|
+
export function saveLastEventId(lastEventId) {
|
|
46
|
+
const baseDir = getBaseDir();
|
|
47
|
+
mkdirSync(baseDir, { recursive: true });
|
|
48
|
+
writeFileSync(getEventCursorFile(), JSON.stringify({ lastEventId, savedAt: new Date().toISOString() }), 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
export function loadLastEventId() {
|
|
51
|
+
if (!existsSync(getEventCursorFile()))
|
|
52
|
+
return 0;
|
|
53
|
+
try {
|
|
54
|
+
const raw = readFileSync(getEventCursorFile(), 'utf-8');
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return typeof parsed.lastEventId === 'number' ? parsed.lastEventId : 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
39
62
|
function getSessionFile() {
|
|
40
63
|
return resolve(getBaseDir(), 'session.json');
|
|
41
64
|
}
|