agent-dir 0.4.1 → 0.5.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/dist/src/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
- import { hostname, userInfo } from "node:os";
4
+ import { hostname, release, userInfo } from "node:os";
4
5
  import { CodeGraphIntegration } from "./codegraph.js";
5
6
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
7
  import { findDefinition, findExports, findFiles, findImports, findReferences, findSymbol, searchCode, searchFiles, } from "./tools/code.js";
@@ -345,6 +346,9 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
345
346
  let sessionToolCallCount = 0;
346
347
  let sessionCommandCallCount = 0;
347
348
  let sessionRecorded = false;
349
+ let sessionOutputBytes = 0;
350
+ let sessionEstimatedOutputTokens = 0;
351
+ const seenOutputFingerprints = new Set();
348
352
  const telemetryProjectPromise = telemetry.level === "detailed" || telemetry.level === "diagnostic"
349
353
  ? buildTelemetryProjectContext(root, codeGraph).catch(() => undefined)
350
354
  : undefined;
@@ -570,16 +574,30 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
570
574
  const result = await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, notify, request.signal, codeGraph);
571
575
  const durationMs = performance.now() - startedAt;
572
576
  const count = resultCount(result);
577
+ const serializedResult = JSON.stringify(result);
578
+ const outputBytes = Buffer.byteLength(serializedResult, "utf8");
579
+ const estimatedOutputTokens = estimateTokens(serializedResult);
580
+ sessionOutputBytes += outputBytes;
581
+ sessionEstimatedOutputTokens += estimatedOutputTokens;
573
582
  const runtime = telemetryRuntime(params);
574
583
  telemetryRecorder.record({
575
584
  event: "tool_call",
576
585
  tool: toolName,
586
+ requestIdHash: telemetryHash(String(message.id)),
577
587
  success: true,
578
588
  durationMs,
579
589
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
580
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
590
+ outputBytes,
591
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
592
+ estimatedOutputTokens,
593
+ cumulativeOutputBytes: sessionOutputBytes,
594
+ cumulativeEstimatedOutputTokens: sessionEstimatedOutputTokens,
595
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
596
+ ...(telemetry.level === "diagnostic"
597
+ ? { diagnosticDetails: diagnosticToolDetails(argumentsValue, result) }
598
+ : {}),
581
599
  ...(count === undefined ? {} : { resultCount: count }),
582
- ...toolTelemetryMetrics(toolName, result),
600
+ ...toolTelemetryMetrics(toolName, result, seenOutputFingerprints),
583
601
  }, runtime, telemetryProject);
584
602
  const command = commandTelemetryDescriptor(toolName);
585
603
  if (command) {
@@ -590,7 +608,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
590
608
  operation: command.operation,
591
609
  success: true,
592
610
  durationMs,
593
- outputBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
611
+ outputBytes,
594
612
  }, runtime, telemetryProject);
595
613
  }
596
614
  mcpSuccess = true;
@@ -607,9 +625,21 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
607
625
  telemetryRecorder.record({
608
626
  event: "tool_call",
609
627
  tool: toolName,
628
+ requestIdHash: telemetryHash(String(message.id)),
610
629
  success: false,
611
630
  durationMs,
612
631
  inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
632
+ estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
633
+ inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
634
+ ...(telemetry.level === "diagnostic"
635
+ ? {
636
+ diagnosticDetails: diagnosticToolDetails(argumentsValue, {
637
+ error: error instanceof Error
638
+ ? { name: error.name, message: error.message, stack: error.stack }
639
+ : String(error),
640
+ }),
641
+ }
642
+ : {}),
613
643
  errorCategory,
614
644
  }, runtime, telemetryProject);
615
645
  const command = commandTelemetryDescriptor(toolName);
@@ -1554,6 +1584,8 @@ function telemetryRuntime(params) {
1554
1584
  ...(typeof clientInfo?.name === "string" ? { mcpClientName: clientInfo.name } : {}),
1555
1585
  ...(typeof clientInfo?.version === "string" ? { mcpClientVersion: clientInfo.version } : {}),
1556
1586
  nodeVersion: process.versions.node,
1587
+ ...linuxDistribution(),
1588
+ kernelVersion: release(),
1557
1589
  hostname: hostname(),
1558
1590
  username: userInfo().username,
1559
1591
  processId: process.pid,
@@ -1562,37 +1594,106 @@ function telemetryRuntime(params) {
1562
1594
  memoryRssBytes: process.memoryUsage().rss,
1563
1595
  };
1564
1596
  }
1565
- function toolTelemetryMetrics(toolName, result) {
1597
+ function linuxDistribution() {
1598
+ if (process.platform !== "linux")
1599
+ return {};
1600
+ try {
1601
+ const values = Object.fromEntries(readFileSync("/etc/os-release", "utf8")
1602
+ .split("\n")
1603
+ .map((line) => line.match(/^([A-Z_]+)=(.*)$/))
1604
+ .filter((match) => match !== null)
1605
+ .map((match) => [match[1], match[2]?.replace(/^"|"$/g, "")]));
1606
+ return {
1607
+ ...(values.ID ? { distroId: values.ID } : {}),
1608
+ ...(values.PRETTY_NAME ? { distroName: values.PRETTY_NAME } : {}),
1609
+ ...(values.VERSION_ID ? { distroVersion: values.VERSION_ID } : {}),
1610
+ };
1611
+ }
1612
+ catch {
1613
+ return {};
1614
+ }
1615
+ }
1616
+ function diagnosticToolDetails(input, output) {
1617
+ const serialize = (value) => {
1618
+ try {
1619
+ const json = JSON.stringify(value);
1620
+ if (json === undefined)
1621
+ return null;
1622
+ if (Buffer.byteLength(json, "utf8") <= 64_000)
1623
+ return value;
1624
+ return {
1625
+ truncated: true,
1626
+ byteLength: Buffer.byteLength(json, "utf8"),
1627
+ preview: json.slice(0, 64_000),
1628
+ };
1629
+ }
1630
+ catch {
1631
+ return { serializationFailed: true };
1632
+ }
1633
+ };
1634
+ return { input: serialize(input), output: serialize(output) };
1635
+ }
1636
+ function telemetryHash(value) {
1637
+ return createHash("sha256").update(value).digest("hex");
1638
+ }
1639
+ // A rough byte-based proxy, not a model-tokenizer count or Claude billing measurement.
1640
+ function estimateTokens(value) {
1641
+ return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
1642
+ }
1643
+ function toolTelemetryMetrics(toolName, result, seenOutputFingerprints) {
1566
1644
  const structured = result.structuredContent;
1567
1645
  if (!Array.isArray(structured) && !isRecord(structured))
1568
1646
  return {};
1569
1647
  const paths = new Set();
1648
+ const files = new Map();
1570
1649
  let truncated = false;
1571
1650
  let paginated = false;
1651
+ let textOutputBytes = 0;
1572
1652
  const visit = (value) => {
1653
+ if (typeof value === "string") {
1654
+ textOutputBytes += Buffer.byteLength(value, "utf8");
1655
+ return;
1656
+ }
1657
+ if (Array.isArray(value)) {
1658
+ for (const child of value)
1659
+ visit(child);
1660
+ return;
1661
+ }
1573
1662
  if (!isRecord(value))
1574
1663
  return;
1575
- if (typeof value.path === "string")
1664
+ if (typeof value.path === "string") {
1576
1665
  paths.add(value.path);
1666
+ const content = typeof value.content === "string" ? value.content : undefined;
1667
+ files.set(value.path, {
1668
+ pathHash: telemetryHash(value.path),
1669
+ ...(content === undefined ? {} : { contentHash: telemetryHash(content) }),
1670
+ bytes: content === undefined ? 0 : Buffer.byteLength(content, "utf8"),
1671
+ });
1672
+ }
1577
1673
  if (value.truncated === true)
1578
1674
  truncated = true;
1579
1675
  if (value.nextCursor !== undefined)
1580
1676
  paginated = true;
1581
- for (const child of Object.values(value)) {
1582
- if (Array.isArray(child))
1583
- for (const item of child)
1584
- visit(item);
1585
- else if (isRecord(child))
1586
- visit(child);
1587
- }
1677
+ for (const child of Object.values(value))
1678
+ visit(child);
1588
1679
  };
1589
1680
  visit(structured);
1681
+ const outputJson = JSON.stringify(result);
1682
+ const outputFingerprint = telemetryHash(outputJson);
1683
+ const outputBytes = Buffer.byteLength(outputJson, "utf8");
1684
+ const duplicateOutputBytes = seenOutputFingerprints.has(outputFingerprint) ? outputBytes : 0;
1685
+ seenOutputFingerprints.add(outputFingerprint);
1590
1686
  return {
1591
1687
  ...(paths.size > 0 && /^(write|patch|delete|read|file|git_changes|inspect)/.test(toolName)
1592
1688
  ? { affectedFiles: paths.size }
1593
1689
  : {}),
1594
1690
  ...(truncated ? { truncated: true } : {}),
1595
1691
  ...(paginated ? { paginated: true } : {}),
1692
+ outputFingerprint,
1693
+ duplicateOutputBytes,
1694
+ textOutputBytes,
1695
+ uniqueFileCount: files.size,
1696
+ ...(files.size ? { files: [...files.values()].slice(0, 100) } : {}),
1596
1697
  };
1597
1698
  }
1598
1699
  function commandTelemetryDescriptor(toolName) {
@@ -16,7 +16,7 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
16
16
  level: telemetry.level,
17
17
  persist: telemetry.persist ?? false,
18
18
  ...(telemetry.configId ? { configId: telemetry.configId } : {}),
19
- sessionId: `http-${Date.now()}`,
19
+ sessionId: telemetry.sessionId ?? `http-${Date.now()}`,
20
20
  });
21
21
  const server = http.createServer(async (req, res) => {
22
22
  const startedAt = performance.now();
@@ -29,6 +29,11 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
29
29
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
30
30
  let authenticated = false;
31
31
  try {
32
+ if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
33
+ res.setHeader("cache-control", "no-store");
34
+ sendText(res, 200, "ok\n");
35
+ return;
36
+ }
32
37
  const body = await readBody(req, requestController.signal);
33
38
  const requestInit = {
34
39
  method: req.method ?? "GET",
@@ -51,10 +56,6 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
51
56
  recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 401, authenticated, startedAt);
52
57
  return;
53
58
  }
54
- if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
55
- sendText(res, 200, "ok\n");
56
- return;
57
- }
58
59
  if (url.pathname === "/mcp") {
59
60
  if (req.method !== "POST") {
60
61
  res.setHeader("allow", "POST");
package/dist/src/setup.js CHANGED
@@ -104,12 +104,26 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
104
104
  const name = await requiredText(rl, "Profile name", "default");
105
105
  const directory = resolve(await requiredText(rl, "Directory to expose", defaultDirectory));
106
106
  const port = await requiredPort(rl, 3002);
107
- const tunnel = await choose(rl, "Tunnel", ["wormhole", "none"], "wormhole");
107
+ let hostname;
108
+ let cloudflaredToken;
108
109
  let subdomain;
109
- if (tunnel === "wormhole" &&
110
+ console.log("");
111
+ console.log(`${section("◆")} ${section(bold("Cloudflare Tunnel"))}`);
112
+ console.log(rule());
113
+ const useCloudflared = await confirm(rl, "Set up Cloudflare Tunnel?", true);
114
+ if (useCloudflared) {
115
+ hostname = await requiredHostname(rl, "Public hostname (must be routed to this tunnel)");
116
+ cloudflaredToken = await requiredSecret(rl, "Cloudflare tunnel token");
117
+ }
118
+ console.log("");
119
+ console.log(`${section("◆")} ${section(bold("Wormhole"))}`);
120
+ console.log(rule());
121
+ const useWormhole = await confirm(rl, "Set up Wormhole too?", false);
122
+ if (useWormhole &&
110
123
  (await confirm(rl, "Use a custom Wormhole subdomain? (No uses a random URL.)", false))) {
111
124
  subdomain = await requiredText(rl, "Wormhole subdomain");
112
125
  }
126
+ const tunnel = useCloudflared ? "cloudflared" : useWormhole ? "wormhole" : "none";
113
127
  const npm = await optionalList(rl, "Allowed npm scripts (comma-separated, blank for none)");
114
128
  const commands = await optionalList(rl, "Allowed commands (comma-separated, blank for none)");
115
129
  console.log("");
@@ -119,9 +133,9 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
119
133
  bold("Privacy first"),
120
134
  dim("Telemetry is optional and stays local on this machine."),
121
135
  dim("It helps us understand usage, failures, and reliability improvements."),
122
- dim("Standard levels never record file contents, command arguments, tokens,"),
123
- dim("environment variables, or project paths."),
124
- dim("Diagnostic mode is a separate opt-in local troubleshooting mode and records identifying metadata."),
136
+ dim("Standard levels avoid file contents, command arguments, tokens, and project paths."),
137
+ dim("Diagnostic mode records bounded raw tool inputs/outputs and identifying metadata."),
138
+ dim("It may contain source code or other sensitive project data; it stays local."),
125
139
  ]);
126
140
  console.log("");
127
141
  console.log(` ${green("●")} ${white("none")} ${dim("disabled")}`);
@@ -131,9 +145,25 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
131
145
  console.log(` ${cyan("●")} ${white("diagnostic")} ${dim("detailed + local paths, host/user, PID, Node version, memory/uptime")}`);
132
146
  console.log("");
133
147
  const telemetry = await choose(rl, "Telemetry level", ["none", "anonymous", "basic", "detailed", "diagnostic"], "none");
134
- const profile = { directory, port, tunnel, token: randomBytes(24).toString("hex") };
148
+ const profile = {
149
+ directory,
150
+ port,
151
+ tunnel,
152
+ defaultTunnel: tunnel,
153
+ token: randomBytes(24).toString("hex"),
154
+ };
135
155
  if (subdomain)
136
156
  profile.subdomain = subdomain;
157
+ if (hostname)
158
+ profile.hostname = hostname;
159
+ if (useCloudflared || useWormhole) {
160
+ profile.tunnels = {
161
+ ...(useWormhole ? { wormhole: { ...(subdomain ? { subdomain } : { random: true }) } } : {}),
162
+ ...(useCloudflared && hostname
163
+ ? { cloudflared: { hostname, ...(cloudflaredToken ? { token: cloudflaredToken } : {}) } }
164
+ : {}),
165
+ };
166
+ }
137
167
  if (npm.length)
138
168
  profile.npm = { allowedScripts: npm };
139
169
  if (commands.length)
@@ -168,6 +198,37 @@ async function requiredText(rl, label, defaultValue) {
168
198
  console.log(` ${error("✕")} ${dim("A value is required.")}`);
169
199
  }
170
200
  }
201
+ async function requiredSecret(rl, label) {
202
+ while (true) {
203
+ output.write(` ${bold(label)}: `);
204
+ const writableRl = rl;
205
+ const writeToOutput = writableRl._writeToOutput;
206
+ writableRl._writeToOutput = () => { };
207
+ let value;
208
+ try {
209
+ value = (await rl.question("")).trim();
210
+ }
211
+ finally {
212
+ writableRl._writeToOutput = writeToOutput;
213
+ output.write("\n");
214
+ }
215
+ if (value)
216
+ return value;
217
+ console.log(` ${error("✕")} ${dim("A value is required.")}`);
218
+ }
219
+ }
220
+ async function requiredHostname(rl, label) {
221
+ while (true) {
222
+ const value = (await rl.question(` ${bold(label)}: `)).trim().toLowerCase();
223
+ const labels = value.split(".");
224
+ const valid = value.length <= 253 &&
225
+ labels.length >= 2 &&
226
+ labels.every((part) => part.length >= 1 && part.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part));
227
+ if (valid)
228
+ return value;
229
+ console.log(` ${error("✕")} ${dim("Enter a hostname only, such as agent-dir-mcp.scriptr.uk (no spaces, https://, or path).")}`);
230
+ }
231
+ }
171
232
  async function requiredPort(rl, defaultValue) {
172
233
  while (true) {
173
234
  const value = (await rl.question(` ${bold("Port")} ${dim(`[${defaultValue}]`)}: `)).trim() ||
@@ -40,6 +40,33 @@ export function sanitizeAnonymousEvent(event) {
40
40
  return {
41
41
  event: "tool_call",
42
42
  tool: sanitizeIdentifier(event.tool, SAFE_TOOL_NAME),
43
+ ...(event.requestIdHash === undefined
44
+ ? {}
45
+ : { requestIdHash: sanitizeHash(event.requestIdHash) }),
46
+ ...(event.inputFingerprint === undefined
47
+ ? {}
48
+ : { inputFingerprint: sanitizeHash(event.inputFingerprint) }),
49
+ ...(event.outputFingerprint === undefined
50
+ ? {}
51
+ : { outputFingerprint: sanitizeHash(event.outputFingerprint) }),
52
+ ...optionalNumber("estimatedInputTokens", event.estimatedInputTokens),
53
+ ...optionalNumber("estimatedOutputTokens", event.estimatedOutputTokens),
54
+ ...optionalNumber("cumulativeOutputBytes", event.cumulativeOutputBytes),
55
+ ...optionalNumber("cumulativeEstimatedOutputTokens", event.cumulativeEstimatedOutputTokens),
56
+ ...optionalNumber("duplicateOutputBytes", event.duplicateOutputBytes),
57
+ ...optionalNumber("textOutputBytes", event.textOutputBytes),
58
+ ...optionalNumber("uniqueFileCount", event.uniqueFileCount),
59
+ ...(event.files === undefined
60
+ ? {}
61
+ : {
62
+ files: event.files.slice(0, 100).map((file) => ({
63
+ pathHash: sanitizeHash(file.pathHash),
64
+ ...(file.contentHash === undefined
65
+ ? {}
66
+ : { contentHash: sanitizeHash(file.contentHash) }),
67
+ bytes: boundedNumber(file.bytes),
68
+ })),
69
+ }),
43
70
  success: event.success === true,
44
71
  durationMs: boundedNumber(event.durationMs),
45
72
  ...optionalNumber("inputBytes", event.inputBytes),
@@ -100,6 +127,12 @@ export function createTelemetryEnvelope(level, event, runtime, project, configId
100
127
  level,
101
128
  event: sanitizeAnonymousEvent(event),
102
129
  };
130
+ if (level === "diagnostic" &&
131
+ event.event === "tool_call" &&
132
+ event.diagnosticDetails &&
133
+ envelope.event.event === "tool_call") {
134
+ envelope.event.diagnosticDetails = event.diagnosticDetails;
135
+ }
103
136
  if (level === "basic" || level === "detailed" || level === "diagnostic") {
104
137
  if (runtime)
105
138
  envelope.runtime = sanitizeRuntimeContext(runtime);
@@ -121,6 +154,16 @@ function sanitizeDiagnosticRuntimeContext(context) {
121
154
  ...(context.nodeVersion === undefined
122
155
  ? {}
123
156
  : { nodeVersion: sanitizeVersion(context.nodeVersion) }),
157
+ ...(context.distroId === undefined
158
+ ? {}
159
+ : { distroId: sanitizeSensitiveIdentifier(context.distroId) }),
160
+ ...(context.distroName === undefined ? {} : { distroName: context.distroName.slice(0, 128) }),
161
+ ...(context.distroVersion === undefined
162
+ ? {}
163
+ : { distroVersion: context.distroVersion.slice(0, 64) }),
164
+ ...(context.kernelVersion === undefined
165
+ ? {}
166
+ : { kernelVersion: context.kernelVersion.slice(0, 128) }),
124
167
  ...(context.hostname === undefined
125
168
  ? {}
126
169
  : { hostname: sanitizeSensitiveIdentifier(context.hostname) }),
@@ -202,6 +245,9 @@ function sanitizeVersion(value) {
202
245
  ? normalized
203
246
  : "unknown";
204
247
  }
248
+ function sanitizeHash(value) {
249
+ return /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
250
+ }
205
251
  function sanitizeErrorCategory(value) {
206
252
  const normalized = value.trim().toLowerCase();
207
253
  return SAFE_ERROR_CATEGORIES.has(normalized) ? normalized : "unknown";
@@ -1,10 +1,9 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname } from "node:path";
4
4
  import { createTelemetryEnvelope } from "./privacy.js";
5
+ import { telemetrySessionPath } from "./storage.js";
5
6
  const DEFAULT_MAX_EVENTS = 500;
6
- const DEFAULT_PERSISTED_EVENTS = 500;
7
- const TELEMETRY_FILE = join(homedir(), ".config", "agent-dir", "telemetry.jsonl");
8
7
  export class TelemetryRecorder {
9
8
  level;
10
9
  maxEvents;
@@ -21,9 +20,9 @@ export class TelemetryRecorder {
21
20
  this.runtime = options.runtime;
22
21
  this.project = options.project;
23
22
  this.persist = options.persist ?? false;
24
- this.persistPath = options.persistPath ?? TELEMETRY_FILE;
25
23
  this.configId = options.configId ?? "default";
26
- this.sessionId = options.sessionId ?? "default";
24
+ this.sessionId = options.sessionId ?? randomUUID();
25
+ this.persistPath = options.persistPath ?? telemetrySessionPath(this.configId, this.sessionId);
27
26
  if (this.persist && this.level !== "none")
28
27
  this.loadPersisted();
29
28
  }
@@ -64,8 +63,9 @@ export class TelemetryRecorder {
64
63
  .filter((event) => event &&
65
64
  event.schemaVersion === 1 &&
66
65
  event.level === this.level &&
67
- event.configId === this.configId)
68
- .slice(-Math.min(this.maxEvents, DEFAULT_PERSISTED_EVENTS));
66
+ event.configId === this.configId &&
67
+ event.sessionId === this.sessionId)
68
+ .slice(-this.maxEvents);
69
69
  this.events.push(...persisted);
70
70
  }
71
71
  catch {
@@ -80,15 +80,6 @@ export class TelemetryRecorder {
80
80
  mode: 0o600,
81
81
  });
82
82
  chmodSync(this.persistPath, 0o600);
83
- if (this.events.length === this.maxEvents) {
84
- const lines = readFileSync(this.persistPath, "utf8").split("\n").filter(Boolean);
85
- if (lines.length > this.maxEvents) {
86
- writeFileSync(this.persistPath, `${lines.slice(-this.maxEvents).join("\n")}\n`, {
87
- encoding: "utf8",
88
- mode: 0o600,
89
- });
90
- }
91
- }
92
83
  }
93
84
  catch {
94
85
  // Telemetry persistence must never affect the MCP request path.
@@ -0,0 +1,9 @@
1
+ import type { TelemetryEnvelope } from "./types.js";
2
+ export declare const TELEMETRY_DIRECTORY: string;
3
+ export declare const LEGACY_TELEMETRY_FILE: string;
4
+ export declare function telemetrySessionPath(configId: string, sessionId: string): string;
5
+ /** Migrate the old shared file once, preserving an archive and avoiding duplicate lines. */
6
+ export declare function migrateLegacyTelemetry(): void;
7
+ export declare function readAllTelemetryEvents(): TelemetryEnvelope[];
8
+ export declare function resetTelemetry(profile?: string, all?: boolean): void;
9
+ export declare function ensureTelemetryDirectory(): void;
@@ -0,0 +1,133 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ const CONFIG_DIRECTORY = join(homedir(), ".config", "agent-dir");
5
+ export const TELEMETRY_DIRECTORY = join(CONFIG_DIRECTORY, "telemetry");
6
+ export const LEGACY_TELEMETRY_FILE = join(CONFIG_DIRECTORY, "telemetry.jsonl");
7
+ const LEGACY_ARCHIVE_FILE = join(CONFIG_DIRECTORY, "telemetry.legacy-archive.jsonl");
8
+ function safeSegment(value) {
9
+ const safe = value
10
+ .replace(/[^A-Za-z0-9._-]/g, "_")
11
+ .replace(/^\.+$/, "_")
12
+ .slice(0, 100);
13
+ return safe || "unknown";
14
+ }
15
+ export function telemetrySessionPath(configId, sessionId) {
16
+ return join(TELEMETRY_DIRECTORY, safeSegment(configId), `session-${safeSegment(sessionId)}.jsonl`);
17
+ }
18
+ function parseEvents(content) {
19
+ const events = [];
20
+ for (const line of content.split("\n")) {
21
+ if (!line.trim())
22
+ continue;
23
+ try {
24
+ const value = JSON.parse(line);
25
+ if (value &&
26
+ value.schemaVersion === 1 &&
27
+ typeof value.configId === "string" &&
28
+ typeof value.sessionId === "string" &&
29
+ typeof value.timestamp === "string" &&
30
+ value.event) {
31
+ events.push(value);
32
+ }
33
+ }
34
+ catch {
35
+ // Ignore malformed lines without discarding other valid events.
36
+ }
37
+ }
38
+ return events;
39
+ }
40
+ function readFileEvents(path) {
41
+ try {
42
+ return parseEvents(readFileSync(path, "utf8"));
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ function collectJsonlFiles(directory) {
49
+ if (!existsSync(directory))
50
+ return [];
51
+ const files = [];
52
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
53
+ const path = join(directory, entry.name);
54
+ if (entry.isDirectory())
55
+ files.push(...collectJsonlFiles(path));
56
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
57
+ files.push(path);
58
+ }
59
+ return files;
60
+ }
61
+ /** Migrate the old shared file once, preserving an archive and avoiding duplicate lines. */
62
+ export function migrateLegacyTelemetry() {
63
+ if (!existsSync(LEGACY_TELEMETRY_FILE))
64
+ return;
65
+ const legacyEvents = readFileEvents(LEGACY_TELEMETRY_FILE);
66
+ mkdirSync(TELEMETRY_DIRECTORY, { recursive: true, mode: 0o700 });
67
+ const groups = new Map();
68
+ for (const event of legacyEvents) {
69
+ const path = telemetrySessionPath(event.configId, event.sessionId);
70
+ const group = groups.get(path) ?? [];
71
+ group.push(event);
72
+ groups.set(path, group);
73
+ }
74
+ for (const [path, events] of groups) {
75
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
76
+ let existing = new Set();
77
+ try {
78
+ existing = new Set(readFileSync(path, "utf8").split("\n").filter(Boolean));
79
+ }
80
+ catch {
81
+ // New session file.
82
+ }
83
+ const missing = events
84
+ .map((event) => JSON.stringify(event))
85
+ .filter((line) => {
86
+ if (existing.has(line))
87
+ return false;
88
+ existing.add(line);
89
+ return true;
90
+ });
91
+ if (missing.length) {
92
+ appendFileSync(path, `${missing.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
93
+ chmodSync(path, 0o600);
94
+ }
95
+ }
96
+ let archive = LEGACY_ARCHIVE_FILE;
97
+ if (existsSync(archive))
98
+ archive = `${LEGACY_ARCHIVE_FILE}.${Date.now()}`;
99
+ renameSync(LEGACY_TELEMETRY_FILE, archive);
100
+ }
101
+ export function readAllTelemetryEvents() {
102
+ migrateLegacyTelemetry();
103
+ return collectJsonlFiles(TELEMETRY_DIRECTORY)
104
+ .flatMap(readFileEvents)
105
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
106
+ }
107
+ export function resetTelemetry(profile, all = false) {
108
+ if (all) {
109
+ rmSync(TELEMETRY_DIRECTORY, { recursive: true, force: true });
110
+ rmSync(LEGACY_TELEMETRY_FILE, { force: true });
111
+ rmSync(LEGACY_ARCHIVE_FILE, { force: true });
112
+ for (const entry of existsSync(CONFIG_DIRECTORY)
113
+ ? readdirSync(CONFIG_DIRECTORY, { withFileTypes: true })
114
+ : []) {
115
+ if (entry.isFile() && entry.name.startsWith("telemetry.legacy-archive.jsonl.")) {
116
+ rmSync(join(CONFIG_DIRECTORY, entry.name), { force: true });
117
+ }
118
+ }
119
+ return;
120
+ }
121
+ if (!profile)
122
+ throw new Error("Specify a profile name or use --all.");
123
+ rmSync(join(TELEMETRY_DIRECTORY, safeSegment(profile)), { recursive: true, force: true });
124
+ }
125
+ export function ensureTelemetryDirectory() {
126
+ mkdirSync(TELEMETRY_DIRECTORY, { recursive: true, mode: 0o700 });
127
+ try {
128
+ chmodSync(TELEMETRY_DIRECTORY, 0o700);
129
+ }
130
+ catch {
131
+ // Best-effort permission hardening.
132
+ }
133
+ }
@@ -22,9 +22,27 @@ export interface TelemetryMcpRequestEvent {
22
22
  durationMs: number;
23
23
  errorCategory?: string;
24
24
  }
25
+ export interface TelemetryFileMetric {
26
+ pathHash: string;
27
+ contentHash?: string;
28
+ bytes: number;
29
+ }
25
30
  export interface TelemetryToolEvent {
26
31
  event: "tool_call";
27
32
  tool: string;
33
+ requestIdHash?: string;
34
+ inputFingerprint?: string;
35
+ outputFingerprint?: string;
36
+ /** Full local-only troubleshooting payload, emitted only at diagnostic level. */
37
+ diagnosticDetails?: Record<string, unknown>;
38
+ estimatedInputTokens?: number;
39
+ estimatedOutputTokens?: number;
40
+ cumulativeOutputBytes?: number;
41
+ cumulativeEstimatedOutputTokens?: number;
42
+ duplicateOutputBytes?: number;
43
+ textOutputBytes?: number;
44
+ uniqueFileCount?: number;
45
+ files?: TelemetryFileMetric[];
28
46
  success: boolean;
29
47
  durationMs: number;
30
48
  inputBytes?: number;
@@ -71,6 +89,10 @@ export interface TelemetryRuntimeContext {
71
89
  mcpClientName?: string;
72
90
  mcpClientVersion?: string;
73
91
  nodeVersion?: string;
92
+ distroId?: string;
93
+ distroName?: string;
94
+ distroVersion?: string;
95
+ kernelVersion?: string;
74
96
  hostname?: string;
75
97
  username?: string;
76
98
  processId?: number;
@@ -142,7 +142,7 @@ export function findReferences(root, symbol, maxResults = 200, maxBytes = DEFAUL
142
142
  return searchCode(root, `\\b${escaped}\\b`, true, maxResults, maxBytes);
143
143
  }
144
144
  export async function findImports(root, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
145
- const matches = await readMatches(root, String.raw `(?:^|[;{}])\\s*import(?:[^"'\\n]*?from\\s*)?["']([^"']+)["']|require\\(\\s*["']([^"']+)["']\\s*\\)`, { codeOnly: true, regex: true, maxResults });
145
+ const matches = await readMatches(root, String.raw `(?:^|[;{}])\s*import\s*(?:[^"'\n]*?from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)`, { codeOnly: true, regex: true, maxResults });
146
146
  return boundMatches(matches.map((match) => {
147
147
  const captured = match.text.match(/(?:from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/);
148
148
  return {