wayfind 0.0.1 → 2.0.0

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.
Files changed (60) hide show
  1. package/BOOTSTRAP_PROMPT.md +120 -0
  2. package/bin/connectors/github.js +617 -0
  3. package/bin/connectors/index.js +13 -0
  4. package/bin/connectors/intercom.js +595 -0
  5. package/bin/connectors/llm.js +469 -0
  6. package/bin/connectors/notion.js +747 -0
  7. package/bin/connectors/transport.js +325 -0
  8. package/bin/content-store.js +2006 -0
  9. package/bin/digest.js +813 -0
  10. package/bin/rebuild-status.js +297 -0
  11. package/bin/slack-bot.js +1535 -0
  12. package/bin/slack.js +342 -0
  13. package/bin/storage/index.js +171 -0
  14. package/bin/storage/json-backend.js +348 -0
  15. package/bin/storage/sqlite-backend.js +415 -0
  16. package/bin/team-context.js +4209 -0
  17. package/bin/telemetry.js +159 -0
  18. package/doctor.sh +291 -0
  19. package/install.sh +144 -0
  20. package/journal-summary.sh +577 -0
  21. package/package.json +48 -6
  22. package/setup.sh +641 -0
  23. package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
  24. package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
  25. package/specializations/claude-code/README.md +99 -0
  26. package/specializations/claude-code/commands/doctor.md +31 -0
  27. package/specializations/claude-code/commands/init-memory.md +154 -0
  28. package/specializations/claude-code/commands/init-team.md +415 -0
  29. package/specializations/claude-code/commands/journal.md +66 -0
  30. package/specializations/claude-code/commands/review-prs.md +119 -0
  31. package/specializations/claude-code/hooks/check-global-state.sh +20 -0
  32. package/specializations/claude-code/hooks/session-end.sh +36 -0
  33. package/specializations/claude-code/settings.json +15 -0
  34. package/specializations/cursor/README.md +120 -0
  35. package/specializations/cursor/global-rule.mdc +53 -0
  36. package/specializations/cursor/repo-rule.mdc +25 -0
  37. package/specializations/generic/README.md +47 -0
  38. package/templates/autopilot/design.md +22 -0
  39. package/templates/autopilot/engineering.md +22 -0
  40. package/templates/autopilot/product.md +22 -0
  41. package/templates/autopilot/strategy.md +22 -0
  42. package/templates/autopilot/unified.md +24 -0
  43. package/templates/deploy/.env.example +110 -0
  44. package/templates/deploy/docker-compose.yml +63 -0
  45. package/templates/deploy/slack-app-manifest.json +45 -0
  46. package/templates/github-actions/meridian-digest.yml +85 -0
  47. package/templates/global.md +79 -0
  48. package/templates/memory-file.md +18 -0
  49. package/templates/personal-state.md +14 -0
  50. package/templates/personas.json +28 -0
  51. package/templates/product-state.md +41 -0
  52. package/templates/prompts-readme.md +19 -0
  53. package/templates/repo-state.md +18 -0
  54. package/templates/session-protocol-fragment.md +46 -0
  55. package/templates/slack-app-manifest.json +27 -0
  56. package/templates/statusline.sh +22 -0
  57. package/templates/strategy-state.md +39 -0
  58. package/templates/team-state.md +55 -0
  59. package/uninstall.sh +105 -0
  60. package/README.md +0 -4
@@ -0,0 +1,1535 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const contentStore = require('./content-store');
7
+ const llm = require('./connectors/llm');
8
+ const { markdownToMrkdwn } = require('./slack');
9
+ const telemetry = require('./telemetry');
10
+
11
+ // ── Slack connection state (for healthcheck) ────────────────────────────────
12
+ let slackConnected = false;
13
+ let slackLastConnected = null;
14
+ let slackLastDisconnected = null;
15
+
16
+ // ── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const HOME = process.env.HOME || process.env.USERPROFILE;
19
+ const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
20
+ const SIGNALS_DIR = path.join(WAYFIND_DIR, 'signals');
21
+ const ENV_FILE = path.join(WAYFIND_DIR, '.env');
22
+
23
+ /** Slack mrkdwn has a practical limit; split responses over this threshold. */
24
+ const MAX_RESPONSE_LENGTH = 3000;
25
+
26
+ /** Maximum number of search results to feed into the synthesis prompt. */
27
+ const MAX_SEARCH_RESULTS = 5;
28
+
29
+ /** Maximum thread exchanges to include as conversation context. */
30
+ const MAX_THREAD_EXCHANGES = 5;
31
+
32
+ /** Build the system prompt for the LLM synthesis step (includes current date). */
33
+ function buildSynthesisPrompt() {
34
+ const today = new Date().toISOString().slice(0, 10);
35
+ 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.
36
+
37
+ Today's date is ${today}.
38
+
39
+ Rules:
40
+ - Lead with the answer. Summarize what happened, what was decided, and what the outcome was.
41
+ - Be concise and specific. Under 500 words.
42
+ - Cite dates and repos when referencing decisions or sessions.
43
+ - 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.
44
+ - Never say you only have titles, headers, or tags — you have the complete content.
45
+ - Never recommend the user "check the full content" — you already have it.
46
+ - If the entries answer the question, answer confidently with specifics.
47
+ - 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]."
48
+ - 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.
49
+ - When presenting entries, always mention who wrote them (the author).
50
+ - If previous thread exchanges are provided, use them for conversational context. Answer follow-up questions naturally without asking the user to repeat themselves.
51
+ - If thread history was truncated, note that you only have partial history and may be missing earlier context.
52
+ - Format your response in markdown. Use bullet points for lists.
53
+ - Do not invent information that isn't in the provided context.`;
54
+ }
55
+
56
+ // ── Helpers ──────────────────────────────────────────────────────────────────
57
+
58
+ function ask(question) {
59
+ const rl = readline.createInterface({
60
+ input: process.stdin,
61
+ output: process.stdout,
62
+ });
63
+ return new Promise((resolve) => {
64
+ rl.question(question, (answer) => {
65
+ rl.close();
66
+ resolve(answer.trim());
67
+ });
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Save a key=value pair to ~/.claude/team-context/.env.
73
+ * Appends or updates the key. Creates the file if missing.
74
+ */
75
+ function saveEnvKey(key, value) {
76
+ fs.mkdirSync(WAYFIND_DIR, { recursive: true });
77
+ let lines = [];
78
+ if (fs.existsSync(ENV_FILE)) {
79
+ lines = fs.readFileSync(ENV_FILE, 'utf8').split('\n');
80
+ }
81
+ const prefix = `${key}=`;
82
+ const idx = lines.findIndex((l) => l.startsWith(prefix));
83
+ const entry = `${key}=${value}`;
84
+ if (idx !== -1) {
85
+ lines[idx] = entry;
86
+ } else {
87
+ lines.push(entry);
88
+ }
89
+ fs.writeFileSync(ENV_FILE, lines.filter((l) => l !== '').join('\n') + '\n', 'utf8');
90
+ console.log(`Saved to ${ENV_FILE}`);
91
+ }
92
+
93
+ /**
94
+ * Split a long message into chunks that fit within Slack's size limits.
95
+ * Tries to split at paragraph boundaries when possible.
96
+ * @param {string} text - Text to split
97
+ * @param {number} maxLen - Maximum chunk length
98
+ * @returns {string[]}
99
+ */
100
+ function chunkMessage(text, maxLen) {
101
+ if (text.length <= maxLen) return [text];
102
+
103
+ const chunks = [];
104
+ let remaining = text;
105
+
106
+ while (remaining.length > maxLen) {
107
+ // Try to split at a paragraph break
108
+ let splitIdx = remaining.lastIndexOf('\n\n', maxLen);
109
+ if (splitIdx < maxLen * 0.3) {
110
+ // Paragraph break is too early — try a single newline
111
+ splitIdx = remaining.lastIndexOf('\n', maxLen);
112
+ }
113
+ if (splitIdx < maxLen * 0.3) {
114
+ // No good break point — hard split
115
+ splitIdx = maxLen;
116
+ }
117
+
118
+ chunks.push(remaining.slice(0, splitIdx));
119
+ remaining = remaining.slice(splitIdx).replace(/^\n+/, '');
120
+ }
121
+
122
+ if (remaining) chunks.push(remaining);
123
+ return chunks;
124
+ }
125
+
126
+ // ── Thread history ──────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Fetch prior exchanges from a Slack thread for conversation context.
130
+ * Returns the first exchange (topic anchor) plus the most recent N-1 exchanges.
131
+ * @param {Object} client - Slack Web API client
132
+ * @param {string} channel - Channel ID
133
+ * @param {string} threadTs - Thread timestamp
134
+ * @param {string} botUserId - Bot's Slack user ID
135
+ * @param {string} currentEventTs - Current message ts (excluded from history)
136
+ * @returns {Promise<Array<{ question: string, answer: string }>>}
137
+ */
138
+ async function fetchThreadHistory(client, channel, threadTs, botUserId, currentEventTs) {
139
+ let messages;
140
+ try {
141
+ const result = await client.conversations.replies({
142
+ channel,
143
+ ts: threadTs,
144
+ limit: 200,
145
+ });
146
+ messages = result.messages || [];
147
+ } catch (err) {
148
+ console.error(`Failed to fetch thread history: ${err.message}`);
149
+ return [];
150
+ }
151
+
152
+ // Exclude the current message — it's the new query, not history
153
+ messages = messages.filter((m) => m.ts !== currentEventTs);
154
+
155
+ // Pair user questions (messages mentioning the bot) with bot answers
156
+ const exchanges = [];
157
+ for (let i = 0; i < messages.length; i++) {
158
+ const msg = messages[i];
159
+ const isBotMention = msg.text && botUserId && msg.text.includes(`<@${botUserId}>`);
160
+ if (!isBotMention) continue;
161
+
162
+ // Look for the bot's reply — next message from the bot
163
+ const query = extractQuery(msg.text, botUserId);
164
+ let answer = '';
165
+ for (let j = i + 1; j < messages.length; j++) {
166
+ if (messages[j].user === botUserId || messages[j].bot_id) {
167
+ answer = messages[j].text || '';
168
+ break;
169
+ }
170
+ }
171
+ if (query && answer) {
172
+ exchanges.push({ question: query, answer });
173
+ }
174
+ }
175
+
176
+ if (exchanges.length <= MAX_THREAD_EXCHANGES) return exchanges;
177
+
178
+ // First exchange anchors the topic, plus the most recent N-1
179
+ const first = exchanges[0];
180
+ const recent = exchanges.slice(-(MAX_THREAD_EXCHANGES - 1));
181
+ const truncated = [first, ...recent];
182
+ truncated._truncated = true;
183
+ return truncated;
184
+ }
185
+
186
+ /**
187
+ * Format thread history as context for the LLM prompt.
188
+ * @param {Array<{ question: string, answer: string }>} exchanges
189
+ * @returns {string}
190
+ */
191
+ function formatThreadContext(exchanges) {
192
+ if (!exchanges || exchanges.length === 0) return '';
193
+
194
+ const lines = ['Previous exchanges in this thread:'];
195
+ if (exchanges._truncated) {
196
+ lines.push('(Thread history truncated — oldest exchanges between the first and most recent were omitted.)');
197
+ }
198
+ lines.push('');
199
+ for (const ex of exchanges) {
200
+ lines.push(`Q: ${ex.question}`);
201
+ lines.push(`A: ${ex.answer}`);
202
+ lines.push('---');
203
+ }
204
+ return lines.join('\n');
205
+ }
206
+
207
+ // ── Query pipeline ───────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Extract the actual query from a Slack message text.
211
+ * Strips bot mention patterns like <@U12345> and @wayfind prefix.
212
+ * @param {string} text - Raw message text from Slack
213
+ * @param {string} [botUserId] - Bot's Slack user ID
214
+ * @returns {string} - Cleaned query text
215
+ */
216
+ function extractQuery(text, botUserId) {
217
+ let query = text;
218
+
219
+ // Strip Slack user mention: <@U12345>
220
+ if (botUserId) {
221
+ query = query.replace(new RegExp(`<@${botUserId}>`, 'g'), '');
222
+ }
223
+ // Strip any remaining <@...> mentions
224
+ query = query.replace(/<@[A-Z0-9]+>/g, '');
225
+
226
+ // Trim before checking @wayfind prefix so leading spaces don't prevent match
227
+ query = query.trim();
228
+
229
+ // Strip @wayfind prefix (case-insensitive)
230
+ query = query.replace(/^@wayfind\s*/i, '');
231
+
232
+ return query.trim();
233
+ }
234
+
235
+ // ── Intent classification ────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Signal-related keywords grouped by channel.
239
+ * Used for fast heuristic intent classification before falling back to LLM.
240
+ */
241
+ const SIGNAL_KEYWORDS = {
242
+ intercom: [
243
+ 'intercom', 'conversations', 'support tickets', 'customer complaints',
244
+ 'customer feedback', 'support trends', 'support volume', 'inbox',
245
+ 'customer issues', 'customer signals', 'support signals',
246
+ ],
247
+ github: [
248
+ 'github signals', 'ci failures', 'build failures', 'failing tests',
249
+ 'open issues', 'stale prs', 'pr trends',
250
+ ],
251
+ };
252
+
253
+ /** Keywords that strongly indicate engineering activity (decision trail) queries. */
254
+ const ENGINEERING_KEYWORDS = [
255
+ 'decision', 'decided', 'architecture', 'session', 'working on',
256
+ 'work was done', 'work done', 'work happened',
257
+ 'commit', 'shipped', 'deployed', 'refactor', 'implemented',
258
+ 'sprint', 'standup', 'retrospective', 'onboard',
259
+ 'built', 'merged', 'released', 'fixed',
260
+ ];
261
+
262
+ /**
263
+ * Classify the intent of a user query.
264
+ * Returns { type: 'signals'|'engineering'|'mixed', channel?: string }.
265
+ *
266
+ * Uses keyword heuristics — fast, no API call needed. The LLM synthesis
267
+ * step will handle nuance; this just routes to the right data source.
268
+ */
269
+ function classifyIntent(query) {
270
+ const q = query.toLowerCase();
271
+
272
+ // Check for signal channel keywords
273
+ let signalChannel = null;
274
+ let signalScore = 0;
275
+ for (const [channel, keywords] of Object.entries(SIGNAL_KEYWORDS)) {
276
+ for (const kw of keywords) {
277
+ if (q.includes(kw)) {
278
+ signalChannel = channel;
279
+ signalScore++;
280
+ }
281
+ }
282
+ }
283
+
284
+ // Check for engineering keywords
285
+ let engineeringScore = 0;
286
+ for (const kw of ENGINEERING_KEYWORDS) {
287
+ if (q.includes(kw)) engineeringScore++;
288
+ }
289
+
290
+ if (signalChannel && engineeringScore > 0) {
291
+ return { type: 'mixed', channel: signalChannel };
292
+ }
293
+ if (signalChannel) {
294
+ return { type: 'signals', channel: signalChannel };
295
+ }
296
+ return { type: 'engineering' };
297
+ }
298
+
299
+ // ── Signal search ────────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Search signal files for a given channel.
303
+ * Reads the most recent signal markdown files and returns their content.
304
+ * @param {string} channel - Signal channel name (e.g. 'intercom', 'github')
305
+ * @param {number} [maxFiles=3] - Maximum number of recent signal files to include
306
+ * @returns {{ files: string[], content: string, available: boolean }}
307
+ */
308
+ function searchSignals(channel, maxFiles) {
309
+ const limit = maxFiles || 3;
310
+ const channelDir = path.join(SIGNALS_DIR, channel);
311
+
312
+ if (!fs.existsSync(channelDir)) {
313
+ return { files: [], content: '', available: false };
314
+ }
315
+
316
+ let files;
317
+ try {
318
+ files = fs.readdirSync(channelDir)
319
+ .filter((f) => f.endsWith('.md'))
320
+ .sort()
321
+ .reverse()
322
+ .slice(0, limit);
323
+ } catch {
324
+ return { files: [], content: '', available: false };
325
+ }
326
+
327
+ if (files.length === 0) {
328
+ return { files: [], content: '', available: false };
329
+ }
330
+
331
+ const contentParts = [];
332
+ for (const f of files) {
333
+ try {
334
+ const text = fs.readFileSync(path.join(channelDir, f), 'utf8');
335
+ contentParts.push(text);
336
+ } catch {
337
+ // Skip unreadable files
338
+ }
339
+ }
340
+
341
+ return {
342
+ files,
343
+ content: contentParts.join('\n\n---\n\n'),
344
+ available: contentParts.length > 0,
345
+ };
346
+ }
347
+
348
+ /** Build a system prompt for signal-based questions. */
349
+ function buildSignalSynthesisPrompt(channel) {
350
+ const today = new Date().toISOString().slice(0, 10);
351
+ return `You are Wayfind, a team context assistant. You answer questions about business signals and trends using data pulled from ${channel}.
352
+
353
+ Today's date is ${today}.
354
+
355
+ Rules:
356
+ - Lead with the answer. Summarize trends, patterns, and notable items.
357
+ - Be concise and specific. Under 500 words.
358
+ - Use the signal data provided — it contains volume, tags, topics, and open items.
359
+ - If the data covers a different time period than what was asked, say so clearly.
360
+ - If the signal data is empty or unavailable, say so honestly.
361
+ - Format your response in markdown. Use bullet points for lists.
362
+ - Do not invent information that isn't in the provided context.`;
363
+ }
364
+
365
+ /**
366
+ * Search the content store for entries matching the query.
367
+ * Tries semantic search first, falls back to text search.
368
+ * @param {string} query - Search query
369
+ * @param {Object} config - Bot config (may contain store_path)
370
+ * @returns {Promise<Array<{ id: string, score: number, entry: Object }>>}
371
+ */
372
+ /**
373
+ * Resolve temporal references in a query to {since, until} date filters.
374
+ * Returns null values for fields that aren't constrained.
375
+ */
376
+ function resolveDateFilters(query) {
377
+ const today = new Date();
378
+ const fmt = (d) => d.toISOString().slice(0, 10);
379
+ const lower = query.toLowerCase();
380
+
381
+ if (/\btoday\b/.test(lower)) {
382
+ return { since: fmt(today), until: fmt(today) };
383
+ }
384
+ if (/\byesterday\b/.test(lower)) {
385
+ const d = new Date(today);
386
+ d.setDate(d.getDate() - 1);
387
+ return { since: fmt(d), until: fmt(d) };
388
+ }
389
+ if (/\bthis week\b/.test(lower)) {
390
+ const d = new Date(today);
391
+ const day = d.getDay();
392
+ d.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); // Monday
393
+ return { since: fmt(d), until: fmt(today) };
394
+ }
395
+ if (/\blast week\b/.test(lower)) {
396
+ const d = new Date(today);
397
+ const day = d.getDay();
398
+ const thisMon = new Date(d);
399
+ thisMon.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
400
+ const lastMon = new Date(thisMon);
401
+ lastMon.setDate(thisMon.getDate() - 7);
402
+ const lastSun = new Date(thisMon);
403
+ lastSun.setDate(thisMon.getDate() - 1);
404
+ return { since: fmt(lastMon), until: fmt(lastSun) };
405
+ }
406
+ if (/\blast (\d+) days?\b/.test(lower)) {
407
+ const n = parseInt(lower.match(/\blast (\d+) days?\b/)[1], 10);
408
+ const d = new Date(today);
409
+ d.setDate(d.getDate() - n);
410
+ return { since: fmt(d), until: fmt(today) };
411
+ }
412
+
413
+ // Check for explicit YYYY-MM-DD dates in the query
414
+ const dateMatch = query.match(/\b(\d{4}-\d{2}-\d{2})\b/g);
415
+ if (dateMatch) {
416
+ if (dateMatch.length >= 2) {
417
+ const sorted = dateMatch.sort();
418
+ return { since: sorted[0], until: sorted[sorted.length - 1] };
419
+ }
420
+ return { since: dateMatch[0], until: dateMatch[0] };
421
+ }
422
+
423
+ // Parse natural language dates: "March 3", "March 3 and March 6",
424
+ // "between March 3 and March 6", "March 3-6", "March 3 to March 6"
425
+ const monthNames = {
426
+ january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
427
+ july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
428
+ jan: 0, feb: 1, mar: 2, apr: 3, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
429
+ };
430
+ const monthPattern = Object.keys(monthNames).join('|');
431
+ const naturalDates = [];
432
+
433
+ // "March 3-6" or "March 3 - 6" (range within same month)
434
+ const sameMonthRange = lower.match(new RegExp(`\\b(${monthPattern})\\s+(\\d{1,2})\\s*[-–—]\\s*(\\d{1,2})\\b`));
435
+ if (sameMonthRange) {
436
+ const month = monthNames[sameMonthRange[1]];
437
+ const year = resolveYear(today, month);
438
+ const d1 = new Date(year, month, parseInt(sameMonthRange[2], 10));
439
+ const d2 = new Date(year, month, parseInt(sameMonthRange[3], 10));
440
+ return { since: fmt(d1), until: fmt(d2) };
441
+ }
442
+
443
+ // Find all "Month Day" patterns
444
+ const monthDayRegex = new RegExp(`\\b(${monthPattern})\\s+(\\d{1,2})\\b`, 'g');
445
+ let m;
446
+ while ((m = monthDayRegex.exec(lower)) !== null) {
447
+ const month = monthNames[m[1]];
448
+ const day = parseInt(m[2], 10);
449
+ if (day >= 1 && day <= 31) {
450
+ const year = resolveYear(today, month);
451
+ naturalDates.push(new Date(year, month, day));
452
+ }
453
+ }
454
+
455
+ if (naturalDates.length >= 2) {
456
+ naturalDates.sort((a, b) => a - b);
457
+ return { since: fmt(naturalDates[0]), until: fmt(naturalDates[naturalDates.length - 1]) };
458
+ }
459
+ if (naturalDates.length === 1) {
460
+ return { since: fmt(naturalDates[0]), until: fmt(naturalDates[0]) };
461
+ }
462
+
463
+ return { since: null, until: null };
464
+ }
465
+
466
+ /** Resolve year for a month reference — use current year, or previous year if the month is in the future. */
467
+ function resolveYear(today, month) {
468
+ return month > today.getMonth() ? today.getFullYear() - 1 : today.getFullYear();
469
+ }
470
+
471
+ /** Words that are temporal references, not content keywords. */
472
+ const TEMPORAL_WORDS = new Set([
473
+ 'today', 'yesterday', 'week', 'this', 'last', 'days', 'day',
474
+ 'recent', 'recently', 'latest', 'current', 'now', 'between', 'and', 'to',
475
+ 'january', 'february', 'march', 'april', 'may', 'june',
476
+ 'july', 'august', 'september', 'october', 'november', 'december',
477
+ 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
478
+ ]);
479
+
480
+ /**
481
+ * Strip temporal and filler words from a query to get content keywords.
482
+ * Returns the cleaned query string.
483
+ */
484
+ function stripTemporalWords(query) {
485
+ const filler = new Set(['what', 'happened', 'show', 'me', 'tell', 'about',
486
+ 'are', 'is', 'the', 'any', 'all', 'from', 'in', 'on', 'for', 'of',
487
+ 'how', 'many', 'much', 'were', 'was', 'there', 'can', 'you', 'do',
488
+ 'did', 'has', 'have', 'been', 'get', 'give', 'list', 'count',
489
+ 'journal', 'entries', 'entry', 'sessions', 'session', 'made']);
490
+ return query
491
+ .toLowerCase()
492
+ .split(/[\s\-_]+/)
493
+ .filter(w => w.length > 1 && !TEMPORAL_WORDS.has(w) && !filler.has(w))
494
+ .join(' ');
495
+ }
496
+
497
+ async function searchDecisionTrail(query, config) {
498
+ const searchOpts = {
499
+ limit: MAX_SEARCH_RESULTS,
500
+ };
501
+ if (config.store_path) {
502
+ searchOpts.storePath = config.store_path;
503
+ }
504
+ if (config.journal_dir) {
505
+ searchOpts.journalDir = config.journal_dir;
506
+ }
507
+
508
+ // Resolve temporal references to date filters
509
+ const dateFilters = resolveDateFilters(query);
510
+ if (dateFilters.since) searchOpts.since = dateFilters.since;
511
+ if (dateFilters.until) searchOpts.until = dateFilters.until;
512
+
513
+ // Strip temporal words to get content keywords
514
+ const contentQuery = stripTemporalWords(query);
515
+
516
+ // If query is purely temporal (no content keywords), browse by date
517
+ if (!contentQuery && (dateFilters.since || dateFilters.until)) {
518
+ const browseOpts = { ...searchOpts };
519
+ const all = contentStore.queryMetadata(browseOpts);
520
+ return all.slice(0, searchOpts.limit).map(r => ({ ...r, score: 1 }));
521
+ }
522
+
523
+ // Use content keywords for search (or original query if no temporal refs)
524
+ const searchQuery = contentQuery || query;
525
+
526
+ let results;
527
+ try {
528
+ results = await contentStore.searchJournals(searchQuery, searchOpts);
529
+ } catch {
530
+ // Semantic search failed — fall back to text search
531
+ results = contentStore.searchText(searchQuery, searchOpts);
532
+ }
533
+
534
+ // If semantic search returned empty, also try text search
535
+ if (!results || results.length === 0) {
536
+ results = contentStore.searchText(searchQuery, searchOpts);
537
+ }
538
+
539
+ // If date-filtered search returned empty, try browsing by date
540
+ if ((!results || results.length === 0) && (dateFilters.since || dateFilters.until)) {
541
+ const browseOpts = { ...searchOpts };
542
+ const all = contentStore.queryMetadata(browseOpts);
543
+ if (all.length > 0) {
544
+ return all.slice(0, searchOpts.limit).map(r => ({ ...r, score: 1 }));
545
+ }
546
+
547
+ // Still nothing — broaden to all dates so the LLM can explain what it has
548
+ delete searchOpts.since;
549
+ delete searchOpts.until;
550
+ try {
551
+ results = await contentStore.searchJournals(searchQuery, searchOpts);
552
+ } catch {
553
+ results = contentStore.searchText(searchQuery, searchOpts);
554
+ }
555
+ if (!results || results.length === 0) {
556
+ results = contentStore.searchText(searchQuery, searchOpts);
557
+ }
558
+ }
559
+
560
+ return results || [];
561
+ }
562
+
563
+ /**
564
+ * Synthesize an answer from search results using the LLM.
565
+ * If synthesis fails, returns a formatted list of raw results.
566
+ * @param {string} query - User's question
567
+ * @param {Array<{ id: string, score: number, entry: Object }>} results - Search results
568
+ * @param {Object} llmConfig - LLM configuration
569
+ * @returns {Promise<string>} - Synthesized answer in markdown
570
+ */
571
+ async function synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory) {
572
+ if (results.length === 0) {
573
+ return 'No matching entries found in the decision trail. Try rephrasing your question or indexing more journals with `wayfind index-journals`.';
574
+ }
575
+
576
+ // Build context from search results, enriching with full content where possible
577
+ const contextParts = [];
578
+ for (const r of results) {
579
+ const fullContent = contentStore.getEntryContent(r.id, contentOpts || {});
580
+ if (fullContent) {
581
+ contextParts.push(`---\n${fullContent}`);
582
+ } else {
583
+ // Log why content retrieval failed for debugging
584
+ console.log(`[content-miss] id=${r.id} date=${r.entry.date} repo=${r.entry.repo} title=${r.entry.title} opts=${JSON.stringify(contentOpts)}`);
585
+ // Fall back to metadata-only summary
586
+ const drift = r.entry.drifted ? ' [DRIFT]' : '';
587
+ contextParts.push(
588
+ `---\n${r.entry.date} | ${r.entry.repo} | ${r.entry.title}${drift}\n` +
589
+ `Tags: ${(r.entry.tags || []).join(', ')}`
590
+ );
591
+ }
592
+ }
593
+ const context = contextParts.join('\n\n');
594
+
595
+ const threadContext = formatThreadContext(threadHistory);
596
+ const userContent =
597
+ (threadContext ? `${threadContext}\n\n` : '') +
598
+ `Question: ${query}\n\n` +
599
+ `Here are the most relevant entries from the team's decision trail:\n\n${context}`;
600
+
601
+ try {
602
+ return await llm.call(llmConfig, buildSynthesisPrompt(), userContent);
603
+ } catch (err) {
604
+ // LLM failed — return raw results as fallback
605
+ console.error(`LLM synthesis failed: ${err.message}`);
606
+ return formatRawResults(results);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Format raw search results as a readable list.
612
+ * Used as a fallback when LLM synthesis fails.
613
+ * @param {Array<{ id: string, score: number, entry: Object }>} results
614
+ * @returns {string}
615
+ */
616
+ function formatRawResults(results) {
617
+ if (results.length === 0) return 'No results found.';
618
+
619
+ const lines = ['Here are the most relevant entries I found:\n'];
620
+ for (const r of results) {
621
+ const drift = r.entry.drifted ? ' [DRIFT]' : '';
622
+ lines.push(`- **${r.entry.date}** | ${r.entry.repo} | ${r.entry.title}${drift}`);
623
+ }
624
+ lines.push('\n_LLM synthesis was unavailable. These are raw search results._');
625
+ return lines.join('\n');
626
+ }
627
+
628
+ /**
629
+ * Format a synthesized answer for Slack.
630
+ * Converts markdown to Slack mrkdwn and adds a sources section.
631
+ * @param {string} answer - Markdown answer text
632
+ * @param {Array<{ id: string, score: number, entry: Object }>} results - Source entries
633
+ * @returns {string} - Slack mrkdwn formatted response
634
+ */
635
+ function formatResponse(answer, results) {
636
+ let text = markdownToMrkdwn(answer);
637
+
638
+ // Add sources section
639
+ if (results && results.length > 0) {
640
+ const sources = results.map((r) => {
641
+ const drift = r.entry.drifted ? ' [drift]' : '';
642
+ return `${r.entry.date} | ${r.entry.repo} | ${r.entry.title}${drift}`;
643
+ });
644
+ text += '\n\n_Sources:_\n' + sources.map((s) => `> ${s}`).join('\n');
645
+ }
646
+
647
+ return text;
648
+ }
649
+
650
+ /**
651
+ * Resolve the prompts directory from environment or config.
652
+ * @returns {string|null} - Path to prompts directory, or null if not found
653
+ */
654
+ function resolvePromptsDir() {
655
+ const teamDir = process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
656
+ if (teamDir) {
657
+ const dir = path.join(teamDir, 'prompts');
658
+ if (fs.existsSync(dir)) return dir;
659
+ }
660
+ return null;
661
+ }
662
+
663
+ // ── Direct commands (no LLM needed) ─────────────────────────────────────────
664
+
665
+ /**
666
+ * Resolve the team-context members directory from environment or config.
667
+ * @param {Object} config - Bot config
668
+ * @returns {string|null}
669
+ */
670
+ function resolveMembersDir(config) {
671
+ const teamDir = config.team_context_dir || process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || '';
672
+ if (teamDir) {
673
+ const dir = path.join(teamDir, 'members');
674
+ if (fs.existsSync(dir)) return dir;
675
+ }
676
+ return null;
677
+ }
678
+
679
+ /**
680
+ * Handle direct bot commands that don't require LLM synthesis.
681
+ * Returns formatted text if the query matches a command, or null to fall through.
682
+ * @param {string} query - User's question
683
+ * @param {Object} config - Bot config
684
+ * @returns {string|null}
685
+ */
686
+ function handleDirectCommand(query, config) {
687
+ const q = query.trim().toLowerCase();
688
+
689
+ // help
690
+ if (q === 'help' || q === '?' || q === 'commands') {
691
+ return handleHelp();
692
+ }
693
+
694
+ // version
695
+ if (q === 'version' || q === 'what version' || q === 'what version are you') {
696
+ return handleVersion();
697
+ }
698
+
699
+ // members
700
+ if (q === 'members' || q === 'team members' || q === 'who is on the team' ||
701
+ /^(?:show|list|get)\s+(?:team\s+)?members$/i.test(query.trim()) ||
702
+ /^(?:what|which)\s+version\s+is\s+\w+\s+(?:on|running|using)/i.test(query.trim()) ||
703
+ /^(?:who|what)\s+version/i.test(query.trim())) {
704
+ return handleMembers(config);
705
+ }
706
+
707
+ // insights
708
+ if (q === 'insights' || q === 'journal insights' || q === 'show insights' ||
709
+ /^(?:show|get)\s+(?:journal\s+)?insights$/i.test(query.trim())) {
710
+ return handleInsights(config);
711
+ }
712
+
713
+ // digest scores / feedback
714
+ if (q === 'digest scores' || q === 'scores' || q === 'digest feedback' ||
715
+ /^(?:show|get)\s+(?:digest\s+)?(?:scores|feedback)$/i.test(query.trim())) {
716
+ return handleDigestScores(config);
717
+ }
718
+
719
+ // signals
720
+ if (q === 'signals' || q === 'show signals' || q === 'signal channels' ||
721
+ /^(?:show|list|get)\s+(?:signal\s+)?channels$/i.test(query.trim())) {
722
+ return handleSignalChannels();
723
+ }
724
+
725
+ return null;
726
+ }
727
+
728
+ /** Bot help command — lists available capabilities. */
729
+ function handleHelp() {
730
+ return `*Wayfind Bot — Commands*
731
+
732
+ You can ask me anything about your team's decision trail, or use these commands:
733
+
734
+ *Queries*
735
+ • Ask any question — I'll search journals and synthesize an answer
736
+ • Use dates naturally: "what happened yesterday", "this week", "March 3-6"
737
+ • Thread replies continue the conversation without re-mentioning me
738
+
739
+ *Commands*
740
+ • \`help\` — this message
741
+ • \`version\` — bot version
742
+ • \`members\` — team members, versions, and activity
743
+ • \`insights\` — journal analytics (session count, drift rate, repo activity)
744
+ • \`digest scores\` — digest feedback and reactions
745
+ • \`signals\` — configured signal channels
746
+ • \`onboard <repo>\` — generate an onboarding context pack
747
+ • \`reindex\` — re-index all sources
748
+ • \`prompts\` — list shared team prompts
749
+ • \`show <name> prompt\` — show a specific prompt`;
750
+ }
751
+
752
+ /** Bot version command. */
753
+ function handleVersion() {
754
+ const version = telemetry.getWayfindVersion();
755
+ return `Wayfind v${version}`;
756
+ }
757
+
758
+ /** Bot members command — reads member profiles from team-context repo. */
759
+ function handleMembers(config) {
760
+ const membersDir = resolveMembersDir(config);
761
+ if (!membersDir) {
762
+ return 'No team members directory found. Set `TEAM_CONTEXT_TEAM_CONTEXT_DIR` or configure `team_context_dir` in the bot config.';
763
+ }
764
+
765
+ let files;
766
+ try {
767
+ files = fs.readdirSync(membersDir).filter(f => f.endsWith('.json'));
768
+ } catch {
769
+ return 'Could not read team members directory.';
770
+ }
771
+
772
+ if (files.length === 0) {
773
+ return 'No team members found. Members register via `wayfind whoami --setup`.';
774
+ }
775
+
776
+ const lines = ['*Team Members*\n'];
777
+ for (const file of files.sort()) {
778
+ try {
779
+ const member = JSON.parse(fs.readFileSync(path.join(membersDir, file), 'utf8'));
780
+ const name = member.name || file.replace('.json', '');
781
+ const version = member.wayfind_version ? `v${member.wayfind_version}` : '?';
782
+ const lastActive = member.last_active ? member.last_active.slice(0, 10) : '—';
783
+ const personas = (member.personas || []).join(', ') || '—';
784
+ const slackId = member.slack_id ? `<@${member.slack_id}>` : '';
785
+ lines.push(`• *${name}* ${slackId} — ${version} — active: ${lastActive} — personas: ${personas}`);
786
+ } catch {
787
+ // Skip unreadable files
788
+ }
789
+ }
790
+
791
+ return lines.join('\n');
792
+ }
793
+
794
+ /** Bot insights command — journal analytics. */
795
+ function handleInsights(config) {
796
+ const opts = {};
797
+ if (config.store_path) opts.storePath = config.store_path;
798
+
799
+ const insights = contentStore.extractInsights(opts);
800
+
801
+ const lines = ['*Journal Insights*\n'];
802
+ lines.push(`Total sessions: ${insights.totalSessions}`);
803
+ lines.push(`Drift rate: ${insights.driftRate}%`);
804
+
805
+ if (Object.keys(insights.repoActivity).length > 0) {
806
+ lines.push('\n*Repo activity:*');
807
+ const sorted = Object.entries(insights.repoActivity).sort((a, b) => b[1] - a[1]).slice(0, 10);
808
+ for (const [repo, count] of sorted) {
809
+ lines.push(` ${repo} — ${count} session(s)`);
810
+ }
811
+ }
812
+
813
+ if (Object.keys(insights.tagFrequency).length > 0) {
814
+ lines.push('\n*Top tags:*');
815
+ const sorted = Object.entries(insights.tagFrequency).sort((a, b) => b[1] - a[1]).slice(0, 10);
816
+ for (const [tag, count] of sorted) {
817
+ lines.push(` ${tag} — ${count}`);
818
+ }
819
+ }
820
+
821
+ if (insights.timeline.length > 0) {
822
+ lines.push('\n*Recent activity (last 7 days):*');
823
+ const recent = insights.timeline.slice(-7);
824
+ for (const { date, sessions } of recent) {
825
+ const bar = '\u2588'.repeat(Math.min(sessions, 20));
826
+ lines.push(` ${date} ${bar} ${sessions}`);
827
+ }
828
+ }
829
+
830
+ return lines.join('\n');
831
+ }
832
+
833
+ /** Bot digest scores command — feedback and reactions. */
834
+ function handleDigestScores(config) {
835
+ const opts = { limit: 10 };
836
+ if (config.store_path) opts.storePath = config.store_path;
837
+
838
+ const feedback = contentStore.getDigestFeedback(opts);
839
+ if (feedback.length === 0) {
840
+ return 'No digest feedback yet. Reactions on digest messages will appear here.';
841
+ }
842
+
843
+ const lines = ['*Digest Feedback*\n'];
844
+ for (const d of feedback) {
845
+ const reactions = Object.entries(d.reactions)
846
+ .map(([emoji, count]) => `:${emoji}: \u00d7 ${count}`)
847
+ .join(' ');
848
+ lines.push(`• ${d.date} (${d.persona}): ${reactions || 'no reactions'} — total: ${d.totalReactions}`);
849
+ if (d.comments.length > 0) {
850
+ for (const c of d.comments) {
851
+ lines.push(` \u2192 "${c.text}"`);
852
+ }
853
+ }
854
+ }
855
+
856
+ return lines.join('\n');
857
+ }
858
+
859
+ /** Bot signals command — show configured signal channels. */
860
+ function handleSignalChannels() {
861
+ const signalsDir = process.env.TEAM_CONTEXT_SIGNALS_DIR || path.join(
862
+ process.env.HOME || process.env.USERPROFILE || '',
863
+ '.claude', 'team-context', 'signals'
864
+ );
865
+
866
+ if (!fs.existsSync(signalsDir)) {
867
+ return 'No signal channels configured. Set up channels with `wayfind pull <channel> --configure`.';
868
+ }
869
+
870
+ let channels;
871
+ try {
872
+ channels = fs.readdirSync(signalsDir).filter(f => {
873
+ try { return fs.statSync(path.join(signalsDir, f)).isDirectory(); } catch { return false; }
874
+ });
875
+ } catch {
876
+ return 'Could not read signals directory.';
877
+ }
878
+
879
+ if (channels.length === 0) {
880
+ return 'No signal channels found. Set up channels with `wayfind pull <channel> --configure`.';
881
+ }
882
+
883
+ const lines = ['*Signal Channels*\n'];
884
+ for (const ch of channels.sort()) {
885
+ const chDir = path.join(signalsDir, ch);
886
+ let fileCount = 0;
887
+ let latestFile = null;
888
+ try {
889
+ const files = fs.readdirSync(chDir).filter(f => f.endsWith('.md')).sort();
890
+ fileCount = files.length;
891
+ latestFile = files.length > 0 ? files[files.length - 1].replace('.md', '') : null;
892
+ } catch { /* skip */ }
893
+ lines.push(`• *${ch}* — ${fileCount} file(s)${latestFile ? ` — latest: ${latestFile}` : ''}`);
894
+ }
895
+
896
+ return lines.join('\n');
897
+ }
898
+
899
+ /**
900
+ * Handle prompt-related queries directly from the filesystem.
901
+ * Returns formatted text if the query matches a prompt pattern, or null to fall through.
902
+ * @param {string} query - User's question
903
+ * @returns {string|null}
904
+ */
905
+ function handlePromptQuery(query) {
906
+ 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)
907
+ || query.match(/^prompts?$/i)
908
+ || query.match(/prompt\s+(?:for|about|called|named)\s+(.+)/i);
909
+
910
+ if (!promptMatch) return null;
911
+
912
+ const promptsDir = resolvePromptsDir();
913
+ if (!promptsDir) return null;
914
+
915
+ const files = fs.readdirSync(promptsDir)
916
+ .filter(f => f.endsWith('.md') && f.toLowerCase() !== 'readme.md')
917
+ .sort();
918
+
919
+ const searchTerm = (promptMatch[1] || '').trim();
920
+
921
+ if (searchTerm) {
922
+ // Find specific prompt
923
+ const match = files.find(f =>
924
+ f.toLowerCase().includes(searchTerm.toLowerCase()) ||
925
+ f.replace('.md', '').toLowerCase() === searchTerm.toLowerCase()
926
+ );
927
+ if (match) {
928
+ const content = fs.readFileSync(path.join(promptsDir, match), 'utf8');
929
+ return `*${match.replace('.md', '')}*\n\n${content}`;
930
+ }
931
+ return `No prompt matching "${searchTerm}" found. Available: ${files.map(f => f.replace('.md', '')).join(', ')}`;
932
+ }
933
+
934
+ // List all prompts
935
+ if (files.length === 0) {
936
+ return 'No prompts yet. Add .md files to your team-context/prompts/ directory.';
937
+ }
938
+ const list = files.map(f => {
939
+ const content = fs.readFileSync(path.join(promptsDir, f), 'utf8');
940
+ const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim() || '';
941
+ return `• *${f.replace('.md', '')}*${firstLine ? ` — ${firstLine}` : ''}`;
942
+ }).join('\n');
943
+ return `*Team Prompts*\n\n${list}\n\nAsk me to "show <name> prompt" to see the full text.`;
944
+ }
945
+
946
+ /**
947
+ * Core query handler. Runs the full pipeline: search, synthesize, format.
948
+ * Exported for independent testing without a Slack connection.
949
+ * @param {string} query - User's question
950
+ * @param {Object} config - Bot configuration from connectors.json
951
+ * @returns {Promise<{ text: string, results: Array }>}
952
+ */
953
+ async function handleQuery(query, config, threadHistory) {
954
+ const queryStart = Date.now();
955
+
956
+ // Check direct commands first — no LLM needed
957
+ const directResult = handleDirectCommand(query, config);
958
+ if (directResult) {
959
+ return { text: directResult, results: [], _directCommand: true };
960
+ }
961
+
962
+ // Check if this is a prompt query — answer directly without LLM
963
+ const promptResult = handlePromptQuery(query);
964
+ if (promptResult) {
965
+ return { text: promptResult, results: [], _promptQuery: true };
966
+ }
967
+
968
+ const intent = classifyIntent(query);
969
+ const llmConfig = config.llm || {};
970
+ const contentOpts = {};
971
+ if (config.store_path) contentOpts.storePath = config.store_path;
972
+ if (config.journal_dir) contentOpts.journalDir = config.journal_dir;
973
+
974
+ // Route based on intent
975
+ if (intent.type === 'signals' || intent.type === 'mixed') {
976
+ const signals = searchSignals(intent.channel);
977
+
978
+ if (signals.available) {
979
+ // Synthesize from signal data
980
+ const threadContext = formatThreadContext(threadHistory);
981
+ const userContent =
982
+ (threadContext ? `${threadContext}\n\n` : '') +
983
+ `Question: ${query}\n\n` +
984
+ `Here is the latest ${intent.channel} signal data:\n\n${signals.content}`;
985
+
986
+ let answer;
987
+ try {
988
+ answer = await llm.call(llmConfig, buildSignalSynthesisPrompt(intent.channel), userContent);
989
+ } catch (err) {
990
+ console.error(`LLM synthesis failed for signals: ${err.message}`);
991
+ answer = `Here is the raw ${intent.channel} signal data:\n\n${signals.content}`;
992
+ }
993
+
994
+ // For mixed intent, also search the decision trail and append
995
+ if (intent.type === 'mixed') {
996
+ const results = await searchDecisionTrail(query, config);
997
+ if (results.length > 0) {
998
+ const trailAnswer = await synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory);
999
+ answer += '\n\n---\n\n**From the engineering decision trail:**\n\n' + trailAnswer;
1000
+ }
1001
+ }
1002
+
1003
+ const text = markdownToMrkdwn(answer);
1004
+ return { text, results: [] };
1005
+ }
1006
+
1007
+ // Signal data not available from files — check indexed entries
1008
+ if (intent.type === 'signals') {
1009
+ const indexedResults = await searchDecisionTrail(query, config);
1010
+ const signalEntries = indexedResults.filter(r => r.entry.repo && r.entry.repo.startsWith('signals/'));
1011
+ if (signalEntries.length > 0) {
1012
+ const answer = await synthesizeAnswer(query, signalEntries, llmConfig, contentOpts, threadHistory);
1013
+ const text = formatResponse(answer, signalEntries);
1014
+ return { text, results: signalEntries };
1015
+ }
1016
+
1017
+ const text = `I don't have ${intent.channel} signal data available. ` +
1018
+ `The ${intent.channel} connector may not be configured, or no signals have been pulled yet.\n\n` +
1019
+ `To set it up: \`wayfind pull ${intent.channel} --configure\`\n` +
1020
+ `To pull now: \`wayfind pull ${intent.channel}\``;
1021
+ return { text, results: [] };
1022
+ }
1023
+
1024
+ // Mixed but no signals — fall through to engineering-only
1025
+ }
1026
+
1027
+ // Engineering intent (or mixed fallback)
1028
+ // For follow-up queries in threads, enrich the search with date context from prior exchanges.
1029
+ // If the current query has no date references, inherit them from the thread.
1030
+ let searchQuery = query;
1031
+ const currentDates = resolveDateFilters(query);
1032
+ if (!currentDates.since && threadHistory && threadHistory.length > 0) {
1033
+ // Combine all prior thread text and check for date references
1034
+ const priorText = threadHistory
1035
+ .flatMap(ex => [ex.question, ex.answer])
1036
+ .filter(Boolean)
1037
+ .join(' ');
1038
+ const priorDates = resolveDateFilters(priorText);
1039
+ if (priorDates.since) {
1040
+ // Append resolved dates so searchDecisionTrail picks them up
1041
+ searchQuery = `${query} ${priorDates.since}` +
1042
+ (priorDates.until !== priorDates.since ? ` ${priorDates.until}` : '');
1043
+ }
1044
+ }
1045
+ const results = await searchDecisionTrail(searchQuery, config);
1046
+ const answer = await synthesizeAnswer(query, results, llmConfig, contentOpts, threadHistory);
1047
+ const text = formatResponse(answer, results);
1048
+ return {
1049
+ text,
1050
+ results,
1051
+ _telemetry: {
1052
+ intent_type: intent.type,
1053
+ has_thread_history: !!(threadHistory && threadHistory.length),
1054
+ result_count: results.length,
1055
+ response_length: text.length,
1056
+ duration_ms: Date.now() - queryStart,
1057
+ },
1058
+ };
1059
+ }
1060
+
1061
+ // ── Bot lifecycle ────────────────────────────────────────────────────────────
1062
+
1063
+ /**
1064
+ * Start the Slack bot using Socket Mode.
1065
+ * Connects to Slack, listens for @mentions, and runs the query pipeline.
1066
+ * @param {Object} config - Bot configuration from connectors.json (slack_bot key)
1067
+ * @returns {Promise<{ app: Object, stop: Function }>}
1068
+ */
1069
+ async function start(config) {
1070
+ // Lazy-require @slack/bolt so the module can be loaded without it installed
1071
+ // (e.g. for configure-only flows or testing)
1072
+ let App, LogLevel;
1073
+ try {
1074
+ const bolt = require('@slack/bolt');
1075
+ App = bolt.App;
1076
+ LogLevel = bolt.LogLevel;
1077
+ } catch {
1078
+ throw new Error(
1079
+ '@slack/bolt is not installed. Run "npm install @slack/bolt" in the Wayfind directory.'
1080
+ );
1081
+ }
1082
+
1083
+ // Ensure journal_dir has a default for configs saved before this field existed
1084
+ if (!config.journal_dir) {
1085
+ config.journal_dir = process.env.TEAM_CONTEXT_JOURNALS_DIR || '/data/journals';
1086
+ }
1087
+
1088
+ // Resolve tokens from environment
1089
+ const botTokenEnv = config.bot_token_env || 'SLACK_BOT_TOKEN';
1090
+ const appTokenEnv = config.app_token_env || 'SLACK_APP_TOKEN';
1091
+ const botToken = process.env[botTokenEnv];
1092
+ const appToken = process.env[appTokenEnv];
1093
+
1094
+ if (!botToken) {
1095
+ throw new Error(
1096
+ `Missing ${botTokenEnv}. Run "wayfind bot --configure" or set it in your environment.`
1097
+ );
1098
+ }
1099
+ if (!appToken) {
1100
+ throw new Error(
1101
+ `Missing ${appTokenEnv}. Run "wayfind bot --configure" or set it in your environment.`
1102
+ );
1103
+ }
1104
+
1105
+ const app = new App({
1106
+ token: botToken,
1107
+ appToken,
1108
+ socketMode: true,
1109
+ logLevel: LogLevel.ERROR,
1110
+ });
1111
+
1112
+ // Get bot user ID for mention detection
1113
+ let botUserId;
1114
+ try {
1115
+ const auth = await app.client.auth.test();
1116
+ botUserId = auth.user_id;
1117
+ } catch (err) {
1118
+ console.error(`Warning: Could not get bot user ID: ${err.message}`);
1119
+ }
1120
+
1121
+ // Build members directory path for Slack user identity resolution
1122
+ const teamContextDir = config.team_context_dir || process.env.TEAM_CONTEXT_TEAM_CONTEXT_DIR || null;
1123
+ const membersDir = teamContextDir ? path.join(teamContextDir, 'members') : null;
1124
+
1125
+ // Handle @mentions
1126
+ app.event('app_mention', async ({ event, client }) => {
1127
+ const channel = event.channel;
1128
+ const threadTs = event.thread_ts || event.ts;
1129
+
1130
+ // Acknowledge with eyes emoji
1131
+ try {
1132
+ await client.reactions.add({
1133
+ channel,
1134
+ timestamp: event.ts,
1135
+ name: 'eyes',
1136
+ });
1137
+ } catch {
1138
+ // Non-critical — continue even if reaction fails
1139
+ }
1140
+
1141
+ // Extract and validate query
1142
+ const query = extractQuery(event.text, botUserId);
1143
+ if (!query) {
1144
+ await client.chat.postMessage({
1145
+ channel,
1146
+ thread_ts: threadTs,
1147
+ text: 'What would you like to know? Mention me with a question about your team\'s decision trail.',
1148
+ });
1149
+ return;
1150
+ }
1151
+
1152
+ try {
1153
+ // Handle onboard command
1154
+ const onboardMatch = query.match(/^onboard\s+(.+)/i);
1155
+ if (onboardMatch) {
1156
+ const repoQuery = onboardMatch[1].trim();
1157
+ await client.reactions.add({ channel, timestamp: event.ts, name: 'hourglass_flowing_sand' }).catch(() => {});
1158
+ try {
1159
+ const pack = await contentStore.generateOnboardingPack(repoQuery, {
1160
+ storePath: config.store_path || undefined,
1161
+ journalDir: config.journal_dir || undefined,
1162
+ llmConfig: config.llm || undefined,
1163
+ });
1164
+ const formatted = markdownToMrkdwn(pack);
1165
+ const chunks = chunkMessage(formatted, MAX_RESPONSE_LENGTH);
1166
+ for (const chunk of chunks) {
1167
+ await client.chat.postMessage({ channel, thread_ts: threadTs, text: chunk });
1168
+ }
1169
+ } catch (err) {
1170
+ await client.chat.postMessage({
1171
+ channel, thread_ts: threadTs,
1172
+ text: `Onboarding pack failed: ${err.message}`,
1173
+ });
1174
+ }
1175
+ return;
1176
+ }
1177
+
1178
+ // Handle reindex command
1179
+ if (query.toLowerCase().match(/^reindex\b/)) {
1180
+ await client.chat.postMessage({
1181
+ channel,
1182
+ thread_ts: threadTs,
1183
+ text: 'Re-indexing all sources... this may take a few minutes.',
1184
+ });
1185
+ try {
1186
+ const journalStats = await contentStore.indexJournals({
1187
+ storePath: config.store_path || undefined,
1188
+ journalDir: config.journal_dir || undefined,
1189
+ });
1190
+ const convStats = await contentStore.indexConversations({
1191
+ storePath: config.store_path || undefined,
1192
+ });
1193
+ let signalStats = { fileCount: 0, newEntries: 0, updatedEntries: 0, skippedEntries: 0 };
1194
+ const signalsDir = process.env.TEAM_CONTEXT_SIGNALS_DIR || contentStore.DEFAULT_SIGNALS_DIR;
1195
+ if (signalsDir && fs.existsSync(signalsDir)) {
1196
+ signalStats = await contentStore.indexSignals({ signalsDir });
1197
+ }
1198
+ await client.chat.postMessage({
1199
+ channel,
1200
+ thread_ts: threadTs,
1201
+ text: `Reindex complete.\n` +
1202
+ `Journals: ${journalStats.entryCount} entries (${journalStats.newEntries} new)\n` +
1203
+ `Conversations: ${convStats.transcriptsProcessed} processed, ${convStats.decisionsExtracted} decisions extracted\n` +
1204
+ `Signals: ${signalStats.fileCount} files (${signalStats.newEntries} new, ${signalStats.updatedEntries} updated)`,
1205
+ });
1206
+ } catch (reindexErr) {
1207
+ await client.chat.postMessage({
1208
+ channel,
1209
+ thread_ts: threadTs,
1210
+ text: `Reindex failed: ${reindexErr.message}`,
1211
+ });
1212
+ }
1213
+ return;
1214
+ }
1215
+
1216
+ // Fetch prior thread exchanges for conversation context
1217
+ let threadHistory = [];
1218
+ if (event.thread_ts && botUserId) {
1219
+ threadHistory = await fetchThreadHistory(client, channel, event.thread_ts, botUserId, event.ts);
1220
+ }
1221
+
1222
+ const result = await handleQuery(query, config, threadHistory);
1223
+ const authorSlug = telemetry.resolveSlackUser(event.user, membersDir);
1224
+ if (result._directCommand) {
1225
+ telemetry.capture('bot_direct_command', { query: query.toLowerCase().slice(0, 50) }, authorSlug);
1226
+ } else if (result._promptQuery) {
1227
+ telemetry.capture('bot_prompt_query', { found: true }, authorSlug);
1228
+ } else if (result._telemetry) {
1229
+ telemetry.capture('bot_query', result._telemetry, authorSlug);
1230
+ }
1231
+
1232
+ // Split long responses into chunks
1233
+ const chunks = chunkMessage(result.text, MAX_RESPONSE_LENGTH);
1234
+ for (const chunk of chunks) {
1235
+ await client.chat.postMessage({
1236
+ channel,
1237
+ thread_ts: threadTs,
1238
+ text: chunk,
1239
+ });
1240
+ }
1241
+ } catch (err) {
1242
+ console.error(`Query failed: ${err.message}`);
1243
+ await client.chat.postMessage({
1244
+ channel,
1245
+ thread_ts: threadTs,
1246
+ text: `Something went wrong while processing your query: ${err.message}`,
1247
+ });
1248
+ }
1249
+ });
1250
+
1251
+ // Listen for threaded replies in conversations the bot is already in.
1252
+ // This lets users ask follow-up questions without re-mentioning @Wayfind.
1253
+ app.event('message', async ({ event, client }) => {
1254
+ // Only respond to threaded replies (not top-level messages)
1255
+ if (!event.thread_ts) return;
1256
+ // Ignore bot's own messages
1257
+ if (event.user === botUserId || event.bot_id) return;
1258
+ // Ignore messages that mention the bot (handled by app_mention)
1259
+ if (event.text && event.text.includes(`<@${botUserId}>`)) return;
1260
+ // Ignore message subtypes (edits, joins, etc.)
1261
+ if (event.subtype) return;
1262
+
1263
+ // Check if this is a reply to a digest message — capture as feedback
1264
+ const feedbackRecorded = contentStore.recordDigestFeedbackText({
1265
+ messageTs: event.thread_ts,
1266
+ user: event.user,
1267
+ text: event.text ? event.text.trim() : '',
1268
+ storePath: config.store_path,
1269
+ });
1270
+ if (feedbackRecorded) {
1271
+ const feedbackAuthor = telemetry.resolveSlackUser(event.user, membersDir);
1272
+ telemetry.capture('digest_feedback', { text_length: event.text ? event.text.length : 0 }, feedbackAuthor);
1273
+ try {
1274
+ await client.reactions.add({ channel: event.channel, timestamp: event.ts, name: 'memo' });
1275
+ } catch { /* non-critical */ }
1276
+ return; // Don't process as a bot query
1277
+ }
1278
+
1279
+ // Check if the bot has already replied in this thread
1280
+ let botInThread = false;
1281
+ try {
1282
+ const result = await client.conversations.replies({
1283
+ channel: event.channel,
1284
+ ts: event.thread_ts,
1285
+ limit: 20,
1286
+ });
1287
+ botInThread = (result.messages || []).some(
1288
+ (m) => m.user === botUserId || (m.bot_id && m.user === botUserId)
1289
+ );
1290
+ } catch {
1291
+ // Can't read thread — skip
1292
+ return;
1293
+ }
1294
+
1295
+ if (!botInThread) return;
1296
+
1297
+ const channel = event.channel;
1298
+ const threadTs = event.thread_ts;
1299
+ const query = event.text ? event.text.trim() : '';
1300
+ if (!query) return;
1301
+
1302
+ // Acknowledge
1303
+ try {
1304
+ await client.reactions.add({ channel, timestamp: event.ts, name: 'eyes' });
1305
+ } catch { /* non-critical */ }
1306
+
1307
+ try {
1308
+ // Fetch thread history for context
1309
+ const threadHistory = await fetchThreadHistory(client, channel, threadTs, botUserId, event.ts);
1310
+ const result = await handleQuery(query, config, threadHistory);
1311
+ const authorSlug = telemetry.resolveSlackUser(event.user, membersDir);
1312
+ if (result._directCommand) {
1313
+ telemetry.capture('bot_direct_command', { query: query.toLowerCase().slice(0, 50) }, authorSlug);
1314
+ } else if (result._promptQuery) {
1315
+ telemetry.capture('bot_prompt_query', { found: true }, authorSlug);
1316
+ } else if (result._telemetry) {
1317
+ telemetry.capture('bot_query', result._telemetry, authorSlug);
1318
+ }
1319
+
1320
+ const chunks = chunkMessage(result.text, MAX_RESPONSE_LENGTH);
1321
+ for (const chunk of chunks) {
1322
+ await client.chat.postMessage({ channel, thread_ts: threadTs, text: chunk });
1323
+ }
1324
+ } catch (err) {
1325
+ console.error(`Thread reply query failed: ${err.message}`);
1326
+ await client.chat.postMessage({
1327
+ channel,
1328
+ thread_ts: threadTs,
1329
+ text: `Something went wrong: ${err.message}`,
1330
+ });
1331
+ }
1332
+ });
1333
+
1334
+ // ── Reaction tracking for digest feedback ──────────────────────────────────
1335
+ app.event('reaction_added', async ({ event }) => {
1336
+ try {
1337
+ const recorded = contentStore.recordDigestReaction({
1338
+ messageTs: event.item.ts,
1339
+ reaction: event.reaction,
1340
+ delta: 1,
1341
+ storePath: config.store_path,
1342
+ });
1343
+ if (recorded) {
1344
+ const reactionAuthor = telemetry.resolveSlackUser(event.user, membersDir);
1345
+ telemetry.capture('digest_reaction', { reaction_emoji: event.reaction, delta: 1 }, reactionAuthor);
1346
+ }
1347
+ } catch (err) {
1348
+ // Silently ignore — reaction may not be on a tracked digest message
1349
+ }
1350
+ });
1351
+
1352
+ app.event('reaction_removed', async ({ event }) => {
1353
+ try {
1354
+ const recorded = contentStore.recordDigestReaction({
1355
+ messageTs: event.item.ts,
1356
+ reaction: event.reaction,
1357
+ delta: -1,
1358
+ storePath: config.store_path,
1359
+ });
1360
+ if (recorded) {
1361
+ const reactionAuthor = telemetry.resolveSlackUser(event.user, membersDir);
1362
+ telemetry.capture('digest_reaction', { reaction_emoji: event.reaction, delta: -1 }, reactionAuthor);
1363
+ }
1364
+ } catch (err) {
1365
+ // Silently ignore
1366
+ }
1367
+ });
1368
+
1369
+ await app.start();
1370
+
1371
+ // Track Socket Mode WebSocket connection state for healthcheck
1372
+ slackConnected = true;
1373
+ slackLastConnected = new Date().toISOString();
1374
+ if (app.receiver && app.receiver.client) {
1375
+ const socketClient = app.receiver.client;
1376
+ socketClient.on('connected', () => {
1377
+ slackConnected = true;
1378
+ slackLastConnected = new Date().toISOString();
1379
+ console.log(`[${slackLastConnected}] Slack WebSocket reconnected`);
1380
+ });
1381
+ socketClient.on('disconnected', () => {
1382
+ slackConnected = false;
1383
+ slackLastDisconnected = new Date().toISOString();
1384
+ console.log(`[${slackLastDisconnected}] Slack WebSocket disconnected`);
1385
+ });
1386
+ }
1387
+
1388
+ console.log('Wayfind bot connected to Slack (Socket Mode)');
1389
+ if (botUserId) {
1390
+ console.log(`Bot user ID: ${botUserId}`);
1391
+ }
1392
+
1393
+ // Graceful shutdown
1394
+ const shutdown = async () => {
1395
+ console.log('\nShutting down Wayfind bot...');
1396
+ await app.stop();
1397
+ process.exit(0);
1398
+ };
1399
+ process.on('SIGINT', shutdown);
1400
+ process.on('SIGTERM', shutdown);
1401
+
1402
+ return {
1403
+ app,
1404
+ stop: () => app.stop(),
1405
+ };
1406
+ }
1407
+
1408
+ /**
1409
+ * Returns current Slack WebSocket connection state for healthcheck integration.
1410
+ */
1411
+ function getConnectionStatus() {
1412
+ return {
1413
+ connected: slackConnected,
1414
+ lastConnected: slackLastConnected,
1415
+ lastDisconnected: slackLastDisconnected,
1416
+ };
1417
+ }
1418
+
1419
+ // ── Configure ────────────────────────────────────────────────────────────────
1420
+
1421
+ /**
1422
+ * Interactive setup for the Slack bot.
1423
+ * Prompts for tokens, validates format, saves to .env and returns config.
1424
+ * @returns {Promise<Object>} - Bot configuration for connectors.json
1425
+ */
1426
+ async function configure() {
1427
+ console.log('');
1428
+ console.log('Wayfind Slack Bot Configuration');
1429
+ console.log('================================');
1430
+ console.log('');
1431
+ console.log('Prerequisites:');
1432
+ console.log(' 1. Create a Slack app at https://api.slack.com/apps');
1433
+ console.log(' 2. Enable Socket Mode (Settings > Socket Mode)');
1434
+ console.log(' 3. Add an App-Level Token with "connections:write" scope');
1435
+ console.log(' 4. Add Bot Token Scopes: app_mentions:read, chat:write, reactions:read, reactions:write');
1436
+ console.log(' 5. Subscribe to bot events: app_mention, reaction_added, reaction_removed');
1437
+ console.log(' 6. Install the app to your workspace');
1438
+ console.log('');
1439
+
1440
+ // App-Level Token
1441
+ const appToken = await ask('App-Level Token (xapp-...): ');
1442
+ if (!appToken.startsWith('xapp-')) {
1443
+ console.error('Error: App-Level Token must start with "xapp-".');
1444
+ process.exit(1);
1445
+ }
1446
+
1447
+ // Bot Token
1448
+ const botToken = await ask('Bot User OAuth Token (xoxb-...): ');
1449
+ if (!botToken.startsWith('xoxb-')) {
1450
+ console.error('Error: Bot Token must start with "xoxb-".');
1451
+ process.exit(1);
1452
+ }
1453
+
1454
+ // Save tokens to .env
1455
+ saveEnvKey('SLACK_APP_TOKEN', appToken);
1456
+ saveEnvKey('SLACK_BOT_TOKEN', botToken);
1457
+
1458
+ // LLM config — detect or reuse from digest
1459
+ let llmConfig = {};
1460
+ const connectorsFile = path.join(WAYFIND_DIR, 'connectors.json');
1461
+ try {
1462
+ const connectors = JSON.parse(fs.readFileSync(connectorsFile, 'utf8'));
1463
+ if (connectors.digest && connectors.digest.llm) {
1464
+ console.log(`\nFound existing LLM config from digest: ${connectors.digest.llm.provider}`);
1465
+ const reuse = await ask('Reuse this LLM config for the bot? (Y/n): ');
1466
+ if (!reuse || reuse.toLowerCase() !== 'n') {
1467
+ llmConfig = { ...connectors.digest.llm };
1468
+ }
1469
+ }
1470
+ } catch {
1471
+ // No existing config — detect
1472
+ }
1473
+
1474
+ if (!llmConfig.provider) {
1475
+ const detected = await llm.detect();
1476
+ if (detected) {
1477
+ console.log(`\nDetected: ${detected.provider} (${detected.model || 'default model'})`);
1478
+ const useDetected = await ask(`Use ${detected.provider}? (Y/n): `);
1479
+ if (!useDetected || useDetected.toLowerCase() !== 'n') {
1480
+ llmConfig = detected;
1481
+ }
1482
+ }
1483
+
1484
+ if (!llmConfig.provider) {
1485
+ console.log('\nAvailable providers: anthropic, openai, cli');
1486
+ const provider = await ask('LLM provider: ');
1487
+ llmConfig.provider = provider;
1488
+
1489
+ if (provider === 'anthropic') {
1490
+ llmConfig.model = (await ask('Model (default: claude-sonnet-4-5-20250929): ')) || 'claude-sonnet-4-5-20250929';
1491
+ llmConfig.api_key_env = 'ANTHROPIC_API_KEY';
1492
+ } else if (provider === 'openai') {
1493
+ llmConfig.model = (await ask('Model (default: gpt-4o-mini): ')) || 'gpt-4o-mini';
1494
+ llmConfig.api_key_env = (await ask('API key env var (default: OPENAI_API_KEY): ')) || 'OPENAI_API_KEY';
1495
+ } else if (provider === 'cli') {
1496
+ llmConfig.command = await ask('Command (e.g. "ollama run llama3.2"): ');
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ const config = {
1502
+ mode: 'local',
1503
+ bot_token_env: 'SLACK_BOT_TOKEN',
1504
+ app_token_env: 'SLACK_APP_TOKEN',
1505
+ llm: llmConfig,
1506
+ store_path: null,
1507
+ configured_at: new Date().toISOString(),
1508
+ };
1509
+
1510
+ console.log('');
1511
+ console.log('Slack bot configured successfully.');
1512
+ console.log('');
1513
+ console.log('Start the bot with:');
1514
+ console.log(' wayfind bot');
1515
+ console.log('');
1516
+
1517
+ return config;
1518
+ }
1519
+
1520
+ // ── Exports ──────────────────────────────────────────────────────────────────
1521
+
1522
+ module.exports = {
1523
+ configure,
1524
+ start,
1525
+ getConnectionStatus,
1526
+ handleQuery,
1527
+ handleDirectCommand,
1528
+ formatResponse,
1529
+ extractQuery,
1530
+ chunkMessage,
1531
+ fetchThreadHistory,
1532
+ formatThreadContext,
1533
+ classifyIntent,
1534
+ searchSignals,
1535
+ };