@lorekit/cli 1.29.2 → 1.30.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/bin/lorekit.mjs CHANGED
@@ -125,6 +125,7 @@ ${c.bold('Options')}
125
125
  --no-hooks Skip wiring the lifecycle hooks (install)
126
126
  --force Overwrite existing skill files (install)
127
127
  --deep Do a write→read→delete round-trip (doctor)
128
+ --telemetry Verify the OTLP export credential works (doctor)
128
129
  --adapter <name> Host framework for hook: claude | cursor | codex
129
130
  --event <name> Host hook event (else read from stdin payload)
130
131
  -h, --help Show this help
@@ -230,10 +231,15 @@ ${c.bold('Options')}
230
231
  -t, --token <token> Token override (else .mcp.json / LOREKIT_TOKEN)
231
232
  --store <path> Local project-tier store directory (default: .lorekit)
232
233
  --deep Do a write→read→delete round-trip
234
+ --telemetry Focused run: skip the other checks, POST a probe
235
+ span to the OTLP endpoint, and FAIL if the Dash0
236
+ ingesting token is missing or rejected. Without it,
237
+ telemetry is reported as info only.
233
238
 
234
239
  ${c.bold('Examples')}
235
240
  npx @lorekit/cli doctor
236
241
  npx @lorekit/cli doctor --deep
242
+ npx @lorekit/cli doctor --telemetry
237
243
  npx @lorekit/cli doctor --mode local
238
244
  `,
239
245
  list: `${c.bold('lorekit list')} — list the memories that apply to the current directory ${c.dim('(alias: ls)')}
@@ -593,7 +599,7 @@ ${c.bold('Options')}
593
599
  const KNOWN_FLAGS = [
594
600
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
595
601
  'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
596
- 'event', 'json', 'scope', 'threshold', 'help', 'version',
602
+ 'event', 'json', 'scope', 'threshold', 'help', 'version', 'telemetry',
597
603
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
598
604
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
599
605
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
@@ -621,7 +627,7 @@ async function main() {
621
627
  const argv = process.argv.slice(2);
622
628
  const args = parseArgs(argv, {
623
629
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
624
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl'],
630
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry'],
625
631
  known: KNOWN_FLAGS,
626
632
  });
627
633
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.29.2",
3
+ "version": "1.30.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/doctor.mjs CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  readLorekitJson,
18
18
  } from './config.mjs';
19
19
  import { splitEndpoint } from './mcp.mjs';
20
+ import {
21
+ resolveTelemetryConfig,
22
+ resolveTelemetryTokenSource,
23
+ probeTelemetryExport,
24
+ } from './telemetry.mjs';
20
25
  import { deriveScope } from './scope.mjs';
21
26
  import { loadControl } from './control.mjs';
22
27
  import { createStore } from './store/index.mjs';
@@ -38,6 +43,19 @@ export async function doctor(args) {
38
43
  heading('LoreKit doctor');
39
44
  log(` project: ${c.dim(root)}\n`);
40
45
 
46
+ // 0. `--telemetry` is a FOCUSED run, not a modifier on the full sweep.
47
+ //
48
+ // It exists to be a CI gate on one specific credential — the Dash0
49
+ // ingesting-only OTLP token — and a bare runner has no skills installed, no
50
+ // .mcp.json and no LoreKit API token, so a full sweep there fails for four
51
+ // unrelated reasons and tells you nothing about the one thing being gated.
52
+ // Returning early keeps the exit code a clean answer to a single question:
53
+ // "can this build still emit telemetry?"
54
+ if (args.telemetry) {
55
+ await checkTelemetryExport(args, root, record);
56
+ return summarize(failures, warnings, failedChecks, 'Telemetry export is working.');
57
+ }
58
+
41
59
  // 1. Runtime.
42
60
  const major = Number(process.versions.node.split('.')[0]);
43
61
  record(
@@ -123,6 +141,9 @@ export async function doctor(args) {
123
141
  // 4b. BYOD storage connectivity check.
124
142
  await checkBYODStorage(record);
125
143
 
144
+ // 4c. Dash0 telemetry export — is the CLI's own phone-home still working?
145
+ await checkTelemetryExport(args, root, record);
146
+
126
147
  // 5. Scope.
127
148
  const scope = deriveScope(root);
128
149
  if (scope.hasRemote) {
@@ -165,10 +186,18 @@ export async function doctor(args) {
165
186
  }
166
187
  }
167
188
 
168
- // Summary.
189
+ return summarize(failures, warnings, failedChecks);
190
+ }
191
+
192
+ /**
193
+ * Print the Summary block and build doctor's `{ exitCode, ...extra }` return.
194
+ * Extracted so the focused `--telemetry` run and the full sweep end the same
195
+ * way — same summary line, same exit-code rule, same bounded telemetry extra.
196
+ */
197
+ function summarize(failures, warnings, failedChecks, ready = 'LoreKit memory is ready.') {
169
198
  heading('Summary');
170
199
  if (failures === 0 && warnings === 0) {
171
- log(` ${c.green('All checks passed.')} LoreKit memory is ready.`);
200
+ log(` ${c.green('All checks passed.')} ${ready}`);
172
201
  } else {
173
202
  log(
174
203
  ` ${failures ? c.red(failures + ' failed') : c.green('0 failed')}, ${
@@ -438,6 +467,102 @@ function detectDuplicateHooks(root) {
438
467
  return CLAUDE_HOOK_EVENTS.filter((event) => inProject.has(event) && inGlobal.has(event));
439
468
  }
440
469
 
470
+ /**
471
+ * Is the CLI's own OpenTelemetry export still working?
472
+ *
473
+ * This is a DIFFERENT credential from the `token` / `authentication` checks
474
+ * above: those verify the LoreKit API token (`lk_rw_*`) that reads and writes
475
+ * memories. This one verifies the Dash0 ingesting-only OTLP token, which is
476
+ * baked into the published tarball at release time and is otherwise invisible —
477
+ * `exportInvocation` swallows every transport error by design, so a revoked
478
+ * token, a moved endpoint, or a release that published with the
479
+ * `LOREKIT_TELEMETRY_TOKEN` secret unset all look identical from the outside:
480
+ * silence. Nothing in CI noticed, because nothing was looking.
481
+ *
482
+ * Two tiers, so the default `doctor` run stays offline and side-effect-free:
483
+ * • always — report whether export is ON and where the credential came from,
484
+ * as `info`. Never a failure: end users legitimately opt out, and a user's
485
+ * machine having no phone-home is not a broken install.
486
+ * • `--telemetry` — actually POST a probe span and assert the endpoint
487
+ * accepted it. Here a dead export IS a failure; that flag is what CI passes.
488
+ */
489
+ async function checkTelemetryExport(args, root, record) {
490
+ const required = Boolean(args.telemetry);
491
+
492
+ let config;
493
+ try {
494
+ // Read `.lorekit.json` from the SAME root every other check uses. Left to
495
+ // its default, `resolveTelemetryConfig` reads it from `process.cwd()`, so a
496
+ // `telemetry.disabled` in the shell's directory would decide the verdict for
497
+ // an unrelated `--dir`.
498
+ config = resolveTelemetryConfig(process.env, readLorekitJson(root));
499
+ } catch {
500
+ config = { enabled: false, reason: 'error' };
501
+ }
502
+ const source = resolveTelemetryTokenSource();
503
+
504
+ if (!config.enabled) {
505
+ // Report the reason `resolveTelemetryConfig` actually returned. Deriving it
506
+ // from the token source instead misreports every case where a credential
507
+ // variable is set but unusable — e.g. an OTEL_EXPORTER_OTLP_HEADERS that
508
+ // parses to zero headers, which is a broken credential, not an opt-out.
509
+ const detail =
510
+ config.reason === 'opted-out'
511
+ ? 'export off — opted out via LOREKIT_TELEMETRY / DO_NOT_TRACK / `telemetry.disabled` in .lorekit.json'
512
+ : config.reason === 'no-endpoint'
513
+ ? 'export off — no OTLP endpoint resolved. Published tarballs get one baked in at release; ' +
514
+ 'set OTEL_EXPORTER_OTLP_ENDPOINT to override.'
515
+ : config.reason === 'error'
516
+ ? 'export off — the telemetry config could not be resolved'
517
+ : 'export off — no OTLP credential resolved. Published tarballs get one injected at release; ' +
518
+ 'set LOREKIT_TELEMETRY_TOKEN (or OTEL_EXPORTER_OTLP_HEADERS) to override.';
519
+ record(required ? 'fail' : 'info', 'telemetry', detail);
520
+ return;
521
+ }
522
+
523
+ record('info', 'telemetry', `export on → ${config.endpoint} ${c.dim(`(credential from ${source})`)}`);
524
+
525
+ // The probe writes a real span to a real backend — only on explicit request.
526
+ if (!required) return;
527
+
528
+ const probe = await probeTelemetryExport(config, { version: cliVersion(), timeoutMs: 5000 });
529
+
530
+ if (probe.networkError) {
531
+ record('fail', 'telemetry export', `could not reach ${config.endpoint} — ${probe.networkError}`);
532
+ } else if (probe.unauthorized) {
533
+ record(
534
+ 'fail',
535
+ 'telemetry export',
536
+ `probe span REJECTED (HTTP ${probe.httpStatus}) — the OTLP token is revoked, expired, or was never valid. ` +
537
+ 'Mint a new Dash0 ingesting-only token and update the LOREKIT_TELEMETRY_TOKEN secret.',
538
+ );
539
+ } else if (probe.rejectedSpans) {
540
+ // A 2xx that dropped the span. Reporting the bare status here would read as
541
+ // a contradiction ("rejected (HTTP 200)"), so name the partial-success
542
+ // envelope the collector actually answered with.
543
+ record(
544
+ 'fail',
545
+ 'telemetry export',
546
+ `probe span REJECTED by the collector (HTTP ${probe.httpStatus}, partialSuccess.rejectedSpans=${probe.rejectedSpans}) — ` +
547
+ 'the endpoint answered success but did not ingest the span' +
548
+ (probe.rejectionMessage ? `: ${probe.rejectionMessage}` : '.'),
549
+ );
550
+ } else if (probe.ok) {
551
+ record('pass', 'telemetry export', `probe span accepted (HTTP ${probe.httpStatus}) — the token can ingest`);
552
+ } else {
553
+ record('fail', 'telemetry export', `probe span rejected (HTTP ${probe.httpStatus})`);
554
+ }
555
+ }
556
+
557
+ /** The running CLI version, for stamping the telemetry probe span. */
558
+ function cliVersion() {
559
+ try {
560
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
561
+ } catch {
562
+ return '0.0.0';
563
+ }
564
+ }
565
+
441
566
  async function checkBYODStorage(record) {
442
567
  const storageUrl = process.env['LOREKIT_STORAGE_URL'];
443
568
  const storageAnonKey = process.env['LOREKIT_STORAGE_ANON_KEY'];
package/src/hook.mjs CHANGED
@@ -168,6 +168,11 @@ async function run(args) {
168
168
  // consumed, so a clean early turn stays silent without burning the marker —
169
169
  // a later turn that does hit friction can still fire (once).
170
170
  const stopMode = control.hooksStop || 'friction';
171
+ // Once the retro has fired, short-circuit BEFORE the (growing) transcript
172
+ // read — the 'retro' marker is only ever SET on emit below, so a clean
173
+ // earlier turn never reaches that line to consume it, and every later Stop
174
+ // this session skips the re-parse cheaply (read-only peek, never consumed).
175
+ if (sessionMarkerExists(parsed.sessionId, 'retro')) return 0;
171
176
  let reasons = [];
172
177
  let friction = null;
173
178
  if (stopMode === 'friction') {
package/src/telemetry.mjs CHANGED
@@ -73,33 +73,42 @@ export function getActiveTraceparent() {
73
73
 
74
74
  /**
75
75
  * Resolve telemetry config from env + baked-in defaults + .lorekit.json.
76
- * Returns { enabled: false } when disabled or unconfigured, else the endpoint
77
- * and headers to export with.
76
+ * Returns { enabled: false, reason } when disabled or unconfigured, else the
77
+ * endpoint and headers to export with.
78
+ *
79
+ * `reason` names WHY export is off, because the three causes are not
80
+ * interchangeable to a user and cannot be re-derived from the outside:
81
+ * • `opted-out` — LOREKIT_TELEMETRY / DO_NOT_TRACK / `telemetry.disabled`.
82
+ * • `no-endpoint` — nothing to export to.
83
+ * • `no-credential` — an endpoint, but no usable auth header. Note that an
84
+ * OTEL_EXPORTER_OTLP_HEADERS that parses to zero headers lands here, which
85
+ * is exactly why a caller must not infer the cause from the token source:
86
+ * the variable is set, yet no credential resolved.
78
87
  * @param {object} [env] defaults to process.env
79
88
  * @param {object} [repoConfig] pre-loaded .lorekit.json (optional; read from cwd if absent)
80
89
  */
81
90
  export function resolveTelemetryConfig(env = process.env, repoConfig) {
82
91
  const optOut = env.LOREKIT_TELEMETRY;
83
92
  if (optOut !== undefined && OFF_VALUES.has(String(optOut).trim().toLowerCase())) {
84
- return { enabled: false };
93
+ return { enabled: false, reason: 'opted-out' };
85
94
  }
86
95
  // DNT spec designates exactly `1` as the opt-out signal (consoledonottrack.com).
87
96
  // Match it precisely — a stray `DO_NOT_TRACK=false` should NOT disable export
88
97
  // (use LOREKIT_TELEMETRY for the loose app-specific opt-out values).
89
98
  if (env.DO_NOT_TRACK && String(env.DO_NOT_TRACK).trim() === '1') {
90
- return { enabled: false };
99
+ return { enabled: false, reason: 'opted-out' };
91
100
  }
92
101
  // `telemetry.disabled: true` in .lorekit.json — team-level opt-out committed
93
102
  // to the repo. Checked after env overrides (env always wins).
94
103
  const cfg = repoConfig !== undefined ? repoConfig : readLorekitJson(process.cwd());
95
104
  if (cfg['telemetry.disabled'] === true) {
96
- return { enabled: false };
105
+ return { enabled: false, reason: 'opted-out' };
97
106
  }
98
107
 
99
108
  const endpoint = (env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_ENDPOINT || '')
100
109
  .trim()
101
110
  .replace(/\/+$/, '');
102
- if (!endpoint) return { enabled: false };
111
+ if (!endpoint) return { enabled: false, reason: 'no-endpoint' };
103
112
 
104
113
  const headers = {};
105
114
  // Auth header priority (highest first):
@@ -124,7 +133,7 @@ export function resolveTelemetryConfig(env = process.env, repoConfig) {
124
133
  // headers) to authenticate — no point exporting an unauthenticated request.
125
134
  const usingDefaultEndpoint = !env.OTEL_EXPORTER_OTLP_ENDPOINT;
126
135
  if (usingDefaultEndpoint && Object.keys(headers).length === 0) {
127
- return { enabled: false };
136
+ return { enabled: false, reason: 'no-credential' };
128
137
  }
129
138
 
130
139
  // Dataset routing, highest precedence first: an explicit `Dash0-Dataset`
@@ -142,6 +151,21 @@ export function resolveTelemetryConfig(env = process.env, repoConfig) {
142
151
  return { enabled: true, endpoint, headers };
143
152
  }
144
153
 
154
+ /**
155
+ * Which source supplied the OTLP credential, for `doctor` to report. Mirrors
156
+ * the priority order in `resolveTelemetryConfig` above — keep the two in step.
157
+ * Returns a human-readable label, or `'none'` when nothing resolved.
158
+ * @param {object} [env] defaults to process.env
159
+ */
160
+ export function resolveTelemetryTokenSource(env = process.env) {
161
+ if (env.OTEL_EXPORTER_OTLP_HEADERS) return 'OTEL_EXPORTER_OTLP_HEADERS';
162
+ if (env.LOREKIT_TELEMETRY_TOKEN && String(env.LOREKIT_TELEMETRY_TOKEN).trim()) {
163
+ return 'LOREKIT_TELEMETRY_TOKEN';
164
+ }
165
+ if (DEFAULT_TOKEN) return 'the token baked in at publish time';
166
+ return 'none';
167
+ }
168
+
145
169
  // ── ID + value helpers (mirror _shared/otel.ts) ───────────────────────────────
146
170
 
147
171
  export function randHex(bytes) {
@@ -333,6 +357,101 @@ export async function exportInvocation(config, { version, name, attributes, star
333
357
  ]);
334
358
  }
335
359
 
360
+ /**
361
+ * Send ONE synthetic span to the configured OTLP endpoint and report what the
362
+ * collector said about it.
363
+ *
364
+ * Deliberately not routed through `post()`: command telemetry swallows every
365
+ * error on purpose (it must never disturb the CLI), which is exactly the
366
+ * behaviour that let export die silently. `doctor --telemetry` needs the
367
+ * opposite — the HTTP status, so it can tell "accepted" from "token rejected"
368
+ * from "endpoint unreachable".
369
+ *
370
+ * The probe span is tagged `lorekit.telemetry.probe=true` so it is trivially
371
+ * filtered out of usage dashboards, and carries the same bounded, non-PII
372
+ * attribute set as a real command span.
373
+ *
374
+ * `ok` means the span was INGESTED, not merely that the POST returned 2xx —
375
+ * OTLP/HTTP reports a dropped span as a 2xx carrying
376
+ * `partialSuccess.rejectedSpans`, so a status-only verdict would report a
377
+ * rejected probe as accepted. That false green is the exact failure this probe
378
+ * exists to catch, and the probe sends exactly one span, so any non-zero
379
+ * `rejectedSpans` means the thing we sent was dropped.
380
+ *
381
+ * @returns {Promise<{skipped?: boolean, ok?: boolean, unauthorized?: boolean, httpStatus?: number, rejectedSpans?: number, rejectionMessage?: string, networkError?: string}>}
382
+ */
383
+ export async function probeTelemetryExport(config, { version = '0.0.0', timeoutMs = 5000 } = {}) {
384
+ if (!config || !config.enabled) return { skipped: true };
385
+
386
+ const now = Date.now();
387
+ const payload = buildTracePayload({
388
+ version,
389
+ name: 'lorekit.cli.doctor.telemetry_probe',
390
+ attributes: {
391
+ 'lorekit.cli.command': 'doctor',
392
+ 'lorekit.cli.outcome': 'ok',
393
+ 'lorekit.telemetry.probe': true,
394
+ },
395
+ startMs: now,
396
+ endMs: now,
397
+ status: 'ok',
398
+ });
399
+
400
+ const controller = new AbortController();
401
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
402
+ try {
403
+ const res = await fetch(`${config.endpoint}/v1/traces`, {
404
+ method: 'POST',
405
+ headers: { 'content-type': 'application/json', ...config.headers },
406
+ body: JSON.stringify(payload),
407
+ signal: controller.signal,
408
+ });
409
+ const httpStatus = res.status;
410
+ const accepted = httpStatus >= 200 && httpStatus < 300;
411
+
412
+ // A 2xx only means the collector took the REQUEST. Read the OTLP
413
+ // partial-success envelope to find out whether it took the SPAN.
414
+ // Deliberately one-directional: only a positively parsed, non-zero
415
+ // `rejectedSpans` downgrades the verdict. An empty, non-JSON, or
416
+ // unreadable body leaves the status-based answer alone, so a terse but
417
+ // healthy collector can never be turned into a false red — and the read is
418
+ // wrapped in its own try so a failure here cannot erase an HTTP status we
419
+ // already have by falling into the transport `catch` below.
420
+ let rejectedSpans = 0;
421
+ let rejectionMessage;
422
+ if (accepted) {
423
+ try {
424
+ const partial = JSON.parse(await res.text())?.['partialSuccess'];
425
+ const count = Number(partial?.['rejectedSpans'] ?? 0);
426
+ if (Number.isFinite(count) && count > 0) {
427
+ rejectedSpans = count;
428
+ const message = partial?.['errorMessage'];
429
+ // Collector-controlled free text — bounded before it reaches output.
430
+ if (typeof message === 'string' && message) rejectionMessage = message.slice(0, 200);
431
+ }
432
+ } catch {
433
+ // Unreadable or non-OTLP body — fall back to the status alone.
434
+ }
435
+ }
436
+
437
+ return {
438
+ httpStatus,
439
+ ok: accepted && rejectedSpans === 0,
440
+ unauthorized: httpStatus === 401 || httpStatus === 403,
441
+ ...(rejectedSpans > 0 ? { rejectedSpans } : {}),
442
+ ...(rejectionMessage ? { rejectionMessage } : {}),
443
+ };
444
+ } catch (e) {
445
+ const networkError =
446
+ e && e.name === 'AbortError'
447
+ ? `no response within ${timeoutMs}ms`
448
+ : (e && e.message) || String(e);
449
+ return { networkError };
450
+ } finally {
451
+ clearTimeout(timer);
452
+ }
453
+ }
454
+
336
455
  // ── Command wrapper ───────────────────────────────────────────────────────────
337
456
 
338
457
  /**