@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,757 @@
1
+ /**
2
+ * Opt-in telemetry — local-first, explicitly-consented benchmark sharing.
3
+ *
4
+ * Telemetry is OFF by default. Setting `DENDRITE_WIKI_TELEMETRY_SHARING=opt-in` (or
5
+ * running `dendrite-wiki telemetry opt-in`) records explicit consent in
6
+ * `local-data/telemetry-config.json` — but consent alone does not send anything. The
7
+ * operator must additionally configure `DENDRITE_WIKI_TELEMETRY_TURSO_URL` and
8
+ * `_TOKEN` to point at a Turso libSQL database THEY own; only then does
9
+ * `dendrite-wiki telemetry upload` push a sanitized aggregate payload there.
10
+ *
11
+ * Sanitization is deliberate: page counts, lint summaries, and recall scores ship; raw
12
+ * page content, memory bodies, file paths, and project-log entries DO NOT. The audit log
13
+ * at `local-data/telemetry-upload-audit.jsonl` records every send so the operator can
14
+ * verify what left the machine. There is no Anthropic-managed backend in this milestone
15
+ * — the only destination is the operator's own database.
16
+ */
17
+ import { promises as fs } from 'node:fs';
18
+ import { randomUUID } from 'node:crypto';
19
+ import path from 'node:path';
20
+ import type { DendriteBenchmarkEventSummary } from './benchmark-events.js';
21
+ import {
22
+ TELEMETRY_DEFAULT_TABLE,
23
+ TELEMETRY_DEFAULT_TOKEN,
24
+ TELEMETRY_DEFAULT_URL
25
+ } from './telemetry-defaults.js';
26
+
27
+ export type DendriteTelemetrySharingMode = 'off' | 'opt-in';
28
+
29
+ export interface DendriteTelemetryConfig {
30
+ schemaVersion: 1;
31
+ sharingMode: DendriteTelemetrySharingMode;
32
+ updatedAt: string;
33
+ installationId: string;
34
+ projectId: string;
35
+ }
36
+
37
+ export interface DendriteTelemetryUploadPayload {
38
+ schemaVersion: 1;
39
+ installationId: string;
40
+ projectId: string;
41
+ packageVersion: string | null;
42
+ event: 'telemetry_summary';
43
+ timestamp: string;
44
+ sharingMode: 'opt-in';
45
+ clientProfiles: string[];
46
+ metrics: {
47
+ eventCount: number;
48
+ sessionStartedCount: number;
49
+ contextRequestCount: number;
50
+ wikiUpdateCount: number;
51
+ maintenanceStateChangeCount: number;
52
+ sessionSnapshotCount: number;
53
+ latestContextPageCount: number | null;
54
+ latestContextOmittedPageCount: number | null;
55
+ latestOpenQuestionCount: number | null;
56
+ acceptedProposalCount: number;
57
+ latestLintFindingCount: number | null;
58
+ latestProposalCount: number | null;
59
+ };
60
+ }
61
+
62
+ export interface DendriteTelemetryUploadAttempt {
63
+ attemptedAt: string;
64
+ status: 'success' | 'error' | 'skipped';
65
+ destination: string | null;
66
+ reason: string | null;
67
+ httpStatus: number | null;
68
+ responseBody: string | null;
69
+ payload: DendriteTelemetryUploadPayload | null;
70
+ }
71
+
72
+ export interface DendriteTelemetryUploadAudit {
73
+ schemaVersion: 1;
74
+ updatedAt: string;
75
+ destination: string | null;
76
+ lastAttempt: DendriteTelemetryUploadAttempt | null;
77
+ lastSuccess: DendriteTelemetryUploadAttempt | null;
78
+ }
79
+
80
+ export interface DendriteTelemetryUploadResult {
81
+ ok: boolean;
82
+ message: string;
83
+ auditPath: string;
84
+ destination: string | null;
85
+ attempt: DendriteTelemetryUploadAttempt;
86
+ status: DendriteTelemetryStatusArtifact;
87
+ }
88
+
89
+ export interface DendriteTelemetryStatusArtifact {
90
+ schemaVersion: 1;
91
+ generatedAt: string;
92
+ sharingMode: DendriteTelemetrySharingMode;
93
+ sharingEnabled: boolean;
94
+ consent: {
95
+ isExplicit: boolean;
96
+ updatedAt: string | null;
97
+ };
98
+ paths: {
99
+ configPath: string;
100
+ statusArtifactPath: string;
101
+ uploadAuditPath: string;
102
+ benchmarkEventLogPath: string;
103
+ benchmarkEventSummaryPath: string;
104
+ };
105
+ remoteUpload: {
106
+ configured: boolean;
107
+ destination: string | null;
108
+ auditPath: string;
109
+ lastAttemptAt: string | null;
110
+ lastAttemptStatus: 'success' | 'error' | 'skipped' | null;
111
+ lastSuccessAt: string | null;
112
+ lastError: string | null;
113
+ lastPayloadPreview: DendriteTelemetryUploadPayload | null;
114
+ };
115
+ benchmarkEvents: {
116
+ eventCount: number;
117
+ latestEventAt: string | null;
118
+ byType: DendriteBenchmarkEventSummary['byType'];
119
+ };
120
+ notes: string[];
121
+ }
122
+
123
+ const dataDirRelativePath = process.env.DENDRITE_WIKI_DATA_DIR ?? 'local-data';
124
+ const telemetryConfigRelativePath = path.join(dataDirRelativePath, 'telemetry.json');
125
+ const telemetryUploadAuditRelativePath = path.join(dataDirRelativePath, 'telemetry-upload-audit.json');
126
+ const benchmarkEventLogRelativePath = path.join(dataDirRelativePath, 'benchmark-events.jsonl');
127
+ const benchmarkEventSummaryRelativePath = path.join('docs', 'public', 'dendrite-benchmark-events-summary.json');
128
+ const telemetryStatusArtifactRelativePath = path.join('docs', 'public', 'dendrite-telemetry-status.json');
129
+
130
+ interface TelemetryUploadOptions {
131
+ root?: string;
132
+ fetchImpl?: typeof fetch;
133
+ packageVersion?: string | null;
134
+ }
135
+
136
+ const DEFAULT_AUTO_UPLOAD_THROTTLE_HOURS = 24;
137
+
138
+ /**
139
+ * Default throttle window for the auto-upload path. Operators can override via the
140
+ * env var `DENDRITE_WIKI_TELEMETRY_AUTO_UPLOAD_HOURS` (positive integer). Set
141
+ * `DENDRITE_WIKI_TELEMETRY_AUTO_UPLOAD=off` to disable the auto path entirely
142
+ * while keeping consent on (manual `dendrite-wiki telemetry upload` or the browser
143
+ * button still works).
144
+ */
145
+ function resolveAutoUploadThrottleHours(): number | null {
146
+ const disabled = (process.env.DENDRITE_WIKI_TELEMETRY_AUTO_UPLOAD ?? '').trim().toLowerCase();
147
+ if (disabled === 'off' || disabled === 'false' || disabled === '0' || disabled === 'no' || disabled === 'disable' || disabled === 'disabled') {
148
+ return null;
149
+ }
150
+ const raw = (process.env.DENDRITE_WIKI_TELEMETRY_AUTO_UPLOAD_HOURS ?? '').trim();
151
+ if (!raw) return DEFAULT_AUTO_UPLOAD_THROTTLE_HOURS;
152
+ const parsed = Number.parseInt(raw, 10);
153
+ if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_AUTO_UPLOAD_THROTTLE_HOURS;
154
+ return Math.min(parsed, 24 * 30); // hard cap at 30 days to avoid silent year-long throttles from a typo
155
+ }
156
+
157
+ export interface MaybeAutoUploadResult {
158
+ fired: boolean;
159
+ reason: 'no-consent' | 'auto-disabled' | 'no-destination' | 'throttled' | 'uploaded' | 'error';
160
+ detail?: string;
161
+ hoursSinceLastAttempt?: number | null;
162
+ }
163
+
164
+ /**
165
+ * T11: best-effort auto-upload at session start. Called from src/index.ts after the
166
+ * `session_started` benchmark event, runs in the background (never awaited from the
167
+ * server boot path), and short-circuits silently when:
168
+ *
169
+ * - consent is off (sharing not opted in)
170
+ * - operator set `DENDRITE_WIKI_TELEMETRY_AUTO_UPLOAD=off`
171
+ * - no upload destination is resolvable (env vars unset AND baked defaults empty)
172
+ * - the last attempt landed within the throttle window
173
+ *
174
+ * When all conditions allow, it triggers `uploadTelemetry()` once. The user never had
175
+ * to click anything after the original opt-in — that's the whole point.
176
+ */
177
+ export async function maybeAutoUploadTelemetry(
178
+ options: TelemetryUploadOptions = {}
179
+ ): Promise<MaybeAutoUploadResult> {
180
+ const root = path.resolve(options.root ?? process.cwd());
181
+ try {
182
+ const config = await readTelemetryConfig(root).catch(() => null);
183
+ if (config?.sharingMode !== 'opt-in') {
184
+ return { fired: false, reason: 'no-consent' };
185
+ }
186
+
187
+ const throttleHours = resolveAutoUploadThrottleHours();
188
+ if (throttleHours === null) {
189
+ return { fired: false, reason: 'auto-disabled' };
190
+ }
191
+
192
+ const target = resolveLibsqlUploadTarget();
193
+ if (!target.configured) {
194
+ return { fired: false, reason: 'no-destination' };
195
+ }
196
+
197
+ const { uploadAuditPath } = resolveTelemetryPaths(root);
198
+ const audit = await readTelemetryUploadAudit(uploadAuditPath).catch(() => null);
199
+ const lastAttemptIso = audit?.lastAttempt?.attemptedAt ?? null;
200
+ const hoursSinceLastAttempt = lastAttemptIso
201
+ ? (Date.now() - new Date(lastAttemptIso).getTime()) / (1000 * 60 * 60)
202
+ : null;
203
+
204
+ if (hoursSinceLastAttempt !== null && hoursSinceLastAttempt < throttleHours) {
205
+ return {
206
+ fired: false,
207
+ reason: 'throttled',
208
+ hoursSinceLastAttempt: Math.round(hoursSinceLastAttempt * 10) / 10,
209
+ detail: `Last attempt ${Math.round(hoursSinceLastAttempt * 10) / 10}h ago, throttle window is ${throttleHours}h.`
210
+ };
211
+ }
212
+
213
+ const result = await uploadTelemetry({ root, fetchImpl: options.fetchImpl, packageVersion: options.packageVersion });
214
+ return {
215
+ fired: true,
216
+ reason: result.ok ? 'uploaded' : 'error',
217
+ detail: result.message
218
+ };
219
+ } catch (error) {
220
+ return {
221
+ fired: false,
222
+ reason: 'error',
223
+ detail: error instanceof Error ? error.message : String(error)
224
+ };
225
+ }
226
+ }
227
+
228
+ interface LibsqlUploadTarget {
229
+ configured: boolean;
230
+ /** The Turso/libSQL HTTP pipeline endpoint, e.g. https://my-db-myorg.turso.io/v2/pipeline */
231
+ destination: string | null;
232
+ /** Bearer token for the libSQL HTTP API (Turso auth token). */
233
+ apiKey: string | null;
234
+ /** Target table name for the INSERT (defaults to benchmark_events). */
235
+ table: string;
236
+ }
237
+
238
+ export function resolveTelemetryPaths(root: string = process.cwd()): {
239
+ root: string;
240
+ configPath: string;
241
+ statusArtifactPath: string;
242
+ uploadAuditPath: string;
243
+ benchmarkEventLogPath: string;
244
+ benchmarkEventSummaryPath: string;
245
+ } {
246
+ const resolvedRoot = path.resolve(root);
247
+ return {
248
+ root: resolvedRoot,
249
+ configPath: path.join(resolvedRoot, telemetryConfigRelativePath),
250
+ statusArtifactPath: path.join(resolvedRoot, telemetryStatusArtifactRelativePath),
251
+ uploadAuditPath: path.join(resolvedRoot, telemetryUploadAuditRelativePath),
252
+ benchmarkEventLogPath: path.join(resolvedRoot, benchmarkEventLogRelativePath),
253
+ benchmarkEventSummaryPath: path.join(resolvedRoot, benchmarkEventSummaryRelativePath)
254
+ };
255
+ }
256
+
257
+ export async function readTelemetryConfig(root: string = process.cwd()): Promise<DendriteTelemetryConfig | null> {
258
+ const { configPath } = resolveTelemetryPaths(root);
259
+ const content = await fs.readFile(configPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
260
+ if (error.code === 'ENOENT') {
261
+ return null;
262
+ }
263
+ throw error;
264
+ });
265
+
266
+ if (content === null) {
267
+ return null;
268
+ }
269
+
270
+ const parsed = JSON.parse(content) as Partial<DendriteTelemetryConfig>;
271
+ if (parsed.schemaVersion !== 1) {
272
+ throw new Error(`Unsupported telemetry config schema in ${toPortablePath(path.relative(root, configPath))}.`);
273
+ }
274
+ if (parsed.sharingMode !== 'off' && parsed.sharingMode !== 'opt-in') {
275
+ throw new Error(`Invalid telemetry sharing mode in ${toPortablePath(path.relative(root, configPath))}.`);
276
+ }
277
+ if (typeof parsed.updatedAt !== 'string' || parsed.updatedAt.length === 0) {
278
+ throw new Error(`Telemetry config in ${toPortablePath(path.relative(root, configPath))} is missing updatedAt.`);
279
+ }
280
+ if (typeof parsed.installationId !== 'string' || parsed.installationId.length === 0) {
281
+ throw new Error(`Telemetry config in ${toPortablePath(path.relative(root, configPath))} is missing installationId.`);
282
+ }
283
+ if (typeof parsed.projectId !== 'string' || parsed.projectId.length === 0) {
284
+ throw new Error(`Telemetry config in ${toPortablePath(path.relative(root, configPath))} is missing projectId.`);
285
+ }
286
+
287
+ return {
288
+ schemaVersion: 1,
289
+ sharingMode: parsed.sharingMode,
290
+ updatedAt: parsed.updatedAt,
291
+ installationId: parsed.installationId,
292
+ projectId: parsed.projectId
293
+ };
294
+ }
295
+
296
+ export async function setTelemetrySharingMode(
297
+ sharingMode: DendriteTelemetrySharingMode,
298
+ root: string = process.cwd()
299
+ ): Promise<DendriteTelemetryStatusArtifact> {
300
+ const existingConfig = await readTelemetryConfig(root).catch((error) => {
301
+ if (error instanceof Error && /missing installationId|missing projectId/.test(error.message)) {
302
+ return null;
303
+ }
304
+ throw error;
305
+ });
306
+ const { configPath } = resolveTelemetryPaths(root);
307
+ const config: DendriteTelemetryConfig = {
308
+ schemaVersion: 1,
309
+ sharingMode,
310
+ updatedAt: new Date().toISOString(),
311
+ installationId: existingConfig?.installationId ?? randomUUID(),
312
+ projectId: existingConfig?.projectId ?? randomUUID()
313
+ };
314
+
315
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
316
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
317
+
318
+ return writeTelemetryStatusArtifact(root);
319
+ }
320
+
321
+ export async function writeTelemetryStatusArtifact(root: string = process.cwd()): Promise<DendriteTelemetryStatusArtifact> {
322
+ const telemetryStatus = await buildTelemetryStatusArtifact(root);
323
+ const { statusArtifactPath } = resolveTelemetryPaths(root);
324
+
325
+ await fs.mkdir(path.dirname(statusArtifactPath), { recursive: true });
326
+ await fs.writeFile(statusArtifactPath, `${JSON.stringify(telemetryStatus, null, 2)}\n`, 'utf8');
327
+
328
+ return telemetryStatus;
329
+ }
330
+
331
+ export async function uploadTelemetry(options: TelemetryUploadOptions = {}): Promise<DendriteTelemetryUploadResult> {
332
+ const root = path.resolve(options.root ?? process.cwd());
333
+ const fetchImpl = options.fetchImpl ?? fetch;
334
+ const telemetryConfig = await readTelemetryConfig(root).catch((error) => {
335
+ if (error instanceof Error && /missing installationId|missing projectId/.test(error.message)) {
336
+ return null;
337
+ }
338
+ throw error;
339
+ });
340
+ const target = resolveLibsqlUploadTarget();
341
+ const packageVersion = options.packageVersion ?? (await readPackageVersion(root));
342
+
343
+ if (telemetryConfig?.sharingMode !== 'opt-in') {
344
+ return finalizeUploadAttempt(root, target.destination, {
345
+ attemptedAt: new Date().toISOString(),
346
+ status: 'skipped',
347
+ destination: target.destination,
348
+ reason: 'Telemetry sharing is not enabled. Run dendrite-wiki telemetry opt-in first.',
349
+ httpStatus: null,
350
+ responseBody: null,
351
+ payload: null
352
+ });
353
+ }
354
+
355
+ if (!target.configured || !target.destination || !target.apiKey) {
356
+ return finalizeUploadAttempt(root, target.destination, {
357
+ attemptedAt: new Date().toISOString(),
358
+ status: 'skipped',
359
+ destination: target.destination,
360
+ reason: 'Turso libSQL upload is not configured. Set DENDRITE_WIKI_TELEMETRY_TURSO_URL (e.g. https://<db>-<org>.turso.io) and DENDRITE_WIKI_TELEMETRY_TURSO_TOKEN (auth token).',
361
+ httpStatus: null,
362
+ responseBody: null,
363
+ payload: null
364
+ });
365
+ }
366
+
367
+ const payload = await buildTelemetryUploadPayload(root, telemetryConfig, packageVersion);
368
+ const requestBody = buildLibsqlInsertRequest(target.table, payload);
369
+
370
+ let attempt = 0;
371
+ let lastError: DendriteTelemetryUploadAttempt | null = null;
372
+ while (attempt < 2) {
373
+ attempt += 1;
374
+ try {
375
+ const response = await fetchImpl(target.destination, {
376
+ method: 'POST',
377
+ headers: {
378
+ 'content-type': 'application/json',
379
+ authorization: `Bearer ${target.apiKey}`
380
+ },
381
+ body: JSON.stringify(requestBody)
382
+ });
383
+ const responseBody = await response.text();
384
+
385
+ if (response.ok) {
386
+ // libSQL returns 200 with a results payload even on per-statement errors. Inspect
387
+ // the first response.results[].type — if it's 'error', treat the whole pipeline as
388
+ // failed so the audit reflects reality (the row didn't actually land).
389
+ const pipelineError = parseLibsqlPipelineError(responseBody);
390
+ if (pipelineError) {
391
+ lastError = {
392
+ attemptedAt: new Date().toISOString(),
393
+ status: 'error',
394
+ destination: target.destination,
395
+ reason: `Turso libSQL pipeline reported error: ${pipelineError}`,
396
+ httpStatus: response.status,
397
+ responseBody: responseBody.length > 0 ? responseBody : null,
398
+ payload
399
+ };
400
+ // Per-statement errors are deterministic (e.g. table missing, schema mismatch)
401
+ // so retrying won't help — break out.
402
+ break;
403
+ }
404
+
405
+ return finalizeUploadAttempt(root, target.destination, {
406
+ attemptedAt: new Date().toISOString(),
407
+ status: 'success',
408
+ destination: target.destination,
409
+ reason: null,
410
+ httpStatus: response.status,
411
+ responseBody: responseBody.length > 0 ? responseBody : null,
412
+ payload
413
+ });
414
+ }
415
+
416
+ lastError = {
417
+ attemptedAt: new Date().toISOString(),
418
+ status: 'error',
419
+ destination: target.destination,
420
+ reason: `Turso libSQL upload failed with HTTP ${response.status}.`,
421
+ httpStatus: response.status,
422
+ responseBody: responseBody.length > 0 ? responseBody : null,
423
+ payload
424
+ };
425
+ if (response.status < 500) {
426
+ break;
427
+ }
428
+ } catch (error) {
429
+ lastError = {
430
+ attemptedAt: new Date().toISOString(),
431
+ status: 'error',
432
+ destination: target.destination,
433
+ reason: error instanceof Error ? error.message : String(error),
434
+ httpStatus: null,
435
+ responseBody: null,
436
+ payload
437
+ };
438
+ }
439
+ }
440
+
441
+ return finalizeUploadAttempt(
442
+ root,
443
+ target.destination,
444
+ lastError ?? {
445
+ attemptedAt: new Date().toISOString(),
446
+ status: 'error',
447
+ destination: target.destination,
448
+ reason: 'Turso libSQL upload failed.',
449
+ httpStatus: null,
450
+ responseBody: null,
451
+ payload
452
+ }
453
+ );
454
+ }
455
+
456
+ // libSQL HTTP API uses a "pipeline" of statements. We always send one INSERT with named args
457
+ // followed by a `close` request so the connection is released cleanly. Schema documented in
458
+ // docs/wiki/privacy-telemetry-disclosure.md alongside the operator setup steps.
459
+ function buildLibsqlInsertRequest(table: string, payload: DendriteTelemetryUploadPayload): {
460
+ requests: Array<{ type: string; stmt?: { sql: string; named_args: Array<{ name: string; value: { type: string; value?: string } }> } }>;
461
+ } {
462
+ const sql = `INSERT INTO ${table} (installation_id, project_id, package_version, event, timestamp, sharing_mode, client_profiles, metrics) VALUES (:installation_id, :project_id, :package_version, :event, :timestamp, :sharing_mode, :client_profiles, :metrics)`;
463
+ const namedArg = (name: string, value: string | null) =>
464
+ value === null
465
+ ? { name, value: { type: 'null' } }
466
+ : { name, value: { type: 'text', value } };
467
+ return {
468
+ requests: [
469
+ {
470
+ type: 'execute',
471
+ stmt: {
472
+ sql,
473
+ named_args: [
474
+ namedArg('installation_id', payload.installationId),
475
+ namedArg('project_id', payload.projectId),
476
+ namedArg('package_version', payload.packageVersion),
477
+ namedArg('event', payload.event),
478
+ namedArg('timestamp', payload.timestamp),
479
+ namedArg('sharing_mode', payload.sharingMode),
480
+ namedArg('client_profiles', JSON.stringify(payload.clientProfiles)),
481
+ namedArg('metrics', JSON.stringify(payload.metrics))
482
+ ]
483
+ }
484
+ },
485
+ { type: 'close' }
486
+ ]
487
+ };
488
+ }
489
+
490
+ function parseLibsqlPipelineError(responseBody: string): string | null {
491
+ try {
492
+ const parsed = JSON.parse(responseBody) as { results?: Array<{ type?: string; error?: { message?: string } }> };
493
+ const errored = (parsed.results ?? []).find((r) => r?.type === 'error');
494
+ if (!errored) return null;
495
+ return errored.error?.message ?? 'unknown pipeline error';
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
501
+ async function buildTelemetryStatusArtifact(root: string): Promise<DendriteTelemetryStatusArtifact> {
502
+ const paths = resolveTelemetryPaths(root);
503
+ const config = await readTelemetryConfig(root);
504
+ const benchmarkEventSummary = await readBenchmarkEventSummary(paths.benchmarkEventSummaryPath);
505
+ const uploadAudit = await readTelemetryUploadAudit(paths.uploadAuditPath);
506
+ const uploadTarget = resolveLibsqlUploadTarget();
507
+ const latestEventAt = benchmarkEventSummary?.recentEvents.at(-1)?.timestamp ?? null;
508
+ const sharingMode = config?.sharingMode ?? 'off';
509
+ const notes = buildTelemetryNotes(sharingMode, benchmarkEventSummary?.eventCount ?? 0, uploadTarget.configured, uploadAudit?.lastAttempt ?? null);
510
+
511
+ return {
512
+ schemaVersion: 1,
513
+ generatedAt: new Date().toISOString(),
514
+ sharingMode,
515
+ sharingEnabled: sharingMode === 'opt-in',
516
+ consent: {
517
+ isExplicit: config !== null,
518
+ updatedAt: config?.updatedAt ?? null
519
+ },
520
+ paths: {
521
+ configPath: toPortablePath(path.relative(paths.root, paths.configPath)),
522
+ statusArtifactPath: toPortablePath(path.relative(paths.root, paths.statusArtifactPath)),
523
+ uploadAuditPath: toPortablePath(path.relative(paths.root, paths.uploadAuditPath)),
524
+ benchmarkEventLogPath: toPortablePath(path.relative(paths.root, paths.benchmarkEventLogPath)),
525
+ benchmarkEventSummaryPath: toPortablePath(path.relative(paths.root, paths.benchmarkEventSummaryPath))
526
+ },
527
+ remoteUpload: {
528
+ configured: uploadTarget.configured,
529
+ destination: uploadTarget.destination,
530
+ auditPath: toPortablePath(path.relative(paths.root, paths.uploadAuditPath)),
531
+ lastAttemptAt: uploadAudit?.lastAttempt?.attemptedAt ?? null,
532
+ lastAttemptStatus: uploadAudit?.lastAttempt?.status ?? null,
533
+ lastSuccessAt: uploadAudit?.lastSuccess?.attemptedAt ?? null,
534
+ lastError: uploadAudit?.lastAttempt?.status === 'error' ? uploadAudit.lastAttempt.reason : null,
535
+ lastPayloadPreview: uploadAudit?.lastSuccess?.payload ?? uploadAudit?.lastAttempt?.payload ?? null
536
+ },
537
+ benchmarkEvents: {
538
+ eventCount: benchmarkEventSummary?.eventCount ?? 0,
539
+ latestEventAt,
540
+ byType: benchmarkEventSummary?.byType ?? createEmptyEventCounts()
541
+ },
542
+ notes
543
+ };
544
+ }
545
+
546
+ async function readBenchmarkEventSummary(summaryPath: string): Promise<DendriteBenchmarkEventSummary | null> {
547
+ const content = await fs.readFile(summaryPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
548
+ if (error.code === 'ENOENT') {
549
+ return null;
550
+ }
551
+ throw error;
552
+ });
553
+
554
+ if (content === null) {
555
+ return null;
556
+ }
557
+
558
+ return JSON.parse(content) as DendriteBenchmarkEventSummary;
559
+ }
560
+
561
+ async function readTelemetryUploadAudit(auditPath: string): Promise<DendriteTelemetryUploadAudit | null> {
562
+ const content = await fs.readFile(auditPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
563
+ if (error.code === 'ENOENT') {
564
+ return null;
565
+ }
566
+ throw error;
567
+ });
568
+
569
+ if (content === null) {
570
+ return null;
571
+ }
572
+
573
+ return JSON.parse(content) as DendriteTelemetryUploadAudit;
574
+ }
575
+
576
+ async function writeTelemetryUploadAudit(root: string, audit: DendriteTelemetryUploadAudit): Promise<void> {
577
+ const { uploadAuditPath } = resolveTelemetryPaths(root);
578
+ await fs.mkdir(path.dirname(uploadAuditPath), { recursive: true });
579
+ await fs.writeFile(uploadAuditPath, `${JSON.stringify(audit, null, 2)}\n`, 'utf8');
580
+ }
581
+
582
+ async function finalizeUploadAttempt(
583
+ root: string,
584
+ destination: string | null,
585
+ attempt: DendriteTelemetryUploadAttempt
586
+ ): Promise<DendriteTelemetryUploadResult> {
587
+ const previousAudit = await readTelemetryUploadAudit(resolveTelemetryPaths(root).uploadAuditPath);
588
+ const audit: DendriteTelemetryUploadAudit = {
589
+ schemaVersion: 1,
590
+ updatedAt: attempt.attemptedAt,
591
+ destination,
592
+ lastAttempt: attempt,
593
+ lastSuccess: attempt.status === 'success' ? attempt : previousAudit?.lastSuccess ?? null
594
+ };
595
+
596
+ await writeTelemetryUploadAudit(root, audit);
597
+ const status = await writeTelemetryStatusArtifact(root);
598
+
599
+ return {
600
+ ok: attempt.status === 'success',
601
+ message: attempt.reason ?? (attempt.status === 'success' ? 'Telemetry upload completed.' : 'Telemetry upload skipped.'),
602
+ auditPath: status.paths.uploadAuditPath,
603
+ destination,
604
+ attempt,
605
+ status
606
+ };
607
+ }
608
+
609
+ /**
610
+ * T12: build (but never send) the exact payload that `uploadTelemetry()` would
611
+ * post next, so the browser's "What will be sent" preview panel can show users
612
+ * the truth of what leaves their machine before they click the manual Upload
613
+ * button. Returns null when no consent record exists yet (preview is meaningful
614
+ * only after the user has at least once recorded explicit consent — that's when
615
+ * the installationId/projectId UUIDs were generated).
616
+ */
617
+ export async function previewTelemetryUploadPayload(
618
+ options: { root?: string; packageVersion?: string | null } = {}
619
+ ): Promise<DendriteTelemetryUploadPayload | null> {
620
+ const root = path.resolve(options.root ?? process.cwd());
621
+ const config = await readTelemetryConfig(root).catch(() => null);
622
+ if (!config) return null;
623
+ const packageVersion = options.packageVersion ?? (await readPackageVersion(root));
624
+ return buildTelemetryUploadPayload(root, config, packageVersion);
625
+ }
626
+
627
+ async function buildTelemetryUploadPayload(
628
+ root: string,
629
+ config: DendriteTelemetryConfig,
630
+ packageVersion: string | null
631
+ ): Promise<DendriteTelemetryUploadPayload> {
632
+ const benchmarkEventSummary = await readBenchmarkEventSummary(resolveTelemetryPaths(root).benchmarkEventSummaryPath);
633
+
634
+ return {
635
+ schemaVersion: 1,
636
+ installationId: config.installationId,
637
+ projectId: config.projectId,
638
+ packageVersion,
639
+ event: 'telemetry_summary',
640
+ timestamp: new Date().toISOString(),
641
+ sharingMode: 'opt-in',
642
+ clientProfiles: readClientProfilesFromEnv(),
643
+ metrics: {
644
+ eventCount: benchmarkEventSummary?.eventCount ?? 0,
645
+ sessionStartedCount: benchmarkEventSummary?.usage.sessionStartedCount ?? 0,
646
+ contextRequestCount: benchmarkEventSummary?.usage.contextRequestCount ?? 0,
647
+ wikiUpdateCount: benchmarkEventSummary?.usage.wikiUpdateCount ?? 0,
648
+ maintenanceStateChangeCount: benchmarkEventSummary?.usage.maintenanceStateChangeCount ?? 0,
649
+ sessionSnapshotCount: benchmarkEventSummary?.usage.sessionSnapshotCount ?? 0,
650
+ latestContextPageCount: benchmarkEventSummary?.orientation.latestContextPageCount ?? null,
651
+ latestContextOmittedPageCount: benchmarkEventSummary?.orientation.latestContextOmittedPageCount ?? null,
652
+ latestOpenQuestionCount: benchmarkEventSummary?.orientation.latestOpenQuestionCount ?? null,
653
+ acceptedProposalCount: benchmarkEventSummary?.maintenance.acceptedProposalCount ?? 0,
654
+ latestLintFindingCount: benchmarkEventSummary?.maintenance.latestLintFindingCount ?? null,
655
+ latestProposalCount: benchmarkEventSummary?.maintenance.latestProposalCount ?? null
656
+ }
657
+ };
658
+ }
659
+
660
+ function resolveLibsqlUploadTarget(): LibsqlUploadTarget {
661
+ // Turso/libSQL HTTP API:
662
+ // - Base URL: the database host (e.g. https://my-db-myorg.turso.io).
663
+ // Endpoint becomes <base>/v2/pipeline.
664
+ // - Token: an authentication token from `turso db tokens create <db>` or the dashboard.
665
+ // - Table: which table to INSERT into (defaults to benchmark_events).
666
+ //
667
+ // Resolution order (Benchmark Telemetry Database Roadmap T2):
668
+ // 1. Env vars (BYO destination — operator-owned Turso DB, wins over baked defaults)
669
+ // 2. Build-time baked defaults from telemetry-defaults.ts (Dendrite-hosted destination,
670
+ // written at publish time only — empty in source)
671
+ // 3. Both empty → upload returns `skipped` with a clear audit entry
672
+ const envUrl = process.env.DENDRITE_WIKI_TELEMETRY_TURSO_URL?.trim() ?? '';
673
+ const envToken = process.env.DENDRITE_WIKI_TELEMETRY_TURSO_TOKEN?.trim() ?? '';
674
+ const envTable = process.env.DENDRITE_WIKI_TELEMETRY_TURSO_TABLE?.trim() ?? '';
675
+
676
+ const baseUrl = envUrl || TELEMETRY_DEFAULT_URL.trim();
677
+ const apiKey = envToken || TELEMETRY_DEFAULT_TOKEN.trim();
678
+ const table = envTable || TELEMETRY_DEFAULT_TABLE.trim() || 'benchmark_events';
679
+
680
+ const destination = baseUrl ? `${baseUrl.replace(/\/$/, '')}/v2/pipeline` : null;
681
+
682
+ if (!baseUrl || !apiKey) {
683
+ return { configured: false, destination, apiKey: apiKey || null, table };
684
+ }
685
+
686
+ return { configured: true, destination, apiKey, table };
687
+ }
688
+
689
+ function createEmptyEventCounts(): DendriteBenchmarkEventSummary['byType'] {
690
+ return {
691
+ session_started: 0,
692
+ context_requested: 0,
693
+ wiki_updated: 0,
694
+ maintenance_state_changed: 0,
695
+ session_snapshot: 0
696
+ };
697
+ }
698
+
699
+ function buildTelemetryNotes(
700
+ sharingMode: DendriteTelemetrySharingMode,
701
+ eventCount: number,
702
+ uploadConfigured: boolean,
703
+ lastAttempt: DendriteTelemetryUploadAttempt | null
704
+ ): string[] {
705
+ const notes = [`Automatic local benchmark events remain enabled and currently include ${eventCount} captured events.`];
706
+
707
+ if (sharingMode === 'opt-in') {
708
+ notes.push(
709
+ uploadConfigured
710
+ ? 'Telemetry sharing consent is recorded locally and the uploader can send the sanitized summary payload when you run dendrite-wiki telemetry upload.'
711
+ : 'Telemetry sharing consent is recorded locally, but no Turso libSQL upload destination is configured yet. Set DENDRITE_WIKI_TELEMETRY_TURSO_URL and DENDRITE_WIKI_TELEMETRY_TURSO_TOKEN to enable uploads.'
712
+ );
713
+ } else {
714
+ notes.push('Telemetry sharing is off. Local benchmark artifacts continue to work without sending data anywhere.');
715
+ }
716
+
717
+ if (lastAttempt?.status === 'success') {
718
+ notes.push('The last telemetry upload completed successfully and the sanitized payload preview is available on this page.');
719
+ } else if (lastAttempt?.status === 'error') {
720
+ notes.push(`The last telemetry upload failed: ${lastAttempt.reason ?? 'unknown error'}`);
721
+ }
722
+
723
+ return notes;
724
+ }
725
+
726
+ function readClientProfilesFromEnv(): string[] {
727
+ const value = process.env.DENDRITE_WIKI_TELEMETRY_CLIENT_PROFILES?.trim();
728
+ if (!value) {
729
+ return [];
730
+ }
731
+
732
+ return value
733
+ .split(',')
734
+ .map((item) => item.trim())
735
+ .filter((item) => item.length > 0);
736
+ }
737
+
738
+ async function readPackageVersion(root: string): Promise<string | null> {
739
+ const packageJsonPath = path.join(root, 'package.json');
740
+ const content = await fs.readFile(packageJsonPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
741
+ if (error.code === 'ENOENT') {
742
+ return null;
743
+ }
744
+ throw error;
745
+ });
746
+
747
+ if (content === null) {
748
+ return null;
749
+ }
750
+
751
+ const parsed = JSON.parse(content) as { version?: unknown };
752
+ return typeof parsed.version === 'string' ? parsed.version : null;
753
+ }
754
+
755
+ function toPortablePath(value: string): string {
756
+ return value.replace(/\\/g, '/');
757
+ }