thumbgate 1.29.2 → 1.31.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.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Document pipeline for ThumbGate RAG: parse → clean → chunk → metadata.
6
+ *
7
+ * Primary corpus is feedback/memory/skill-pack text. Markdown operator docs are
8
+ * supported. PDF/binary is explicitly rejected with a clear error (no fake parse).
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+ const path = require('node:path');
13
+
14
+ const DEFAULT_CHUNK_CHARS = 900;
15
+ const DEFAULT_CHUNK_OVERLAP = 120;
16
+ const MIN_CHUNK_CHARS = 40;
17
+ const PLACEHOLDER_TOKENS = new Set([
18
+ 'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
19
+ 'good', 'bad', 'ok', 'nice', 'verify', 'test', 'testing',
20
+ ]);
21
+
22
+ function sha1(text) {
23
+ return crypto.createHash('sha1').update(String(text || '')).digest('hex').slice(0, 16);
24
+ }
25
+
26
+ function looksLikePlaceholder(text) {
27
+ const t = String(text || '').trim();
28
+ if (!t || t.length < 20) return true;
29
+ return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
30
+ }
31
+
32
+ function sanitizeText(text) {
33
+ return String(text || '')
34
+ .replaceAll('\r\n', '\n')
35
+ .split('\n')
36
+ .map((line) => line.trimEnd())
37
+ .join('\n')
38
+ .replace(/\n{3,}/g, '\n\n')
39
+ .replace(/[^\S\n]{2,}/g, ' ')
40
+ .trim();
41
+ }
42
+
43
+ function tryLoadSanitizer() {
44
+ try {
45
+ return require('./feedback-sanitizer');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Parse a single source into zero or more raw records.
53
+ * @param {{ type?: string, id?: string, title?: string, content?: string, text?: string, signal?: string, tags?: string[], metadata?: object, source?: string }} input
54
+ */
55
+ function parseDocument(input = {}) {
56
+ const errors = [];
57
+ const type = String(input.type || input.mediaType || 'text').toLowerCase();
58
+
59
+ if (type === 'pdf' || type === 'application/pdf' || /\.pdf$/i.test(input.path || input.id || '')) {
60
+ return {
61
+ ok: false,
62
+ records: [],
63
+ errors: [{ code: 'pdf_not_supported', message: 'PDF ingest is not implemented; convert to markdown/text first.' }],
64
+ };
65
+ }
66
+
67
+ if (type === 'jsonl' || (typeof input.content === 'string' && input.content.includes('\n') && input.content.trim().startsWith('{'))) {
68
+ const lines = String(input.content || '').split('\n').filter((l) => l.trim());
69
+ const records = [];
70
+ for (let i = 0; i < lines.length; i++) {
71
+ try {
72
+ const row = JSON.parse(lines[i]);
73
+ records.push({
74
+ id: row.id || `jsonl-${i}-${sha1(lines[i])}`,
75
+ title: row.title || row.context || '',
76
+ content: row.content || row.whatWentWrong || row.whatWorked || row.context || '',
77
+ signal: row.signal || row.feedback || '',
78
+ tags: Array.isArray(row.tags) ? row.tags : [],
79
+ metadata: { ...(row.metadata || {}), line: i, source: input.source || 'jsonl' },
80
+ source: input.source || 'jsonl',
81
+ });
82
+ } catch (err) {
83
+ errors.push({ code: 'jsonl_parse_error', line: i, message: err.message });
84
+ }
85
+ }
86
+ return {
87
+ ok: errors.length === 0 || records.length > 0,
88
+ records,
89
+ errors,
90
+ parse_success_rate: lines.length ? records.length / lines.length : 0,
91
+ };
92
+ }
93
+
94
+ if (type === 'markdown' || type === 'md' || type === 'text' || !type) {
95
+ const raw = String(input.content || input.text || '');
96
+ if (raw.trim()) {
97
+ // Split on markdown ## headings when present; else single record.
98
+ const sections = splitMarkdownSections(raw, input.title || input.id || 'document');
99
+ const records = sections.map((sec, i) => ({
100
+ id: input.id ? `${input.id}#${i}` : `doc-${sha1(sec.title + sec.content)}`,
101
+ title: sec.title,
102
+ content: sec.content,
103
+ signal: input.signal || '',
104
+ tags: Array.isArray(input.tags) ? [...input.tags] : [],
105
+ metadata: { ...(input.metadata || {}), sectionIndex: i, source: input.source || 'markdown' },
106
+ source: input.source || 'markdown',
107
+ }));
108
+ return { ok: true, records, errors, parse_success_rate: 1 };
109
+ }
110
+ // Empty content/text: fall through to memory-shaped fields (context-only feedback).
111
+ }
112
+
113
+ // Structured memory/lesson object (native feedback often has context without content/text)
114
+ if (input.title || input.content || input.context || input.whatWentWrong || input.whatWorked) {
115
+ const body = input.content
116
+ || input.context
117
+ || input.whatWentWrong
118
+ || input.whatWorked
119
+ || '';
120
+ if (!String(body).trim() && !input.title) {
121
+ return { ok: false, records: [], errors: [{ code: 'empty_document', message: 'No content' }] };
122
+ }
123
+ return {
124
+ ok: true,
125
+ records: [{
126
+ id: input.id || `rec-${sha1(input.title || body || '')}`,
127
+ title: input.title || String(body).slice(0, 80),
128
+ content: body,
129
+ signal: input.signal || input.feedback || '',
130
+ tags: Array.isArray(input.tags) ? input.tags : [],
131
+ metadata: { ...(input.metadata || {}), source: input.source || 'memory' },
132
+ source: input.source || 'memory',
133
+ }],
134
+ errors,
135
+ parse_success_rate: 1,
136
+ };
137
+ }
138
+
139
+ if (type === 'markdown' || type === 'md' || type === 'text' || !type) {
140
+ return { ok: false, records: [], errors: [{ code: 'empty_document', message: 'No content' }] };
141
+ }
142
+
143
+ return {
144
+ ok: false,
145
+ records: [],
146
+ errors: [{ code: 'unknown_document_type', message: `Unsupported type: ${type}` }],
147
+ };
148
+ }
149
+
150
+ function splitMarkdownSections(raw, fallbackTitle) {
151
+ const text = String(raw || '');
152
+ if (!/^#{1,3}\s+/m.test(text)) {
153
+ return [{ title: fallbackTitle, content: text }];
154
+ }
155
+ const parts = text.split(/(?=^#{1,3}\s+)/m).map((p) => p.trim()).filter(Boolean);
156
+ return parts.map((part) => {
157
+ const lines = part.split('\n');
158
+ const heading = lines.shift() || '';
159
+ const markerEnd = heading.indexOf(' ');
160
+ if (markerEnd < 1 || markerEnd > 3) return { title: fallbackTitle, content: part };
161
+ return {
162
+ title: heading.slice(markerEnd + 1).trim(),
163
+ content: lines.join('\n').trim(),
164
+ };
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Clean a parsed record. Returns null if it should be dropped.
170
+ */
171
+ function cleanRecord(record, options = {}) {
172
+ if (!record) return { kept: false, reason: 'null_record', record: null };
173
+ const sanitizer = tryLoadSanitizer();
174
+ const title = sanitizeText(record.title);
175
+ const content = sanitizeText(record.content);
176
+ const combined = `${title}\n${content}`.trim();
177
+
178
+ if (!combined) {
179
+ return { kept: false, reason: 'empty_after_clean', record: null };
180
+ }
181
+
182
+ if (sanitizer && typeof sanitizer.looksLikeTransportBlob === 'function') {
183
+ if (
184
+ sanitizer.looksLikeTransportBlob(title)
185
+ || sanitizer.looksLikeTransportBlob(content)
186
+ || sanitizer.looksLikeTransportBlob(combined)
187
+ ) {
188
+ return { kept: false, reason: 'transport_blob', record: null };
189
+ }
190
+ }
191
+
192
+ if (!options.keepPlaceholders && looksLikePlaceholder(combined)) {
193
+ return { kept: false, reason: 'placeholder', record: null };
194
+ }
195
+
196
+ return {
197
+ kept: true,
198
+ reason: null,
199
+ record: {
200
+ ...record,
201
+ title,
202
+ content,
203
+ },
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Chunk long text with overlap. Short records stay single-chunk.
209
+ */
210
+ function chunkTextWithOffsets(text, options = {}) {
211
+ const maxChars = Math.max(MIN_CHUNK_CHARS, Number(options.maxChars) || DEFAULT_CHUNK_CHARS);
212
+ const overlap = Math.max(0, Math.min(maxChars - 1, Number(options.overlap) || DEFAULT_CHUNK_OVERLAP));
213
+ const raw = String(text || '').trim();
214
+ if (!raw) return [];
215
+ if (raw.length <= maxChars) return [{ content: raw, startChar: 0, endChar: raw.length }];
216
+
217
+ const chunks = [];
218
+ let start = 0;
219
+ while (start < raw.length) {
220
+ let end = Math.min(raw.length, start + maxChars);
221
+ if (end < raw.length) {
222
+ // Prefer break on paragraph/sentence boundary in the last 20% of the window.
223
+ const window = raw.slice(start, end);
224
+ const searchFrom = Math.floor(window.length * 0.8);
225
+ const para = window.lastIndexOf('\n\n');
226
+ const sent = window.lastIndexOf('. ');
227
+ let breakAt = -1;
228
+ if (para >= searchFrom) breakAt = para + 2;
229
+ else if (sent >= searchFrom) breakAt = sent + 2;
230
+ if (breakAt > 0) end = start + breakAt;
231
+ }
232
+ const window = raw.slice(start, end);
233
+ const leftTrim = window.length - window.trimStart().length;
234
+ const rightTrim = window.length - window.trimEnd().length;
235
+ const piece = window.trim();
236
+ if (piece) {
237
+ chunks.push({
238
+ content: piece,
239
+ startChar: start + leftTrim,
240
+ endChar: end - rightTrim,
241
+ });
242
+ }
243
+ if (end >= raw.length) break;
244
+ start = Math.max(0, end - overlap);
245
+ if (start >= end) start = end; // safety
246
+ }
247
+ return chunks;
248
+ }
249
+
250
+ function chunkText(text, options = {}) {
251
+ return chunkTextWithOffsets(text, options).map((chunk) => chunk.content);
252
+ }
253
+
254
+ function chunkRecord(record, options = {}) {
255
+ const sourceText = `${record.title ? `${record.title}\n\n` : ''}${record.content || ''}`;
256
+ const pieces = chunkTextWithOffsets(sourceText, options);
257
+ if (pieces.length === 0) return [];
258
+ const versionHash = crypto.createHash('sha256').update(sourceText).digest('hex');
259
+ return pieces.map((piece, i) => ({
260
+ ...record,
261
+ id: pieces.length === 1 ? record.id : `${record.id}::c${i}`,
262
+ content: piece.content,
263
+ metadata: {
264
+ ...(record.metadata || {}),
265
+ chunkIndex: i,
266
+ chunkTotal: pieces.length,
267
+ parentId: record.id,
268
+ startChar: piece.startChar,
269
+ endChar: piece.endChar,
270
+ contentHash: crypto.createHash('sha256').update(piece.content).digest('hex'),
271
+ parentVersionHash: versionHash,
272
+ },
273
+ }));
274
+ }
275
+
276
+ const TOOL_HINTS = [
277
+ 'bash', 'shell', 'git', 'npm', 'node', 'curl', 'docker', 'railway',
278
+ 'stripe', 'prisma', 'sqlite', 'postgres', 'write', 'edit', 'read',
279
+ ];
280
+
281
+ function extractPathCandidates(content) {
282
+ const boundaryChars = new Set(['"', "'", '`', '(', ')', '[', ']', '{', '}', '<', '>', ',', ':', ';']);
283
+ return String(content || '').split(/\s+/).map((rawToken) => {
284
+ let start = 0;
285
+ let end = rawToken.length;
286
+ while (start < end && boundaryChars.has(rawToken[start])) start += 1;
287
+ while (end > start && boundaryChars.has(rawToken[end - 1])) end -= 1;
288
+ return rawToken.slice(start, end);
289
+ }).filter((candidate) => {
290
+ if (!candidate.includes('/') || candidate.length <= 3 || candidate.length >= 200) return false;
291
+ return candidate.split('/').filter(Boolean).length >= 2;
292
+ });
293
+ }
294
+
295
+ function extractMetadata(record) {
296
+ const content = `${record.title || ''}\n${record.content || ''}`;
297
+ const lower = content.toLowerCase();
298
+ const tags = new Set(Array.isArray(record.tags) ? record.tags.map(String) : []);
299
+ const toolsUsed = new Set(Array.isArray(record.metadata?.toolsUsed) ? record.metadata.toolsUsed : []);
300
+ const filesInvolved = new Set(Array.isArray(record.metadata?.filesInvolved) ? record.metadata.filesInvolved : []);
301
+
302
+ for (const hint of TOOL_HINTS) {
303
+ if (lower.includes(hint)) {
304
+ toolsUsed.add(hint);
305
+ tags.add(hint);
306
+ }
307
+ }
308
+
309
+ const paths = extractPathCandidates(content);
310
+ for (const p of paths.slice(0, 12)) {
311
+ if (p.length > 3 && p.length < 200) filesInvolved.add(p);
312
+ }
313
+
314
+ let signal = record.signal || '';
315
+ if (!signal) {
316
+ if (/\b(never|don't|do not|blocked|mistake|fail|wrong)\b/i.test(content)) signal = 'negative';
317
+ else if (/\b(always|worked|success|prefer)\b/i.test(content)) signal = 'positive';
318
+ }
319
+
320
+ const domain = record.metadata?.domain
321
+ || (lower.includes('stripe') || lower.includes('payment') ? 'stripe-integration'
322
+ : lower.includes('railway') || lower.includes('deploy') ? 'railway-deploy'
323
+ : lower.includes('migration') || lower.includes('database') || lower.includes('prisma') ? 'database-migration'
324
+ : 'general');
325
+
326
+ return {
327
+ ...record,
328
+ signal,
329
+ tags: [...tags],
330
+ metadata: {
331
+ ...(record.metadata || {}),
332
+ domain,
333
+ toolsUsed: [...toolsUsed],
334
+ filesInvolved: [...filesInvolved],
335
+ contentChars: content.length,
336
+ },
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Full pipeline: documents → cleaned chunks with metadata.
342
+ * @returns {{ documents, chunks, metrics }}
343
+ */
344
+ function runDocumentPipeline(inputs = [], options = {}) {
345
+ const documents = [];
346
+ const chunks = [];
347
+ let parseAttempts = 0;
348
+ let parseSuccesses = 0;
349
+ let parseErrors = 0;
350
+ let cleanKept = 0;
351
+ let cleanRejected = 0;
352
+ let placeholderRejects = 0;
353
+ const sourceMix = {};
354
+
355
+ const list = Array.isArray(inputs) ? inputs : [inputs];
356
+ for (const input of list) {
357
+ parseAttempts += 1;
358
+ const parsed = parseDocument(input);
359
+ parseErrors += (parsed.errors || []).length;
360
+ if (parsed.ok && parsed.records.length) parseSuccesses += 1;
361
+
362
+ for (const rec of parsed.records || []) {
363
+ const cleaned = cleanRecord(rec, options);
364
+ if (!cleaned.kept) {
365
+ cleanRejected += 1;
366
+ if (cleaned.reason === 'placeholder') placeholderRejects += 1;
367
+ continue;
368
+ }
369
+ cleanKept += 1;
370
+ const withMeta = extractMetadata(cleaned.record);
371
+ documents.push(withMeta);
372
+ const src = withMeta.source || 'unknown';
373
+ sourceMix[src] = (sourceMix[src] || 0) + 1;
374
+
375
+ const parts = chunkRecord(withMeta, options);
376
+ for (const part of parts) {
377
+ chunks.push(extractMetadata(part));
378
+ }
379
+ }
380
+ }
381
+
382
+ const chunkChars = chunks.map((c) => String(c.content || '').length);
383
+ const sourceChars = documents.reduce((s, d) => s + String(d.content || '').length, 0);
384
+ const chunkedChars = chunkChars.reduce((s, n) => s + n, 0);
385
+
386
+ const metrics = {
387
+ corpus_document_count: documents.length,
388
+ corpus_source_mix: sourceMix,
389
+ parse_success_rate: parseAttempts ? parseSuccesses / parseAttempts : 0,
390
+ parse_error_count: parseErrors,
391
+ records_emitted: documents.length,
392
+ clean_reject_rate: (cleanKept + cleanRejected) ? cleanRejected / (cleanKept + cleanRejected) : 0,
393
+ clean_kept_rate: (cleanKept + cleanRejected) ? cleanKept / (cleanKept + cleanRejected) : 0,
394
+ placeholder_reject_count: placeholderRejects,
395
+ chunk_count: chunks.length,
396
+ avg_chunk_chars: chunkChars.length ? Math.round(chunkChars.reduce((a, b) => a + b, 0) / chunkChars.length) : 0,
397
+ max_chunk_chars: chunkChars.length ? Math.max(...chunkChars) : 0,
398
+ chunk_coverage_ratio: sourceChars ? Math.min(1, chunkedChars / sourceChars) : 0,
399
+ metadata_field_fill_rate: documents.length
400
+ ? documents.filter((d) => d.tags?.length || d.metadata?.toolsUsed?.length).length / documents.length
401
+ : 0,
402
+ records_with_tags_rate: documents.length
403
+ ? documents.filter((d) => Array.isArray(d.tags) && d.tags.length > 0).length / documents.length
404
+ : 0,
405
+ records_with_signal_rate: documents.length
406
+ ? documents.filter((d) => d.signal).length / documents.length
407
+ : 0,
408
+ no_transport_blob_rate: 1, // transport blobs rejected in clean
409
+ };
410
+
411
+ return { documents, chunks, metrics };
412
+ }
413
+
414
+ /**
415
+ * Build eval/seed documents from skill packs so retrieval has a real corpus.
416
+ */
417
+ function skillPacksToDocuments() {
418
+ const docs = [];
419
+ try {
420
+ const mod = require('./skill-packs');
421
+ const listed = typeof mod.listSkillPacks === 'function' ? mod.listSkillPacks() : [];
422
+ for (const entry of listed) {
423
+ const name = entry.name || entry.id;
424
+ const pack = typeof mod.getSkillPack === 'function' ? mod.getSkillPack(name) : entry;
425
+ if (!pack) continue;
426
+ const rules = pack.rules || [];
427
+ const triggers = pack.triggers || entry.triggers || [];
428
+ for (let i = 0; i < rules.length; i++) {
429
+ const rule = rules[i];
430
+ docs.push({
431
+ type: 'text',
432
+ id: `skill:${name}:${i}`,
433
+ title: `${name}: ${String(rule).slice(0, 80)}`,
434
+ content: rule,
435
+ tags: [name, 'skill_pack', ...triggers.slice(0, 6)],
436
+ signal: /NEVER|don't|do not/i.test(rule) ? 'negative' : 'positive',
437
+ source: 'skill_pack',
438
+ metadata: { domain: name },
439
+ });
440
+ }
441
+ }
442
+ } catch {
443
+ // no skill packs available
444
+ }
445
+ return docs;
446
+ }
447
+
448
+ module.exports = {
449
+ DEFAULT_CHUNK_CHARS,
450
+ DEFAULT_CHUNK_OVERLAP,
451
+ parseDocument,
452
+ cleanRecord,
453
+ chunkText,
454
+ chunkTextWithOffsets,
455
+ chunkRecord,
456
+ extractMetadata,
457
+ runDocumentPipeline,
458
+ skillPacksToDocuments,
459
+ looksLikePlaceholder,
460
+ sanitizeText,
461
+ };