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.
- package/BOOTSTRAP_PROMPT.md +120 -0
- package/bin/connectors/github.js +617 -0
- package/bin/connectors/index.js +13 -0
- package/bin/connectors/intercom.js +595 -0
- package/bin/connectors/llm.js +469 -0
- package/bin/connectors/notion.js +747 -0
- package/bin/connectors/transport.js +325 -0
- package/bin/content-store.js +2006 -0
- package/bin/digest.js +813 -0
- package/bin/rebuild-status.js +297 -0
- package/bin/slack-bot.js +1535 -0
- package/bin/slack.js +342 -0
- package/bin/storage/index.js +171 -0
- package/bin/storage/json-backend.js +348 -0
- package/bin/storage/sqlite-backend.js +415 -0
- package/bin/team-context.js +4209 -0
- package/bin/telemetry.js +159 -0
- package/doctor.sh +291 -0
- package/install.sh +144 -0
- package/journal-summary.sh +577 -0
- package/package.json +48 -6
- package/setup.sh +641 -0
- package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
- package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
- package/specializations/claude-code/README.md +99 -0
- package/specializations/claude-code/commands/doctor.md +31 -0
- package/specializations/claude-code/commands/init-memory.md +154 -0
- package/specializations/claude-code/commands/init-team.md +415 -0
- package/specializations/claude-code/commands/journal.md +66 -0
- package/specializations/claude-code/commands/review-prs.md +119 -0
- package/specializations/claude-code/hooks/check-global-state.sh +20 -0
- package/specializations/claude-code/hooks/session-end.sh +36 -0
- package/specializations/claude-code/settings.json +15 -0
- package/specializations/cursor/README.md +120 -0
- package/specializations/cursor/global-rule.mdc +53 -0
- package/specializations/cursor/repo-rule.mdc +25 -0
- package/specializations/generic/README.md +47 -0
- package/templates/autopilot/design.md +22 -0
- package/templates/autopilot/engineering.md +22 -0
- package/templates/autopilot/product.md +22 -0
- package/templates/autopilot/strategy.md +22 -0
- package/templates/autopilot/unified.md +24 -0
- package/templates/deploy/.env.example +110 -0
- package/templates/deploy/docker-compose.yml +63 -0
- package/templates/deploy/slack-app-manifest.json +45 -0
- package/templates/github-actions/meridian-digest.yml +85 -0
- package/templates/global.md +79 -0
- package/templates/memory-file.md +18 -0
- package/templates/personal-state.md +14 -0
- package/templates/personas.json +28 -0
- package/templates/product-state.md +41 -0
- package/templates/prompts-readme.md +19 -0
- package/templates/repo-state.md +18 -0
- package/templates/session-protocol-fragment.md +46 -0
- package/templates/slack-app-manifest.json +27 -0
- package/templates/statusline.sh +22 -0
- package/templates/strategy-state.md +39 -0
- package/templates/team-state.md +55 -0
- package/uninstall.sh +105 -0
- package/README.md +0 -4
package/bin/slack-bot.js
ADDED
|
@@ -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
|
+
};
|