@alejandroroman/agent-kit 0.1.4 → 0.2.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/_memory/dist/server.js +0 -0
- package/dist/_memory/server.js +0 -0
- package/dist/agent/loop.js +210 -111
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +37 -0
- package/dist/api/events.d.ts +5 -0
- package/dist/api/events.js +28 -0
- package/dist/api/router.js +10 -0
- package/dist/api/traces.d.ts +3 -0
- package/dist/api/traces.js +35 -0
- package/dist/api/types.d.ts +2 -0
- package/dist/bootstrap.d.ts +3 -1
- package/dist/bootstrap.js +26 -7
- package/dist/cli/chat.js +3 -1
- package/dist/cli/claude-md-template.d.ts +5 -0
- package/dist/cli/claude-md-template.js +220 -0
- package/dist/cli/config-writer.js +3 -0
- package/dist/cli/env.d.ts +14 -0
- package/dist/cli/env.js +68 -0
- package/dist/cli/init.js +10 -0
- package/dist/cli/slack-setup.d.ts +6 -0
- package/dist/cli/slack-setup.js +234 -0
- package/dist/cli/start.js +65 -16
- package/dist/cli/ui.d.ts +2 -0
- package/dist/cli/ui.js +4 -1
- package/dist/cli/whats-new.d.ts +1 -0
- package/dist/cli/whats-new.js +69 -0
- package/dist/cli.js +14 -0
- package/dist/config/resolve.d.ts +1 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/writer.d.ts +18 -0
- package/dist/config/writer.js +85 -0
- package/dist/cron/scheduler.d.ts +4 -1
- package/dist/cron/scheduler.js +99 -52
- package/dist/gateways/slack/client.d.ts +1 -0
- package/dist/gateways/slack/client.js +9 -0
- package/dist/gateways/slack/handler.js +2 -1
- package/dist/gateways/slack/index.js +75 -29
- package/dist/gateways/slack/listener.d.ts +8 -1
- package/dist/gateways/slack/listener.js +36 -10
- package/dist/heartbeat/runner.js +99 -82
- package/dist/llm/anthropic.d.ts +1 -0
- package/dist/llm/anthropic.js +11 -2
- package/dist/llm/fallback.js +34 -2
- package/dist/llm/openai.d.ts +2 -0
- package/dist/llm/openai.js +33 -2
- package/dist/llm/types.d.ts +16 -2
- package/dist/llm/types.js +9 -0
- package/dist/logger.js +8 -0
- package/dist/media/sanitize.d.ts +5 -0
- package/dist/media/sanitize.js +53 -0
- package/dist/multi/spawn.js +29 -10
- package/dist/session/compaction.js +3 -1
- package/dist/session/prune-images.d.ts +9 -0
- package/dist/session/prune-images.js +42 -0
- package/dist/skills/activate.d.ts +6 -0
- package/dist/skills/activate.js +72 -27
- package/dist/skills/index.d.ts +1 -1
- package/dist/skills/index.js +1 -1
- package/dist/telemetry/db.d.ts +63 -0
- package/dist/telemetry/db.js +193 -0
- package/dist/telemetry/index.d.ts +17 -0
- package/dist/telemetry/index.js +82 -0
- package/dist/telemetry/sanitize.d.ts +6 -0
- package/dist/telemetry/sanitize.js +48 -0
- package/dist/telemetry/sqlite-processor.d.ts +11 -0
- package/dist/telemetry/sqlite-processor.js +108 -0
- package/dist/telemetry/types.d.ts +30 -0
- package/dist/telemetry/types.js +31 -0
- package/dist/tools/builtin/index.d.ts +2 -0
- package/dist/tools/builtin/index.js +2 -0
- package/dist/tools/builtin/self-config.d.ts +4 -0
- package/dist/tools/builtin/self-config.js +182 -0
- package/package.json +25 -18
package/dist/llm/types.js
CHANGED
|
@@ -4,3 +4,12 @@ export function extractText(content) {
|
|
|
4
4
|
.map((b) => b.text)
|
|
5
5
|
.join("");
|
|
6
6
|
}
|
|
7
|
+
/** Extract text from user content (string or UserContentBlock[]) */
|
|
8
|
+
export function extractUserText(content) {
|
|
9
|
+
if (typeof content === "string")
|
|
10
|
+
return content;
|
|
11
|
+
return content
|
|
12
|
+
.filter((b) => b.type === "text")
|
|
13
|
+
.map((b) => b.text)
|
|
14
|
+
.join("");
|
|
15
|
+
}
|
package/dist/logger.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pino from "pino";
|
|
2
|
+
import { trace } from "@opentelemetry/api";
|
|
2
3
|
const root = pino({
|
|
3
4
|
level: process.env.LOG_LEVEL ?? "info",
|
|
4
5
|
transport: {
|
|
@@ -9,6 +10,13 @@ const root = pino({
|
|
|
9
10
|
colorize: true,
|
|
10
11
|
},
|
|
11
12
|
},
|
|
13
|
+
mixin() {
|
|
14
|
+
const span = trace.getActiveSpan();
|
|
15
|
+
if (!span)
|
|
16
|
+
return {};
|
|
17
|
+
const ctx = span.spanContext();
|
|
18
|
+
return { trace_id: ctx.traceId, span_id: ctx.spanId };
|
|
19
|
+
},
|
|
12
20
|
});
|
|
13
21
|
export function createLogger(name) {
|
|
14
22
|
return root.child({ component: name });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
const MAX_BYTES = 20 * 1024 * 1024; // 20MB — raw upload limit (sharp will resize/compress)
|
|
3
|
+
const MAX_DIMENSION = 1200;
|
|
4
|
+
const JPEG_QUALITY = 85;
|
|
5
|
+
const SUPPORTED_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
6
|
+
// Magic byte signatures
|
|
7
|
+
const SIGNATURES = [
|
|
8
|
+
[Buffer.from([0x89, 0x50, 0x4e, 0x47]), "image/png"],
|
|
9
|
+
[Buffer.from([0xff, 0xd8, 0xff]), "image/jpeg"],
|
|
10
|
+
[Buffer.from("GIF87a"), "image/gif"],
|
|
11
|
+
[Buffer.from("GIF89a"), "image/gif"],
|
|
12
|
+
[Buffer.from("RIFF"), "image/webp"], // RIFF....WEBP
|
|
13
|
+
];
|
|
14
|
+
function sniffMimeType(buffer) {
|
|
15
|
+
for (const [sig, type] of SIGNATURES) {
|
|
16
|
+
if (buffer.length >= sig.length && buffer.subarray(0, sig.length).equals(sig)) {
|
|
17
|
+
// For WEBP, also check bytes 8-12 for "WEBP"
|
|
18
|
+
if (type === "image/webp") {
|
|
19
|
+
if (buffer.length >= 12 && buffer.subarray(8, 12).toString() === "WEBP")
|
|
20
|
+
return type;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
return type;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export async function sanitizeImage(buffer, claimedMimeType) {
|
|
29
|
+
if (buffer.length > MAX_BYTES) {
|
|
30
|
+
throw new Error(`Image exceeds 20 MB size limit (${(buffer.length / 1024 / 1024).toFixed(1)} MB)`);
|
|
31
|
+
}
|
|
32
|
+
const sniffed = sniffMimeType(buffer);
|
|
33
|
+
const mediaType = sniffed ?? claimedMimeType;
|
|
34
|
+
if (!mediaType || !SUPPORTED_TYPES.has(mediaType)) {
|
|
35
|
+
throw new Error(`Unsupported image type: ${mediaType ?? "unknown"}`);
|
|
36
|
+
}
|
|
37
|
+
const meta = await sharp(buffer).metadata();
|
|
38
|
+
const width = meta.width ?? 0;
|
|
39
|
+
const height = meta.height ?? 0;
|
|
40
|
+
let output;
|
|
41
|
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
42
|
+
// Resize to fit within MAX_DIMENSION, preserving aspect ratio
|
|
43
|
+
// Recompress as JPEG for smaller output
|
|
44
|
+
output = await sharp(buffer)
|
|
45
|
+
.resize(MAX_DIMENSION, MAX_DIMENSION, { fit: "inside", withoutEnlargement: true })
|
|
46
|
+
.jpeg({ quality: JPEG_QUALITY })
|
|
47
|
+
.toBuffer();
|
|
48
|
+
return { base64: output.toString("base64"), mediaType: "image/jpeg" };
|
|
49
|
+
}
|
|
50
|
+
// No resize needed — return as-is
|
|
51
|
+
output = buffer;
|
|
52
|
+
return { base64: output.toString("base64"), mediaType };
|
|
53
|
+
}
|
package/dist/multi/spawn.js
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import { runAgentLoop } from "../agent/loop.js";
|
|
2
|
+
import { SpanStatusCode } from "@opentelemetry/api";
|
|
3
|
+
import { getTracer, ATTR } from "../telemetry/index.js";
|
|
2
4
|
export async function spawnAgent(options) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
maxIterations: options.maxIterations ?? 10,
|
|
9
|
-
label: options.label,
|
|
10
|
-
agentName: options.label,
|
|
11
|
-
usageStore: options.usageStore,
|
|
12
|
-
source: "spawn",
|
|
5
|
+
const tracer = getTracer("multi");
|
|
6
|
+
const span = tracer.startSpan("agent.spawn", {
|
|
7
|
+
attributes: {
|
|
8
|
+
[ATTR.SPAWN_CHILD]: options.label ?? "unknown",
|
|
9
|
+
},
|
|
13
10
|
});
|
|
11
|
+
try {
|
|
12
|
+
const result = await runAgentLoop([{ role: "user", content: options.task }], {
|
|
13
|
+
model: options.model,
|
|
14
|
+
fallbacks: options.fallbacks,
|
|
15
|
+
systemPrompt: options.systemPrompt,
|
|
16
|
+
tools: options.tools,
|
|
17
|
+
maxIterations: options.maxIterations ?? 10,
|
|
18
|
+
label: options.label,
|
|
19
|
+
agentName: options.label,
|
|
20
|
+
usageStore: options.usageStore,
|
|
21
|
+
source: "spawn",
|
|
22
|
+
});
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
|
27
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
span.end();
|
|
32
|
+
}
|
|
14
33
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { complete } from "../llm/index.js";
|
|
2
2
|
import { extractText } from "../llm/types.js";
|
|
3
|
+
import { pruneImages } from "./prune-images.js";
|
|
3
4
|
export function estimateTokens(message) {
|
|
4
5
|
return Math.ceil(JSON.stringify(message).length / 4);
|
|
5
6
|
}
|
|
@@ -13,6 +14,7 @@ export async function compactMessages(messages, model, tokenThreshold = 100_000)
|
|
|
13
14
|
const splitPoint = Math.floor(messages.length / 2);
|
|
14
15
|
const oldMessages = messages.slice(0, splitPoint);
|
|
15
16
|
const recentMessages = messages.slice(splitPoint);
|
|
17
|
+
const prunedOld = pruneImages(oldMessages, 0);
|
|
16
18
|
const summaryResponse = await complete(model, [{
|
|
17
19
|
role: "user",
|
|
18
20
|
content: "Summarize this conversation concisely. Preserve:\n" +
|
|
@@ -20,7 +22,7 @@ export async function compactMessages(messages, model, tokenThreshold = 100_000)
|
|
|
20
22
|
"- Important decisions made\n" +
|
|
21
23
|
"- Open tasks or TODOs\n" +
|
|
22
24
|
"- File paths and code changes discussed\n\n" +
|
|
23
|
-
JSON.stringify(
|
|
25
|
+
JSON.stringify(prunedOld, null, 2),
|
|
24
26
|
}]);
|
|
25
27
|
const summaryText = extractText(summaryResponse.content);
|
|
26
28
|
return [
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Message } from "../llm/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns a shallow copy of messages with ImageBlocks replaced by text placeholders
|
|
4
|
+
* in user messages older than `keepLastNTurns` user turns from the end.
|
|
5
|
+
* A "turn" is defined by a user message — all messages (assistant, tool_result)
|
|
6
|
+
* between two user messages belong to the same turn as the preceding user message.
|
|
7
|
+
* Does NOT mutate the original array.
|
|
8
|
+
*/
|
|
9
|
+
export declare function pruneImages(messages: Message[], keepLastNTurns?: number): Message[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const IMAGE_PLACEHOLDER = {
|
|
2
|
+
type: "text",
|
|
3
|
+
text: "[image previously shared \u2014 already processed]",
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Returns a shallow copy of messages with ImageBlocks replaced by text placeholders
|
|
7
|
+
* in user messages older than `keepLastNTurns` user turns from the end.
|
|
8
|
+
* A "turn" is defined by a user message — all messages (assistant, tool_result)
|
|
9
|
+
* between two user messages belong to the same turn as the preceding user message.
|
|
10
|
+
* Does NOT mutate the original array.
|
|
11
|
+
*/
|
|
12
|
+
export function pruneImages(messages, keepLastNTurns = 4) {
|
|
13
|
+
// Find the Nth user message from the end — this is the start of the kept range.
|
|
14
|
+
// Everything before it (older turns and their tool results) gets image-pruned.
|
|
15
|
+
let turnsSeen = 0;
|
|
16
|
+
let cutoff = -1;
|
|
17
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
18
|
+
if (messages[i].role === "user") {
|
|
19
|
+
turnsSeen++;
|
|
20
|
+
if (turnsSeen === keepLastNTurns) {
|
|
21
|
+
cutoff = i;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (turnsSeen < keepLastNTurns)
|
|
27
|
+
return messages; // fewer turns than threshold
|
|
28
|
+
return messages.map((msg, i) => {
|
|
29
|
+
if (i >= cutoff)
|
|
30
|
+
return msg;
|
|
31
|
+
if (msg.role === "user" || msg.role === "tool_result") {
|
|
32
|
+
if (typeof msg.content === "string")
|
|
33
|
+
return msg;
|
|
34
|
+
const hasImage = msg.content.some((b) => b.type === "image");
|
|
35
|
+
if (!hasImage)
|
|
36
|
+
return msg;
|
|
37
|
+
const pruned = msg.content.map((b) => b.type === "image" ? IMAGE_PLACEHOLDER : b);
|
|
38
|
+
return { ...msg, content: pruned };
|
|
39
|
+
}
|
|
40
|
+
return msg;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Tool } from "../tools/types.js";
|
|
2
2
|
import type { ToolRegistry } from "../tools/registry.js";
|
|
3
3
|
import type { SkillManifest } from "./schema.js";
|
|
4
|
+
import type { Logger } from "pino";
|
|
4
5
|
export interface SkillActivationContext {
|
|
5
6
|
manifests: SkillManifest[];
|
|
6
7
|
skillsDir: string;
|
|
@@ -8,4 +9,9 @@ export interface SkillActivationContext {
|
|
|
8
9
|
promptFragments: string[];
|
|
9
10
|
activatedSkills: Set<string>;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Auto-activate all skills upfront so the LLM doesn't need to call activate_skill.
|
|
14
|
+
* Returns the skillsIndex string to append to the system prompt.
|
|
15
|
+
*/
|
|
16
|
+
export declare function preActivateSkills(ctx: SkillActivationContext, activateTool: Tool, logger?: Logger): Promise<string>;
|
|
11
17
|
export declare function createActivateSkillTool(ctx: SkillActivationContext): Tool;
|
package/dist/skills/activate.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { loadSkillManifest } from "./loader.js";
|
|
4
|
+
import { SpanStatusCode } from "@opentelemetry/api";
|
|
5
|
+
import { getTracer, ATTR } from "../telemetry/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Auto-activate all skills upfront so the LLM doesn't need to call activate_skill.
|
|
8
|
+
* Returns the skillsIndex string to append to the system prompt.
|
|
9
|
+
*/
|
|
10
|
+
export async function preActivateSkills(ctx, activateTool, logger) {
|
|
11
|
+
for (const manifest of ctx.manifests) {
|
|
12
|
+
try {
|
|
13
|
+
const result = await activateTool.execute({ skill_name: manifest.name });
|
|
14
|
+
if (typeof result === "string" && result.startsWith("Error")) {
|
|
15
|
+
logger?.error({ skill: manifest.name, result }, "failed to auto-activate skill");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
logger?.error({ err, skill: manifest.name }, "skill auto-activation threw");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const activatedNames = [...ctx.activatedSkills];
|
|
23
|
+
if (activatedNames.length > 0) {
|
|
24
|
+
return "\n\nThe following skills are pre-activated: "
|
|
25
|
+
+ activatedNames.join(", ")
|
|
26
|
+
+ ".\nTheir tools are already available — you do not need to call activate_skill.";
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
4
30
|
export function createActivateSkillTool(ctx) {
|
|
5
31
|
return {
|
|
6
32
|
name: "activate_skill",
|
|
@@ -24,39 +50,58 @@ export function createActivateSkillTool(ctx) {
|
|
|
24
50
|
if (!manifest) {
|
|
25
51
|
return `Skill "${skillName}" is not available. Available skills: ${ctx.manifests.map((m) => m.name).join(", ")}`;
|
|
26
52
|
}
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
const tracer = getTracer("skills");
|
|
54
|
+
const span = tracer.startSpan("skill.activate", {
|
|
55
|
+
attributes: {
|
|
56
|
+
[ATTR.SKILL_NAME]: skillName,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const skillDir = path.join(ctx.skillsDir, skillName);
|
|
61
|
+
const fullManifest = loadSkillManifest(skillDir);
|
|
62
|
+
const loadedTools = [];
|
|
63
|
+
// Load prompt fragment
|
|
64
|
+
if (fullManifest.prompt) {
|
|
65
|
+
const promptPath = path.join(skillDir, fullManifest.prompt);
|
|
66
|
+
if (fs.existsSync(promptPath)) {
|
|
67
|
+
ctx.promptFragments.push(fs.readFileSync(promptPath, "utf-8"));
|
|
68
|
+
}
|
|
35
69
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
// Load tools via dynamic import
|
|
71
|
+
if (fullManifest.tools) {
|
|
72
|
+
for (const toolName of fullManifest.tools) {
|
|
73
|
+
const toolPath = path.join(skillDir, "tools", `${toolName}.ts`);
|
|
74
|
+
if (fs.existsSync(toolPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const mod = await import(toolPath);
|
|
77
|
+
const tool = mod.default;
|
|
78
|
+
ctx.toolRegistry.register(tool);
|
|
79
|
+
loadedTools.push(tool.name);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
|
83
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
84
|
+
return `Error loading tool "${toolName}" from skill "${skillName}": ${err instanceof Error ? err.message : String(err)}`;
|
|
85
|
+
}
|
|
50
86
|
}
|
|
51
87
|
}
|
|
52
88
|
}
|
|
89
|
+
ctx.activatedSkills.add(skillName);
|
|
90
|
+
span.setAttribute(ATTR.SKILL_TOOLS_LOADED, loadedTools.length);
|
|
91
|
+
const parts = [`Skill "${skillName}" activated.`];
|
|
92
|
+
if (loadedTools.length > 0) {
|
|
93
|
+
parts.push(`Tools now available: ${loadedTools.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
return parts.join(" ");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
|
|
99
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
100
|
+
throw err;
|
|
53
101
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (loadedTools.length > 0) {
|
|
57
|
-
parts.push(`Tools now available: ${loadedTools.join(", ")}`);
|
|
102
|
+
finally {
|
|
103
|
+
span.end();
|
|
58
104
|
}
|
|
59
|
-
return parts.join(" ");
|
|
60
105
|
},
|
|
61
106
|
};
|
|
62
107
|
}
|
package/dist/skills/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { SkillManifestSchema, type SkillManifest } from "./schema.js";
|
|
2
2
|
export { loadSkillManifest, loadAllSkillManifests } from "./loader.js";
|
|
3
|
-
export { createActivateSkillTool, type SkillActivationContext } from "./activate.js";
|
|
3
|
+
export { createActivateSkillTool, preActivateSkills, type SkillActivationContext } from "./activate.js";
|
package/dist/skills/index.js
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface SpanRow {
|
|
2
|
+
trace_id: string;
|
|
3
|
+
span_id: string;
|
|
4
|
+
parent_span_id?: string | null;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
status: string;
|
|
8
|
+
agent?: string | null;
|
|
9
|
+
source?: string | null;
|
|
10
|
+
start_time: number;
|
|
11
|
+
end_time: number;
|
|
12
|
+
duration_ms: number;
|
|
13
|
+
attributes: string;
|
|
14
|
+
events: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ErrorRow {
|
|
17
|
+
trace_id: string;
|
|
18
|
+
span_id: string;
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
error_type?: string | null;
|
|
21
|
+
message: string;
|
|
22
|
+
stack?: string | null;
|
|
23
|
+
agent?: string | null;
|
|
24
|
+
source?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface EventRow {
|
|
27
|
+
trace_id?: string | null;
|
|
28
|
+
span_id?: string | null;
|
|
29
|
+
name: string;
|
|
30
|
+
agent?: string | null;
|
|
31
|
+
body?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ListTracesOptions {
|
|
34
|
+
agent?: string;
|
|
35
|
+
source?: string;
|
|
36
|
+
status?: string;
|
|
37
|
+
since?: string;
|
|
38
|
+
limit?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface RecentErrorsOptions {
|
|
41
|
+
agent?: string;
|
|
42
|
+
source?: string;
|
|
43
|
+
since?: string;
|
|
44
|
+
limit?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ErrorStatsOptions {
|
|
47
|
+
since?: string;
|
|
48
|
+
groupBy?: "fingerprint" | "agent";
|
|
49
|
+
}
|
|
50
|
+
export declare class TelemetryDb {
|
|
51
|
+
private db;
|
|
52
|
+
constructor(dbPath: string);
|
|
53
|
+
insertSpan(span: SpanRow): void;
|
|
54
|
+
insertError(error: ErrorRow): void;
|
|
55
|
+
insertEvent(event: EventRow): void;
|
|
56
|
+
getTraceSpans(traceId: string): any[];
|
|
57
|
+
listTraces(options: ListTracesOptions): any[];
|
|
58
|
+
getRecentErrors(options: RecentErrorsOptions): any[];
|
|
59
|
+
private static readonly ALLOWED_GROUP_BY;
|
|
60
|
+
getErrorStats(options: ErrorStatsOptions): any[];
|
|
61
|
+
cleanup(retentionDays: number, vacuum?: boolean): void;
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
const SCHEMA_SQL = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
trace_id TEXT NOT NULL,
|
|
6
|
+
span_id TEXT NOT NULL,
|
|
7
|
+
parent_span_id TEXT,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
kind TEXT NOT NULL DEFAULT 'internal',
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'unset',
|
|
11
|
+
agent TEXT,
|
|
12
|
+
source TEXT,
|
|
13
|
+
start_time INTEGER NOT NULL,
|
|
14
|
+
end_time INTEGER NOT NULL,
|
|
15
|
+
duration_ms REAL NOT NULL,
|
|
16
|
+
attributes TEXT NOT NULL DEFAULT '{}',
|
|
17
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
19
|
+
UNIQUE(trace_id, span_id)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
trace_id TEXT NOT NULL,
|
|
25
|
+
span_id TEXT NOT NULL,
|
|
26
|
+
fingerprint TEXT NOT NULL,
|
|
27
|
+
error_type TEXT,
|
|
28
|
+
message TEXT NOT NULL,
|
|
29
|
+
stack TEXT,
|
|
30
|
+
agent TEXT,
|
|
31
|
+
source TEXT,
|
|
32
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
FOREIGN KEY (trace_id, span_id) REFERENCES spans(trace_id, span_id) ON DELETE CASCADE
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
trace_id TEXT,
|
|
39
|
+
span_id TEXT,
|
|
40
|
+
name TEXT NOT NULL,
|
|
41
|
+
agent TEXT,
|
|
42
|
+
body TEXT NOT NULL DEFAULT '{}',
|
|
43
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_spans_parent ON spans(parent_span_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_spans_name_time ON spans(name, start_time);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_spans_agent_time ON spans(agent, created_at);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_spans_source_time ON spans(source, created_at);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_spans_status ON spans(status, created_at);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_errors_agent_time ON errors(agent, created_at);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_errors_fingerprint ON errors(fingerprint, created_at);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_errors_trace ON errors(trace_id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent, timestamp);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_events_span ON events(span_id);
|
|
58
|
+
`;
|
|
59
|
+
export class TelemetryDb {
|
|
60
|
+
db;
|
|
61
|
+
constructor(dbPath) {
|
|
62
|
+
this.db = new Database(dbPath);
|
|
63
|
+
this.db.pragma("journal_mode = WAL");
|
|
64
|
+
this.db.pragma("foreign_keys = ON");
|
|
65
|
+
this.db.exec(SCHEMA_SQL);
|
|
66
|
+
}
|
|
67
|
+
insertSpan(span) {
|
|
68
|
+
this.db
|
|
69
|
+
.prepare(`INSERT INTO spans (trace_id, span_id, parent_span_id, name, kind, status, agent, source, start_time, end_time, duration_ms, attributes, events)
|
|
70
|
+
VALUES (@trace_id, @span_id, @parent_span_id, @name, @kind, @status, @agent, @source, @start_time, @end_time, @duration_ms, @attributes, @events)`)
|
|
71
|
+
.run({
|
|
72
|
+
trace_id: span.trace_id,
|
|
73
|
+
span_id: span.span_id,
|
|
74
|
+
parent_span_id: span.parent_span_id ?? null,
|
|
75
|
+
name: span.name,
|
|
76
|
+
kind: span.kind,
|
|
77
|
+
status: span.status,
|
|
78
|
+
agent: span.agent ?? null,
|
|
79
|
+
source: span.source ?? null,
|
|
80
|
+
start_time: span.start_time,
|
|
81
|
+
end_time: span.end_time,
|
|
82
|
+
duration_ms: span.duration_ms,
|
|
83
|
+
attributes: span.attributes,
|
|
84
|
+
events: span.events,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
insertError(error) {
|
|
88
|
+
this.db
|
|
89
|
+
.prepare(`INSERT INTO errors (trace_id, span_id, fingerprint, error_type, message, stack, agent, source)
|
|
90
|
+
VALUES (@trace_id, @span_id, @fingerprint, @error_type, @message, @stack, @agent, @source)`)
|
|
91
|
+
.run({
|
|
92
|
+
trace_id: error.trace_id,
|
|
93
|
+
span_id: error.span_id,
|
|
94
|
+
fingerprint: error.fingerprint,
|
|
95
|
+
error_type: error.error_type ?? null,
|
|
96
|
+
message: error.message,
|
|
97
|
+
stack: error.stack ?? null,
|
|
98
|
+
agent: error.agent ?? null,
|
|
99
|
+
source: error.source ?? null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
insertEvent(event) {
|
|
103
|
+
this.db
|
|
104
|
+
.prepare(`INSERT INTO events (trace_id, span_id, name, agent, body)
|
|
105
|
+
VALUES (@trace_id, @span_id, @name, @agent, @body)`)
|
|
106
|
+
.run({
|
|
107
|
+
trace_id: event.trace_id ?? null,
|
|
108
|
+
span_id: event.span_id ?? null,
|
|
109
|
+
name: event.name,
|
|
110
|
+
agent: event.agent ?? null,
|
|
111
|
+
body: event.body ?? "{}",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
getTraceSpans(traceId) {
|
|
115
|
+
return this.db
|
|
116
|
+
.prepare("SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time")
|
|
117
|
+
.all(traceId);
|
|
118
|
+
}
|
|
119
|
+
listTraces(options) {
|
|
120
|
+
const conditions = ["parent_span_id IS NULL"];
|
|
121
|
+
const params = [];
|
|
122
|
+
if (options.agent) {
|
|
123
|
+
conditions.push("agent = ?");
|
|
124
|
+
params.push(options.agent);
|
|
125
|
+
}
|
|
126
|
+
if (options.source) {
|
|
127
|
+
conditions.push("source = ?");
|
|
128
|
+
params.push(options.source);
|
|
129
|
+
}
|
|
130
|
+
if (options.status) {
|
|
131
|
+
conditions.push("status = ?");
|
|
132
|
+
params.push(options.status);
|
|
133
|
+
}
|
|
134
|
+
if (options.since) {
|
|
135
|
+
conditions.push("created_at >= ?");
|
|
136
|
+
params.push(options.since);
|
|
137
|
+
}
|
|
138
|
+
const limit = options.limit ?? 100;
|
|
139
|
+
const sql = `SELECT * FROM spans WHERE ${conditions.join(" AND ")} ORDER BY start_time DESC LIMIT ?`;
|
|
140
|
+
params.push(limit);
|
|
141
|
+
return this.db.prepare(sql).all(...params);
|
|
142
|
+
}
|
|
143
|
+
getRecentErrors(options) {
|
|
144
|
+
const conditions = [];
|
|
145
|
+
const params = [];
|
|
146
|
+
if (options.agent) {
|
|
147
|
+
conditions.push("agent = ?");
|
|
148
|
+
params.push(options.agent);
|
|
149
|
+
}
|
|
150
|
+
if (options.source) {
|
|
151
|
+
conditions.push("source = ?");
|
|
152
|
+
params.push(options.source);
|
|
153
|
+
}
|
|
154
|
+
if (options.since) {
|
|
155
|
+
conditions.push("created_at >= ?");
|
|
156
|
+
params.push(options.since);
|
|
157
|
+
}
|
|
158
|
+
const limit = options.limit ?? 100;
|
|
159
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
160
|
+
const sql = `SELECT * FROM errors ${where} ORDER BY created_at DESC LIMIT ?`;
|
|
161
|
+
params.push(limit);
|
|
162
|
+
return this.db.prepare(sql).all(...params);
|
|
163
|
+
}
|
|
164
|
+
static ALLOWED_GROUP_BY = new Set(["fingerprint", "agent"]);
|
|
165
|
+
getErrorStats(options) {
|
|
166
|
+
const conditions = [];
|
|
167
|
+
const params = [];
|
|
168
|
+
if (options.since) {
|
|
169
|
+
conditions.push("created_at >= ?");
|
|
170
|
+
params.push(options.since);
|
|
171
|
+
}
|
|
172
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
173
|
+
const groupBy = options.groupBy ?? "fingerprint";
|
|
174
|
+
if (!TelemetryDb.ALLOWED_GROUP_BY.has(groupBy)) {
|
|
175
|
+
throw new Error(`Invalid groupBy: ${groupBy}`);
|
|
176
|
+
}
|
|
177
|
+
const sql = `SELECT ${groupBy}, COUNT(*) as count FROM errors ${where} GROUP BY ${groupBy} ORDER BY count DESC`;
|
|
178
|
+
return this.db.prepare(sql).all(...params);
|
|
179
|
+
}
|
|
180
|
+
cleanup(retentionDays, vacuum = false) {
|
|
181
|
+
const cutoff = `-${retentionDays} days`;
|
|
182
|
+
this.db.transaction(() => {
|
|
183
|
+
this.db.prepare("DELETE FROM events WHERE timestamp < datetime('now', ?)").run(cutoff);
|
|
184
|
+
this.db.prepare("DELETE FROM spans WHERE created_at < datetime('now', ?)").run(cutoff);
|
|
185
|
+
})();
|
|
186
|
+
if (vacuum) {
|
|
187
|
+
this.db.exec("VACUUM");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
close() {
|
|
191
|
+
this.db.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import { TelemetryDb } from "./db.js";
|
|
3
|
+
export interface TelemetryConfig {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
serviceName?: string;
|
|
6
|
+
otlpEndpoint?: string;
|
|
7
|
+
sentryDsn?: string;
|
|
8
|
+
dbPath?: string;
|
|
9
|
+
sampleRate?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Get the TelemetryDb instance (available after initTelemetry with a dbPath) */
|
|
12
|
+
export declare function getTelemetryDb(): TelemetryDb | undefined;
|
|
13
|
+
export declare function initTelemetry(config?: TelemetryConfig): () => Promise<void>;
|
|
14
|
+
export declare function getTracer(name: string): Tracer;
|
|
15
|
+
export { ATTR } from "./types.js";
|
|
16
|
+
export { TelemetryDb } from "./db.js";
|
|
17
|
+
export { SQLiteSpanProcessor } from "./sqlite-processor.js";
|