thumbgate 1.4.1 → 1.4.3

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.
Files changed (60) hide show
  1. package/.claude-plugin/README.md +45 -34
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +3 -3
  4. package/.well-known/llms.txt +1 -1
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +26 -2
  7. package/adapters/README.md +4 -1
  8. package/adapters/chatgpt/INSTALL.md +39 -19
  9. package/adapters/claude/.mcp.json +2 -2
  10. package/adapters/codex/config.toml +2 -2
  11. package/adapters/mcp/server-stdio.js +10 -4
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/adapters/perplexity/.mcp.json +36 -0
  14. package/adapters/perplexity/config.toml +16 -0
  15. package/adapters/perplexity/opencode.json +29 -0
  16. package/bin/cli.js +246 -90
  17. package/config/mcp-allowlists.json +11 -3
  18. package/package.json +28 -13
  19. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  20. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  21. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  22. package/plugins/codex-profile/.mcp.json +1 -1
  23. package/plugins/codex-profile/INSTALL.md +1 -1
  24. package/plugins/codex-profile/README.md +1 -1
  25. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/index.html +121 -24
  28. package/public/llm-context.md +17 -1
  29. package/scripts/ai-search-visibility.js +10 -36
  30. package/scripts/audit-trail.js +25 -15
  31. package/scripts/auto-wire-hooks.js +127 -0
  32. package/scripts/cli-demo.js +102 -0
  33. package/scripts/cli-schema.js +285 -0
  34. package/scripts/cli-status.js +166 -0
  35. package/scripts/cross-encoder-reranker.js +235 -0
  36. package/scripts/explore-subcommands.js +277 -0
  37. package/scripts/explore.js +569 -0
  38. package/scripts/feedback-loop.js +20 -6
  39. package/scripts/lesson-inference.js +27 -2
  40. package/scripts/lesson-reranker.js +263 -0
  41. package/scripts/lesson-retrieval.js +34 -17
  42. package/scripts/lesson-search.js +69 -0
  43. package/scripts/perplexity-client.js +210 -0
  44. package/scripts/perplexity-command-center.js +644 -0
  45. package/scripts/perplexity-marketing.js +17 -29
  46. package/scripts/prove-packaged-runtime.js +5 -4
  47. package/scripts/ralph-mode-ci.js +122 -19
  48. package/scripts/reflector-agent.js +2 -2
  49. package/scripts/session-analyzer.js +533 -0
  50. package/scripts/social-analytics/db/marketing-db.js +179 -0
  51. package/scripts/social-analytics/db/schema.sql +23 -0
  52. package/scripts/social-analytics/generate-instagram-card.js +31 -5
  53. package/scripts/social-analytics/generate-slides.js +268 -0
  54. package/scripts/social-analytics/post-video.js +316 -0
  55. package/scripts/social-analytics/publishers/zernio.js +52 -23
  56. package/scripts/statusline-local-stats.js +3 -1
  57. package/scripts/statusline.sh +15 -10
  58. package/scripts/thumbgate-bench.js +494 -0
  59. package/src/api/server.js +65 -1
  60. package/scripts/social-analytics/db/analytics.sqlite +0 -0
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cross-encoder reranker for lesson retrieval.
5
+ *
6
+ * Unlike the bi-encoders already in use (Jaccard + bigram Jaccard), a
7
+ * cross-encoder processes the (query, lesson) pair jointly — so it can
8
+ * catch relevance signals that independent scoring misses:
9
+ *
10
+ * - Field-weighted BM25: a query term in `whatWentWrong` is worth more
11
+ * than the same term in `tags`
12
+ * - Synonym/alias expansion: "force-push" ↔ "push --force", "deploy" ↔
13
+ * "deployment", etc.
14
+ * - Signal coherence: failure-sounding queries boost negative-signal lessons
15
+ * - Tool name joint scoring: query toolName × lesson toolsUsed
16
+ * - Score blending: reranked score is blended with the original retrieval
17
+ * score so we never fully discard the bi-encoder's signal
18
+ *
19
+ * Usage:
20
+ * const { rerankLessons } = require('./lesson-reranker');
21
+ * const reranked = rerankLessons(query, candidates, { topK: 5, toolName });
22
+ */
23
+
24
+ // BM25 hyper-parameters
25
+ const BM25_K1 = 1.5; // term saturation
26
+ const BM25_B = 0.75; // length normalisation
27
+
28
+ // Weight given to each lesson field when scoring a (query, lesson) pair.
29
+ // Higher weight = query terms appearing in that field contribute more to score.
30
+ const FIELD_WEIGHTS = {
31
+ whatWentWrong: 3.0,
32
+ whatToChange: 2.5,
33
+ howToAvoid: 2.0,
34
+ whatWorked: 2.0,
35
+ summary: 1.5,
36
+ content: 1.5,
37
+ context: 1.2,
38
+ title: 1.0,
39
+ rootCause: 1.0,
40
+ reasoning: 0.8,
41
+ tags: 0.5,
42
+ category: 0.4,
43
+ };
44
+
45
+ // Synonym clusters: any term in a group matches all others.
46
+ const SYNONYM_GROUPS = [
47
+ ['force-push', 'force push', 'push --force', 'git push --force', 'force_push'],
48
+ ['main', 'main branch', 'master', 'trunk', 'protected branch'],
49
+ ['env', '.env', 'environment variable', 'env var', 'dotenv', 'secret'],
50
+ ['deploy', 'deployment', 'ship', 'release', 'publish', 'rollout'],
51
+ ['db', 'database', 'sqlite', 'postgres', 'postgresql', 'migration', 'migrate'],
52
+ ['test', 'tests', 'test suite', 'spec', 'failing test', 'test failure'],
53
+ ['ci', 'ci/cd', 'pipeline', 'github actions', 'workflow', 'build'],
54
+ ['lint', 'linter', 'eslint', 'prettier', 'format'],
55
+ ['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
56
+ ['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
57
+ ['merge', 'pull request', 'pr', 'rebase', 'squash'],
58
+ ];
59
+
60
+ // Regex patterns that indicate the query is about a failure/mistake.
61
+ const FAILURE_PATTERN = /fail|error|wrong|broken|mistake|bad|incorrect|problem|issue|bug|crash|broke|exception/i;
62
+
63
+ /**
64
+ * Tokenise text into lowercase word-like tokens of length >= 2.
65
+ * Hyphens and underscores are treated as delimiters so "force-push"
66
+ * becomes ["force", "push"].
67
+ * Exported so tests can verify expansion behaviour.
68
+ */
69
+ function tokenize(text) {
70
+ if (!text) return [];
71
+ return text
72
+ .toLowerCase()
73
+ .replace(/[^\w\s]/g, ' ') // replace all non-word, non-space chars (incl. hyphens, dots) with space
74
+ .split(/[\s_]+/) // split on whitespace and underscores
75
+ .filter((t) => t.length >= 2);
76
+ }
77
+
78
+ /**
79
+ * Expand a set of query tokens with synonyms from SYNONYM_GROUPS.
80
+ * Returns a deduplicated array of all terms (originals + expansions).
81
+ */
82
+ function expandTerms(terms) {
83
+ const expanded = new Set(terms);
84
+ for (const term of terms) {
85
+ for (const group of SYNONYM_GROUPS) {
86
+ if (group.some((syn) => syn.split(/\s+/).some((w) => w === term || term.includes(w)))) {
87
+ group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
88
+ }
89
+ }
90
+ }
91
+ return [...expanded];
92
+ }
93
+
94
+ /**
95
+ * Extract the text value of a named field from a lesson candidate.
96
+ * Handles both the flat structure from lesson-retrieval.js and the nested
97
+ * { lesson: { whatWentWrong, ... } } structure from lesson-search.js.
98
+ */
99
+ function getField(candidate, field) {
100
+ const nested = candidate.lesson;
101
+ const val = (nested && nested[field]) || candidate[field] || '';
102
+ if (Array.isArray(val)) return val.join(' ');
103
+ return String(val);
104
+ }
105
+
106
+ /**
107
+ * Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
108
+ *
109
+ * BM25F processes the (query, lesson) pair jointly: query terms are weighted
110
+ * differently depending on which lesson field they appear in (via FIELD_WEIGHTS).
111
+ * IDF is computed at document level (how many docs contain the term across any
112
+ * field) so it stays positive regardless of field weights.
113
+ *
114
+ * Returns an array of { candidate, bm25Score } objects in the same order
115
+ * as the input.
116
+ */
117
+ function fieldWeightedBM25(queryTerms, candidates) {
118
+ const N = candidates.length;
119
+ if (N === 0) return [];
120
+
121
+ const fieldEntries = Object.entries(FIELD_WEIGHTS);
122
+ const fieldKeys = Object.keys(FIELD_WEIGHTS);
123
+
124
+ // Precompute per-document, per-field token arrays (avoid re-tokenising)
125
+ const docFieldTokens = candidates.map((candidate) => {
126
+ const fieldMap = {};
127
+ for (const field of fieldKeys) {
128
+ fieldMap[field] = tokenize(getField(candidate, field));
129
+ }
130
+ return fieldMap;
131
+ });
132
+
133
+ // Per-field average token lengths across all documents
134
+ const avgFieldLen = {};
135
+ for (const field of fieldKeys) {
136
+ const total = docFieldTokens.reduce((sum, d) => sum + d[field].length, 0);
137
+ avgFieldLen[field] = total / N || 1; // fallback to 1 to avoid /0
138
+ }
139
+
140
+ // Document-level df: count of documents that contain each term (any field).
141
+ // Keeping df as a plain count (not field-weighted) ensures IDF is always positive.
142
+ const df = new Map();
143
+ for (let i = 0; i < N; i++) {
144
+ const seenInDoc = new Set();
145
+ for (const field of fieldKeys) {
146
+ for (const tok of docFieldTokens[i][field]) {
147
+ if (!seenInDoc.has(tok)) {
148
+ df.set(tok, (df.get(tok) || 0) + 1);
149
+ seenInDoc.add(tok);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ return candidates.map((candidate, i) => {
156
+ let score = 0;
157
+
158
+ for (const qTerm of queryTerms) {
159
+ const termDf = df.get(qTerm) || 0;
160
+ if (termDf === 0) continue;
161
+
162
+ // IDF is always positive because df ≤ N
163
+ const idf = Math.log((N - termDf + 0.5) / (termDf + 0.5) + 1);
164
+ if (idf <= 0) continue;
165
+
166
+ // BM25F: compute weighted sum of per-field normalised TF, then scale by IDF
167
+ let weightedTF = 0;
168
+ for (const [field, fieldWeight] of fieldEntries) {
169
+ const tokens = docFieldTokens[i][field];
170
+ const fieldLen = tokens.length;
171
+ if (fieldLen === 0) continue;
172
+
173
+ let tf = 0;
174
+ for (const t of tokens) {
175
+ if (t === qTerm) tf++;
176
+ }
177
+ if (tf === 0) continue;
178
+
179
+ const avgLen = avgFieldLen[field];
180
+ const normTF = tf / (tf + BM25_K1 * (1 - BM25_B + BM25_B * fieldLen / avgLen));
181
+ weightedTF += fieldWeight * normTF;
182
+ }
183
+
184
+ score += idf * weightedTF;
185
+ }
186
+
187
+ return { candidate, bm25Score: score };
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Rerank a list of lesson candidates using a cross-encoder approach.
193
+ *
194
+ * @param {string} query - The original retrieval query / action context
195
+ * @param {Array} candidates - Lesson objects from the bi-encoder stage
196
+ * @param {object} options
197
+ * @param {number} [options.topK=5] - How many results to return
198
+ * @param {string} [options.toolName] - Tool name from the triggering hook call
199
+ * @param {number} [options.blendWeight=0.7] - Weight given to BM25 score vs original
200
+ * retrieval score (0 = all original, 1 = all BM25)
201
+ * @returns {Array} Reranked candidates with `rerankedScore` field added
202
+ */
203
+ function rerankLessons(query, candidates, options = {}) {
204
+ const {
205
+ topK = 5,
206
+ toolName = '',
207
+ blendWeight = 0.7,
208
+ } = options;
209
+
210
+ if (!candidates || candidates.length === 0) return [];
211
+ if (candidates.length === 1) return candidates.slice(0, topK);
212
+
213
+ // Build expanded query term set
214
+ const rawTerms = tokenize((query || '') + (toolName ? ' ' + toolName : ''));
215
+ const queryTerms = expandTerms(rawTerms);
216
+
217
+ const isFailureQuery = FAILURE_PATTERN.test(query || '');
218
+
219
+ // Compute BM25 scores for all candidates
220
+ const bm25Results = fieldWeightedBM25(queryTerms, candidates);
221
+
222
+ // Normalise BM25 scores to [0, 1]
223
+ const maxBm25 = Math.max(...bm25Results.map((r) => r.bm25Score), 1e-9);
224
+
225
+ const reranked = bm25Results.map(({ candidate, bm25Score }) => {
226
+ const normBm25 = bm25Score / maxBm25;
227
+
228
+ // Original bi-encoder score (field name differs between retrieval paths)
229
+ const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
230
+
231
+ // Blend BM25 with original score
232
+ let finalScore = blendWeight * normBm25 + (1 - blendWeight) * origScore;
233
+
234
+ // Signal coherence bonus: failure queries → negative lessons rank higher
235
+ const candidateSignal =
236
+ candidate.signal ||
237
+ (candidate.tags && candidate.tags.includes('negative') ? 'negative' : null);
238
+ if (isFailureQuery && candidateSignal === 'negative') {
239
+ finalScore *= 1.2;
240
+ }
241
+
242
+ // Tool name joint bonus: exact tool match between query context and lesson
243
+ if (toolName) {
244
+ const lessonTools = [
245
+ ...(candidate.metadata?.toolsUsed || []),
246
+ getField(candidate, 'toolUsed'),
247
+ getField(candidate, 'toolName'),
248
+ ].map((t) => (t || '').toLowerCase());
249
+
250
+ if (lessonTools.some((t) => t && t.includes(toolName.toLowerCase()))) {
251
+ finalScore *= 1.3;
252
+ }
253
+ }
254
+
255
+ return { ...candidate, rerankedScore: Number(finalScore.toFixed(6)) };
256
+ });
257
+
258
+ return reranked
259
+ .sort((a, b) => b.rerankedScore - a.rerankedScore)
260
+ .slice(0, topK);
261
+ }
262
+
263
+ module.exports = { rerankLessons, fieldWeightedBM25, tokenize, expandTerms };
@@ -3,14 +3,22 @@
3
3
 
4
4
  /**
5
5
  * Per-action lesson retrieval.
6
- * v2: backward retrieval + bigram Jaccard fuzzy matching
6
+ * v3: bi-encoder retrieval cross-encoder reranking
7
+ *
8
+ * Stage 1 (bi-encoder): score all memories independently using token overlap,
9
+ * bigram Jaccard, tool-name matching, and recency decay. Retrieve top-50.
10
+ * Stage 2 (cross-encoder): rerank the top-50 candidates by computing a
11
+ * field-weighted BM25 score that processes (query, lesson) jointly, then
12
+ * blend with the original bi-encoder score. Return top-maxResults.
7
13
  */
8
14
 
9
15
  const RECENCY_DECAY_DAYS = 30;
16
+ const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
10
17
 
11
18
  function retrieveRelevantLessons(toolName, actionContext, options = {}) {
12
19
  const { maxResults = 5, feedbackDir } = options;
13
20
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
21
+ const { rerankLessons } = require('./lesson-reranker');
14
22
  const pathMod = require('path');
15
23
  const paths = feedbackDir
16
24
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
@@ -21,24 +29,33 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
21
29
 
22
30
  const actionSig = buildActionSignature(toolName, actionContext);
23
31
 
24
- const scored = memories.map((mem) => ({
25
- ...mem,
26
- relevanceScore: scoreRelevance(mem, toolName, actionContext, actionSig),
27
- }));
28
-
29
- return scored
32
+ // Stage 1 — bi-encoder: score all memories independently, take top-50 candidates
33
+ const candidates = memories
34
+ .map((mem) => ({
35
+ ...mem,
36
+ relevanceScore: scoreRelevance(mem, toolName, actionContext, actionSig),
37
+ }))
30
38
  .filter((m) => m.relevanceScore > 0.1)
31
39
  .sort((a, b) => b.relevanceScore - a.relevanceScore)
32
- .slice(0, maxResults)
33
- .map((m) => ({
34
- id: m.id,
35
- title: m.title,
36
- content: m.content,
37
- signal: m.tags?.includes('negative') ? 'negative' : 'positive',
38
- rule: m.structuredRule || null,
39
- relevanceScore: m.relevanceScore,
40
- timestamp: m.timestamp,
41
- }));
40
+ .slice(0, RERANK_CANDIDATE_POOL);
41
+
42
+ if (candidates.length === 0) return [];
43
+
44
+ // Stage 2 — cross-encoder reranker: rerank candidates by joint (query, lesson) score
45
+ const reranked = rerankLessons(actionContext, candidates, {
46
+ topK: maxResults,
47
+ toolName,
48
+ });
49
+
50
+ return reranked.map((m) => ({
51
+ id: m.id,
52
+ title: m.title,
53
+ content: m.content,
54
+ signal: m.tags?.includes('negative') ? 'negative' : 'positive',
55
+ rule: m.structuredRule || null,
56
+ relevanceScore: m.rerankedScore ?? m.relevanceScore,
57
+ timestamp: m.timestamp,
58
+ }));
42
59
  }
43
60
 
44
61
  function buildActionSignature(toolName, actionContext) {
@@ -511,6 +511,16 @@ function searchLessons(query = '', options = {}) {
511
511
  return String(b.timestamp || '').localeCompare(String(a.timestamp || ''));
512
512
  });
513
513
 
514
+ // Cross-encoder reranking: when a query is present, rerank the top-50 bi-encoder
515
+ // candidates using field-weighted BM25 so the most relevant lessons surface first.
516
+ if (query && results.length > 1) {
517
+ const { rerankLessons } = require('./lesson-reranker');
518
+ const pool = results.slice(0, 50);
519
+ const tail = results.slice(50);
520
+ const reranked = rerankLessons(query, pool, { topK: pool.length });
521
+ results = [...reranked, ...tail];
522
+ }
523
+
514
524
  return {
515
525
  query: String(query || ''),
516
526
  limit,
@@ -630,10 +640,69 @@ function formatLessonSearchResults(payload) {
630
640
  return `${lines.join('\n')}\n`;
631
641
  }
632
642
 
643
+ /**
644
+ * Enrich lesson search results with Perplexity web context.
645
+ * Queries Perplexity for each top result's root cause to find external
646
+ * documentation, known issues, or community solutions.
647
+ *
648
+ * Requires PERPLEXITY_API_KEY. Returns original results unmodified if
649
+ * the API key is missing or calls fail.
650
+ *
651
+ * @param {object} searchPayload - Output from searchLessons()
652
+ * @param {object} [options]
653
+ * @param {number} [options.enrichLimit=3] - Max results to enrich
654
+ * @returns {Promise<object>} searchPayload with enriched results
655
+ */
656
+ async function enrichWithPerplexity(searchPayload, options = {}) {
657
+ const apiKey = process.env.PERPLEXITY_API_KEY;
658
+ if (!apiKey || !searchPayload.results || searchPayload.results.length === 0) {
659
+ return searchPayload;
660
+ }
661
+
662
+ const enrichLimit = Math.min(options.enrichLimit || 3, searchPayload.results.length);
663
+ const { PerplexityClient, normalizeSearchResults } = require('./perplexity-client');
664
+ const client = new PerplexityClient({ apiKey });
665
+
666
+ const enriched = await Promise.allSettled(
667
+ searchPayload.results.slice(0, enrichLimit).map(async (result) => {
668
+ const query = result.lesson.whatWentWrong || result.lesson.summary || result.title;
669
+ if (!query || query.length < 10) return result;
670
+
671
+ try {
672
+ const searchResults = await client.search({ query, maxResults: 3 });
673
+ const normalized = normalizeSearchResults(searchResults);
674
+ if (normalized.length > 0) {
675
+ result.perplexityContext = {
676
+ sources: normalized.slice(0, 3).map((s) => ({
677
+ title: s.title,
678
+ url: s.url,
679
+ snippet: s.snippet,
680
+ })),
681
+ enrichedAt: new Date().toISOString(),
682
+ };
683
+ }
684
+ } catch {
685
+ // Perplexity enrichment is best-effort; never block local results
686
+ }
687
+ return result;
688
+ })
689
+ );
690
+
691
+ for (let i = 0; i < enriched.length; i++) {
692
+ if (enriched[i].status === 'fulfilled') {
693
+ searchPayload.results[i] = enriched[i].value;
694
+ }
695
+ }
696
+
697
+ searchPayload.backend = searchPayload.backend + '+perplexity';
698
+ return searchPayload;
699
+ }
700
+
633
701
  module.exports = {
634
702
  parseLessonContent,
635
703
  resolveLessonPaths,
636
704
  searchLessons,
705
+ enrichWithPerplexity,
637
706
  formatLessonSearchResults,
638
707
  };
639
708
 
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_BASE_URL = 'https://api.perplexity.ai';
4
+ const DEFAULT_TIMEOUT_MS = 120000;
5
+
6
+ class PerplexityApiError extends Error {
7
+ constructor(message, details = {}) {
8
+ super(message);
9
+ this.name = 'PerplexityApiError';
10
+ this.status = details.status || null;
11
+ this.path = details.path || null;
12
+ this.body = details.body || null;
13
+ }
14
+ }
15
+
16
+ function redactSecrets(value) {
17
+ return String(value || '')
18
+ .replaceAll(/pplx-[A-Za-z0-9_-]+/g, 'pplx-***')
19
+ .replaceAll(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer ***');
20
+ }
21
+
22
+ function trimTrailingSlash(value) {
23
+ let text = String(value || '');
24
+ while (text.endsWith('/')) {
25
+ text = text.slice(0, -1);
26
+ }
27
+ return text;
28
+ }
29
+
30
+ function ensureLeadingSlash(value) {
31
+ return String(value || '').startsWith('/') ? String(value) : `/${value}`;
32
+ }
33
+
34
+ function buildUrl(baseUrl, path) {
35
+ return `${trimTrailingSlash(baseUrl || DEFAULT_BASE_URL)}${ensureLeadingSlash(path)}`;
36
+ }
37
+
38
+ function timeoutSignal(timeoutMs) {
39
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
40
+ return AbortSignal.timeout(timeoutMs);
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ function parseJsonSafe(text) {
46
+ if (!text) return {};
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch {
50
+ return { raw: text };
51
+ }
52
+ }
53
+
54
+ function normalizeSearchResults(response) {
55
+ const candidates =
56
+ response?.results ||
57
+ response?.data ||
58
+ response?.output?.flatMap((item) => item.results || []) ||
59
+ [];
60
+
61
+ return candidates
62
+ .filter((item) => item && typeof item === 'object')
63
+ .map((item, index) => ({
64
+ rank: Number(item.rank || item.id || index + 1),
65
+ title: String(item.title || item.name || 'Untitled result'),
66
+ url: String(item.url || item.link || ''),
67
+ snippet: String(item.snippet || item.summary || item.text || ''),
68
+ source: String(item.source || ''),
69
+ date: item.date || item.published_date || item.last_updated || null,
70
+ }))
71
+ .filter((item) => item.url);
72
+ }
73
+
74
+ function extractChatText(response) {
75
+ return String(response?.choices?.[0]?.message?.content || '');
76
+ }
77
+
78
+ function extractAgentText(response) {
79
+ if (typeof response?.output_text === 'string') return response.output_text;
80
+ if (typeof response?.text === 'string') return response.text;
81
+ const output = Array.isArray(response?.output) ? response.output : [];
82
+ return output.flatMap(extractOutputItemText).join('\n').trim();
83
+ }
84
+
85
+ function extractOutputItemText(item) {
86
+ const textParts = [];
87
+ appendString(textParts, item?.content);
88
+ if (Array.isArray(item?.content)) {
89
+ textParts.push(...item.content.flatMap(extractContentPartText));
90
+ }
91
+ appendString(textParts, item?.text);
92
+ return textParts;
93
+ }
94
+
95
+ function extractContentPartText(part) {
96
+ const textParts = [];
97
+ appendString(textParts, part?.text);
98
+ appendString(textParts, part?.content);
99
+ return textParts;
100
+ }
101
+
102
+ function appendString(target, value) {
103
+ if (typeof value === 'string') target.push(value);
104
+ }
105
+
106
+ function extractCitations(response) {
107
+ const citations = response?.citations || response?.search_results || [];
108
+ return Array.isArray(citations) ? citations : [];
109
+ }
110
+
111
+ class PerplexityClient {
112
+ constructor(options = {}) {
113
+ this.apiKey = options.apiKey || process.env.PERPLEXITY_API_KEY || '';
114
+ this.baseUrl = options.baseUrl || process.env.PERPLEXITY_BASE_URL || DEFAULT_BASE_URL;
115
+ this.fetchFn = options.fetchFn || globalThis.fetch;
116
+ this.timeoutMs = Number(options.timeoutMs || process.env.PERPLEXITY_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
117
+
118
+ if (typeof this.fetchFn !== 'function') {
119
+ throw new PerplexityApiError('fetch is not available in this Node runtime');
120
+ }
121
+ }
122
+
123
+ hasApiKey() {
124
+ return Boolean(this.apiKey);
125
+ }
126
+
127
+ async requestJson(path, body, options = {}) {
128
+ if (!this.apiKey) {
129
+ throw new PerplexityApiError('PERPLEXITY_API_KEY is required for live Perplexity calls', { path });
130
+ }
131
+
132
+ const headers = {
133
+ Authorization: `Bearer ${this.apiKey}`,
134
+ 'Content-Type': 'application/json',
135
+ };
136
+ if (options.headers) {
137
+ Object.assign(headers, options.headers);
138
+ }
139
+
140
+ const response = await this.fetchFn(buildUrl(this.baseUrl, path), {
141
+ method: 'POST',
142
+ headers,
143
+ body: JSON.stringify(body),
144
+ signal: timeoutSignal(options.timeoutMs || this.timeoutMs),
145
+ });
146
+
147
+ const text = await response.text();
148
+ const json = parseJsonSafe(text);
149
+ if (!response.ok) {
150
+ throw new PerplexityApiError(`Perplexity API ${response.status} on ${path}: ${redactSecrets(text)}`, {
151
+ status: response.status,
152
+ path,
153
+ body: json,
154
+ });
155
+ }
156
+ return json;
157
+ }
158
+
159
+ chatCompletion({ model = 'sonar-pro', messages, options = {} }) {
160
+ if (!Array.isArray(messages) || messages.length === 0) {
161
+ throw new PerplexityApiError('chatCompletion requires at least one message');
162
+ }
163
+ return this.requestJson('/chat/completions', {
164
+ model,
165
+ messages,
166
+ ...options,
167
+ });
168
+ }
169
+
170
+ search({ query, maxResults = 5, maxTokensPerPage = 1024, options = {} }) {
171
+ if (!query) throw new PerplexityApiError('search requires a query');
172
+ return this.requestJson('/search', {
173
+ query,
174
+ max_results: maxResults,
175
+ max_tokens_per_page: maxTokensPerPage,
176
+ ...options,
177
+ });
178
+ }
179
+
180
+ agentResponse({
181
+ model = 'openai/gpt-5.4',
182
+ input,
183
+ instructions,
184
+ tools,
185
+ maxOutputTokens,
186
+ options = {},
187
+ }) {
188
+ if (!input) throw new PerplexityApiError('agentResponse requires input');
189
+ return this.requestJson('/v1/agent', {
190
+ model,
191
+ input,
192
+ ...(instructions ? { instructions } : {}),
193
+ ...(tools ? { tools } : {}),
194
+ ...(maxOutputTokens ? { max_output_tokens: maxOutputTokens } : {}),
195
+ ...options,
196
+ });
197
+ }
198
+ }
199
+
200
+ module.exports = {
201
+ DEFAULT_BASE_URL,
202
+ PerplexityApiError,
203
+ PerplexityClient,
204
+ buildUrl,
205
+ extractAgentText,
206
+ extractChatText,
207
+ extractCitations,
208
+ normalizeSearchResults,
209
+ redactSecrets,
210
+ };