@jonit-dev/night-watch-cli 1.7.25 → 1.7.29
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 -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 +98 -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 +1 -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 +9 -1
- package/dist/src/slack/client.d.ts.map +1 -1
- package/dist/src/slack/client.js +18 -4
- package/dist/src/slack/client.js.map +1 -1
- package/dist/src/slack/deliberation.d.ts +22 -1
- package/dist/src/slack/deliberation.d.ts.map +1 -1
- package/dist/src/slack/deliberation.js +663 -51
- package/dist/src/slack/deliberation.js.map +1 -1
- package/dist/src/slack/interaction-listener.d.ts +33 -9
- package/dist/src/slack/interaction-listener.d.ts.map +1 -1
- package/dist/src/slack/interaction-listener.js +393 -197
- 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 +6 -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 +37 -1
- 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/avatar-generator.d.ts.map +1 -1
- package/dist/src/utils/avatar-generator.js +7 -2
- package/dist/src/utils/avatar-generator.js.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 +149 -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/web/dist/assets/index-BiJf9LFT.js +458 -0
- package/web/dist/assets/index-OpSgvsYu.css +1 -0
- 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
- 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,13 @@ 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;
|
|
27
26
|
const REACTION_DELAY_MIN_MS = 180;
|
|
28
27
|
const REACTION_DELAY_MAX_MS = 1200;
|
|
29
28
|
const RESPONSE_DELAY_MIN_MS = 700;
|
|
30
29
|
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
|
-
]);
|
|
30
|
+
const SOCKET_DISCONNECT_TIMEOUT_MS = 5_000;
|
|
67
31
|
const JOB_STOPWORDS = new Set([
|
|
68
32
|
'and',
|
|
69
33
|
'or',
|
|
@@ -128,126 +92,6 @@ function normalizeForParsing(text) {
|
|
|
128
92
|
.replace(/\s+/g, ' ')
|
|
129
93
|
.trim();
|
|
130
94
|
}
|
|
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
95
|
export function isAmbientTeamMessage(text) {
|
|
252
96
|
const normalized = normalizeForParsing(stripSlackUserMentions(text));
|
|
253
97
|
if (!normalized)
|
|
@@ -294,6 +138,78 @@ export function parseSlackJobRequest(text) {
|
|
|
294
138
|
request.fixConflicts = true;
|
|
295
139
|
return request;
|
|
296
140
|
}
|
|
141
|
+
export function parseSlackIssuePickupRequest(text) {
|
|
142
|
+
const withoutMentions = stripSlackUserMentions(text);
|
|
143
|
+
const normalized = normalizeForParsing(withoutMentions);
|
|
144
|
+
if (!normalized)
|
|
145
|
+
return null;
|
|
146
|
+
// Extract GitHub issue URL — NOT pull requests (those handled by parseSlackJobRequest)
|
|
147
|
+
const compactForUrl = withoutMentions.replace(/\s+/g, '');
|
|
148
|
+
let issueUrl;
|
|
149
|
+
let issueNumber;
|
|
150
|
+
let repo;
|
|
151
|
+
// Standard format: github.com/{owner}/{repo}/issues/{number}
|
|
152
|
+
const directIssueMatch = compactForUrl.match(/https?:\/\/github\.com\/([^/\s<>]+)\/([^/\s<>]+)\/issues\/(\d+)/i);
|
|
153
|
+
if (directIssueMatch) {
|
|
154
|
+
[issueUrl, , repo, issueNumber] = directIssueMatch;
|
|
155
|
+
repo = repo.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Project board format: github.com/...?...&issue={owner}%7C{repo}%7C{number}
|
|
159
|
+
// e.g. github.com/users/jonit-dev/projects/41/views/2?pane=issue&issue=jonit-dev%7Cnight-watch-cli%7C12
|
|
160
|
+
const boardMatch = compactForUrl.match(/https?:\/\/github\.com\/[^<>\s]*[?&]issue=([^<>\s&]+)/i);
|
|
161
|
+
if (!boardMatch)
|
|
162
|
+
return null;
|
|
163
|
+
const rawParam = boardMatch[1].replace(/%7[Cc]/g, '|');
|
|
164
|
+
const parts = rawParam.split('|');
|
|
165
|
+
if (parts.length < 3 || !/^\d+$/.test(parts[parts.length - 1]))
|
|
166
|
+
return null;
|
|
167
|
+
issueNumber = parts[parts.length - 1];
|
|
168
|
+
repo = parts[parts.length - 2].toLowerCase();
|
|
169
|
+
issueUrl = boardMatch[0];
|
|
170
|
+
}
|
|
171
|
+
// Requires pickup-intent language or "this issue" + request language
|
|
172
|
+
// "pickup" (one word) is also accepted alongside "pick up" (two words)
|
|
173
|
+
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);
|
|
174
|
+
const requestSignal = /\b(please|can\s+someone|anyone)\b/i.test(normalized) && /\bthis\s+issue\b/i.test(normalized);
|
|
175
|
+
if (!pickupSignal && !requestSignal)
|
|
176
|
+
return null;
|
|
177
|
+
return {
|
|
178
|
+
issueNumber,
|
|
179
|
+
issueUrl,
|
|
180
|
+
repoHint: repo,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function parseSlackProviderRequest(text) {
|
|
184
|
+
const withoutMentions = stripSlackUserMentions(text);
|
|
185
|
+
if (!withoutMentions.trim())
|
|
186
|
+
return null;
|
|
187
|
+
// Explicit direct-provider invocation from Slack, e.g.:
|
|
188
|
+
// "claude fix the flaky tests", "run codex on repo-x: investigate CI failures"
|
|
189
|
+
const prefixMatch = withoutMentions.match(/^\s*(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(claude|codex)\b[\s:,-]*/i);
|
|
190
|
+
if (!prefixMatch)
|
|
191
|
+
return null;
|
|
192
|
+
const provider = prefixMatch[1].toLowerCase();
|
|
193
|
+
let remainder = withoutMentions.slice(prefixMatch[0].length).trim();
|
|
194
|
+
if (!remainder)
|
|
195
|
+
return null;
|
|
196
|
+
let projectHint;
|
|
197
|
+
const projectMatch = remainder.match(/^(?:for|on)\s+([a-z0-9./_-]+)\b[\s:,-]*/i);
|
|
198
|
+
if (projectMatch) {
|
|
199
|
+
const candidate = projectMatch[1].toLowerCase();
|
|
200
|
+
if (!JOB_STOPWORDS.has(candidate)) {
|
|
201
|
+
projectHint = candidate;
|
|
202
|
+
}
|
|
203
|
+
remainder = remainder.slice(projectMatch[0].length).trim();
|
|
204
|
+
}
|
|
205
|
+
if (!remainder)
|
|
206
|
+
return null;
|
|
207
|
+
return {
|
|
208
|
+
provider,
|
|
209
|
+
prompt: remainder,
|
|
210
|
+
...(projectHint ? { projectHint } : {}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
297
213
|
function getPersonaDomain(persona) {
|
|
298
214
|
const role = persona.role.toLowerCase();
|
|
299
215
|
const expertise = (persona.soul?.expertise ?? []).join(' ').toLowerCase();
|
|
@@ -442,6 +358,46 @@ export function shouldIgnoreInboundSlackEvent(event, botUserId) {
|
|
|
442
358
|
return true;
|
|
443
359
|
return false;
|
|
444
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Extract GitHub issue or PR URLs from a message string.
|
|
363
|
+
*/
|
|
364
|
+
export function extractGitHubIssueUrls(text) {
|
|
365
|
+
const matches = text.match(/https?:\/\/github\.com\/[^\s<>]+/g) ?? [];
|
|
366
|
+
return matches.filter((u) => /\/(issues|pull)\/\d+/.test(u));
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Fetch GitHub issue/PR content via `gh api` for agent context.
|
|
370
|
+
* Returns a formatted string, or '' on failure.
|
|
371
|
+
*/
|
|
372
|
+
async function fetchGitHubIssueContext(urls) {
|
|
373
|
+
if (urls.length === 0)
|
|
374
|
+
return '';
|
|
375
|
+
const parts = [];
|
|
376
|
+
for (const url of urls.slice(0, 3)) {
|
|
377
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/);
|
|
378
|
+
if (!match)
|
|
379
|
+
continue;
|
|
380
|
+
const [, owner, repo, type, number] = match;
|
|
381
|
+
const endpoint = type === 'pull'
|
|
382
|
+
? `/repos/${owner}/${repo}/pulls/${number}`
|
|
383
|
+
: `/repos/${owner}/${repo}/issues/${number}`;
|
|
384
|
+
try {
|
|
385
|
+
const raw = execFileSync('gh', ['api', endpoint, '--jq', '{title: .title, state: .state, body: .body, labels: [.labels[].name]}'], {
|
|
386
|
+
timeout: 10_000,
|
|
387
|
+
encoding: 'utf-8',
|
|
388
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
389
|
+
});
|
|
390
|
+
const data = JSON.parse(raw);
|
|
391
|
+
const labelStr = data.labels.length > 0 ? ` [${data.labels.join(', ')}]` : '';
|
|
392
|
+
const body = (data.body ?? '').trim().slice(0, 1200);
|
|
393
|
+
parts.push(`GitHub ${type === 'pull' ? 'PR' : 'Issue'} #${number}${labelStr}: ${data.title} (${data.state})\n${body}`);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// gh not available or not authenticated — skip
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return parts.join('\n\n---\n\n');
|
|
400
|
+
}
|
|
445
401
|
export class SlackInteractionListener {
|
|
446
402
|
_config;
|
|
447
403
|
_slackClient;
|
|
@@ -455,12 +411,12 @@ export class SlackInteractionListener {
|
|
|
455
411
|
_lastChannelActivityAt = new Map();
|
|
456
412
|
_lastProactiveAt = new Map();
|
|
457
413
|
_lastCodeWatchAt = new Map();
|
|
458
|
-
_lastCodeWatchSignatureAt = new Map();
|
|
459
414
|
_proactiveTimer = null;
|
|
460
415
|
constructor(config) {
|
|
461
416
|
this._config = config;
|
|
462
417
|
const token = config.slack?.botToken ?? '';
|
|
463
|
-
|
|
418
|
+
const serverBaseUrl = config.slack?.serverBaseUrl ?? 'http://localhost:7575';
|
|
419
|
+
this._slackClient = new SlackClient(token, serverBaseUrl);
|
|
464
420
|
this._engine = new DeliberationEngine(this._slackClient, config);
|
|
465
421
|
}
|
|
466
422
|
async start() {
|
|
@@ -512,14 +468,21 @@ export class SlackInteractionListener {
|
|
|
512
468
|
const socket = this._socketClient;
|
|
513
469
|
this._socketClient = null;
|
|
514
470
|
try {
|
|
515
|
-
|
|
516
|
-
|
|
471
|
+
await Promise.race([
|
|
472
|
+
socket.disconnect(),
|
|
473
|
+
sleep(SOCKET_DISCONNECT_TIMEOUT_MS).then(() => {
|
|
474
|
+
throw new Error(`timed out after ${SOCKET_DISCONNECT_TIMEOUT_MS}ms`);
|
|
475
|
+
}),
|
|
476
|
+
]);
|
|
517
477
|
console.log('Slack interaction listener stopped');
|
|
518
478
|
}
|
|
519
479
|
catch (err) {
|
|
520
480
|
const msg = err instanceof Error ? err.message : String(err);
|
|
521
481
|
console.warn(`Slack interaction listener shutdown failed: ${msg}`);
|
|
522
482
|
}
|
|
483
|
+
finally {
|
|
484
|
+
socket.removeAllListeners();
|
|
485
|
+
}
|
|
523
486
|
}
|
|
524
487
|
/**
|
|
525
488
|
* Join all configured channels, generate avatars for personas that need them,
|
|
@@ -671,6 +634,49 @@ export class SlackInteractionListener {
|
|
|
671
634
|
expiresAt: Date.now() + AD_HOC_THREAD_MEMORY_MS,
|
|
672
635
|
});
|
|
673
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* After an agent posts a reply, check if the text mentions other personas by plain name.
|
|
639
|
+
* If so, trigger those personas to respond once (no further cascading — depth 1 only).
|
|
640
|
+
* This enables natural agent-to-agent handoffs like "Carlos, what's the priority here?"
|
|
641
|
+
*/
|
|
642
|
+
async _followAgentMentions(postedText, channel, threadTs, personas, projectContext, skipPersonaId) {
|
|
643
|
+
if (!postedText)
|
|
644
|
+
return;
|
|
645
|
+
const mentioned = resolvePersonasByPlainName(postedText, personas).filter((p) => p.id !== skipPersonaId && !this._isPersonaOnCooldown(channel, threadTs, p.id));
|
|
646
|
+
if (mentioned.length === 0)
|
|
647
|
+
return;
|
|
648
|
+
console.log(`[slack] agent mention follow-up: ${mentioned.map((p) => p.name).join(', ')}`);
|
|
649
|
+
for (const persona of mentioned) {
|
|
650
|
+
// Small human-like delay before the tagged persona responds
|
|
651
|
+
await sleep(this._randomInt(RESPONSE_DELAY_MIN_MS * 2, RESPONSE_DELAY_MAX_MS * 3));
|
|
652
|
+
// replyAsAgent fetches thread history internally so Carlos sees Dev's message
|
|
653
|
+
await this._engine.replyAsAgent(channel, threadTs, postedText, persona, projectContext);
|
|
654
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
655
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Recover the persona that last replied in a thread by scanning its history.
|
|
660
|
+
* Used as a fallback when in-memory state was lost (e.g. after a server restart).
|
|
661
|
+
* Matches message `username` fields against known persona names.
|
|
662
|
+
*/
|
|
663
|
+
async _recoverPersonaFromThreadHistory(channel, threadTs, personas) {
|
|
664
|
+
try {
|
|
665
|
+
const history = await this._slackClient.getChannelHistory(channel, threadTs, 50);
|
|
666
|
+
// Walk backwards to find the most recent message sent by a persona
|
|
667
|
+
for (const msg of [...history].reverse()) {
|
|
668
|
+
if (!msg.username)
|
|
669
|
+
continue;
|
|
670
|
+
const matched = personas.find((p) => p.name.toLowerCase() === msg.username.toLowerCase());
|
|
671
|
+
if (matched)
|
|
672
|
+
return matched;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// Ignore — treat as no prior context
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
674
680
|
_getRememberedAdHocPersona(channel, threadTs, personas) {
|
|
675
681
|
const key = this._threadKey(channel, threadTs);
|
|
676
682
|
const remembered = this._adHocThreadState.get(key);
|
|
@@ -821,6 +827,7 @@ export class SlackInteractionListener {
|
|
|
821
827
|
...process.env,
|
|
822
828
|
NW_EXECUTION_CONTEXT: 'agent',
|
|
823
829
|
...(opts?.prNumber ? { NW_TARGET_PR: opts.prNumber } : {}),
|
|
830
|
+
...(opts?.issueNumber ? { NW_TARGET_ISSUE: opts.issueNumber } : {}),
|
|
824
831
|
...(opts?.fixConflicts
|
|
825
832
|
? {
|
|
826
833
|
NW_SLACK_FEEDBACK: JSON.stringify({
|
|
@@ -880,6 +887,96 @@ export class SlackInteractionListener {
|
|
|
880
887
|
this._markPersonaReply(channel, threadTs, persona.id);
|
|
881
888
|
});
|
|
882
889
|
}
|
|
890
|
+
async _spawnDirectProviderRequest(request, project, channel, threadTs, persona) {
|
|
891
|
+
const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
|
|
892
|
+
const args = request.provider === 'claude'
|
|
893
|
+
? ['-p', request.prompt, '--dangerously-skip-permissions']
|
|
894
|
+
: ['--quiet', '--yolo', '--prompt', request.prompt];
|
|
895
|
+
console.log(`[slack][provider] persona=${persona.name} provider=${request.provider} project=${project.name} spawn=${formatCommandForLog(request.provider, args)}`);
|
|
896
|
+
const child = spawn(request.provider, args, {
|
|
897
|
+
cwd: project.path,
|
|
898
|
+
env: {
|
|
899
|
+
...process.env,
|
|
900
|
+
...(this._config.providerEnv ?? {}),
|
|
901
|
+
NW_EXECUTION_CONTEXT: 'agent',
|
|
902
|
+
},
|
|
903
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
904
|
+
});
|
|
905
|
+
console.log(`[slack][provider] ${persona.name} spawned ${request.provider} for ${project.name} pid=${child.pid ?? 'unknown'}`);
|
|
906
|
+
let output = '';
|
|
907
|
+
let errored = false;
|
|
908
|
+
const appendOutput = (chunk) => {
|
|
909
|
+
output += chunk.toString();
|
|
910
|
+
if (output.length > MAX_JOB_OUTPUT_CHARS) {
|
|
911
|
+
output = output.slice(-MAX_JOB_OUTPUT_CHARS);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
child.stdout?.on('data', appendOutput);
|
|
915
|
+
child.stderr?.on('data', appendOutput);
|
|
916
|
+
child.on('error', async (err) => {
|
|
917
|
+
errored = true;
|
|
918
|
+
console.warn(`[slack][provider] ${persona.name} ${request.provider} spawn error for ${project.name}: ${err.message}`);
|
|
919
|
+
await this._slackClient.postAsAgent(channel, `Couldn't start ${providerLabel}. Error logged — looking into it.`, persona, threadTs);
|
|
920
|
+
this._markChannelActivity(channel);
|
|
921
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
922
|
+
});
|
|
923
|
+
child.on('close', async (code) => {
|
|
924
|
+
if (errored)
|
|
925
|
+
return;
|
|
926
|
+
console.log(`[slack][provider] ${persona.name} ${request.provider} finished for ${project.name} exit=${code ?? 'unknown'}`);
|
|
927
|
+
const detail = extractLastMeaningfulLines(output);
|
|
928
|
+
if (code === 0) {
|
|
929
|
+
await this._slackClient.postAsAgent(channel, `${providerLabel} command finished.`, persona, threadTs);
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
if (detail) {
|
|
933
|
+
console.warn(`[slack][provider] ${persona.name} ${request.provider} failure detail: ${detail}`);
|
|
934
|
+
}
|
|
935
|
+
await this._slackClient.postAsAgent(channel, `${providerLabel} hit a snag. Logged the details — looking into it.`, persona, threadTs);
|
|
936
|
+
}
|
|
937
|
+
this._markChannelActivity(channel);
|
|
938
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
async _triggerDirectProviderIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
942
|
+
const request = parseSlackProviderRequest(event.text ?? '');
|
|
943
|
+
if (!request)
|
|
944
|
+
return false;
|
|
945
|
+
const addressedToBot = this._isMessageAddressedToBot(event);
|
|
946
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
947
|
+
const startsWithProviderCommand = /^(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(?:claude|codex)\b/i.test(normalized);
|
|
948
|
+
if (!addressedToBot && !startsWithProviderCommand) {
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
const repos = getRepositories();
|
|
952
|
+
const projects = repos.projectRegistry.getAll();
|
|
953
|
+
const persona = this._findPersonaByName(personas, 'Dev')
|
|
954
|
+
?? this._pickRandomPersona(personas, channel, threadTs)
|
|
955
|
+
?? personas[0];
|
|
956
|
+
if (!persona)
|
|
957
|
+
return false;
|
|
958
|
+
const targetProject = this._resolveTargetProject(channel, projects, request.projectHint);
|
|
959
|
+
if (!targetProject) {
|
|
960
|
+
const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
|
|
961
|
+
await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
|
|
962
|
+
this._markChannelActivity(channel);
|
|
963
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
console.log(`[slack][provider] routing provider=${request.provider} to persona=${persona.name} project=${targetProject.name}`);
|
|
967
|
+
const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
|
|
968
|
+
const compactPrompt = request.prompt.replace(/\s+/g, ' ').trim();
|
|
969
|
+
const promptPreview = compactPrompt.length > 120
|
|
970
|
+
? `${compactPrompt.slice(0, 117)}...`
|
|
971
|
+
: compactPrompt;
|
|
972
|
+
await this._applyHumanResponseTiming(channel, messageTs, persona);
|
|
973
|
+
await this._slackClient.postAsAgent(channel, `Running ${providerLabel} directly${request.projectHint ? ` on ${targetProject.name}` : ''}: "${promptPreview}"`, persona, threadTs);
|
|
974
|
+
this._markChannelActivity(channel);
|
|
975
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
976
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
977
|
+
await this._spawnDirectProviderRequest(request, targetProject, channel, threadTs, persona);
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
883
980
|
async _triggerSlackJobIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
884
981
|
const request = parseSlackJobRequest(event.text ?? '');
|
|
885
982
|
if (!request)
|
|
@@ -927,12 +1024,104 @@ export class SlackInteractionListener {
|
|
|
927
1024
|
await this._spawnNightWatchJob(request.job, targetProject, channel, threadTs, persona, { prNumber: request.prNumber, fixConflicts: request.fixConflicts });
|
|
928
1025
|
return true;
|
|
929
1026
|
}
|
|
1027
|
+
async _triggerIssuePickupIfRequested(event, channel, threadTs, messageTs, personas) {
|
|
1028
|
+
const request = parseSlackIssuePickupRequest(event.text ?? '');
|
|
1029
|
+
if (!request)
|
|
1030
|
+
return false;
|
|
1031
|
+
const addressedToBot = this._isMessageAddressedToBot(event);
|
|
1032
|
+
const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
|
|
1033
|
+
const teamRequestLanguage = /\b(can someone|someone|anyone|please|need)\b/i.test(normalized);
|
|
1034
|
+
if (!addressedToBot && !teamRequestLanguage)
|
|
1035
|
+
return false;
|
|
1036
|
+
const repos = getRepositories();
|
|
1037
|
+
const projects = repos.projectRegistry.getAll();
|
|
1038
|
+
const persona = this._findPersonaByName(personas, 'Dev')
|
|
1039
|
+
?? this._pickRandomPersona(personas, channel, threadTs)
|
|
1040
|
+
?? personas[0];
|
|
1041
|
+
if (!persona)
|
|
1042
|
+
return false;
|
|
1043
|
+
const targetProject = this._resolveTargetProject(channel, projects, request.repoHint);
|
|
1044
|
+
if (!targetProject) {
|
|
1045
|
+
const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
|
|
1046
|
+
await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
|
|
1047
|
+
this._markChannelActivity(channel);
|
|
1048
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
console.log(`[slack][issue-pickup] routing issue=#${request.issueNumber} to persona=${persona.name} project=${targetProject.name}`);
|
|
1052
|
+
await this._applyHumanResponseTiming(channel, messageTs, persona);
|
|
1053
|
+
await this._slackClient.postAsAgent(channel, `On it — picking up #${request.issueNumber}. Starting the run now.`, persona, threadTs);
|
|
1054
|
+
this._markChannelActivity(channel);
|
|
1055
|
+
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1056
|
+
this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
|
|
1057
|
+
// Move issue to In Progress on board (best-effort, spawn via CLI subprocess)
|
|
1058
|
+
const boardArgs = buildCurrentCliInvocation([
|
|
1059
|
+
'board', 'move-issue', request.issueNumber, '--column', 'In Progress',
|
|
1060
|
+
]);
|
|
1061
|
+
if (boardArgs) {
|
|
1062
|
+
try {
|
|
1063
|
+
execFileSync(process.execPath, boardArgs, {
|
|
1064
|
+
cwd: targetProject.path,
|
|
1065
|
+
timeout: 15_000,
|
|
1066
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1067
|
+
});
|
|
1068
|
+
console.log(`[slack][issue-pickup] moved #${request.issueNumber} to In Progress`);
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
console.warn(`[slack][issue-pickup] failed to move #${request.issueNumber} to In Progress`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
console.log(`[slack][issue-pickup] spawning run for #${request.issueNumber}`);
|
|
1075
|
+
await this._spawnNightWatchJob('run', targetProject, channel, threadTs, persona, {
|
|
1076
|
+
issueNumber: request.issueNumber,
|
|
1077
|
+
});
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
930
1080
|
_resolveProactiveChannelForProject(project) {
|
|
931
1081
|
const slack = this._config.slack;
|
|
932
1082
|
if (!slack)
|
|
933
1083
|
return null;
|
|
934
1084
|
return project.slackChannelId || slack.channels.eng || null;
|
|
935
1085
|
}
|
|
1086
|
+
_spawnCodeWatchAudit(project, channel) {
|
|
1087
|
+
const invocationArgs = buildCurrentCliInvocation(['audit']);
|
|
1088
|
+
if (!invocationArgs) {
|
|
1089
|
+
console.warn(`[slack][codewatch] audit spawn failed for ${project.name}: CLI entry path unavailable`);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
console.log(`[slack][codewatch] spawning audit for ${project.name} → ${channel} cmd=${formatCommandForLog(process.execPath, invocationArgs)}`);
|
|
1093
|
+
const child = spawn(process.execPath, invocationArgs, {
|
|
1094
|
+
cwd: project.path,
|
|
1095
|
+
env: { ...process.env, NW_EXECUTION_CONTEXT: 'agent' },
|
|
1096
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1097
|
+
});
|
|
1098
|
+
console.log(`[slack][codewatch] audit spawned for ${project.name} pid=${child.pid ?? 'unknown'}`);
|
|
1099
|
+
child.stdout?.on('data', () => { });
|
|
1100
|
+
child.stderr?.on('data', () => { });
|
|
1101
|
+
child.on('error', (err) => {
|
|
1102
|
+
console.warn(`[slack][codewatch] audit spawn error for ${project.name}: ${err.message}`);
|
|
1103
|
+
});
|
|
1104
|
+
child.on('close', async (code) => {
|
|
1105
|
+
console.log(`[slack][codewatch] audit finished for ${project.name} exit=${code ?? 'unknown'}`);
|
|
1106
|
+
const reportPath = path.join(project.path, 'logs', 'audit-report.md');
|
|
1107
|
+
let report;
|
|
1108
|
+
try {
|
|
1109
|
+
report = fs.readFileSync(reportPath, 'utf-8').trim();
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
console.log(`[slack][codewatch] no audit report found at ${reportPath}`);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
try {
|
|
1116
|
+
await this._engine.handleAuditReport(report, project.name, project.path, channel);
|
|
1117
|
+
this._markChannelActivity(channel);
|
|
1118
|
+
}
|
|
1119
|
+
catch (err) {
|
|
1120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1121
|
+
console.warn(`[slack][codewatch] handleAuditReport failed for ${project.name}: ${msg}`);
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
936
1125
|
async _runProactiveCodeWatch(projects, now) {
|
|
937
1126
|
for (const project of projects) {
|
|
938
1127
|
const channel = this._resolveProactiveChannelForProject(project);
|
|
@@ -943,38 +1132,7 @@ export class SlackInteractionListener {
|
|
|
943
1132
|
continue;
|
|
944
1133
|
}
|
|
945
1134
|
this._lastCodeWatchAt.set(project.path, now);
|
|
946
|
-
|
|
947
|
-
if (!candidate) {
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
const signatureKey = `${project.path}:${candidate.signature}`;
|
|
951
|
-
const lastSeen = this._lastCodeWatchSignatureAt.get(signatureKey) ?? 0;
|
|
952
|
-
if (now - lastSeen < PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS) {
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
const ref = `codewatch-${candidate.signature}`;
|
|
956
|
-
const context = `Project: ${project.name}\n` +
|
|
957
|
-
`Signal: ${candidate.summary}\n` +
|
|
958
|
-
`Location: ${candidate.relativePath}:${candidate.line}\n` +
|
|
959
|
-
`Snippet: ${candidate.snippet}\n` +
|
|
960
|
-
`Question: Is this intentional, or should we patch it now?`;
|
|
961
|
-
console.log(`[slack][codewatch] project=${project.name} location=${candidate.relativePath}:${candidate.line} signal=${candidate.type}`);
|
|
962
|
-
try {
|
|
963
|
-
await this._engine.startDiscussion({
|
|
964
|
-
type: 'code_watch',
|
|
965
|
-
projectPath: project.path,
|
|
966
|
-
ref,
|
|
967
|
-
context,
|
|
968
|
-
channelId: channel,
|
|
969
|
-
});
|
|
970
|
-
this._lastCodeWatchSignatureAt.set(signatureKey, now);
|
|
971
|
-
this._markChannelActivity(channel);
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
catch (err) {
|
|
975
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
976
|
-
console.warn(`[slack][codewatch] failed for ${project.name}: ${msg}`);
|
|
977
|
-
}
|
|
1135
|
+
this._spawnCodeWatchAudit(project, channel);
|
|
978
1136
|
}
|
|
979
1137
|
}
|
|
980
1138
|
_startProactiveLoop() {
|
|
@@ -1049,9 +1207,20 @@ export class SlackInteractionListener {
|
|
|
1049
1207
|
const personas = repos.agentPersona.getActive();
|
|
1050
1208
|
const projects = repos.projectRegistry.getAll();
|
|
1051
1209
|
const projectContext = this._buildProjectContext(channel, projects);
|
|
1210
|
+
// Fetch GitHub issue/PR content from URLs in the message so agents can inspect them.
|
|
1211
|
+
const githubUrls = extractGitHubIssueUrls(text);
|
|
1212
|
+
console.log(`[slack] processing message channel=${channel} thread=${threadTs} urls=${githubUrls.length}`);
|
|
1213
|
+
const githubContext = githubUrls.length > 0 ? await fetchGitHubIssueContext(githubUrls) : '';
|
|
1214
|
+
const fullContext = githubContext ? `${projectContext}\n\nReferenced GitHub content:\n${githubContext}` : projectContext;
|
|
1215
|
+
if (await this._triggerDirectProviderIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1052
1218
|
if (await this._triggerSlackJobIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1053
1219
|
return;
|
|
1054
1220
|
}
|
|
1221
|
+
if (await this._triggerIssuePickupIfRequested(event, channel, threadTs, ts, personas)) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1055
1224
|
// @mention matching: "@maya ..."
|
|
1056
1225
|
let mentionedPersonas = resolveMentionedPersonas(text, personas);
|
|
1057
1226
|
// Also try plain-name matching (e.g. "Carlos, are you there?").
|
|
@@ -1069,6 +1238,8 @@ export class SlackInteractionListener {
|
|
|
1069
1238
|
.slackDiscussion
|
|
1070
1239
|
.getActive('')
|
|
1071
1240
|
.find((d) => d.channelId === channel && d.threadTs === threadTs);
|
|
1241
|
+
let lastPosted = '';
|
|
1242
|
+
let lastPersonaId = '';
|
|
1072
1243
|
for (const persona of mentionedPersonas) {
|
|
1073
1244
|
if (this._isPersonaOnCooldown(channel, threadTs, persona.id)) {
|
|
1074
1245
|
console.log(`[slack] ${persona.name} is on cooldown — skipping`);
|
|
@@ -1079,13 +1250,19 @@ export class SlackInteractionListener {
|
|
|
1079
1250
|
await this._engine.contributeAsAgent(discussion.id, persona);
|
|
1080
1251
|
}
|
|
1081
1252
|
else {
|
|
1082
|
-
|
|
1253
|
+
console.log(`[slack] replying as ${persona.name} in ${channel}`);
|
|
1254
|
+
lastPosted = await this._engine.replyAsAgent(channel, threadTs, text, persona, fullContext);
|
|
1255
|
+
lastPersonaId = persona.id;
|
|
1083
1256
|
}
|
|
1084
1257
|
this._markPersonaReply(channel, threadTs, persona.id);
|
|
1085
1258
|
}
|
|
1086
1259
|
if (!discussion && mentionedPersonas[0]) {
|
|
1087
1260
|
this._rememberAdHocThreadPersona(channel, threadTs, mentionedPersonas[0].id);
|
|
1088
1261
|
}
|
|
1262
|
+
// Follow up if the last agent reply mentions other teammates by name.
|
|
1263
|
+
if (lastPosted && lastPersonaId) {
|
|
1264
|
+
await this._followAgentMentions(lastPosted, channel, threadTs, personas, fullContext, lastPersonaId);
|
|
1265
|
+
}
|
|
1089
1266
|
return;
|
|
1090
1267
|
}
|
|
1091
1268
|
console.log(`[slack] no persona match — checking for active discussion in ${channel}:${threadTs}`);
|
|
@@ -1109,11 +1286,28 @@ export class SlackInteractionListener {
|
|
|
1109
1286
|
console.log(`[slack] continuing ad-hoc thread with ${rememberedPersona.name}`);
|
|
1110
1287
|
}
|
|
1111
1288
|
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1112
|
-
|
|
1289
|
+
console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
|
|
1290
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
|
|
1113
1291
|
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1114
1292
|
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
1293
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
|
|
1115
1294
|
return;
|
|
1116
1295
|
}
|
|
1296
|
+
// In-memory state was lost (e.g. server restart) — recover persona from thread history.
|
|
1297
|
+
if (threadTs) {
|
|
1298
|
+
const recoveredPersona = await this._recoverPersonaFromThreadHistory(channel, threadTs, personas);
|
|
1299
|
+
if (recoveredPersona) {
|
|
1300
|
+
const followUpPersona = selectFollowUpPersona(recoveredPersona, personas, text);
|
|
1301
|
+
console.log(`[slack] recovered ad-hoc thread persona ${recoveredPersona.name} from history, replying as ${followUpPersona.name}`);
|
|
1302
|
+
await this._applyHumanResponseTiming(channel, ts, followUpPersona);
|
|
1303
|
+
console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
|
|
1304
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
|
|
1305
|
+
this._markPersonaReply(channel, threadTs, followUpPersona.id);
|
|
1306
|
+
this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
|
|
1307
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1117
1311
|
// Keep the channel alive: direct mentions and ambient greetings get a random responder.
|
|
1118
1312
|
const shouldAutoEngage = event.type === 'app_mention' || isAmbientTeamMessage(text);
|
|
1119
1313
|
if (shouldAutoEngage) {
|
|
@@ -1121,9 +1315,11 @@ export class SlackInteractionListener {
|
|
|
1121
1315
|
if (randomPersona) {
|
|
1122
1316
|
console.log(`[slack] auto-engaging via ${randomPersona.name}`);
|
|
1123
1317
|
await this._applyHumanResponseTiming(channel, ts, randomPersona);
|
|
1124
|
-
|
|
1318
|
+
console.log(`[slack] replying as ${randomPersona.name} in ${channel}`);
|
|
1319
|
+
const postedText = await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, fullContext);
|
|
1125
1320
|
this._markPersonaReply(channel, threadTs, randomPersona.id);
|
|
1126
1321
|
this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
|
|
1322
|
+
await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, randomPersona.id);
|
|
1127
1323
|
return;
|
|
1128
1324
|
}
|
|
1129
1325
|
}
|