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
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
|
|
8
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
9
|
+
const WAYFIND_DIR = path.join(HOME, '.claude', 'team-context');
|
|
10
|
+
const SIGNALS_DIR = path.join(WAYFIND_DIR, 'signals');
|
|
11
|
+
|
|
12
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function ask(question) {
|
|
15
|
+
const rl = readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
});
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
rl.question(question, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function localDateStr(d) {
|
|
28
|
+
const y = d.getFullYear();
|
|
29
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
30
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
31
|
+
return `${y}-${m}-${day}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function today() {
|
|
35
|
+
return localDateStr(new Date());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function daysAgo(n) {
|
|
39
|
+
const d = new Date();
|
|
40
|
+
d.setDate(d.getDate() - n);
|
|
41
|
+
return localDateStr(d);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toUnixTimestamp(dateStr) {
|
|
45
|
+
const ms = new Date(dateStr + 'T00:00:00Z').getTime();
|
|
46
|
+
if (isNaN(ms)) throw new Error(`Invalid date: "${dateStr}"`);
|
|
47
|
+
return Math.floor(ms / 1000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeForMarkdown(text) {
|
|
51
|
+
return text.replace(/<[^>]*>/g, '').replace(/\|/g, '\\|');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSimulation() {
|
|
55
|
+
return process.env.TEAM_CONTEXT_SIMULATE === '1';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getFixturesDir() {
|
|
59
|
+
return process.env.TEAM_CONTEXT_SIM_FIXTURES || '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Intercom API transport ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function intercomGet(token, endpoint) {
|
|
65
|
+
if (isSimulation()) {
|
|
66
|
+
return loadFixture(endpoint);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const reqOpts = {
|
|
71
|
+
hostname: 'api.intercom.io',
|
|
72
|
+
path: endpoint,
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {
|
|
75
|
+
'Authorization': `Bearer ${token}`,
|
|
76
|
+
'Accept': 'application/json',
|
|
77
|
+
'Intercom-Version': '2.11',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const req = https.request(reqOpts, (res) => {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
84
|
+
res.on('error', (err) => reject(new Error(`Response error: ${err.message}`)));
|
|
85
|
+
res.on('end', () => {
|
|
86
|
+
const body = Buffer.concat(chunks).toString();
|
|
87
|
+
if (res.statusCode === 401) {
|
|
88
|
+
reject(new Error('Intercom API: unauthorized. Check your access token.'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (res.statusCode === 429) {
|
|
92
|
+
reject(new Error('Intercom API: rate limited. Try again in a few minutes.'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
96
|
+
reject(new Error(`Intercom API returned ${res.statusCode}`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
resolve(JSON.parse(body));
|
|
101
|
+
} catch (parseErr) {
|
|
102
|
+
reject(new Error(`Failed to parse Intercom API response: ${parseErr.message}`));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
req.setTimeout(30000, () => {
|
|
108
|
+
req.destroy();
|
|
109
|
+
reject(new Error('Intercom API request timed out (30s)'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
req.end();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function intercomPost(token, endpoint, body) {
|
|
118
|
+
if (isSimulation()) {
|
|
119
|
+
return loadFixture(endpoint);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const data = JSON.stringify(body);
|
|
124
|
+
const reqOpts = {
|
|
125
|
+
hostname: 'api.intercom.io',
|
|
126
|
+
path: endpoint,
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
'Authorization': `Bearer ${token}`,
|
|
130
|
+
'Accept': 'application/json',
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
'Content-Length': Buffer.byteLength(data),
|
|
133
|
+
'Intercom-Version': '2.11',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const req = https.request(reqOpts, (res) => {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
140
|
+
res.on('error', (err) => reject(new Error(`Response error: ${err.message}`)));
|
|
141
|
+
res.on('end', () => {
|
|
142
|
+
const respBody = Buffer.concat(chunks).toString();
|
|
143
|
+
if (res.statusCode === 401) {
|
|
144
|
+
reject(new Error('Intercom API: unauthorized. Check your access token.'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (res.statusCode === 429) {
|
|
148
|
+
reject(new Error('Intercom API: rate limited. Try again in a few minutes.'));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
152
|
+
reject(new Error(`Intercom API returned ${res.statusCode}`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
resolve(JSON.parse(respBody));
|
|
157
|
+
} catch (parseErr) {
|
|
158
|
+
reject(new Error(`Failed to parse Intercom API response: ${parseErr.message}`));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
req.setTimeout(30000, () => {
|
|
164
|
+
req.destroy();
|
|
165
|
+
reject(new Error('Intercom API request timed out (30s)'));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
req.on('error', reject);
|
|
169
|
+
req.write(data);
|
|
170
|
+
req.end();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Simulation fixtures ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function loadFixture(endpoint) {
|
|
177
|
+
const fixturesDir = getFixturesDir();
|
|
178
|
+
if (!fixturesDir) {
|
|
179
|
+
return Promise.resolve({ conversations: [], pages: {} });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Map endpoints to fixture files
|
|
183
|
+
if (endpoint.includes('/conversations/search') || endpoint.includes('/conversations')) {
|
|
184
|
+
const fixturePath = path.join(fixturesDir, 'conversations.json');
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
187
|
+
// Wrap raw array in Intercom-like response if needed
|
|
188
|
+
if (Array.isArray(data)) {
|
|
189
|
+
return Promise.resolve({
|
|
190
|
+
type: 'conversation.list',
|
|
191
|
+
conversations: data,
|
|
192
|
+
total_count: data.length,
|
|
193
|
+
pages: { type: 'pages', total_pages: 1 },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return Promise.resolve(data);
|
|
197
|
+
} catch {
|
|
198
|
+
return Promise.resolve({ conversations: [], pages: {} });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (endpoint.includes('/tags')) {
|
|
203
|
+
const fixturePath = path.join(fixturesDir, 'tags.json');
|
|
204
|
+
try {
|
|
205
|
+
return Promise.resolve(JSON.parse(fs.readFileSync(fixturePath, 'utf8')));
|
|
206
|
+
} catch {
|
|
207
|
+
return Promise.resolve({ type: 'list', data: [] });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return Promise.resolve({});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Configure ───────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
async function configure() {
|
|
217
|
+
console.log('');
|
|
218
|
+
console.log('Intercom Connector Setup');
|
|
219
|
+
console.log('');
|
|
220
|
+
console.log('You need an Intercom Access Token.');
|
|
221
|
+
console.log('Find it at: Settings > Developers > Your App > Authentication');
|
|
222
|
+
console.log('Required scopes: Read conversations, Read tags');
|
|
223
|
+
console.log('');
|
|
224
|
+
|
|
225
|
+
const token = await ask('Intercom Access Token: ');
|
|
226
|
+
if (!token) {
|
|
227
|
+
throw new Error('An access token is required.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Optional: inbox filter
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log('Optional: filter to specific tags (comma-separated, or leave blank for all)');
|
|
233
|
+
const tagFilter = await ask('Tag filter: ');
|
|
234
|
+
const tags = tagFilter
|
|
235
|
+
.split(',')
|
|
236
|
+
.map((t) => t.trim())
|
|
237
|
+
.filter(Boolean);
|
|
238
|
+
|
|
239
|
+
const channelConfig = {
|
|
240
|
+
transport: 'https',
|
|
241
|
+
token,
|
|
242
|
+
tag_filter: tags.length > 0 ? tags : null,
|
|
243
|
+
last_pull: null,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log('Intercom connector configured.');
|
|
248
|
+
if (tags.length > 0) {
|
|
249
|
+
console.log(`Tag filter: ${tags.join(', ')}`);
|
|
250
|
+
}
|
|
251
|
+
console.log('');
|
|
252
|
+
|
|
253
|
+
return channelConfig;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Pull ────────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
async function pull(config, since) {
|
|
259
|
+
const sinceDate = since || daysAgo(7);
|
|
260
|
+
const todayDate = today();
|
|
261
|
+
const timestamp = new Date().toISOString();
|
|
262
|
+
const token = config.token || (config.token_env ? process.env[config.token_env] : '') || '';
|
|
263
|
+
|
|
264
|
+
if (!token && !isSimulation()) {
|
|
265
|
+
throw new Error('Intercom token is missing. Run "wayfind pull intercom --configure" to set it up.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Fetch conversations
|
|
269
|
+
const conversations = await fetchConversations(token, sinceDate);
|
|
270
|
+
|
|
271
|
+
// Apply tag filter if configured
|
|
272
|
+
let filtered = conversations;
|
|
273
|
+
if (config.tag_filter && config.tag_filter.length > 0) {
|
|
274
|
+
const allowedTags = new Set(config.tag_filter.map((t) => t.toLowerCase()));
|
|
275
|
+
filtered = conversations.filter((conv) => {
|
|
276
|
+
const convTags = extractTags(conv);
|
|
277
|
+
return convTags.some((t) => allowedTags.has(t.toLowerCase()));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Analyze patterns
|
|
282
|
+
const analysis = analyzeConversations(filtered, sinceDate, todayDate);
|
|
283
|
+
|
|
284
|
+
// Generate markdown
|
|
285
|
+
const md = generateMarkdown(analysis, sinceDate, todayDate, timestamp);
|
|
286
|
+
|
|
287
|
+
// Write signal file
|
|
288
|
+
const signalDir = path.join(SIGNALS_DIR, 'intercom');
|
|
289
|
+
fs.mkdirSync(signalDir, { recursive: true });
|
|
290
|
+
const signalFile = path.join(signalDir, `${todayDate}.md`);
|
|
291
|
+
fs.writeFileSync(signalFile, md, 'utf8');
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
files: [signalFile],
|
|
295
|
+
summary: generateSummaryText(analysis),
|
|
296
|
+
counts: {
|
|
297
|
+
conversations: filtered.length,
|
|
298
|
+
open: analysis.openCount,
|
|
299
|
+
tags: analysis.sortedTags.length,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Data fetching ───────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
async function fetchConversations(token, sinceDate) {
|
|
307
|
+
const sinceTimestamp = toUnixTimestamp(sinceDate);
|
|
308
|
+
|
|
309
|
+
// Use search endpoint to filter by created_at
|
|
310
|
+
const body = {
|
|
311
|
+
query: {
|
|
312
|
+
field: 'created_at',
|
|
313
|
+
operator: '>=',
|
|
314
|
+
value: sinceTimestamp,
|
|
315
|
+
},
|
|
316
|
+
pagination: {
|
|
317
|
+
per_page: 150,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const allConversations = [];
|
|
322
|
+
let response = await intercomPost(token, '/conversations/search', body);
|
|
323
|
+
const conversations = Array.isArray(response.conversations) ? response.conversations : [];
|
|
324
|
+
allConversations.push(...conversations);
|
|
325
|
+
|
|
326
|
+
// Handle pagination (safety bound to prevent infinite loops)
|
|
327
|
+
const MAX_PAGES = 50;
|
|
328
|
+
let pageCount = 0;
|
|
329
|
+
let pages = response.pages || {};
|
|
330
|
+
while (pages.next && pageCount < MAX_PAGES) {
|
|
331
|
+
pageCount++;
|
|
332
|
+
const nextBody = {
|
|
333
|
+
...body,
|
|
334
|
+
pagination: {
|
|
335
|
+
...body.pagination,
|
|
336
|
+
starting_after: pages.next.starting_after,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
response = await intercomPost(token, '/conversations/search', nextBody);
|
|
340
|
+
const pageConversations = Array.isArray(response.conversations) ? response.conversations : [];
|
|
341
|
+
allConversations.push(...pageConversations);
|
|
342
|
+
pages = response.pages || {};
|
|
343
|
+
}
|
|
344
|
+
if (pageCount >= MAX_PAGES) {
|
|
345
|
+
console.warn(`Warning: pagination hit safety limit (${MAX_PAGES} pages). Some conversations may be missing.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return allConversations;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Analysis ────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
function extractTags(conv) {
|
|
354
|
+
if (!conv.tags) return [];
|
|
355
|
+
// Intercom tags can be { type: 'tag.list', tags: [...] } or { tags: [...] }
|
|
356
|
+
const tagList = conv.tags.tags || conv.tags.data || conv.tags;
|
|
357
|
+
if (!Array.isArray(tagList)) return [];
|
|
358
|
+
return tagList.map((t) => (typeof t === 'string' ? t : t.name || t.id || '')).filter(Boolean);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function extractTitle(conv) {
|
|
362
|
+
// Intercom conversations may have a title or source.subject
|
|
363
|
+
// Never fall through to source.body — it contains raw customer messages (PII risk)
|
|
364
|
+
if (conv.title) return conv.title;
|
|
365
|
+
if (conv.source && conv.source.subject) return conv.source.subject;
|
|
366
|
+
return conv.id ? `(conversation #${conv.id})` : '(no subject)';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function analyzeConversations(conversations, sinceDate, todayDate) {
|
|
370
|
+
const tagCounts = {};
|
|
371
|
+
const stateCounts = { open: 0, closed: 0, snoozed: 0 };
|
|
372
|
+
const topicPatterns = {};
|
|
373
|
+
const dailyCounts = {};
|
|
374
|
+
let totalFirstResponseMs = 0;
|
|
375
|
+
let firstResponseCount = 0;
|
|
376
|
+
|
|
377
|
+
for (const conv of conversations) {
|
|
378
|
+
// State
|
|
379
|
+
const state = conv.state || (conv.open === true ? 'open' : conv.open === false ? 'closed' : 'unknown');
|
|
380
|
+
if (stateCounts[state] !== undefined) {
|
|
381
|
+
stateCounts[state]++;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Tags
|
|
385
|
+
const tags = extractTags(conv);
|
|
386
|
+
for (const tag of tags) {
|
|
387
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Daily volume
|
|
391
|
+
const createdDate = conv.created_at
|
|
392
|
+
? (typeof conv.created_at === 'number'
|
|
393
|
+
? new Date(conv.created_at * 1000).toISOString().slice(0, 10)
|
|
394
|
+
: new Date(conv.created_at).toISOString().slice(0, 10))
|
|
395
|
+
: null;
|
|
396
|
+
if (createdDate) {
|
|
397
|
+
dailyCounts[createdDate] = (dailyCounts[createdDate] || 0) + 1;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Title-based topic clustering (simple keyword extraction)
|
|
401
|
+
const title = extractTitle(conv).toLowerCase();
|
|
402
|
+
const keywords = extractKeywords(title);
|
|
403
|
+
for (const kw of keywords) {
|
|
404
|
+
topicPatterns[kw] = (topicPatterns[kw] || 0) + 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// First response time from statistics
|
|
408
|
+
if (conv.statistics && conv.statistics.first_contact_reply_at && conv.created_at) {
|
|
409
|
+
const created = typeof conv.created_at === 'number' ? conv.created_at : new Date(conv.created_at).getTime() / 1000;
|
|
410
|
+
const replied = typeof conv.statistics.first_contact_reply_at === 'number'
|
|
411
|
+
? conv.statistics.first_contact_reply_at
|
|
412
|
+
: new Date(conv.statistics.first_contact_reply_at).getTime() / 1000;
|
|
413
|
+
if (replied > created) {
|
|
414
|
+
totalFirstResponseMs += (replied - created);
|
|
415
|
+
firstResponseCount++;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Sort tags by count
|
|
421
|
+
const sortedTags = Object.entries(tagCounts)
|
|
422
|
+
.sort((a, b) => b[1] - a[1]);
|
|
423
|
+
|
|
424
|
+
// Sort topics by frequency, take top 10
|
|
425
|
+
const sortedTopics = Object.entries(topicPatterns)
|
|
426
|
+
.filter(([, count]) => count >= 2) // Only topics mentioned 2+ times
|
|
427
|
+
.sort((a, b) => b[1] - a[1])
|
|
428
|
+
.slice(0, 10);
|
|
429
|
+
|
|
430
|
+
// Average first response time
|
|
431
|
+
const avgFirstResponseHours = firstResponseCount > 0
|
|
432
|
+
? (totalFirstResponseMs / firstResponseCount / 3600)
|
|
433
|
+
: null;
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
total: conversations.length,
|
|
437
|
+
openCount: stateCounts.open,
|
|
438
|
+
closedCount: stateCounts.closed,
|
|
439
|
+
snoozedCount: stateCounts.snoozed,
|
|
440
|
+
sortedTags,
|
|
441
|
+
sortedTopics,
|
|
442
|
+
dailyCounts,
|
|
443
|
+
avgFirstResponseHours,
|
|
444
|
+
conversations,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function extractKeywords(text) {
|
|
449
|
+
// Simple keyword extraction: split on non-alpha, filter stopwords, keep 2+ char words
|
|
450
|
+
const stopwords = new Set([
|
|
451
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
452
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
453
|
+
'should', 'may', 'might', 'can', 'to', 'of', 'in', 'for', 'on', 'with',
|
|
454
|
+
'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after',
|
|
455
|
+
'and', 'but', 'or', 'nor', 'not', 'no', 'so', 'if', 'then', 'than',
|
|
456
|
+
'too', 'very', 'just', 'about', 'above', 'all', 'also', 'any', 'each',
|
|
457
|
+
'how', 'what', 'when', 'where', 'which', 'who', 'whom', 'why',
|
|
458
|
+
'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they',
|
|
459
|
+
'this', 'that', 'these', 'those', 'up', 'out', 'get', 'got', 'getting',
|
|
460
|
+
'im', 'dont', 'cant', 'wont',
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
return text
|
|
464
|
+
.split(/[^a-z0-9]+/)
|
|
465
|
+
.filter((w) => w.length >= 3 && !stopwords.has(w));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Markdown generation ─────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
function generateMarkdown(analysis, sinceDate, todayDate, timestamp) {
|
|
471
|
+
const lines = [];
|
|
472
|
+
|
|
473
|
+
lines.push('# Intercom Signals');
|
|
474
|
+
lines.push('');
|
|
475
|
+
lines.push(`**Period:** ${sinceDate} to ${todayDate} `);
|
|
476
|
+
lines.push(`**Pulled:** ${timestamp}`);
|
|
477
|
+
lines.push('');
|
|
478
|
+
|
|
479
|
+
// Volume overview
|
|
480
|
+
lines.push('## Volume');
|
|
481
|
+
lines.push('');
|
|
482
|
+
lines.push(`- **${analysis.total}** conversations in period`);
|
|
483
|
+
lines.push(`- **${analysis.openCount}** open, **${analysis.closedCount}** closed, **${analysis.snoozedCount}** snoozed`);
|
|
484
|
+
if (analysis.avgFirstResponseHours != null) {
|
|
485
|
+
lines.push(`- Avg first response: **${analysis.avgFirstResponseHours.toFixed(1)} hrs**`);
|
|
486
|
+
}
|
|
487
|
+
lines.push('');
|
|
488
|
+
|
|
489
|
+
// Daily volume
|
|
490
|
+
const sortedDays = Object.entries(analysis.dailyCounts).sort((a, b) => a[0].localeCompare(b[0]));
|
|
491
|
+
if (sortedDays.length > 0) {
|
|
492
|
+
lines.push('## Daily Volume');
|
|
493
|
+
lines.push('');
|
|
494
|
+
for (const [day, count] of sortedDays) {
|
|
495
|
+
const bar = '\u2588'.repeat(Math.min(count, 30));
|
|
496
|
+
lines.push(` ${day} ${bar} ${count}`);
|
|
497
|
+
}
|
|
498
|
+
lines.push('');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Top tags
|
|
502
|
+
if (analysis.sortedTags.length > 0) {
|
|
503
|
+
lines.push('## Top Tags');
|
|
504
|
+
lines.push('');
|
|
505
|
+
lines.push('| Tag | Count |');
|
|
506
|
+
lines.push('|-----|-------|');
|
|
507
|
+
for (const [tag, count] of analysis.sortedTags.slice(0, 15)) {
|
|
508
|
+
lines.push(`| ${sanitizeForMarkdown(tag)} | ${count} |`);
|
|
509
|
+
}
|
|
510
|
+
lines.push('');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Recurring topics (privacy-safe: patterns, not individual conversations)
|
|
514
|
+
if (analysis.sortedTopics.length > 0) {
|
|
515
|
+
lines.push('## Recurring Topics');
|
|
516
|
+
lines.push('');
|
|
517
|
+
lines.push('Topics mentioned in 2+ conversations:');
|
|
518
|
+
lines.push('');
|
|
519
|
+
for (const [topic, count] of analysis.sortedTopics) {
|
|
520
|
+
lines.push(`- **${topic}** (${count} conversations)`);
|
|
521
|
+
}
|
|
522
|
+
lines.push('');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Open conversations (privacy-safe: titles and tags only, no raw message content)
|
|
526
|
+
const openConvs = analysis.conversations.filter((c) => {
|
|
527
|
+
const state = c.state || (c.open === true ? 'open' : 'closed');
|
|
528
|
+
return state === 'open';
|
|
529
|
+
});
|
|
530
|
+
const todayMs = new Date(todayDate + 'T00:00:00Z').getTime();
|
|
531
|
+
if (openConvs.length > 0) {
|
|
532
|
+
lines.push('## Open Conversations');
|
|
533
|
+
lines.push('');
|
|
534
|
+
lines.push('| Title | Tags | Age |');
|
|
535
|
+
lines.push('|-------|------|-----|');
|
|
536
|
+
for (const conv of openConvs.slice(0, 20)) {
|
|
537
|
+
const title = sanitizeForMarkdown(extractTitle(conv));
|
|
538
|
+
const tags = extractTags(conv).map((t) => sanitizeForMarkdown(t)).join(', ') || '-';
|
|
539
|
+
const created = conv.created_at
|
|
540
|
+
? (typeof conv.created_at === 'number'
|
|
541
|
+
? new Date(conv.created_at * 1000)
|
|
542
|
+
: new Date(conv.created_at))
|
|
543
|
+
: null;
|
|
544
|
+
const age = created ? `${Math.floor((todayMs - created.getTime()) / (1000 * 60 * 60 * 24))}d` : '-';
|
|
545
|
+
lines.push(`| ${title} | ${tags} | ${age} |`);
|
|
546
|
+
}
|
|
547
|
+
lines.push('');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Summary
|
|
551
|
+
lines.push('## Summary');
|
|
552
|
+
lines.push('');
|
|
553
|
+
lines.push(generateSummaryText(analysis));
|
|
554
|
+
lines.push('');
|
|
555
|
+
|
|
556
|
+
return lines.join('\n');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function generateSummaryText(analysis) {
|
|
560
|
+
const parts = [];
|
|
561
|
+
parts.push(`${analysis.total} conversations (${analysis.openCount} open, ${analysis.closedCount} closed)`);
|
|
562
|
+
|
|
563
|
+
if (analysis.sortedTags.length > 0) {
|
|
564
|
+
const topTags = analysis.sortedTags.slice(0, 3).map(([tag, count]) => `${tag} (${count})`);
|
|
565
|
+
parts.push(`Top tags: ${topTags.join(', ')}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (analysis.sortedTopics.length > 0) {
|
|
569
|
+
const topTopics = analysis.sortedTopics.slice(0, 3).map(([topic, count]) => `${topic} (${count}x)`);
|
|
570
|
+
parts.push(`Recurring: ${topTopics.join(', ')}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (analysis.avgFirstResponseHours != null) {
|
|
574
|
+
parts.push(`Avg first response: ${analysis.avgFirstResponseHours.toFixed(1)} hrs`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return parts.join('\n');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Summarize ───────────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
function summarize(filePath) {
|
|
583
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
584
|
+
const match = content.match(/## Summary\n([\s\S]*?)(?:\n## |\n$|$)/);
|
|
585
|
+
if (!match) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
return match[1].trim();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
module.exports = {
|
|
592
|
+
configure,
|
|
593
|
+
pull,
|
|
594
|
+
summarize,
|
|
595
|
+
};
|