@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.
- package/README.md +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- package/src/wiki-synthesis.ts +1307 -0
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
/** Turso libSQL database base URL for OPT-IN uploads. Empty in source. */
|
|
34
|
+
export const TELEMETRY_DEFAULT_URL = "";
|
|
35
|
+
/** Write-scoped Turso auth token. Empty in source; written at publish time only. */
|
|
36
|
+
export const TELEMETRY_DEFAULT_TOKEN = "";
|
|
37
|
+
/** Table name for the INSERT. Falls back to `benchmark_events` if empty. */
|
|
38
|
+
export const TELEMETRY_DEFAULT_TABLE = "";
|
|
39
|
+
/** Turso libSQL database base URL for the public cohort DASHBOARD. Empty in source. */
|
|
40
|
+
export const TELEMETRY_DEFAULT_REPORT_URL = "";
|
|
41
|
+
/** Read-scoped Turso auth token. Empty in source; written at publish time only. */
|
|
42
|
+
export const TELEMETRY_DEFAULT_REPORT_TOKEN = "";
|
|
43
|
+
/** Table name for the SELECT. Falls back to `benchmark_events` if empty. */
|
|
44
|
+
export const TELEMETRY_DEFAULT_REPORT_TABLE = "";
|
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
function buildSelectRequest(table, sinceIso) {
|
|
20
|
+
// One row per upload — we'll aggregate in TypeScript rather than do server-side GROUP BY
|
|
21
|
+
// because libSQL pipelines support multiple execute statements but tuning the SQL adds
|
|
22
|
+
// complexity for very modest gains at the expected scale (low thousands of rows).
|
|
23
|
+
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`;
|
|
24
|
+
return {
|
|
25
|
+
requests: [
|
|
26
|
+
{
|
|
27
|
+
type: 'execute',
|
|
28
|
+
stmt: {
|
|
29
|
+
sql,
|
|
30
|
+
named_args: [{ name: 'since', value: { type: 'text', value: sinceIso } }]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{ type: 'close' }
|
|
34
|
+
]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function cellText(row, cols, name) {
|
|
38
|
+
const index = cols.findIndex((col) => col.name === name);
|
|
39
|
+
if (index < 0)
|
|
40
|
+
return '';
|
|
41
|
+
const cell = row[index];
|
|
42
|
+
if (!cell || cell.type === 'null')
|
|
43
|
+
return '';
|
|
44
|
+
return cell.value ?? '';
|
|
45
|
+
}
|
|
46
|
+
function isoWeekKey(date) {
|
|
47
|
+
// ISO 8601 week — Monday-based, week 1 contains the year's first Thursday. Standard for
|
|
48
|
+
// weekly reporting; matches what the Recall Quality panel uses elsewhere.
|
|
49
|
+
const tmp = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
50
|
+
const dayNum = tmp.getUTCDay() === 0 ? 7 : tmp.getUTCDay();
|
|
51
|
+
tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
|
|
52
|
+
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
53
|
+
const week = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
|
54
|
+
return `${tmp.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
55
|
+
}
|
|
56
|
+
function safeParseJson(value) {
|
|
57
|
+
if (!value)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(value);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function averageOrNull(values) {
|
|
67
|
+
if (values.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const sum = values.reduce((acc, value) => acc + value, 0);
|
|
70
|
+
return Math.round((sum / values.length) * 100) / 100;
|
|
71
|
+
}
|
|
72
|
+
export async function buildTelemetryReport(config) {
|
|
73
|
+
if (!config.url || !config.token) {
|
|
74
|
+
throw new Error('telemetry-report requires both url and token. Pass DENDRITE_WIKI_TELEMETRY_REPORT_URL and DENDRITE_WIKI_TELEMETRY_REPORT_TOKEN.');
|
|
75
|
+
}
|
|
76
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
77
|
+
const now = config.now ?? new Date();
|
|
78
|
+
const days = Math.max(1, Math.min(config.sinceDays ?? 30, 365));
|
|
79
|
+
const since = new Date(now.getTime() - days * 86_400_000);
|
|
80
|
+
const sinceIso = since.toISOString();
|
|
81
|
+
const table = config.table || 'benchmark_events';
|
|
82
|
+
const endpoint = `${config.url.replace(/\/$/, '')}/v2/pipeline`;
|
|
83
|
+
const response = await fetchImpl(endpoint, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'content-type': 'application/json',
|
|
87
|
+
authorization: `Bearer ${config.token}`
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(buildSelectRequest(table, sinceIso))
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`telemetry-report upstream returned HTTP ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
const body = (await response.json());
|
|
95
|
+
const executeResult = body.results.find((r) => 'response' in r && r.response.type === 'execute');
|
|
96
|
+
if (!executeResult) {
|
|
97
|
+
throw new Error('telemetry-report upstream returned no execute result');
|
|
98
|
+
}
|
|
99
|
+
const { cols, rows } = executeResult.response.result;
|
|
100
|
+
// Single-pass aggregation. The rows are received_at ASC so weekly buckets are append-only.
|
|
101
|
+
const installationSet = new Set();
|
|
102
|
+
const projectSet = new Set();
|
|
103
|
+
let totalEvents = 0;
|
|
104
|
+
let totalWikiUpdates = 0;
|
|
105
|
+
let totalAcceptedProposals = 0;
|
|
106
|
+
const versionCounts = new Map();
|
|
107
|
+
const profileCounts = new Map();
|
|
108
|
+
// Track most-recent row per installation so we can average the "latest" context metrics
|
|
109
|
+
// across the cohort without one chatty installation dominating.
|
|
110
|
+
const latestPerInstallation = new Map();
|
|
111
|
+
const weekBuckets = new Map();
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const installationId = cellText(row, cols, 'installation_id');
|
|
114
|
+
if (!installationId)
|
|
115
|
+
continue;
|
|
116
|
+
installationSet.add(installationId);
|
|
117
|
+
const projectId = cellText(row, cols, 'project_id');
|
|
118
|
+
if (projectId)
|
|
119
|
+
projectSet.add(projectId);
|
|
120
|
+
const packageVersion = cellText(row, cols, 'package_version');
|
|
121
|
+
if (packageVersion)
|
|
122
|
+
versionCounts.set(packageVersion, (versionCounts.get(packageVersion) ?? 0) + 1);
|
|
123
|
+
const profilesJson = cellText(row, cols, 'client_profiles');
|
|
124
|
+
const profiles = safeParseJson(profilesJson) ?? [];
|
|
125
|
+
for (const profile of profiles) {
|
|
126
|
+
profileCounts.set(profile, (profileCounts.get(profile) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
const metricsJson = cellText(row, cols, 'metrics');
|
|
129
|
+
const metrics = safeParseJson(metricsJson) ?? {};
|
|
130
|
+
totalEvents += metrics.eventCount ?? 0;
|
|
131
|
+
totalWikiUpdates += metrics.wikiUpdateCount ?? 0;
|
|
132
|
+
totalAcceptedProposals += metrics.acceptedProposalCount ?? 0;
|
|
133
|
+
latestPerInstallation.set(installationId, metrics);
|
|
134
|
+
const receivedAt = cellText(row, cols, 'received_at') || cellText(row, cols, 'timestamp');
|
|
135
|
+
if (receivedAt) {
|
|
136
|
+
const date = new Date(receivedAt);
|
|
137
|
+
if (!Number.isNaN(date.getTime())) {
|
|
138
|
+
const week = isoWeekKey(date);
|
|
139
|
+
const bucket = weekBuckets.get(week) ?? {
|
|
140
|
+
uploadCount: 0,
|
|
141
|
+
installations: new Set(),
|
|
142
|
+
totalEvents: 0,
|
|
143
|
+
totalWikiUpdates: 0
|
|
144
|
+
};
|
|
145
|
+
bucket.uploadCount += 1;
|
|
146
|
+
bucket.installations.add(installationId);
|
|
147
|
+
bucket.totalEvents += metrics.eventCount ?? 0;
|
|
148
|
+
bucket.totalWikiUpdates += metrics.wikiUpdateCount ?? 0;
|
|
149
|
+
weekBuckets.set(week, bucket);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const pageValues = [];
|
|
154
|
+
const omittedValues = [];
|
|
155
|
+
const openQuestionValues = [];
|
|
156
|
+
for (const metrics of latestPerInstallation.values()) {
|
|
157
|
+
if (typeof metrics.latestContextPageCount === 'number')
|
|
158
|
+
pageValues.push(metrics.latestContextPageCount);
|
|
159
|
+
if (typeof metrics.latestContextOmittedPageCount === 'number')
|
|
160
|
+
omittedValues.push(metrics.latestContextOmittedPageCount);
|
|
161
|
+
if (typeof metrics.latestOpenQuestionCount === 'number')
|
|
162
|
+
openQuestionValues.push(metrics.latestOpenQuestionCount);
|
|
163
|
+
}
|
|
164
|
+
const packageVersions = Array.from(versionCounts.entries())
|
|
165
|
+
.map(([version, uploadCount]) => ({ version, uploadCount }))
|
|
166
|
+
.sort((left, right) => right.uploadCount - left.uploadCount || left.version.localeCompare(right.version));
|
|
167
|
+
const clientProfiles = Array.from(profileCounts.entries())
|
|
168
|
+
.map(([profile, uploadCount]) => ({ profile, uploadCount }))
|
|
169
|
+
.sort((left, right) => right.uploadCount - left.uploadCount || left.profile.localeCompare(right.profile));
|
|
170
|
+
const weeklyBuckets = Array.from(weekBuckets.entries())
|
|
171
|
+
.map(([week, bucket]) => ({
|
|
172
|
+
week,
|
|
173
|
+
uploadCount: bucket.uploadCount,
|
|
174
|
+
uniqueInstallations: bucket.installations.size,
|
|
175
|
+
totalEvents: bucket.totalEvents,
|
|
176
|
+
totalWikiUpdates: bucket.totalWikiUpdates
|
|
177
|
+
}))
|
|
178
|
+
.sort((left, right) => left.week.localeCompare(right.week));
|
|
179
|
+
const uniqueInstallationCount = installationSet.size;
|
|
180
|
+
const divisor = Math.max(1, uniqueInstallationCount);
|
|
181
|
+
const round1 = (value) => Math.round(value * 10) / 10;
|
|
182
|
+
return {
|
|
183
|
+
schemaVersion: 1,
|
|
184
|
+
generatedAt: now.toISOString(),
|
|
185
|
+
window: { since: sinceIso, until: now.toISOString(), days },
|
|
186
|
+
uniqueInstallations: uniqueInstallationCount,
|
|
187
|
+
uniqueProjects: projectSet.size,
|
|
188
|
+
uploadCount: rows.length,
|
|
189
|
+
totalEvents,
|
|
190
|
+
totalWikiUpdates,
|
|
191
|
+
totalAcceptedProposals,
|
|
192
|
+
derived: {
|
|
193
|
+
wikiUpdatesPerInstallation: uniqueInstallationCount > 0 ? round1(totalWikiUpdates / divisor) : 0,
|
|
194
|
+
eventsPerInstallation: uniqueInstallationCount > 0 ? round1(totalEvents / divisor) : 0,
|
|
195
|
+
acceptedProposalsPerInstallation: uniqueInstallationCount > 0 ? round1(totalAcceptedProposals / divisor) : 0,
|
|
196
|
+
uploadsPerInstallation: uniqueInstallationCount > 0 ? round1(rows.length / divisor) : 0
|
|
197
|
+
},
|
|
198
|
+
latestContext: {
|
|
199
|
+
averagePageCount: averageOrNull(pageValues),
|
|
200
|
+
averageOmittedPageCount: averageOrNull(omittedValues),
|
|
201
|
+
averageOpenQuestionCount: averageOrNull(openQuestionValues)
|
|
202
|
+
},
|
|
203
|
+
packageVersions,
|
|
204
|
+
clientProfiles,
|
|
205
|
+
weeklyBuckets
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function formatTelemetryReportAsText(report) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
lines.push('Dendrite Wiki MCP — telemetry cohort report');
|
|
211
|
+
lines.push(`Generated: ${report.generatedAt}`);
|
|
212
|
+
lines.push(`Window: ${report.window.since} → ${report.window.until} (${report.window.days} days)`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push(`Unique installations: ${report.uniqueInstallations}`);
|
|
215
|
+
lines.push(`Unique projects: ${report.uniqueProjects}`);
|
|
216
|
+
lines.push(`Total uploads: ${report.uploadCount}`);
|
|
217
|
+
lines.push(`Total events: ${report.totalEvents}`);
|
|
218
|
+
lines.push(`Total wiki updates: ${report.totalWikiUpdates}`);
|
|
219
|
+
lines.push(`Accepted proposals: ${report.totalAcceptedProposals}`);
|
|
220
|
+
if (report.uniqueInstallations > 0 && report.derived) {
|
|
221
|
+
lines.push('');
|
|
222
|
+
lines.push('Per-installation averages (the "does the average user benefit?" cut):');
|
|
223
|
+
lines.push(` wiki updates / installation: ${report.derived.wikiUpdatesPerInstallation}`);
|
|
224
|
+
lines.push(` events / installation: ${report.derived.eventsPerInstallation}`);
|
|
225
|
+
lines.push(` accepted proposals / installation: ${report.derived.acceptedProposalsPerInstallation}`);
|
|
226
|
+
lines.push(` uploads / installation: ${report.derived.uploadsPerInstallation}`);
|
|
227
|
+
}
|
|
228
|
+
lines.push('');
|
|
229
|
+
if (report.latestContext.averagePageCount !== null) {
|
|
230
|
+
lines.push(`Latest context (averaged across most-recent-per-installation):`);
|
|
231
|
+
lines.push(` avg pages: ${report.latestContext.averagePageCount}`);
|
|
232
|
+
lines.push(` avg omitted pages: ${report.latestContext.averageOmittedPageCount ?? '—'}`);
|
|
233
|
+
lines.push(` avg open questions: ${report.latestContext.averageOpenQuestionCount ?? '—'}`);
|
|
234
|
+
lines.push('');
|
|
235
|
+
}
|
|
236
|
+
if (report.packageVersions.length > 0) {
|
|
237
|
+
lines.push('Package versions:');
|
|
238
|
+
for (const entry of report.packageVersions.slice(0, 5)) {
|
|
239
|
+
lines.push(` ${entry.version} (${entry.uploadCount} upload${entry.uploadCount === 1 ? '' : 's'})`);
|
|
240
|
+
}
|
|
241
|
+
if (report.packageVersions.length > 5) {
|
|
242
|
+
lines.push(` … ${report.packageVersions.length - 5} more`);
|
|
243
|
+
}
|
|
244
|
+
lines.push('');
|
|
245
|
+
}
|
|
246
|
+
if (report.clientProfiles.length > 0) {
|
|
247
|
+
lines.push('Client profiles:');
|
|
248
|
+
for (const entry of report.clientProfiles) {
|
|
249
|
+
lines.push(` ${entry.profile} (${entry.uploadCount} upload${entry.uploadCount === 1 ? '' : 's'})`);
|
|
250
|
+
}
|
|
251
|
+
lines.push('');
|
|
252
|
+
}
|
|
253
|
+
if (report.weeklyBuckets.length > 0) {
|
|
254
|
+
lines.push('Weekly breakdown (uploads / unique installations / total events):');
|
|
255
|
+
for (const entry of report.weeklyBuckets) {
|
|
256
|
+
lines.push(` ${entry.week}: ${entry.uploadCount} / ${entry.uniqueInstallations} / ${entry.totalEvents}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
lines.push('No uploads in the configured window.');
|
|
261
|
+
}
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|