wayfind 0.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/BOOTSTRAP_PROMPT.md +120 -0
  2. package/bin/connectors/github.js +617 -0
  3. package/bin/connectors/index.js +13 -0
  4. package/bin/connectors/intercom.js +595 -0
  5. package/bin/connectors/llm.js +469 -0
  6. package/bin/connectors/notion.js +747 -0
  7. package/bin/connectors/transport.js +325 -0
  8. package/bin/content-store.js +2006 -0
  9. package/bin/digest.js +813 -0
  10. package/bin/rebuild-status.js +297 -0
  11. package/bin/slack-bot.js +1535 -0
  12. package/bin/slack.js +342 -0
  13. package/bin/storage/index.js +171 -0
  14. package/bin/storage/json-backend.js +348 -0
  15. package/bin/storage/sqlite-backend.js +415 -0
  16. package/bin/team-context.js +4209 -0
  17. package/bin/telemetry.js +159 -0
  18. package/doctor.sh +291 -0
  19. package/install.sh +144 -0
  20. package/journal-summary.sh +577 -0
  21. package/package.json +48 -6
  22. package/setup.sh +641 -0
  23. package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
  24. package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
  25. package/specializations/claude-code/README.md +99 -0
  26. package/specializations/claude-code/commands/doctor.md +31 -0
  27. package/specializations/claude-code/commands/init-memory.md +154 -0
  28. package/specializations/claude-code/commands/init-team.md +415 -0
  29. package/specializations/claude-code/commands/journal.md +66 -0
  30. package/specializations/claude-code/commands/review-prs.md +119 -0
  31. package/specializations/claude-code/hooks/check-global-state.sh +20 -0
  32. package/specializations/claude-code/hooks/session-end.sh +36 -0
  33. package/specializations/claude-code/settings.json +15 -0
  34. package/specializations/cursor/README.md +120 -0
  35. package/specializations/cursor/global-rule.mdc +53 -0
  36. package/specializations/cursor/repo-rule.mdc +25 -0
  37. package/specializations/generic/README.md +47 -0
  38. package/templates/autopilot/design.md +22 -0
  39. package/templates/autopilot/engineering.md +22 -0
  40. package/templates/autopilot/product.md +22 -0
  41. package/templates/autopilot/strategy.md +22 -0
  42. package/templates/autopilot/unified.md +24 -0
  43. package/templates/deploy/.env.example +110 -0
  44. package/templates/deploy/docker-compose.yml +63 -0
  45. package/templates/deploy/slack-app-manifest.json +45 -0
  46. package/templates/github-actions/meridian-digest.yml +85 -0
  47. package/templates/global.md +79 -0
  48. package/templates/memory-file.md +18 -0
  49. package/templates/personal-state.md +14 -0
  50. package/templates/personas.json +28 -0
  51. package/templates/product-state.md +41 -0
  52. package/templates/prompts-readme.md +19 -0
  53. package/templates/repo-state.md +18 -0
  54. package/templates/session-protocol-fragment.md +46 -0
  55. package/templates/slack-app-manifest.json +27 -0
  56. package/templates/statusline.sh +22 -0
  57. package/templates/strategy-state.md +39 -0
  58. package/templates/team-state.md +55 -0
  59. package/uninstall.sh +105 -0
  60. package/README.md +0 -4
@@ -0,0 +1,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
+ };