thinking-phrases 1.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 (41) hide show
  1. package/README.md +230 -142
  2. package/configs/hn-top.config.json +60 -27
  3. package/launchd/rss-update.error.log +3 -27
  4. package/launchd/rss-update.log +308 -0
  5. package/launchd/task-health.json +54 -0
  6. package/out/dwyl-quotes.json +1621 -0
  7. package/out/javascript-tips.json +107 -0
  8. package/out/league-loading-screen-tips.json +107 -0
  9. package/out/ruby-tips.json +115 -0
  10. package/out/settings-linux.json +87 -0
  11. package/out/settings-mac.json +87 -0
  12. package/out/settings-windows.json +87 -0
  13. package/out/typescript-tips.json +131 -0
  14. package/out/vscode-tips.json +87 -0
  15. package/out/wow-loading-screen-tips.json +116 -0
  16. package/package.json +19 -12
  17. package/scripts/build.ts +3 -3
  18. package/scripts/debug-hn-hydration.ts +33 -0
  19. package/scripts/run-rss-update.zsh +25 -3
  20. package/scripts/show-thinking-phrases-health.ts +74 -0
  21. package/scripts/trigger-thinking-phrases-scheduler.zsh +50 -0
  22. package/src/core/config.ts +65 -3
  23. package/src/core/githubModels.ts +200 -112
  24. package/src/core/interactive.ts +49 -67
  25. package/src/core/phraseCache.ts +242 -0
  26. package/src/core/phraseFormats.ts +243 -0
  27. package/src/core/presets.ts +1 -1
  28. package/src/core/runner.ts +246 -113
  29. package/src/core/scheduler.ts +1 -1
  30. package/src/core/taskHealth.ts +213 -0
  31. package/src/core/types.ts +32 -8
  32. package/src/core/utils.ts +27 -2
  33. package/src/sources/customJson.ts +28 -18
  34. package/src/sources/earthquakes.ts +4 -4
  35. package/src/sources/githubActivity.ts +120 -48
  36. package/src/sources/hackerNews.ts +19 -7
  37. package/src/sources/rss.ts +25 -11
  38. package/src/sources/stocks.ts +31 -10
  39. package/src/sources/weatherAlerts.ts +173 -7
  40. package/tsconfig.json +1 -1
  41. package/scripts/update-rss-settings.ts +0 -7
@@ -1,22 +1,27 @@
1
1
  import { resolve } from 'node:path';
2
- import process from 'node:process';
3
2
  import { execFileSync } from 'node:child_process';
3
+ import process from 'node:process';
4
4
  import { outro, spinner } from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
+
6
7
  import { DEFAULT_CONFIG, mergeConfig, parseArgs, readConfigFile, resolveConfigPath, validateConfig, writeConfigFile } from './config.js';
7
8
  import { buildModelArticlePhrases } from './githubModels.js';
8
9
  import {
9
10
  promptForConfigName,
10
- promptForDynamicSchedulerAfterDryRun,
11
11
  promptForInteractiveOverrides,
12
12
  promptForPostDryRunAction,
13
13
  promptForStaticSchedulerAfterDryRun,
14
14
  } from './interactive.js';
15
+ import { cacheModelResults, clearModelCache, getMergedPhrases, isSourceStale, markSourceFetched, partitionArticlesByModelCache, storePhrases } from './phraseCache.js';
15
16
  import { DEFAULT_SCHEDULER_INTERVAL_SECONDS, formatConfigPathForDisplay, getInstalledSchedulerInfo } from './scheduler.js';
16
17
  import { dynamicSources } from './sourceCatalog.js';
17
18
  import { getStaticPackByPath } from './staticPacks.js';
19
+ import { TaskHealthTracker } from './taskHealth.js';
20
+ import { formatArticlePhrase } from './phraseFormats.js';
18
21
  import type { ArticleItem, Config } from './types.js';
19
22
  import { dedupePhrases, loadDotEnv, logInfo, resolveSettingsPath, truncate } from './utils.js';
23
+
24
+ import { hydrateArticleContent } from '../sources/rss.js';
20
25
  import { buildStockPhrase } from '../sources/stocks.js';
21
26
  import { removeVsCodeThinkingPhrases, writeVsCodeSettings } from '../sinks/vscodeSettings.js';
22
27
 
@@ -29,18 +34,17 @@ function buildBasicArticlePhrase(article: ArticleItem, config: Config): string |
29
34
  return null;
30
35
  }
31
36
 
32
- const parts: string[] = [];
33
- if (config.phraseFormatting.includeSource && article.source?.trim()) {
34
- parts.push(article.source.trim());
35
- }
36
-
37
- parts.push(article.title.trim());
38
-
39
- if (config.phraseFormatting.includeTime && article.time?.trim()) {
40
- parts.push(article.time.trim());
41
- }
42
-
43
- return truncate(parts.join(' — '), config.phraseFormatting.maxLength);
37
+ return truncate(
38
+ formatArticlePhrase(
39
+ { source: article.source, title: article.title, time: article.time },
40
+ {
41
+ includeSource: config.phraseFormatting.includeSource,
42
+ includeTime: config.phraseFormatting.includeTime,
43
+ template: config.phraseFormatting.templates?.article,
44
+ },
45
+ ),
46
+ config.phraseFormatting.maxLength,
47
+ );
44
48
  }
45
49
 
46
50
  function buildBasicArticlePhrases(articles: ArticleItem[], config: Config): string[] {
@@ -66,6 +70,11 @@ function uninstallMacScheduler(): void {
66
70
  execFileSync('zsh', [uninstallPath], { stdio: 'inherit' });
67
71
  }
68
72
 
73
+ function triggerMacSchedulerNow(): void {
74
+ const triggerPath = resolve(process.cwd(), 'scripts/trigger-thinking-phrases-scheduler.zsh');
75
+ execFileSync('zsh', [triggerPath], { stdio: 'inherit' });
76
+ }
77
+
69
78
  function syncGitHubLookbackToScheduler(config: Config, intervalSeconds: number): Config {
70
79
  if (!config.githubActivity.enabled) {
71
80
  return config;
@@ -91,6 +100,8 @@ export async function runDynamicPhrases(): Promise<void> {
91
100
  const args = parseArgs(process.argv.slice(2));
92
101
  const isInteractive = Boolean(args.interactive);
93
102
  let uninstall = Boolean(args.uninstall);
103
+ const clearCache = Boolean(args.clearCache);
104
+ let triggerSchedulerNow = Boolean(args.triggerSchedulerNow);
94
105
  let createNewConfig = Boolean(args.createNewConfig);
95
106
  let dryRun = Boolean(args.dryRun);
96
107
  let installScheduler = Boolean(args.installScheduler);
@@ -101,6 +112,12 @@ export async function runDynamicPhrases(): Promise<void> {
101
112
  let configPath: string | undefined = resolveConfigPath(args.configPath);
102
113
  let fileConfig = configPath ? readConfigFile(configPath) : {};
103
114
  let config = mergeConfig(DEFAULT_CONFIG, fileConfig, args);
115
+
116
+ if (clearCache) {
117
+ clearModelCache();
118
+ logInfo(config, 'Model cache cleared');
119
+ }
120
+
104
121
  let interactivePass = 0;
105
122
  const interactiveSpinner = isInteractive ? spinner({ indicator: 'timer' }) : null;
106
123
  let interactiveSpinnerActive = false;
@@ -151,6 +168,7 @@ export async function runDynamicPhrases(): Promise<void> {
151
168
 
152
169
  interactivePass += 1;
153
170
  uninstall = Boolean(interactiveOverrides.uninstall);
171
+ triggerSchedulerNow = Boolean(interactiveOverrides.triggerSchedulerNow);
154
172
  createNewConfig = Boolean(interactiveOverrides.createNewConfig);
155
173
  dryRun = Boolean(interactiveOverrides.dryRun);
156
174
  installScheduler = Boolean(interactiveOverrides.installScheduler);
@@ -165,12 +183,27 @@ export async function runDynamicPhrases(): Promise<void> {
165
183
 
166
184
  const settingsPath = resolveSettingsPath(config.target, config.settingsPath);
167
185
 
186
+ if (triggerSchedulerNow) {
187
+ if (process.platform !== 'darwin') {
188
+ throw new Error('Scheduler triggering is currently only available on macOS.');
189
+ }
190
+
191
+ const installedScheduler = getInstalledSchedulerInfo();
192
+ if (!installedScheduler.installed) {
193
+ throw new Error('No macOS scheduler is currently installed.');
194
+ }
195
+
196
+ console.log(pc.cyan('Triggering macOS launchd scheduler now...'));
197
+ triggerMacSchedulerNow();
198
+ return;
199
+ }
200
+
168
201
  if (uninstall) {
169
202
  const removedThinkingPhrases = removeVsCodeThinkingPhrases(settingsPath);
170
203
  if (removedThinkingPhrases) {
171
- console.log(pc.green(`Removed chat.agent.thinking.phrases from ${settingsPath}`));
204
+ console.log(pc.green(`Removed chat.agent.thinking.phrases from "${settingsPath}"`));
172
205
  } else {
173
- console.log(pc.dim(`No chat.agent.thinking.phrases entry found in ${settingsPath}`));
206
+ console.log(pc.dim(`No chat.agent.thinking.phrases entry found in "${settingsPath}"`));
174
207
  }
175
208
 
176
209
  if (process.platform === 'darwin') {
@@ -194,7 +227,7 @@ export async function runDynamicPhrases(): Promise<void> {
194
227
  }
195
228
 
196
229
  if (dryRun) {
197
- console.log(pc.bold(pc.cyan(`Dry run only — would write ${pack.phrases.length} phrases from ${pack.name} to ${settingsPath}`)));
230
+ console.log(pc.bold(pc.cyan(`Dry run only — would write ${pack.phrases.length} phrases from ${pack.name} to "${settingsPath}"`)));
198
231
  console.log(pc.dim('Preview:'));
199
232
  for (const phrase of pack.phrases.slice(0, 5)) {
200
233
  console.log(`${pc.green('•')} ${phrase}`);
@@ -228,7 +261,7 @@ export async function runDynamicPhrases(): Promise<void> {
228
261
  }
229
262
 
230
263
  writeVsCodeSettings(settingsPath, pack.phrases, pack.mode);
231
- console.log(pc.green(`Updated ${settingsPath}`));
264
+ console.log(pc.green(`Updated "${settingsPath}"`));
232
265
  console.log(pc.bold(`Installed static pack ${pack.name} with ${pack.phrases.length} phrases.`));
233
266
 
234
267
  if (uninstallScheduler && process.platform === 'darwin') {
@@ -249,127 +282,227 @@ export async function runDynamicPhrases(): Promise<void> {
249
282
 
250
283
  validateConfig(config);
251
284
 
252
- const enabledSources = dynamicSources.filter(source => source.isEnabled(config));
253
- logInfo(config, `Running ${enabledSources.length} dynamic source(s)`);
254
-
255
- let phrases: string[];
285
+ const healthTracker = new TaskHealthTracker({
286
+ dryRun,
287
+ configPath,
288
+ settingsPath,
289
+ });
256
290
 
257
291
  try {
258
- startInteractiveProgress(`Fetching ${enabledSources.length} dynamic source${enabledSources.length === 1 ? '' : 's'}`);
259
- const sourceItems = (await Promise.all(enabledSources.map(source => source.fetch(config)))).flat();
260
- const articles = sourceItems.filter((item): item is ArticleItem => item.type === 'article');
261
- const stocks = sourceItems.filter(item => item.type === 'stock');
262
-
263
- startInteractiveProgress(`Preparing ${sourceItems.length} fetched item${sourceItems.length === 1 ? '' : 's'}`);
264
- const stockPhrases = dedupePhrases(stocks.map(stock => buildStockPhrase(stock, config)));
265
- const fallbackArticlePhrases = buildBasicArticlePhrases(articles, config);
266
- const articlePhrases = config.githubModels.enabled && articles.length > 0
267
- ? await buildModelArticlePhrases(articles, config, {
268
- onProgress: startInteractiveProgress,
269
- }).catch((error: unknown) => {
270
- const message = error instanceof Error ? error.message : String(error);
271
- console.warn(`GitHub Models formatting skipped — ${message}`);
272
- startInteractiveProgress('GitHub Models unavailable, falling back to feed phrases');
273
- return fallbackArticlePhrases;
292
+ const enabledSources = dynamicSources.filter(source => source.isEnabled(config));
293
+ // In dry-run or interactive mode, fetch everything regardless of intervals
294
+ const respectIntervals = !dryRun && !isInteractive;
295
+ const sourcesToFetch = respectIntervals
296
+ ? enabledSources.filter(source => {
297
+ const stale = isSourceStale(source.type, config);
298
+ if (!stale) {
299
+ logInfo(config, `Skipping ${source.type} interval not elapsed`);
300
+ }
301
+
302
+ return stale;
274
303
  })
275
- : fallbackArticlePhrases;
276
-
277
- phrases = dedupePhrases([...stockPhrases, ...articlePhrases]);
278
- stopInteractiveProgress(dryRun ? `Dry run ready — generated ${phrases.length} phrases` : `Generated ${phrases.length} phrases`);
279
- } catch (error) {
280
- failInteractiveProgress('Dynamic run failed');
281
- throw error;
282
- }
283
-
284
- if (phrases.length === 0) {
285
- throw new Error('No thinking phrases were generated from the configured sources.');
286
- }
304
+ : enabledSources;
305
+
306
+ healthTracker.setSources(enabledSources.map(source => source.type));
307
+ healthTracker.setPhase('fetching-sources', `Running ${sourcesToFetch.length} of ${enabledSources.length} source${enabledSources.length === 1 ? '' : 's'}`);
308
+ logInfo(config, `Running ${sourcesToFetch.length} of ${enabledSources.length} enabled source(s) (${enabledSources.length - sourcesToFetch.length} skipped — not yet stale)`);
309
+
310
+ startInteractiveProgress(`Fetching ${sourcesToFetch.length} dynamic source${sourcesToFetch.length === 1 ? '' : 's'}`);
311
+ const sourceResults = await Promise.all(
312
+ sourcesToFetch.map(async source => {
313
+ healthTracker.startSource(source.type);
314
+ try {
315
+ const items = await source.fetch(config);
316
+ markSourceFetched(source.type);
317
+ healthTracker.completeSource(source.type, items.length);
318
+ return { type: source.type, items };
319
+ } catch (error: unknown) {
320
+ const message = error instanceof Error ? error.message : String(error);
321
+ healthTracker.failSource(source.type, message);
322
+ throw error;
323
+ }
324
+ }),
325
+ );
326
+
327
+ // Build phrases per-source and persist to the phrase store
328
+ let totalArticles = 0;
329
+ let totalStocks = 0;
330
+ for (const { type, items } of sourceResults) {
331
+ const articles = items.filter((item): item is ArticleItem => item.type === 'article');
332
+ const stocks = items.filter(item => item.type === 'stock');
333
+ totalArticles += articles.length;
334
+ totalStocks += stocks.length;
335
+
336
+ startInteractiveProgress(`Preparing ${items.length} item${items.length === 1 ? '' : 's'} from ${type}`);
337
+ healthTracker.setPhase('formatting-phrases', `Preparing ${items.length} item${items.length === 1 ? '' : 's'} from ${type}`);
338
+
339
+ const stockPhrases = dedupePhrases(stocks.map(stock => buildStockPhrase(stock, config)));
340
+
341
+ // Articles with skipModelRewrite keep their displayPhrase as-is (e.g. weather conditions)
342
+ const modelEligible = articles.filter(a => !a.skipModelRewrite);
343
+ const preFormatted = articles.filter(a => a.skipModelRewrite);
344
+ const preFormattedPhrases = buildBasicArticlePhrases(preFormatted, config);
345
+
346
+ const hydratedArticles = config.githubModels.enabled && config.githubModels.fetchArticleContent
347
+ ? await hydrateArticleContent(modelEligible, config)
348
+ : modelEligible;
349
+
350
+ const fallbackArticlePhrases = buildBasicArticlePhrases(hydratedArticles, config);
351
+ let articlePhrases: string[];
352
+ if (config.githubModels.enabled && hydratedArticles.length > 0) {
353
+ const { uncached, cachedPhrases } = partitionArticlesByModelCache(hydratedArticles, config);
354
+ if (uncached.length === 0) {
355
+ logInfo(config, `All ${type} articles already in model cache — skipping GitHub Models API`);
356
+ articlePhrases = cachedPhrases.length > 0 ? cachedPhrases : fallbackArticlePhrases;
357
+ } else {
358
+ try {
359
+ const newPhrases = await buildModelArticlePhrases(uncached, config, {
360
+ sourceType: type,
361
+ onProgress: (message: string) => {
362
+ startInteractiveProgress(message);
363
+ healthTracker.setPhase('formatting-phrases', message);
364
+ },
365
+ onPhrases: (phrases: string[]) => {
366
+ const latest = phrases[phrases.length - 1];
367
+ if (latest) {
368
+ const truncated = latest.length > 100 ? latest.slice(0, 100) + '…' : latest;
369
+ startInteractiveProgress(`${pc.dim('•')} ${truncated}`);
370
+ }
371
+ },
372
+ });
373
+ cacheModelResults(uncached, newPhrases, config);
374
+ articlePhrases = [...cachedPhrases, ...newPhrases];
375
+ } catch (error: unknown) {
376
+ const message = error instanceof Error ? error.message : String(error);
377
+ const warning = `GitHub Models formatting skipped for ${type} — ${message}`;
378
+ logInfo(config, warning);
379
+ healthTracker.addWarning(warning);
380
+ startInteractiveProgress('GitHub Models unavailable, falling back to feed phrases');
381
+ healthTracker.setPhase('formatting-phrases', 'GitHub Models unavailable, falling back to feed phrases');
382
+ articlePhrases = cachedPhrases.length > 0 ? [...cachedPhrases, ...fallbackArticlePhrases] : fallbackArticlePhrases;
383
+ }
384
+ }
385
+ } else {
386
+ articlePhrases = fallbackArticlePhrases;
387
+ }
287
388
 
288
- if (dryRun) {
289
- console.log(pc.bold(pc.cyan(`Dry run only would write ${phrases.length} phrases to ${settingsPath}`)));
290
- console.log(pc.dim('Preview:'));
291
- for (const phrase of phrases.slice(0, 5)) {
292
- console.log(`${pc.green('•')} ${phrase}`);
389
+ articlePhrases = dedupePhrases([...preFormattedPhrases, ...articlePhrases]);
390
+ const sourcePhrases = dedupePhrases([...stockPhrases, ...articlePhrases]);
391
+ storePhrases(type, sourcePhrases);
392
+ logInfo(config, `Stored ${sourcePhrases.length} phrases for ${type}`);
293
393
  }
294
394
 
295
- if (!isInteractive) {
395
+ // Merge all stored phrases (freshly fetched + retained from previous runs)
396
+ // Fair round-robin ensures every source gets representation
397
+ const phrases = dedupePhrases(getMergedPhrases(config.limit));
398
+ stopInteractiveProgress(dryRun ? `Dry run ready — generated ${phrases.length} phrases` : `Generated ${phrases.length} phrases`);
399
+ if (phrases.length === 0 && sourcesToFetch.length === 0) {
400
+ logInfo(config, 'All sources still fresh — nothing to update this cycle');
401
+ healthTracker.succeed({ sourceCount: 0, articleCount: 0, stockCount: 0, phraseCount: 0 }, 'All sources still fresh');
296
402
  return;
297
403
  }
298
404
 
299
- const nextAction = await promptForPostDryRunAction('dynamic phrases');
300
- if (!nextAction || nextAction === 'exit') {
301
- outro(pc.yellow('Interactive run finished after preview. No settings were changed.'));
302
- return;
405
+ if (phrases.length === 0) {
406
+ throw new Error('No thinking phrases were generated from the configured sources.');
303
407
  }
304
408
 
305
- if (nextAction === 'edit') {
306
- continue;
307
- }
409
+ const summary = {
410
+ sourceCount: enabledSources.length,
411
+ articleCount: totalArticles,
412
+ stockCount: totalStocks,
413
+ phraseCount: phrases.length,
414
+ };
308
415
 
309
- dryRun = false;
310
- if (process.platform === 'darwin') {
311
- const schedulerOverrides = await promptForDynamicSchedulerAfterDryRun();
312
- if (!schedulerOverrides) {
416
+ if (dryRun) {
417
+ console.log(pc.bold(pc.cyan(`Dry run only — would write ${phrases.length} phrases to "${settingsPath}"`)));
418
+ console.log(pc.dim('Preview:'));
419
+ for (const phrase of phrases.slice(0, 5)) {
420
+ console.log(`${pc.green('•')} ${phrase}`);
421
+ }
422
+
423
+ if (!isInteractive) {
424
+ healthTracker.succeed(summary, `Dry run generated ${phrases.length} phrases`);
313
425
  return;
314
426
  }
315
427
 
316
- installScheduler = Boolean(schedulerOverrides.installScheduler);
317
- schedulerIntervalSeconds = schedulerOverrides.schedulerIntervalSeconds ?? schedulerIntervalSeconds;
428
+ const nextAction = await promptForPostDryRunAction('dynamic phrases');
429
+ if (!nextAction || nextAction === 'exit') {
430
+ healthTracker.succeed(summary, `Dry run generated ${phrases.length} phrases`);
431
+ outro(pc.yellow('Interactive run finished after preview. No settings were changed.'));
432
+ return;
433
+ }
434
+
435
+ if (nextAction === 'edit') {
436
+ healthTracker.succeed(summary, `Dry run generated ${phrases.length} phrases`);
437
+ continue;
438
+ }
439
+
440
+ dryRun = false;
441
+ healthTracker.setDryRun(false);
442
+ // Scheduler config was already collected upfront in promptForInteractiveOverrides
318
443
  config = syncGitHubLookbackToScheduler(config, schedulerIntervalSeconds);
444
+
445
+ outro(pc.green('Installing dynamic phrases…'));
319
446
  }
320
447
 
321
- outro(pc.green('Installing dynamic phrases…'));
322
- }
448
+ if (isInteractive) {
449
+ if (createNewConfig) {
450
+ const namedConfigPath = await promptForConfigName(config);
451
+ if (!namedConfigPath) {
452
+ outro(pc.yellow('Interactive run cancelled. No settings were changed.'));
453
+ return;
454
+ }
323
455
 
324
- if (isInteractive) {
325
- if (createNewConfig) {
326
- const namedConfigPath = await promptForConfigName(config);
327
- if (!namedConfigPath) {
328
- outro(pc.yellow('Interactive run cancelled. No settings were changed.'));
329
- return;
456
+ configPath = resolveConfigPath(namedConfigPath);
457
+ schedulerConfigPath = namedConfigPath;
458
+ createNewConfig = false;
330
459
  }
331
460
 
332
- configPath = resolveConfigPath(namedConfigPath);
333
- schedulerConfigPath = namedConfigPath;
334
- createNewConfig = false;
335
- }
461
+ if (!configPath) {
462
+ throw new Error('Interactive config path was not resolved before save.');
463
+ }
336
464
 
337
- if (!configPath) {
338
- throw new Error('Interactive config path was not resolved before save.');
465
+ writeConfigFile(configPath, config);
466
+ console.log(pc.dim(`Saved dynamic config ${formatConfigPathForDisplay(configPath)}`));
339
467
  }
340
468
 
341
- writeConfigFile(configPath, config);
342
- console.log(pc.dim(`Saved dynamic config → ${formatConfigPathForDisplay(configPath)}`));
343
- }
344
-
345
- writeVsCodeSettings(settingsPath, phrases, config.mode);
346
- console.log(pc.green(`Updated ${settingsPath}`));
347
- console.log(
348
- pc.bold(
349
- `Replaced thinking phrases with ${phrases.length} phrases from ${enabledSources.length} source(s)${config.githubModels.enabled ? ' using GitHub Models formatting when available' : ''}`,
350
- ),
351
- );
352
-
353
- if (installScheduler && process.platform === 'darwin') {
354
- config = syncGitHubLookbackToScheduler(config, schedulerIntervalSeconds);
355
- const resolvedSchedulerConfigPath = resolveConfigPath(schedulerConfigPath ?? configPath ?? args.configPath);
356
- writeConfigFile(resolvedSchedulerConfigPath, config);
357
- console.log(pc.cyan(`Installing macOS launchd scheduler for every ${schedulerIntervalSeconds} seconds...`));
358
- installMacScheduler(schedulerIntervalSeconds, resolvedSchedulerConfigPath);
359
- console.log(pc.green(`Scheduler installed for every ${schedulerIntervalSeconds} seconds using ${resolvedSchedulerConfigPath}.`));
360
- }
469
+ healthTracker.setPhase('writing-settings', `Writing ${phrases.length} phrases to VS Code settings`);
470
+ writeVsCodeSettings(settingsPath, phrases, config.mode);
471
+ console.log(pc.green(`Updated "${settingsPath}"`));
472
+ console.log(
473
+ pc.bold(
474
+ `Replaced thinking phrases with ${phrases.length} phrases from ${enabledSources.length} source(s)${config.githubModels.enabled ? ' using GitHub Models formatting when available' : ''}`,
475
+ ),
476
+ );
477
+
478
+ if (installScheduler && process.platform === 'darwin') {
479
+ healthTracker.setPhase('updating-scheduler', `Installing macOS scheduler for every ${schedulerIntervalSeconds} seconds`);
480
+ config = syncGitHubLookbackToScheduler(config, schedulerIntervalSeconds);
481
+ const resolvedSchedulerConfigPath = resolveConfigPath(schedulerConfigPath ?? configPath ?? args.configPath);
482
+ writeConfigFile(resolvedSchedulerConfigPath, config);
483
+ console.log(pc.cyan(`Installing macOS launchd scheduler for every ${schedulerIntervalSeconds} seconds...`));
484
+ installMacScheduler(schedulerIntervalSeconds, resolvedSchedulerConfigPath);
485
+ console.log(pc.green(`Scheduler installed for every ${schedulerIntervalSeconds} seconds using ${resolvedSchedulerConfigPath}.`));
486
+ }
361
487
 
362
- if (process.platform === 'darwin') {
363
- const installedScheduler = getInstalledSchedulerInfo();
364
- if (installedScheduler.installed) {
365
- console.log(
366
- pc.dim(
367
- `Scheduler status: installed${installedScheduler.intervalSeconds ? ` every ${installedScheduler.intervalSeconds}s` : ''} → ${formatConfigPathForDisplay(installedScheduler.configPath ?? configPath ?? resolveConfigPath(args.configPath))}`,
368
- ),
369
- );
488
+ if (process.platform === 'darwin') {
489
+ const installedScheduler = getInstalledSchedulerInfo();
490
+ if (installedScheduler.installed) {
491
+ console.log(
492
+ pc.dim(
493
+ `Scheduler status: installed${installedScheduler.intervalSeconds ? ` every ${installedScheduler.intervalSeconds}s` : ''} → ${formatConfigPathForDisplay(installedScheduler.configPath ?? configPath ?? resolveConfigPath(args.configPath))}`,
494
+ ),
495
+ );
496
+ }
370
497
  }
371
- }
372
498
 
373
- return;
499
+ healthTracker.succeed(summary, `Completed run with ${phrases.length} phrases from ${enabledSources.length} source(s)`);
500
+ return;
501
+ } catch (error) {
502
+ const message = error instanceof Error ? error.message : String(error);
503
+ failInteractiveProgress('Dynamic run failed');
504
+ healthTracker.fail(message);
505
+ throw error;
506
+ }
374
507
  }
375
508
  }
@@ -5,7 +5,7 @@ import type { InstalledSchedulerInfo } from './types.js';
5
5
 
6
6
  export const SCHEDULER_LABEL = 'com.austenstone.thinking-phrases.rss';
7
7
  export const INSTALLED_PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${SCHEDULER_LABEL}.plist`);
8
- export const DEFAULT_SCHEDULER_INTERVAL_SECONDS = 300;
8
+ export const DEFAULT_SCHEDULER_INTERVAL_SECONDS = 60;
9
9
 
10
10
  const IGNORED_DIRECTORIES = new Set(['.git', 'dist', 'node_modules', 'out']);
11
11