@jonit-dev/night-watch-cli 1.7.27 → 1.7.30
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 +1 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/audit.d.ts +19 -0
- package/dist/src/commands/audit.d.ts.map +1 -0
- package/dist/src/commands/audit.js +109 -0
- package/dist/src/commands/audit.js.map +1 -0
- package/dist/src/commands/dashboard.js +1 -1
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +6 -6
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/install.d.ts +4 -0
- package/dist/src/commands/install.d.ts.map +1 -1
- package/dist/src/commands/install.js +25 -20
- package/dist/src/commands/install.js.map +1 -1
- package/dist/src/commands/logs.js +3 -3
- package/dist/src/commands/logs.js.map +1 -1
- package/dist/src/commands/prs.js +2 -2
- package/dist/src/commands/prs.js.map +1 -1
- package/dist/src/commands/review.d.ts.map +1 -1
- package/dist/src/commands/review.js +13 -5
- package/dist/src/commands/review.js.map +1 -1
- package/dist/src/commands/uninstall.d.ts.map +1 -1
- package/dist/src/commands/uninstall.js +3 -22
- package/dist/src/commands/uninstall.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +30 -1
- package/dist/src/config.js.map +1 -1
- package/dist/src/constants.d.ts +10 -3
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +15 -2
- package/dist/src/constants.js.map +1 -1
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +50 -3
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/slack/client.d.ts +3 -2
- package/dist/src/slack/client.d.ts.map +1 -1
- package/dist/src/slack/client.js +5 -6
- package/dist/src/slack/client.js.map +1 -1
- package/dist/src/slack/deliberation.d.ts +13 -1
- package/dist/src/slack/deliberation.d.ts.map +1 -1
- package/dist/src/slack/deliberation.js +585 -71
- package/dist/src/slack/deliberation.js.map +1 -1
- package/dist/src/slack/interaction-listener.d.ts +27 -9
- package/dist/src/slack/interaction-listener.d.ts.map +1 -1
- package/dist/src/slack/interaction-listener.js +418 -201
- package/dist/src/slack/interaction-listener.js.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +3 -2
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +14 -11
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
- package/dist/src/types.d.ts +13 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/notify.d.ts.map +1 -1
- package/dist/src/utils/notify.js +5 -1
- package/dist/src/utils/notify.js.map +1 -1
- package/dist/src/utils/status-data.d.ts +2 -2
- package/dist/src/utils/status-data.d.ts.map +1 -1
- package/dist/src/utils/status-data.js +78 -123
- package/dist/src/utils/status-data.js.map +1 -1
- package/package.json +3 -1
- package/scripts/night-watch-audit-cron.sh +165 -0
- package/scripts/night-watch-cron.sh +33 -14
- package/scripts/night-watch-helpers.sh +10 -2
- package/scripts/night-watch-pr-reviewer-cron.sh +224 -18
- package/templates/night-watch-audit.md +87 -0
- package/web/dist/assets/index-BiJf9LFT.js +458 -0
- package/web/dist/assets/index-OpSgvsYu.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CndIPm_F.js +0 -473
- package/web/dist/assets/index-w6Q6gxCS.css +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and applies loop-protection safeguards.
|
|
5
5
|
*/
|
|
6
6
|
import { SocketModeClient } from '@slack/socket-mode';
|
|
7
|
-
import { spawn } from 'child_process';
|
|
7
|
+
import { execFileSync, spawn } from 'child_process';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { getDb } from '../storage/sqlite/client.js';
|
|
@@ -21,49 +21,14 @@ const PROACTIVE_IDLE_MS = 20 * 60_000; // 20 min
|
|
|
21
21
|
const PROACTIVE_MIN_INTERVAL_MS = 90 * 60_000; // per channel
|
|
22
22
|
const PROACTIVE_SWEEP_INTERVAL_MS = 60_000;
|
|
23
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
24
|
const MAX_JOB_OUTPUT_CHARS = 12_000;
|
|
26
25
|
const HUMAN_REACTION_PROBABILITY = 0.65;
|
|
26
|
+
const RANDOM_REACTION_PROBABILITY = 0.25;
|
|
27
27
|
const REACTION_DELAY_MIN_MS = 180;
|
|
28
28
|
const REACTION_DELAY_MAX_MS = 1200;
|
|
29
29
|
const RESPONSE_DELAY_MIN_MS = 700;
|
|
30
30
|
const RESPONSE_DELAY_MAX_MS = 3400;
|
|
31
|
-
const
|
|
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
|
-
]);
|
|
31
|
+
const SOCKET_DISCONNECT_TIMEOUT_MS = 5_000;
|
|
67
32
|
const JOB_STOPWORDS = new Set([
|
|
68
33
|
'and',
|
|
69
34
|
'or',
|
|
@@ -128,126 +93,6 @@ function normalizeForParsing(text) {
|
|
|
128
93
|
.replace(/\s+/g, ' ')
|
|
129
94
|
.trim();
|
|
130
95
|
}
|
|
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
96
|
export function isAmbientTeamMessage(text) {
|
|
252
97
|
const normalized = normalizeForParsing(stripSlackUserMentions(text));
|
|
253
98
|
if (!normalized)
|
|
@@ -294,6 +139,78 @@ export function parseSlackJobRequest(text) {
|
|
|
294
139
|
request.fixConflicts = true;
|
|
295
140
|
return request;
|
|
296
141
|
}
|
|
142
|
+
export function parseSlackIssuePickupRequest(text) {
|
|
143
|
+
const withoutMentions = stripSlackUserMentions(text);
|
|
144
|
+
const normalized = normalizeForParsing(withoutMentions);
|
|
145
|
+
if (!normalized)
|
|
146
|
+
return null;
|
|
147
|
+
// Extract GitHub issue URL — NOT pull requests (those handled by parseSlackJobRequest)
|
|
148
|
+
const compactForUrl = withoutMentions.replace(/\s+/g, '');
|
|
149
|
+
let issueUrl;
|
|
150
|
+
let issueNumber;
|
|
151
|
+
let repo;
|
|
152
|
+
// Standard format: github.com/{owner}/{repo}/issues/{number}
|
|
153
|
+
const directIssueMatch = compactForUrl.match(/https?:\/\/github\.com\/([^/\s<>]+)\/([^/\s<>]+)\/issues\/(\d+)/i);
|
|
154
|
+
if (directIssueMatch) {
|
|
155
|
+
[issueUrl, , repo, issueNumber] = directIssueMatch;
|
|
156
|
+
repo = repo.toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Project board format: github.com/...?...&issue={owner}%7C{repo}%7C{number}
|
|
160
|
+
// e.g. github.com/users/jonit-dev/projects/41/views/2?pane=issue&issue=jonit-dev%7Cnight-watch-cli%7C12
|
|
161
|
+
const boardMatch = compactForUrl.match(/https?:\/\/github\.com\/[^<>\s]*[?&]issue=([^<>\s&]+)/i);
|
|
162
|
+
if (!boardMatch)
|
|
163
|
+
return null;
|
|
164
|
+
const rawParam = boardMatch[1].replace(/%7[Cc]/g, '|');
|
|
165
|
+
const parts = rawParam.split('|');
|
|
166
|
+
if (parts.length < 3 || !/^\d+$/.test(parts[parts.length - 1]))
|
|
167
|
+
return null;
|
|
168
|
+
issueNumber = parts[parts.length - 1];
|
|
169
|
+
repo = parts[parts.length - 2].toLowerCase();
|
|
170
|
+
issueUrl = boardMatch[0];
|
|
171
|
+
}
|
|
172
|
+
// Requires pickup-intent language or "this issue" + request language
|
|
173
|
+
// "pickup" (one word) is also accepted alongside "pick up" (two words)
|
|
174
|
+
const pickupSignal = /\b(pick\s+up|pickup|work\s+on|implement|tackle|start\s+on|grab|handle\s+this|ship\s+this)\b/i.test(normalized);
|
|
175
|
+
const requestSignal = /\b(please|can\s+someone|anyone)\b/i.test(normalized) && /\bthis\s+issue\b/i.test(normalized);
|
|
176
|
+
if (!pickupSignal && !requestSignal)
|
|
177
|
+
return null;
|
|
178
|
+
return {
|
|
179
|
+
issueNumber,
|
|
180
|
+
issueUrl,
|
|
181
|
+
repoHint: repo,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function parseSlackProviderRequest(text) {
|
|
185
|
+
const withoutMentions = stripSlackUserMentions(text);
|
|
186
|
+
if (!withoutMentions.trim())
|
|
187
|
+
return null;
|
|
188
|
+
// Explicit direct-provider invocation from Slack, e.g.:
|
|
189
|
+
// "claude fix the flaky tests", "run codex on repo-x: investigate CI failures"
|
|
190
|
+
const prefixMatch = withoutMentions.match(/^\s*(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(claude|codex)\b[\s:,-]*/i);
|
|
191
|
+
if (!prefixMatch)
|
|
192
|
+
return null;
|
|
193
|
+
const provider = prefixMatch[1].toLowerCase();
|
|
194
|
+
let remainder = withoutMentions.slice(prefixMatch[0].length).trim();
|
|
195
|
+
if (!remainder)
|
|
196
|
+
return null;
|
|
197
|
+
let projectHint;
|
|
198
|
+
const projectMatch = remainder.match(/^(?:for|on)\s+([a-z0-9./_-]+)\b[\s:,-]*/i);
|
|
199
|
+
if (projectMatch) {
|
|
200
|
+
const candidate = projectMatch[1].toLowerCase();
|
|
201
|
+
if (!JOB_STOPWORDS.has(candidate)) {
|
|
202
|
+
projectHint = candidate;
|
|
203
|
+
}
|
|
204
|
+
remainder = remainder.slice(projectMatch[0].length).trim();
|
|
205
|
+
}
|
|
206
|
+
if (!remainder)
|
|
207
|
+
return null;
|
|
208
|
+
return {
|
|
209
|
+
provider,
|
|
210
|
+
prompt: remainder,
|
|
211
|
+
...(projectHint ? { projectHint } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
297
214
|
function getPersonaDomain(persona) {
|
|
298
215
|
const role = persona.role.toLowerCase();
|
|
299
216
|
const expertise = (persona.soul?.expertise ?? []).join(' ').toLowerCase();
|
|
@@ -442,6 +359,46 @@ export function shouldIgnoreInboundSlackEvent(event, botUserId) {
|
|
|
442
359
|
return true;
|
|
443
360
|
return false;
|
|
444
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Extract GitHub issue or PR URLs from a message string.
|
|
364
|
+
*/
|
|
365
|
+
export function extractGitHubIssueUrls(text) {
|
|
366
|
+
const matches = text.match(/https?:\/\/github\.com\/[^\s<>]+/g) ?? [];
|
|
367
|
+
return matches.filter((u) => /\/(issues|pull)\/\d+/.test(u));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Fetch GitHub issue/PR content via `gh api` for agent context.
|
|
371
|
+
* Returns a formatted string, or '' on failure.
|
|
372
|
+
*/
|
|
373
|
+
async function fetchGitHubIssueContext(urls) {
|
|
374
|
+
if (urls.length === 0)
|
|
375
|
+
return '';
|
|
376
|
+
const parts = [];
|
|
377
|
+
for (const url of urls.slice(0, 3)) {
|
|
378
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/);
|
|
379
|
+
if (!match)
|
|
380
|
+
continue;
|
|
381
|
+
const [, owner, repo, type, number] = match;
|
|
382
|
+
const endpoint = type === 'pull'
|
|
383
|
+
? `/repos/${owner}/${repo}/pulls/${number}`
|
|
384
|
+
: `/repos/${owner}/${repo}/issues/${number}`;
|
|
385
|
+
try {
|
|
386
|
+
const raw = execFileSync('gh', ['api', endpoint, '--jq', '{title: .title, state: .state, body: .body, labels: [.labels[].name]}'], {
|
|
387
|
+
timeout: 10_000,
|
|
388
|
+
encoding: 'utf-8',
|
|
389
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
390
|
+
});
|
|
391
|
+
const data = JSON.parse(raw);
|
|
392
|
+
const labelStr = data.labels.length > 0 ? ` [${data.labels.join(', ')}]` : '';
|
|
393
|
+
const body = (data.body ?? '').trim().slice(0, 1200);
|
|
394
|
+
parts.push(`GitHub ${type === 'pull' ? 'PR' : 'Issue'} #${number}${labelStr}: ${data.title} (${data.state})\n${body}`);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// gh not available or not authenticated — skip
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return parts.join('\n\n---\n\n');
|
|
401
|
+
}
|
|
445
402
|
export class SlackInteractionListener {
|
|
446
403
|
_config;
|
|
447
404
|
_slackClient;
|
|
@@ -455,7 +412,6 @@ export class SlackInteractionListener {
|
|
|
455
412
|
_lastChannelActivityAt = new Map();
|
|
456
413
|
_lastProactiveAt = new Map();
|
|
457
414
|
_lastCodeWatchAt = new Map();
|
|
458
|
-
_lastCodeWatchSignatureAt = new Map();
|
|
459
415
|
_proactiveTimer = null;
|
|
460
416
|
constructor(config) {
|
|
461
417
|
this._config = config;
|
|
@@ -513,14 +469,21 @@ export class SlackInteractionListener {
|
|
|
513
469
|
const socket = this._socketClient;
|
|
514
470
|
this._socketClient = null;
|
|
515
471
|
try {
|
|
516
|
-
|
|
517
|
-
|
|
472
|
+
await Promise.race([
|
|
473
|
+
socket.disconnect(),
|
|
474
|
+
sleep(SOCKET_DISCONNECT_TIMEOUT_MS).then(() => {
|
|
475
|
+
throw new Error(`timed out after ${SOCKET_DISCONNECT_TIMEOUT_MS}ms`);
|
|
476
|
+
}),
|
|
477
|
+
]);
|
|
518
478
|
console.log('Slack interaction listener stopped');
|
|
519
479
|
}
|
|
520
480
|
catch (err) {
|
|
521
481
|
const msg = err instanceof Error ? err.message : String(err);
|
|
522
482
|
console.warn(`Slack interaction listener shutdown failed: ${msg}`);
|
|
523
483
|
}
|
|
484
|
+
finally {
|
|
485
|
+
socket.removeAllListeners();
|
|
486
|
+
}
|
|
524
487
|
}
|
|
525
488
|
/**
|
|
526
489
|
* Join all configured channels, generate avatars for personas that need them,
|
|
@@ -672,6 +635,27 @@ export class SlackInteractionListener {
|
|
|
672
635
|
expiresAt: Date.now() + AD_HOC_THREAD_MEMORY_MS,
|
|
673
636
|
});
|
|
674
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* After an agent posts a reply, check if the text mentions other personas by plain name.
|
|
640
|
+
* If so, trigger those personas to respond once (no further cascading — depth 1 only).
|
|
641
|
+
* This enables natural agent-to-agent handoffs like "Carlos, what's the priority here?"
|
|
642
|
+
*/
|
|
643
|
+
async _followAgentMentions(postedText, channel, threadTs, personas, projectContext, skipPersonaId) {
|
|
644
|
+
if (!postedText)
|
|
645
|
+
return;
|
|
646
|
+
const mentioned = resolvePersonasByPlainName(postedText, personas).filter((p) => p.id !== skipPersonaId && !this._isPersonaOnCooldown(channel, threadTs, p.id));
|
|
647
|
+
if (mentioned.length === 0)
|
|
648
|
+
return;
|
|
649
|
+
console.log(`[slack] agent mention follow-up: ${mentioned.map((p) => p.name).join(', ')}`);
|
|
650
|
+
for (const persona of mentioned) {
|
|
651
|
+
// Small human-like delay before the tagged persona responds
|
|
652
|
+
await sleep(this._randomInt(RESPONSE_DELAY_MIN_MS * 2, RESPONSE_DELAY_MAX_MS * 3));
|
|
653
|
+
// replyAsAgent fetches thread history internally so Carlos sees Dev's message
|
|
654
|
+
await this._engine.replyAsAgent(channel, threadTs, postedText, persona, projectContext);
|
|
655
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
656
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
675
659
|
/**
|
|
676
660
|
* Recover the persona that last replied in a thread by scanning its history.
|
|
677
661
|
* Used as a fallback when in-memory state was lost (e.g. after a server restart).
|
|
@@ -844,6 +828,7 @@ export class SlackInteractionListener {
|
|
|
844
828
|
...process.env,
|
|
845
829
|
NW_EXECUTION_CONTEXT: 'agent',
|
|
846
830
|
...(opts?.prNumber ? { NW_TARGET_PR: opts.prNumber } : {}),
|
|
831
|
+
...(opts?.issueNumber ? { NW_TARGET_ISSUE: opts.issueNumber } : {}),
|
|
847
832
|
...(opts?.fixConflicts
|
|
848
833
|
? {
|
|
849
834
|
NW_SLACK_FEEDBACK: JSON.stringify({
|
|
@@ -903,6 +888,96 @@ export class SlackInteractionListener {
|
|
|
903
888
|
this._markPersonaReply(channel, threadTs, persona.id);
|
|
904
889
|
});
|
|
905
890
|
}
|
|
891
|
+
async _spawnDirectProviderRequest(request, project, channel, threadTs, persona) {
|
|
892
|
+
const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
|
|
893
|
+
const args = request.provider === 'claude'
|
|
894
|
+
? ['-p', request.prompt, '--dangerously-skip-permissions']
|
|
895
|
+
: ['--quiet', '--yolo', '--prompt', request.prompt];
|
|
896
|
+
console.log(`[slack][provider] persona=${persona.name} provider=${request.provider} project=${project.name} spawn=${formatCommandForLog(request.provider, args)}`);
|
|
897
|
+
const child = spawn(request.provider, args, {
|
|
898
|
+
cwd: project.path,
|
|
899
|
+
env: {
|
|
900
|
+
...process.env,
|
|
901
|
+
...(this._config.providerEnv ?? {}),
|
|
902
|
+
NW_EXECUTION_CONTEXT: 'agent',
|
|
903
|
+
},
|
|
904
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
905
|
+
});
|
|
906
|
+
console.log(`[slack][provider] ${persona.name} spawned ${request.provider} for ${project.name} pid=${child.pid ?? 'unknown'}`);
|
|
907
|
+
let output = '';
|
|
908
|
+
let errored = false;
|
|
909
|
+
const appendOutput = (chunk) => {
|
|
910
|
+
output += chunk.toString();
|
|
911
|
+
if (output.length > MAX_JOB_OUTPUT_CHARS) {
|
|
912
|
+
output = output.slice(-MAX_JOB_OUTPUT_CHARS);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
child.stdout?.on('data', appendOutput);
|
|
916
|
+
child.stderr?.on('data', appendOutput);
|
|
917
|
+
child.on('error', async (err) => {
|
|
918
|
+
errored = true;
|
|
919
|
+
console.warn(`[slack][provider] ${persona.name} ${request.provider} spawn error for ${project.name}: ${err.message}`);
|
|
920
|
+
await this._slackClient.postAsAgent(channel, `Couldn't start ${providerLabel}. Error logged — looking into it.`, persona, threadTs);
|
|
921
|
+
this._markChannelActivity(channel);
|
|
922
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
923
|
+
});
|
|
924
|
+
child.on('close', async (code) => {
|
|
925
|
+
if (errored)
|
|
926
|
+
return;
|
|
927
|
+
console.log(`[slack][provider] ${persona.name} ${request.provider} finished for ${project.name} exit=${code ?? 'unknown'}`);
|
|
928
|
+
const detail = extractLastMeaningfulLines(output);
|
|
929
|
+
if (code === 0) {
|
|
930
|
+
await this._slackClient.postAsAgent(channel, `${providerLabel} command finished.`, persona, threadTs);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
if (detail) {
|
|
934
|
+
console.warn(`[slack][provider] ${persona.name} ${request.provider} failure detail: ${detail}`);
|
|
935
|
+
}
|
|
936
|
+
await this._slackClient.postAsAgent(channel, `${providerLabel} hit a snag. Logged the details — looking into it.`, persona, threadTs);
|
|
937
|
+
}
|
|
938
|
+
this._markChannelActivity(channel);
|
|
939
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
async _triggerDirectProviderIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
943
|
+
const request = parseSlackProviderRequest(event.text ?? '');
|
|
944
|
+
if (!request)
|
|
945
|
+
return false;
|
|
946
|
+
const addressedToBot = this._isMessageAddressedToBot(event);
|
|
947
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
948
|
+
const startsWithProviderCommand = /^(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(?:claude|codex)\b/i.test(normalized);
|
|
949
|
+
if (!addressedToBot && !startsWithProviderCommand) {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
const repos = getRepositories();
|
|
953
|
+
const projects = repos.projectRegistry.getAll();
|
|
954
|
+
const persona = this._findPersonaByName(personas, 'Dev')
|
|
955
|
+
?? this._pickRandomPersona(personas, channel, threadTs)
|
|
956
|
+
?? personas[0];
|
|
957
|
+
if (!persona)
|
|
958
|
+
return false;
|
|
959
|
+
const targetProject = this._resolveTargetProject(channel, projects, request.projectHint);
|
|
960
|
+
if (!targetProject) {
|
|
961
|
+
const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
|
|
962
|
+
await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
|
|
963
|
+
this._markChannelActivity(channel);
|
|
964
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
console.log(`[slack][provider] routing provider=${request.provider} to persona=${persona.name} project=${targetProject.name}`);
|
|
968
|
+
const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
|
|
969
|
+
const compactPrompt = request.prompt.replace(/\s+/g, ' ').trim();
|
|
970
|
+
const promptPreview = compactPrompt.length > 120
|
|
971
|
+
? `${compactPrompt.slice(0, 117)}...`
|
|
972
|
+
: compactPrompt;
|
|
973
|
+
await this._applyHumanResponseTiming(channel, messageTs, persona);
|
|
974
|
+
await this._slackClient.postAsAgent(channel, `Running ${providerLabel} directly${request.projectHint ? ` on ${targetProject.name}` : ''}: "${promptPreview}"`, persona, threadTs);
|
|
975
|
+
this._markChannelActivity(channel);
|
|
976
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
977
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
978
|
+
await this._spawnDirectProviderRequest(request, targetProject, channel, threadTs, persona);
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
906
981
|
async _triggerSlackJobIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
907
982
|
const request = parseSlackJobRequest(event.text ?? '');
|
|
908
983
|
if (!request)
|
|
@@ -950,12 +1025,145 @@ export class SlackInteractionListener {
|
|
|
950
1025
|
await this._spawnNightWatchJob(request.job, targetProject, channel, threadTs, persona, { prNumber: request.prNumber, fixConflicts: request.fixConflicts });
|
|
951
1026
|
return true;
|
|
952
1027
|
}
|
|
1028
|
+
async _triggerIssuePickupIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
1029
|
+
const request = parseSlackIssuePickupRequest(event.text ?? '');
|
|
1030
|
+
if (!request)
|
|
1031
|
+
return false;
|
|
1032
|
+
const addressedToBot = this._isMessageAddressedToBot(event);
|
|
1033
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
1034
|
+
const teamRequestLanguage = /\b(can someone|someone|anyone|please|need)\b/i.test(normalized);
|
|
1035
|
+
if (!addressedToBot && !teamRequestLanguage)
|
|
1036
|
+
return false;
|
|
1037
|
+
const repos = getRepositories();
|
|
1038
|
+
const projects = repos.projectRegistry.getAll();
|
|
1039
|
+
const persona = this._findPersonaByName(personas, 'Dev')
|
|
1040
|
+
?? this._pickRandomPersona(personas, channel, threadTs)
|
|
1041
|
+
?? personas[0];
|
|
1042
|
+
if (!persona)
|
|
1043
|
+
return false;
|
|
1044
|
+
const targetProject = this._resolveTargetProject(channel, projects, request.repoHint);
|
|
1045
|
+
if (!targetProject) {
|
|
1046
|
+
const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
|
|
1047
|
+
await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
|
|
1048
|
+
this._markChannelActivity(channel);
|
|
1049
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
console.log(`[slack][issue-pickup] routing issue=#${request.issueNumber} to persona=${persona.name} project=${targetProject.name}`);
|
|
1053
|
+
await this._applyHumanResponseTiming(channel, messageTs, persona);
|
|
1054
|
+
await this._slackClient.postAsAgent(channel, `On it — picking up #${request.issueNumber}. Starting the run now.`, persona, threadTs);
|
|
1055
|
+
this._markChannelActivity(channel);
|
|
1056
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1057
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
1058
|
+
// Move issue to In Progress on board (best-effort, spawn via CLI subprocess)
|
|
1059
|
+
const boardArgs = buildCurrentCliInvocation([
|
|
1060
|
+
'board', 'move-issue', request.issueNumber, '--column', 'In Progress',
|
|
1061
|
+
]);
|
|
1062
|
+
if (boardArgs) {
|
|
1063
|
+
try {
|
|
1064
|
+
execFileSync(process.execPath, boardArgs, {
|
|
1065
|
+
cwd: targetProject.path,
|
|
1066
|
+
timeout: 15_000,
|
|
1067
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1068
|
+
});
|
|
1069
|
+
console.log(`[slack][issue-pickup] moved #${request.issueNumber} to In Progress`);
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
console.warn(`[slack][issue-pickup] failed to move #${request.issueNumber} to In Progress`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
console.log(`[slack][issue-pickup] spawning run for #${request.issueNumber}`);
|
|
1076
|
+
await this._spawnNightWatchJob('run', targetProject, channel, threadTs, persona, {
|
|
1077
|
+
issueNumber: request.issueNumber,
|
|
1078
|
+
});
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
953
1081
|
_resolveProactiveChannelForProject(project) {
|
|
954
1082
|
const slack = this._config.slack;
|
|
955
1083
|
if (!slack)
|
|
956
1084
|
return null;
|
|
957
1085
|
return project.slackChannelId || slack.channels.eng || null;
|
|
958
1086
|
}
|
|
1087
|
+
_spawnCodeWatchAudit(project, channel) {
|
|
1088
|
+
if (!fs.existsSync(project.path)) {
|
|
1089
|
+
console.warn(`[slack][codewatch] audit skipped for ${project.name}: missing project path ${project.path}`);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const invocationArgs = buildCurrentCliInvocation(['audit']);
|
|
1093
|
+
if (!invocationArgs) {
|
|
1094
|
+
console.warn(`[slack][codewatch] audit spawn failed for ${project.name}: CLI entry path unavailable`);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
console.log(`[slack][codewatch] spawning audit for ${project.name} → ${channel} cmd=${formatCommandForLog(process.execPath, invocationArgs)}`);
|
|
1098
|
+
const startedAt = Date.now();
|
|
1099
|
+
const child = spawn(process.execPath, invocationArgs, {
|
|
1100
|
+
cwd: project.path,
|
|
1101
|
+
env: { ...process.env, NW_EXECUTION_CONTEXT: 'agent' },
|
|
1102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1103
|
+
});
|
|
1104
|
+
console.log(`[slack][codewatch] audit spawned for ${project.name} pid=${child.pid ?? 'unknown'}`);
|
|
1105
|
+
let output = '';
|
|
1106
|
+
const appendOutput = (chunk) => {
|
|
1107
|
+
output += chunk.toString();
|
|
1108
|
+
if (output.length > MAX_JOB_OUTPUT_CHARS) {
|
|
1109
|
+
output = output.slice(-MAX_JOB_OUTPUT_CHARS);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
child.stdout?.on('data', appendOutput);
|
|
1113
|
+
child.stderr?.on('data', appendOutput);
|
|
1114
|
+
let spawnErrored = false;
|
|
1115
|
+
child.on('error', (err) => {
|
|
1116
|
+
spawnErrored = true;
|
|
1117
|
+
console.warn(`[slack][codewatch] audit spawn error for ${project.name}: ${err.message}`);
|
|
1118
|
+
});
|
|
1119
|
+
child.on('close', async (code) => {
|
|
1120
|
+
console.log(`[slack][codewatch] audit finished for ${project.name} exit=${code ?? 'unknown'}`);
|
|
1121
|
+
if (spawnErrored) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (code !== 0) {
|
|
1125
|
+
const detail = extractLastMeaningfulLines(output);
|
|
1126
|
+
if (detail) {
|
|
1127
|
+
console.warn(`[slack][codewatch] audit failure detail for ${project.name}: ${detail}`);
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const reportPath = path.join(project.path, 'logs', 'audit-report.md');
|
|
1132
|
+
let reportStat;
|
|
1133
|
+
let report;
|
|
1134
|
+
try {
|
|
1135
|
+
reportStat = fs.statSync(reportPath);
|
|
1136
|
+
report = fs.readFileSync(reportPath, 'utf-8').trim();
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
const parsed = parseScriptResult(output);
|
|
1140
|
+
if (parsed?.status?.startsWith('skip_')) {
|
|
1141
|
+
console.log(`[slack][codewatch] audit skipped for ${project.name} (${parsed.status})`);
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
console.log(`[slack][codewatch] no audit report found at ${reportPath}`);
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
// Ignore old reports when an audit exits early without producing a fresh output.
|
|
1149
|
+
if (reportStat.mtimeMs + 1000 < startedAt) {
|
|
1150
|
+
console.log(`[slack][codewatch] stale audit report ignored at ${reportPath}`);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (!report) {
|
|
1154
|
+
console.log(`[slack][codewatch] empty audit report ignored at ${reportPath}`);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
try {
|
|
1158
|
+
await this._engine.handleAuditReport(report, project.name, project.path, channel);
|
|
1159
|
+
this._markChannelActivity(channel);
|
|
1160
|
+
}
|
|
1161
|
+
catch (err) {
|
|
1162
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1163
|
+
console.warn(`[slack][codewatch] handleAuditReport failed for ${project.name}: ${msg}`);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
959
1167
|
async _runProactiveCodeWatch(projects, now) {
|
|
960
1168
|
for (const project of projects) {
|
|
961
1169
|
const channel = this._resolveProactiveChannelForProject(project);
|
|
@@ -966,38 +1174,7 @@ export class SlackInteractionListener {
|
|
|
966
1174
|
continue;
|
|
967
1175
|
}
|
|
968
1176
|
this._lastCodeWatchAt.set(project.path, now);
|
|
969
|
-
|
|
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
|
-
}
|
|
1177
|
+
this._spawnCodeWatchAudit(project, channel);
|
|
1001
1178
|
}
|
|
1002
1179
|
}
|
|
1003
1180
|
_startProactiveLoop() {
|
|
@@ -1072,9 +1249,20 @@ export class SlackInteractionListener {
|
|
|
1072
1249
|
const personas = repos.agentPersona.getActive();
|
|
1073
1250
|
const projects = repos.projectRegistry.getAll();
|
|
1074
1251
|
const projectContext = this._buildProjectContext(channel, projects);
|
|
1252
|
+
// Fetch GitHub issue/PR content from URLs in the message so agents can inspect them.
|
|
1253
|
+
const githubUrls = extractGitHubIssueUrls(text);
|
|
1254
|
+
console.log(`[slack] processing message channel=${channel} thread=${threadTs} urls=${githubUrls.length}`);
|
|
1255
|
+
const githubContext = githubUrls.length > 0 ? await fetchGitHubIssueContext(githubUrls) : '';
|
|
1256
|
+
const fullContext = githubContext ? `${projectContext}\n\nReferenced GitHub content:\n${githubContext}` : projectContext;
|
|
1257
|
+
if (await this._triggerDirectProviderIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1075
1260
|
if (await this._triggerSlackJobIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1076
1261
|
return;
|
|
1077
1262
|
}
|
|
1263
|
+
if (await this._triggerIssuePickupIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1078
1266
|
// @mention matching: "@maya ..."
|
|
1079
1267
|
let mentionedPersonas = resolveMentionedPersonas(text, personas);
|
|
1080
1268
|
// Also try plain-name matching (e.g. "Carlos, are you there?").
|
|
@@ -1092,6 +1280,8 @@ export class SlackInteractionListener {
|
|
|
1092
1280
|
.slackDiscussion
|
|
1093
1281
|
.getActive('')
|
|
1094
1282
|
.find((d) => d.channelId === channel && d.threadTs === threadTs);
|
|
1283
|
+
let lastPosted = '';
|
|
1284
|
+
let lastPersonaId = '';
|
|
1095
1285
|
for (const persona of mentionedPersonas) {
|
|
1096
1286
|
if (this._isPersonaOnCooldown(channel, threadTs, persona.id)) {
|
|
1097
1287
|
console.log(`[slack] ${persona.name} is on cooldown — skipping`);
|
|
@@ -1102,13 +1292,19 @@ export class SlackInteractionListener {
|
|
|
1102
1292
|
await this._engine.contributeAsAgent(discussion.id, persona);
|
|
1103
1293
|
}
|
|
1104
1294
|
else {
|
|
1105
|
-
|
|
1295
|
+
console.log(`[slack] replying as ${persona.name} in ${channel}`);
|
|
1296
|
+
lastPosted = await this._engine.replyAsAgent(channel, threadTs, text, persona, fullContext);
|
|
1297
|
+
lastPersonaId = persona.id;
|
|
1106
1298
|
}
|
|
1107
1299
|
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1108
1300
|
}
|
|
1109
1301
|
if (!discussion && mentionedPersonas[0]) {
|
|
1110
1302
|
this._rememberAdHocThreadPersona(channel, threadTs, mentionedPersonas[0].id);
|
|
1111
1303
|
}
|
|
1304
|
+
// Follow up if the last agent reply mentions other teammates by name.
|
|
1305
|
+
if (lastPosted && lastPersonaId) {
|
|
1306
|
+
await this._followAgentMentions(lastPosted, channel, threadTs, personas, fullContext, lastPersonaId);
|
|
1307
|
+
}
|
|
1112
1308
|
return;
|
|
1113
1309
|
}
|
|
1114
1310
|
console.log(`[slack] no persona match — checking for active discussion in ${channel}:${threadTs}`);
|
|
@@ -1132,9 +1328,11 @@ export class SlackInteractionListener {
|
|
|
1132
1328
|
console.log(`[slack] continuing ad-hoc thread with ${rememberedPersona.name}`);
|
|
1133
1329
|
}
|
|
1134
1330
|
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1135
|
-
|
|
1331
|
+
console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
|
|
1332
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
|
|
1136
1333
|
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1137
1334
|
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
1335
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
|
|
1138
1336
|
return;
|
|
1139
1337
|
}
|
|
1140
1338
|
// In-memory state was lost (e.g. server restart) — recover persona from thread history.
|
|
@@ -1144,25 +1342,44 @@ export class SlackInteractionListener {
|
|
|
1144
1342
|
const followUpPersona = selectFollowUpPersona(recoveredPersona, personas, text);
|
|
1145
1343
|
console.log(`[slack] recovered ad-hoc thread persona ${recoveredPersona.name} from history, replying as ${followUpPersona.name}`);
|
|
1146
1344
|
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1147
|
-
|
|
1345
|
+
console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
|
|
1346
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
|
|
1148
1347
|
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1149
1348
|
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
1349
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
|
|
1150
1350
|
return;
|
|
1151
1351
|
}
|
|
1152
1352
|
}
|
|
1153
|
-
//
|
|
1154
|
-
|
|
1155
|
-
if (shouldAutoEngage) {
|
|
1353
|
+
// Direct bot mentions always get a reply.
|
|
1354
|
+
if (event.type === 'app_mention') {
|
|
1156
1355
|
const randomPersona = this._pickRandomPersona(personas, channel, threadTs);
|
|
1157
1356
|
if (randomPersona) {
|
|
1158
|
-
console.log(`[slack] auto-engaging via ${randomPersona.name}`);
|
|
1357
|
+
console.log(`[slack] app_mention auto-engaging via ${randomPersona.name}`);
|
|
1159
1358
|
await this._applyHumanResponseTiming(channel, ts, randomPersona);
|
|
1160
|
-
await this._engine.replyAsAgent(channel, threadTs, text, randomPersona,
|
|
1359
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, fullContext);
|
|
1161
1360
|
this._markPersonaReply(channel, threadTs, randomPersona.id);
|
|
1162
1361
|
this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
|
|
1362
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, randomPersona.id);
|
|
1163
1363
|
return;
|
|
1164
1364
|
}
|
|
1165
1365
|
}
|
|
1366
|
+
// Any human message: agents independently decide whether to react.
|
|
1367
|
+
for (const persona of personas) {
|
|
1368
|
+
if (!this._isPersonaOnCooldown(channel, threadTs, persona.id) && Math.random() < RANDOM_REACTION_PROBABILITY) {
|
|
1369
|
+
void this._maybeReactToHumanMessage(channel, ts, persona);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
// Guaranteed fallback reply — someone always responds.
|
|
1373
|
+
const randomPersona = this._pickRandomPersona(personas, channel, threadTs);
|
|
1374
|
+
if (randomPersona) {
|
|
1375
|
+
console.log(`[slack] fallback engage via ${randomPersona.name}`);
|
|
1376
|
+
await this._applyHumanResponseTiming(channel, ts, randomPersona);
|
|
1377
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, fullContext);
|
|
1378
|
+
this._markPersonaReply(channel, threadTs, randomPersona.id);
|
|
1379
|
+
this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
|
|
1380
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, randomPersona.id);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1166
1383
|
console.log(`[slack] no active discussion found — ignoring message`);
|
|
1167
1384
|
}
|
|
1168
1385
|
}
|