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,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
|
+
};
|