@jonit-dev/night-watch-cli 1.7.24 → 1.7.27
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/shared/types.d.ts +2 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/src/agents/soul-compiler.d.ts.map +1 -1
- package/dist/src/agents/soul-compiler.js +60 -6
- package/dist/src/agents/soul-compiler.js.map +1 -1
- package/dist/src/commands/qa.d.ts +4 -0
- package/dist/src/commands/qa.d.ts.map +1 -1
- package/dist/src/commands/qa.js +35 -0
- package/dist/src/commands/qa.js.map +1 -1
- package/dist/src/commands/serve.d.ts +12 -0
- package/dist/src/commands/serve.d.ts.map +1 -1
- package/dist/src/commands/serve.js +115 -0
- package/dist/src/commands/serve.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +16 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/slack/channel-manager.js +3 -3
- package/dist/src/slack/channel-manager.js.map +1 -1
- package/dist/src/slack/client.d.ts +10 -2
- package/dist/src/slack/client.d.ts.map +1 -1
- package/dist/src/slack/client.js +38 -5
- package/dist/src/slack/client.js.map +1 -1
- package/dist/src/slack/deliberation.d.ts +26 -1
- package/dist/src/slack/deliberation.d.ts.map +1 -1
- package/dist/src/slack/deliberation.js +325 -53
- package/dist/src/slack/deliberation.js.map +1 -1
- package/dist/src/slack/interaction-listener.d.ts +54 -0
- package/dist/src/slack/interaction-listener.d.ts.map +1 -1
- package/dist/src/slack/interaction-listener.js +830 -13
- package/dist/src/slack/interaction-listener.js.map +1 -1
- package/dist/src/storage/repositories/index.d.ts.map +1 -1
- package/dist/src/storage/repositories/index.js +2 -0
- package/dist/src/storage/repositories/index.js.map +1 -1
- package/dist/src/storage/repositories/interfaces.d.ts +1 -0
- package/dist/src/storage/repositories/interfaces.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +5 -0
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +243 -100
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
- package/dist/src/utils/avatar-generator.d.ts +1 -1
- package/dist/src/utils/avatar-generator.d.ts.map +1 -1
- package/dist/src/utils/avatar-generator.js +62 -17
- package/dist/src/utils/avatar-generator.js.map +1 -1
- package/dist/src/utils/notify.d.ts +1 -0
- package/dist/src/utils/notify.d.ts.map +1 -1
- package/dist/src/utils/notify.js +13 -1
- package/dist/src/utils/notify.js.map +1 -1
- package/package.json +1 -1
- package/scripts/night-watch-pr-reviewer-cron.sh +36 -8
- package/scripts/night-watch-qa-cron.sh +15 -3
- package/templates/night-watch-pr-reviewer.md +46 -17
- package/web/dist/avatars/carlos.webp +0 -0
- package/web/dist/avatars/dev.webp +0 -0
- package/web/dist/avatars/maya.webp +0 -0
- package/web/dist/avatars/priya.webp +0 -0
|
@@ -4,16 +4,364 @@
|
|
|
4
4
|
* and applies loop-protection safeguards.
|
|
5
5
|
*/
|
|
6
6
|
import { SocketModeClient } from '@slack/socket-mode';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
7
10
|
import { getDb } from '../storage/sqlite/client.js';
|
|
8
11
|
import { getRepositories } from '../storage/repositories/index.js';
|
|
12
|
+
import { parseScriptResult } from '../utils/script-result.js';
|
|
13
|
+
import { getRoadmapStatus } from '../utils/roadmap-scanner.js';
|
|
9
14
|
import { generatePersonaAvatar } from '../utils/avatar-generator.js';
|
|
10
15
|
import { DeliberationEngine } from './deliberation.js';
|
|
11
16
|
import { SlackClient } from './client.js';
|
|
12
17
|
const MAX_PROCESSED_MESSAGE_KEYS = 2000;
|
|
13
18
|
const PERSONA_REPLY_COOLDOWN_MS = 45_000;
|
|
19
|
+
const AD_HOC_THREAD_MEMORY_MS = 60 * 60_000; // 1h
|
|
20
|
+
const PROACTIVE_IDLE_MS = 20 * 60_000; // 20 min
|
|
21
|
+
const PROACTIVE_MIN_INTERVAL_MS = 90 * 60_000; // per channel
|
|
22
|
+
const PROACTIVE_SWEEP_INTERVAL_MS = 60_000;
|
|
23
|
+
const PROACTIVE_CODEWATCH_MIN_INTERVAL_MS = 3 * 60 * 60_000; // per project
|
|
24
|
+
const PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS = 24 * 60 * 60_000; // per issue signature
|
|
25
|
+
const MAX_JOB_OUTPUT_CHARS = 12_000;
|
|
26
|
+
const HUMAN_REACTION_PROBABILITY = 0.65;
|
|
27
|
+
const REACTION_DELAY_MIN_MS = 180;
|
|
28
|
+
const REACTION_DELAY_MAX_MS = 1200;
|
|
29
|
+
const RESPONSE_DELAY_MIN_MS = 700;
|
|
30
|
+
const RESPONSE_DELAY_MAX_MS = 3400;
|
|
31
|
+
const CODEWATCH_MAX_FILES = 250;
|
|
32
|
+
const CODEWATCH_MAX_FILE_BYTES = 256_000;
|
|
33
|
+
const CODEWATCH_INCLUDE_EXTENSIONS = new Set([
|
|
34
|
+
'.ts',
|
|
35
|
+
'.tsx',
|
|
36
|
+
'.js',
|
|
37
|
+
'.jsx',
|
|
38
|
+
'.mjs',
|
|
39
|
+
'.cjs',
|
|
40
|
+
'.py',
|
|
41
|
+
'.go',
|
|
42
|
+
'.rb',
|
|
43
|
+
'.java',
|
|
44
|
+
'.kt',
|
|
45
|
+
'.rs',
|
|
46
|
+
'.php',
|
|
47
|
+
'.cs',
|
|
48
|
+
'.swift',
|
|
49
|
+
'.scala',
|
|
50
|
+
'.sh',
|
|
51
|
+
]);
|
|
52
|
+
const CODEWATCH_IGNORE_DIRS = new Set([
|
|
53
|
+
'.git',
|
|
54
|
+
'node_modules',
|
|
55
|
+
'dist',
|
|
56
|
+
'build',
|
|
57
|
+
'.next',
|
|
58
|
+
'coverage',
|
|
59
|
+
'.turbo',
|
|
60
|
+
'.cache',
|
|
61
|
+
'logs',
|
|
62
|
+
'.yarn',
|
|
63
|
+
'vendor',
|
|
64
|
+
'tmp',
|
|
65
|
+
'temp',
|
|
66
|
+
]);
|
|
67
|
+
const JOB_STOPWORDS = new Set([
|
|
68
|
+
'and',
|
|
69
|
+
'or',
|
|
70
|
+
'for',
|
|
71
|
+
'on',
|
|
72
|
+
'of',
|
|
73
|
+
'please',
|
|
74
|
+
'now',
|
|
75
|
+
'it',
|
|
76
|
+
'this',
|
|
77
|
+
'these',
|
|
78
|
+
'those',
|
|
79
|
+
'the',
|
|
80
|
+
'a',
|
|
81
|
+
'an',
|
|
82
|
+
'pr',
|
|
83
|
+
'pull',
|
|
84
|
+
'that',
|
|
85
|
+
'thanks',
|
|
86
|
+
'thank',
|
|
87
|
+
'again',
|
|
88
|
+
'job',
|
|
89
|
+
'pipeline',
|
|
90
|
+
]);
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
function extractLastMeaningfulLines(output, maxLines = 4) {
|
|
95
|
+
const lines = output
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.map((line) => line.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
if (lines.length === 0)
|
|
100
|
+
return '';
|
|
101
|
+
return lines.slice(-maxLines).join(' | ');
|
|
102
|
+
}
|
|
103
|
+
function buildCurrentCliInvocation(args) {
|
|
104
|
+
const cliEntry = process.argv[1];
|
|
105
|
+
if (!cliEntry)
|
|
106
|
+
return null;
|
|
107
|
+
return [...process.execArgv, cliEntry, ...args];
|
|
108
|
+
}
|
|
109
|
+
function formatCommandForLog(bin, args) {
|
|
110
|
+
return [bin, ...args].map((part) => JSON.stringify(part)).join(' ');
|
|
111
|
+
}
|
|
14
112
|
function extractInboundEvent(payload) {
|
|
15
113
|
return payload.event ?? payload.body?.event ?? payload.payload?.event ?? null;
|
|
16
114
|
}
|
|
115
|
+
export function buildInboundMessageKey(channel, ts, type) {
|
|
116
|
+
return `${channel}:${ts}:${type ?? 'message'}`;
|
|
117
|
+
}
|
|
118
|
+
function normalizeProjectRef(value) {
|
|
119
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
120
|
+
}
|
|
121
|
+
function stripSlackUserMentions(text) {
|
|
122
|
+
return text.replace(/<@[A-Z0-9]+>/g, ' ');
|
|
123
|
+
}
|
|
124
|
+
function normalizeForParsing(text) {
|
|
125
|
+
return text
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^\w\s./-]/g, ' ')
|
|
128
|
+
.replace(/\s+/g, ' ')
|
|
129
|
+
.trim();
|
|
130
|
+
}
|
|
131
|
+
function isCodeWatchSourceFile(filePath) {
|
|
132
|
+
return CODEWATCH_INCLUDE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
function lineNumberAt(content, index) {
|
|
135
|
+
if (index <= 0)
|
|
136
|
+
return 1;
|
|
137
|
+
let line = 1;
|
|
138
|
+
for (let i = 0; i < index && i < content.length; i += 1) {
|
|
139
|
+
if (content.charCodeAt(i) === 10) {
|
|
140
|
+
line += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return line;
|
|
144
|
+
}
|
|
145
|
+
function extractLineSnippet(content, index) {
|
|
146
|
+
const clamped = Math.max(0, Math.min(index, content.length));
|
|
147
|
+
const before = content.lastIndexOf('\n', clamped);
|
|
148
|
+
const after = content.indexOf('\n', clamped);
|
|
149
|
+
const start = before === -1 ? 0 : before + 1;
|
|
150
|
+
const end = after === -1 ? content.length : after;
|
|
151
|
+
return content.slice(start, end).trim().slice(0, 220);
|
|
152
|
+
}
|
|
153
|
+
function walkProjectFilesForCodeWatch(projectPath) {
|
|
154
|
+
const files = [];
|
|
155
|
+
const stack = [projectPath];
|
|
156
|
+
while (stack.length > 0 && files.length < CODEWATCH_MAX_FILES) {
|
|
157
|
+
const dir = stack.pop();
|
|
158
|
+
if (!dir)
|
|
159
|
+
break;
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (files.length >= CODEWATCH_MAX_FILES)
|
|
169
|
+
break;
|
|
170
|
+
const fullPath = path.join(dir, entry.name);
|
|
171
|
+
if (entry.isDirectory()) {
|
|
172
|
+
if (CODEWATCH_IGNORE_DIRS.has(entry.name)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
stack.push(fullPath);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!entry.isFile()) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (isCodeWatchSourceFile(fullPath)) {
|
|
182
|
+
files.push(fullPath);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return files;
|
|
187
|
+
}
|
|
188
|
+
export function findCodeWatchSignal(content) {
|
|
189
|
+
if (!content || content.trim().length === 0)
|
|
190
|
+
return null;
|
|
191
|
+
const emptyCatchMatch = /catch\s*(?:\([^)]*\))?\s*\{\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*\}/gm.exec(content);
|
|
192
|
+
const criticalTodoMatch = /\b(?:TODO|FIXME|HACK)\b[^\n]{0,140}\b(?:bug|security|race|leak|crash|hotfix|rollback|unsafe)\b/gi.exec(content);
|
|
193
|
+
const emptyCatchIndex = emptyCatchMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
194
|
+
const criticalTodoIndex = criticalTodoMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
195
|
+
if (!Number.isFinite(emptyCatchIndex) && !Number.isFinite(criticalTodoIndex)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (emptyCatchIndex <= criticalTodoIndex) {
|
|
199
|
+
return {
|
|
200
|
+
type: 'empty_catch',
|
|
201
|
+
index: emptyCatchIndex,
|
|
202
|
+
summary: 'empty catch block may hide runtime failures',
|
|
203
|
+
snippet: extractLineSnippet(content, emptyCatchIndex),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
type: 'critical_todo',
|
|
208
|
+
index: criticalTodoIndex,
|
|
209
|
+
summary: 'high-risk TODO/FIXME likely indicates unresolved bug or security concern',
|
|
210
|
+
snippet: (criticalTodoMatch?.[0] ?? extractLineSnippet(content, criticalTodoIndex)).trim().slice(0, 220),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function detectCodeWatchCandidate(projectPath) {
|
|
214
|
+
const files = walkProjectFilesForCodeWatch(projectPath);
|
|
215
|
+
for (const filePath of files) {
|
|
216
|
+
let stat;
|
|
217
|
+
try {
|
|
218
|
+
stat = fs.statSync(filePath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (stat.size <= 0 || stat.size > CODEWATCH_MAX_FILE_BYTES) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
let content;
|
|
227
|
+
try {
|
|
228
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (content.includes('\u0000')) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const signal = findCodeWatchSignal(content);
|
|
237
|
+
if (!signal)
|
|
238
|
+
continue;
|
|
239
|
+
const relativePath = path.relative(projectPath, filePath).replace(/\\/g, '/');
|
|
240
|
+
const line = lineNumberAt(content, signal.index);
|
|
241
|
+
const signature = `${signal.type}:${relativePath}:${line}`;
|
|
242
|
+
return {
|
|
243
|
+
...signal,
|
|
244
|
+
relativePath,
|
|
245
|
+
line,
|
|
246
|
+
signature,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
export function isAmbientTeamMessage(text) {
|
|
252
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(text));
|
|
253
|
+
if (!normalized)
|
|
254
|
+
return false;
|
|
255
|
+
if (/^(hey|hi|hello|yo|sup)\b/.test(normalized) && /\b(guys|team|everyone|folks)\b/.test(normalized)) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
if (/^(hey|hi|hello|yo|sup)\b/.test(normalized) && normalized.split(' ').length <= 6) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
export function parseSlackJobRequest(text) {
|
|
264
|
+
const withoutMentions = stripSlackUserMentions(text);
|
|
265
|
+
const normalized = normalizeForParsing(withoutMentions);
|
|
266
|
+
if (!normalized)
|
|
267
|
+
return null;
|
|
268
|
+
// Be tolerant of wrapped/copied URLs where whitespace/newlines split segments.
|
|
269
|
+
const compactForUrl = withoutMentions.replace(/\s+/g, '');
|
|
270
|
+
const prUrlMatch = compactForUrl.match(/https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i);
|
|
271
|
+
const prPathMatch = compactForUrl.match(/\/pull\/(\d+)(?:[/?#]|$)/i);
|
|
272
|
+
const prHashMatch = withoutMentions.match(/(?:^|\s)#(\d+)(?:\s|$)/);
|
|
273
|
+
const conflictSignal = /\b(conflict|conflicts|merge conflict|merge issues?|rebase)\b/i.test(normalized);
|
|
274
|
+
const requestSignal = /\b(can someone|someone|anyone|please|need|look at|take a look|fix|review|check)\b/i.test(normalized);
|
|
275
|
+
const match = normalized.match(/\b(run|review|qa)\b(?:\s+(?:for|on)?\s*([a-z0-9./_-]+))?/i);
|
|
276
|
+
if (!match && !prUrlMatch && !prHashMatch)
|
|
277
|
+
return null;
|
|
278
|
+
const explicitJob = match?.[1]?.toLowerCase();
|
|
279
|
+
const hasPrReference = Boolean(prUrlMatch?.[3] ?? prPathMatch?.[1] ?? prHashMatch?.[1]);
|
|
280
|
+
const inferredReviewJob = conflictSignal || (hasPrReference && requestSignal);
|
|
281
|
+
const job = explicitJob ?? (inferredReviewJob ? 'review' : undefined);
|
|
282
|
+
if (!job || !['run', 'review', 'qa'].includes(job))
|
|
283
|
+
return null;
|
|
284
|
+
const prNumber = prUrlMatch?.[3] ?? prPathMatch?.[1] ?? prHashMatch?.[1];
|
|
285
|
+
const repoHintFromUrl = prUrlMatch?.[2]?.toLowerCase();
|
|
286
|
+
const candidates = [match?.[2]?.toLowerCase(), repoHintFromUrl].filter((value) => Boolean(value && !JOB_STOPWORDS.has(value)));
|
|
287
|
+
const projectHint = candidates[0];
|
|
288
|
+
const request = { job };
|
|
289
|
+
if (projectHint)
|
|
290
|
+
request.projectHint = projectHint;
|
|
291
|
+
if (prNumber)
|
|
292
|
+
request.prNumber = prNumber;
|
|
293
|
+
if (job === 'review' && conflictSignal)
|
|
294
|
+
request.fixConflicts = true;
|
|
295
|
+
return request;
|
|
296
|
+
}
|
|
297
|
+
function getPersonaDomain(persona) {
|
|
298
|
+
const role = persona.role.toLowerCase();
|
|
299
|
+
const expertise = (persona.soul?.expertise ?? []).join(' ').toLowerCase();
|
|
300
|
+
const blob = `${role} ${expertise}`;
|
|
301
|
+
if (/\bsecurity|auth|pentest|owasp|crypt|vuln\b/.test(blob))
|
|
302
|
+
return 'security';
|
|
303
|
+
if (/\bqa|quality|test|e2e\b/.test(blob))
|
|
304
|
+
return 'qa';
|
|
305
|
+
if (/\blead|architect|architecture|systems\b/.test(blob))
|
|
306
|
+
return 'lead';
|
|
307
|
+
if (/\bimplementer|developer|executor|engineer\b/.test(blob))
|
|
308
|
+
return 'dev';
|
|
309
|
+
return 'general';
|
|
310
|
+
}
|
|
311
|
+
export function scorePersonaForText(text, persona) {
|
|
312
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(text));
|
|
313
|
+
if (!normalized)
|
|
314
|
+
return 0;
|
|
315
|
+
let score = 0;
|
|
316
|
+
const domain = getPersonaDomain(persona);
|
|
317
|
+
if (normalized.includes(persona.name.toLowerCase())) {
|
|
318
|
+
score += 12;
|
|
319
|
+
}
|
|
320
|
+
const securitySignal = /\b(security|auth|vuln|owasp|xss|csrf|token|permission|exploit|threat)\b/.test(normalized);
|
|
321
|
+
const qaSignal = /\b(qa|test|testing|bug|e2e|playwright|regression|flaky)\b/.test(normalized);
|
|
322
|
+
const leadSignal = /\b(architecture|architect|design|scalability|performance|tech debt|tradeoff|strategy)\b/.test(normalized);
|
|
323
|
+
const devSignal = /\b(implement|implementation|code|build|fix|patch|ship|pr)\b/.test(normalized);
|
|
324
|
+
if (securitySignal && domain === 'security')
|
|
325
|
+
score += 8;
|
|
326
|
+
if (qaSignal && domain === 'qa')
|
|
327
|
+
score += 8;
|
|
328
|
+
if (leadSignal && domain === 'lead')
|
|
329
|
+
score += 8;
|
|
330
|
+
if (devSignal && domain === 'dev')
|
|
331
|
+
score += 8;
|
|
332
|
+
const personaTokens = new Set([
|
|
333
|
+
...persona.role.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3),
|
|
334
|
+
...(persona.soul?.expertise ?? [])
|
|
335
|
+
.flatMap((s) => s.toLowerCase().split(/[^a-z0-9]+/))
|
|
336
|
+
.filter((t) => t.length >= 3),
|
|
337
|
+
]);
|
|
338
|
+
const textTokens = normalized.split(/\s+/).filter((t) => t.length >= 3);
|
|
339
|
+
for (const token of textTokens) {
|
|
340
|
+
if (personaTokens.has(token)) {
|
|
341
|
+
score += 2;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return score;
|
|
345
|
+
}
|
|
346
|
+
export function selectFollowUpPersona(preferred, personas, text) {
|
|
347
|
+
if (personas.length === 0)
|
|
348
|
+
return preferred;
|
|
349
|
+
const preferredScore = scorePersonaForText(text, preferred);
|
|
350
|
+
let best = preferred;
|
|
351
|
+
let bestScore = preferredScore;
|
|
352
|
+
for (const persona of personas) {
|
|
353
|
+
const score = scorePersonaForText(text, persona);
|
|
354
|
+
if (score > bestScore) {
|
|
355
|
+
best = persona;
|
|
356
|
+
bestScore = score;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Default to continuity unless another persona is clearly a better fit.
|
|
360
|
+
if (best.id !== preferred.id && bestScore >= preferredScore + 4 && bestScore >= 8) {
|
|
361
|
+
return best;
|
|
362
|
+
}
|
|
363
|
+
return preferred;
|
|
364
|
+
}
|
|
17
365
|
function normalizeHandle(value) {
|
|
18
366
|
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
19
367
|
}
|
|
@@ -103,10 +451,17 @@ export class SlackInteractionListener {
|
|
|
103
451
|
_processedMessageKeys = new Set();
|
|
104
452
|
_processedMessageOrder = [];
|
|
105
453
|
_lastPersonaReplyAt = new Map();
|
|
454
|
+
_adHocThreadState = new Map();
|
|
455
|
+
_lastChannelActivityAt = new Map();
|
|
456
|
+
_lastProactiveAt = new Map();
|
|
457
|
+
_lastCodeWatchAt = new Map();
|
|
458
|
+
_lastCodeWatchSignatureAt = new Map();
|
|
459
|
+
_proactiveTimer = null;
|
|
106
460
|
constructor(config) {
|
|
107
461
|
this._config = config;
|
|
108
462
|
const token = config.slack?.botToken ?? '';
|
|
109
|
-
|
|
463
|
+
const serverBaseUrl = config.slack?.serverBaseUrl ?? 'http://localhost:7575';
|
|
464
|
+
this._slackClient = new SlackClient(token, serverBaseUrl);
|
|
110
465
|
this._engine = new DeliberationEngine(this._slackClient, config);
|
|
111
466
|
}
|
|
112
467
|
async start() {
|
|
@@ -147,9 +502,11 @@ export class SlackInteractionListener {
|
|
|
147
502
|
await socket.start();
|
|
148
503
|
this._socketClient = socket;
|
|
149
504
|
console.log('Slack interaction listener started (Socket Mode)');
|
|
505
|
+
this._startProactiveLoop();
|
|
150
506
|
void this._postPersonaIntros();
|
|
151
507
|
}
|
|
152
508
|
async stop() {
|
|
509
|
+
this._stopProactiveLoop();
|
|
153
510
|
if (!this._socketClient) {
|
|
154
511
|
return;
|
|
155
512
|
}
|
|
@@ -175,6 +532,10 @@ export class SlackInteractionListener {
|
|
|
175
532
|
return;
|
|
176
533
|
// Join all configured channels so the bot receives messages in them
|
|
177
534
|
const channelIds = Object.values(slack.channels ?? {}).filter(Boolean);
|
|
535
|
+
const now = Date.now();
|
|
536
|
+
for (const channelId of channelIds) {
|
|
537
|
+
this._lastChannelActivityAt.set(channelId, now);
|
|
538
|
+
}
|
|
178
539
|
for (const channelId of channelIds) {
|
|
179
540
|
try {
|
|
180
541
|
await this._slackClient.joinChannel(channelId);
|
|
@@ -206,7 +567,7 @@ export class SlackInteractionListener {
|
|
|
206
567
|
if (!currentPersona.avatarUrl && slack.replicateApiToken) {
|
|
207
568
|
try {
|
|
208
569
|
console.log(`[slack] Generating avatar for ${persona.name}…`);
|
|
209
|
-
const avatarUrl = await generatePersonaAvatar(persona.role, slack.replicateApiToken);
|
|
570
|
+
const avatarUrl = await generatePersonaAvatar(persona.name, persona.role, slack.replicateApiToken);
|
|
210
571
|
if (avatarUrl) {
|
|
211
572
|
currentPersona = repos.agentPersona.update(persona.id, { avatarUrl });
|
|
212
573
|
console.log(`[slack] Avatar set for ${persona.name}: ${avatarUrl}`);
|
|
@@ -250,10 +611,22 @@ export class SlackInteractionListener {
|
|
|
250
611
|
const event = extractInboundEvent(payload);
|
|
251
612
|
if (!event)
|
|
252
613
|
return;
|
|
253
|
-
// Log every event so we can debug what's arriving
|
|
254
|
-
console.log(`[slack] event type=${event.type ?? '?'} subtype=${event.subtype ?? '-'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} bot_id=${event.bot_id ?? '-'} text=${(event.text ?? '').slice(0, 80)}`);
|
|
255
614
|
if (event.type !== 'message' && event.type !== 'app_mention')
|
|
256
615
|
return;
|
|
616
|
+
const ignored = shouldIgnoreInboundSlackEvent(event, this._botUserId);
|
|
617
|
+
if (ignored) {
|
|
618
|
+
console.log(`[slack] ignored self/system event type=${event.type ?? '?'} subtype=${event.subtype ?? '-'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} bot_id=${event.bot_id ?? '-'}`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
console.log(`[slack] inbound human event type=${event.type ?? '?'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} text=${(event.text ?? '').slice(0, 80)}`);
|
|
622
|
+
// Direct bot mentions arrive as app_mention; ignore the mirrored message event
|
|
623
|
+
// to avoid duplicate or out-of-order handling on the same Slack message ts.
|
|
624
|
+
if (event.type === 'message'
|
|
625
|
+
&& this._botUserId
|
|
626
|
+
&& (event.text ?? '').includes(`<@${this._botUserId}>`)) {
|
|
627
|
+
console.log('[slack] ignoring mirrored message event for direct bot mention');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
257
630
|
try {
|
|
258
631
|
await this._handleInboundMessage(event);
|
|
259
632
|
}
|
|
@@ -287,6 +660,398 @@ export class SlackInteractionListener {
|
|
|
287
660
|
const key = `${channel}:${threadTs}:${personaId}`;
|
|
288
661
|
this._lastPersonaReplyAt.set(key, Date.now());
|
|
289
662
|
}
|
|
663
|
+
_threadKey(channel, threadTs) {
|
|
664
|
+
return `${channel}:${threadTs}`;
|
|
665
|
+
}
|
|
666
|
+
_markChannelActivity(channel) {
|
|
667
|
+
this._lastChannelActivityAt.set(channel, Date.now());
|
|
668
|
+
}
|
|
669
|
+
_rememberAdHocThreadPersona(channel, threadTs, personaId) {
|
|
670
|
+
this._adHocThreadState.set(this._threadKey(channel, threadTs), {
|
|
671
|
+
personaId,
|
|
672
|
+
expiresAt: Date.now() + AD_HOC_THREAD_MEMORY_MS,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Recover the persona that last replied in a thread by scanning its history.
|
|
677
|
+
* Used as a fallback when in-memory state was lost (e.g. after a server restart).
|
|
678
|
+
* Matches message `username` fields against known persona names.
|
|
679
|
+
*/
|
|
680
|
+
async _recoverPersonaFromThreadHistory(channel, threadTs, personas) {
|
|
681
|
+
try {
|
|
682
|
+
const history = await this._slackClient.getChannelHistory(channel, threadTs, 50);
|
|
683
|
+
// Walk backwards to find the most recent message sent by a persona
|
|
684
|
+
for (const msg of [...history].reverse()) {
|
|
685
|
+
if (!msg.username)
|
|
686
|
+
continue;
|
|
687
|
+
const matched = personas.find((p) => p.name.toLowerCase() === msg.username.toLowerCase());
|
|
688
|
+
if (matched)
|
|
689
|
+
return matched;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// Ignore — treat as no prior context
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
_getRememberedAdHocPersona(channel, threadTs, personas) {
|
|
698
|
+
const key = this._threadKey(channel, threadTs);
|
|
699
|
+
const remembered = this._adHocThreadState.get(key);
|
|
700
|
+
if (!remembered)
|
|
701
|
+
return null;
|
|
702
|
+
if (Date.now() > remembered.expiresAt) {
|
|
703
|
+
this._adHocThreadState.delete(key);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
return personas.find((p) => p.id === remembered.personaId) ?? null;
|
|
707
|
+
}
|
|
708
|
+
_pickRandomPersona(personas, channel, threadTs) {
|
|
709
|
+
if (personas.length === 0)
|
|
710
|
+
return null;
|
|
711
|
+
const available = personas.filter((p) => !this._isPersonaOnCooldown(channel, threadTs, p.id));
|
|
712
|
+
const pool = available.length > 0 ? available : personas;
|
|
713
|
+
return pool[Math.floor(Math.random() * pool.length)] ?? null;
|
|
714
|
+
}
|
|
715
|
+
_findPersonaByName(personas, name) {
|
|
716
|
+
const target = name.toLowerCase();
|
|
717
|
+
return personas.find((p) => p.name.toLowerCase() === target) ?? null;
|
|
718
|
+
}
|
|
719
|
+
_buildProjectContext(channel, projects) {
|
|
720
|
+
if (projects.length === 0)
|
|
721
|
+
return '';
|
|
722
|
+
const inChannel = projects.find((p) => p.slackChannelId === channel);
|
|
723
|
+
const names = projects.map((p) => p.name).join(', ');
|
|
724
|
+
if (inChannel) {
|
|
725
|
+
return `Current channel project: ${inChannel.name}. Registered projects: ${names}.`;
|
|
726
|
+
}
|
|
727
|
+
return `Registered projects: ${names}.`;
|
|
728
|
+
}
|
|
729
|
+
_buildRoadmapContext(channel, projects) {
|
|
730
|
+
if (projects.length === 0)
|
|
731
|
+
return '';
|
|
732
|
+
const parts = [];
|
|
733
|
+
for (const project of projects) {
|
|
734
|
+
try {
|
|
735
|
+
const status = getRoadmapStatus(project.path, this._config);
|
|
736
|
+
if (!status.found || status.items.length === 0)
|
|
737
|
+
continue;
|
|
738
|
+
const pending = status.items.filter((i) => !i.processed && !i.checked);
|
|
739
|
+
const done = status.items.filter((i) => i.processed);
|
|
740
|
+
const total = status.items.length;
|
|
741
|
+
let summary = `${project.name}: ${done.length}/${total} roadmap items done`;
|
|
742
|
+
if (pending.length > 0) {
|
|
743
|
+
const nextItems = pending.slice(0, 3).map((i) => i.title);
|
|
744
|
+
summary += `. Next up: ${nextItems.join(', ')}`;
|
|
745
|
+
}
|
|
746
|
+
if (done.length === total) {
|
|
747
|
+
summary += ' (all complete)';
|
|
748
|
+
}
|
|
749
|
+
parts.push(summary);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Skip projects where roadmap can't be read
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return parts.join('\n');
|
|
756
|
+
}
|
|
757
|
+
_resolveProjectByHint(projects, hint) {
|
|
758
|
+
const normalizedHint = normalizeProjectRef(hint);
|
|
759
|
+
if (!normalizedHint)
|
|
760
|
+
return null;
|
|
761
|
+
const byNameExact = projects.find((p) => normalizeProjectRef(p.name) === normalizedHint);
|
|
762
|
+
if (byNameExact)
|
|
763
|
+
return byNameExact;
|
|
764
|
+
const byPathExact = projects.find((p) => {
|
|
765
|
+
const base = p.path.split('/').pop() ?? '';
|
|
766
|
+
return normalizeProjectRef(base) === normalizedHint;
|
|
767
|
+
});
|
|
768
|
+
if (byPathExact)
|
|
769
|
+
return byPathExact;
|
|
770
|
+
const byNameContains = projects.find((p) => normalizeProjectRef(p.name).includes(normalizedHint));
|
|
771
|
+
if (byNameContains)
|
|
772
|
+
return byNameContains;
|
|
773
|
+
return projects.find((p) => {
|
|
774
|
+
const base = p.path.split('/').pop() ?? '';
|
|
775
|
+
return normalizeProjectRef(base).includes(normalizedHint);
|
|
776
|
+
}) ?? null;
|
|
777
|
+
}
|
|
778
|
+
_resolveTargetProject(channel, projects, projectHint) {
|
|
779
|
+
if (projectHint) {
|
|
780
|
+
return this._resolveProjectByHint(projects, projectHint);
|
|
781
|
+
}
|
|
782
|
+
const byChannel = projects.find((p) => p.slackChannelId === channel);
|
|
783
|
+
if (byChannel)
|
|
784
|
+
return byChannel;
|
|
785
|
+
if (projects.length === 1)
|
|
786
|
+
return projects[0];
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
_isMessageAddressedToBot(event) {
|
|
790
|
+
if (event.type === 'app_mention')
|
|
791
|
+
return true;
|
|
792
|
+
const text = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
793
|
+
return /^night[-\s]?watch\b/.test(text) || /^nw\b/.test(text);
|
|
794
|
+
}
|
|
795
|
+
_randomInt(min, max) {
|
|
796
|
+
if (max <= min)
|
|
797
|
+
return min;
|
|
798
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
799
|
+
}
|
|
800
|
+
_reactionCandidatesForPersona(persona) {
|
|
801
|
+
const role = persona.role.toLowerCase();
|
|
802
|
+
if (role.includes('security'))
|
|
803
|
+
return ['eyes', 'thinking_face', 'shield', 'thumbsup'];
|
|
804
|
+
if (role.includes('qa') || role.includes('quality'))
|
|
805
|
+
return ['test_tube', 'mag', 'thinking_face', 'thumbsup'];
|
|
806
|
+
if (role.includes('lead') || role.includes('architect'))
|
|
807
|
+
return ['thinking_face', 'thumbsup', 'memo', 'eyes'];
|
|
808
|
+
if (role.includes('implementer') || role.includes('developer'))
|
|
809
|
+
return ['wrench', 'hammer_and_wrench', 'thumbsup', 'eyes'];
|
|
810
|
+
return ['eyes', 'thinking_face', 'thumbsup', 'wave'];
|
|
811
|
+
}
|
|
812
|
+
async _maybeReactToHumanMessage(channel, messageTs, persona) {
|
|
813
|
+
if (Math.random() > HUMAN_REACTION_PROBABILITY) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const candidates = this._reactionCandidatesForPersona(persona);
|
|
817
|
+
const reaction = candidates[this._randomInt(0, candidates.length - 1)];
|
|
818
|
+
await sleep(this._randomInt(REACTION_DELAY_MIN_MS, REACTION_DELAY_MAX_MS));
|
|
819
|
+
try {
|
|
820
|
+
await this._slackClient.addReaction(channel, messageTs, reaction);
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// Ignore reaction failures (permissions, already reacted, etc.)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
async _applyHumanResponseTiming(channel, messageTs, persona) {
|
|
827
|
+
await this._maybeReactToHumanMessage(channel, messageTs, persona);
|
|
828
|
+
await sleep(this._randomInt(RESPONSE_DELAY_MIN_MS, RESPONSE_DELAY_MAX_MS));
|
|
829
|
+
}
|
|
830
|
+
async _spawnNightWatchJob(job, project, channel, threadTs, persona, opts) {
|
|
831
|
+
const invocationArgs = buildCurrentCliInvocation([job]);
|
|
832
|
+
const prRef = opts?.prNumber ? ` PR #${opts.prNumber}` : '';
|
|
833
|
+
if (!invocationArgs) {
|
|
834
|
+
console.warn(`[slack][job] ${persona.name} cannot start ${job} for ${project.name}${prRef ? ` (${prRef.trim()})` : ''}: CLI entry path unavailable`);
|
|
835
|
+
await this._slackClient.postAsAgent(channel, `Can't start that ${job} right now — runtime issue. Checking it.`, persona, threadTs);
|
|
836
|
+
this._markChannelActivity(channel);
|
|
837
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
console.log(`[slack][job] persona=${persona.name} project=${project.name}${opts?.prNumber ? ` pr=${opts.prNumber}` : ''} spawn=${formatCommandForLog(process.execPath, invocationArgs)}`);
|
|
841
|
+
const child = spawn(process.execPath, invocationArgs, {
|
|
842
|
+
cwd: project.path,
|
|
843
|
+
env: {
|
|
844
|
+
...process.env,
|
|
845
|
+
NW_EXECUTION_CONTEXT: 'agent',
|
|
846
|
+
...(opts?.prNumber ? { NW_TARGET_PR: opts.prNumber } : {}),
|
|
847
|
+
...(opts?.fixConflicts
|
|
848
|
+
? {
|
|
849
|
+
NW_SLACK_FEEDBACK: JSON.stringify({
|
|
850
|
+
source: 'slack',
|
|
851
|
+
kind: 'merge_conflict_resolution',
|
|
852
|
+
prNumber: opts.prNumber ?? '',
|
|
853
|
+
changes: 'Resolve merge conflicts and stabilize the PR for re-review.',
|
|
854
|
+
}),
|
|
855
|
+
}
|
|
856
|
+
: {}),
|
|
857
|
+
},
|
|
858
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
859
|
+
});
|
|
860
|
+
console.log(`[slack][job] ${persona.name} spawned ${job} for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''} pid=${child.pid ?? 'unknown'}`);
|
|
861
|
+
let output = '';
|
|
862
|
+
let errored = false;
|
|
863
|
+
const appendOutput = (chunk) => {
|
|
864
|
+
output += chunk.toString();
|
|
865
|
+
if (output.length > MAX_JOB_OUTPUT_CHARS) {
|
|
866
|
+
output = output.slice(-MAX_JOB_OUTPUT_CHARS);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
child.stdout?.on('data', appendOutput);
|
|
870
|
+
child.stderr?.on('data', appendOutput);
|
|
871
|
+
child.on('error', async (err) => {
|
|
872
|
+
errored = true;
|
|
873
|
+
console.warn(`[slack][job] ${persona.name} ${job} spawn error for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''}: ${err.message}`);
|
|
874
|
+
await this._slackClient.postAsAgent(channel, `Couldn't kick off that ${job}. Error logged — looking into it.`, persona, threadTs);
|
|
875
|
+
this._markChannelActivity(channel);
|
|
876
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
877
|
+
});
|
|
878
|
+
child.on('close', async (code) => {
|
|
879
|
+
if (errored)
|
|
880
|
+
return;
|
|
881
|
+
console.log(`[slack][job] ${persona.name} ${job} finished for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''} exit=${code ?? 'unknown'}`);
|
|
882
|
+
const parsed = parseScriptResult(output);
|
|
883
|
+
const status = parsed?.status ? ` (${parsed.status})` : '';
|
|
884
|
+
const detail = extractLastMeaningfulLines(output);
|
|
885
|
+
if (code === 0) {
|
|
886
|
+
const doneMessage = job === 'review'
|
|
887
|
+
? `Review done${prRef ? ` on${prRef}` : ''}.`
|
|
888
|
+
: job === 'qa'
|
|
889
|
+
? `QA pass done${prRef ? ` on${prRef}` : ''}.`
|
|
890
|
+
: `Run finished${prRef ? ` for${prRef}` : ''}.`;
|
|
891
|
+
await this._slackClient.postAsAgent(channel, doneMessage, persona, threadTs);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
if (detail) {
|
|
895
|
+
console.warn(`[slack][job] ${persona.name} ${job} failure detail: ${detail}`);
|
|
896
|
+
}
|
|
897
|
+
await this._slackClient.postAsAgent(channel, `Hit a snag running ${job}${prRef ? ` on${prRef}` : ''}. Logged the details — looking into it.`, persona, threadTs);
|
|
898
|
+
}
|
|
899
|
+
if (code !== 0 && status) {
|
|
900
|
+
console.warn(`[slack][job] ${persona.name} ${job} status=${status.replace(/[()]/g, '')}`);
|
|
901
|
+
}
|
|
902
|
+
this._markChannelActivity(channel);
|
|
903
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
async _triggerSlackJobIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
907
|
+
const request = parseSlackJobRequest(event.text ?? '');
|
|
908
|
+
if (!request)
|
|
909
|
+
return false;
|
|
910
|
+
const addressedToBot = this._isMessageAddressedToBot(event);
|
|
911
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
912
|
+
const teamRequestLanguage = /\b(can someone|someone|anyone|please|need)\b/i.test(normalized);
|
|
913
|
+
const startsWithCommand = /^(run|review|qa)\b/i.test(normalized);
|
|
914
|
+
if (!addressedToBot
|
|
915
|
+
&& !request.prNumber
|
|
916
|
+
&& !request.fixConflicts
|
|
917
|
+
&& !teamRequestLanguage
|
|
918
|
+
&& !startsWithCommand) {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
const repos = getRepositories();
|
|
922
|
+
const projects = repos.projectRegistry.getAll();
|
|
923
|
+
const persona = (request.job === 'run' ? this._findPersonaByName(personas, 'Dev') : null)
|
|
924
|
+
?? (request.job === 'qa' ? this._findPersonaByName(personas, 'Priya') : null)
|
|
925
|
+
?? (request.job === 'review' ? this._findPersonaByName(personas, 'Carlos') : null)
|
|
926
|
+
?? this._pickRandomPersona(personas, channel, threadTs)
|
|
927
|
+
?? personas[0];
|
|
928
|
+
if (!persona)
|
|
929
|
+
return false;
|
|
930
|
+
const targetProject = this._resolveTargetProject(channel, projects, request.projectHint);
|
|
931
|
+
if (!targetProject) {
|
|
932
|
+
const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
|
|
933
|
+
await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
|
|
934
|
+
this._markChannelActivity(channel);
|
|
935
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
console.log(`[slack][job] routing job=${request.job} to persona=${persona.name} project=${targetProject.name}${request.prNumber ? ` pr=${request.prNumber}` : ''}${request.fixConflicts ? ' fix_conflicts=true' : ''}`);
|
|
939
|
+
const planLine = request.job === 'review'
|
|
940
|
+
? `On it${request.prNumber ? ` — PR #${request.prNumber}` : ''}${request.fixConflicts ? ', including the conflicts' : ''}.`
|
|
941
|
+
: request.job === 'qa'
|
|
942
|
+
? `Running QA${request.prNumber ? ` on #${request.prNumber}` : ''}.`
|
|
943
|
+
: `Starting the run${request.prNumber ? ` for #${request.prNumber}` : ''}.`;
|
|
944
|
+
await this._applyHumanResponseTiming(channel, messageTs, persona);
|
|
945
|
+
await this._slackClient.postAsAgent(channel, `${planLine}`, persona, threadTs);
|
|
946
|
+
console.log(`[slack][job] ${persona.name} accepted job=${request.job} project=${targetProject.name}${request.prNumber ? ` pr=${request.prNumber}` : ''}`);
|
|
947
|
+
this._markChannelActivity(channel);
|
|
948
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
949
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
950
|
+
await this._spawnNightWatchJob(request.job, targetProject, channel, threadTs, persona, { prNumber: request.prNumber, fixConflicts: request.fixConflicts });
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
_resolveProactiveChannelForProject(project) {
|
|
954
|
+
const slack = this._config.slack;
|
|
955
|
+
if (!slack)
|
|
956
|
+
return null;
|
|
957
|
+
return project.slackChannelId || slack.channels.eng || null;
|
|
958
|
+
}
|
|
959
|
+
async _runProactiveCodeWatch(projects, now) {
|
|
960
|
+
for (const project of projects) {
|
|
961
|
+
const channel = this._resolveProactiveChannelForProject(project);
|
|
962
|
+
if (!channel)
|
|
963
|
+
continue;
|
|
964
|
+
const lastScan = this._lastCodeWatchAt.get(project.path) ?? 0;
|
|
965
|
+
if (now - lastScan < PROACTIVE_CODEWATCH_MIN_INTERVAL_MS) {
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
this._lastCodeWatchAt.set(project.path, now);
|
|
969
|
+
const candidate = detectCodeWatchCandidate(project.path);
|
|
970
|
+
if (!candidate) {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const signatureKey = `${project.path}:${candidate.signature}`;
|
|
974
|
+
const lastSeen = this._lastCodeWatchSignatureAt.get(signatureKey) ?? 0;
|
|
975
|
+
if (now - lastSeen < PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const ref = `codewatch-${candidate.signature}`;
|
|
979
|
+
const context = `Project: ${project.name}\n` +
|
|
980
|
+
`Signal: ${candidate.summary}\n` +
|
|
981
|
+
`Location: ${candidate.relativePath}:${candidate.line}\n` +
|
|
982
|
+
`Snippet: ${candidate.snippet}\n` +
|
|
983
|
+
`Question: Is this intentional, or should we patch it now?`;
|
|
984
|
+
console.log(`[slack][codewatch] project=${project.name} location=${candidate.relativePath}:${candidate.line} signal=${candidate.type}`);
|
|
985
|
+
try {
|
|
986
|
+
await this._engine.startDiscussion({
|
|
987
|
+
type: 'code_watch',
|
|
988
|
+
projectPath: project.path,
|
|
989
|
+
ref,
|
|
990
|
+
context,
|
|
991
|
+
channelId: channel,
|
|
992
|
+
});
|
|
993
|
+
this._lastCodeWatchSignatureAt.set(signatureKey, now);
|
|
994
|
+
this._markChannelActivity(channel);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
catch (err) {
|
|
998
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
999
|
+
console.warn(`[slack][codewatch] failed for ${project.name}: ${msg}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
_startProactiveLoop() {
|
|
1004
|
+
if (this._proactiveTimer)
|
|
1005
|
+
return;
|
|
1006
|
+
this._proactiveTimer = setInterval(() => {
|
|
1007
|
+
void this._sendProactiveMessages();
|
|
1008
|
+
}, PROACTIVE_SWEEP_INTERVAL_MS);
|
|
1009
|
+
this._proactiveTimer.unref?.();
|
|
1010
|
+
}
|
|
1011
|
+
_stopProactiveLoop() {
|
|
1012
|
+
if (!this._proactiveTimer)
|
|
1013
|
+
return;
|
|
1014
|
+
clearInterval(this._proactiveTimer);
|
|
1015
|
+
this._proactiveTimer = null;
|
|
1016
|
+
}
|
|
1017
|
+
async _sendProactiveMessages() {
|
|
1018
|
+
const slack = this._config.slack;
|
|
1019
|
+
if (!slack?.enabled || !slack.discussionEnabled)
|
|
1020
|
+
return;
|
|
1021
|
+
const channelIds = Object.values(slack.channels ?? {}).filter(Boolean);
|
|
1022
|
+
if (channelIds.length === 0)
|
|
1023
|
+
return;
|
|
1024
|
+
const repos = getRepositories();
|
|
1025
|
+
const personas = repos.agentPersona.getActive();
|
|
1026
|
+
if (personas.length === 0)
|
|
1027
|
+
return;
|
|
1028
|
+
const now = Date.now();
|
|
1029
|
+
const projects = repos.projectRegistry.getAll();
|
|
1030
|
+
await this._runProactiveCodeWatch(projects, now);
|
|
1031
|
+
for (const channel of channelIds) {
|
|
1032
|
+
const lastActivity = this._lastChannelActivityAt.get(channel) ?? now;
|
|
1033
|
+
const lastProactive = this._lastProactiveAt.get(channel) ?? 0;
|
|
1034
|
+
if (now - lastActivity < PROACTIVE_IDLE_MS)
|
|
1035
|
+
continue;
|
|
1036
|
+
if (now - lastProactive < PROACTIVE_MIN_INTERVAL_MS)
|
|
1037
|
+
continue;
|
|
1038
|
+
const persona = this._pickRandomPersona(personas, channel, `${now}`) ?? personas[0];
|
|
1039
|
+
if (!persona)
|
|
1040
|
+
continue;
|
|
1041
|
+
const projectContext = this._buildProjectContext(channel, projects);
|
|
1042
|
+
const roadmapContext = this._buildRoadmapContext(channel, projects);
|
|
1043
|
+
try {
|
|
1044
|
+
await this._engine.postProactiveMessage(channel, persona, projectContext, roadmapContext);
|
|
1045
|
+
this._lastProactiveAt.set(channel, now);
|
|
1046
|
+
this._markChannelActivity(channel);
|
|
1047
|
+
console.log(`[slack] proactive message posted by ${persona.name} in ${channel}`);
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1051
|
+
console.warn(`Slack proactive message failed: ${msg}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
290
1055
|
async _handleInboundMessage(event) {
|
|
291
1056
|
if (shouldIgnoreInboundSlackEvent(event, this._botUserId)) {
|
|
292
1057
|
console.log(`[slack] ignoring event — failed shouldIgnore check (user=${event.user}, bot_id=${event.bot_id ?? '-'}, subtype=${event.subtype ?? '-'})`);
|
|
@@ -296,7 +1061,8 @@ export class SlackInteractionListener {
|
|
|
296
1061
|
const ts = event.ts;
|
|
297
1062
|
const threadTs = event.thread_ts ?? ts;
|
|
298
1063
|
const text = event.text ?? '';
|
|
299
|
-
const messageKey =
|
|
1064
|
+
const messageKey = buildInboundMessageKey(channel, ts, event.type);
|
|
1065
|
+
this._markChannelActivity(channel);
|
|
300
1066
|
// Deduplicate retried/replayed events to prevent response loops.
|
|
301
1067
|
if (!this._rememberMessageKey(messageKey)) {
|
|
302
1068
|
console.log(`[slack] duplicate event ${messageKey} — skipping`);
|
|
@@ -304,14 +1070,19 @@ export class SlackInteractionListener {
|
|
|
304
1070
|
}
|
|
305
1071
|
const repos = getRepositories();
|
|
306
1072
|
const personas = repos.agentPersona.getActive();
|
|
1073
|
+
const projects = repos.projectRegistry.getAll();
|
|
1074
|
+
const projectContext = this._buildProjectContext(channel, projects);
|
|
1075
|
+
if (await this._triggerSlackJobIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
307
1078
|
// @mention matching: "@maya ..."
|
|
308
1079
|
let mentionedPersonas = resolveMentionedPersonas(text, personas);
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
if (mentionedPersonas.length === 0
|
|
1080
|
+
// Also try plain-name matching (e.g. "Carlos, are you there?").
|
|
1081
|
+
// For app_mention text like "<@UBOTID> maya check this", the @-regex won't find "maya".
|
|
1082
|
+
if (mentionedPersonas.length === 0) {
|
|
312
1083
|
mentionedPersonas = resolvePersonasByPlainName(text, personas);
|
|
313
1084
|
if (mentionedPersonas.length > 0) {
|
|
314
|
-
console.log(`[slack] plain-name match
|
|
1085
|
+
console.log(`[slack] plain-name match: ${mentionedPersonas.map((p) => p.name).join(', ')}`);
|
|
315
1086
|
}
|
|
316
1087
|
}
|
|
317
1088
|
// Persona mentioned → respond regardless of whether a formal discussion exists.
|
|
@@ -326,14 +1097,18 @@ export class SlackInteractionListener {
|
|
|
326
1097
|
console.log(`[slack] ${persona.name} is on cooldown — skipping`);
|
|
327
1098
|
continue;
|
|
328
1099
|
}
|
|
1100
|
+
await this._applyHumanResponseTiming(channel, ts, persona);
|
|
329
1101
|
if (discussion) {
|
|
330
1102
|
await this._engine.contributeAsAgent(discussion.id, persona);
|
|
331
1103
|
}
|
|
332
1104
|
else {
|
|
333
|
-
await this._engine.replyAsAgent(channel, threadTs, text, persona);
|
|
1105
|
+
await this._engine.replyAsAgent(channel, threadTs, text, persona, projectContext);
|
|
334
1106
|
}
|
|
335
1107
|
this._markPersonaReply(channel, threadTs, persona.id);
|
|
336
1108
|
}
|
|
1109
|
+
if (!discussion && mentionedPersonas[0]) {
|
|
1110
|
+
this._rememberAdHocThreadPersona(channel, threadTs, mentionedPersonas[0].id);
|
|
1111
|
+
}
|
|
337
1112
|
return;
|
|
338
1113
|
}
|
|
339
1114
|
console.log(`[slack] no persona match — checking for active discussion in ${channel}:${threadTs}`);
|
|
@@ -342,11 +1117,53 @@ export class SlackInteractionListener {
|
|
|
342
1117
|
.slackDiscussion
|
|
343
1118
|
.getActive('')
|
|
344
1119
|
.find((d) => d.channelId === channel && d.threadTs === threadTs);
|
|
345
|
-
if (
|
|
346
|
-
|
|
1120
|
+
if (discussion) {
|
|
1121
|
+
await this._engine.handleHumanMessage(channel, threadTs, text, event.user);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
// Continue ad-hoc threads even without a persisted discussion.
|
|
1125
|
+
const rememberedPersona = this._getRememberedAdHocPersona(channel, threadTs, personas);
|
|
1126
|
+
if (rememberedPersona) {
|
|
1127
|
+
const followUpPersona = selectFollowUpPersona(rememberedPersona, personas, text);
|
|
1128
|
+
if (followUpPersona.id !== rememberedPersona.id) {
|
|
1129
|
+
console.log(`[slack] handing off ad-hoc thread from ${rememberedPersona.name} to ${followUpPersona.name} based on topic`);
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
console.log(`[slack] continuing ad-hoc thread with ${rememberedPersona.name}`);
|
|
1133
|
+
}
|
|
1134
|
+
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1135
|
+
await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, projectContext);
|
|
1136
|
+
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1137
|
+
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
347
1138
|
return;
|
|
348
1139
|
}
|
|
349
|
-
|
|
1140
|
+
// In-memory state was lost (e.g. server restart) — recover persona from thread history.
|
|
1141
|
+
if (threadTs) {
|
|
1142
|
+
const recoveredPersona = await this._recoverPersonaFromThreadHistory(channel, threadTs, personas);
|
|
1143
|
+
if (recoveredPersona) {
|
|
1144
|
+
const followUpPersona = selectFollowUpPersona(recoveredPersona, personas, text);
|
|
1145
|
+
console.log(`[slack] recovered ad-hoc thread persona ${recoveredPersona.name} from history, replying as ${followUpPersona.name}`);
|
|
1146
|
+
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1147
|
+
await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, projectContext);
|
|
1148
|
+
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1149
|
+
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
// Keep the channel alive: direct mentions and ambient greetings get a random responder.
|
|
1154
|
+
const shouldAutoEngage = event.type === 'app_mention' || isAmbientTeamMessage(text);
|
|
1155
|
+
if (shouldAutoEngage) {
|
|
1156
|
+
const randomPersona = this._pickRandomPersona(personas, channel, threadTs);
|
|
1157
|
+
if (randomPersona) {
|
|
1158
|
+
console.log(`[slack] auto-engaging via ${randomPersona.name}`);
|
|
1159
|
+
await this._applyHumanResponseTiming(channel, ts, randomPersona);
|
|
1160
|
+
await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, projectContext);
|
|
1161
|
+
this._markPersonaReply(channel, threadTs, randomPersona.id);
|
|
1162
|
+
this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
console.log(`[slack] no active discussion found — ignoring message`);
|
|
350
1167
|
}
|
|
351
1168
|
}
|
|
352
1169
|
//# sourceMappingURL=interaction-listener.js.map
|