pilotlynx 0.1.2 → 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 (145) hide show
  1. package/README.md +40 -40
  2. package/dist/agents/improve.agent.d.ts +42 -1
  3. package/dist/agents/improve.agent.js +102 -7
  4. package/dist/agents/improve.agent.js.map +1 -1
  5. package/dist/agents/run.agent.d.ts +0 -1
  6. package/dist/agents/run.agent.js +10 -7
  7. package/dist/agents/run.agent.js.map +1 -1
  8. package/dist/cli.js +8 -3
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/audit.d.ts +2 -0
  11. package/dist/commands/audit.js +51 -0
  12. package/dist/commands/audit.js.map +1 -0
  13. package/dist/commands/eval.d.ts +2 -0
  14. package/dist/commands/eval.js +47 -0
  15. package/dist/commands/eval.js.map +1 -0
  16. package/dist/commands/improve.js +83 -5
  17. package/dist/commands/improve.js.map +1 -1
  18. package/dist/commands/init.js +17 -27
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/insights.js +63 -0
  21. package/dist/commands/insights.js.map +1 -1
  22. package/dist/commands/logs.d.ts +1 -0
  23. package/dist/commands/logs.js +23 -1
  24. package/dist/commands/logs.js.map +1 -1
  25. package/dist/commands/relay.js +250 -124
  26. package/dist/commands/relay.js.map +1 -1
  27. package/dist/commands/run.js +21 -2
  28. package/dist/commands/run.js.map +1 -1
  29. package/dist/commands/schedule.js +7 -1
  30. package/dist/commands/schedule.js.map +1 -1
  31. package/dist/commands/status.js +9 -2
  32. package/dist/commands/status.js.map +1 -1
  33. package/dist/lib/agent-runner.d.ts +5 -1
  34. package/dist/lib/agent-runner.js +41 -1
  35. package/dist/lib/agent-runner.js.map +1 -1
  36. package/dist/lib/audit.d.ts +7 -0
  37. package/dist/lib/audit.js +63 -0
  38. package/dist/lib/audit.js.map +1 -0
  39. package/dist/lib/callbacks.d.ts +12 -1
  40. package/dist/lib/callbacks.js +147 -19
  41. package/dist/lib/callbacks.js.map +1 -1
  42. package/dist/lib/command-ops/doctor-ops.js +41 -6
  43. package/dist/lib/command-ops/doctor-ops.js.map +1 -1
  44. package/dist/lib/command-ops/eval-ops.d.ts +7 -0
  45. package/dist/lib/command-ops/eval-ops.js +111 -0
  46. package/dist/lib/command-ops/eval-ops.js.map +1 -0
  47. package/dist/lib/command-ops/improve-ops.d.ts +14 -1
  48. package/dist/lib/command-ops/improve-ops.js +369 -17
  49. package/dist/lib/command-ops/improve-ops.js.map +1 -1
  50. package/dist/lib/command-ops/run-ops.d.ts +8 -1
  51. package/dist/lib/command-ops/run-ops.js +25 -4
  52. package/dist/lib/command-ops/run-ops.js.map +1 -1
  53. package/dist/lib/command-ops/secrets-migration-ops.js +1 -1
  54. package/dist/lib/command-ops/secrets-migration-ops.js.map +1 -1
  55. package/dist/lib/command-ops/status-ops.d.ts +1 -0
  56. package/dist/lib/command-ops/status-ops.js +19 -7
  57. package/dist/lib/command-ops/status-ops.js.map +1 -1
  58. package/dist/lib/config.js +3 -3
  59. package/dist/lib/config.js.map +1 -1
  60. package/dist/lib/cron.d.ts +1 -1
  61. package/dist/lib/cron.js +2 -2
  62. package/dist/lib/cron.js.map +1 -1
  63. package/dist/lib/global-config.js +1 -1
  64. package/dist/lib/global-config.js.map +1 -1
  65. package/dist/lib/observation.d.ts +60 -2
  66. package/dist/lib/observation.js +261 -13
  67. package/dist/lib/observation.js.map +1 -1
  68. package/dist/lib/registry.d.ts +0 -1
  69. package/dist/lib/registry.js +1 -1
  70. package/dist/lib/registry.js.map +1 -1
  71. package/dist/lib/relay/admin.d.ts +29 -0
  72. package/dist/lib/relay/admin.js +176 -0
  73. package/dist/lib/relay/admin.js.map +1 -0
  74. package/dist/lib/relay/bindings.d.ts +8 -0
  75. package/dist/lib/relay/bindings.js +50 -0
  76. package/dist/lib/relay/bindings.js.map +1 -0
  77. package/dist/lib/relay/config.d.ts +3 -3
  78. package/dist/lib/relay/config.js +18 -10
  79. package/dist/lib/relay/config.js.map +1 -1
  80. package/dist/lib/relay/context.d.ts +31 -0
  81. package/dist/lib/relay/context.js +118 -0
  82. package/dist/lib/relay/context.js.map +1 -0
  83. package/dist/lib/relay/db.d.ts +38 -0
  84. package/dist/lib/relay/db.js +252 -0
  85. package/dist/lib/relay/db.js.map +1 -0
  86. package/dist/lib/relay/executor.d.ts +2 -0
  87. package/dist/lib/relay/executor.js +108 -0
  88. package/dist/lib/relay/executor.js.map +1 -0
  89. package/dist/lib/relay/feedback.d.ts +29 -0
  90. package/dist/lib/relay/feedback.js +142 -0
  91. package/dist/lib/relay/feedback.js.map +1 -0
  92. package/dist/lib/relay/notifier.d.ts +30 -0
  93. package/dist/lib/relay/notifier.js +78 -0
  94. package/dist/lib/relay/notifier.js.map +1 -0
  95. package/dist/lib/relay/notify.d.ts +3 -4
  96. package/dist/lib/relay/notify.js +43 -159
  97. package/dist/lib/relay/notify.js.map +1 -1
  98. package/dist/lib/relay/platform.d.ts +52 -0
  99. package/dist/lib/relay/platform.js +5 -0
  100. package/dist/lib/relay/platform.js.map +1 -0
  101. package/dist/lib/relay/platforms/slack.d.ts +37 -0
  102. package/dist/lib/relay/platforms/slack.js +240 -0
  103. package/dist/lib/relay/platforms/slack.js.map +1 -0
  104. package/dist/lib/relay/platforms/telegram.d.ts +29 -0
  105. package/dist/lib/relay/platforms/telegram.js +193 -0
  106. package/dist/lib/relay/platforms/telegram.js.map +1 -0
  107. package/dist/lib/relay/poster.d.ts +24 -0
  108. package/dist/lib/relay/poster.js +136 -0
  109. package/dist/lib/relay/poster.js.map +1 -0
  110. package/dist/lib/relay/queue.d.ts +18 -0
  111. package/dist/lib/relay/queue.js +85 -0
  112. package/dist/lib/relay/queue.js.map +1 -0
  113. package/dist/lib/relay/router.d.ts +25 -2
  114. package/dist/lib/relay/router.js +259 -168
  115. package/dist/lib/relay/router.js.map +1 -1
  116. package/dist/lib/relay/service.d.ts +31 -7
  117. package/dist/lib/relay/service.js +281 -200
  118. package/dist/lib/relay/service.js.map +1 -1
  119. package/dist/lib/relay/types.d.ts +189 -34
  120. package/dist/lib/relay/types.js +68 -28
  121. package/dist/lib/relay/types.js.map +1 -1
  122. package/dist/lib/sandbox.d.ts +9 -1
  123. package/dist/lib/sandbox.js +17 -2
  124. package/dist/lib/sandbox.js.map +1 -1
  125. package/dist/lib/schedule.d.ts +4 -5
  126. package/dist/lib/schedule.js +7 -8
  127. package/dist/lib/schedule.js.map +1 -1
  128. package/dist/lib/secrets.js +11 -1
  129. package/dist/lib/secrets.js.map +1 -1
  130. package/dist/lib/types.d.ts +80 -0
  131. package/dist/lib/types.js +9 -1
  132. package/dist/lib/types.js.map +1 -1
  133. package/package.json +18 -18
  134. package/prompts/improve.yaml +114 -6
  135. package/prompts/relay.yaml +29 -0
  136. package/prompts/run.yaml +36 -2
  137. package/template/CLAUDE.md +34 -5
  138. package/template/RUNBOOK.md +5 -5
  139. package/template/evals/sample.json +16 -0
  140. package/template/memory/MEMORY.md +6 -0
  141. package/template/memory/procedures/.gitkeep +0 -0
  142. package/template/schedule.yaml +1 -1
  143. package/template/workflows/daily_feedback.ts +78 -2
  144. package/template/workflows/project_review.ts +51 -2
  145. package/prompts/relay-chat.yaml +0 -24
@@ -0,0 +1,18 @@
1
+ export declare class AgentPool {
2
+ private maxConcurrent;
3
+ private maxQueueDepth;
4
+ private maxMemoryMB;
5
+ private projectQueues;
6
+ private globalSemaphore;
7
+ private idleTimers;
8
+ constructor(maxConcurrent: number, maxQueueDepth: number, maxMemoryMB: number);
9
+ enqueue<T>(project: string, fn: () => Promise<T>): Promise<{
10
+ result: Promise<T>;
11
+ position: number;
12
+ }>;
13
+ getQueueDepth(project: string): number;
14
+ getActiveCount(): number;
15
+ shutdown(): Promise<void>;
16
+ private getOrCreateQueue;
17
+ private resetIdleTimer;
18
+ }
@@ -0,0 +1,85 @@
1
+ import PQueue from 'p-queue';
2
+ const IDLE_TIMEOUT_MS = 30 * 60_000; // 30 minutes
3
+ export class AgentPool {
4
+ maxConcurrent;
5
+ maxQueueDepth;
6
+ maxMemoryMB;
7
+ projectQueues = new Map();
8
+ globalSemaphore;
9
+ idleTimers = new Map();
10
+ constructor(maxConcurrent, maxQueueDepth, maxMemoryMB) {
11
+ this.maxConcurrent = maxConcurrent;
12
+ this.maxQueueDepth = maxQueueDepth;
13
+ this.maxMemoryMB = maxMemoryMB;
14
+ this.globalSemaphore = new PQueue({ concurrency: maxConcurrent });
15
+ }
16
+ async enqueue(project, fn) {
17
+ // Check memory pressure
18
+ const rss = process.memoryUsage().rss;
19
+ if (rss > this.maxMemoryMB * 1024 * 1024) {
20
+ throw new Error(`Memory pressure: RSS ${Math.round(rss / 1024 / 1024)}MB exceeds limit ${this.maxMemoryMB}MB`);
21
+ }
22
+ const queue = this.getOrCreateQueue(project);
23
+ // Check queue depth
24
+ const depth = queue.size + queue.pending;
25
+ if (depth >= this.maxQueueDepth) {
26
+ throw new Error(`Queue full for project "${project}": ${depth}/${this.maxQueueDepth}`);
27
+ }
28
+ const position = depth;
29
+ // Reset idle timer for this project
30
+ this.resetIdleTimer(project);
31
+ const result = queue.add(async () => {
32
+ // Wait for a global semaphore slot before running
33
+ return this.globalSemaphore.add(() => fn());
34
+ });
35
+ return { result, position };
36
+ }
37
+ getQueueDepth(project) {
38
+ const queue = this.projectQueues.get(project);
39
+ if (!queue)
40
+ return 0;
41
+ return queue.size + queue.pending;
42
+ }
43
+ getActiveCount() {
44
+ return this.globalSemaphore.pending;
45
+ }
46
+ async shutdown() {
47
+ // Clear all idle timers
48
+ for (const timer of this.idleTimers.values()) {
49
+ clearTimeout(timer);
50
+ }
51
+ this.idleTimers.clear();
52
+ // Clear all project queues
53
+ for (const queue of this.projectQueues.values()) {
54
+ queue.clear();
55
+ }
56
+ this.projectQueues.clear();
57
+ // Wait for in-flight work to complete
58
+ await this.globalSemaphore.onIdle();
59
+ this.globalSemaphore.clear();
60
+ }
61
+ getOrCreateQueue(project) {
62
+ let queue = this.projectQueues.get(project);
63
+ if (!queue) {
64
+ queue = new PQueue({ concurrency: 1 });
65
+ this.projectQueues.set(project, queue);
66
+ }
67
+ return queue;
68
+ }
69
+ resetIdleTimer(project) {
70
+ const existing = this.idleTimers.get(project);
71
+ if (existing)
72
+ clearTimeout(existing);
73
+ const timer = setTimeout(() => {
74
+ const queue = this.projectQueues.get(project);
75
+ if (queue && queue.size === 0 && queue.pending === 0) {
76
+ this.projectQueues.delete(project);
77
+ this.idleTimers.delete(project);
78
+ }
79
+ }, IDLE_TIMEOUT_MS);
80
+ // Don't hold the process open for idle timers
81
+ timer.unref();
82
+ this.idleTimers.set(project, timer);
83
+ }
84
+ }
85
+ //# sourceMappingURL=queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.js","sourceRoot":"","sources":["../../../src/lib/relay/queue.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,25 @@
1
- import type { ChannelAdapter, InboundMessage } from './channel.js';
2
- export declare function createRouter(adapter: ChannelAdapter): (msg: InboundMessage) => Promise<void>;
1
+ import type Database from 'better-sqlite3';
2
+ import type { ChatPlatform, ChatMessage } from './platform.js';
3
+ import type { RelayConfig } from './types.js';
4
+ import { AgentPool } from './queue.js';
5
+ export declare class RelayRouter {
6
+ private db;
7
+ private pool;
8
+ private config;
9
+ private activeAbortControllers;
10
+ private startedAt;
11
+ constructor(db: Database.Database, pool: AgentPool, config: RelayConfig);
12
+ /**
13
+ * Main message handler — called by platform adapters on incoming messages.
14
+ */
15
+ routeMessage(platform: ChatPlatform, msg: ChatMessage): Promise<void>;
16
+ /**
17
+ * Reaction handler — called by platform adapters on reactions.
18
+ */
19
+ routeReaction(platform: ChatPlatform, channelId: string, messageId: string, userId: string, emoji: string): Promise<void>;
20
+ /**
21
+ * Slash command handler — called by platform adapters.
22
+ */
23
+ routeCommand(platform: ChatPlatform, channelId: string, userId: string, command: string, args: string): Promise<string>;
24
+ private executeAndPost;
25
+ }
@@ -1,191 +1,282 @@
1
- import { listProjects } from '../project.js';
2
- import { getRecentLogs } from '../observation.js';
3
- import { executeRun } from '../command-ops/run-ops.js';
4
- import { acquireRunLock } from './locks.js';
5
- import { loadRelayConfig } from './config.js';
6
- import { appendConversation, getRecentConversation } from './history.js';
7
- import { runRelayChatAgent } from './chat.js';
8
- function getChatConfig(config, chatId) {
9
- return config.routing.chats[chatId] ?? null;
10
- }
11
- function isUserAllowed(config, userId) {
12
- if (config.routing.allowedUsers.length === 0)
1
+ // ── Relay Router ──
2
+ // Central dispatch: routes platform messages to the right handler.
3
+ // Wires together: bindings, context, queue, executor, poster, admin, feedback.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { lookupBinding } from './bindings.js';
6
+ import { cacheMessage, writePendingMessage, markPendingDone, recordRelayRun, updateRelayRun } from './db.js';
7
+ import { assembleContext } from './context.js';
8
+ import { executeRelayRun } from './executor.js';
9
+ import { formatResponse, addCostFooter } from './poster.js';
10
+ import { parseCommand, handleAdminCommand } from './admin.js';
11
+ import { classifyReaction, handleFeedback, appendRelayFeedback, saveFeedbackToMemory, isReactionRateLimited } from './feedback.js';
12
+ import { sendWebhookNotification } from './notify.js';
13
+ // ── Rate Limiting ──
14
+ const userMessageTimestamps = new Map();
15
+ function isUserRateLimited(userId, maxPerHour) {
16
+ const now = Date.now();
17
+ const cutoff = now - 3_600_000;
18
+ let timestamps = userMessageTimestamps.get(userId);
19
+ if (!timestamps) {
20
+ timestamps = [];
21
+ userMessageTimestamps.set(userId, timestamps);
22
+ }
23
+ const recent = timestamps.filter((t) => t > cutoff);
24
+ userMessageTimestamps.set(userId, recent);
25
+ if (recent.length >= maxPerHour)
13
26
  return true;
14
- return config.routing.allowedUsers.includes(userId);
15
- }
16
- function formatLogsSummary(project) {
17
- const logs = getRecentLogs(project, 7);
18
- if (logs.length === 0)
19
- return `No recent logs for "${project}".`;
20
- const lines = logs.slice(-10).map(r => {
21
- const icon = r.success ? '\u2705' : '\u274c';
22
- const time = new Date(r.startedAt).toISOString().slice(0, 16).replace('T', ' ');
23
- return `${icon} ${time} ${r.workflow} ($${r.costUsd.toFixed(4)})`;
24
- });
25
- return `Recent runs for *${project}* (last 7 days):\n${lines.join('\n')}`;
27
+ recent.push(now);
28
+ return false;
26
29
  }
27
- const HELP_TEXT = `*PilotLynx Relay*
28
-
29
- Commands:
30
- /run <project> <workflow> — Execute a workflow
31
- /status [project] — Show recent run logs
32
- /projects List registered projects
33
- /help Show this message
34
-
35
- Or just type normally to chat with the project agent.`;
36
- export function createRouter(adapter) {
37
- return async (msg) => {
38
- const config = loadRelayConfig();
39
- if (!config || !config.enabled)
30
+ // ── Router ──
31
+ export class RelayRouter {
32
+ db;
33
+ pool;
34
+ config;
35
+ activeAbortControllers = new Map(); // conversationId → controller
36
+ startedAt = new Date();
37
+ constructor(db, pool, config) {
38
+ this.db = db;
39
+ this.pool = pool;
40
+ this.config = config;
41
+ }
42
+ /**
43
+ * Main message handler — called by platform adapters on incoming messages.
44
+ */
45
+ async routeMessage(platform, msg) {
46
+ // Ignore bot messages
47
+ if (msg.isBot)
40
48
  return;
41
- // User allowlist check
42
- if (!isUserAllowed(config, msg.userId)) {
43
- await adapter.send(msg.chatId, 'Unauthorized. Your user ID is not in the allowedUsers list.');
49
+ // Cache the incoming message
50
+ cacheMessage(this.db, msg);
51
+ // Check for admin commands first
52
+ const parsed = parseCommand(msg.text);
53
+ if (parsed) {
54
+ const response = await handleAdminCommand({
55
+ db: this.db,
56
+ platform: platform.name,
57
+ channelId: msg.channelId,
58
+ userId: msg.userId,
59
+ config: this.config,
60
+ getQueueDepth: (p) => this.pool.getQueueDepth(p),
61
+ getActiveCount: () => this.pool.getActiveCount(),
62
+ startedAt: this.startedAt,
63
+ }, parsed.command, parsed.args);
64
+ // Handle special commands
65
+ if (parsed.command === 'cancel') {
66
+ const controller = this.activeAbortControllers.get(msg.conversationId);
67
+ if (controller) {
68
+ controller.abort();
69
+ this.activeAbortControllers.delete(msg.conversationId);
70
+ }
71
+ }
72
+ await platform.sendMessage(msg.channelId, response, msg.conversationId);
44
73
  return;
45
74
  }
46
- const chatConfig = getChatConfig(config, msg.chatId);
47
- // Unmapped chat: send setup instructions
48
- if (!chatConfig) {
49
- const rawId = msg.chatId.startsWith('telegram:') ? msg.chatId.slice('telegram:'.length) : msg.chatId;
50
- await adapter.send(msg.chatId, `Your chat ID is \`${rawId}\`.\n\n` +
51
- `Run this to connect:\n` +
52
- `\`pilotlynx relay add-chat ${rawId} --project <name>\``);
75
+ // Look up channel binding
76
+ const project = lookupBinding(this.db, platform.name, msg.channelId);
77
+ if (!project) {
78
+ await platform.sendMessage(msg.channelId, 'This channel is not bound to a project. An admin can use `bind <project>` to set one up.', msg.conversationId);
53
79
  return;
54
80
  }
55
- const text = msg.text.trim();
56
- // Command routing
57
- if (text.startsWith('/')) {
58
- await handleCommand(msg, text, chatConfig, config, adapter);
81
+ // Rate limit
82
+ if (isUserRateLimited(msg.userId, this.config.limits.userRatePerHour)) {
83
+ await platform.sendMessage(msg.channelId, 'You\'re sending messages too quickly. Please slow down.', msg.conversationId);
84
+ return;
59
85
  }
60
- else if (chatConfig.allowChat) {
61
- await handleChat(msg, text, chatConfig, adapter);
86
+ // Write pending message for crash recovery
87
+ const pendingId = randomUUID();
88
+ writePendingMessage(this.db, {
89
+ id: pendingId,
90
+ platform: platform.name,
91
+ channelId: msg.channelId,
92
+ conversationId: msg.conversationId,
93
+ userId: msg.userId,
94
+ userName: msg.userName,
95
+ text: msg.text,
96
+ receivedAt: new Date().toISOString(),
97
+ status: 'pending',
98
+ });
99
+ // Enqueue the agent run
100
+ try {
101
+ const { result: runPromise, position } = await this.pool.enqueue(project, async () => {
102
+ return this.executeAndPost(platform, msg, project, pendingId);
103
+ });
104
+ // If queued (not immediate), notify the user
105
+ if (position > 0) {
106
+ await platform.sendMessage(msg.channelId, `Your request is queued (position ${position}). I'll respond shortly.`, msg.conversationId);
107
+ }
108
+ // Don't await the run promise — it executes in the background via the queue
109
+ runPromise.catch((err) => {
110
+ console.error(`[relay] Run failed for ${project}:`, err);
111
+ });
62
112
  }
63
- else {
64
- await adapter.send(msg.chatId, 'Chat is disabled for this channel. Use /help for commands.');
113
+ catch (err) {
114
+ // Queue full or memory pressure
115
+ markPendingDone(this.db, pendingId);
116
+ const errMsg = err instanceof Error ? err.message : String(err);
117
+ await platform.sendMessage(msg.channelId, `Cannot process request: ${errMsg}`, msg.conversationId);
65
118
  }
66
- };
67
- }
68
- async function handleCommand(msg, text, chatConfig, config, adapter) {
69
- try {
70
- const parts = text.split(/\s+/);
71
- const cmd = parts[0].toLowerCase();
72
- if (cmd === '/help') {
73
- await adapter.send(msg.chatId, HELP_TEXT);
119
+ }
120
+ /**
121
+ * Reaction handler called by platform adapters on reactions.
122
+ */
123
+ async routeReaction(platform, channelId, messageId, userId, emoji) {
124
+ // Rate limit reactions
125
+ if (isReactionRateLimited(userId, this.config.limits.reactionRatePerHour))
74
126
  return;
75
- }
76
- if (cmd === '/projects') {
77
- const projects = listProjects();
78
- if (projects.length === 0) {
79
- await adapter.send(msg.chatId, 'No projects registered.');
80
- }
81
- else {
82
- await adapter.send(msg.chatId, `*Registered projects:*\n${projects.map(p => `• ${p}`).join('\n')}`);
127
+ // Check for cancellation reaction (stop_sign on a "Working on it..." message)
128
+ if (emoji === 'stop_sign' || emoji === 'octagonal_sign') {
129
+ // Find conversation for this message and abort
130
+ for (const [convId, controller] of this.activeAbortControllers) {
131
+ controller.abort();
132
+ this.activeAbortControllers.delete(convId);
133
+ await platform.sendMessage(channelId, 'Run cancelled.', convId);
134
+ break;
83
135
  }
84
136
  return;
85
137
  }
86
- if (cmd === '/status') {
87
- const project = parts[1] ?? chatConfig.project ?? config.routing.defaultProject;
88
- if (!project) {
89
- await adapter.send(msg.chatId, 'Usage: /status <project>\nNo default project configured.');
90
- return;
91
- }
92
- await adapter.send(msg.chatId, formatLogsSummary(project));
138
+ const feedbackType = classifyReaction(emoji);
139
+ if (!feedbackType)
93
140
  return;
94
- }
95
- if (cmd === '/run') {
96
- if (!chatConfig.allowRun) {
97
- await adapter.send(msg.chatId, 'Run commands are disabled for this chat.');
98
- return;
99
- }
100
- let resolvedProject;
101
- let resolvedWorkflow;
102
- if (parts.length === 3) {
103
- resolvedProject = parts[1];
104
- resolvedWorkflow = parts[2];
105
- }
106
- else if (parts.length === 2 && chatConfig.project) {
107
- resolvedProject = chatConfig.project;
108
- resolvedWorkflow = parts[1];
109
- }
110
- else {
111
- await adapter.send(msg.chatId, 'Usage: /run <project> <workflow>');
112
- return;
113
- }
114
- // Acquire run lock
115
- const release = await acquireRunLock(resolvedProject);
116
- if (!release) {
117
- await adapter.send(msg.chatId, `\u23f3 Project "${resolvedProject}" is busy. Try again shortly.`);
118
- return;
119
- }
120
- let typingInterval;
121
- try {
122
- await adapter.sendTyping(msg.chatId);
123
- typingInterval = setInterval(() => {
124
- adapter.sendTyping(msg.chatId).catch(() => { });
125
- }, 4000);
126
- const result = await executeRun(resolvedProject, resolvedWorkflow);
127
- if (result.success) {
128
- const duration = result.durationMs ? `${Math.round(result.durationMs / 1000)}s` : '?';
129
- await adapter.send(msg.chatId, `\u2705 *${resolvedProject}/${resolvedWorkflow}* completed\n` +
130
- `Duration: ${duration} | Cost: $${result.costUsd?.toFixed(4) ?? '?'}\n` +
131
- `${result.record?.summary?.slice(0, 500) ?? ''}`);
132
- }
133
- else {
134
- await adapter.send(msg.chatId, `\u274c *${resolvedProject}/${resolvedWorkflow}* failed\n${result.error?.slice(0, 500) ?? 'Unknown error'}`);
135
- }
136
- }
137
- finally {
138
- clearInterval(typingInterval);
139
- await release();
140
- }
141
+ const project = lookupBinding(this.db, platform.name, channelId);
142
+ if (!project)
141
143
  return;
144
+ const signal = {
145
+ type: feedbackType,
146
+ platform: platform.name,
147
+ conversationId: messageId, // approximate — reactions target a message
148
+ messageId,
149
+ userId,
150
+ userName: userId, // resolved by platform adapter if available
151
+ timestamp: new Date().toISOString(),
152
+ };
153
+ // Try to get agent output summary from the most recent run for this project
154
+ const lastRun = this.db.prepare(`SELECT id, status FROM relay_runs
155
+ WHERE project = ? AND platform = ? AND channel_id = ?
156
+ ORDER BY started_at DESC LIMIT 1`).get(project, platform.name, channelId);
157
+ const outputSummary = lastRun?.status === 'completed' ? `(run ${lastRun.id})` : undefined;
158
+ const entry = handleFeedback(signal, project, outputSummary);
159
+ appendRelayFeedback(entry);
160
+ // Save starred/saved responses to project memory/ dir
161
+ if (feedbackType === 'save') {
162
+ saveFeedbackToMemory(project, entry, outputSummary);
163
+ }
164
+ if (feedbackType === 'negative') {
165
+ await platform.sendMessage(channelId, 'Got it — what went wrong? Reply in this thread and I\'ll note the feedback.', messageId);
142
166
  }
143
- await adapter.send(msg.chatId, `Unknown command: ${cmd}\nUse /help for available commands.`);
144
- }
145
- catch (err) {
146
- await adapter.send(msg.chatId, `Command error: ${err instanceof Error ? err.message : String(err)}`);
147
- }
148
- }
149
- async function handleChat(msg, text, chatConfig, adapter) {
150
- const project = chatConfig.project;
151
- if (!project) {
152
- await adapter.send(msg.chatId, 'No project assigned to this chat. Use /help for commands.');
153
- return;
154
167
  }
155
- // Log user message
156
- await appendConversation(msg.chatId, {
157
- role: 'user',
158
- content: text,
159
- timestamp: new Date().toISOString(),
160
- });
161
- // Acquire lock for read-only agent too (prevents concurrent agent sessions)
162
- const release = await acquireRunLock(project);
163
- if (!release) {
164
- await adapter.send(msg.chatId, '\u23f3 Project is busy processing another request. Try again shortly.');
165
- return;
168
+ /**
169
+ * Slash command handler — called by platform adapters.
170
+ */
171
+ async routeCommand(platform, channelId, userId, command, args) {
172
+ return handleAdminCommand({
173
+ db: this.db,
174
+ platform: platform.name,
175
+ channelId,
176
+ userId,
177
+ config: this.config,
178
+ getQueueDepth: (p) => this.pool.getQueueDepth(p),
179
+ getActiveCount: () => this.pool.getActiveCount(),
180
+ startedAt: this.startedAt,
181
+ }, command, args);
166
182
  }
167
- let typingInterval;
168
- try {
169
- await adapter.sendTyping(msg.chatId);
170
- typingInterval = setInterval(() => {
171
- adapter.sendTyping(msg.chatId).catch(() => { });
172
- }, 4000);
173
- const history = getRecentConversation(msg.chatId);
174
- const reply = await runRelayChatAgent(project, text, history);
175
- // Log assistant reply
176
- await appendConversation(msg.chatId, {
177
- role: 'assistant',
178
- content: reply,
179
- timestamp: new Date().toISOString(),
183
+ // ── Internal ──
184
+ async executeAndPost(platform, msg, project, pendingId) {
185
+ const runId = randomUUID();
186
+ const abortController = new AbortController();
187
+ this.activeAbortControllers.set(msg.conversationId, abortController);
188
+ // Record the run start
189
+ recordRelayRun(this.db, {
190
+ id: runId,
191
+ platform: platform.name,
192
+ channelId: msg.channelId,
193
+ conversationId: msg.conversationId,
194
+ project,
195
+ userId: msg.userId,
196
+ startedAt: new Date().toISOString(),
197
+ status: 'running',
198
+ costUsd: 0,
199
+ inputTokens: 0,
200
+ outputTokens: 0,
201
+ durationMs: 0,
180
202
  });
181
- await adapter.send(msg.chatId, reply);
182
- }
183
- catch (err) {
184
- await adapter.send(msg.chatId, `Error: ${err instanceof Error ? err.message : String(err)}`);
185
- }
186
- finally {
187
- clearInterval(typingInterval);
188
- await release();
203
+ // Start streaming indicator
204
+ const stream = await platform.startStream(msg.channelId, msg.conversationId);
205
+ try {
206
+ // Assemble context
207
+ const { prompt, isStale } = await assembleContext(this.db, platform, msg.channelId, msg.conversationId, msg.text, msg.userName, project, this.config.context);
208
+ if (isStale) {
209
+ await platform.sendMessage(msg.channelId, `_Thread inactive for ${this.config.context.staleThreadDays}+ days. Starting fresh context._`, msg.conversationId);
210
+ }
211
+ // Execute agent run
212
+ const request = {
213
+ platform: platform.name,
214
+ channelId: msg.channelId,
215
+ conversationId: msg.conversationId,
216
+ userId: msg.userId,
217
+ userName: msg.userName,
218
+ project,
219
+ prompt,
220
+ abortSignal: abortController.signal,
221
+ onText: (text) => {
222
+ stream.append(text).catch(() => { });
223
+ },
224
+ };
225
+ const result = await executeRelayRun(request);
226
+ // Stop streaming and post final response
227
+ await stream.stop();
228
+ // Format and split response
229
+ const maxLen = platform.capabilities.maxMessageLength;
230
+ const parts = formatResponse(result.text, maxLen);
231
+ for (const part of parts) {
232
+ await platform.sendMessage(msg.channelId, part, msg.conversationId);
233
+ }
234
+ // Git diff summary
235
+ if (result.gitDiffStat) {
236
+ await platform.sendMessage(msg.channelId, `\`\`\`\n${result.gitDiffStat}\n\`\`\``, msg.conversationId);
237
+ }
238
+ // Cost footer
239
+ const footer = addCostFooter(result);
240
+ await platform.sendMessage(msg.channelId, footer, msg.conversationId);
241
+ // Update run record
242
+ const completedAt = new Date().toISOString();
243
+ updateRelayRun(this.db, runId, {
244
+ completedAt,
245
+ status: result.success ? 'completed' : 'failed',
246
+ costUsd: result.costUsd,
247
+ inputTokens: result.inputTokens,
248
+ outputTokens: result.outputTokens,
249
+ durationMs: result.durationMs,
250
+ model: result.model,
251
+ });
252
+ // Emit relay webhook event
253
+ sendWebhookNotification({
254
+ event: result.success ? 'relay_run_complete' : 'relay_run_failed',
255
+ timestamp: completedAt,
256
+ project,
257
+ workflow: 'relay',
258
+ success: result.success,
259
+ summary: result.text.slice(0, 200),
260
+ costUsd: result.costUsd,
261
+ durationMs: result.durationMs,
262
+ model: result.model,
263
+ platform: platform.name,
264
+ channelId: msg.channelId,
265
+ }).catch(() => { });
266
+ }
267
+ catch (err) {
268
+ await stream.stop();
269
+ const errMsg = err instanceof Error ? err.message : String(err);
270
+ await platform.sendMessage(msg.channelId, `Error: ${errMsg}`, msg.conversationId);
271
+ updateRelayRun(this.db, runId, {
272
+ completedAt: new Date().toISOString(),
273
+ status: 'failed',
274
+ });
275
+ }
276
+ finally {
277
+ this.activeAbortControllers.delete(msg.conversationId);
278
+ markPendingDone(this.db, pendingId);
279
+ }
189
280
  }
190
281
  }
191
282
  //# sourceMappingURL=router.js.map