@rarusoft/dendrite-wiki 0.1.0-alpha.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 (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Baked-in fallback constants for the opt-in benchmark telemetry destination
3
+ * (Brain-Faithfulness follow-up track — Benchmark Telemetry Database Roadmap T2/T13).
4
+ *
5
+ * **This file ships EMPTY in source.** The published npm package contains the real
6
+ * values, written at publish time by `scripts/write-telemetry-defaults.ts` from
7
+ * environment-only secrets in the release pipeline. The git source tree must never
8
+ * carry production tokens — see [Benchmark Telemetry Database Roadmap](../../docs/wiki/benchmark-telemetry-database-roadmap.md)
9
+ * Gap 1 for the credential-strategy rationale.
10
+ *
11
+ * Two scoped pairs:
12
+ *
13
+ * - **Write pair (T2)**: `TELEMETRY_DEFAULT_URL` + `_TOKEN` power the opt-in
14
+ * upload path. Write-scoped on Turso so the worst-case extraction is write-quota
15
+ * abuse, recoverable via patch-release token rotation.
16
+ * - **Read pair (T13)**: `TELEMETRY_DEFAULT_REPORT_URL` + `_REPORT_TOKEN` power
17
+ * the public cohort dashboard at /wiki/aggregate-learnings. Read-scoped — anyone
18
+ * who extracts it can query the cohort. That is the deliberate transparency
19
+ * call documented in the roadmap.
20
+ *
21
+ * Runtime resolution order (in `resolveLibsqlUploadTarget` and the bridge's
22
+ * /telemetry/report endpoint):
23
+ *
24
+ * 1. Env vars (`DENDRITE_WIKI_TELEMETRY_TURSO_URL` + `_TOKEN` for upload;
25
+ * `DENDRITE_WIKI_TELEMETRY_REPORT_URL` + `_TOKEN` for the dashboard).
26
+ * BYO destination wins over baked defaults.
27
+ * 2. These constants (Dendrite-hosted destination, baked at publish time).
28
+ * 3. Neither → upload returns `skipped`; dashboard falls back to the committed JSON.
29
+ *
30
+ * Local development: keep all six empty. Run with the env-var pairs set against
31
+ * your own scratch Turso database when you need to exercise either path end-to-end.
32
+ */
33
+
34
+ /** Turso libSQL database base URL for OPT-IN uploads. Empty in source. */
35
+ export const TELEMETRY_DEFAULT_URL = "";
36
+
37
+ /** Write-scoped Turso auth token. Empty in source; written at publish time only. */
38
+ export const TELEMETRY_DEFAULT_TOKEN = "";
39
+
40
+ /** Table name for the INSERT. Falls back to `benchmark_events` if empty. */
41
+ export const TELEMETRY_DEFAULT_TABLE = "";
42
+
43
+ /** Turso libSQL database base URL for the public cohort DASHBOARD. Empty in source. */
44
+ export const TELEMETRY_DEFAULT_REPORT_URL = "";
45
+
46
+ /** Read-scoped Turso auth token. Empty in source; written at publish time only. */
47
+ export const TELEMETRY_DEFAULT_REPORT_TOKEN = "";
48
+
49
+ /** Table name for the SELECT. Falls back to `benchmark_events` if empty. */
50
+ export const TELEMETRY_DEFAULT_REPORT_TABLE = "";
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Operator-side analysis layer for the shared benchmark telemetry corpus
3
+ * (Benchmark Telemetry Database Roadmap T5).
4
+ *
5
+ * Reads aggregate stats from the Turso libSQL `benchmark_events` table using a read-
6
+ * scoped token the project owner creates separately (so the package's baked-in write-
7
+ * scoped token can never be misused as a credential to query the cohort). This lives
8
+ * in CLI land for the project owner; opt-in users do not read from the cohort.
9
+ *
10
+ * Output shape is JSON-safe so it can be:
11
+ * 1. Piped to a human via `--format text` for quick console scanning.
12
+ * 2. Pasted as the new contents of `docs/public/aggregate-learnings.json` (T6).
13
+ * 3. Diffed week-over-week to see trend movement.
14
+ *
15
+ * The module is fully deterministic given a fetch implementation — tests inject a mock
16
+ * fetch that returns canned libSQL pipeline responses, so no real Turso DB is required
17
+ * to exercise the analysis paths.
18
+ */
19
+
20
+ export interface TelemetryReportConfig {
21
+ /** Turso base URL (`https://<db>-<org>.turso.io`). Endpoint becomes `<base>/v2/pipeline`. */
22
+ url: string;
23
+ /** Read-scoped Turso auth token. */
24
+ token: string;
25
+ /** Source table name. Defaults to `benchmark_events`. */
26
+ table?: string;
27
+ /** Lookback window in days (rows with `received_at` older than this are excluded). */
28
+ sinceDays?: number;
29
+ /** Override `fetch` for testability. Defaults to global fetch. */
30
+ fetchImpl?: typeof fetch;
31
+ /** Override `now` for deterministic windowing in tests. */
32
+ now?: Date;
33
+ }
34
+
35
+ export interface TelemetryReport {
36
+ schemaVersion: 1;
37
+ generatedAt: string;
38
+ /** Wall-clock window the report covers (UTC, ISO 8601). */
39
+ window: { since: string; until: string; days: number };
40
+ /** Number of unique `installation_id` values that uploaded in the window. */
41
+ uniqueInstallations: number;
42
+ /** Number of unique `project_id` values that uploaded in the window. */
43
+ uniqueProjects: number;
44
+ /** Total telemetry_summary rows in the window. */
45
+ uploadCount: number;
46
+ /** Total `eventCount` across all rows in the window — proxies how much real work users did. */
47
+ totalEvents: number;
48
+ /** Total `wikiUpdateCount` across all rows — proxies how much agents wrote back. */
49
+ totalWikiUpdates: number;
50
+ /** Total `acceptedProposalCount` across all rows — proxies how much got promoted/cleaned. */
51
+ totalAcceptedProposals: number;
52
+ /**
53
+ * T14: derived per-installation averages. These exist so the dashboard can answer
54
+ * "does the product help an *average* user?" without the reader having to do
55
+ * arithmetic. All four are zero when the cohort is empty. Optional in the type so
56
+ * the formatter and consumers tolerate older snapshots committed before T14.
57
+ */
58
+ derived?: {
59
+ /** totalWikiUpdates / uniqueInstallations — average durable knowledge per project. */
60
+ wikiUpdatesPerInstallation: number;
61
+ /** totalEvents / uniqueInstallations — average activity per project. */
62
+ eventsPerInstallation: number;
63
+ /** totalAcceptedProposals / uniqueInstallations — average maintenance engagement. */
64
+ acceptedProposalsPerInstallation: number;
65
+ /** uploads per installation — proxy for how often users actually trigger uploads. */
66
+ uploadsPerInstallation: number;
67
+ };
68
+ /** Latest context page / omitted-page numbers averaged across most-recent-per-installation. */
69
+ latestContext: {
70
+ averagePageCount: number | null;
71
+ averageOmittedPageCount: number | null;
72
+ averageOpenQuestionCount: number | null;
73
+ };
74
+ /** Distinct package versions seen in the window, sorted by upload count desc. */
75
+ packageVersions: Array<{ version: string; uploadCount: number }>;
76
+ /** Distinct client-profile labels seen in the window (claude/codex/cursor/etc.). */
77
+ clientProfiles: Array<{ profile: string; uploadCount: number }>;
78
+ /** Week buckets (`YYYY-Www`) within the window, oldest first. Empty when no rows. */
79
+ weeklyBuckets: Array<{
80
+ week: string;
81
+ uploadCount: number;
82
+ uniqueInstallations: number;
83
+ totalEvents: number;
84
+ totalWikiUpdates: number;
85
+ }>;
86
+ }
87
+
88
+ interface LibsqlRow {
89
+ type: 'text' | 'integer' | 'null' | 'real' | 'blob';
90
+ value?: string;
91
+ }
92
+
93
+ interface LibsqlExecuteResponse {
94
+ type: 'ok';
95
+ response: {
96
+ type: 'execute';
97
+ result: {
98
+ cols: Array<{ name: string }>;
99
+ rows: Array<Array<LibsqlRow>>;
100
+ };
101
+ };
102
+ }
103
+
104
+ interface LibsqlPipelineResponse {
105
+ results: Array<LibsqlExecuteResponse | { type: 'ok'; response: { type: 'close' } }>;
106
+ }
107
+
108
+ function buildSelectRequest(table: string, sinceIso: string): { requests: Array<unknown> } {
109
+ // One row per upload — we'll aggregate in TypeScript rather than do server-side GROUP BY
110
+ // because libSQL pipelines support multiple execute statements but tuning the SQL adds
111
+ // complexity for very modest gains at the expected scale (low thousands of rows).
112
+ const sql = `SELECT installation_id, project_id, package_version, timestamp, received_at, client_profiles, metrics FROM ${table} WHERE received_at >= :since ORDER BY received_at ASC`;
113
+ return {
114
+ requests: [
115
+ {
116
+ type: 'execute',
117
+ stmt: {
118
+ sql,
119
+ named_args: [{ name: 'since', value: { type: 'text', value: sinceIso } }]
120
+ }
121
+ },
122
+ { type: 'close' }
123
+ ]
124
+ };
125
+ }
126
+
127
+ function cellText(row: Array<LibsqlRow>, cols: Array<{ name: string }>, name: string): string {
128
+ const index = cols.findIndex((col) => col.name === name);
129
+ if (index < 0) return '';
130
+ const cell = row[index];
131
+ if (!cell || cell.type === 'null') return '';
132
+ return cell.value ?? '';
133
+ }
134
+
135
+ function isoWeekKey(date: Date): string {
136
+ // ISO 8601 week — Monday-based, week 1 contains the year's first Thursday. Standard for
137
+ // weekly reporting; matches what the Recall Quality panel uses elsewhere.
138
+ const tmp = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
139
+ const dayNum = tmp.getUTCDay() === 0 ? 7 : tmp.getUTCDay();
140
+ tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
141
+ const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
142
+ const week = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
143
+ return `${tmp.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
144
+ }
145
+
146
+ interface MetricsShape {
147
+ eventCount?: number;
148
+ wikiUpdateCount?: number;
149
+ acceptedProposalCount?: number;
150
+ latestContextPageCount?: number | null;
151
+ latestContextOmittedPageCount?: number | null;
152
+ latestOpenQuestionCount?: number | null;
153
+ }
154
+
155
+ function safeParseJson<T>(value: string): T | null {
156
+ if (!value) return null;
157
+ try {
158
+ return JSON.parse(value) as T;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function averageOrNull(values: number[]): number | null {
165
+ if (values.length === 0) return null;
166
+ const sum = values.reduce((acc, value) => acc + value, 0);
167
+ return Math.round((sum / values.length) * 100) / 100;
168
+ }
169
+
170
+ export async function buildTelemetryReport(config: TelemetryReportConfig): Promise<TelemetryReport> {
171
+ if (!config.url || !config.token) {
172
+ throw new Error('telemetry-report requires both url and token. Pass DENDRITE_WIKI_TELEMETRY_REPORT_URL and DENDRITE_WIKI_TELEMETRY_REPORT_TOKEN.');
173
+ }
174
+ const fetchImpl = config.fetchImpl ?? fetch;
175
+ const now = config.now ?? new Date();
176
+ const days = Math.max(1, Math.min(config.sinceDays ?? 30, 365));
177
+ const since = new Date(now.getTime() - days * 86_400_000);
178
+ const sinceIso = since.toISOString();
179
+ const table = config.table || 'benchmark_events';
180
+ const endpoint = `${config.url.replace(/\/$/, '')}/v2/pipeline`;
181
+
182
+ const response = await fetchImpl(endpoint, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'content-type': 'application/json',
186
+ authorization: `Bearer ${config.token}`
187
+ },
188
+ body: JSON.stringify(buildSelectRequest(table, sinceIso))
189
+ });
190
+ if (!response.ok) {
191
+ throw new Error(`telemetry-report upstream returned HTTP ${response.status}`);
192
+ }
193
+ const body = (await response.json()) as LibsqlPipelineResponse;
194
+ const executeResult = body.results.find(
195
+ (r): r is LibsqlExecuteResponse => 'response' in r && r.response.type === 'execute'
196
+ );
197
+ if (!executeResult) {
198
+ throw new Error('telemetry-report upstream returned no execute result');
199
+ }
200
+ const { cols, rows } = executeResult.response.result;
201
+
202
+ // Single-pass aggregation. The rows are received_at ASC so weekly buckets are append-only.
203
+ const installationSet = new Set<string>();
204
+ const projectSet = new Set<string>();
205
+ let totalEvents = 0;
206
+ let totalWikiUpdates = 0;
207
+ let totalAcceptedProposals = 0;
208
+ const versionCounts = new Map<string, number>();
209
+ const profileCounts = new Map<string, number>();
210
+ // Track most-recent row per installation so we can average the "latest" context metrics
211
+ // across the cohort without one chatty installation dominating.
212
+ const latestPerInstallation = new Map<string, MetricsShape>();
213
+ const weekBuckets = new Map<string, { uploadCount: number; installations: Set<string>; totalEvents: number; totalWikiUpdates: number }>();
214
+
215
+ for (const row of rows) {
216
+ const installationId = cellText(row, cols, 'installation_id');
217
+ if (!installationId) continue;
218
+ installationSet.add(installationId);
219
+ const projectId = cellText(row, cols, 'project_id');
220
+ if (projectId) projectSet.add(projectId);
221
+ const packageVersion = cellText(row, cols, 'package_version');
222
+ if (packageVersion) versionCounts.set(packageVersion, (versionCounts.get(packageVersion) ?? 0) + 1);
223
+ const profilesJson = cellText(row, cols, 'client_profiles');
224
+ const profiles = safeParseJson<string[]>(profilesJson) ?? [];
225
+ for (const profile of profiles) {
226
+ profileCounts.set(profile, (profileCounts.get(profile) ?? 0) + 1);
227
+ }
228
+ const metricsJson = cellText(row, cols, 'metrics');
229
+ const metrics = safeParseJson<MetricsShape>(metricsJson) ?? {};
230
+ totalEvents += metrics.eventCount ?? 0;
231
+ totalWikiUpdates += metrics.wikiUpdateCount ?? 0;
232
+ totalAcceptedProposals += metrics.acceptedProposalCount ?? 0;
233
+ latestPerInstallation.set(installationId, metrics);
234
+
235
+ const receivedAt = cellText(row, cols, 'received_at') || cellText(row, cols, 'timestamp');
236
+ if (receivedAt) {
237
+ const date = new Date(receivedAt);
238
+ if (!Number.isNaN(date.getTime())) {
239
+ const week = isoWeekKey(date);
240
+ const bucket = weekBuckets.get(week) ?? {
241
+ uploadCount: 0,
242
+ installations: new Set<string>(),
243
+ totalEvents: 0,
244
+ totalWikiUpdates: 0
245
+ };
246
+ bucket.uploadCount += 1;
247
+ bucket.installations.add(installationId);
248
+ bucket.totalEvents += metrics.eventCount ?? 0;
249
+ bucket.totalWikiUpdates += metrics.wikiUpdateCount ?? 0;
250
+ weekBuckets.set(week, bucket);
251
+ }
252
+ }
253
+ }
254
+
255
+ const pageValues: number[] = [];
256
+ const omittedValues: number[] = [];
257
+ const openQuestionValues: number[] = [];
258
+ for (const metrics of latestPerInstallation.values()) {
259
+ if (typeof metrics.latestContextPageCount === 'number') pageValues.push(metrics.latestContextPageCount);
260
+ if (typeof metrics.latestContextOmittedPageCount === 'number') omittedValues.push(metrics.latestContextOmittedPageCount);
261
+ if (typeof metrics.latestOpenQuestionCount === 'number') openQuestionValues.push(metrics.latestOpenQuestionCount);
262
+ }
263
+
264
+ const packageVersions = Array.from(versionCounts.entries())
265
+ .map(([version, uploadCount]) => ({ version, uploadCount }))
266
+ .sort((left, right) => right.uploadCount - left.uploadCount || left.version.localeCompare(right.version));
267
+ const clientProfiles = Array.from(profileCounts.entries())
268
+ .map(([profile, uploadCount]) => ({ profile, uploadCount }))
269
+ .sort((left, right) => right.uploadCount - left.uploadCount || left.profile.localeCompare(right.profile));
270
+ const weeklyBuckets = Array.from(weekBuckets.entries())
271
+ .map(([week, bucket]) => ({
272
+ week,
273
+ uploadCount: bucket.uploadCount,
274
+ uniqueInstallations: bucket.installations.size,
275
+ totalEvents: bucket.totalEvents,
276
+ totalWikiUpdates: bucket.totalWikiUpdates
277
+ }))
278
+ .sort((left, right) => left.week.localeCompare(right.week));
279
+
280
+ const uniqueInstallationCount = installationSet.size;
281
+ const divisor = Math.max(1, uniqueInstallationCount);
282
+ const round1 = (value: number): number => Math.round(value * 10) / 10;
283
+
284
+ return {
285
+ schemaVersion: 1,
286
+ generatedAt: now.toISOString(),
287
+ window: { since: sinceIso, until: now.toISOString(), days },
288
+ uniqueInstallations: uniqueInstallationCount,
289
+ uniqueProjects: projectSet.size,
290
+ uploadCount: rows.length,
291
+ totalEvents,
292
+ totalWikiUpdates,
293
+ totalAcceptedProposals,
294
+ derived: {
295
+ wikiUpdatesPerInstallation: uniqueInstallationCount > 0 ? round1(totalWikiUpdates / divisor) : 0,
296
+ eventsPerInstallation: uniqueInstallationCount > 0 ? round1(totalEvents / divisor) : 0,
297
+ acceptedProposalsPerInstallation: uniqueInstallationCount > 0 ? round1(totalAcceptedProposals / divisor) : 0,
298
+ uploadsPerInstallation: uniqueInstallationCount > 0 ? round1(rows.length / divisor) : 0
299
+ },
300
+ latestContext: {
301
+ averagePageCount: averageOrNull(pageValues),
302
+ averageOmittedPageCount: averageOrNull(omittedValues),
303
+ averageOpenQuestionCount: averageOrNull(openQuestionValues)
304
+ },
305
+ packageVersions,
306
+ clientProfiles,
307
+ weeklyBuckets
308
+ };
309
+ }
310
+
311
+ export function formatTelemetryReportAsText(report: TelemetryReport): string {
312
+ const lines: string[] = [];
313
+ lines.push('Dendrite Wiki MCP — telemetry cohort report');
314
+ lines.push(`Generated: ${report.generatedAt}`);
315
+ lines.push(`Window: ${report.window.since} → ${report.window.until} (${report.window.days} days)`);
316
+ lines.push('');
317
+ lines.push(`Unique installations: ${report.uniqueInstallations}`);
318
+ lines.push(`Unique projects: ${report.uniqueProjects}`);
319
+ lines.push(`Total uploads: ${report.uploadCount}`);
320
+ lines.push(`Total events: ${report.totalEvents}`);
321
+ lines.push(`Total wiki updates: ${report.totalWikiUpdates}`);
322
+ lines.push(`Accepted proposals: ${report.totalAcceptedProposals}`);
323
+ if (report.uniqueInstallations > 0 && report.derived) {
324
+ lines.push('');
325
+ lines.push('Per-installation averages (the "does the average user benefit?" cut):');
326
+ lines.push(` wiki updates / installation: ${report.derived.wikiUpdatesPerInstallation}`);
327
+ lines.push(` events / installation: ${report.derived.eventsPerInstallation}`);
328
+ lines.push(` accepted proposals / installation: ${report.derived.acceptedProposalsPerInstallation}`);
329
+ lines.push(` uploads / installation: ${report.derived.uploadsPerInstallation}`);
330
+ }
331
+ lines.push('');
332
+ if (report.latestContext.averagePageCount !== null) {
333
+ lines.push(`Latest context (averaged across most-recent-per-installation):`);
334
+ lines.push(` avg pages: ${report.latestContext.averagePageCount}`);
335
+ lines.push(` avg omitted pages: ${report.latestContext.averageOmittedPageCount ?? '—'}`);
336
+ lines.push(` avg open questions: ${report.latestContext.averageOpenQuestionCount ?? '—'}`);
337
+ lines.push('');
338
+ }
339
+ if (report.packageVersions.length > 0) {
340
+ lines.push('Package versions:');
341
+ for (const entry of report.packageVersions.slice(0, 5)) {
342
+ lines.push(` ${entry.version} (${entry.uploadCount} upload${entry.uploadCount === 1 ? '' : 's'})`);
343
+ }
344
+ if (report.packageVersions.length > 5) {
345
+ lines.push(` … ${report.packageVersions.length - 5} more`);
346
+ }
347
+ lines.push('');
348
+ }
349
+ if (report.clientProfiles.length > 0) {
350
+ lines.push('Client profiles:');
351
+ for (const entry of report.clientProfiles) {
352
+ lines.push(` ${entry.profile} (${entry.uploadCount} upload${entry.uploadCount === 1 ? '' : 's'})`);
353
+ }
354
+ lines.push('');
355
+ }
356
+ if (report.weeklyBuckets.length > 0) {
357
+ lines.push('Weekly breakdown (uploads / unique installations / total events):');
358
+ for (const entry of report.weeklyBuckets) {
359
+ lines.push(` ${entry.week}: ${entry.uploadCount} / ${entry.uniqueInstallations} / ${entry.totalEvents}`);
360
+ }
361
+ } else {
362
+ lines.push('No uploads in the configured window.');
363
+ }
364
+ return lines.join('\n');
365
+ }