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/README.md +59 -12
- package/dist/bin/agent-dir.js +105 -44
- package/dist/src/config.js +31 -1
- package/dist/src/logging.d.ts +1 -0
- package/dist/src/logging.js +7 -1
- package/dist/src/mcp.js +115 -14
- package/dist/src/server.js +6 -5
- package/dist/src/setup.js +67 -6
- package/dist/src/telemetry/privacy.js +46 -0
- package/dist/src/telemetry/recorder.js +8 -17
- package/dist/src/telemetry/storage.d.ts +9 -0
- package/dist/src/telemetry/storage.js +133 -0
- package/dist/src/telemetry/types.d.ts +22 -0
- package/dist/src/tools/code.js +1 -1
- package/dist/src/tools/commands.d.ts +7 -0
- package/dist/src/tools/commands.js +63 -5
- package/dist/src/tools/git.js +10 -4
- package/dist/src/tools/npm.js +7 -5
- package/dist/src/tunnel.js +35 -18
- package/dist/src/types.d.ts +16 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
107
|
+
let hostname;
|
|
108
|
+
let cloudflaredToken;
|
|
108
109
|
let subdomain;
|
|
109
|
-
|
|
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
|
|
123
|
-
dim("
|
|
124
|
-
dim("
|
|
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 = {
|
|
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 {
|
|
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 ??
|
|
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
|
-
.
|
|
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;
|
package/dist/src/tools/code.js
CHANGED
|
@@ -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 `(?:^|[;{}])
|
|
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 {
|