dotmd-cli 0.7.0 → 0.8.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/src/notion.mjs ADDED
@@ -0,0 +1,528 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { asString, toRepoPath, die, warn } from './util.mjs';
6
+ import { bold, green, yellow, dim } from './color.mjs';
7
+
8
+ let notionClient;
9
+ let notionToMd;
10
+
11
+ async function loadDeps() {
12
+ if (notionClient) return;
13
+ try {
14
+ const { Client } = await import('@notionhq/client');
15
+ const { NotionToMarkdown } = await import('notion-to-md');
16
+ notionClient = Client;
17
+ notionToMd = NotionToMarkdown;
18
+ } catch {
19
+ die('Notion dependencies not installed. Run: npm install @notionhq/client notion-to-md');
20
+ }
21
+ }
22
+
23
+ function getClient(config) {
24
+ const token = config.raw?.notion?.token ?? process.env.NOTION_TOKEN;
25
+ if (!token) die('No Notion token. Set NOTION_TOKEN env var or add notion.token to your config.');
26
+ return new notionClient({ auth: token });
27
+ }
28
+
29
+ function getDbId(argv, config) {
30
+ const positional = [];
31
+ for (let i = 0; i < argv.length; i++) {
32
+ if (argv[i] === '--config') { i++; continue; }
33
+ if (argv[i].startsWith('-')) continue;
34
+ positional.push(argv[i]);
35
+ }
36
+ const id = positional[0] ?? config.raw?.notion?.database;
37
+ if (!id) die('No database ID. Pass as argument or set notion.database in config.');
38
+ return id;
39
+ }
40
+
41
+ function hasFlag(argv, flag) {
42
+ return argv.includes(flag);
43
+ }
44
+
45
+ // ── Property mapping: Notion → frontmatter ─────────────────────────────
46
+
47
+ function mapPropertiesToFrontmatter(properties, config) {
48
+ const fm = {};
49
+ const map = config.raw?.notion?.propertyMap ?? {};
50
+
51
+ for (const [name, prop] of Object.entries(properties)) {
52
+ const key = map[name] ?? name.toLowerCase().replace(/\s+/g, '_');
53
+ switch (prop.type) {
54
+ case 'title':
55
+ fm.title = prop.title.map(t => t.plain_text).join('');
56
+ break;
57
+ case 'rich_text':
58
+ fm[key] = prop.rich_text.map(t => t.plain_text).join('');
59
+ break;
60
+ case 'select':
61
+ fm[key] = prop.select?.name ?? null;
62
+ break;
63
+ case 'multi_select':
64
+ fm[key] = prop.multi_select.map(o => o.name);
65
+ break;
66
+ case 'date':
67
+ fm[key] = prop.date?.start ?? null;
68
+ break;
69
+ case 'checkbox':
70
+ fm[key] = prop.checkbox;
71
+ break;
72
+ case 'number':
73
+ fm[key] = prop.number;
74
+ break;
75
+ case 'url':
76
+ fm[key] = prop.url;
77
+ break;
78
+ case 'email':
79
+ fm[key] = prop.email;
80
+ break;
81
+ case 'status':
82
+ fm[key] = prop.status?.name?.toLowerCase() ?? null;
83
+ break;
84
+ case 'people':
85
+ fm[key] = prop.people.map(p => p.name ?? p.id);
86
+ break;
87
+ case 'relation':
88
+ // Store as page IDs for now; could resolve titles
89
+ fm[key] = prop.relation.map(r => r.id);
90
+ break;
91
+ case 'formula':
92
+ case 'rollup':
93
+ case 'created_time':
94
+ case 'last_edited_time':
95
+ case 'created_by':
96
+ case 'last_edited_by':
97
+ break; // skip computed/system
98
+ }
99
+ }
100
+ return fm;
101
+ }
102
+
103
+ // ── Property mapping: frontmatter → Notion ─────────────────────────────
104
+
105
+ function mapFrontmatterToProperties(doc, dbProperties, config) {
106
+ const reverseMap = {};
107
+ const map = config.raw?.notion?.propertyMap ?? {};
108
+ for (const [notionName, fmKey] of Object.entries(map)) {
109
+ reverseMap[fmKey] = notionName;
110
+ }
111
+
112
+ const properties = {};
113
+
114
+ for (const [notionName, propDef] of Object.entries(dbProperties)) {
115
+ const fmKey = map[notionName] ?? notionName.toLowerCase().replace(/\s+/g, '_');
116
+
117
+ // Title property
118
+ if (propDef.type === 'title') {
119
+ properties[notionName] = {
120
+ title: [{ type: 'text', text: { content: doc.title ?? '' } }],
121
+ };
122
+ continue;
123
+ }
124
+
125
+ // Check if we have a frontmatter value for this property
126
+ const value = doc[fmKey] ?? null;
127
+ if (value === null || value === undefined) continue;
128
+
129
+ switch (propDef.type) {
130
+ case 'rich_text':
131
+ properties[notionName] = {
132
+ rich_text: [{ type: 'text', text: { content: String(value) } }],
133
+ };
134
+ break;
135
+ case 'select':
136
+ if (typeof value === 'string' && value) {
137
+ properties[notionName] = { select: { name: value } };
138
+ }
139
+ break;
140
+ case 'multi_select':
141
+ if (Array.isArray(value)) {
142
+ properties[notionName] = { multi_select: value.map(v => ({ name: String(v) })) };
143
+ }
144
+ break;
145
+ case 'date':
146
+ if (typeof value === 'string' && value) {
147
+ properties[notionName] = { date: { start: value } };
148
+ }
149
+ break;
150
+ case 'checkbox':
151
+ properties[notionName] = { checkbox: Boolean(value) };
152
+ break;
153
+ case 'number':
154
+ if (typeof value === 'number') {
155
+ properties[notionName] = { number: value };
156
+ }
157
+ break;
158
+ case 'url':
159
+ if (typeof value === 'string' && value) {
160
+ properties[notionName] = { url: value };
161
+ }
162
+ break;
163
+ case 'email':
164
+ if (typeof value === 'string' && value) {
165
+ properties[notionName] = { email: value };
166
+ }
167
+ break;
168
+ case 'status':
169
+ if (typeof value === 'string' && value) {
170
+ properties[notionName] = { status: { name: value } };
171
+ }
172
+ break;
173
+ // Skip: formula, rollup, relation (complex), created_time, etc.
174
+ }
175
+ }
176
+
177
+ return properties;
178
+ }
179
+
180
+ // ── Serialization helpers ──────────────────────────────────────────────
181
+
182
+ function serializeFrontmatter(fm) {
183
+ const lines = [];
184
+ for (const [key, value] of Object.entries(fm)) {
185
+ if (value === null || value === undefined) continue;
186
+ if (Array.isArray(value)) {
187
+ if (value.length === 0) continue;
188
+ lines.push(`${key}:`);
189
+ for (const item of value) lines.push(` - ${item}`);
190
+ } else {
191
+ lines.push(`${key}: ${value}`);
192
+ }
193
+ }
194
+ return lines.join('\n');
195
+ }
196
+
197
+ function slugify(text) {
198
+ return text.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'untitled';
199
+ }
200
+
201
+ function loadBody(doc, config) {
202
+ const raw = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
203
+ const { body } = extractFrontmatter(raw);
204
+ return body ?? '';
205
+ }
206
+
207
+ // ── Paginated query helper ─────────────────────────────────────────────
208
+
209
+ async function queryAllPages(client, dbId) {
210
+ const pages = [];
211
+ let cursor;
212
+ do {
213
+ const response = await client.databases.query({
214
+ database_id: dbId,
215
+ start_cursor: cursor,
216
+ });
217
+ pages.push(...response.results);
218
+ cursor = response.has_more ? response.next_cursor : undefined;
219
+ } while (cursor);
220
+ return pages;
221
+ }
222
+
223
+ async function findPageByTitle(client, dbId, title) {
224
+ const response = await client.databases.query({
225
+ database_id: dbId,
226
+ filter: { property: 'title', title: { equals: title } },
227
+ page_size: 1,
228
+ });
229
+ return response.results[0] ?? null;
230
+ }
231
+
232
+ // ── Import: Notion → local .md ─────────────────────────────────────────
233
+
234
+ export async function runNotionImport(argv, config, opts = {}) {
235
+ await loadDeps();
236
+ const client = getClient(config);
237
+ const n2m = new notionToMd({ notionClient: client });
238
+ const dbId = getDbId(argv, config);
239
+ const force = hasFlag(argv, '--force');
240
+ const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
241
+
242
+ process.stdout.write(`Importing from Notion database ${dim(dbId)}...\n`);
243
+
244
+ const pages = await queryAllPages(client, dbId);
245
+ process.stdout.write(`Found ${pages.length} pages.\n\n`);
246
+
247
+ let created = 0, skipped = 0, updated = 0;
248
+ const prefix = dryRun ? dim('[dry-run] ') : '';
249
+
250
+ for (const page of pages) {
251
+ const fm = mapPropertiesToFrontmatter(page.properties, config);
252
+ const title = fm.title ?? 'Untitled';
253
+ delete fm.title; // title goes in heading, not frontmatter
254
+
255
+ // Add notion_id for sync tracking
256
+ fm.notion_id = page.id;
257
+ if (!fm.updated) fm.updated = page.last_edited_time?.slice(0, 10) ?? new Date().toISOString().slice(0, 10);
258
+
259
+ const slug = slugify(title);
260
+ const filePath = path.join(config.docsRoot, slug + '.md');
261
+ const repoPath = toRepoPath(filePath, config.repoRoot);
262
+
263
+ if (existsSync(filePath) && !force) {
264
+ process.stdout.write(`${prefix}${dim('skip')} ${repoPath} (exists, use --force to overwrite)\n`);
265
+ skipped++;
266
+ continue;
267
+ }
268
+
269
+ // Convert blocks to markdown
270
+ let body = '';
271
+ try {
272
+ const mdBlocks = await n2m.pageToMarkdown(page.id);
273
+ body = n2m.toMarkdownString(mdBlocks).parent ?? '';
274
+ } catch (err) {
275
+ warn(`Failed to convert blocks for "${title}": ${err.message}`);
276
+ }
277
+
278
+ const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
279
+
280
+ if (!dryRun) {
281
+ writeFileSync(filePath, content, 'utf8');
282
+ }
283
+
284
+ const action = existsSync(filePath) ? 'update' : 'create';
285
+ process.stdout.write(`${prefix}${green(action)} ${repoPath}\n`);
286
+ if (action === 'update') updated++;
287
+ else created++;
288
+ }
289
+
290
+ process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated, ${skipped} skipped.\n`);
291
+ }
292
+
293
+ // ── Export: local docs → Notion ─────────────────────────────────────────
294
+
295
+ export async function runNotionExport(argv, config, opts = {}) {
296
+ await loadDeps();
297
+ const client = getClient(config);
298
+ const dbId = getDbId(argv, config);
299
+ const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
300
+
301
+ process.stdout.write(`Exporting to Notion database ${dim(dbId)}...\n`);
302
+
303
+ // Get database schema for property mapping
304
+ const dbInfo = await client.databases.retrieve({ database_id: dbId });
305
+ const dbProperties = dbInfo.properties;
306
+
307
+ const index = buildIndex(config);
308
+ process.stdout.write(`Found ${index.docs.length} local docs.\n\n`);
309
+
310
+ let created = 0, updated = 0;
311
+ const prefix = dryRun ? dim('[dry-run] ') : '';
312
+
313
+ for (const doc of index.docs) {
314
+ const properties = mapFrontmatterToProperties(doc, dbProperties, config);
315
+ const body = loadBody(doc, config);
316
+
317
+ const existing = await findPageByTitle(client, dbId, doc.title);
318
+
319
+ if (dryRun) {
320
+ process.stdout.write(`${prefix}${existing ? 'update' : 'create'} ${doc.path}\n`);
321
+ if (existing) updated++;
322
+ else created++;
323
+ continue;
324
+ }
325
+
326
+ try {
327
+ if (existing) {
328
+ await client.pages.update({
329
+ page_id: existing.id,
330
+ properties,
331
+ markdown: body,
332
+ });
333
+ process.stdout.write(`${green('update')} ${doc.path}\n`);
334
+ updated++;
335
+ } else {
336
+ await client.pages.create({
337
+ parent: { database_id: dbId },
338
+ properties,
339
+ markdown: body,
340
+ });
341
+ process.stdout.write(`${green('create')} ${doc.path}\n`);
342
+ created++;
343
+ }
344
+ } catch (err) {
345
+ warn(`Failed to export "${doc.title}": ${err.message}`);
346
+ }
347
+ }
348
+
349
+ process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated.\n`);
350
+ }
351
+
352
+ // ── Sync: bidirectional merge ───────────────────────────────────────────
353
+
354
+ export async function runNotionSync(argv, config, opts = {}) {
355
+ await loadDeps();
356
+ const client = getClient(config);
357
+ const n2m = new notionToMd({ notionClient: client });
358
+ const dbId = getDbId(argv, config);
359
+ const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
360
+
361
+ process.stdout.write(`Syncing with Notion database ${dim(dbId)}...\n\n`);
362
+
363
+ // Get database schema
364
+ const dbInfo = await client.databases.retrieve({ database_id: dbId });
365
+ const dbProperties = dbInfo.properties;
366
+
367
+ // Get remote pages
368
+ const remotePages = await queryAllPages(client, dbId);
369
+ const remoteBySlug = new Map();
370
+ for (const page of remotePages) {
371
+ const title = page.properties.title?.title?.map(t => t.plain_text).join('') ??
372
+ Object.values(page.properties).find(p => p.type === 'title')?.title?.map(t => t.plain_text).join('') ?? '';
373
+ const slug = slugify(title);
374
+ remoteBySlug.set(slug, { page, title });
375
+ }
376
+
377
+ // Get local docs
378
+ const index = buildIndex(config);
379
+ const localBySlug = new Map();
380
+ for (const doc of index.docs) {
381
+ const slug = path.basename(doc.path, '.md');
382
+ localBySlug.set(slug, doc);
383
+ }
384
+
385
+ const allSlugs = new Set([...remoteBySlug.keys(), ...localBySlug.keys()]);
386
+ let pulled = 0, pushed = 0, conflicts = 0, skipped = 0;
387
+ const prefix = dryRun ? dim('[dry-run] ') : '';
388
+
389
+ for (const slug of allSlugs) {
390
+ const remote = remoteBySlug.get(slug);
391
+ const local = localBySlug.get(slug);
392
+
393
+ if (remote && !local) {
394
+ // New in Notion → pull
395
+ const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
396
+ const title = fm.title ?? remote.title;
397
+ delete fm.title;
398
+ fm.notion_id = remote.page.id;
399
+ if (!fm.updated) fm.updated = remote.page.last_edited_time?.slice(0, 10);
400
+
401
+ let body = '';
402
+ try {
403
+ const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
404
+ body = n2m.toMarkdownString(mdBlocks).parent ?? '';
405
+ } catch { /* skip body on error */ }
406
+
407
+ const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
408
+ const filePath = path.join(config.docsRoot, slug + '.md');
409
+
410
+ if (!dryRun) writeFileSync(filePath, content, 'utf8');
411
+ process.stdout.write(`${prefix}${green('pull')} ${slug} (new in Notion)\n`);
412
+ pulled++;
413
+ continue;
414
+ }
415
+
416
+ if (local && !remote) {
417
+ // New locally → push
418
+ const properties = mapFrontmatterToProperties(local, dbProperties, config);
419
+ const body = loadBody(local, config);
420
+
421
+ if (!dryRun) {
422
+ try {
423
+ await client.pages.create({
424
+ parent: { database_id: dbId },
425
+ properties,
426
+ markdown: body,
427
+ });
428
+ } catch (err) {
429
+ warn(`Failed to push "${local.title}": ${err.message}`);
430
+ continue;
431
+ }
432
+ }
433
+ process.stdout.write(`${prefix}${green('push')} ${slug} (new locally)\n`);
434
+ pushed++;
435
+ continue;
436
+ }
437
+
438
+ // Both exist — compare timestamps
439
+ const remoteTime = remote.page.last_edited_time?.slice(0, 10) ?? '';
440
+ const localTime = local.updated ?? '';
441
+
442
+ if (remoteTime === localTime) {
443
+ skipped++;
444
+ continue;
445
+ }
446
+
447
+ if (remoteTime > localTime) {
448
+ // Notion is newer → pull
449
+ const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
450
+ const title = fm.title ?? remote.title;
451
+ delete fm.title;
452
+ fm.notion_id = remote.page.id;
453
+ fm.updated = remoteTime;
454
+
455
+ let body = '';
456
+ try {
457
+ const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
458
+ body = n2m.toMarkdownString(mdBlocks).parent ?? '';
459
+ } catch { /* skip body on error */ }
460
+
461
+ const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
462
+ const filePath = path.join(config.repoRoot, local.path);
463
+
464
+ if (!dryRun) writeFileSync(filePath, content, 'utf8');
465
+ process.stdout.write(`${prefix}${green('pull')} ${slug} (Notion newer: ${remoteTime} > ${localTime})\n`);
466
+ pulled++;
467
+ } else {
468
+ // Local is newer → push
469
+ const properties = mapFrontmatterToProperties(local, dbProperties, config);
470
+ const body = loadBody(local, config);
471
+
472
+ if (!dryRun) {
473
+ try {
474
+ await client.pages.update({
475
+ page_id: remote.page.id,
476
+ properties,
477
+ markdown: body,
478
+ });
479
+ } catch (err) {
480
+ warn(`Failed to push "${local.title}": ${err.message}`);
481
+ continue;
482
+ }
483
+ }
484
+ process.stdout.write(`${prefix}${green('push')} ${slug} (local newer: ${localTime} > ${remoteTime})\n`);
485
+ pushed++;
486
+ }
487
+ }
488
+
489
+ process.stdout.write(`\n${prefix}Done: ${pulled} pulled, ${pushed} pushed, ${skipped} unchanged.\n`);
490
+ }
491
+
492
+ // ── CLI dispatcher ──────────────────────────────────────────────────────
493
+
494
+ export async function runNotion(argv, config, opts = {}) {
495
+ // Filter out global flags before finding subcommand
496
+ const filtered = [];
497
+ for (let i = 0; i < argv.length; i++) {
498
+ if (argv[i] === '--config') { i++; continue; }
499
+ if (argv[i] === '--verbose') continue;
500
+ filtered.push(argv[i]);
501
+ }
502
+ const subcommand = filtered[0];
503
+ const restArgs = filtered.slice(1);
504
+
505
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
506
+ process.stdout.write(`dotmd notion — Notion database integration
507
+
508
+ Subcommands:
509
+ import <database-id> Pull Notion database → local .md files
510
+ export <database-id> Push local docs → Notion database rows
511
+ sync <database-id> Bidirectional sync (merge by slug)
512
+
513
+ Options:
514
+ --force Overwrite existing files on import
515
+ --dry-run, -n Preview without changes
516
+
517
+ Requires NOTION_TOKEN env var or notion.token in config.
518
+ Database ID can be set in config as notion.database.
519
+ `);
520
+ return;
521
+ }
522
+
523
+ if (subcommand === 'import') return runNotionImport(restArgs, config, opts);
524
+ if (subcommand === 'export') return runNotionExport(restArgs, config, opts);
525
+ if (subcommand === 'sync') return runNotionSync(restArgs, config, opts);
526
+
527
+ die(`Unknown notion subcommand: ${subcommand}\nRun \`dotmd notion --help\` for usage.`);
528
+ }
package/src/query.mjs CHANGED
@@ -1,7 +1,12 @@
1
- import { capitalize, toSlug } from './util.mjs';
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { capitalize, toSlug, warn } from './util.mjs';
2
4
  import { renderProgressBar } from './render.mjs';
3
5
  import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
4
6
  import { getGitLastModified } from './git.mjs';
7
+ import { extractFrontmatter } from './frontmatter.mjs';
8
+ import { summarizeDocBody } from './ai.mjs';
9
+ import { dim } from './color.mjs';
5
10
 
6
11
  export function runFocus(index, argv, config) {
7
12
  const statusFilter = argv[0] ?? 'active';
@@ -36,11 +41,16 @@ export function runQuery(index, argv, config) {
36
41
  const docs = filterDocs(index.docs, filters, config);
37
42
 
38
43
  if (filters.json) {
44
+ if (filters.summarize) {
45
+ for (let i = 0; i < docs.length && i < filters.summarizeLimit; i++) {
46
+ docs[i].aiSummary = getDocSummary(docs[i], config);
47
+ }
48
+ }
39
49
  process.stdout.write(`${JSON.stringify({ filters, count: docs.length, docs }, null, 2)}\n`);
40
50
  return;
41
51
  }
42
52
 
43
- renderQueryResults(docs, filters);
53
+ renderQueryResults(docs, filters, config);
44
54
  }
45
55
 
46
56
  export function parseQueryArgs(argv) {
@@ -50,6 +60,7 @@ export function parseQueryArgs(argv) {
50
60
  updatedSince: null, limit: 20, all: false, sort: 'updated',
51
61
  stale: false, hasNextStep: false, hasBlockers: false,
52
62
  checklistOpen: false, json: false, git: false,
63
+ summarize: false, summarizeLimit: 5, model: undefined,
53
64
  };
54
65
 
55
66
  for (let i = 0; i < argv.length; i += 1) {
@@ -74,6 +85,9 @@ export function parseQueryArgs(argv) {
74
85
  if (arg === '--checklist-open') { filters.checklistOpen = true; continue; }
75
86
  if (arg === '--json') { filters.json = true; continue; }
76
87
  if (arg === '--git') { filters.git = true; continue; }
88
+ if (arg === '--summarize') { filters.summarize = true; continue; }
89
+ if (arg === '--summarize-limit' && next) { filters.summarizeLimit = Number.parseInt(next, 10) || 5; i += 1; continue; }
90
+ if (arg === '--model' && next) { filters.model = next; i += 1; continue; }
77
91
  }
78
92
 
79
93
  return filters;
@@ -116,7 +130,20 @@ export function filterDocs(docs, filters, config) {
116
130
  return filters.all ? result : result.slice(0, filters.limit);
117
131
  }
118
132
 
119
- function renderQueryResults(docs, filters) {
133
+ function getDocSummary(doc, config) {
134
+ try {
135
+ const absPath = path.resolve(config.repoRoot, doc.path);
136
+ const raw = readFileSync(absPath, 'utf8');
137
+ const { body } = extractFrontmatter(raw);
138
+ if (!body?.trim()) return null;
139
+ const meta = { title: doc.title, status: doc.status, path: doc.path };
140
+ return config.hooks.summarizeDoc
141
+ ? config.hooks.summarizeDoc(body, meta)
142
+ : summarizeDocBody(body, meta, { model: undefined });
143
+ } catch { return null; }
144
+ }
145
+
146
+ function renderQueryResults(docs, filters, config) {
120
147
  process.stdout.write('Query\n\n');
121
148
  process.stdout.write(`- results: ${docs.length}\n`);
122
149
  if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
@@ -138,7 +165,8 @@ function renderQueryResults(docs, filters) {
138
165
 
139
166
  if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
140
167
 
141
- for (const doc of docs) {
168
+ for (let idx = 0; idx < docs.length; idx++) {
169
+ const doc = docs[idx];
142
170
  process.stdout.write(`- ${doc.title}\n`);
143
171
  process.stdout.write(` status: ${doc.status}\n`);
144
172
  process.stdout.write(` updated: ${doc.updated ?? 'n/a'}\n`);
@@ -155,6 +183,10 @@ function renderQueryResults(docs, filters) {
155
183
  if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
156
184
  if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
157
185
  if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
186
+ if (filters.summarize && idx < filters.summarizeLimit) {
187
+ const summary = getDocSummary(doc, config);
188
+ if (summary) process.stdout.write(` ${dim('ai-summary:')} ${summary}\n`);
189
+ }
158
190
  process.stdout.write('\n');
159
191
  }
160
192
  }
package/src/render.mjs CHANGED
@@ -1,5 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { capitalize, toSlug, truncate, warn } from './util.mjs';
2
- import { bold, red, yellow, green } from './color.mjs';
4
+ import { extractFrontmatter } from './frontmatter.mjs';
5
+ import { summarizeDocBody } from './ai.mjs';
6
+ import { bold, red, yellow, green, dim } from './color.mjs';
3
7
 
4
8
  export function renderCompactList(index, config) {
5
9
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
@@ -61,8 +65,8 @@ export function renderVerboseList(index, config) {
61
65
  return `${lines.join('\n').trimEnd()}\n`;
62
66
  }
63
67
 
64
- export function renderContext(index, config) {
65
- const defaultRenderer = (idx) => _renderContext(idx, config);
68
+ export function renderContext(index, config, opts = {}) {
69
+ const defaultRenderer = (idx) => _renderContext(idx, config, opts);
66
70
  if (config.hooks.renderContext) {
67
71
  try { return config.hooks.renderContext(index, defaultRenderer); }
68
72
  catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
@@ -70,7 +74,7 @@ export function renderContext(index, config) {
70
74
  return defaultRenderer(index);
71
75
  }
72
76
 
73
- function _renderContext(index, config) {
77
+ function _renderContext(index, config, opts = {}) {
74
78
  const today = new Date().toISOString().slice(0, 10);
75
79
  const lines = [`BRIEFING (${today})`, ''];
76
80
  const ctx = config.context;
@@ -91,6 +95,20 @@ function _renderContext(index, config) {
91
95
  ? truncate(doc.nextStep, ctx.truncateNextStep || 80)
92
96
  : '(no next step)';
93
97
  lines.push(` ${slug} next: ${next}`);
98
+ if (opts.summarize) {
99
+ try {
100
+ const absPath = path.resolve(config.repoRoot, doc.path);
101
+ const raw = readFileSync(absPath, 'utf8');
102
+ const { body } = extractFrontmatter(raw);
103
+ const meta = { title: doc.title, status: doc.status, path: doc.path };
104
+ const summary = config.hooks.summarizeDoc
105
+ ? config.hooks.summarizeDoc(body, meta)
106
+ : summarizeDocBody(body, meta, { model: opts.model });
107
+ if (summary) {
108
+ lines.push(` ${''.padEnd(maxSlug)} ${dim('ai: ' + truncate(summary, 120))}`);
109
+ }
110
+ } catch { /* skip on failure */ }
111
+ }
94
112
  }
95
113
  lines.push('');
96
114
  }