wayfind 2.0.68 → 2.0.70
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/bin/connectors/llm.js +87 -0
- package/bin/content-store.js +13 -143
- package/bin/mcp-server.js +47 -50
- package/bin/slack-bot.js +136 -1060
- package/bin/team-context.js +41 -31
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
package/bin/slack-bot.js
CHANGED
|
@@ -11,9 +11,6 @@ const telemetry = require('./telemetry');
|
|
|
11
11
|
// ── Slack connection state (for healthcheck) ────────────────────────────────
|
|
12
12
|
let slackConnected = false;
|
|
13
13
|
|
|
14
|
-
// ── Feature map (loaded at startup, reloaded on-demand) ──────────────────────
|
|
15
|
-
/** In-memory feature map: { "org/repo": { tags: string[], description: string } } */
|
|
16
|
-
let featureMap = null;
|
|
17
14
|
let slackLastConnected = null;
|
|
18
15
|
let slackLastDisconnected = null;
|
|
19
16
|
|
|
@@ -21,98 +18,14 @@ let slackLastDisconnected = null;
|
|
|
21
18
|
|
|
22
19
|
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
23
20
|
const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
|
|
24
|
-
const SIGNALS_DIR = path.join(WAYFIND_DIR, 'signals');
|
|
25
21
|
const ENV_FILE = path.join(WAYFIND_DIR, '.env');
|
|
26
22
|
|
|
27
23
|
/** Slack mrkdwn has a practical limit; split responses over this threshold. */
|
|
28
24
|
const MAX_RESPONSE_LENGTH = 3000;
|
|
29
25
|
|
|
30
|
-
/** Maximum number of search results to feed into the synthesis prompt. */
|
|
31
|
-
const MAX_SEARCH_RESULTS = 5;
|
|
32
|
-
|
|
33
26
|
/** Maximum thread exchanges to include as conversation context. */
|
|
34
27
|
const MAX_THREAD_EXCHANGES = 5;
|
|
35
28
|
|
|
36
|
-
/** Build the system prompt for the LLM synthesis step (includes current date). */
|
|
37
|
-
function buildSynthesisPrompt() {
|
|
38
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
39
|
-
return `You are Wayfind, a team decision trail assistant. You answer questions about the team's engineering decisions, work sessions, and project history using the provided decision trail entries.
|
|
40
|
-
|
|
41
|
-
Today's date is ${today}.
|
|
42
|
-
|
|
43
|
-
Rules:
|
|
44
|
-
- Lead with the answer. Summarize what happened, what was decided, and what the outcome was.
|
|
45
|
-
- Be concise and specific. Under 500 words.
|
|
46
|
-
- Cite dates and repos when referencing decisions or sessions.
|
|
47
|
-
- The entries below ARE the full session notes, not summaries or titles. Read them carefully and use specific details from the Why/What/Outcome/Lessons fields.
|
|
48
|
-
- Never say you only have titles, headers, or tags — you have the complete content.
|
|
49
|
-
- Never recommend the user "check the full content" — you already have it.
|
|
50
|
-
- If the entries answer the question, answer confidently with specifics.
|
|
51
|
-
- CRITICAL: If the user asks about a specific time period (e.g. "today", "this week", "yesterday") and the entries don't fall within that period, say so clearly. Do NOT present older entries as if they match the requested time frame. Instead say something like "I don't have any entries for [requested period]. The most recent activity I have for [repo] is from [date]."
|
|
52
|
-
- Each entry has an Author field. When a user asks about a specific person (e.g. "what did Nick do"), use the Author field to identify entries BY that person, not entries that merely MENTION them in the content. A person can appear in content without being the author.
|
|
53
|
-
- When presenting entries, always mention who wrote them (the author).
|
|
54
|
-
- If previous thread exchanges are provided, use them for conversational context. Answer follow-up questions naturally without asking the user to repeat themselves.
|
|
55
|
-
- If thread history was truncated, note that you only have partial history and may be missing earlier context.
|
|
56
|
-
- Format your response in markdown. Use bullet points for lists.
|
|
57
|
-
- Do not invent information that isn't in the provided context.`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ── Feature map ──────────────────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Load features.json from the team-context repo into memory.
|
|
64
|
-
* Silently no-ops if the file doesn't exist.
|
|
65
|
-
* @param {Object} config - Bot config (uses team_context_dir or TEAM_CONTEXT_TEAM_CONTEXT_DIR)
|
|
66
|
-
*/
|
|
67
|
-
function loadFeatureMap(config) {
|
|
68
|
-
const teamDir = config.team_context_dir || process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
|
|
69
|
-
if (!teamDir) return;
|
|
70
|
-
const featuresFile = path.join(teamDir, 'features.json');
|
|
71
|
-
try {
|
|
72
|
-
const raw = fs.readFileSync(featuresFile, 'utf8');
|
|
73
|
-
featureMap = JSON.parse(raw);
|
|
74
|
-
} catch {
|
|
75
|
-
featureMap = null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Use Haiku to determine which repos are relevant to a query, based on the feature map.
|
|
81
|
-
* Returns an array of repo slugs (e.g. ["org/api-service", "org/analytics"]).
|
|
82
|
-
* Returns null if routing cannot be determined (map empty, LLM fails, etc.).
|
|
83
|
-
* @param {string} query
|
|
84
|
-
* @param {Object} map - Feature map object
|
|
85
|
-
* @param {Object} llmConfig - LLM configuration
|
|
86
|
-
* @returns {Promise<string[]|null>}
|
|
87
|
-
*/
|
|
88
|
-
async function routeQueryToRepos(query, map, llmConfig) {
|
|
89
|
-
if (!map || Object.keys(map).length === 0) return null;
|
|
90
|
-
|
|
91
|
-
const repoList = Object.entries(map).map(([repo, entry]) => {
|
|
92
|
-
const tags = (entry.tags || []).join(', ');
|
|
93
|
-
const desc = entry.description ? ` — ${entry.description}` : '';
|
|
94
|
-
return `${repo}: ${tags}${desc}`;
|
|
95
|
-
}).join('\n');
|
|
96
|
-
|
|
97
|
-
const systemPrompt = `You are a routing assistant. Given a user query and a list of repositories with their feature tags, return a JSON array of repository slugs (e.g. ["org/repo"]) that are most relevant to the query. Return only the repos that are clearly relevant. If no repos match, return an empty array. Return only valid JSON — no explanation.`;
|
|
98
|
-
|
|
99
|
-
const userContent = `Query: ${query}\n\nRepositories:\n${repoList}`;
|
|
100
|
-
|
|
101
|
-
const haikuConfig = {
|
|
102
|
-
...llmConfig,
|
|
103
|
-
model: process.env.TEAM_CONTEXT_HAIKU_MODEL || 'claude-haiku-4-5-20251001',
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
const raw = await llm.call(haikuConfig, systemPrompt, userContent);
|
|
108
|
-
const parsed = JSON.parse(raw.trim());
|
|
109
|
-
if (Array.isArray(parsed)) return parsed;
|
|
110
|
-
return null;
|
|
111
|
-
} catch {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
29
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
117
30
|
|
|
118
31
|
function ask(question) {
|
|
@@ -186,82 +99,33 @@ function chunkMessage(text, maxLen) {
|
|
|
186
99
|
// ── Thread history ──────────────────────────────────────────────────────────
|
|
187
100
|
|
|
188
101
|
/**
|
|
189
|
-
* Fetch
|
|
190
|
-
* Returns
|
|
102
|
+
* Fetch recent thread messages for conversation context.
|
|
103
|
+
* Returns array of { role: 'user'|'bot', text: string }.
|
|
191
104
|
* @param {Object} client - Slack Web API client
|
|
192
105
|
* @param {string} channel - Channel ID
|
|
193
106
|
* @param {string} threadTs - Thread timestamp
|
|
194
107
|
* @param {string} botUserId - Bot's Slack user ID
|
|
195
|
-
* @param {string}
|
|
196
|
-
* @returns {Promise<Array<{
|
|
108
|
+
* @param {string} currentTs - Current message ts (excluded from history)
|
|
109
|
+
* @returns {Promise<Array<{ role: string, text: string }>>}
|
|
197
110
|
*/
|
|
198
|
-
async function fetchThreadHistory(client, channel, threadTs, botUserId,
|
|
199
|
-
let messages;
|
|
111
|
+
async function fetchThreadHistory(client, channel, threadTs, botUserId, currentTs) {
|
|
200
112
|
try {
|
|
201
113
|
const result = await client.conversations.replies({
|
|
202
114
|
channel,
|
|
203
115
|
ts: threadTs,
|
|
204
|
-
limit:
|
|
116
|
+
limit: 20,
|
|
205
117
|
});
|
|
206
|
-
messages = result.messages || []
|
|
207
|
-
|
|
208
|
-
|
|
118
|
+
const messages = (result.messages || [])
|
|
119
|
+
.filter(m => m.ts !== threadTs && m.ts !== currentTs) // exclude root + current
|
|
120
|
+
.slice(-(MAX_THREAD_EXCHANGES * 2)); // keep last N exchanges
|
|
121
|
+
|
|
122
|
+
return messages.map(m => ({
|
|
123
|
+
role: m.user === botUserId ? 'bot' : 'user',
|
|
124
|
+
text: m.text || '',
|
|
125
|
+
}));
|
|
126
|
+
} catch {
|
|
209
127
|
return [];
|
|
210
128
|
}
|
|
211
|
-
|
|
212
|
-
// Exclude the current message — it's the new query, not history
|
|
213
|
-
messages = messages.filter((m) => m.ts !== currentEventTs);
|
|
214
|
-
|
|
215
|
-
// Pair user questions (messages mentioning the bot) with bot answers
|
|
216
|
-
const exchanges = [];
|
|
217
|
-
for (let i = 0; i < messages.length; i++) {
|
|
218
|
-
const msg = messages[i];
|
|
219
|
-
const isBotMention = msg.text && botUserId && msg.text.includes(`<@${botUserId}>`);
|
|
220
|
-
if (!isBotMention) continue;
|
|
221
|
-
|
|
222
|
-
// Look for the bot's reply — next message from the bot
|
|
223
|
-
const query = extractQuery(msg.text, botUserId);
|
|
224
|
-
let answer = '';
|
|
225
|
-
for (let j = i + 1; j < messages.length; j++) {
|
|
226
|
-
if (messages[j].user === botUserId || messages[j].bot_id) {
|
|
227
|
-
answer = messages[j].text || '';
|
|
228
|
-
break;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (query && answer) {
|
|
232
|
-
exchanges.push({ question: query, answer });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (exchanges.length <= MAX_THREAD_EXCHANGES) return exchanges;
|
|
237
|
-
|
|
238
|
-
// First exchange anchors the topic, plus the most recent N-1
|
|
239
|
-
const first = exchanges[0];
|
|
240
|
-
const recent = exchanges.slice(-(MAX_THREAD_EXCHANGES - 1));
|
|
241
|
-
const truncated = [first, ...recent];
|
|
242
|
-
truncated._truncated = true;
|
|
243
|
-
return truncated;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Format thread history as context for the LLM prompt.
|
|
248
|
-
* @param {Array<{ question: string, answer: string }>} exchanges
|
|
249
|
-
* @returns {string}
|
|
250
|
-
*/
|
|
251
|
-
function formatThreadContext(exchanges) {
|
|
252
|
-
if (!exchanges || exchanges.length === 0) return '';
|
|
253
|
-
|
|
254
|
-
const lines = ['Previous exchanges in this thread:'];
|
|
255
|
-
if (exchanges._truncated) {
|
|
256
|
-
lines.push('(Thread history truncated — oldest exchanges between the first and most recent were omitted.)');
|
|
257
|
-
}
|
|
258
|
-
lines.push('');
|
|
259
|
-
for (const ex of exchanges) {
|
|
260
|
-
lines.push(`Q: ${ex.question}`);
|
|
261
|
-
lines.push(`A: ${ex.answer}`);
|
|
262
|
-
lines.push('---');
|
|
263
|
-
}
|
|
264
|
-
return lines.join('\n');
|
|
265
129
|
}
|
|
266
130
|
|
|
267
131
|
// ── Query pipeline ───────────────────────────────────────────────────────────
|
|
@@ -292,503 +156,155 @@ function extractQuery(text, botUserId) {
|
|
|
292
156
|
return query.trim();
|
|
293
157
|
}
|
|
294
158
|
|
|
295
|
-
// ──
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Signal-related keywords grouped by channel.
|
|
299
|
-
* Used for fast heuristic intent classification before falling back to LLM.
|
|
300
|
-
*/
|
|
301
|
-
const SIGNAL_KEYWORDS = {
|
|
302
|
-
intercom: [
|
|
303
|
-
'intercom', 'conversations', 'support tickets', 'customer complaints',
|
|
304
|
-
'customer feedback', 'support trends', 'support volume', 'inbox',
|
|
305
|
-
'customer issues', 'customer signals', 'support signals',
|
|
306
|
-
],
|
|
307
|
-
github: [
|
|
308
|
-
'github signals', 'ci failures', 'build failures', 'failing tests',
|
|
309
|
-
'open issues', 'stale prs', 'pr trends',
|
|
310
|
-
],
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
/** Keywords that strongly indicate engineering activity (decision trail) queries. */
|
|
314
|
-
const ENGINEERING_KEYWORDS = [
|
|
315
|
-
'decision', 'decided', 'architecture', 'session', 'working on',
|
|
316
|
-
'work was done', 'work done', 'work happened',
|
|
317
|
-
'commit', 'shipped', 'deployed', 'refactor', 'implemented',
|
|
318
|
-
'sprint', 'standup', 'retrospective', 'onboard',
|
|
319
|
-
'built', 'merged', 'released', 'fixed',
|
|
320
|
-
];
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Classify the intent of a user query.
|
|
324
|
-
* Returns { type: 'signals'|'engineering'|'mixed', channel?: string }.
|
|
325
|
-
*
|
|
326
|
-
* Uses keyword heuristics — fast, no API call needed. The LLM synthesis
|
|
327
|
-
* step will handle nuance; this just routes to the right data source.
|
|
328
|
-
*/
|
|
329
|
-
function classifyIntent(query) {
|
|
330
|
-
const q = query.toLowerCase();
|
|
331
|
-
|
|
332
|
-
// Check for signal channel keywords
|
|
333
|
-
let signalChannel = null;
|
|
334
|
-
let signalScore = 0;
|
|
335
|
-
for (const [channel, keywords] of Object.entries(SIGNAL_KEYWORDS)) {
|
|
336
|
-
for (const kw of keywords) {
|
|
337
|
-
if (q.includes(kw)) {
|
|
338
|
-
signalChannel = channel;
|
|
339
|
-
signalScore++;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Check for engineering keywords
|
|
345
|
-
let engineeringScore = 0;
|
|
346
|
-
for (const kw of ENGINEERING_KEYWORDS) {
|
|
347
|
-
if (q.includes(kw)) engineeringScore++;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (signalChannel && engineeringScore > 0) {
|
|
351
|
-
return { type: 'mixed', channel: signalChannel };
|
|
352
|
-
}
|
|
353
|
-
if (signalChannel) {
|
|
354
|
-
return { type: 'signals', channel: signalChannel };
|
|
355
|
-
}
|
|
356
|
-
return { type: 'engineering' };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// ── Signal search ────────────────────────────────────────────────────────────
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Search signal files for a given channel.
|
|
363
|
-
* Reads the most recent signal markdown files and returns their content.
|
|
364
|
-
* @param {string} channel - Signal channel name (e.g. 'intercom', 'github')
|
|
365
|
-
* @param {number} [maxFiles=3] - Maximum number of recent signal files to include
|
|
366
|
-
* @returns {{ files: string[], content: string, available: boolean }}
|
|
367
|
-
*/
|
|
368
|
-
function searchSignals(channel, maxFiles) {
|
|
369
|
-
const limit = maxFiles || 3;
|
|
370
|
-
const channelDir = path.join(SIGNALS_DIR, channel);
|
|
371
|
-
|
|
372
|
-
if (!fs.existsSync(channelDir)) {
|
|
373
|
-
return { files: [], content: '', available: false };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
let files;
|
|
377
|
-
try {
|
|
378
|
-
files = fs.readdirSync(channelDir)
|
|
379
|
-
.filter((f) => f.endsWith('.md'))
|
|
380
|
-
.sort()
|
|
381
|
-
.reverse()
|
|
382
|
-
.slice(0, limit);
|
|
383
|
-
} catch {
|
|
384
|
-
return { files: [], content: '', available: false };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (files.length === 0) {
|
|
388
|
-
return { files: [], content: '', available: false };
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const contentParts = [];
|
|
392
|
-
for (const f of files) {
|
|
393
|
-
try {
|
|
394
|
-
const text = fs.readFileSync(path.join(channelDir, f), 'utf8');
|
|
395
|
-
contentParts.push(text);
|
|
396
|
-
} catch {
|
|
397
|
-
// Skip unreadable files
|
|
398
|
-
}
|
|
399
|
-
}
|
|
159
|
+
// ── Tool-use relay query handler ────────────────────────────────────────────
|
|
400
160
|
|
|
401
|
-
return {
|
|
402
|
-
files,
|
|
403
|
-
content: contentParts.join('\n\n---\n\n'),
|
|
404
|
-
available: contentParts.length > 0,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
161
|
|
|
408
|
-
/** Build
|
|
409
|
-
function
|
|
162
|
+
/** Build system prompt for the tool-use relay. */
|
|
163
|
+
function buildBotSystemPrompt() {
|
|
410
164
|
const today = new Date().toISOString().slice(0, 10);
|
|
411
|
-
return `You are Wayfind, a team context assistant.
|
|
165
|
+
return `You are Wayfind, a team context assistant. Answer questions about the team's engineering decisions, work sessions, and project history using the provided tools.
|
|
412
166
|
|
|
413
167
|
Today's date is ${today}.
|
|
414
168
|
|
|
415
169
|
Rules:
|
|
416
|
-
-
|
|
417
|
-
-
|
|
418
|
-
-
|
|
419
|
-
-
|
|
420
|
-
-
|
|
170
|
+
- Use search_context to find relevant entries. Use get_entry to read full content.
|
|
171
|
+
- For time-range questions ("this week", "yesterday"), use mode=browse with since/until date filters.
|
|
172
|
+
- For topical questions, use mode=semantic with a query string.
|
|
173
|
+
- For person-specific questions ("what did Nick do"), set the user parameter to their first name lowercase.
|
|
174
|
+
- You may call search_context multiple times with different parameters if needed.
|
|
175
|
+
- Be concise and specific. Under 500 words. Cite dates and repos.
|
|
176
|
+
- Each entry has an Author field. When asked about a specific person, use the user parameter to filter, don't just search for their name.
|
|
421
177
|
- Format your response in markdown. Use bullet points for lists.
|
|
422
178
|
- Do not invent information that isn't in the provided context.`;
|
|
423
179
|
}
|
|
424
180
|
|
|
425
|
-
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const d = new Date(today);
|
|
457
|
-
const day = d.getDay();
|
|
458
|
-
const thisMon = new Date(d);
|
|
459
|
-
thisMon.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
|
|
460
|
-
const lastMon = new Date(thisMon);
|
|
461
|
-
lastMon.setDate(thisMon.getDate() - 7);
|
|
462
|
-
const lastSun = new Date(thisMon);
|
|
463
|
-
lastSun.setDate(thisMon.getDate() - 1);
|
|
464
|
-
return { since: fmt(lastMon), until: fmt(lastSun) };
|
|
465
|
-
}
|
|
466
|
-
if (/\blast (\d+) days?\b/.test(lower)) {
|
|
467
|
-
const n = parseInt(lower.match(/\blast (\d+) days?\b/)[1], 10);
|
|
468
|
-
const d = new Date(today);
|
|
469
|
-
d.setDate(d.getDate() - n);
|
|
470
|
-
return { since: fmt(d), until: fmt(today) };
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Check for explicit YYYY-MM-DD dates in the query
|
|
474
|
-
const dateMatch = query.match(/\b(\d{4}-\d{2}-\d{2})\b/g);
|
|
475
|
-
if (dateMatch) {
|
|
476
|
-
if (dateMatch.length >= 2) {
|
|
477
|
-
const sorted = dateMatch.sort();
|
|
478
|
-
return { since: sorted[0], until: sorted[sorted.length - 1] };
|
|
479
|
-
}
|
|
480
|
-
return { since: dateMatch[0], until: dateMatch[0] };
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Parse natural language dates: "March 3", "March 3 and March 6",
|
|
484
|
-
// "between March 3 and March 6", "March 3-6", "March 3 to March 6"
|
|
485
|
-
const monthNames = {
|
|
486
|
-
january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
|
|
487
|
-
july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
|
|
488
|
-
jan: 0, feb: 1, mar: 2, apr: 3, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
489
|
-
};
|
|
490
|
-
const monthPattern = Object.keys(monthNames).join('|');
|
|
491
|
-
const naturalDates = [];
|
|
492
|
-
|
|
493
|
-
// "March 3-6" or "March 3 - 6" (range within same month)
|
|
494
|
-
const sameMonthRange = lower.match(new RegExp(`\\b(${monthPattern})\\s+(\\d{1,2})\\s*[-–—]\\s*(\\d{1,2})\\b`));
|
|
495
|
-
if (sameMonthRange) {
|
|
496
|
-
const month = monthNames[sameMonthRange[1]];
|
|
497
|
-
const year = resolveYear(today, month);
|
|
498
|
-
const d1 = new Date(year, month, parseInt(sameMonthRange[2], 10));
|
|
499
|
-
const d2 = new Date(year, month, parseInt(sameMonthRange[3], 10));
|
|
500
|
-
return { since: fmt(d1), until: fmt(d2) };
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Find all "Month Day" patterns
|
|
504
|
-
const monthDayRegex = new RegExp(`\\b(${monthPattern})\\s+(\\d{1,2})\\b`, 'g');
|
|
505
|
-
let m;
|
|
506
|
-
while ((m = monthDayRegex.exec(lower)) !== null) {
|
|
507
|
-
const month = monthNames[m[1]];
|
|
508
|
-
const day = parseInt(m[2], 10);
|
|
509
|
-
if (day >= 1 && day <= 31) {
|
|
510
|
-
const year = resolveYear(today, month);
|
|
511
|
-
naturalDates.push(new Date(year, month, day));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (naturalDates.length >= 2) {
|
|
516
|
-
naturalDates.sort((a, b) => a - b);
|
|
517
|
-
return { since: fmt(naturalDates[0]), until: fmt(naturalDates[naturalDates.length - 1]) };
|
|
518
|
-
}
|
|
519
|
-
if (naturalDates.length === 1) {
|
|
520
|
-
return { since: fmt(naturalDates[0]), until: fmt(naturalDates[0]) };
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return { since: null, until: null };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** Resolve year for a month reference — use current year, or previous year if the month is in the future. */
|
|
527
|
-
function resolveYear(today, month) {
|
|
528
|
-
return month > today.getMonth() ? today.getFullYear() - 1 : today.getFullYear();
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Words that are temporal references, not content keywords. */
|
|
532
|
-
const TEMPORAL_WORDS = new Set([
|
|
533
|
-
'today', 'yesterday', 'week', 'this', 'last', 'days', 'day',
|
|
534
|
-
'recent', 'recently', 'latest', 'current', 'now', 'between', 'and', 'to',
|
|
535
|
-
'january', 'february', 'march', 'april', 'may', 'june',
|
|
536
|
-
'july', 'august', 'september', 'october', 'november', 'december',
|
|
537
|
-
'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
|
538
|
-
]);
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Strip temporal and filler words from a query to get content keywords.
|
|
542
|
-
* Returns the cleaned query string.
|
|
543
|
-
*/
|
|
544
|
-
function stripTemporalWords(query) {
|
|
545
|
-
const filler = new Set(['what', 'happened', 'show', 'me', 'tell', 'about',
|
|
546
|
-
'are', 'is', 'the', 'any', 'all', 'from', 'in', 'on', 'for', 'of',
|
|
547
|
-
'how', 'many', 'much', 'were', 'was', 'there', 'can', 'you', 'do',
|
|
548
|
-
'did', 'has', 'have', 'been', 'get', 'give', 'list', 'count',
|
|
549
|
-
'journal', 'entries', 'entry', 'sessions', 'session', 'made']);
|
|
550
|
-
return query
|
|
551
|
-
.toLowerCase()
|
|
552
|
-
.split(/[\s\-_]+/)
|
|
553
|
-
.filter(w => w.length > 1 && !TEMPORAL_WORDS.has(w) && !filler.has(w))
|
|
554
|
-
.join(' ');
|
|
555
|
-
}
|
|
181
|
+
/** Tool definitions for the bot's LLM relay — mirrors MCP search_context + get_entry. */
|
|
182
|
+
const BOT_TOOLS = [
|
|
183
|
+
{
|
|
184
|
+
name: 'search_context',
|
|
185
|
+
description: 'Search team decision history. Use mode=browse with date filters for time-range queries ("what happened this week"). Use mode=semantic with a query for topical searches ("what did we decide about retry logic").',
|
|
186
|
+
input_schema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
query: { type: 'string', description: 'Search query (optional for browse mode)' },
|
|
190
|
+
since: { type: 'string', description: 'Start date filter (YYYY-MM-DD)' },
|
|
191
|
+
until: { type: 'string', description: 'End date filter (YYYY-MM-DD)' },
|
|
192
|
+
user: { type: 'string', description: 'Author slug filter (lowercase first name)' },
|
|
193
|
+
repo: { type: 'string', description: 'Repository name filter' },
|
|
194
|
+
source: { type: 'string', enum: ['journal', 'conversation', 'signal'], description: 'Entry source type filter' },
|
|
195
|
+
mode: { type: 'string', enum: ['semantic', 'browse'], description: 'Search strategy. semantic (default) uses embeddings. browse returns entries sorted by date.' },
|
|
196
|
+
limit: { type: 'number', description: 'Max results to return (default 10)' },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'get_entry',
|
|
202
|
+
description: 'Retrieve the full content of a specific entry by ID. Use IDs returned by search_context.',
|
|
203
|
+
input_schema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
id: { type: 'string', description: 'Entry ID from search_context results' },
|
|
207
|
+
},
|
|
208
|
+
required: ['id'],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
];
|
|
556
212
|
|
|
557
213
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
214
|
+
* Handle a bot query using LLM tool-use relay.
|
|
215
|
+
* Instead of parsing intent ourselves, we give Claude the user's question
|
|
216
|
+
* and let it decide which search_context/get_entry calls to make.
|
|
217
|
+
* @param {string} query - User's question
|
|
218
|
+
* @param {Object} config - Bot configuration from connectors.json
|
|
219
|
+
* @param {Array<{ role: string, text: string }>} [threadHistory] - Prior thread messages
|
|
220
|
+
* @returns {Promise<{ text: string }>}
|
|
561
221
|
*/
|
|
562
|
-
function
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
222
|
+
async function handleQuery(query, config, threadHistory) {
|
|
223
|
+
const llmConfig = config.llm || {};
|
|
224
|
+
const storePath = config.store_path;
|
|
225
|
+
const journalDir = config.journal_dir;
|
|
226
|
+
|
|
227
|
+
// Handle tool calls by calling content-store directly (same container, no HTTP round-trip)
|
|
228
|
+
async function handleToolCall(name, input) {
|
|
229
|
+
if (name === 'search_context') {
|
|
230
|
+
const opts = {
|
|
231
|
+
limit: input.limit || 10,
|
|
232
|
+
storePath,
|
|
233
|
+
journalDir,
|
|
234
|
+
};
|
|
235
|
+
if (input.since) opts.since = input.since;
|
|
236
|
+
if (input.until) opts.until = input.until;
|
|
237
|
+
if (input.user) opts.user = input.user;
|
|
238
|
+
if (input.repo) opts.repo = input.repo;
|
|
239
|
+
if (input.source) opts.source = input.source;
|
|
240
|
+
|
|
241
|
+
let results;
|
|
242
|
+
if (input.mode === 'browse' || (!input.query && !input.mode)) {
|
|
243
|
+
results = contentStore.queryMetadata(opts);
|
|
244
|
+
return results.slice(0, opts.limit).map(r => ({
|
|
245
|
+
id: r.id,
|
|
246
|
+
date: r.entry.date,
|
|
247
|
+
repo: r.entry.repo,
|
|
248
|
+
title: r.entry.title,
|
|
249
|
+
user: r.entry.user,
|
|
250
|
+
source: r.entry.source,
|
|
251
|
+
tags: r.entry.tags || [],
|
|
252
|
+
}));
|
|
577
253
|
}
|
|
578
|
-
} catch {}
|
|
579
|
-
}
|
|
580
254
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const candidate = personMatch[1].toLowerCase();
|
|
586
|
-
|
|
587
|
-
// Match against known members
|
|
588
|
-
for (const a of knownAuthors) {
|
|
589
|
-
if (a.slug === candidate || a.firstName === candidate || a.name === candidate) {
|
|
590
|
-
return a.slug;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// If no members dir, just use the extracted name as a slug
|
|
595
|
-
return candidate.length > 2 ? candidate : null;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
async function searchDecisionTrail(query, config) {
|
|
599
|
-
const searchOpts = {
|
|
600
|
-
limit: MAX_SEARCH_RESULTS,
|
|
601
|
-
};
|
|
602
|
-
if (config.store_path) {
|
|
603
|
-
searchOpts.storePath = config.store_path;
|
|
604
|
-
}
|
|
605
|
-
if (config.journal_dir) {
|
|
606
|
-
searchOpts.journalDir = config.journal_dir;
|
|
607
|
-
}
|
|
608
|
-
if (config._repoFilter && config._repoFilter.length > 0) {
|
|
609
|
-
searchOpts.repos = config._repoFilter;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// If query is about a specific person, filter to their entries
|
|
613
|
-
const authorFilter = resolveAuthorFromQuery(query, config);
|
|
614
|
-
if (authorFilter) searchOpts.user = authorFilter;
|
|
615
|
-
|
|
616
|
-
// Resolve temporal references to date filters
|
|
617
|
-
const dateFilters = resolveDateFilters(query);
|
|
618
|
-
if (dateFilters.since) searchOpts.since = dateFilters.since;
|
|
619
|
-
if (dateFilters.until) searchOpts.until = dateFilters.until;
|
|
620
|
-
|
|
621
|
-
// Strip temporal words to get content keywords
|
|
622
|
-
const contentQuery = stripTemporalWords(query);
|
|
623
|
-
|
|
624
|
-
// If query is purely temporal (no content keywords), browse by date
|
|
625
|
-
if (!contentQuery && (dateFilters.since || dateFilters.until)) {
|
|
626
|
-
const browseOpts = { ...searchOpts };
|
|
627
|
-
const all = contentStore.queryMetadata(browseOpts);
|
|
628
|
-
return all.slice(0, searchOpts.limit).map(r => ({ ...r, score: 1 }));
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Use content keywords for search (or original query if no temporal refs)
|
|
632
|
-
const searchQuery = contentQuery || query;
|
|
633
|
-
|
|
634
|
-
let results;
|
|
635
|
-
try {
|
|
636
|
-
results = await contentStore.searchJournals(searchQuery, searchOpts);
|
|
637
|
-
} catch {
|
|
638
|
-
// Semantic search failed — fall back to text search
|
|
639
|
-
results = contentStore.searchText(searchQuery, searchOpts);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// If semantic search returned empty, also try text search
|
|
643
|
-
if (!results || results.length === 0) {
|
|
644
|
-
results = contentStore.searchText(searchQuery, searchOpts);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// If text search returned results but none are journal entries (all signals/conversations),
|
|
648
|
-
// and we have date filters, also try metadata browse to find actual journal entries.
|
|
649
|
-
// This handles broad queries like "today's activity" where keyword search matches
|
|
650
|
-
// signal files but misses the journal entries the user actually wants.
|
|
651
|
-
if (results && results.length > 0 && (dateFilters.since || dateFilters.until)) {
|
|
652
|
-
const hasJournalResults = results.some(r => {
|
|
653
|
-
const source = r.entry?.source;
|
|
654
|
-
return !source || source === 'journal';
|
|
655
|
-
});
|
|
656
|
-
if (!hasJournalResults) {
|
|
657
|
-
const browseOpts = { ...searchOpts };
|
|
658
|
-
const metadataResults = contentStore.queryMetadata(browseOpts);
|
|
659
|
-
if (metadataResults.length > 0) {
|
|
660
|
-
// Prefer metadata browse (actual journal entries) over signal-only text search
|
|
661
|
-
results = metadataResults.slice(0, searchOpts.limit).map(r => ({ ...r, score: 1 }));
|
|
255
|
+
try {
|
|
256
|
+
results = await contentStore.searchJournals(input.query || '', opts);
|
|
257
|
+
} catch {
|
|
258
|
+
results = contentStore.queryMetadata(opts);
|
|
662
259
|
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
260
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
261
|
+
return (results || []).slice(0, opts.limit).map(r => ({
|
|
262
|
+
id: r.id,
|
|
263
|
+
score: r.score != null ? Math.round(r.score * 1000) / 1000 : null,
|
|
264
|
+
date: r.entry.date,
|
|
265
|
+
repo: r.entry.repo,
|
|
266
|
+
title: r.entry.title,
|
|
267
|
+
user: r.entry.user,
|
|
268
|
+
source: r.entry.source,
|
|
269
|
+
tags: r.entry.tags || [],
|
|
270
|
+
}));
|
|
672
271
|
}
|
|
673
272
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
try {
|
|
678
|
-
results = await contentStore.searchJournals(searchQuery, searchOpts);
|
|
679
|
-
} catch {
|
|
680
|
-
results = contentStore.searchText(searchQuery, searchOpts);
|
|
681
|
-
}
|
|
682
|
-
if (!results || results.length === 0) {
|
|
683
|
-
results = contentStore.searchText(searchQuery, searchOpts);
|
|
273
|
+
if (name === 'get_entry') {
|
|
274
|
+
const content = contentStore.getEntryContent(input.id, { storePath, journalDir });
|
|
275
|
+
return content || { error: `Entry not found: ${input.id}` };
|
|
684
276
|
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Deduplicate: prefer distilled entries over their raw sources
|
|
688
|
-
return contentStore.deduplicateResults(results || []);
|
|
689
|
-
}
|
|
690
277
|
|
|
691
|
-
|
|
692
|
-
* Synthesize an answer from search results using the LLM.
|
|
693
|
-
* If synthesis fails, returns a formatted list of raw results.
|
|
694
|
-
* @param {string} query - User's question
|
|
695
|
-
* @param {Array<{ id: string, score: number, entry: Object }>} results - Search results
|
|
696
|
-
* @param {Object} llmConfig - LLM configuration
|
|
697
|
-
* @returns {Promise<string>} - Synthesized answer in markdown
|
|
698
|
-
*/
|
|
699
|
-
async function synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory) {
|
|
700
|
-
if (results.length === 0) {
|
|
701
|
-
return 'No matching entries found in the decision trail. Try rephrasing your question or indexing more journals with `wayfind index-journals`.';
|
|
278
|
+
return { error: `Unknown tool: ${name}` };
|
|
702
279
|
}
|
|
703
280
|
|
|
704
|
-
// Build
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
711
|
-
// Log why content retrieval failed for debugging
|
|
712
|
-
console.log(`[content-miss] id=${r.id} date=${r.entry.date} repo=${r.entry.repo} title=${r.entry.title} opts=${JSON.stringify(contentOpts)}`);
|
|
713
|
-
// Fall back to metadata-only summary
|
|
714
|
-
const drift = r.entry.drifted ? ' [DRIFT]' : '';
|
|
715
|
-
contextParts.push(
|
|
716
|
-
`---\n${r.entry.date} | ${r.entry.repo} | ${r.entry.title}${drift}\n` +
|
|
717
|
-
`Tags: ${(r.entry.tags || []).join(', ')}`
|
|
718
|
-
);
|
|
719
|
-
}
|
|
281
|
+
// Build user message with thread context if available
|
|
282
|
+
let userMessage = query;
|
|
283
|
+
if (threadHistory && threadHistory.length > 0) {
|
|
284
|
+
const context = threadHistory
|
|
285
|
+
.map(t => `${t.role === 'bot' ? 'Wayfind' : 'User'}: ${t.text}`)
|
|
286
|
+
.join('\n');
|
|
287
|
+
userMessage = `Previous conversation:\n${context}\n\nNew question: ${query}`;
|
|
720
288
|
}
|
|
721
|
-
const context = contextParts.join('\n\n');
|
|
722
|
-
|
|
723
|
-
const threadContext = formatThreadContext(threadHistory);
|
|
724
|
-
const userContent =
|
|
725
|
-
(threadContext ? `${threadContext}\n\n` : '') +
|
|
726
|
-
`Question: ${query}\n\n` +
|
|
727
|
-
`Here are the most relevant entries from the team's decision trail:\n\n${context}`;
|
|
728
289
|
|
|
729
290
|
try {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Format raw search results as a readable list.
|
|
740
|
-
* Used as a fallback when LLM synthesis fails.
|
|
741
|
-
* @param {Array<{ id: string, score: number, entry: Object }>} results
|
|
742
|
-
* @returns {string}
|
|
743
|
-
*/
|
|
744
|
-
function formatRawResults(results) {
|
|
745
|
-
if (results.length === 0) return 'No results found.';
|
|
746
|
-
|
|
747
|
-
const lines = ['Here are the most relevant entries I found:\n'];
|
|
748
|
-
for (const r of results) {
|
|
749
|
-
const drift = r.entry.drifted ? ' [DRIFT]' : '';
|
|
750
|
-
lines.push(`- **${r.entry.date}** | ${r.entry.repo} | ${r.entry.title}${drift}`);
|
|
751
|
-
}
|
|
752
|
-
lines.push('\n_LLM synthesis was unavailable. These are raw search results._');
|
|
753
|
-
return lines.join('\n');
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Format a synthesized answer for Slack.
|
|
758
|
-
* Converts markdown to Slack mrkdwn and adds a sources section.
|
|
759
|
-
* @param {string} answer - Markdown answer text
|
|
760
|
-
* @param {Array<{ id: string, score: number, entry: Object }>} results - Source entries
|
|
761
|
-
* @returns {string} - Slack mrkdwn formatted response
|
|
762
|
-
*/
|
|
763
|
-
function formatResponse(answer, results) {
|
|
764
|
-
let text = markdownToMrkdwn(answer);
|
|
765
|
-
|
|
766
|
-
// Add sources section
|
|
767
|
-
if (results && results.length > 0) {
|
|
768
|
-
const sources = results.map((r) => {
|
|
769
|
-
const drift = r.entry.drifted ? ' [drift]' : '';
|
|
770
|
-
return `${r.entry.date} | ${r.entry.repo} | ${r.entry.title}${drift}`;
|
|
771
|
-
});
|
|
772
|
-
text += '\n\n_Sources:_\n' + sources.map((s) => `> ${s}`).join('\n');
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
return text;
|
|
776
|
-
}
|
|
291
|
+
const answer = await llm.callWithTools(
|
|
292
|
+
{ ...llmConfig, provider: 'anthropic', api_key_env: llmConfig.api_key_env || 'ANTHROPIC_API_KEY', model: process.env.WAYFIND_BOT_MODEL || llmConfig.query_model || llmConfig.model || 'claude-haiku-4-5-20251001' },
|
|
293
|
+
buildBotSystemPrompt(),
|
|
294
|
+
userMessage,
|
|
295
|
+
BOT_TOOLS,
|
|
296
|
+
handleToolCall
|
|
297
|
+
);
|
|
777
298
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
|
|
784
|
-
if (teamDir) {
|
|
785
|
-
const dir = path.join(teamDir, 'prompts');
|
|
786
|
-
if (fs.existsSync(dir)) return dir;
|
|
299
|
+
const text = markdownToMrkdwn(answer);
|
|
300
|
+
return { text };
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(`Tool-use query failed: ${err.message}`);
|
|
303
|
+
return { text: `I had trouble answering that question: ${err.message}. Try asking in Claude Code or claude.ai where you can use the Wayfind MCP tools directly.` };
|
|
787
304
|
}
|
|
788
|
-
return null;
|
|
789
305
|
}
|
|
790
306
|
|
|
791
|
-
// ──
|
|
307
|
+
// ── Bot lifecycle ────────────────────────────────────────────────────────────
|
|
792
308
|
|
|
793
309
|
/**
|
|
794
310
|
* Resolve the team-context members directory from environment or config.
|
|
@@ -804,423 +320,6 @@ function resolveMembersDir(config) {
|
|
|
804
320
|
return null;
|
|
805
321
|
}
|
|
806
322
|
|
|
807
|
-
/**
|
|
808
|
-
* Handle direct bot commands that don't require LLM synthesis.
|
|
809
|
-
* Returns formatted text if the query matches a command, or null to fall through.
|
|
810
|
-
* @param {string} query - User's question
|
|
811
|
-
* @param {Object} config - Bot config
|
|
812
|
-
* @returns {string|null}
|
|
813
|
-
*/
|
|
814
|
-
function handleDirectCommand(query, config) {
|
|
815
|
-
const q = query.trim().toLowerCase();
|
|
816
|
-
|
|
817
|
-
// help
|
|
818
|
-
if (q === 'help' || q === '?' || q === 'commands') {
|
|
819
|
-
return handleHelp();
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// version
|
|
823
|
-
if (q === 'version' || q === 'what version' || q === 'what version are you') {
|
|
824
|
-
return handleVersion();
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// members
|
|
828
|
-
if (q === 'members' || q === 'team members' || q === 'who is on the team' ||
|
|
829
|
-
/^(?:show|list|get)\s+(?:team\s+)?members$/i.test(query.trim()) ||
|
|
830
|
-
/^(?:what|which)\s+version\s+is\s+\w+\s+(?:on|running|using)/i.test(query.trim()) ||
|
|
831
|
-
/^(?:who|what)\s+version/i.test(query.trim())) {
|
|
832
|
-
return handleMembers(config);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// insights
|
|
836
|
-
if (q === 'insights' || q === 'journal insights' || q === 'show insights' ||
|
|
837
|
-
/^(?:show|get)\s+(?:journal\s+)?insights$/i.test(query.trim())) {
|
|
838
|
-
return handleInsights(config);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// digest scores / feedback
|
|
842
|
-
if (q === 'digest scores' || q === 'scores' || q === 'digest feedback' ||
|
|
843
|
-
/^(?:show|get)\s+(?:digest\s+)?(?:scores|feedback)$/i.test(query.trim())) {
|
|
844
|
-
return handleDigestScores(config);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// signals
|
|
848
|
-
if (q === 'signals' || q === 'show signals' || q === 'signal channels' ||
|
|
849
|
-
/^(?:show|list|get)\s+(?:signal\s+)?channels$/i.test(query.trim())) {
|
|
850
|
-
return handleSignalChannels();
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
return null;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/** Bot help command — lists available capabilities. */
|
|
857
|
-
function handleHelp() {
|
|
858
|
-
return `*Wayfind Bot — Commands*
|
|
859
|
-
|
|
860
|
-
You can ask me anything about your team's decision trail, or use these commands:
|
|
861
|
-
|
|
862
|
-
*Queries*
|
|
863
|
-
• Ask any question — I'll search journals and synthesize an answer
|
|
864
|
-
• Use dates naturally: "what happened yesterday", "this week", "March 3-6"
|
|
865
|
-
• Thread replies continue the conversation without re-mentioning me
|
|
866
|
-
|
|
867
|
-
*Commands*
|
|
868
|
-
• \`help\` — this message
|
|
869
|
-
• \`version\` — bot version
|
|
870
|
-
• \`members\` — team members, versions, and activity
|
|
871
|
-
• \`insights\` — journal analytics (session count, drift rate, repo activity)
|
|
872
|
-
• \`digest scores\` — digest feedback and reactions
|
|
873
|
-
• \`signals\` — configured signal channels
|
|
874
|
-
• \`onboard <repo>\` — generate an onboarding context pack
|
|
875
|
-
• \`reindex\` — re-index all sources
|
|
876
|
-
• \`prompts\` — list shared team prompts
|
|
877
|
-
• \`show <name> prompt\` — show a specific prompt`;
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
/** Bot version command. */
|
|
881
|
-
function handleVersion() {
|
|
882
|
-
const version = telemetry.getWayfindVersion();
|
|
883
|
-
return `Wayfind v${version}`;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/** Bot members command — reads member profiles from team-context repo. */
|
|
887
|
-
function handleMembers(config) {
|
|
888
|
-
const membersDir = resolveMembersDir(config);
|
|
889
|
-
if (!membersDir) {
|
|
890
|
-
return 'No team members directory found. Set `TEAM_CONTEXT_TEAM_CONTEXT_DIR` or configure `team_context_dir` in the bot config.';
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
let files;
|
|
894
|
-
try {
|
|
895
|
-
files = fs.readdirSync(membersDir).filter(f => f.endsWith('.json'));
|
|
896
|
-
} catch {
|
|
897
|
-
return 'Could not read team members directory.';
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (files.length === 0) {
|
|
901
|
-
return 'No team members found. Members register via `wayfind whoami --setup`.';
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
const lines = ['*Team Members*\n'];
|
|
905
|
-
for (const file of files.sort()) {
|
|
906
|
-
try {
|
|
907
|
-
const member = JSON.parse(fs.readFileSync(path.join(membersDir, file), 'utf8'));
|
|
908
|
-
const name = member.name || file.replace('.json', '');
|
|
909
|
-
const version = member.wayfind_version ? `v${member.wayfind_version}` : '?';
|
|
910
|
-
const lastActive = member.last_active ? member.last_active.slice(0, 10) : '—';
|
|
911
|
-
const personas = (member.personas || []).join(', ') || '—';
|
|
912
|
-
const slackId = member.slack_id ? `<@${member.slack_id}>` : '';
|
|
913
|
-
lines.push(`• *${name}* ${slackId} — ${version} — active: ${lastActive} — personas: ${personas}`);
|
|
914
|
-
} catch {
|
|
915
|
-
// Skip unreadable files
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
return lines.join('\n');
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/** Bot insights command — journal analytics. */
|
|
923
|
-
function handleInsights(config) {
|
|
924
|
-
const opts = {};
|
|
925
|
-
if (config.store_path) opts.storePath = config.store_path;
|
|
926
|
-
|
|
927
|
-
const insights = contentStore.extractInsights(opts);
|
|
928
|
-
|
|
929
|
-
const lines = ['*Journal Insights*\n'];
|
|
930
|
-
lines.push(`Total sessions: ${insights.totalSessions}`);
|
|
931
|
-
lines.push(`Drift rate: ${insights.driftRate}%`);
|
|
932
|
-
|
|
933
|
-
if (Object.keys(insights.repoActivity).length > 0) {
|
|
934
|
-
lines.push('\n*Repo activity:*');
|
|
935
|
-
const sorted = Object.entries(insights.repoActivity).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
936
|
-
for (const [repo, count] of sorted) {
|
|
937
|
-
lines.push(` ${repo} — ${count} session(s)`);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
if (Object.keys(insights.tagFrequency).length > 0) {
|
|
942
|
-
lines.push('\n*Top tags:*');
|
|
943
|
-
const sorted = Object.entries(insights.tagFrequency).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
944
|
-
for (const [tag, count] of sorted) {
|
|
945
|
-
lines.push(` ${tag} — ${count}`);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
if (insights.timeline.length > 0) {
|
|
950
|
-
lines.push('\n*Recent activity (last 7 days):*');
|
|
951
|
-
const recent = insights.timeline.slice(-7);
|
|
952
|
-
for (const { date, sessions } of recent) {
|
|
953
|
-
const bar = '\u2588'.repeat(Math.min(sessions, 20));
|
|
954
|
-
lines.push(` ${date} ${bar} ${sessions}`);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
return lines.join('\n');
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/** Bot digest scores command — feedback and reactions. */
|
|
962
|
-
function handleDigestScores(config) {
|
|
963
|
-
const opts = { limit: 10 };
|
|
964
|
-
if (config.store_path) opts.storePath = config.store_path;
|
|
965
|
-
|
|
966
|
-
const feedback = contentStore.getDigestFeedback(opts);
|
|
967
|
-
if (feedback.length === 0) {
|
|
968
|
-
return 'No digest feedback yet. Reactions on digest messages will appear here.';
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const lines = ['*Digest Feedback*\n'];
|
|
972
|
-
for (const d of feedback) {
|
|
973
|
-
const reactions = Object.entries(d.reactions)
|
|
974
|
-
.map(([emoji, count]) => `:${emoji}: \u00d7 ${count}`)
|
|
975
|
-
.join(' ');
|
|
976
|
-
lines.push(`• ${d.date} (${d.persona}): ${reactions || 'no reactions'} — total: ${d.totalReactions}`);
|
|
977
|
-
if (d.comments.length > 0) {
|
|
978
|
-
for (const c of d.comments) {
|
|
979
|
-
lines.push(` \u2192 "${c.text}"`);
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
return lines.join('\n');
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/** Bot signals command — show configured signal channels. */
|
|
988
|
-
function handleSignalChannels() {
|
|
989
|
-
const signalsDir = process.env.TEAM_CONTEXT_SIGNALS_DIR || path.join(
|
|
990
|
-
process.env.HOME || process.env.USERPROFILE || '',
|
|
991
|
-
'.claude', 'team-context', 'signals'
|
|
992
|
-
);
|
|
993
|
-
|
|
994
|
-
if (!fs.existsSync(signalsDir)) {
|
|
995
|
-
return 'No signal channels configured. Set up channels with `wayfind pull <channel> --configure`.';
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
let channels;
|
|
999
|
-
try {
|
|
1000
|
-
channels = fs.readdirSync(signalsDir).filter(f => {
|
|
1001
|
-
try { return fs.statSync(path.join(signalsDir, f)).isDirectory(); } catch { return false; }
|
|
1002
|
-
});
|
|
1003
|
-
} catch {
|
|
1004
|
-
return 'Could not read signals directory.';
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (channels.length === 0) {
|
|
1008
|
-
return 'No signal channels found. Set up channels with `wayfind pull <channel> --configure`.';
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const lines = ['*Signal Channels*\n'];
|
|
1012
|
-
for (const ch of channels.sort()) {
|
|
1013
|
-
const chDir = path.join(signalsDir, ch);
|
|
1014
|
-
let fileCount = 0;
|
|
1015
|
-
let latestFile = null;
|
|
1016
|
-
try {
|
|
1017
|
-
const files = fs.readdirSync(chDir).filter(f => f.endsWith('.md')).sort();
|
|
1018
|
-
fileCount = files.length;
|
|
1019
|
-
latestFile = files.length > 0 ? files[files.length - 1].replace('.md', '') : null;
|
|
1020
|
-
} catch { /* skip */ }
|
|
1021
|
-
lines.push(`• *${ch}* — ${fileCount} file(s)${latestFile ? ` — latest: ${latestFile}` : ''}`);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
return lines.join('\n');
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
/**
|
|
1028
|
-
* Handle prompt-related queries directly from the filesystem.
|
|
1029
|
-
* Returns formatted text if the query matches a prompt pattern, or null to fall through.
|
|
1030
|
-
* @param {string} query - User's question
|
|
1031
|
-
* @returns {string|null}
|
|
1032
|
-
*/
|
|
1033
|
-
function handlePromptQuery(query) {
|
|
1034
|
-
const promptMatch = query.match(/(?:show|get|find|what(?:'s| is)|list)\s+(?:the\s+)?(?:prompts?|(?:our|team)\s+prompts?)(?:\s+(?:for|about|called|named)\s+(.+))?/i)
|
|
1035
|
-
|| query.match(/^prompts?$/i)
|
|
1036
|
-
|| query.match(/prompt\s+(?:for|about|called|named)\s+(.+)/i);
|
|
1037
|
-
|
|
1038
|
-
if (!promptMatch) return null;
|
|
1039
|
-
|
|
1040
|
-
const promptsDir = resolvePromptsDir();
|
|
1041
|
-
if (!promptsDir) return null;
|
|
1042
|
-
|
|
1043
|
-
const files = fs.readdirSync(promptsDir)
|
|
1044
|
-
.filter(f => f.endsWith('.md') && f.toLowerCase() !== 'readme.md')
|
|
1045
|
-
.sort();
|
|
1046
|
-
|
|
1047
|
-
const searchTerm = (promptMatch[1] || '').trim();
|
|
1048
|
-
|
|
1049
|
-
if (searchTerm) {
|
|
1050
|
-
// Find specific prompt
|
|
1051
|
-
const match = files.find(f =>
|
|
1052
|
-
f.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
1053
|
-
f.replace('.md', '').toLowerCase() === searchTerm.toLowerCase()
|
|
1054
|
-
);
|
|
1055
|
-
if (match) {
|
|
1056
|
-
const content = fs.readFileSync(path.join(promptsDir, match), 'utf8');
|
|
1057
|
-
return `*${match.replace('.md', '')}*\n\n${content}`;
|
|
1058
|
-
}
|
|
1059
|
-
return `No prompt matching "${searchTerm}" found. Available: ${files.map(f => f.replace('.md', '')).join(', ')}`;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// List all prompts
|
|
1063
|
-
if (files.length === 0) {
|
|
1064
|
-
return 'No prompts yet. Add .md files to your team-context/prompts/ directory.';
|
|
1065
|
-
}
|
|
1066
|
-
const list = files.map(f => {
|
|
1067
|
-
const content = fs.readFileSync(path.join(promptsDir, f), 'utf8');
|
|
1068
|
-
const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim() || '';
|
|
1069
|
-
return `• *${f.replace('.md', '')}*${firstLine ? ` — ${firstLine}` : ''}`;
|
|
1070
|
-
}).join('\n');
|
|
1071
|
-
return `*Team Prompts*\n\n${list}\n\nAsk me to "show <name> prompt" to see the full text.`;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Core query handler. Runs the full pipeline: search, synthesize, format.
|
|
1076
|
-
* Exported for independent testing without a Slack connection.
|
|
1077
|
-
* @param {string} query - User's question
|
|
1078
|
-
* @param {Object} config - Bot configuration from connectors.json
|
|
1079
|
-
* @returns {Promise<{ text: string, results: Array }>}
|
|
1080
|
-
*/
|
|
1081
|
-
async function handleQuery(query, config, threadHistory) {
|
|
1082
|
-
const queryStart = Date.now();
|
|
1083
|
-
|
|
1084
|
-
// Check direct commands first — no LLM needed
|
|
1085
|
-
const directResult = handleDirectCommand(query, config);
|
|
1086
|
-
if (directResult) {
|
|
1087
|
-
return { text: directResult, results: [], _directCommand: true };
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// Check if this is a prompt query — answer directly without LLM
|
|
1091
|
-
const promptResult = handlePromptQuery(query);
|
|
1092
|
-
if (promptResult) {
|
|
1093
|
-
return { text: promptResult, results: [], _promptQuery: true };
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// On-demand feature map reload — user signals they just updated features
|
|
1097
|
-
if (/\b(?:just\s+(?:added|updated|set|ran)\s+(?:features?|wayfind\s+features?))\b/i.test(query) ||
|
|
1098
|
-
/\bwayfind\s+features\s+(?:add|set|describe)\b/i.test(query)) {
|
|
1099
|
-
loadFeatureMap(config);
|
|
1100
|
-
const count = featureMap ? Object.keys(featureMap).length : 0;
|
|
1101
|
-
return {
|
|
1102
|
-
text: count > 0
|
|
1103
|
-
? `Feature map reloaded. I now know about ${count} repo(s): ${Object.keys(featureMap).join(', ')}`
|
|
1104
|
-
: 'Feature map reloaded, but no repos are configured yet. Run `wayfind features add` in a repo.',
|
|
1105
|
-
results: [],
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const intent = classifyIntent(query);
|
|
1110
|
-
const llmConfig = config.llm || {};
|
|
1111
|
-
const contentOpts = {};
|
|
1112
|
-
if (config.store_path) contentOpts.storePath = config.store_path;
|
|
1113
|
-
if (config.journal_dir) contentOpts.journalDir = config.journal_dir;
|
|
1114
|
-
|
|
1115
|
-
// Route based on intent
|
|
1116
|
-
if (intent.type === 'signals' || intent.type === 'mixed') {
|
|
1117
|
-
const signals = searchSignals(intent.channel);
|
|
1118
|
-
|
|
1119
|
-
if (signals.available) {
|
|
1120
|
-
// Synthesize from signal data
|
|
1121
|
-
const threadContext = formatThreadContext(threadHistory);
|
|
1122
|
-
const userContent =
|
|
1123
|
-
(threadContext ? `${threadContext}\n\n` : '') +
|
|
1124
|
-
`Question: ${query}\n\n` +
|
|
1125
|
-
`Here is the latest ${intent.channel} signal data:\n\n${signals.content}`;
|
|
1126
|
-
|
|
1127
|
-
let answer;
|
|
1128
|
-
try {
|
|
1129
|
-
answer = await llm.call(llmConfig, buildSignalSynthesisPrompt(intent.channel), userContent);
|
|
1130
|
-
} catch (err) {
|
|
1131
|
-
console.error(`LLM synthesis failed for signals: ${err.message}`);
|
|
1132
|
-
answer = `Here is the raw ${intent.channel} signal data:\n\n${signals.content}`;
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
// For mixed intent, also search the decision trail and append
|
|
1136
|
-
if (intent.type === 'mixed') {
|
|
1137
|
-
const results = await searchDecisionTrail(query, config);
|
|
1138
|
-
if (results.length > 0) {
|
|
1139
|
-
const trailAnswer = await synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory);
|
|
1140
|
-
answer += '\n\n---\n\n**From the engineering decision trail:**\n\n' + trailAnswer;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
const text = markdownToMrkdwn(answer);
|
|
1145
|
-
return { text, results: [] };
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Signal data not available from files — check indexed entries
|
|
1149
|
-
if (intent.type === 'signals') {
|
|
1150
|
-
const indexedResults = await searchDecisionTrail(query, config);
|
|
1151
|
-
const signalEntries = indexedResults.filter(r => r.entry.repo && r.entry.repo.startsWith('signals/'));
|
|
1152
|
-
if (signalEntries.length > 0) {
|
|
1153
|
-
const answer = await synthesizeAnswer(query, signalEntries, llmConfig, contentOpts, threadHistory);
|
|
1154
|
-
const text = formatResponse(answer, signalEntries);
|
|
1155
|
-
return { text, results: signalEntries };
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
const text = `I don't have ${intent.channel} signal data available. ` +
|
|
1159
|
-
`The ${intent.channel} connector may not be configured, or no signals have been pulled yet.\n\n` +
|
|
1160
|
-
`To set it up: \`wayfind pull ${intent.channel} --configure\`\n` +
|
|
1161
|
-
`To pull now: \`wayfind pull ${intent.channel}\``;
|
|
1162
|
-
return { text, results: [] };
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// Mixed but no signals — fall through to engineering-only
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
// Engineering intent (or mixed fallback)
|
|
1169
|
-
// For follow-up queries in threads, enrich the search with date context from prior exchanges.
|
|
1170
|
-
// If the current query has no date references, inherit them from the thread.
|
|
1171
|
-
let searchQuery = query;
|
|
1172
|
-
const currentDates = resolveDateFilters(query);
|
|
1173
|
-
if (!currentDates.since && threadHistory && threadHistory.length > 0) {
|
|
1174
|
-
// Combine all prior thread text and check for date references
|
|
1175
|
-
const priorText = threadHistory
|
|
1176
|
-
.flatMap(ex => [ex.question, ex.answer])
|
|
1177
|
-
.filter(Boolean)
|
|
1178
|
-
.join(' ');
|
|
1179
|
-
const priorDates = resolveDateFilters(priorText);
|
|
1180
|
-
if (priorDates.since) {
|
|
1181
|
-
// Append resolved dates so searchDecisionTrail picks them up
|
|
1182
|
-
searchQuery = `${query} ${priorDates.since}` +
|
|
1183
|
-
(priorDates.until !== priorDates.since ? ` ${priorDates.until}` : '');
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
// Feature map routing: if a map exists, ask Haiku which repos are relevant
|
|
1188
|
-
let repoFilter = null;
|
|
1189
|
-
if (featureMap && Object.keys(featureMap).length > 0) {
|
|
1190
|
-
const routedRepos = await routeQueryToRepos(query, featureMap, llmConfig);
|
|
1191
|
-
if (routedRepos !== null) {
|
|
1192
|
-
if (routedRepos.length === 0) {
|
|
1193
|
-
return {
|
|
1194
|
-
text: "I couldn't determine which repositories are relevant to your question. If this seems wrong, run `wayfind features add` in the relevant repo(s) and try again.",
|
|
1195
|
-
results: [],
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
repoFilter = routedRepos;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
const searchConfig = repoFilter
|
|
1203
|
-
? { ...config, _repoFilter: repoFilter }
|
|
1204
|
-
: config;
|
|
1205
|
-
|
|
1206
|
-
const results = await searchDecisionTrail(searchQuery, searchConfig);
|
|
1207
|
-
const answer = await synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory);
|
|
1208
|
-
const text = formatResponse(answer, results);
|
|
1209
|
-
return {
|
|
1210
|
-
text,
|
|
1211
|
-
results,
|
|
1212
|
-
_telemetry: {
|
|
1213
|
-
intent_type: intent.type,
|
|
1214
|
-
has_thread_history: !!(threadHistory && threadHistory.length),
|
|
1215
|
-
result_count: results.length,
|
|
1216
|
-
response_length: text.length,
|
|
1217
|
-
duration_ms: Date.now() - queryStart,
|
|
1218
|
-
},
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// ── Bot lifecycle ────────────────────────────────────────────────────────────
|
|
1223
|
-
|
|
1224
323
|
/**
|
|
1225
324
|
* Start the Slack bot using Socket Mode.
|
|
1226
325
|
* Connects to Slack, listens for @mentions, and runs the query pipeline.
|
|
@@ -1283,12 +382,6 @@ async function start(config) {
|
|
|
1283
382
|
const teamContextDir = config.team_context_dir || process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || null;
|
|
1284
383
|
const membersDir = teamContextDir ? path.join(teamContextDir, 'members') : null;
|
|
1285
384
|
|
|
1286
|
-
// Load feature map for repo routing
|
|
1287
|
-
loadFeatureMap(config);
|
|
1288
|
-
if (featureMap && Object.keys(featureMap).length > 0) {
|
|
1289
|
-
console.log(`Loaded feature map: ${Object.keys(featureMap).length} repo(s)`);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
385
|
// Handle @mentions
|
|
1293
386
|
app.event('app_mention', async ({ event, client }) => {
|
|
1294
387
|
const channel = event.channel;
|
|
@@ -1380,7 +473,7 @@ async function start(config) {
|
|
|
1380
473
|
return;
|
|
1381
474
|
}
|
|
1382
475
|
|
|
1383
|
-
// Fetch
|
|
476
|
+
// Fetch thread history for conversation context
|
|
1384
477
|
let threadHistory = [];
|
|
1385
478
|
if (event.thread_ts && botUserId) {
|
|
1386
479
|
threadHistory = await fetchThreadHistory(client, channel, event.thread_ts, botUserId, event.ts);
|
|
@@ -1388,13 +481,7 @@ async function start(config) {
|
|
|
1388
481
|
|
|
1389
482
|
const result = await handleQuery(query, config, threadHistory);
|
|
1390
483
|
const authorSlug = telemetry.resolveSlackUser(event.user, membersDir);
|
|
1391
|
-
|
|
1392
|
-
telemetry.capture('bot_direct_command', { query: query.toLowerCase().slice(0, 50) }, authorSlug);
|
|
1393
|
-
} else if (result._promptQuery) {
|
|
1394
|
-
telemetry.capture('bot_prompt_query', { found: true }, authorSlug);
|
|
1395
|
-
} else if (result._telemetry) {
|
|
1396
|
-
telemetry.capture('bot_query', result._telemetry, authorSlug);
|
|
1397
|
-
}
|
|
484
|
+
telemetry.capture('bot_query', { response_length: result.text.length }, authorSlug);
|
|
1398
485
|
|
|
1399
486
|
// Split long responses into chunks
|
|
1400
487
|
const chunks = chunkMessage(result.text, MAX_RESPONSE_LENGTH);
|
|
@@ -1479,13 +566,7 @@ async function start(config) {
|
|
|
1479
566
|
const threadHistory = await fetchThreadHistory(client, channel, threadTs, botUserId, event.ts);
|
|
1480
567
|
const result = await handleQuery(query, config, threadHistory);
|
|
1481
568
|
const authorSlug = telemetry.resolveSlackUser(event.user, membersDir);
|
|
1482
|
-
|
|
1483
|
-
telemetry.capture('bot_direct_command', { query: query.toLowerCase().slice(0, 50) }, authorSlug);
|
|
1484
|
-
} else if (result._promptQuery) {
|
|
1485
|
-
telemetry.capture('bot_prompt_query', { found: true }, authorSlug);
|
|
1486
|
-
} else if (result._telemetry) {
|
|
1487
|
-
telemetry.capture('bot_query', result._telemetry, authorSlug);
|
|
1488
|
-
}
|
|
569
|
+
telemetry.capture('bot_query', { response_length: result.text.length }, authorSlug);
|
|
1489
570
|
|
|
1490
571
|
const chunks = chunkMessage(result.text, MAX_RESPONSE_LENGTH);
|
|
1491
572
|
for (const chunk of chunks) {
|
|
@@ -1694,12 +775,7 @@ module.exports = {
|
|
|
1694
775
|
start,
|
|
1695
776
|
getConnectionStatus,
|
|
1696
777
|
handleQuery,
|
|
1697
|
-
handleDirectCommand,
|
|
1698
|
-
formatResponse,
|
|
1699
778
|
extractQuery,
|
|
1700
779
|
chunkMessage,
|
|
1701
780
|
fetchThreadHistory,
|
|
1702
|
-
formatThreadContext,
|
|
1703
|
-
classifyIntent,
|
|
1704
|
-
searchSignals,
|
|
1705
781
|
};
|