@alejandroroman/agent-kit 0.1.3 → 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.
Files changed (102) hide show
  1. package/dist/_memory/dist/config.d.ts +14 -0
  2. package/dist/_memory/dist/config.js +16 -0
  3. package/dist/_memory/dist/db/client.d.ts +2 -0
  4. package/dist/_memory/dist/db/client.js +15 -0
  5. package/dist/_memory/dist/db/schema.d.ts +14 -0
  6. package/dist/_memory/dist/db/schema.js +51 -0
  7. package/dist/_memory/dist/embeddings/ollama.d.ts +12 -0
  8. package/dist/_memory/dist/embeddings/ollama.js +22 -0
  9. package/dist/_memory/dist/embeddings/provider.d.ts +4 -0
  10. package/dist/_memory/dist/embeddings/provider.js +1 -0
  11. package/dist/_memory/dist/index.d.ts +10 -0
  12. package/dist/_memory/dist/index.js +6 -0
  13. package/dist/_memory/dist/search.d.ts +30 -0
  14. package/dist/_memory/dist/search.js +121 -0
  15. package/dist/_memory/dist/server.d.ts +8 -0
  16. package/dist/_memory/dist/server.js +126 -0
  17. package/dist/_memory/dist/store.d.ts +51 -0
  18. package/dist/_memory/dist/store.js +115 -0
  19. package/dist/_memory/server.js +0 -0
  20. package/dist/agent/loop.js +210 -111
  21. package/dist/api/errors.d.ts +3 -0
  22. package/dist/api/errors.js +37 -0
  23. package/dist/api/events.d.ts +5 -0
  24. package/dist/api/events.js +28 -0
  25. package/dist/api/router.js +10 -0
  26. package/dist/api/traces.d.ts +3 -0
  27. package/dist/api/traces.js +35 -0
  28. package/dist/api/types.d.ts +2 -0
  29. package/dist/bootstrap.d.ts +6 -5
  30. package/dist/bootstrap.js +26 -7
  31. package/dist/cli/chat.js +18 -63
  32. package/dist/cli/claude-md-template.d.ts +5 -0
  33. package/dist/cli/claude-md-template.js +220 -0
  34. package/dist/cli/config-writer.js +3 -0
  35. package/dist/cli/create.js +1 -4
  36. package/dist/cli/env.d.ts +14 -0
  37. package/dist/cli/env.js +68 -0
  38. package/dist/cli/init.js +14 -7
  39. package/dist/cli/list.js +1 -2
  40. package/dist/cli/paths.d.ts +3 -0
  41. package/dist/cli/paths.js +4 -0
  42. package/dist/cli/repl.d.ts +23 -0
  43. package/dist/cli/repl.js +73 -0
  44. package/dist/cli/slack-setup.d.ts +6 -0
  45. package/dist/cli/slack-setup.js +234 -0
  46. package/dist/cli/start.js +96 -96
  47. package/dist/cli/ui.d.ts +2 -2
  48. package/dist/cli/ui.js +5 -5
  49. package/dist/cli/validate.js +1 -4
  50. package/dist/cli/whats-new.d.ts +1 -0
  51. package/dist/cli/whats-new.js +69 -0
  52. package/dist/cli.js +14 -0
  53. package/dist/config/resolve.d.ts +1 -0
  54. package/dist/config/resolve.js +1 -0
  55. package/dist/config/schema.d.ts +2 -0
  56. package/dist/config/schema.js +1 -0
  57. package/dist/config/writer.d.ts +18 -0
  58. package/dist/config/writer.js +85 -0
  59. package/dist/cron/scheduler.d.ts +4 -1
  60. package/dist/cron/scheduler.js +99 -52
  61. package/dist/gateways/slack/client.d.ts +1 -0
  62. package/dist/gateways/slack/client.js +9 -0
  63. package/dist/gateways/slack/handler.js +2 -1
  64. package/dist/gateways/slack/index.js +75 -29
  65. package/dist/gateways/slack/listener.d.ts +8 -1
  66. package/dist/gateways/slack/listener.js +36 -10
  67. package/dist/heartbeat/runner.js +99 -82
  68. package/dist/index.js +4 -209
  69. package/dist/llm/anthropic.d.ts +1 -0
  70. package/dist/llm/anthropic.js +11 -2
  71. package/dist/llm/fallback.js +34 -2
  72. package/dist/llm/openai.d.ts +2 -0
  73. package/dist/llm/openai.js +33 -2
  74. package/dist/llm/types.d.ts +16 -2
  75. package/dist/llm/types.js +9 -0
  76. package/dist/logger.js +8 -0
  77. package/dist/media/sanitize.d.ts +5 -0
  78. package/dist/media/sanitize.js +53 -0
  79. package/dist/multi/spawn.js +29 -10
  80. package/dist/session/compaction.js +3 -1
  81. package/dist/session/prune-images.d.ts +9 -0
  82. package/dist/session/prune-images.js +42 -0
  83. package/dist/skills/activate.d.ts +6 -0
  84. package/dist/skills/activate.js +72 -27
  85. package/dist/skills/index.d.ts +1 -1
  86. package/dist/skills/index.js +1 -1
  87. package/dist/telemetry/db.d.ts +63 -0
  88. package/dist/telemetry/db.js +193 -0
  89. package/dist/telemetry/index.d.ts +17 -0
  90. package/dist/telemetry/index.js +82 -0
  91. package/dist/telemetry/sanitize.d.ts +6 -0
  92. package/dist/telemetry/sanitize.js +48 -0
  93. package/dist/telemetry/sqlite-processor.d.ts +11 -0
  94. package/dist/telemetry/sqlite-processor.js +108 -0
  95. package/dist/telemetry/types.d.ts +30 -0
  96. package/dist/telemetry/types.js +31 -0
  97. package/dist/tools/builtin/index.d.ts +2 -0
  98. package/dist/tools/builtin/index.js +2 -0
  99. package/dist/tools/builtin/self-config.d.ts +4 -0
  100. package/dist/tools/builtin/self-config.js +182 -0
  101. package/dist/tools/registry.js +8 -1
  102. package/package.json +26 -20
@@ -56,6 +56,7 @@ declare const AgentSchema: z.ZodObject<{
56
56
  model: z.ZodOptional<z.ZodString>;
57
57
  tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
58
58
  skills: z.ZodDefault<z.ZodArray<z.ZodString>>;
59
+ autoActivateSkills: z.ZodDefault<z.ZodBoolean>;
59
60
  spawn_only: z.ZodDefault<z.ZodBoolean>;
60
61
  can_spawn: z.ZodDefault<z.ZodArray<z.ZodObject<{
61
62
  agent: z.ZodString;
@@ -150,6 +151,7 @@ export declare const ConfigSchema: z.ZodObject<{
150
151
  model: z.ZodOptional<z.ZodString>;
151
152
  tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
152
153
  skills: z.ZodDefault<z.ZodArray<z.ZodString>>;
154
+ autoActivateSkills: z.ZodDefault<z.ZodBoolean>;
153
155
  spawn_only: z.ZodDefault<z.ZodBoolean>;
154
156
  can_spawn: z.ZodDefault<z.ZodArray<z.ZodObject<{
155
157
  agent: z.ZodString;
@@ -83,6 +83,7 @@ const AgentSchema = z.object({
83
83
  model: z.string().min(1).optional(),
84
84
  tools: z.array(z.string()).default([]),
85
85
  skills: z.array(z.string().min(1)).default([]),
86
+ autoActivateSkills: z.boolean().default(false),
86
87
  spawn_only: z.boolean().default(false),
87
88
  can_spawn: z.array(SpawnTargetSchema).default([]),
88
89
  maxIterations: z.number().positive().int().optional(),
@@ -0,0 +1,18 @@
1
+ import { EventEmitter } from "events";
2
+ export declare class ConfigWriter extends EventEmitter {
3
+ private configPath;
4
+ private lockPromise;
5
+ private watcher?;
6
+ private debounceTimer?;
7
+ private lastSelfWriteMs;
8
+ constructor(configPath: string);
9
+ /** Read the current config from disk (fresh read, no cache). */
10
+ read(): Record<string, any>;
11
+ /** Write config atomically (temp file + rename). Emits config:changed. */
12
+ write(config: Record<string, any>): void;
13
+ /** Watch the config file for external changes. Debounced, ignores self-writes. */
14
+ watch(): void;
15
+ stopWatching(): void;
16
+ /** Mutex-protected read-modify-write. The callback mutates config in place. */
17
+ mutate(fn: (config: Record<string, any>) => void): Promise<void>;
18
+ }
@@ -0,0 +1,85 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { EventEmitter } from "events";
5
+ import { createLogger } from "../logger.js";
6
+ const log = createLogger("config:writer");
7
+ export class ConfigWriter extends EventEmitter {
8
+ configPath;
9
+ lockPromise = Promise.resolve();
10
+ watcher;
11
+ debounceTimer;
12
+ lastSelfWriteMs = 0;
13
+ constructor(configPath) {
14
+ super();
15
+ this.configPath = configPath;
16
+ }
17
+ /** Read the current config from disk (fresh read, no cache). */
18
+ read() {
19
+ const raw = fs.readFileSync(this.configPath, "utf-8");
20
+ return JSON.parse(raw);
21
+ }
22
+ /** Write config atomically (temp file + rename). Emits config:changed. */
23
+ write(config) {
24
+ const tmp = path.join(path.dirname(this.configPath), `.agent-kit-${process.pid}-${Date.now()}.tmp`);
25
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + os.EOL);
26
+ try {
27
+ fs.renameSync(tmp, this.configPath);
28
+ }
29
+ catch (err) {
30
+ try {
31
+ fs.unlinkSync(tmp);
32
+ }
33
+ catch { /* ignore cleanup error */ }
34
+ throw err;
35
+ }
36
+ log.info("config written");
37
+ this.lastSelfWriteMs = Date.now();
38
+ this.emit("config:changed", config);
39
+ }
40
+ /** Watch the config file for external changes. Debounced, ignores self-writes. */
41
+ watch() {
42
+ if (this.watcher)
43
+ return;
44
+ const DEBOUNCE_MS = 250;
45
+ const SELF_WRITE_WINDOW_MS = 1000;
46
+ this.watcher = fs.watch(this.configPath, () => {
47
+ if (this.debounceTimer)
48
+ clearTimeout(this.debounceTimer);
49
+ this.debounceTimer = setTimeout(() => {
50
+ // Ignore fs events triggered by our own write()
51
+ if (Date.now() - this.lastSelfWriteMs < SELF_WRITE_WINDOW_MS)
52
+ return;
53
+ try {
54
+ const config = this.read();
55
+ log.info("external config change detected");
56
+ this.emit("config:changed", config);
57
+ }
58
+ catch (err) {
59
+ log.error({ err }, "failed to read config after external change");
60
+ }
61
+ }, DEBOUNCE_MS);
62
+ });
63
+ }
64
+ stopWatching() {
65
+ if (this.debounceTimer)
66
+ clearTimeout(this.debounceTimer);
67
+ this.watcher?.close();
68
+ this.watcher = undefined;
69
+ }
70
+ /** Mutex-protected read-modify-write. The callback mutates config in place. */
71
+ async mutate(fn) {
72
+ const prev = this.lockPromise;
73
+ let resolve;
74
+ this.lockPromise = new Promise((r) => { resolve = r; });
75
+ await prev;
76
+ try {
77
+ const config = this.read();
78
+ fn(config);
79
+ this.write(config);
80
+ }
81
+ finally {
82
+ resolve();
83
+ }
84
+ }
85
+ }
@@ -2,6 +2,7 @@ import type { Config, CronJobDef } from "../config/schema.js";
2
2
  import type { ToolRegistry } from "../tools/registry.js";
3
3
  import type { AgentResult } from "../agent/types.js";
4
4
  import type { AgentRegistry } from "../multi/registry.js";
5
+ import type { ConfigWriter } from "../config/writer.js";
5
6
  import type { UsageStore } from "../usage/store.js";
6
7
  export declare class CronScheduler {
7
8
  private tasks;
@@ -11,12 +12,14 @@ export declare class CronScheduler {
11
12
  private dataDir;
12
13
  private skillsDir;
13
14
  private usageStore?;
14
- constructor(config: Config, toolRegistry: ToolRegistry, agentRegistry: AgentRegistry, dataDir: string, skillsDir?: string, usageStore?: UsageStore);
15
+ private configWriter?;
16
+ constructor(config: Config, toolRegistry: ToolRegistry, agentRegistry: AgentRegistry, dataDir: string, skillsDir?: string, usageStore?: UsageStore, configWriter?: ConfigWriter);
15
17
  getJobs(): CronJobDef[];
16
18
  runJob(jobId: string): Promise<AgentResult | undefined>;
17
19
  start(callbacks?: {
18
20
  onResult?: (jobId: string, agentName: string, result: AgentResult) => void;
19
21
  onError?: (jobId: string, agentName: string, error: Error) => void;
20
22
  }): void;
23
+ reload(newConfig: Config): void;
21
24
  stop(): void;
22
25
  }
@@ -4,10 +4,13 @@ import { setupAgentSession } from "../agent/setup.js";
4
4
  import { resolveAgent, resolveWebSearch } from "../config/resolve.js";
5
5
  import { createBuiltinRegistry } from "../tools/builtin/index.js";
6
6
  import { registerSpawnWrappers } from "../tools/builtin/spawn.js";
7
+ import { createUpdateAgentConfigTool, createManageCronTool } from "../tools/builtin/self-config.js";
7
8
  import * as path from "path";
8
- import { createActivateSkillTool } from "../skills/index.js";
9
+ import { createActivateSkillTool, preActivateSkills } from "../skills/index.js";
9
10
  import { createLogger } from "../logger.js";
10
11
  import { dateContext } from "../text.js";
12
+ import { context, trace, SpanStatusCode } from "@opentelemetry/api";
13
+ import { getTracer, ATTR } from "../telemetry/index.js";
11
14
  const log = createLogger("cron");
12
15
  export class CronScheduler {
13
16
  tasks = [];
@@ -17,13 +20,15 @@ export class CronScheduler {
17
20
  dataDir;
18
21
  skillsDir;
19
22
  usageStore;
20
- constructor(config, toolRegistry, agentRegistry, dataDir, skillsDir, usageStore) {
23
+ configWriter;
24
+ constructor(config, toolRegistry, agentRegistry, dataDir, skillsDir, usageStore, configWriter) {
21
25
  this.config = config;
22
26
  this.toolRegistry = toolRegistry;
23
27
  this.agentRegistry = agentRegistry;
24
28
  this.dataDir = dataDir;
25
29
  this.skillsDir = skillsDir ?? path.join(process.cwd(), "skills");
26
30
  this.usageStore = usageStore;
31
+ this.configWriter = configWriter;
27
32
  }
28
33
  getJobs() {
29
34
  return this.config.cron;
@@ -32,59 +37,96 @@ export class CronScheduler {
32
37
  const job = this.config.cron.find((j) => j.id === jobId);
33
38
  if (!job)
34
39
  return undefined;
35
- // Resolve sandbox for this job's agent
36
- const agentDef = this.config.agents[job.agent];
37
- const sandbox = agentDef?.sandbox ?? this.config.defaults.sandbox;
38
- const memoryConfig = this.config.defaults.memory;
39
- const jobRegistry = createBuiltinRegistry({
40
- allowedCommands: sandbox?.allowedCommands,
41
- allowedPaths: sandbox?.allowedPaths,
42
- memoryConfig,
43
- webSearch: resolveWebSearch(job.agent, this.config),
40
+ const tracer = getTracer("cron");
41
+ const span = tracer.startSpan("source.cron", {
42
+ attributes: {
43
+ [ATTR.JOB_ID]: job.id,
44
+ [ATTR.AGENT]: job.agent,
45
+ [ATTR.SCHEDULE]: job.schedule,
46
+ [ATTR.SOURCE]: "cron",
47
+ },
44
48
  });
45
- const resolved = resolveAgent(job.agent, this.config, jobRegistry, this.skillsDir);
46
- // Skills setup for this job
47
- const promptFragments = [];
48
- let skillsIndex = "";
49
- if (resolved.skills.length > 0) {
50
- const ctx = {
51
- manifests: resolved.skills,
52
- skillsDir: this.skillsDir,
53
- toolRegistry: jobRegistry,
54
- promptFragments,
55
- activatedSkills: new Set(),
56
- };
57
- jobRegistry.register(createActivateSkillTool(ctx));
58
- skillsIndex = "\n\nYou have the following skills available:\n\n"
59
- + resolved.skills.map((s) => `- **${s.name}**: ${s.description}`).join("\n")
60
- + "\n\nTo use a skill, call the `activate_skill` tool with the skill name.";
49
+ const spanCtx = trace.setSpan(context.active(), span);
50
+ try {
51
+ return await context.with(spanCtx, async () => {
52
+ // Resolve sandbox for this job's agent
53
+ const agentDef = this.config.agents[job.agent];
54
+ const sandbox = agentDef?.sandbox ?? this.config.defaults.sandbox;
55
+ const memoryConfig = this.config.defaults.memory;
56
+ const jobRegistry = createBuiltinRegistry({
57
+ allowedCommands: sandbox?.allowedCommands,
58
+ allowedPaths: sandbox?.allowedPaths,
59
+ memoryConfig,
60
+ webSearch: resolveWebSearch(job.agent, this.config),
61
+ });
62
+ const resolved = resolveAgent(job.agent, this.config, jobRegistry, this.skillsDir);
63
+ // Skills setup for this job
64
+ const promptFragments = [];
65
+ let skillsIndex = "";
66
+ if (resolved.skills.length > 0) {
67
+ const ctx = {
68
+ manifests: resolved.skills,
69
+ skillsDir: this.skillsDir,
70
+ toolRegistry: jobRegistry,
71
+ promptFragments,
72
+ activatedSkills: new Set(),
73
+ };
74
+ const activateTool = createActivateSkillTool(ctx);
75
+ jobRegistry.register(activateTool);
76
+ if (resolved.autoActivateSkills) {
77
+ skillsIndex = await preActivateSkills(ctx, activateTool, log);
78
+ }
79
+ else {
80
+ skillsIndex = "\n\nYou have the following skills available:\n\n"
81
+ + resolved.skills.map((s) => `- **${s.name}**: ${s.description}`).join("\n")
82
+ + "\n\nTo use a skill, call the `activate_skill` tool with the skill name.";
83
+ }
84
+ }
85
+ // Spawn wrapper registration
86
+ if (resolved.canSpawn.length > 0) {
87
+ registerSpawnWrappers(resolved.canSpawn, this.config, this.agentRegistry, jobRegistry, this.usageStore);
88
+ }
89
+ // Self-config tool registration
90
+ if (this.configWriter && agentDef) {
91
+ if (agentDef.tools.includes("update_agent_config")) {
92
+ jobRegistry.register(createUpdateAgentConfigTool(job.agent, this.configWriter));
93
+ }
94
+ if (agentDef.tools.includes("manage_cron")) {
95
+ jobRegistry.register(createManageCronTool(job.agent, this.configWriter));
96
+ }
97
+ }
98
+ const sessionId = `cron-${job.id}`;
99
+ const { soul, session } = setupAgentSession(this.dataDir, job.agent, sessionId);
100
+ const userMsg = { role: "user", content: job.prompt };
101
+ session.append(userMsg);
102
+ const systemPrompt = [soul, dateContext(), skillsIndex, ...promptFragments]
103
+ .filter(Boolean)
104
+ .join("\n\n") || undefined;
105
+ const result = await runAgentLoop([userMsg], {
106
+ model: resolved.model,
107
+ fallbacks: resolved.fallbacks,
108
+ systemPrompt,
109
+ toolRegistry: jobRegistry,
110
+ maxIterations: resolved.maxIterations,
111
+ compactionThreshold: resolved.compactionThreshold,
112
+ maxToolResultSize: resolved.maxToolResultSize,
113
+ agentName: job.agent,
114
+ usageStore: this.usageStore,
115
+ source: "cron",
116
+ });
117
+ const lastMsg = result.messages[result.messages.length - 1];
118
+ session.append(lastMsg);
119
+ return result;
120
+ });
61
121
  }
62
- // Spawn wrapper registration
63
- if (resolved.canSpawn.length > 0) {
64
- registerSpawnWrappers(resolved.canSpawn, this.config, this.agentRegistry, jobRegistry, this.usageStore);
122
+ catch (err) {
123
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
124
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
125
+ throw err;
126
+ }
127
+ finally {
128
+ span.end();
65
129
  }
66
- const sessionId = `cron-${job.id}`;
67
- const { soul, session } = setupAgentSession(this.dataDir, job.agent, sessionId);
68
- const userMsg = { role: "user", content: job.prompt };
69
- session.append(userMsg);
70
- const systemPrompt = [soul, dateContext(), skillsIndex, ...promptFragments]
71
- .filter(Boolean)
72
- .join("\n\n") || undefined;
73
- const result = await runAgentLoop([userMsg], {
74
- model: resolved.model,
75
- fallbacks: resolved.fallbacks,
76
- systemPrompt,
77
- toolRegistry: jobRegistry,
78
- maxIterations: resolved.maxIterations,
79
- compactionThreshold: resolved.compactionThreshold,
80
- maxToolResultSize: resolved.maxToolResultSize,
81
- agentName: job.agent,
82
- usageStore: this.usageStore,
83
- source: "cron",
84
- });
85
- const lastMsg = result.messages[result.messages.length - 1];
86
- session.append(lastMsg);
87
- return result;
88
130
  }
89
131
  start(callbacks) {
90
132
  for (const job of this.config.cron) {
@@ -107,6 +149,11 @@ export class CronScheduler {
107
149
  this.tasks.push(task);
108
150
  }
109
151
  }
152
+ reload(newConfig) {
153
+ this.stop();
154
+ this.config = newConfig;
155
+ log.info({ jobs: newConfig.cron.length }, "config reloaded");
156
+ }
110
157
  stop() {
111
158
  for (const task of this.tasks)
112
159
  task.stop();
@@ -8,6 +8,7 @@ export interface SlackClient {
8
8
  postMessage(channelId: string, text: string, blocks?: unknown[], threadTs?: string): Promise<{
9
9
  ok: boolean;
10
10
  }>;
11
+ downloadFile(urlPrivateDownload: string): Promise<Buffer>;
11
12
  app: import("@slack/bolt").App;
12
13
  }
13
14
  export declare function createSlackClient(options: SlackClientOptions): SlackClient;
@@ -28,6 +28,15 @@ export function createSlackClient(options) {
28
28
  async stop() {
29
29
  await app.stop();
30
30
  },
31
+ async downloadFile(urlPrivateDownload) {
32
+ const response = await fetch(urlPrivateDownload, {
33
+ headers: { Authorization: `Bearer ${options.botToken}` },
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`Failed to download Slack file: ${response.status} ${response.statusText}`);
37
+ }
38
+ return Buffer.from(await response.arrayBuffer());
39
+ },
31
40
  async postMessage(channelId, text, blocks, threadTs) {
32
41
  const result = await app.client.chat.postMessage({
33
42
  channel: channelId,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { extractUserText } from "../../llm/types.js";
2
3
  import { markdownToBlocks, markdownToPlainText } from "./format.js";
3
4
  import { BudgetStatusSchema } from "./types.js";
4
5
  import { createLogger } from "../../logger.js";
@@ -16,7 +17,7 @@ function findBudgetToolResults(messages) {
16
17
  const results = [];
17
18
  for (const msg of messages) {
18
19
  if (msg.role === "tool_result" && toolCallNames.get(msg.tool_call_id) === "get_budget_status") {
19
- results.push(msg.content);
20
+ results.push(extractUserText(msg.content));
20
21
  }
21
22
  }
22
23
  return results;
@@ -4,7 +4,25 @@ import { createSlackListener } from "./listener.js";
4
4
  import { createThreadSessionManager } from "./sessions.js";
5
5
  import { markdownToBlocks, markdownToPlainText } from "./format.js";
6
6
  import { createLogger } from "../../logger.js";
7
+ import { context, trace, SpanStatusCode } from "@opentelemetry/api";
8
+ import { getTracer, ATTR } from "../../telemetry/index.js";
7
9
  const log = createLogger("slack");
10
+ function buildUserMessage(msg) {
11
+ if (!msg.files?.length) {
12
+ return { role: "user", content: msg.userText };
13
+ }
14
+ const blocks = [];
15
+ if (msg.userText) {
16
+ blocks.push({ type: "text", text: msg.userText });
17
+ }
18
+ for (const file of msg.files) {
19
+ blocks.push({
20
+ type: "image",
21
+ source: { type: "base64", media_type: file.mimetype, data: file.base64 },
22
+ });
23
+ }
24
+ return { role: "user", content: blocks };
25
+ }
8
26
  export function createSlackGateway(config, options) {
9
27
  const botToken = options?.botToken ?? process.env.SLACK_BOT_TOKEN;
10
28
  const appToken = options?.appToken ?? process.env.SLACK_APP_TOKEN;
@@ -40,37 +58,65 @@ export function createSlackGateway(config, options) {
40
58
  async function processMessage(msg) {
41
59
  if (!executor)
42
60
  return;
43
- const userMsg = { role: "user", content: msg.userText };
44
- sessions.append(msg.threadTs, userMsg);
45
- const messages = sessions.get(msg.threadTs);
46
- try {
47
- const start = Date.now();
48
- log.info({ agent: msg.agentName, thread: msg.threadTs, history: messages.length }, "executing agent");
49
- const result = await executor(msg.agentName, messages);
50
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
51
- log.info({ agent: msg.agentName, thread: msg.threadTs, elapsed, tokens: result.usage.inputTokens + result.usage.outputTokens }, "agent completed");
52
- // Replace session with full message history from agent
53
- sessions.set(msg.threadTs, result.messages);
54
- const text = result.text || "(completed with no text response)";
55
- const blocks = markdownToBlocks(text);
56
- const fallback = markdownToPlainText(text);
57
- await client.postMessage(msg.channelId, fallback, blocks, msg.threadTs);
58
- log.info({ agent: msg.agentName, thread: msg.threadTs }, "reply posted");
59
- }
60
- catch (err) {
61
- log.error({ err, agent: msg.agentName, thread: msg.threadTs }, "error handling inbound message");
62
- // Roll back user message on failure to avoid malformed conversation history
63
- const current = sessions.get(msg.threadTs);
64
- if (current.length > 0 && current[current.length - 1].role === "user") {
65
- sessions.set(msg.threadTs, current.slice(0, -1));
66
- }
61
+ const tracer = getTracer("slack");
62
+ const span = tracer.startSpan("source.slack", {
63
+ attributes: {
64
+ [ATTR.CHANNEL]: msg.channelId,
65
+ [ATTR.AGENT]: msg.agentName,
66
+ [ATTR.THREAD_TS]: msg.threadTs,
67
+ [ATTR.SOURCE]: "slack",
68
+ },
69
+ });
70
+ const spanCtx = trace.setSpan(context.active(), span);
71
+ await context.with(spanCtx, async () => {
72
+ const userMsg = buildUserMessage(msg);
73
+ sessions.append(msg.threadTs, userMsg);
74
+ const messages = sessions.get(msg.threadTs);
67
75
  try {
68
- await client.postMessage(msg.channelId, "Sorry, I encountered an error processing your message. Please try again in a moment.", undefined, msg.threadTs);
76
+ const start = Date.now();
77
+ log.info({ agent: msg.agentName, thread: msg.threadTs, history: messages.length }, "executing agent");
78
+ const result = await executor(msg.agentName, messages);
79
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
80
+ log.info({ agent: msg.agentName, thread: msg.threadTs, elapsed, tokens: result.usage.inputTokens + result.usage.outputTokens }, "agent completed");
81
+ // Replace session with full message history from agent
82
+ sessions.set(msg.threadTs, result.messages);
83
+ const text = result.text || "(completed with no text response)";
84
+ const blocks = markdownToBlocks(text);
85
+ const fallback = markdownToPlainText(text);
86
+ const postSpan = tracer.startSpan("slack.post", {}, spanCtx);
87
+ try {
88
+ await client.postMessage(msg.channelId, fallback, blocks, msg.threadTs);
89
+ log.info({ agent: msg.agentName, thread: msg.threadTs }, "reply posted");
90
+ }
91
+ catch (postErr) {
92
+ postSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(postErr) });
93
+ postSpan.recordException(postErr instanceof Error ? postErr : new Error(String(postErr)));
94
+ throw postErr;
95
+ }
96
+ finally {
97
+ postSpan.end();
98
+ }
69
99
  }
70
- catch (postErr) {
71
- log.error({ err: postErr, thread: msg.threadTs }, "failed to send error reply");
100
+ catch (err) {
101
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
102
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
103
+ log.error({ err, agent: msg.agentName, thread: msg.threadTs }, "error handling inbound message");
104
+ // Roll back user message on failure to avoid malformed conversation history
105
+ const current = sessions.get(msg.threadTs);
106
+ if (current.length > 0 && current[current.length - 1].role === "user") {
107
+ sessions.set(msg.threadTs, current.slice(0, -1));
108
+ }
109
+ try {
110
+ await client.postMessage(msg.channelId, "Sorry, I encountered an error processing your message. Please try again in a moment.", undefined, msg.threadTs);
111
+ }
112
+ catch (postErr) {
113
+ log.error({ err: postErr, thread: msg.threadTs }, "failed to send error reply");
114
+ }
72
115
  }
73
- }
116
+ finally {
117
+ span.end();
118
+ }
119
+ });
74
120
  }
75
121
  // Eviction timer handle
76
122
  let evictionTimer;
@@ -78,7 +124,7 @@ export function createSlackGateway(config, options) {
78
124
  async start() {
79
125
  await client.start();
80
126
  if (executor) {
81
- createSlackListener(client.app, gatewayConfig, handleInbound);
127
+ createSlackListener(client.app, gatewayConfig, handleInbound, client);
82
128
  // Evict stale threads every hour
83
129
  evictionTimer = setInterval(() => {
84
130
  sessions.evict();
@@ -1,10 +1,17 @@
1
1
  import type { App } from "@slack/bolt";
2
2
  import type { SlackGatewayConfig } from "./types.js";
3
+ import type { SlackClient } from "./client.js";
4
+ export interface InboundFile {
5
+ mimetype: string;
6
+ name: string;
7
+ base64: string;
8
+ }
3
9
  export interface InboundMessage {
4
10
  agentName: string;
5
11
  userText: string;
6
12
  threadTs: string;
7
13
  channelId: string;
14
+ files?: InboundFile[];
8
15
  }
9
16
  export type OnMessageCallback = (message: InboundMessage) => Promise<void>;
10
- export declare function createSlackListener(app: App, config: SlackGatewayConfig, onMessage: OnMessageCallback): void;
17
+ export declare function createSlackListener(app: App, config: SlackGatewayConfig, onMessage: OnMessageCallback, client?: SlackClient): void;
@@ -1,33 +1,59 @@
1
+ import { sanitizeImage } from "../../media/sanitize.js";
1
2
  import { createLogger } from "../../logger.js";
2
3
  const log = createLogger("slack:listener");
3
- export function createSlackListener(app, config, onMessage) {
4
- // Build reverse map: channelId agentName
4
+ const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
5
+ export function createSlackListener(app, config, onMessage, client) {
5
6
  const channelToAgent = new Map();
6
7
  for (const [agentName, binding] of Object.entries(config.channels)) {
7
8
  channelToAgent.set(binding.channelId, agentName);
8
9
  }
9
10
  app.event("message", async (payload) => {
10
- // Handle both Bolt's { event } wrapper and direct event (tests)
11
11
  const msg = payload.event ?? payload;
12
12
  // Filter out bot messages and subtypes (edits, deletes, etc.)
13
- if (msg.bot_id || msg.subtype)
13
+ // Allow file_share through so we can process image attachments
14
+ if (msg.bot_id)
14
15
  return;
15
- if (!msg.text || typeof msg.text !== "string")
16
+ if (msg.subtype && msg.subtype !== "file_share")
17
+ return;
18
+ const hasText = msg.text && typeof msg.text === "string";
19
+ const hasFiles = Array.isArray(msg.files) && msg.files.length > 0;
20
+ if (!hasText && !hasFiles)
16
21
  return;
17
22
  const channelId = msg.channel;
18
23
  const agentName = channelToAgent.get(channelId);
19
24
  if (!agentName)
20
25
  return;
21
- // Use thread_ts if in a thread, otherwise use ts as the thread parent
22
26
  const threadTs = msg.thread_ts ?? msg.ts;
23
- // Fire-and-forget: don't await the handler so the Bolt event pipeline
24
- // returns immediately. This prevents long-running agent loops from
25
- // stalling WebSocket ping/pong and causing socket timeouts.
27
+ let files;
28
+ if (hasFiles && client) {
29
+ files = [];
30
+ for (const file of msg.files) {
31
+ if (!IMAGE_MIMES.has(file.mimetype)) {
32
+ log.debug({ name: file.name, mimetype: file.mimetype }, "skipping non-image file");
33
+ continue;
34
+ }
35
+ try {
36
+ const buffer = await client.downloadFile(file.url_private_download);
37
+ const sanitized = await sanitizeImage(buffer, file.mimetype);
38
+ files.push({
39
+ mimetype: sanitized.mediaType,
40
+ name: file.name ?? "image",
41
+ base64: sanitized.base64,
42
+ });
43
+ }
44
+ catch (err) {
45
+ log.warn({ err, name: file.name }, "failed to download/sanitize image");
46
+ }
47
+ }
48
+ if (files.length === 0)
49
+ files = undefined;
50
+ }
26
51
  onMessage({
27
52
  agentName,
28
- userText: msg.text,
53
+ userText: msg.text ?? "",
29
54
  threadTs,
30
55
  channelId,
56
+ files,
31
57
  }).catch((err) => {
32
58
  log.error({ err, channelId }, "unhandled error processing message");
33
59
  });