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.
- package/README.md +230 -142
- package/configs/hn-top.config.json +60 -27
- package/launchd/rss-update.error.log +3 -27
- package/launchd/rss-update.log +308 -0
- package/launchd/task-health.json +54 -0
- package/out/dwyl-quotes.json +1621 -0
- package/out/javascript-tips.json +107 -0
- package/out/league-loading-screen-tips.json +107 -0
- package/out/ruby-tips.json +115 -0
- package/out/settings-linux.json +87 -0
- package/out/settings-mac.json +87 -0
- package/out/settings-windows.json +87 -0
- package/out/typescript-tips.json +131 -0
- package/out/vscode-tips.json +87 -0
- package/out/wow-loading-screen-tips.json +116 -0
- package/package.json +19 -12
- package/scripts/build.ts +3 -3
- package/scripts/debug-hn-hydration.ts +33 -0
- package/scripts/run-rss-update.zsh +25 -3
- package/scripts/show-thinking-phrases-health.ts +74 -0
- package/scripts/trigger-thinking-phrases-scheduler.zsh +50 -0
- package/src/core/config.ts +65 -3
- package/src/core/githubModels.ts +200 -112
- package/src/core/interactive.ts +49 -67
- package/src/core/phraseCache.ts +242 -0
- package/src/core/phraseFormats.ts +243 -0
- package/src/core/presets.ts +1 -1
- package/src/core/runner.ts +246 -113
- package/src/core/scheduler.ts +1 -1
- package/src/core/taskHealth.ts +213 -0
- package/src/core/types.ts +32 -8
- package/src/core/utils.ts +27 -2
- package/src/sources/customJson.ts +28 -18
- package/src/sources/earthquakes.ts +4 -4
- package/src/sources/githubActivity.ts +120 -48
- package/src/sources/hackerNews.ts +19 -7
- package/src/sources/rss.ts +25 -11
- package/src/sources/stocks.ts +31 -10
- package/src/sources/weatherAlerts.ts +173 -7
- package/tsconfig.json +1 -1
- package/scripts/update-rss-settings.ts +0 -7
package/src/core/interactive.ts
CHANGED
|
@@ -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:
|
|
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}
|
|
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
|
-
|
|
351
|
+
const selectedConfig = selectedConfigPath
|
|
334
352
|
? mergeConfig(DEFAULT_CONFIG, readConfigFile(resolveConfigPath(selectedConfigPath)), {})
|
|
335
353
|
: DEFAULT_CONFIG;
|
|
336
|
-
|
|
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:
|
|
493
|
+
placeholder: defaultZip || '33312',
|
|
494
|
+
initialValue: defaultZip,
|
|
471
495
|
validate(value) {
|
|
472
|
-
const zipCode = normalizeUsZipCode(keepExistingValue(value,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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:
|
|
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
|
+
}
|