wayfind 2.0.28 → 2.0.30

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.
@@ -303,7 +303,28 @@ async function pull(config, since) {
303
303
  highlights.push(`${failedCount} CI failure(s)`);
304
304
  }
305
305
 
306
- repoHighlights.push({ repo: repoStr, openPRs, mergedPRs, highlights });
306
+ repoHighlights.push({
307
+ repo: repoStr,
308
+ openPRs,
309
+ mergedPRs,
310
+ highlights,
311
+ topPRs: data.prs.slice(0, 5).map((pr) => ({
312
+ number: pr.number,
313
+ title: pr.title,
314
+ author: pr.user?.login || pr.user?.name || 'unknown',
315
+ state: pr.merged_at ? 'merged' : pr.state,
316
+ })),
317
+ topIssues: data.issues.slice(0, 5).map((iss) => ({
318
+ number: iss.number,
319
+ title: iss.title,
320
+ labels: (iss.labels || []).map((l) => (typeof l === 'string' ? l : l.name)).filter(Boolean),
321
+ state: iss.state,
322
+ })),
323
+ failedRuns: failed.map((r) => ({
324
+ name: r.name || r.workflow?.name || 'unknown',
325
+ branch: r.head_branch || '',
326
+ })),
327
+ });
307
328
  }
308
329
 
309
330
  // Generate rollup summary
@@ -489,6 +510,21 @@ function generateSummaryMarkdown(
489
510
  for (const h of rh.highlights) {
490
511
  lines.push(`- ${h}`);
491
512
  }
513
+ if (rh.topPRs && rh.topPRs.length > 0) {
514
+ const prItems = rh.topPRs.map((pr) => `#${pr.number} "${pr.title}" (${pr.author}, ${pr.state})`);
515
+ lines.push(`**PRs:** ${prItems.join(' | ')}`);
516
+ }
517
+ if (rh.topIssues && rh.topIssues.length > 0) {
518
+ const issueItems = rh.topIssues.map((iss) => {
519
+ const labels = iss.labels && iss.labels.length > 0 ? ` [${iss.labels.join(', ')}]` : '';
520
+ return `#${iss.number} "${iss.title}"${labels} (${iss.state})`;
521
+ });
522
+ lines.push(`**Issues:** ${issueItems.join(' | ')}`);
523
+ }
524
+ if (rh.failedRuns && rh.failedRuns.length > 0) {
525
+ const runItems = rh.failedRuns.map((r) => `${r.name}${r.branch ? ' (' + r.branch + ')' : ''}`);
526
+ lines.push(`**Failed CI:** ${runItems.join(' | ')}`);
527
+ }
492
528
  lines.push('');
493
529
  }
494
530
 
@@ -212,6 +212,22 @@ function generateEntryId(date, repo, title) {
212
212
  return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
213
213
  }
214
214
 
215
+ /**
216
+ * Compute a quality score for an entry (0-3).
217
+ * +1 if has reasoning (explains WHY)
218
+ * +1 if has alternatives (what was rejected)
219
+ * +1 if substantive content (>500 chars)
220
+ * @param {Object} entry - Entry metadata
221
+ * @returns {number} 0-3
222
+ */
223
+ function computeQualityScore(entry) {
224
+ let score = 0;
225
+ if (entry.hasReasoning) score++;
226
+ if (entry.hasAlternatives) score++;
227
+ if ((entry.contentLength || 0) > 500) score++;
228
+ return score;
229
+ }
230
+
215
231
  /**
216
232
  * Build the text content for embedding from an entry's fields.
217
233
  * @param {Object} entry - Entry with date, repo, title, fields
@@ -329,7 +345,7 @@ async function indexJournals(options = {}) {
329
345
  const content = buildContent({ ...entry, date, author });
330
346
  const hash = contentHash(content);
331
347
 
332
- newEntries[id] = {
348
+ const entryMeta = {
333
349
  date,
334
350
  repo: entry.repo,
335
351
  title: entry.title,
@@ -339,8 +355,12 @@ async function indexJournals(options = {}) {
339
355
  contentLength: content.length,
340
356
  tags: extractTags(entry),
341
357
  hasEmbedding: false,
358
+ hasReasoning: false,
359
+ hasAlternatives: false,
342
360
  _content: content, // temporary, not saved to index
343
361
  };
362
+ entryMeta.qualityScore = computeQualityScore(entryMeta);
363
+ newEntries[id] = entryMeta;
344
364
  }
345
365
  }
346
366
 
@@ -744,39 +764,50 @@ function getEntryContent(entryId, options = {}) {
744
764
  // ── Signal entries ──────────────────────────────────────────────────────
745
765
  if (entry.source === 'signal') {
746
766
  if (!signalsDir) return null;
747
- // entry.repo is like 'signals/github' — extract the channel
748
- const channel = (entry.repo || '').replace(/^signals\//, '');
749
- if (!channel) return null;
750
-
751
- const channelDir = path.join(signalsDir, channel);
752
- if (!fs.existsSync(channelDir)) return null;
753
-
754
- // Find a matching file in the channel directory
755
- // Try date-based filename first, then scan for any file containing the title
756
- const dateCandidates = [
757
- path.join(channelDir, `${entry.date}.md`),
758
- path.join(channelDir, `${entry.date}-summary.md`),
759
- ];
760
- for (const filePath of dateCandidates) {
767
+ const repo = entry.repo || '';
768
+
769
+ // Determine file location based on repo format:
770
+ // - 'signals/channel' (summary files) → signalsDir/channel/
771
+ // - 'owner/repo' (per-repo files) → find the channel dir containing owner/repo/
772
+ let searchDirs = [];
773
+ if (repo.startsWith('signals/')) {
774
+ const channel = repo.replace(/^signals\//, '');
775
+ searchDirs = [path.join(signalsDir, channel)];
776
+ } else {
777
+ // Per-repo entry: search all channel dirs for owner/repo subdirectory
761
778
  try {
762
- return fs.readFileSync(filePath, 'utf8');
763
- } catch {
764
- // Try next candidate
765
- }
779
+ const channels = fs.readdirSync(signalsDir, { withFileTypes: true })
780
+ .filter(d => d.isDirectory()).map(d => d.name);
781
+ for (const ch of channels) {
782
+ const repoDir = path.join(signalsDir, ch, repo);
783
+ if (fs.existsSync(repoDir)) {
784
+ searchDirs.push(repoDir);
785
+ }
786
+ }
787
+ } catch { /* skip */ }
766
788
  }
767
789
 
768
- // Scan channel dir for files matching the date
769
- try {
770
- const files = fs.readdirSync(channelDir).filter(f => f.endsWith('.md') && f.includes(entry.date));
771
- for (const file of files) {
790
+ for (const dir of searchDirs) {
791
+ if (!fs.existsSync(dir)) continue;
792
+ // Try date-based filename first, then summary, then scan
793
+ const dateCandidates = [
794
+ path.join(dir, `${entry.date}.md`),
795
+ path.join(dir, `${entry.date}-summary.md`),
796
+ ];
797
+ for (const filePath of dateCandidates) {
772
798
  try {
773
- return fs.readFileSync(path.join(channelDir, file), 'utf8');
774
- } catch {
775
- continue;
776
- }
799
+ return fs.readFileSync(filePath, 'utf8');
800
+ } catch { /* try next */ }
777
801
  }
778
- } catch {
779
- // Channel dir not readable
802
+ // Scan for files matching the date
803
+ try {
804
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && f.includes(entry.date));
805
+ for (const file of files) {
806
+ try {
807
+ return fs.readFileSync(path.join(dir, file), 'utf8');
808
+ } catch { continue; }
809
+ }
810
+ } catch { /* dir not readable */ }
780
811
  }
781
812
 
782
813
  return null;
@@ -1346,7 +1377,7 @@ async function indexConversations(options = {}) {
1346
1377
 
1347
1378
  const hash = contentHash(content);
1348
1379
 
1349
- existingIndex.entries[id] = {
1380
+ const convEntry = {
1350
1381
  date,
1351
1382
  repo: transcript.repo,
1352
1383
  title: decision.title,
@@ -1361,6 +1392,8 @@ async function indexConversations(options = {}) {
1361
1392
  hasAlternatives: !!decision.has_alternatives,
1362
1393
  _content: content,
1363
1394
  };
1395
+ convEntry.qualityScore = computeQualityScore(convEntry);
1396
+ existingIndex.entries[id] = convEntry;
1364
1397
 
1365
1398
  if (doEmbeddings) {
1366
1399
  try {
@@ -1653,16 +1686,42 @@ async function indexSignals(options = {}) {
1653
1686
 
1654
1687
  for (const channel of channels) {
1655
1688
  const channelDir = path.join(signalsDir, channel);
1656
- let files;
1689
+
1690
+ // Collect all .md files: channel root + recursive owner/repo subdirectories
1691
+ const signalFiles = [];
1657
1692
  try {
1658
- files = fs.readdirSync(channelDir).filter(f => f.endsWith('.md')).sort();
1693
+ const entries = fs.readdirSync(channelDir, { withFileTypes: true });
1694
+ // Channel-root .md files (summaries like YYYY-MM-DD-summary.md)
1695
+ for (const e of entries) {
1696
+ if (e.isFile() && e.name.endsWith('.md')) {
1697
+ signalFiles.push({ filePath: path.join(channelDir, e.name), file: e.name, repo: 'signals/' + channel });
1698
+ }
1699
+ }
1700
+ // Walk owner/repo subdirectories (e.g., github/acme-corp/web-api/)
1701
+ for (const ownerEntry of entries) {
1702
+ if (!ownerEntry.isDirectory()) continue;
1703
+ const ownerDir = path.join(channelDir, ownerEntry.name);
1704
+ let repoEntries;
1705
+ try { repoEntries = fs.readdirSync(ownerDir, { withFileTypes: true }); } catch { continue; }
1706
+ for (const repoEntry of repoEntries) {
1707
+ if (!repoEntry.isDirectory()) continue;
1708
+ const repoDir = path.join(ownerDir, repoEntry.name);
1709
+ const repoStr = `${ownerEntry.name}/${repoEntry.name}`;
1710
+ let repoFiles;
1711
+ try { repoFiles = fs.readdirSync(repoDir).filter(f => f.endsWith('.md')); } catch { continue; }
1712
+ for (const f of repoFiles) {
1713
+ signalFiles.push({ filePath: path.join(repoDir, f), file: f, repo: repoStr });
1714
+ }
1715
+ }
1716
+ }
1659
1717
  } catch {
1660
1718
  continue;
1661
1719
  }
1662
1720
 
1663
- for (const file of files) {
1721
+ signalFiles.sort((a, b) => a.file.localeCompare(b.file));
1722
+
1723
+ for (const { filePath, file, repo } of signalFiles) {
1664
1724
  stats.fileCount++;
1665
- const filePath = path.join(channelDir, file);
1666
1725
  let content;
1667
1726
  try {
1668
1727
  content = fs.readFileSync(filePath, 'utf8');
@@ -1670,16 +1729,17 @@ async function indexSignals(options = {}) {
1670
1729
  continue;
1671
1730
  }
1672
1731
 
1673
- // Extract date from filename (YYYY-MM-DD.md) or fall back to filename
1674
- const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
1732
+ // Extract date from filename (YYYY-MM-DD.md or YYYY-MM-DD-summary.md) or fall back to filename
1733
+ const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})/);
1675
1734
  const date = dateMatch ? dateMatch[1] : file.replace(/\.md$/, '');
1676
1735
 
1677
1736
  // Extract title from first # heading, or fall back to filename
1678
1737
  const titleMatch = content.match(/^#\s+(.+)$/m);
1679
1738
  const title = titleMatch ? titleMatch[1].trim() : file.replace(/\.md$/, '');
1680
1739
 
1681
- // Extract tags: channel name + any ## section headings
1740
+ // Extract tags: channel name + repo + any ## section headings
1682
1741
  const tags = [channel];
1742
+ if (repo !== 'signals/' + channel) tags.push(repo);
1683
1743
  const sectionRe = /^##\s+(.+)$/gm;
1684
1744
  let sectionMatch;
1685
1745
  while ((sectionMatch = sectionRe.exec(content)) !== null) {
@@ -1689,7 +1749,6 @@ async function indexSignals(options = {}) {
1689
1749
  }
1690
1750
  }
1691
1751
 
1692
- const repo = 'signals/' + channel;
1693
1752
  const id = generateEntryId(date, repo, file.replace(/\.md$/, ''));
1694
1753
  const hash = contentHash(content);
1695
1754
 
@@ -1975,6 +2034,32 @@ function computeQualityProfile(options = {}) {
1975
2034
 
1976
2035
  // ── Exports ─────────────────────────────────────────────────────────────────
1977
2036
 
2037
+ /**
2038
+ * Deduplicate search results by removing raw entries that have been absorbed
2039
+ * into distilled entries. If a distilled entry exists in the results, its
2040
+ * source entries (listed in distilledFrom) are removed.
2041
+ * @param {Array<{id: string, entry: Object, score?: number}>} results
2042
+ * @returns {Array} Deduplicated results
2043
+ */
2044
+ function deduplicateResults(results) {
2045
+ if (!results || results.length === 0) return results;
2046
+
2047
+ // Collect all IDs that have been absorbed into distilled entries
2048
+ const absorbedIds = new Set();
2049
+ for (const r of results) {
2050
+ if (r.entry && r.entry.distilledFrom && Array.isArray(r.entry.distilledFrom)) {
2051
+ for (const id of r.entry.distilledFrom) {
2052
+ absorbedIds.add(id);
2053
+ }
2054
+ }
2055
+ }
2056
+
2057
+ if (absorbedIds.size === 0) return results;
2058
+
2059
+ // Filter out absorbed entries
2060
+ return results.filter(r => !absorbedIds.has(r.id));
2061
+ }
2062
+
1978
2063
  module.exports = {
1979
2064
  // Parsing
1980
2065
  parseJournalFile,
@@ -2005,6 +2090,10 @@ module.exports = {
2005
2090
  isRepoExcluded,
2006
2091
  applyFilters,
2007
2092
 
2093
+ // Quality & dedup
2094
+ computeQualityScore,
2095
+ deduplicateResults,
2096
+
2008
2097
  // Core operations
2009
2098
  indexJournals,
2010
2099
  indexSignals,
package/bin/digest.js CHANGED
@@ -269,13 +269,18 @@ function collectFromStore(sinceDate, options = {}) {
269
269
  });
270
270
 
271
271
  if (entries.length === 0) {
272
- return { journals: '', signals: '', entryCount: 0 };
272
+ return { journals: '', signals: '', entryCount: 0, entryMeta: [] };
273
273
  }
274
274
 
275
275
  const journalParts = [];
276
276
  const signalParts = [];
277
+ const journalMeta = [];
278
+ const signalMeta = [];
277
279
 
278
280
  for (const { id, entry } of entries) {
281
+ // Skip raw entries that have been absorbed into a distilled entry
282
+ if (entry.distilledFrom) continue;
283
+
279
284
  const content = contentStore.getEntryContent(id, { storePath, journalDir, signalsDir });
280
285
  if (!content) continue;
281
286
 
@@ -287,10 +292,21 @@ function collectFromStore(sinceDate, options = {}) {
287
292
  const meta = author ? `${header}\n${author}\n` : `${header}\n`;
288
293
  const formatted = `${meta}\n${content}`;
289
294
 
295
+ const itemMeta = {
296
+ date: entry.date,
297
+ source: entry.source,
298
+ qualityScore: entry.qualityScore || 0,
299
+ hasReasoning: entry.hasReasoning,
300
+ hasAlternatives: entry.hasAlternatives,
301
+ distillTier: entry.distillTier || 'raw',
302
+ };
303
+
290
304
  if (source === 'signal') {
291
305
  signalParts.push(formatted);
306
+ signalMeta.push(itemMeta);
292
307
  } else {
293
308
  journalParts.push(formatted);
309
+ journalMeta.push(itemMeta);
294
310
  }
295
311
  }
296
312
 
@@ -298,6 +314,7 @@ function collectFromStore(sinceDate, options = {}) {
298
314
  journals: journalParts.join('\n\n---\n\n'),
299
315
  signals: signalParts.join('\n\n---\n\n'),
300
316
  entryCount: entries.length,
317
+ entryMeta: { journal: journalMeta, signal: signalMeta },
301
318
  };
302
319
  }
303
320
 
@@ -618,56 +635,102 @@ function buildPrompt(personaId, signalContent, journalContent, dateRange, contex
618
635
  }
619
636
 
620
637
  /**
621
- * Apply token budget constraints to signal and journal content.
622
- * Truncates oldest journal entries first, then signal content.
638
+ * Apply token budget constraints with quality-weighted packing.
639
+ * Higher quality entries are kept preferentially over low-quality ones.
623
640
  * @param {string} signalContent
624
641
  * @param {string} journalContent
625
642
  * @param {number} maxChars
626
- * @returns {{ signals: string, journals: string, truncated: boolean }}
643
+ * @param {Object} [options] - Optional metadata for quality-weighted packing
644
+ * @param {Object} [options.entryMeta] - { journal: [{qualityScore, date, ...}], signal: [...] }
645
+ * @param {Array} [options.scores] - Intelligence scores from Haiku scoring
646
+ * @param {string} [options.personaId] - Current persona for score lookup
647
+ * @returns {{ signals: string, journals: string, truncated: boolean, stats: Object }}
627
648
  */
628
- function applyTokenBudget(signalContent, journalContent, maxChars) {
649
+ function applyTokenBudget(signalContent, journalContent, maxChars, options = {}) {
629
650
  const total = signalContent.length + journalContent.length;
630
651
  if (total <= maxChars) {
631
- return { signals: signalContent, journals: journalContent, truncated: false };
652
+ return { signals: signalContent, journals: journalContent, truncated: false, stats: { dropped: 0 } };
653
+ }
654
+
655
+ const { entryMeta, scores, personaId } = options;
656
+
657
+ // Split into sections
658
+ const signalSections = signalContent ? signalContent.split('\n\n---\n\n') : [];
659
+ const journalSections = journalContent ? journalContent.split('\n\n---\n\n') : [];
660
+ const signalMetaArr = (entryMeta && entryMeta.signal) || [];
661
+ const journalMetaArr = (entryMeta && entryMeta.journal) || [];
662
+
663
+ // Score each section with composite priority
664
+ const todayStr = today();
665
+ const yesterdayStr = (() => { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().split('T')[0]; })();
666
+
667
+ const allSections = [];
668
+ for (let i = 0; i < signalSections.length; i++) {
669
+ const meta = signalMetaArr[i] || {};
670
+ const quality = meta.qualityScore || 0;
671
+ const recency = (meta.date === todayStr || meta.date === yesterdayStr) ? 1 : 0;
672
+ const intel = (scores && scores[i] && personaId) ? (scores[i][personaId] || 0) : 0;
673
+ const distillBonus = (meta.distillTier && meta.distillTier !== 'raw') ? 1 : 0;
674
+ allSections.push({
675
+ text: signalSections[i],
676
+ type: 'signal',
677
+ priority: quality + recency + intel + distillBonus,
678
+ len: signalSections[i].length,
679
+ });
680
+ }
681
+ for (let i = 0; i < journalSections.length; i++) {
682
+ const meta = journalMetaArr[i] || {};
683
+ const quality = meta.qualityScore || 0;
684
+ const recency = (meta.date === todayStr || meta.date === yesterdayStr) ? 1 : 0;
685
+ // Journal score indices start after signal count
686
+ const scoreIdx = signalSections.length + i;
687
+ const intel = (scores && scores[scoreIdx] && personaId) ? (scores[scoreIdx][personaId] || 0) : 0;
688
+ const distillBonus = (meta.distillTier && meta.distillTier !== 'raw') ? 1 : 0;
689
+ allSections.push({
690
+ text: journalSections[i],
691
+ type: 'journal',
692
+ priority: quality + recency + intel + distillBonus,
693
+ len: journalSections[i].length,
694
+ });
632
695
  }
633
696
 
634
- const truncationNote = '\n\n> Note: Input was truncated to fit within token budget. Some older entries may be omitted.\n';
635
- const noteLen = truncationNote.length;
636
- const available = maxChars - noteLen;
697
+ // Sort by priority descending (highest quality first)
698
+ allSections.sort((a, b) => b.priority - a.priority);
637
699
 
638
- let trimmedJournals = journalContent;
639
- let trimmedSignals = signalContent;
640
- let journalsTrimmed = false;
641
- let signalsTrimmed = false;
700
+ // Greedily pack into budget
701
+ const truncationNote = '\n\n> Note: Input was truncated to fit within token budget. Lower-quality entries were dropped.\n';
702
+ const available = maxChars - truncationNote.length;
703
+ const keptSignals = [];
704
+ const keptJournals = [];
705
+ let used = 0;
706
+ let dropped = 0;
642
707
 
643
- // Strategy: drop oldest journal entries first, then trim signals
644
- if (trimmedSignals.length + trimmedJournals.length > available) {
645
- // Try trimming journals first (keep newest entries)
646
- const journalBudget = Math.max(0, available - trimmedSignals.length);
647
- if (journalBudget < trimmedJournals.length) {
648
- trimmedJournals = trimmedJournals.slice(trimmedJournals.length - journalBudget);
649
- journalsTrimmed = true;
708
+ for (const section of allSections) {
709
+ const sectionCost = section.len + 7; // account for '\n\n---\n\n' separator
710
+ if (used + sectionCost <= available) {
711
+ if (section.type === 'signal') {
712
+ keptSignals.push(section.text);
713
+ } else {
714
+ keptJournals.push(section.text);
715
+ }
716
+ used += sectionCost;
717
+ } else {
718
+ dropped++;
650
719
  }
651
720
  }
652
721
 
653
- if (trimmedSignals.length + trimmedJournals.length > available) {
654
- // Still over — trim signal content from the end
655
- const signalBudget = Math.max(0, available - trimmedJournals.length);
656
- trimmedSignals = trimmedSignals.slice(0, signalBudget);
657
- signalsTrimmed = true;
658
- }
659
-
660
- // Append truncation note to whichever content was actually trimmed
661
- if (signalsTrimmed) {
662
- trimmedSignals += truncationNote;
663
- } else if (journalsTrimmed) {
664
- trimmedJournals += truncationNote;
722
+ const truncated = dropped > 0;
723
+ let finalSignals = keptSignals.join('\n\n---\n\n');
724
+ let finalJournals = keptJournals.join('\n\n---\n\n');
725
+ if (truncated) {
726
+ finalJournals += truncationNote;
665
727
  }
666
728
 
667
729
  return {
668
- signals: trimmedSignals,
669
- journals: trimmedJournals,
670
- truncated: true,
730
+ signals: finalSignals,
731
+ journals: finalJournals,
732
+ truncated,
733
+ stats: { dropped, total: allSections.length, kept: allSections.length - dropped },
671
734
  };
672
735
  }
673
736
 
@@ -748,7 +811,11 @@ async function generateDigest(config, personaIds, sinceDate, onProgress) {
748
811
  ({ signals: pSignals, journals: pJournals } =
749
812
  intelligence.filterForPersona(signalContent, journalContent, scores, personaId, threshold, allPersonaIds));
750
813
  }
751
- const budget = applyTokenBudget(pSignals, pJournals, maxInputChars);
814
+ const budget = applyTokenBudget(pSignals, pJournals, maxInputChars, {
815
+ entryMeta: storeResult.entryMeta,
816
+ scores,
817
+ personaId,
818
+ });
752
819
  pSignals = budget.signals;
753
820
  pJournals = budget.journals;
754
821
 
@@ -805,7 +872,21 @@ async function generateDigest(config, personaIds, sinceDate, onProgress) {
805
872
  fs.writeFileSync(combinedFile, combinedContent, 'utf8');
806
873
  files.push(combinedFile);
807
874
 
808
- return { files, personas: personaIds, dateRange, scores };
875
+ // Compute input stats for preview mode
876
+ const entryMeta = storeResult.entryMeta || {};
877
+ const journalMeta = entryMeta.journal || [];
878
+ const signalMeta = entryMeta.signal || [];
879
+ const inputStats = {
880
+ journalEntries: journalMeta.length,
881
+ signalEntries: signalMeta.length,
882
+ qualityDistribution: {
883
+ rich: journalMeta.filter(m => m.qualityScore >= 2).length,
884
+ medium: journalMeta.filter(m => m.qualityScore === 1).length,
885
+ thin: journalMeta.filter(m => m.qualityScore === 0).length,
886
+ },
887
+ };
888
+
889
+ return { files, personas: personaIds, dateRange, scores, inputStats };
809
890
  }
810
891
 
811
892
  /**