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,747 @@
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 today() {
28
+ const d = new Date();
29
+ const y = d.getFullYear();
30
+ const m = String(d.getMonth() + 1).padStart(2, '0');
31
+ const day = String(d.getDate()).padStart(2, '0');
32
+ return `${y}-${m}-${day}`;
33
+ }
34
+
35
+ function daysAgo(n) {
36
+ const d = new Date();
37
+ d.setDate(d.getDate() - n);
38
+ const y = d.getFullYear();
39
+ const m = String(d.getMonth() + 1).padStart(2, '0');
40
+ const day = String(d.getDate()).padStart(2, '0');
41
+ return `${y}-${m}-${day}`;
42
+ }
43
+
44
+ function sanitizeForMarkdown(text) {
45
+ return (text || '').replace(/<[^>]*>/g, '').replace(/\|/g, '\\|');
46
+ }
47
+
48
+ function isSimulation() {
49
+ return process.env.TEAM_CONTEXT_SIMULATE === '1';
50
+ }
51
+
52
+ function getFixturesDir() {
53
+ return process.env.TEAM_CONTEXT_SIM_NOTION_FIXTURES
54
+ || process.env.TEAM_CONTEXT_SIM_FIXTURES
55
+ || '';
56
+ }
57
+
58
+ // ── Notion API transport ────────────────────────────────────────────────────
59
+
60
+ function notionPost(token, endpoint, body) {
61
+ if (isSimulation()) {
62
+ return loadFixture(endpoint);
63
+ }
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const data = JSON.stringify(body);
67
+ const reqOpts = {
68
+ hostname: 'api.notion.com',
69
+ path: `/v1${endpoint}`,
70
+ method: 'POST',
71
+ headers: {
72
+ 'Authorization': `Bearer ${token}`,
73
+ 'Content-Type': 'application/json',
74
+ 'Notion-Version': '2022-06-28',
75
+ 'Content-Length': Buffer.byteLength(data),
76
+ },
77
+ };
78
+
79
+ const req = https.request(reqOpts, (res) => {
80
+ const chunks = [];
81
+ res.on('data', (chunk) => chunks.push(chunk));
82
+ res.on('error', (err) => reject(new Error(`Response error: ${err.message}`)));
83
+ res.on('end', () => {
84
+ const respBody = Buffer.concat(chunks).toString();
85
+ if (res.statusCode === 401) {
86
+ reject(new Error('Notion API: unauthorized. Check your integration token.'));
87
+ return;
88
+ }
89
+ if (res.statusCode === 429) {
90
+ reject(new Error('Notion API: rate limited. Try again in a few minutes.'));
91
+ return;
92
+ }
93
+ if (res.statusCode < 200 || res.statusCode >= 300) {
94
+ reject(new Error(`Notion API returned ${res.statusCode}: ${respBody.slice(0, 200)}`));
95
+ return;
96
+ }
97
+ try {
98
+ resolve(JSON.parse(respBody));
99
+ } catch (parseErr) {
100
+ reject(new Error(`Failed to parse Notion API response: ${parseErr.message}`));
101
+ }
102
+ });
103
+ });
104
+
105
+ req.setTimeout(30000, () => {
106
+ req.destroy();
107
+ reject(new Error('Notion API request timed out (30s)'));
108
+ });
109
+
110
+ req.on('error', reject);
111
+ req.write(data);
112
+ req.end();
113
+ });
114
+ }
115
+
116
+ function notionGet(token, endpoint) {
117
+ if (isSimulation()) {
118
+ return loadFixture(endpoint);
119
+ }
120
+
121
+ return new Promise((resolve, reject) => {
122
+ const reqOpts = {
123
+ hostname: 'api.notion.com',
124
+ path: `/v1${endpoint}`,
125
+ method: 'GET',
126
+ headers: {
127
+ 'Authorization': `Bearer ${token}`,
128
+ 'Notion-Version': '2022-06-28',
129
+ },
130
+ };
131
+
132
+ const req = https.request(reqOpts, (res) => {
133
+ const chunks = [];
134
+ res.on('data', (chunk) => chunks.push(chunk));
135
+ res.on('error', (err) => reject(new Error(`Response error: ${err.message}`)));
136
+ res.on('end', () => {
137
+ const body = Buffer.concat(chunks).toString();
138
+ if (res.statusCode < 200 || res.statusCode >= 300) {
139
+ reject(new Error(`Notion API returned ${res.statusCode}`));
140
+ return;
141
+ }
142
+ try {
143
+ resolve(JSON.parse(body));
144
+ } catch (parseErr) {
145
+ reject(new Error(`Failed to parse Notion API response: ${parseErr.message}`));
146
+ }
147
+ });
148
+ });
149
+
150
+ req.setTimeout(30000, () => {
151
+ req.destroy();
152
+ reject(new Error('Notion API request timed out (30s)'));
153
+ });
154
+
155
+ req.on('error', reject);
156
+ req.end();
157
+ });
158
+ }
159
+
160
+ // ── Simulation fixtures ─────────────────────────────────────────────────────
161
+
162
+ function loadFixture(endpoint) {
163
+ const fixturesDir = getFixturesDir();
164
+ if (!fixturesDir) {
165
+ return Promise.resolve({ results: [], has_more: false });
166
+ }
167
+
168
+ if (endpoint.includes('/search')) {
169
+ const fixturePath = path.join(fixturesDir, 'pages.json');
170
+ try {
171
+ const data = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
172
+ if (Array.isArray(data)) {
173
+ return Promise.resolve({ results: data, has_more: false });
174
+ }
175
+ return Promise.resolve(data);
176
+ } catch {
177
+ return Promise.resolve({ results: [], has_more: false });
178
+ }
179
+ }
180
+
181
+ if (endpoint.includes('/query')) {
182
+ const fixturePath = path.join(fixturesDir, 'database_entries.json');
183
+ try {
184
+ const data = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
185
+ if (Array.isArray(data)) {
186
+ return Promise.resolve({ results: data, has_more: false });
187
+ }
188
+ return Promise.resolve(data);
189
+ } catch {
190
+ return Promise.resolve({ results: [], has_more: false });
191
+ }
192
+ }
193
+
194
+ if (endpoint.includes('/comments')) {
195
+ const fixturePath = path.join(fixturesDir, 'comments.json');
196
+ try {
197
+ const data = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
198
+ if (Array.isArray(data)) {
199
+ return Promise.resolve({ results: data, has_more: false });
200
+ }
201
+ return Promise.resolve(data);
202
+ } catch {
203
+ return Promise.resolve({ results: [], has_more: false });
204
+ }
205
+ }
206
+
207
+ if (endpoint.includes('/users')) {
208
+ const fixturePath = path.join(fixturesDir, 'users.json');
209
+ try {
210
+ const data = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
211
+ if (Array.isArray(data)) {
212
+ return Promise.resolve({ results: data, has_more: false });
213
+ }
214
+ return Promise.resolve(data);
215
+ } catch {
216
+ return Promise.resolve({ results: [], has_more: false });
217
+ }
218
+ }
219
+
220
+ return Promise.resolve({ results: [], has_more: false });
221
+ }
222
+
223
+ // ── Configure ───────────────────────────────────────────────────────────────
224
+
225
+ async function configure() {
226
+ console.log('');
227
+ console.log('Notion Connector Setup');
228
+ console.log('');
229
+ console.log('You need a Notion Internal Integration Token.');
230
+ console.log('Create one at: https://www.notion.so/my-integrations');
231
+ console.log('Required capabilities: Read content, Read comments');
232
+ console.log('');
233
+ console.log('After creating the integration, share the pages/databases');
234
+ console.log('you want to monitor with the integration (via the ··· menu → Connections).');
235
+ console.log('');
236
+
237
+ const token = await ask('Notion Integration Token (ntn_...): ');
238
+ if (!token) {
239
+ throw new Error('An integration token is required.');
240
+ }
241
+
242
+ // Optional: database IDs to monitor
243
+ console.log('');
244
+ console.log('Optional: specific database IDs to monitor (comma-separated, or blank for all shared pages).');
245
+ console.log('Find database IDs in the URL: notion.so/<workspace>/<database-id>');
246
+ const dbInput = await ask('Database IDs: ');
247
+ const databases = dbInput
248
+ .split(',')
249
+ .map((d) => d.trim())
250
+ .filter(Boolean);
251
+
252
+ const channelConfig = {
253
+ transport: 'https',
254
+ token,
255
+ token_env: 'NOTION_TOKEN',
256
+ databases: databases.length > 0 ? databases : null,
257
+ last_pull: null,
258
+ };
259
+
260
+ console.log('');
261
+ console.log('Notion connector configured.');
262
+ if (databases.length > 0) {
263
+ console.log(`Monitoring ${databases.length} database(s).`);
264
+ } else {
265
+ console.log('Monitoring all shared pages.');
266
+ }
267
+ console.log('');
268
+
269
+ return channelConfig;
270
+ }
271
+
272
+ // ── Pull ────────────────────────────────────────────────────────────────────
273
+
274
+ async function pull(config, since) {
275
+ const sinceDate = since || daysAgo(7);
276
+ const todayDate = today();
277
+ const timestamp = new Date().toISOString();
278
+ const token = config.token || (config.token_env ? process.env[config.token_env] : '') || '';
279
+
280
+ if (!token && !isSimulation()) {
281
+ throw new Error('Notion token is missing. Run "wayfind pull notion --configure" to set it up.');
282
+ }
283
+
284
+ // Resolve user IDs to display names
285
+ const userMap = await fetchUsers(token);
286
+
287
+ // Fetch recently edited pages
288
+ const pages = await fetchRecentPages(token, sinceDate);
289
+
290
+ // Fetch database entries — auto-discover databases if none configured
291
+ let dbEntries = [];
292
+ let databases = config.databases || [];
293
+ if (databases.length === 0) {
294
+ databases = await discoverDatabases(token);
295
+ }
296
+ for (const dbId of databases) {
297
+ const entries = await fetchDatabaseEntries(token, dbId, sinceDate);
298
+ dbEntries.push(...entries.map((e) => ({ ...e, _databaseId: dbId })));
299
+ }
300
+
301
+ // Fetch comment counts for active pages (top 20 by recency)
302
+ const activePages = pages.slice(0, 20);
303
+ const commentCounts = {};
304
+ for (const page of activePages) {
305
+ const comments = await fetchComments(token, page.id);
306
+ const recentComments = comments.filter((c) => {
307
+ const created = c.created_time || '';
308
+ return created.slice(0, 10) >= sinceDate;
309
+ });
310
+ if (recentComments.length > 0) {
311
+ commentCounts[page.id] = recentComments.length;
312
+ }
313
+ }
314
+
315
+ // Analyze
316
+ const analysis = analyzeActivity(pages, dbEntries, commentCounts, sinceDate, todayDate, userMap);
317
+
318
+ // Generate markdown
319
+ const md = generateMarkdown(analysis, sinceDate, todayDate, timestamp, userMap);
320
+
321
+ // Write signal file
322
+ const signalDir = path.join(SIGNALS_DIR, 'notion');
323
+ fs.mkdirSync(signalDir, { recursive: true });
324
+ const signalFile = path.join(signalDir, `${todayDate}.md`);
325
+ fs.writeFileSync(signalFile, md, 'utf8');
326
+
327
+ return {
328
+ files: [signalFile],
329
+ summary: generateSummaryText(analysis),
330
+ counts: {
331
+ pages: analysis.pageCount,
332
+ database_entries: analysis.dbEntryCount,
333
+ comments: analysis.totalComments,
334
+ },
335
+ };
336
+ }
337
+
338
+ // ── Data fetching ───────────────────────────────────────────────────────────
339
+
340
+ async function fetchUsers(token) {
341
+ const userMap = {};
342
+ if (isSimulation()) {
343
+ const fixturesDir = getFixturesDir();
344
+ if (fixturesDir) {
345
+ try {
346
+ const data = JSON.parse(fs.readFileSync(path.join(fixturesDir, 'users.json'), 'utf8'));
347
+ const results = Array.isArray(data) ? data : (data.results || []);
348
+ for (const u of results) {
349
+ if (u.id && u.name) userMap[u.id] = u.name;
350
+ }
351
+ } catch { /* no fixture */ }
352
+ }
353
+ return userMap;
354
+ }
355
+
356
+ try {
357
+ let response = await notionGet(token, '/users?page_size=100');
358
+ const results = Array.isArray(response.results) ? response.results : [];
359
+ for (const u of results) {
360
+ if (u.id && u.name) userMap[u.id] = u.name;
361
+ }
362
+ // Paginate if needed
363
+ let hasMore = response.has_more;
364
+ let nextCursor = response.next_cursor;
365
+ while (hasMore) {
366
+ response = await notionGet(token, `/users?page_size=100&start_cursor=${nextCursor}`);
367
+ for (const u of (response.results || [])) {
368
+ if (u.id && u.name) userMap[u.id] = u.name;
369
+ }
370
+ hasMore = response.has_more;
371
+ nextCursor = response.next_cursor;
372
+ }
373
+ } catch {
374
+ // Non-fatal — fall back to IDs
375
+ }
376
+ return userMap;
377
+ }
378
+
379
+ async function discoverDatabases(token) {
380
+ if (isSimulation()) return [];
381
+
382
+ const dbIds = [];
383
+ try {
384
+ const body = {
385
+ filter: { property: 'object', value: 'database' },
386
+ page_size: 100,
387
+ };
388
+ const response = await notionPost(token, '/search', body);
389
+ const results = Array.isArray(response.results) ? response.results : [];
390
+ for (const db of results) {
391
+ if (db.id) dbIds.push(db.id);
392
+ }
393
+ } catch {
394
+ // Non-fatal — just won't have database entries
395
+ }
396
+ return dbIds;
397
+ }
398
+
399
+ async function fetchRecentPages(token, sinceDate) {
400
+ const body = {
401
+ filter: {
402
+ property: 'object',
403
+ value: 'page',
404
+ },
405
+ sort: {
406
+ direction: 'descending',
407
+ timestamp: 'last_edited_time',
408
+ },
409
+ page_size: 100,
410
+ };
411
+
412
+ const allPages = [];
413
+ let response = await notionPost(token, '/search', body);
414
+ const results = Array.isArray(response.results) ? response.results : [];
415
+
416
+ // Filter to pages edited since sinceDate
417
+ for (const page of results) {
418
+ const editedAt = (page.last_edited_time || '').slice(0, 10);
419
+ if (editedAt >= sinceDate) {
420
+ allPages.push(page);
421
+ }
422
+ }
423
+
424
+ // Handle pagination (safety bound)
425
+ const MAX_PAGES = 10;
426
+ let pageCount = 0;
427
+ let hasMore = response.has_more;
428
+ let nextCursor = response.next_cursor;
429
+
430
+ while (hasMore && pageCount < MAX_PAGES) {
431
+ pageCount++;
432
+ const nextBody = { ...body, start_cursor: nextCursor };
433
+ response = await notionPost(token, '/search', nextBody);
434
+ const pageResults = Array.isArray(response.results) ? response.results : [];
435
+
436
+ let foundOlder = false;
437
+ for (const page of pageResults) {
438
+ const editedAt = (page.last_edited_time || '').slice(0, 10);
439
+ if (editedAt >= sinceDate) {
440
+ allPages.push(page);
441
+ } else {
442
+ foundOlder = true;
443
+ }
444
+ }
445
+
446
+ // Stop if we've gone past our date range
447
+ if (foundOlder) break;
448
+ hasMore = response.has_more;
449
+ nextCursor = response.next_cursor;
450
+ }
451
+
452
+ return allPages;
453
+ }
454
+
455
+ async function fetchDatabaseEntries(token, databaseId, sinceDate) {
456
+ const body = {
457
+ filter: {
458
+ timestamp: 'last_edited_time',
459
+ last_edited_time: {
460
+ on_or_after: `${sinceDate}T00:00:00.000Z`,
461
+ },
462
+ },
463
+ page_size: 100,
464
+ };
465
+
466
+ const allEntries = [];
467
+ let response = await notionPost(token, `/databases/${databaseId}/query`, body);
468
+ const results = Array.isArray(response.results) ? response.results : [];
469
+ allEntries.push(...results);
470
+
471
+ // Handle pagination
472
+ const MAX_PAGES = 10;
473
+ let pageCount = 0;
474
+ let hasMore = response.has_more;
475
+ let nextCursor = response.next_cursor;
476
+
477
+ while (hasMore && pageCount < MAX_PAGES) {
478
+ pageCount++;
479
+ const nextBody = { ...body, start_cursor: nextCursor };
480
+ response = await notionPost(token, `/databases/${databaseId}/query`, nextBody);
481
+ const pageResults = Array.isArray(response.results) ? response.results : [];
482
+ allEntries.push(...pageResults);
483
+ hasMore = response.has_more;
484
+ nextCursor = response.next_cursor;
485
+ }
486
+
487
+ return allEntries;
488
+ }
489
+
490
+ async function fetchComments(token, pageId) {
491
+ try {
492
+ const response = await notionGet(token, `/comments?block_id=${pageId}&page_size=100`);
493
+ return Array.isArray(response.results) ? response.results : [];
494
+ } catch {
495
+ return [];
496
+ }
497
+ }
498
+
499
+ // ── Property extraction ─────────────────────────────────────────────────────
500
+
501
+ function extractTitle(page) {
502
+ const props = page.properties || {};
503
+
504
+ // Try common title property names
505
+ for (const key of ['Name', 'Title', 'title', 'name']) {
506
+ const prop = props[key];
507
+ if (prop && prop.title && Array.isArray(prop.title)) {
508
+ const text = prop.title.map((t) => t.plain_text || '').join('');
509
+ if (text) return text;
510
+ }
511
+ }
512
+
513
+ // Try any title-type property
514
+ for (const prop of Object.values(props)) {
515
+ if (prop && prop.type === 'title' && Array.isArray(prop.title)) {
516
+ const text = prop.title.map((t) => t.plain_text || '').join('');
517
+ if (text) return text;
518
+ }
519
+ }
520
+
521
+ return page.id ? `(page ${page.id.slice(0, 8)})` : '(untitled)';
522
+ }
523
+
524
+ function extractEditedBy(page, userMap) {
525
+ const editor = page.last_edited_by;
526
+ if (!editor) return '-';
527
+ if (editor.name) return editor.name;
528
+ if (editor.id && userMap && userMap[editor.id]) return userMap[editor.id];
529
+ return editor.id || '-';
530
+ }
531
+
532
+ function extractPropertyValue(prop) {
533
+ if (!prop) return '-';
534
+ switch (prop.type) {
535
+ case 'select':
536
+ return prop.select ? prop.select.name : '-';
537
+ case 'multi_select':
538
+ return (prop.multi_select || []).map((s) => s.name).join(', ') || '-';
539
+ case 'status':
540
+ return prop.status ? prop.status.name : '-';
541
+ case 'people':
542
+ return (prop.people || []).map((p) => p.name || p.id).join(', ') || '-';
543
+ case 'date':
544
+ return prop.date ? prop.date.start : '-';
545
+ case 'number':
546
+ return prop.number != null ? String(prop.number) : '-';
547
+ case 'checkbox':
548
+ return prop.checkbox ? 'Yes' : 'No';
549
+ case 'rich_text':
550
+ return (prop.rich_text || []).map((t) => t.plain_text).join('') || '-';
551
+ case 'title':
552
+ return (prop.title || []).map((t) => t.plain_text).join('') || '-';
553
+ default:
554
+ return '-';
555
+ }
556
+ }
557
+
558
+ // ── Analysis ────────────────────────────────────────────────────────────────
559
+
560
+ function analyzeActivity(pages, dbEntries, commentCounts, sinceDate, todayDate, userMap) {
561
+ const editorCounts = {};
562
+ const dailyCounts = {};
563
+ let totalComments = 0;
564
+
565
+ for (const page of pages) {
566
+ // Editor activity
567
+ const editor = extractEditedBy(page, userMap);
568
+ editorCounts[editor] = (editorCounts[editor] || 0) + 1;
569
+
570
+ // Daily volume
571
+ const editedDate = (page.last_edited_time || '').slice(0, 10);
572
+ if (editedDate) {
573
+ dailyCounts[editedDate] = (dailyCounts[editedDate] || 0) + 1;
574
+ }
575
+ }
576
+
577
+ // Comment totals
578
+ for (const count of Object.values(commentCounts)) {
579
+ totalComments += count;
580
+ }
581
+
582
+ // Sort editors by activity
583
+ const sortedEditors = Object.entries(editorCounts)
584
+ .sort((a, b) => b[1] - a[1]);
585
+
586
+ // Database entry status counts
587
+ const statusCounts = {};
588
+ for (const entry of dbEntries) {
589
+ const props = entry.properties || {};
590
+ // Try common status properties
591
+ for (const key of ['Status', 'status', 'State', 'state']) {
592
+ const prop = props[key];
593
+ if (prop) {
594
+ const val = extractPropertyValue(prop);
595
+ if (val !== '-') {
596
+ statusCounts[val] = (statusCounts[val] || 0) + 1;
597
+ break;
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ return {
604
+ pageCount: pages.length,
605
+ dbEntryCount: dbEntries.length,
606
+ totalComments,
607
+ sortedEditors,
608
+ dailyCounts,
609
+ statusCounts,
610
+ commentCounts,
611
+ pages,
612
+ dbEntries,
613
+ };
614
+ }
615
+
616
+ // ── Markdown generation ─────────────────────────────────────────────────────
617
+
618
+ function generateMarkdown(analysis, sinceDate, todayDate, timestamp, userMap) {
619
+ const lines = [];
620
+
621
+ lines.push('# Notion Signals');
622
+ lines.push('');
623
+ lines.push(`**Period:** ${sinceDate} to ${todayDate} `);
624
+ lines.push(`**Pulled:** ${timestamp}`);
625
+ lines.push('');
626
+
627
+ // Volume overview
628
+ lines.push('## Volume');
629
+ lines.push('');
630
+ lines.push(`- **${analysis.pageCount}** pages updated`);
631
+ if (analysis.dbEntryCount > 0) {
632
+ lines.push(`- **${analysis.dbEntryCount}** database entries modified`);
633
+ }
634
+ if (analysis.totalComments > 0) {
635
+ lines.push(`- **${analysis.totalComments}** new comments`);
636
+ }
637
+ lines.push('');
638
+
639
+ // Daily volume
640
+ const sortedDays = Object.entries(analysis.dailyCounts).sort((a, b) => a[0].localeCompare(b[0]));
641
+ if (sortedDays.length > 0) {
642
+ lines.push('## Daily Activity');
643
+ lines.push('');
644
+ for (const [day, count] of sortedDays) {
645
+ const bar = '\u2588'.repeat(Math.min(count, 30));
646
+ lines.push(` ${day} ${bar} ${count}`);
647
+ }
648
+ lines.push('');
649
+ }
650
+
651
+ // Top editors
652
+ if (analysis.sortedEditors.length > 0) {
653
+ lines.push('## Top Contributors');
654
+ lines.push('');
655
+ lines.push('| Person | Pages Edited |');
656
+ lines.push('|--------|-------------|');
657
+ for (const [editor, count] of analysis.sortedEditors.slice(0, 10)) {
658
+ lines.push(`| ${sanitizeForMarkdown(editor)} | ${count} |`);
659
+ }
660
+ lines.push('');
661
+ }
662
+
663
+ // Recently updated pages
664
+ if (analysis.pages.length > 0) {
665
+ lines.push('## Recently Updated Pages');
666
+ lines.push('');
667
+ lines.push('| Page | Editor | Updated | Comments |');
668
+ lines.push('|------|--------|---------|----------|');
669
+ for (const page of analysis.pages.slice(0, 20)) {
670
+ const title = sanitizeForMarkdown(extractTitle(page));
671
+ const editor = sanitizeForMarkdown(extractEditedBy(page, userMap));
672
+ const updated = (page.last_edited_time || '').slice(0, 10);
673
+ const comments = analysis.commentCounts[page.id] || 0;
674
+ const commentStr = comments > 0 ? `${comments} new` : '-';
675
+ lines.push(`| ${title} | ${editor} | ${updated} | ${commentStr} |`);
676
+ }
677
+ lines.push('');
678
+ }
679
+
680
+ // Database entry status breakdown
681
+ if (Object.keys(analysis.statusCounts).length > 0) {
682
+ lines.push('## Database Entry Status');
683
+ lines.push('');
684
+ for (const [status, count] of Object.entries(analysis.statusCounts)) {
685
+ lines.push(`- **${sanitizeForMarkdown(status)}**: ${count}`);
686
+ }
687
+ lines.push('');
688
+ }
689
+
690
+ // Pages with active discussions (comments)
691
+ const discussedPages = analysis.pages.filter((p) => analysis.commentCounts[p.id] > 0);
692
+ if (discussedPages.length > 0) {
693
+ lines.push('## Active Discussions');
694
+ lines.push('');
695
+ for (const page of discussedPages.slice(0, 10)) {
696
+ const title = sanitizeForMarkdown(extractTitle(page));
697
+ const count = analysis.commentCounts[page.id];
698
+ lines.push(`- **${title}** — ${count} new comment(s)`);
699
+ }
700
+ lines.push('');
701
+ }
702
+
703
+ // Summary
704
+ lines.push('## Summary');
705
+ lines.push('');
706
+ lines.push(generateSummaryText(analysis));
707
+ lines.push('');
708
+
709
+ return lines.join('\n');
710
+ }
711
+
712
+ function generateSummaryText(analysis) {
713
+ const parts = [];
714
+ parts.push(`${analysis.pageCount} pages updated`);
715
+
716
+ if (analysis.dbEntryCount > 0) {
717
+ parts.push(`${analysis.dbEntryCount} database entries modified`);
718
+ }
719
+
720
+ if (analysis.totalComments > 0) {
721
+ parts.push(`${analysis.totalComments} new comments`);
722
+ }
723
+
724
+ if (analysis.sortedEditors.length > 0) {
725
+ const topEditors = analysis.sortedEditors.slice(0, 3).map(([name, count]) => `${name} (${count})`);
726
+ parts.push(`Top contributors: ${topEditors.join(', ')}`);
727
+ }
728
+
729
+ return parts.join('\n');
730
+ }
731
+
732
+ // ── Summarize ───────────────────────────────────────────────────────────────
733
+
734
+ function summarize(filePath) {
735
+ const content = fs.readFileSync(filePath, 'utf8');
736
+ const match = content.match(/## Summary\n([\s\S]*?)(?:\n## |\n$|$)/);
737
+ if (!match) {
738
+ return null;
739
+ }
740
+ return match[1].trim();
741
+ }
742
+
743
+ module.exports = {
744
+ configure,
745
+ pull,
746
+ summarize,
747
+ };