pandora-cli-skills 1.1.20 → 1.1.22

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.
@@ -1,5 +1,10 @@
1
1
  const { createIndexerClient } = require('./indexer_client.cjs');
2
2
  const { fetchPolymarketMarkets } = require('./polymarket_adapter.cjs');
3
+ const {
4
+ normalizeQuestion,
5
+ questionSimilarityBreakdown,
6
+ questionSimilarity,
7
+ } = require('./similarity_service.cjs');
3
8
 
4
9
  const ARBITRAGE_SCHEMA_VERSION = '1.1.0';
5
10
 
@@ -21,96 +26,6 @@ function toUsdc(raw) {
21
26
  return round(numeric / 1_000_000, 6);
22
27
  }
23
28
 
24
- function normalizeQuestion(question) {
25
- return String(question || '')
26
- .toLowerCase()
27
- .replace(/[^a-z0-9\s]/g, ' ')
28
- .replace(/\b(the|a|an|will|be|on|at|in|to|for|by|of|is|are|was|were)\b/g, ' ')
29
- .replace(/\s+/g, ' ')
30
- .trim();
31
- }
32
-
33
- function tokenize(question) {
34
- return new Set(normalizeQuestion(question).split(' ').filter(Boolean));
35
- }
36
-
37
- function jaccard(a, b) {
38
- if (!a.size || !b.size) return 0;
39
- let intersection = 0;
40
- for (const token of a) {
41
- if (b.has(token)) intersection += 1;
42
- }
43
- const union = a.size + b.size - intersection;
44
- return union ? intersection / union : 0;
45
- }
46
-
47
- function jaroDistance(s1, s2) {
48
- const a = String(s1 || '');
49
- const b = String(s2 || '');
50
- if (a === b) return 1;
51
- const maxDist = Math.floor(Math.max(a.length, b.length) / 2) - 1;
52
- const aMatches = new Array(a.length).fill(false);
53
- const bMatches = new Array(b.length).fill(false);
54
-
55
- let matches = 0;
56
- for (let i = 0; i < a.length; i += 1) {
57
- const start = Math.max(0, i - maxDist);
58
- const end = Math.min(i + maxDist + 1, b.length);
59
- for (let j = start; j < end; j += 1) {
60
- if (bMatches[j]) continue;
61
- if (a[i] !== b[j]) continue;
62
- aMatches[i] = true;
63
- bMatches[j] = true;
64
- matches += 1;
65
- break;
66
- }
67
- }
68
-
69
- if (!matches) return 0;
70
-
71
- let t = 0;
72
- let j = 0;
73
- for (let i = 0; i < a.length; i += 1) {
74
- if (!aMatches[i]) continue;
75
- while (!bMatches[j]) j += 1;
76
- if (a[i] !== b[j]) t += 1;
77
- j += 1;
78
- }
79
-
80
- const transpositions = t / 2;
81
- return (matches / a.length + matches / b.length + (matches - transpositions) / matches) / 3;
82
- }
83
-
84
- function jaroWinkler(a, b) {
85
- const jaro = jaroDistance(a, b);
86
- let prefix = 0;
87
- const s1 = String(a || '');
88
- const s2 = String(b || '');
89
- for (let i = 0; i < Math.min(4, s1.length, s2.length); i += 1) {
90
- if (s1[i] === s2[i]) prefix += 1;
91
- else break;
92
- }
93
- return jaro + prefix * 0.1 * (1 - jaro);
94
- }
95
-
96
- function questionSimilarityBreakdown(a, b) {
97
- const normalizedLeft = normalizeQuestion(a);
98
- const normalizedRight = normalizeQuestion(b);
99
- const tokenScore = jaccard(tokenize(normalizedLeft), tokenize(normalizedRight));
100
- const jw = jaroWinkler(normalizedLeft, normalizedRight);
101
- return {
102
- normalizedLeft,
103
- normalizedRight,
104
- tokenScore: round(tokenScore, 6),
105
- jaroWinkler: round(jw, 6),
106
- score: round(tokenScore * 0.55 + jw * 0.45, 6),
107
- };
108
- }
109
-
110
- function questionSimilarity(a, b) {
111
- return questionSimilarityBreakdown(a, b).score;
112
- }
113
-
114
29
  function toYesProbabilityFromYesChance(rawYesChance) {
115
30
  const raw = toNumber(rawYesChance);
116
31
  if (raw === null) return null;
@@ -66,12 +66,65 @@ function loadState(filePath, hash) {
66
66
  };
67
67
  }
68
68
 
69
+ function sleepSync(ms) {
70
+ if (!Number.isFinite(ms) || ms <= 0) return;
71
+ const shared = new SharedArrayBuffer(4);
72
+ const view = new Int32Array(shared);
73
+ Atomics.wait(view, 0, 0, Math.max(1, Math.floor(ms)));
74
+ }
75
+
76
+ function acquireLock(lockPath, options = {}) {
77
+ const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 2_000;
78
+ const pollMs = Number.isFinite(Number(options.pollMs)) ? Number(options.pollMs) : 10;
79
+ const staleMs = Number.isFinite(Number(options.staleMs)) ? Number(options.staleMs) : 5 * 60 * 1000;
80
+ const deadline = Date.now() + Math.max(50, timeoutMs);
81
+
82
+ while (true) {
83
+ try {
84
+ return fs.openSync(lockPath, 'wx');
85
+ } catch (err) {
86
+ if (!err || err.code !== 'EEXIST') throw err;
87
+
88
+ try {
89
+ const stats = fs.statSync(lockPath);
90
+ const ageMs = Date.now() - stats.mtimeMs;
91
+ if (Number.isFinite(ageMs) && ageMs > staleMs) {
92
+ fs.unlinkSync(lockPath);
93
+ continue;
94
+ }
95
+ } catch {
96
+ // best-effort stale lock cleanup
97
+ }
98
+
99
+ if (Date.now() >= deadline) {
100
+ throw new Error(`Unable to acquire state lock within ${timeoutMs}ms: ${lockPath}`);
101
+ }
102
+ sleepSync(pollMs);
103
+ }
104
+ }
105
+ }
106
+
69
107
  function saveState(filePath, state) {
70
108
  const resolved = path.resolve(expandHome(filePath));
71
109
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
72
- const tmp = `${resolved}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`;
73
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
74
- fs.renameSync(tmp, resolved);
110
+ const lockPath = `${resolved}.lock`;
111
+ const lockFd = acquireLock(lockPath);
112
+ try {
113
+ const tmp = `${resolved}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`;
114
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
115
+ fs.renameSync(tmp, resolved);
116
+ } finally {
117
+ try {
118
+ fs.closeSync(lockFd);
119
+ } catch {
120
+ // ignore lock close failures
121
+ }
122
+ try {
123
+ fs.unlinkSync(lockPath);
124
+ } catch {
125
+ // ignore lock cleanup failures
126
+ }
127
+ }
75
128
  return resolved;
76
129
  }
77
130
 
@@ -438,8 +438,6 @@ async function runMirrorSync(options, deps = {}) {
438
438
  };
439
439
  let actualRebalanceUsdc = 0;
440
440
  let actualHedgeUsdc = 0;
441
- state.idempotencyKeys.push(idempotencyKey);
442
- pruneIdempotencyKeys(state);
443
441
  state.lastExecution = {
444
442
  mode: action.mode,
445
443
  status: 'pending',
@@ -505,18 +503,29 @@ async function runMirrorSync(options, deps = {}) {
505
503
 
506
504
  if (options.executeLive) {
507
505
  const envCreds = readTradingCredsFromEnv();
508
- const hedgeResult = await hedgeFn({
509
- host: options.polymarketHost,
510
- mockUrl: options.polymarketMockUrl,
511
- tokenId,
512
- side: hedgeSide,
513
- amountUsd: plannedHedgeUsdc,
514
- privateKey: options.privateKey || envCreds.privateKey,
515
- funder: options.funder || envCreds.funder,
516
- apiKey: envCreds.apiKey,
517
- apiSecret: envCreds.apiSecret,
518
- apiPassphrase: envCreds.apiPassphrase,
519
- });
506
+ let hedgeResult;
507
+ try {
508
+ hedgeResult = await hedgeFn({
509
+ host: options.polymarketHost,
510
+ mockUrl: options.polymarketMockUrl,
511
+ tokenId,
512
+ side: hedgeSide,
513
+ amountUsd: plannedHedgeUsdc,
514
+ privateKey: options.privateKey || envCreds.privateKey,
515
+ funder: options.funder || envCreds.funder,
516
+ apiKey: envCreds.apiKey,
517
+ apiSecret: envCreds.apiSecret,
518
+ apiPassphrase: envCreds.apiPassphrase,
519
+ });
520
+ } catch (err) {
521
+ hedgeResult = {
522
+ ok: false,
523
+ error: {
524
+ code: err && err.code ? String(err.code) : null,
525
+ message: err && err.message ? String(err.message) : String(err),
526
+ },
527
+ };
528
+ }
520
529
  action.hedge = {
521
530
  tokenId,
522
531
  side: hedgeSide,
@@ -565,6 +574,10 @@ async function runMirrorSync(options, deps = {}) {
565
574
  const actualSpendUsdc = round(actualRebalanceUsdc + actualHedgeUsdc, 6) || 0;
566
575
  state.dailySpendUsdc = round((toNumber(state.dailySpendUsdc) || 0) + actualSpendUsdc, 6) || 0;
567
576
  const executedLegCount = (actualRebalanceUsdc > 0 ? 1 : 0) + (actualHedgeUsdc > 0 ? 1 : 0);
577
+ if (executedLegCount > 0) {
578
+ state.idempotencyKeys.push(idempotencyKey);
579
+ pruneIdempotencyKeys(state);
580
+ }
568
581
  state.tradesToday += executedLegCount;
569
582
  state.lastExecution = action;
570
583
 
@@ -1,6 +1,7 @@
1
1
  const crypto = require('crypto');
2
2
  const { createIndexerClient } = require('./indexer_client.cjs');
3
3
  const { resolvePolymarketMarket } = require('./polymarket_trade_adapter.cjs');
4
+ const { questionSimilarityBreakdown } = require('./similarity_service.cjs');
4
5
 
5
6
  const MIRROR_VERIFY_SCHEMA_VERSION = '1.0.0';
6
7
  const USDC_DECIMALS = 6;
@@ -24,101 +25,6 @@ function normalizeUsdcRawToUsd(value) {
24
25
  return round(numeric / (10 ** USDC_DECIMALS), 6);
25
26
  }
26
27
 
27
- function normalizeQuestion(question) {
28
- return String(question || '')
29
- .toLowerCase()
30
- .replace(/[^a-z0-9\s]/g, ' ')
31
- .replace(/\b(the|a|an|will|be|on|at|in|to|for|by|of|is|are|was|were)\b/g, ' ')
32
- .replace(/\s+/g, ' ')
33
- .trim();
34
- }
35
-
36
- function tokenize(question) {
37
- return new Set(normalizeQuestion(question).split(' ').filter(Boolean));
38
- }
39
-
40
- function jaccard(a, b) {
41
- if (!a.size || !b.size) return 0;
42
- let intersection = 0;
43
- for (const token of a) {
44
- if (b.has(token)) intersection += 1;
45
- }
46
- const union = a.size + b.size - intersection;
47
- return union ? intersection / union : 0;
48
- }
49
-
50
- function jaroDistance(leftInput, rightInput) {
51
- const left = String(leftInput || '');
52
- const right = String(rightInput || '');
53
- if (left === right) return 1;
54
-
55
- const maxDistance = Math.floor(Math.max(left.length, right.length) / 2) - 1;
56
- const leftMatches = new Array(left.length).fill(false);
57
- const rightMatches = new Array(right.length).fill(false);
58
-
59
- let matches = 0;
60
- for (let i = 0; i < left.length; i += 1) {
61
- const start = Math.max(0, i - maxDistance);
62
- const end = Math.min(i + maxDistance + 1, right.length);
63
- for (let j = start; j < end; j += 1) {
64
- if (rightMatches[j]) continue;
65
- if (left[i] !== right[j]) continue;
66
- leftMatches[i] = true;
67
- rightMatches[j] = true;
68
- matches += 1;
69
- break;
70
- }
71
- }
72
-
73
- if (!matches) return 0;
74
-
75
- let transpositions = 0;
76
- let rightIndex = 0;
77
- for (let i = 0; i < left.length; i += 1) {
78
- if (!leftMatches[i]) continue;
79
- while (!rightMatches[rightIndex]) {
80
- rightIndex += 1;
81
- }
82
- if (left[i] !== right[rightIndex]) transpositions += 1;
83
- rightIndex += 1;
84
- }
85
-
86
- const t = transpositions / 2;
87
- return (matches / left.length + matches / right.length + (matches - t) / matches) / 3;
88
- }
89
-
90
- function jaroWinkler(left, right) {
91
- const jaro = jaroDistance(left, right);
92
- const a = String(left || '');
93
- const b = String(right || '');
94
- let prefix = 0;
95
-
96
- for (let i = 0; i < Math.min(4, a.length, b.length); i += 1) {
97
- if (a[i] === b[i]) {
98
- prefix += 1;
99
- } else {
100
- break;
101
- }
102
- }
103
-
104
- return jaro + prefix * 0.1 * (1 - jaro);
105
- }
106
-
107
- function questionSimilarityBreakdown(leftQuestion, rightQuestion) {
108
- const normalizedLeft = normalizeQuestion(leftQuestion);
109
- const normalizedRight = normalizeQuestion(rightQuestion);
110
- const tokenScore = jaccard(tokenize(normalizedLeft), tokenize(normalizedRight));
111
- const jw = jaroWinkler(normalizedLeft, normalizedRight);
112
-
113
- return {
114
- normalizedLeft,
115
- normalizedRight,
116
- tokenScore: round(tokenScore, 6),
117
- jaroWinkler: round(jw, 6),
118
- score: round(tokenScore * 0.55 + jw * 0.45, 6),
119
- };
120
- }
121
-
122
28
  function normalizeProbabilityFromYesChance(value) {
123
29
  const raw = toNumber(value);
124
30
  if (raw === null) return null;
@@ -491,15 +491,20 @@ async function fetchJson(url, timeoutMs) {
491
491
  async function callWithTimeout(work, timeoutMs, label) {
492
492
  const limitMs = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : null;
493
493
  if (!limitMs) {
494
- return work();
494
+ return work(undefined);
495
495
  }
496
496
 
497
+ const abortController = new AbortController();
497
498
  let timer = null;
498
499
  try {
499
500
  return await Promise.race([
500
- work(),
501
+ work(abortController.signal),
501
502
  new Promise((_, reject) => {
502
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${limitMs}ms`)), limitMs);
503
+ timer = setTimeout(() => {
504
+ // Best-effort cancellation: this only interrupts clients that support AbortSignal.
505
+ abortController.abort();
506
+ reject(new Error(`${label} timed out after ${limitMs}ms`));
507
+ }, limitMs);
503
508
  }),
504
509
  ]);
505
510
  } finally {
@@ -554,7 +559,7 @@ async function resolveByClobDirect(conditionId, hosts, options, diagnostics, tim
554
559
  throw new Error('CLOB client does not expose getMarket.');
555
560
  }
556
561
  const market = await callWithTimeout(
557
- () => client.getMarket(conditionId),
562
+ (_signal) => client.getMarket(conditionId),
558
563
  timeoutMs,
559
564
  `Polymarket getMarket(${conditionId})`,
560
565
  );
@@ -656,7 +661,7 @@ async function resolvePolymarketMarket(options = {}) {
656
661
  while (loops < maxPages) {
657
662
  loops += 1;
658
663
  const page = await callWithTimeout(
659
- () => (cursor ? client.getMarkets(cursor) : client.getMarkets()),
664
+ (_signal) => (cursor ? client.getMarkets(cursor) : client.getMarkets()),
660
665
  timeoutMs,
661
666
  `Polymarket getMarkets(${candidateHost})`,
662
667
  );
@@ -829,7 +834,7 @@ async function getOrderbook(clientOrOptions, tokenId, fallbackOrderbooks = null,
829
834
  }
830
835
 
831
836
  return callWithTimeout(
832
- () => clientOrOptions.getOrderBook(tokenId),
837
+ (_signal) => clientOrOptions.getOrderBook(tokenId),
833
838
  timeoutMs,
834
839
  `Polymarket getOrderBook(${tokenId})`,
835
840
  );
@@ -1322,7 +1327,7 @@ async function fetchPolymarketPositionSummary(options = {}) {
1322
1327
  if (!tokenId) return;
1323
1328
  try {
1324
1329
  const response = await callWithTimeout(
1325
- () =>
1330
+ (_signal) =>
1326
1331
  client.getBalanceAllowance({
1327
1332
  asset_type: AssetType.CONDITIONAL,
1328
1333
  token_id: tokenId,
@@ -1355,7 +1360,7 @@ async function fetchPolymarketPositionSummary(options = {}) {
1355
1360
  try {
1356
1361
  if (baseSummary.marketId) {
1357
1362
  openOrders = await callWithTimeout(
1358
- () => client.getOpenOrders({ market: baseSummary.marketId }),
1363
+ (_signal) => client.getOpenOrders({ market: baseSummary.marketId }),
1359
1364
  timeoutMs,
1360
1365
  `Polymarket getOpenOrders(market:${baseSummary.marketId})`,
1361
1366
  );
@@ -1364,7 +1369,7 @@ async function fetchPolymarketPositionSummary(options = {}) {
1364
1369
  if (baseSummary.yesTokenId) {
1365
1370
  grouped.push(
1366
1371
  await callWithTimeout(
1367
- () => client.getOpenOrders({ asset_id: baseSummary.yesTokenId }),
1372
+ (_signal) => client.getOpenOrders({ asset_id: baseSummary.yesTokenId }),
1368
1373
  timeoutMs,
1369
1374
  `Polymarket getOpenOrders(asset:${baseSummary.yesTokenId})`,
1370
1375
  ),
@@ -1373,7 +1378,7 @@ async function fetchPolymarketPositionSummary(options = {}) {
1373
1378
  if (baseSummary.noTokenId && baseSummary.noTokenId !== baseSummary.yesTokenId) {
1374
1379
  grouped.push(
1375
1380
  await callWithTimeout(
1376
- () => client.getOpenOrders({ asset_id: baseSummary.noTokenId }),
1381
+ (_signal) => client.getOpenOrders({ asset_id: baseSummary.noTokenId }),
1377
1382
  timeoutMs,
1378
1383
  `Polymarket getOpenOrders(asset:${baseSummary.noTokenId})`,
1379
1384
  ),
@@ -1408,6 +1413,8 @@ async function buildTradingClient(options = {}) {
1408
1413
  const signatureType = resolveSignatureType(options);
1409
1414
  const cacheKey = buildTradingCacheKey(host, chain, options);
1410
1415
  const allowCache = options.disableCache !== true;
1416
+ const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
1417
+ const ClobCtor = options.clobClientClass || ClobClient;
1411
1418
 
1412
1419
  if (allowCache && tradingClientCache.has(cacheKey)) {
1413
1420
  return tradingClientCache.get(cacheKey);
@@ -1438,7 +1445,7 @@ async function buildTradingClient(options = {}) {
1438
1445
  if (allowCache && derivedCredsCache.has(cacheKey)) {
1439
1446
  creds = derivedCredsCache.get(cacheKey);
1440
1447
  } else {
1441
- const bootstrap = new ClobClient(
1448
+ const bootstrap = new ClobCtor(
1442
1449
  host,
1443
1450
  chain,
1444
1451
  signer,
@@ -1454,12 +1461,27 @@ async function buildTradingClient(options = {}) {
1454
1461
  if (typeof bootstrap.deriveApiKey === 'function') {
1455
1462
  try {
1456
1463
  // deriveApiKey expects nonce, not signature type; default to nonce 0.
1457
- creds = await bootstrap.deriveApiKey(0);
1458
- } catch {
1459
- creds = await bootstrap.deriveApiKey();
1464
+ creds = await callWithTimeout(
1465
+ () => bootstrap.deriveApiKey(0),
1466
+ timeoutMs,
1467
+ 'Polymarket deriveApiKey(0)',
1468
+ );
1469
+ } catch (err) {
1470
+ if (err && typeof err.message === 'string' && err.message.includes('timed out')) {
1471
+ throw err;
1472
+ }
1473
+ creds = await callWithTimeout(
1474
+ () => bootstrap.deriveApiKey(),
1475
+ timeoutMs,
1476
+ 'Polymarket deriveApiKey()',
1477
+ );
1460
1478
  }
1461
1479
  } else if (typeof bootstrap.createOrDeriveApiKey === 'function') {
1462
- creds = await bootstrap.createOrDeriveApiKey();
1480
+ creds = await callWithTimeout(
1481
+ () => bootstrap.createOrDeriveApiKey(),
1482
+ timeoutMs,
1483
+ 'Polymarket createOrDeriveApiKey()',
1484
+ );
1463
1485
  } else {
1464
1486
  throw new Error('CLOB client does not support API key derivation.');
1465
1487
  }
@@ -1469,7 +1491,7 @@ async function buildTradingClient(options = {}) {
1469
1491
  }
1470
1492
  }
1471
1493
 
1472
- const client = new ClobClient(
1494
+ const client = new ClobCtor(
1473
1495
  host,
1474
1496
  chain,
1475
1497
  signer,
@@ -1523,25 +1545,44 @@ async function placeHedgeOrder(options = {}) {
1523
1545
  const host = options.host || DEFAULT_POLYMARKET_HOST;
1524
1546
  const chain = options.chain || DEFAULT_POLYMARKET_CHAIN;
1525
1547
  const cacheKey = buildTradingCacheKey(host, chain, options);
1548
+ const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
1526
1549
  const client = options.client || (await buildTradingClient(options));
1527
1550
  const side = resolveOrderSide(options.side || 'buy');
1528
1551
  try {
1529
- const tickSize = options.tickSize || (await client.getTickSize(tokenId));
1530
- const negRisk = typeof options.negRisk === 'boolean' ? options.negRisk : await client.getNegRisk(tokenId);
1552
+ const tickSize =
1553
+ options.tickSize ||
1554
+ (await callWithTimeout(
1555
+ () => client.getTickSize(tokenId),
1556
+ timeoutMs,
1557
+ `Polymarket getTickSize(${tokenId})`,
1558
+ ));
1559
+ const negRisk =
1560
+ typeof options.negRisk === 'boolean'
1561
+ ? options.negRisk
1562
+ : await callWithTimeout(
1563
+ () => client.getNegRisk(tokenId),
1564
+ timeoutMs,
1565
+ `Polymarket getNegRisk(${tokenId})`,
1566
+ );
1531
1567
 
1532
- const response = await client.createAndPostMarketOrder(
1533
- {
1534
- tokenID: tokenId,
1535
- amount: amountUsd,
1536
- side,
1537
- orderType: OrderType.FAK,
1538
- },
1539
- {
1540
- tickSize,
1541
- negRisk,
1542
- },
1543
- OrderType.FAK,
1544
- false,
1568
+ const response = await callWithTimeout(
1569
+ () =>
1570
+ client.createAndPostMarketOrder(
1571
+ {
1572
+ tokenID: tokenId,
1573
+ amount: amountUsd,
1574
+ side,
1575
+ orderType: OrderType.FAK,
1576
+ },
1577
+ {
1578
+ tickSize,
1579
+ negRisk,
1580
+ },
1581
+ OrderType.FAK,
1582
+ false,
1583
+ ),
1584
+ timeoutMs,
1585
+ `Polymarket createAndPostMarketOrder(${tokenId})`,
1545
1586
  );
1546
1587
  const ok = responseIndicatesSuccess(response);
1547
1588
  if (!ok && classifyAuthFailure(response)) {
@@ -0,0 +1,109 @@
1
+ function toNumber(value) {
2
+ const numeric = Number(value);
3
+ if (!Number.isFinite(numeric)) return null;
4
+ return numeric;
5
+ }
6
+
7
+ function round(value, decimals = 6) {
8
+ const numeric = toNumber(value);
9
+ if (numeric === null) return null;
10
+ const factor = 10 ** decimals;
11
+ return Math.round(numeric * factor) / factor;
12
+ }
13
+
14
+ function normalizeQuestion(question) {
15
+ return String(question || '')
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9\s]/g, ' ')
18
+ .replace(/\b(the|a|an|will|be|on|at|in|to|for|by|of|is|are|was|were)\b/g, ' ')
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+
23
+ function tokenize(question) {
24
+ return new Set(normalizeQuestion(question).split(' ').filter(Boolean));
25
+ }
26
+
27
+ function jaccard(left, right) {
28
+ if (!left.size || !right.size) return 0;
29
+ let intersection = 0;
30
+ for (const token of left) {
31
+ if (right.has(token)) intersection += 1;
32
+ }
33
+ const union = left.size + right.size - intersection;
34
+ return union ? intersection / union : 0;
35
+ }
36
+
37
+ function jaroDistance(leftInput, rightInput) {
38
+ const left = String(leftInput || '');
39
+ const right = String(rightInput || '');
40
+ if (left === right) return 1;
41
+
42
+ const maxDistance = Math.floor(Math.max(left.length, right.length) / 2) - 1;
43
+ const leftMatches = new Array(left.length).fill(false);
44
+ const rightMatches = new Array(right.length).fill(false);
45
+
46
+ let matches = 0;
47
+ for (let i = 0; i < left.length; i += 1) {
48
+ const start = Math.max(0, i - maxDistance);
49
+ const end = Math.min(i + maxDistance + 1, right.length);
50
+ for (let j = start; j < end; j += 1) {
51
+ if (rightMatches[j]) continue;
52
+ if (left[i] !== right[j]) continue;
53
+ leftMatches[i] = true;
54
+ rightMatches[j] = true;
55
+ matches += 1;
56
+ break;
57
+ }
58
+ }
59
+
60
+ if (!matches) return 0;
61
+
62
+ let transpositions = 0;
63
+ let rightIndex = 0;
64
+ for (let i = 0; i < left.length; i += 1) {
65
+ if (!leftMatches[i]) continue;
66
+ while (!rightMatches[rightIndex]) rightIndex += 1;
67
+ if (left[i] !== right[rightIndex]) transpositions += 1;
68
+ rightIndex += 1;
69
+ }
70
+
71
+ const t = transpositions / 2;
72
+ return (matches / left.length + matches / right.length + (matches - t) / matches) / 3;
73
+ }
74
+
75
+ function jaroWinkler(left, right) {
76
+ const jaro = jaroDistance(left, right);
77
+ const a = String(left || '');
78
+ const b = String(right || '');
79
+ let prefix = 0;
80
+ for (let i = 0; i < Math.min(4, a.length, b.length); i += 1) {
81
+ if (a[i] === b[i]) prefix += 1;
82
+ else break;
83
+ }
84
+ return jaro + prefix * 0.1 * (1 - jaro);
85
+ }
86
+
87
+ function questionSimilarityBreakdown(leftQuestion, rightQuestion) {
88
+ const normalizedLeft = normalizeQuestion(leftQuestion);
89
+ const normalizedRight = normalizeQuestion(rightQuestion);
90
+ const tokenScore = jaccard(tokenize(normalizedLeft), tokenize(normalizedRight));
91
+ const jw = jaroWinkler(normalizedLeft, normalizedRight);
92
+ return {
93
+ normalizedLeft,
94
+ normalizedRight,
95
+ tokenScore: round(tokenScore, 6),
96
+ jaroWinkler: round(jw, 6),
97
+ score: round(tokenScore * 0.55 + jw * 0.45, 6),
98
+ };
99
+ }
100
+
101
+ function questionSimilarity(leftQuestion, rightQuestion) {
102
+ return questionSimilarityBreakdown(leftQuestion, rightQuestion).score;
103
+ }
104
+
105
+ module.exports = {
106
+ normalizeQuestion,
107
+ questionSimilarityBreakdown,
108
+ questionSimilarity,
109
+ };
package/cli/pandora.cjs CHANGED
@@ -610,7 +610,14 @@ function formatErrorValue(value) {
610
610
  }
611
611
  }
612
612
 
613
+ let failureAlreadyEmitted = false;
614
+
613
615
  function emitFailure(outputMode, error) {
616
+ if (failureAlreadyEmitted) {
617
+ process.exit(error instanceof CliError ? error.exitCode : 1);
618
+ }
619
+ failureAlreadyEmitted = true;
620
+
614
621
  const envelope = toErrorEnvelope(error);
615
622
 
616
623
  if (outputMode === 'json') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pandora-cli-skills",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "Pandora CLI & Skills",
5
5
  "main": "cli/pandora.cjs",
6
6
  "bin": {