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/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 prior exchanges from a Slack thread for conversation context.
190
- * Returns the first exchange (topic anchor) plus the most recent N-1 exchanges.
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} currentEventTs - Current message ts (excluded from history)
196
- * @returns {Promise<Array<{ question: string, answer: string }>>}
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, currentEventTs) {
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: 200,
116
+ limit: 20,
205
117
  });
206
- messages = result.messages || [];
207
- } catch (err) {
208
- console.error(`Failed to fetch thread history: ${err.message}`);
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
- // ── Intent classification ────────────────────────────────────────────────────
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 a system prompt for signal-based questions. */
409
- function buildSignalSynthesisPrompt(channel) {
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. You answer questions about business signals and trends using data pulled from ${channel}.
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
- - Lead with the answer. Summarize trends, patterns, and notable items.
417
- - Be concise and specific. Under 500 words.
418
- - Use the signal data provided it contains volume, tags, topics, and open items.
419
- - If the data covers a different time period than what was asked, say so clearly.
420
- - If the signal data is empty or unavailable, say so honestly.
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
- * Search the content store for entries matching the query.
427
- * Tries semantic search first, falls back to text search.
428
- * @param {string} query - Search query
429
- * @param {Object} config - Bot config (may contain store_path)
430
- * @returns {Promise<Array<{ id: string, score: number, entry: Object }>>}
431
- */
432
- /**
433
- * Resolve temporal references in a query to {since, until} date filters.
434
- * Returns null values for fields that aren't constrained.
435
- */
436
- function resolveDateFilters(query) {
437
- const today = new Date();
438
- const fmt = (d) => d.toISOString().slice(0, 10);
439
- const lower = query.toLowerCase();
440
-
441
- if (/\btoday\b/.test(lower)) {
442
- return { since: fmt(today), until: fmt(today) };
443
- }
444
- if (/\byesterday\b/.test(lower)) {
445
- const d = new Date(today);
446
- d.setDate(d.getDate() - 1);
447
- return { since: fmt(d), until: fmt(d) };
448
- }
449
- if (/\bthis week\b/.test(lower)) {
450
- const d = new Date(today);
451
- const day = d.getDay();
452
- d.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); // Monday
453
- return { since: fmt(d), until: fmt(today) };
454
- }
455
- if (/\blast week\b/.test(lower)) {
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
- * Detect a person's name in a query and resolve it to an author slug.
559
- * Looks in members/ profiles for a name match. Falls back to naive first-name match.
560
- * Returns lowercase slug or null.
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 resolveAuthorFromQuery(query, config) {
563
- const membersDir = resolveMembersDir(config);
564
- const knownAuthors = [];
565
-
566
- if (membersDir) {
567
- try {
568
- const files = fs.readdirSync(membersDir).filter(f => f.endsWith('.json'));
569
- for (const file of files) {
570
- try {
571
- const m = JSON.parse(fs.readFileSync(path.join(membersDir, file), 'utf8'));
572
- const slug = file.replace('.json', '').toLowerCase();
573
- const name = (m.name || '').toLowerCase();
574
- const firstName = name.split(/\s+/)[0];
575
- knownAuthors.push({ slug, name, firstName });
576
- } catch {}
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
- const q = query.toLowerCase();
582
- // Check for person-directed query patterns
583
- const personMatch = q.match(/\b(?:did|has|have|from|by|about|for)\s+([a-z]+)\b/i);
584
- if (!personMatch) return null;
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
- // If date-filtered search returned empty, try browsing by date
667
- if ((!results || results.length === 0) && (dateFilters.since || dateFilters.until)) {
668
- const browseOpts = { ...searchOpts };
669
- const all = contentStore.queryMetadata(browseOpts);
670
- if (all.length > 0) {
671
- return all.slice(0, searchOpts.limit).map(r => ({ ...r, score: 1 }));
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
- // Still nothing broaden to all dates so the LLM can explain what it has
675
- delete searchOpts.since;
676
- delete searchOpts.until;
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 context from search results, enriching with full content where possible
705
- const contextParts = [];
706
- for (const r of results) {
707
- const fullContent = contentStore.getEntryContent(r.id, contentOpts || {});
708
- if (fullContent) {
709
- contextParts.push(`---\n${fullContent}`);
710
- } else {
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
- return await llm.call(llmConfig, buildSynthesisPrompt(), userContent);
731
- } catch (err) {
732
- // LLM failed — return raw results as fallback
733
- console.error(`LLM synthesis failed: ${err.message}`);
734
- return formatRawResults(results);
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
- * Resolve the prompts directory from environment or config.
780
- * @returns {string|null} - Path to prompts directory, or null if not found
781
- */
782
- function resolvePromptsDir() {
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
- // ── Direct commands (no LLM needed) ─────────────────────────────────────────
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 prior thread exchanges for conversation context
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
- if (result._directCommand) {
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
- if (result._directCommand) {
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
  };