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
@@ -5,7 +5,7 @@ import { CONFIG_PATH, DEFAULT_CONFIG, mergeConfig, readConfigFile, resolveConfig
5
5
  import { discoverConfigProfiles, formatConfigPathForDisplay, getInstalledSchedulerInfo } from './scheduler.js';
6
6
  import { discoverStaticPacks } from './staticPacks.js';
7
7
  import type { CliOverrides, Config, FeedConfig } from './types.js';
8
- import { isValidUsZipCode, normalizeSymbols, normalizeUsZipCode } from './utils.js';
8
+ import { detectZipFromIp, isValidUsZipCode, normalizeSymbols, normalizeUsZipCode } from './utils.js';
9
9
 
10
10
  interface InteractivePromptOptions {
11
11
  showIntro?: boolean;
@@ -205,11 +205,16 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
205
205
  }
206
206
 
207
207
  const installKind = await select({
208
- message: 'What kind of thinking phrases do you want to install?',
208
+ message: installedScheduler?.installed
209
+ ? 'What do you want to do with thinking phrases?'
210
+ : 'What kind of thinking phrases do you want to install?',
209
211
  initialValue: 'dynamic',
210
212
  options: [
211
213
  { value: 'dynamic', label: 'Dynamic phrases', hint: 'RSS, stocks, models, scheduler support' },
212
- { value: 'static', label: 'Static pack', hint: `${staticPacks.length} generated packs available` },
214
+ { value: 'static', label: 'Static pack', hint: `${staticPacks.length} static packs available` },
215
+ ...(installedScheduler?.installed
216
+ ? [{ value: 'trigger', label: 'Trigger installed scheduler now', hint: 'Run the current launchd job immediately' }]
217
+ : []),
213
218
  { value: 'uninstall', label: 'Uninstall', hint: 'Remove thinking phrases and scheduler' },
214
219
  ],
215
220
  });
@@ -218,6 +223,19 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
218
223
  return cancelFlow('Interactive run cancelled. No settings were changed.');
219
224
  }
220
225
 
226
+ if (installKind === 'trigger') {
227
+ note(
228
+ [
229
+ `${pc.bold('Action')} ${pc.green('trigger installed scheduler now')}`,
230
+ `${pc.bold('Scheduler')} ${pc.cyan(installedScheduler?.label ?? 'com.austenstone.thinking-phrases.rss')}`,
231
+ `${pc.bold('Config')} ${pc.yellow(formatConfigPathForDisplay(installedScheduler?.configPath ?? CONFIG_PATH))}`,
232
+ ].join('\n'),
233
+ 'Run summary',
234
+ );
235
+
236
+ return finishFlow({ triggerSchedulerNow: true }, 'Triggering installed scheduler…');
237
+ }
238
+
221
239
  if (installKind === 'uninstall') {
222
240
  note(
223
241
  [
@@ -330,10 +348,10 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
330
348
 
331
349
  const isNewConfig = selectedConfigOption === '__new__';
332
350
  const selectedConfigPath = isNewConfig ? undefined : selectedConfigOption;
333
- let selectedConfig = selectedConfigPath
351
+ const selectedConfig = selectedConfigPath
334
352
  ? mergeConfig(DEFAULT_CONFIG, readConfigFile(resolveConfigPath(selectedConfigPath)), {})
335
353
  : DEFAULT_CONFIG;
336
- let startWithNoSourcesSelected = isNewConfig;
354
+ const startWithNoSourcesSelected = isNewConfig;
337
355
 
338
356
  const selectedSources = await multiselect({
339
357
  message: 'Which sources do you want to use?',
@@ -424,7 +442,6 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
424
442
  }
425
443
 
426
444
  overrides.feeds = parseCsv(keepExistingValue(feedInput, existingFeeds)).map(url => ({ url } satisfies FeedConfig));
427
-
428
445
  }
429
446
 
430
447
  if (useStocks) {
@@ -432,6 +449,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
432
449
  const symbolInput = await text({
433
450
  message: 'Stock symbols',
434
451
  placeholder: existingSymbols || 'MSFT NVDA TSLA',
452
+ initialValue: existingSymbols,
435
453
  validate(value) {
436
454
  return normalizeSymbols(parseSymbolInput(keepExistingValue(value, existingSymbols))).length > 0
437
455
  ? undefined
@@ -465,11 +483,17 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
465
483
  const existingZipCode = selectedConfig.earthquakes.zipCode?.trim()
466
484
  || selectedConfig.weatherAlerts.zipCode?.trim()
467
485
  || '';
486
+
487
+ // Auto-detect ZIP from IP if not already configured
488
+ const detectedZip = existingZipCode || await detectZipFromIp();
489
+ const defaultZip = detectedZip || '';
490
+
468
491
  const zipCodeInput = await text({
469
492
  message: useEarthquakes && useWeatherAlerts ? 'ZIP code for local earthquake + weather lookups' : useEarthquakes ? 'ZIP code for local earthquake lookups' : 'ZIP code for local weather lookups',
470
- placeholder: existingZipCode || '33312',
493
+ placeholder: defaultZip || '33312',
494
+ initialValue: defaultZip,
471
495
  validate(value) {
472
- const zipCode = normalizeUsZipCode(keepExistingValue(value, existingZipCode));
496
+ const zipCode = normalizeUsZipCode(keepExistingValue(value, defaultZip));
473
497
  return zipCode && isValidUsZipCode(zipCode) ? undefined : 'Enter a valid 5-digit US ZIP code.';
474
498
  },
475
499
  });
@@ -478,7 +502,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
478
502
  return cancelFlow('Interactive run cancelled. No settings were changed.');
479
503
  }
480
504
 
481
- resolvedZipCode = normalizeUsZipCode(keepExistingValue(zipCodeInput, existingZipCode));
505
+ resolvedZipCode = normalizeUsZipCode(keepExistingValue(zipCodeInput, defaultZip));
482
506
  }
483
507
 
484
508
  if (useHackerNews) {
@@ -563,6 +587,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
563
587
  const customJsonUrl = await text({
564
588
  message: 'JSON endpoint URL',
565
589
  placeholder: existingJsonUrl || 'https://example.com/api/articles.json',
590
+ initialValue: existingJsonUrl,
566
591
  validate(value) {
567
592
  const resolvedValue = keepExistingValue(value, existingJsonUrl);
568
593
  if (!resolvedValue.trim()) {
@@ -586,6 +611,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
586
611
  const customJsonItemsPath = await text({
587
612
  message: 'Array path inside the JSON payload',
588
613
  placeholder: existingItemsPath || 'items',
614
+ initialValue: existingItemsPath,
589
615
  });
590
616
 
591
617
  if (isCancel(customJsonItemsPath)) {
@@ -596,6 +622,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
596
622
  const customJsonTitleField = await text({
597
623
  message: 'Title field path',
598
624
  placeholder: existingTitleField || 'title',
625
+ initialValue: existingTitleField,
599
626
  validate(value) {
600
627
  return keepExistingValue(value, existingTitleField).trim() ? undefined : 'Enter a field path for the title.';
601
628
  },
@@ -609,6 +636,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
609
636
  const customJsonContentField = await text({
610
637
  message: 'Optional content field path',
611
638
  placeholder: existingContentField || 'summary',
639
+ initialValue: existingContentField,
612
640
  });
613
641
 
614
642
  if (isCancel(customJsonContentField)) {
@@ -619,6 +647,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
619
647
  const customJsonLinkField = await text({
620
648
  message: 'Optional link field path',
621
649
  placeholder: existingLinkField || 'url',
650
+ initialValue: existingLinkField,
622
651
  });
623
652
 
624
653
  if (isCancel(customJsonLinkField)) {
@@ -629,6 +658,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
629
658
  const customJsonSourceField = await text({
630
659
  message: 'Optional source field path',
631
660
  placeholder: existingSourceField || 'source.name',
661
+ initialValue: existingSourceField,
632
662
  });
633
663
 
634
664
  if (isCancel(customJsonSourceField)) {
@@ -639,6 +669,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
639
669
  const customJsonSourceLabel = await text({
640
670
  message: 'Fallback source label',
641
671
  placeholder: existingSourceLabel || 'My API',
672
+ initialValue: existingSourceLabel,
642
673
  });
643
674
 
644
675
  if (isCancel(customJsonSourceLabel)) {
@@ -649,6 +680,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
649
680
  const customJsonDateField = await text({
650
681
  message: 'Optional published date field path',
651
682
  placeholder: existingDateField || 'publishedAt',
683
+ initialValue: existingDateField,
652
684
  });
653
685
 
654
686
  if (isCancel(customJsonDateField)) {
@@ -659,6 +691,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
659
691
  const customJsonIdField = await text({
660
692
  message: 'Optional unique ID field path',
661
693
  placeholder: existingIdField || 'id',
694
+ initialValue: existingIdField,
662
695
  });
663
696
 
664
697
  if (isCancel(customJsonIdField)) {
@@ -669,6 +702,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
669
702
  const customJsonLimit = await text({
670
703
  message: 'How many JSON items should be included?',
671
704
  placeholder: existingJsonLimit,
705
+ initialValue: existingJsonLimit,
672
706
  validate(value) {
673
707
  const parsed = Number(keepExistingValue(value, existingJsonLimit));
674
708
  return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Enter a positive integer.';
@@ -895,24 +929,6 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
895
929
  };
896
930
  }
897
931
 
898
- if (useRss) {
899
- const existingLimit = String(selectedConfig.limit);
900
- const limitInput = await text({
901
- message: 'How many RSS items should be considered?',
902
- initialValue: existingLimit,
903
- validate(value) {
904
- const parsed = Number(keepExistingValue(value, existingLimit));
905
- return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Enter a positive integer.';
906
- },
907
- });
908
-
909
- if (isCancel(limitInput)) {
910
- return cancelFlow('Interactive run cancelled. No settings were changed.');
911
- }
912
-
913
- overrides.limit = Number(keepExistingValue(limitInput, existingLimit));
914
- }
915
-
916
932
  overrides.mode = 'replace';
917
933
 
918
934
  const shouldDryRun = await confirm({
@@ -926,29 +942,12 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
926
942
 
927
943
  overrides.dryRun = shouldDryRun;
928
944
 
929
- if (!overrides.dryRun && process.platform === 'darwin') {
945
+ // Always collect scheduler config upfront so the user isn't blocked after a long run
946
+ if (process.platform === 'darwin') {
930
947
  overrides.installScheduler = true;
931
948
  overrides.schedulerConfigPath = selectedConfigPath;
932
-
933
- const existingInterval = String(installedScheduler?.intervalSeconds ?? 300);
934
- const intervalInput = await text({
935
- message: installedScheduler?.installed
936
- ? 'How often should the scheduler run? Enter interval in seconds'
937
- : 'How often should it run? Enter interval in seconds',
938
- placeholder: '300',
939
- initialValue: existingInterval,
940
- validate(value) {
941
- const parsed = Number(keepExistingValue(value, existingInterval));
942
- return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Enter a positive integer in seconds.';
943
- },
944
- });
945
-
946
- if (isCancel(intervalInput)) {
947
- return cancelFlow('Interactive run cancelled. No settings were changed.');
948
- }
949
-
950
- overrides.schedulerIntervalSeconds = Number(keepExistingValue(intervalInput, existingInterval));
951
- } else if (!overrides.dryRun && process.platform !== 'darwin') {
949
+ overrides.schedulerIntervalSeconds = installedScheduler?.intervalSeconds ?? 60;
950
+ } else if (!overrides.dryRun) {
952
951
  note(
953
952
  pc.dim('Scheduler install is currently only wired for macOS launchd. Settings will still be written.'),
954
953
  'Scheduler',
@@ -969,7 +968,7 @@ export async function promptForInteractiveOverrides(config: Config, options: Int
969
968
  `${pc.bold('Models')} ${overrides.githubModels?.enabled ? pc.magenta('enabled') : pc.dim('disabled')}`,
970
969
  `${pc.bold('Mode')} ${pc.yellow(overrides.mode ?? selectedConfig.mode)}`,
971
970
  `${pc.bold('Action')} ${overrides.dryRun ? pc.blue('preview only') : pc.green('write settings')}`,
972
- `${pc.bold('Schedule')} ${overrides.installScheduler ? pc.green(`every ${overrides.schedulerIntervalSeconds ?? 300}s`) : pc.dim('not installing')}`,
971
+ `${pc.bold('Schedule')} ${overrides.installScheduler ? pc.green(`every ${overrides.schedulerIntervalSeconds ?? 60}s (per-source intervals in config)`) : pc.dim('not installing')}`,
973
972
  `${pc.bold('Config')} ${isNewConfig ? pc.cyan('new config (auto-name if blank)') : pc.cyan(selectedConfigPath ?? '')}`,
974
973
  ].join('\n'),
975
974
  'Run summary',
@@ -1016,26 +1015,9 @@ export async function promptForConfigName(config: Pick<Config, 'feeds' | 'stockQ
1016
1015
  export async function promptForDynamicSchedulerAfterDryRun(): Promise<Pick<CliOverrides, 'installScheduler' | 'schedulerIntervalSeconds'> | null> {
1017
1016
  const installedScheduler = process.platform === 'darwin' ? getInstalledSchedulerInfo() : null;
1018
1017
 
1019
- const existingInterval = String(installedScheduler?.intervalSeconds ?? 300);
1020
- const intervalInput = await text({
1021
- message: installedScheduler?.installed
1022
- ? 'How often should the scheduler run? Enter interval in seconds'
1023
- : 'How often should it run? Enter interval in seconds',
1024
- placeholder: '300',
1025
- initialValue: existingInterval,
1026
- validate(value) {
1027
- const parsed = Number(keepExistingValue(value, existingInterval));
1028
- return Number.isInteger(parsed) && parsed > 0 ? undefined : 'Enter a positive integer in seconds.';
1029
- },
1030
- });
1031
-
1032
- if (isCancel(intervalInput)) {
1033
- return null;
1034
- }
1035
-
1036
1018
  return {
1037
1019
  installScheduler: true,
1038
- schedulerIntervalSeconds: Number(keepExistingValue(intervalInput, existingInterval)),
1020
+ schedulerIntervalSeconds: installedScheduler?.intervalSeconds ?? 60,
1039
1021
  };
1040
1022
  }
1041
1023
 
@@ -0,0 +1,242 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { ArticleItem, Config } from './types.js';
5
+ import { logDebug, logInfo } from './utils.js';
6
+
7
+ const CACHE_DIR = join(homedir(), '.cache', 'thinking-phrases');
8
+ const SOURCE_TIMESTAMPS_FILE = join(CACHE_DIR, 'source-timestamps.json');
9
+ const MODEL_CACHE_FILE = join(CACHE_DIR, 'model-cache.json');
10
+ const DEFAULT_CACHE_TTL_SECONDS = 604800; // 7 days
11
+
12
+ type SourceTimestamps = Record<string, number>;
13
+
14
+ interface ModelCacheEntry {
15
+ phrases: string[];
16
+ cachedAt: number; // epoch ms
17
+ }
18
+
19
+ type ModelCache = Record<string, ModelCacheEntry>;
20
+
21
+ function ensureCacheDir(): void {
22
+ if (!existsSync(CACHE_DIR)) {
23
+ mkdirSync(CACHE_DIR, { recursive: true });
24
+ }
25
+ }
26
+
27
+ function readJson<T>(filePath: string, fallback: T): T {
28
+ if (!existsSync(filePath)) {
29
+ return fallback;
30
+ }
31
+
32
+ try {
33
+ return JSON.parse(readFileSync(filePath, 'utf8')) as T;
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function writeJson(filePath: string, data: unknown): void {
40
+ ensureCacheDir();
41
+ writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
42
+ }
43
+
44
+ // --- Source fetch interval tracking ---
45
+
46
+ function readSourceTimestamps(): SourceTimestamps {
47
+ return readJson<SourceTimestamps>(SOURCE_TIMESTAMPS_FILE, {});
48
+ }
49
+
50
+ function writeSourceTimestamps(timestamps: SourceTimestamps): void {
51
+ writeJson(SOURCE_TIMESTAMPS_FILE, timestamps);
52
+ }
53
+
54
+ /**
55
+ * Returns the configured fetchIntervalSeconds for a given source type.
56
+ * RSS feeds use a top-level `rssFetchIntervalSeconds` since each feed doesn't
57
+ * map 1:1 to a source config block.
58
+ */
59
+ export function getSourceIntervalSeconds(sourceType: string, config: Config): number {
60
+ switch (sourceType) {
61
+ case 'rss': return config.rssFetchIntervalSeconds;
62
+ case 'stocks': return config.stockQuotes.fetchIntervalSeconds ?? 60;
63
+ case 'hacker-news': return config.hackerNews.fetchIntervalSeconds ?? 300;
64
+ case 'earthquakes': return config.earthquakes.fetchIntervalSeconds ?? 1800;
65
+ case 'weather-alerts': return config.weatherAlerts.fetchIntervalSeconds ?? 1800;
66
+ case 'custom-json': return config.customJson.fetchIntervalSeconds ?? 3600;
67
+ case 'github-activity': return config.githubActivity.fetchIntervalSeconds ?? 300;
68
+ default: return 300;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check whether a source should be fetched based on its per-source interval.
74
+ * Returns true if the source is stale (interval elapsed) or has never been fetched.
75
+ */
76
+ export function isSourceStale(sourceType: string, config: Config): boolean {
77
+ const timestamps = readSourceTimestamps();
78
+ const lastFetch = timestamps[sourceType];
79
+ if (lastFetch === undefined) {
80
+ return true;
81
+ }
82
+
83
+ const intervalMs = getSourceIntervalSeconds(sourceType, config) * 1000;
84
+ const elapsed = Date.now() - lastFetch;
85
+ return elapsed >= intervalMs;
86
+ }
87
+
88
+ /**
89
+ * Record that a source was successfully fetched right now.
90
+ */
91
+ export function markSourceFetched(sourceType: string): void {
92
+ const timestamps = readSourceTimestamps();
93
+ timestamps[sourceType] = Date.now();
94
+ writeSourceTimestamps(timestamps);
95
+ }
96
+
97
+ // --- Model result deduplication cache ---
98
+
99
+ function readModelCache(): ModelCache {
100
+ return readJson<ModelCache>(MODEL_CACHE_FILE, {});
101
+ }
102
+
103
+ function writeModelCache(cache: ModelCache): void {
104
+ writeJson(MODEL_CACHE_FILE, cache);
105
+ }
106
+
107
+ export function clearModelCache(): void {
108
+ ensureCacheDir();
109
+ writeJson(MODEL_CACHE_FILE, {});
110
+ }
111
+
112
+ /**
113
+ * Prune model cache entries older than the configured TTL.
114
+ */
115
+ function pruneModelCache(cache: ModelCache, ttlSeconds: number): ModelCache {
116
+ const cutoff = Date.now() - ttlSeconds * 1000;
117
+ const pruned: ModelCache = {};
118
+ for (const [id, entry] of Object.entries(cache)) {
119
+ if (entry.cachedAt >= cutoff) {
120
+ pruned[id] = entry;
121
+ }
122
+ }
123
+
124
+ return pruned;
125
+ }
126
+
127
+ /**
128
+ * Split articles into those that need model processing and those that already
129
+ * have cached phrases. Returns the cached phrases for the already-processed ones.
130
+ */
131
+ export function partitionArticlesByModelCache(
132
+ articles: ArticleItem[],
133
+ config: Config,
134
+ ): { uncached: ArticleItem[]; cachedPhrases: string[] } {
135
+ const ttl = config.githubModels.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS;
136
+ const cache = pruneModelCache(readModelCache(), ttl);
137
+ const uncached: ArticleItem[] = [];
138
+ const cachedPhrases: string[] = [];
139
+
140
+ for (const article of articles) {
141
+ const entry = cache[article.id];
142
+ if (entry) {
143
+ logDebug(config, `Model cache hit for "${article.title ?? article.id}"`);
144
+ cachedPhrases.push(...entry.phrases);
145
+ } else {
146
+ uncached.push(article);
147
+ }
148
+ }
149
+
150
+ logInfo(config, `Model cache: ${cachedPhrases.length} cached phrases, ${uncached.length} articles need processing`);
151
+ return { uncached, cachedPhrases };
152
+ }
153
+
154
+ /**
155
+ * Save model-generated phrases keyed by article ID so we never re-process them.
156
+ * Maps each phrase back to its source article via a simple index-based heuristic:
157
+ * phrases are distributed round-robin across the input articles.
158
+ *
159
+ * For better accuracy, call this per-chunk where articles and phrases align tightly.
160
+ */
161
+ export function cacheModelResults(articles: ArticleItem[], phrases: string[], config: Config): void {
162
+ const ttl = config.githubModels.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS;
163
+ const cache = pruneModelCache(readModelCache(), ttl);
164
+ const maxPerArticle = config.githubModels.maxPhrasesPerArticle;
165
+
166
+ // Best-effort: assign phrases to articles in order, up to maxPhrasesPerArticle each
167
+ let phraseIndex = 0;
168
+ for (const article of articles) {
169
+ const articlePhrases: string[] = [];
170
+ while (phraseIndex < phrases.length && articlePhrases.length < maxPerArticle) {
171
+ articlePhrases.push(phrases[phraseIndex]);
172
+ phraseIndex += 1;
173
+ }
174
+
175
+ cache[article.id] = { phrases: articlePhrases, cachedAt: Date.now() };
176
+ }
177
+
178
+ // Overflow phrases (more output than articles) are dropped — they'd accumulate
179
+ // under unique keys and never get reused.
180
+
181
+ writeModelCache(cache);
182
+ }
183
+
184
+ // --- Phrase store (merge across fetch intervals) ---
185
+
186
+ const PHRASE_STORE_FILE = join(CACHE_DIR, 'phrase-store.json');
187
+
188
+ type PhraseStore = Record<string, {
189
+ phrases: string[];
190
+ updatedAt: number;
191
+ }>;
192
+
193
+ function readPhraseStore(): PhraseStore {
194
+ return readJson<PhraseStore>(PHRASE_STORE_FILE, {});
195
+ }
196
+
197
+ function writePhraseStore(store: PhraseStore): void {
198
+ writeJson(PHRASE_STORE_FILE, store);
199
+ }
200
+
201
+ /**
202
+ * Persist phrases for a source type so they survive across runs
203
+ * where the source isn't re-fetched (interval not elapsed).
204
+ */
205
+ export function storePhrases(sourceType: string, phrases: string[]): void {
206
+ const store = readPhraseStore();
207
+ store[sourceType] = { phrases, updatedAt: Date.now() };
208
+ writePhraseStore(store);
209
+ }
210
+
211
+ /**
212
+ * Merge stored phrases with fair round-robin distribution across sources.
213
+ * Each source contributes proportionally so no single source dominates.
214
+ */
215
+ export function getMergedPhrases(limit: number): string[] {
216
+ const store = readPhraseStore();
217
+ const entries = Object.values(store).filter(e => e.phrases.length > 0);
218
+ if (entries.length === 0) return [];
219
+
220
+ const result: string[] = [];
221
+ const perSource = Math.max(1, Math.ceil(limit / entries.length));
222
+
223
+ // First pass: give each source its fair share
224
+ for (const entry of entries) {
225
+ result.push(...entry.phrases.slice(0, perSource));
226
+ }
227
+
228
+ // Second pass: fill remaining slots from sources that have more
229
+ if (result.length < limit) {
230
+ for (const entry of entries) {
231
+ const remaining = entry.phrases.slice(perSource);
232
+ for (const phrase of remaining) {
233
+ if (result.length >= limit) break;
234
+ if (!result.includes(phrase)) {
235
+ result.push(phrase);
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ return result.slice(0, limit);
242
+ }