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
@@ -0,0 +1,213 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ export type TaskHealthStatus = 'running' | 'succeeded' | 'failed';
5
+ export type TaskHealthPhase =
6
+ | 'initializing'
7
+ | 'fetching-sources'
8
+ | 'formatting-phrases'
9
+ | 'writing-settings'
10
+ | 'updating-scheduler'
11
+ | 'completed'
12
+ | 'failed';
13
+ export type TaskHealthSourceStatus = 'pending' | 'running' | 'succeeded' | 'failed';
14
+
15
+ export interface TaskHealthSourceEntry {
16
+ type: string;
17
+ status: TaskHealthSourceStatus;
18
+ startedAt?: string;
19
+ completedAt?: string;
20
+ durationMs?: number;
21
+ itemCount?: number;
22
+ error?: string;
23
+ }
24
+
25
+ export interface TaskHealthSummary {
26
+ sourceCount: number;
27
+ articleCount: number;
28
+ stockCount: number;
29
+ phraseCount: number;
30
+ }
31
+
32
+ export interface TaskHealthReport {
33
+ status: TaskHealthStatus;
34
+ phase: TaskHealthPhase;
35
+ startedAt: string;
36
+ updatedAt: string;
37
+ completedAt?: string;
38
+ durationMs?: number;
39
+ dryRun: boolean;
40
+ configPath?: string;
41
+ settingsPath?: string;
42
+ pid: number;
43
+ lastMessage?: string;
44
+ error?: string;
45
+ warnings: string[];
46
+ sources: TaskHealthSourceEntry[];
47
+ summary?: TaskHealthSummary;
48
+ }
49
+
50
+ export const TASK_HEALTH_PATH = resolve(process.cwd(), 'launchd', 'task-health.json');
51
+
52
+ function nowIso(): string {
53
+ return new Date().toISOString();
54
+ }
55
+
56
+ function durationMs(startedAt: string, completedAt: string): number {
57
+ return Math.max(0, new Date(completedAt).getTime() - new Date(startedAt).getTime());
58
+ }
59
+
60
+ function writeTaskHealth(report: TaskHealthReport): void {
61
+ mkdirSync(dirname(TASK_HEALTH_PATH), { recursive: true });
62
+ const tempPath = `${TASK_HEALTH_PATH}.tmp`;
63
+ writeFileSync(tempPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
64
+ renameSync(tempPath, TASK_HEALTH_PATH);
65
+ }
66
+
67
+ export function readTaskHealth(): TaskHealthReport | null {
68
+ try {
69
+ return JSON.parse(readFileSync(TASK_HEALTH_PATH, 'utf8')) as TaskHealthReport;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export class TaskHealthTracker {
76
+ private report: TaskHealthReport;
77
+
78
+ constructor(input: { dryRun: boolean; configPath?: string; settingsPath?: string }) {
79
+ const startedAt = nowIso();
80
+ this.report = {
81
+ status: 'running',
82
+ phase: 'initializing',
83
+ startedAt,
84
+ updatedAt: startedAt,
85
+ dryRun: input.dryRun,
86
+ configPath: input.configPath,
87
+ settingsPath: input.settingsPath,
88
+ pid: process.pid,
89
+ warnings: [],
90
+ sources: [],
91
+ lastMessage: 'Booting thinking-phrases run',
92
+ };
93
+ this.persist();
94
+ }
95
+
96
+ get filePath(): string {
97
+ return TASK_HEALTH_PATH;
98
+ }
99
+
100
+ setSources(sourceTypes: string[]): void {
101
+ this.report.sources = sourceTypes.map(type => ({ type, status: 'pending' as const }));
102
+ this.touch('initializing', `Prepared ${sourceTypes.length} source${sourceTypes.length === 1 ? '' : 's'} for execution`);
103
+ }
104
+
105
+ setPhase(phase: TaskHealthPhase, message: string): void {
106
+ this.touch(phase, message);
107
+ }
108
+
109
+ setDryRun(dryRun: boolean): void {
110
+ this.report.dryRun = dryRun;
111
+ this.report.updatedAt = nowIso();
112
+ this.persist();
113
+ }
114
+
115
+ startSource(sourceType: string): void {
116
+ const startedAt = nowIso();
117
+ this.updateSource(sourceType, {
118
+ status: 'running',
119
+ startedAt,
120
+ completedAt: undefined,
121
+ durationMs: undefined,
122
+ itemCount: undefined,
123
+ error: undefined,
124
+ });
125
+ this.touch('fetching-sources', `Fetching ${sourceType}`);
126
+ }
127
+
128
+ completeSource(sourceType: string, itemCount: number): void {
129
+ const completedAt = nowIso();
130
+ const source = this.findOrCreateSource(sourceType);
131
+ this.updateSource(sourceType, {
132
+ status: 'succeeded',
133
+ completedAt,
134
+ durationMs: source.startedAt ? durationMs(source.startedAt, completedAt) : undefined,
135
+ itemCount,
136
+ });
137
+ this.touch('fetching-sources', `Fetched ${itemCount} item${itemCount === 1 ? '' : 's'} from ${sourceType}`);
138
+ }
139
+
140
+ failSource(sourceType: string, error: string): void {
141
+ const completedAt = nowIso();
142
+ const source = this.findOrCreateSource(sourceType);
143
+ this.updateSource(sourceType, {
144
+ status: 'failed',
145
+ completedAt,
146
+ durationMs: source.startedAt ? durationMs(source.startedAt, completedAt) : undefined,
147
+ error,
148
+ });
149
+ this.touch('fetching-sources', `${sourceType} failed`);
150
+ }
151
+
152
+ addWarning(warning: string): void {
153
+ if (!this.report.warnings.includes(warning)) {
154
+ this.report.warnings.push(warning);
155
+ }
156
+
157
+ this.report.updatedAt = nowIso();
158
+ this.persist();
159
+ }
160
+
161
+ succeed(summary: TaskHealthSummary, message: string): void {
162
+ const completedAt = nowIso();
163
+ this.report.status = 'succeeded';
164
+ this.report.phase = 'completed';
165
+ this.report.summary = summary;
166
+ this.report.lastMessage = message;
167
+ this.report.completedAt = completedAt;
168
+ this.report.updatedAt = completedAt;
169
+ this.report.durationMs = durationMs(this.report.startedAt, completedAt);
170
+ this.persist();
171
+ }
172
+
173
+ fail(error: string): void {
174
+ const completedAt = nowIso();
175
+ this.report.status = 'failed';
176
+ this.report.phase = 'failed';
177
+ this.report.error = error;
178
+ this.report.lastMessage = error;
179
+ this.report.completedAt = completedAt;
180
+ this.report.updatedAt = completedAt;
181
+ this.report.durationMs = durationMs(this.report.startedAt, completedAt);
182
+ this.persist();
183
+ }
184
+
185
+ private touch(phase: TaskHealthPhase, message: string): void {
186
+ this.report.phase = phase;
187
+ this.report.lastMessage = message;
188
+ this.report.updatedAt = nowIso();
189
+ this.persist();
190
+ }
191
+
192
+ private findOrCreateSource(sourceType: string): TaskHealthSourceEntry {
193
+ const existing = this.report.sources.find(source => source.type === sourceType);
194
+ if (existing) {
195
+ return existing;
196
+ }
197
+
198
+ const source = { type: sourceType, status: 'pending' as const };
199
+ this.report.sources.push(source);
200
+ return source;
201
+ }
202
+
203
+ private updateSource(sourceType: string, updates: Partial<TaskHealthSourceEntry>): void {
204
+ const source = this.findOrCreateSource(sourceType);
205
+ Object.assign(source, updates);
206
+ this.report.updatedAt = nowIso();
207
+ this.persist();
208
+ }
209
+
210
+ private persist(): void {
211
+ writeTaskHealth(this.report);
212
+ }
213
+ }
package/src/core/types.ts CHANGED
@@ -9,31 +9,50 @@ export type GitHubFeedKind = 'timeline' | 'current-user-public' | 'current-user'
9
9
  export interface FeedConfig {
10
10
  url: string;
11
11
  source?: string;
12
+ fetchIntervalSeconds?: number;
13
+ }
14
+
15
+ export interface PhraseFormatTemplates {
16
+ article?: string;
17
+ hackerNews?: string;
18
+ stock?: string;
19
+ githubCommit?: string;
20
+ githubFeed?: string;
12
21
  }
13
22
 
14
23
  export interface PhraseFormatting {
15
24
  includeSource: boolean;
16
25
  includeTime: boolean;
17
26
  maxLength: number;
27
+ templates?: PhraseFormatTemplates;
18
28
  }
19
29
 
20
30
  export interface GitHubModelsConfig {
21
31
  enabled: boolean;
32
+ endpoint: string;
22
33
  model: string;
23
34
  tokenEnvVar: string;
24
35
  maxInputItems: number;
36
+ maxInputTokens: number;
25
37
  maxTokens: number;
38
+ maxConcurrency: number;
26
39
  maxPhrasesPerArticle: number;
27
40
  temperature: number;
28
41
  fetchArticleContent: boolean;
29
42
  maxArticleContentLength: number;
43
+ /** Default prompt used when no source-specific prompt exists */
30
44
  systemPrompt?: string;
45
+ /** Per-source prompts keyed by source type (rss, hacker-news, github-activity, earthquakes, custom-json) */
46
+ prompts?: Record<string, string>;
47
+ cacheTtlSeconds?: number;
31
48
  }
32
49
 
33
50
  export interface StockQuoteConfig {
34
51
  enabled: boolean;
35
52
  symbols: string[];
36
53
  includeMarketState: boolean;
54
+ showClosed?: boolean;
55
+ fetchIntervalSeconds?: number;
37
56
  }
38
57
 
39
58
  export interface HackerNewsConfig {
@@ -41,6 +60,7 @@ export interface HackerNewsConfig {
41
60
  feed: HackerNewsFeed;
42
61
  maxItems: number;
43
62
  minScore: number;
63
+ fetchIntervalSeconds?: number;
44
64
  }
45
65
 
46
66
  export interface EarthquakeConfig {
@@ -52,6 +72,7 @@ export interface EarthquakeConfig {
52
72
  place?: string;
53
73
  radiusKm: number;
54
74
  orderBy: EarthquakeOrder;
75
+ fetchIntervalSeconds?: number;
55
76
  }
56
77
 
57
78
  export interface WeatherAlertsConfig {
@@ -60,6 +81,7 @@ export interface WeatherAlertsConfig {
60
81
  area?: string;
61
82
  minimumSeverity: WeatherSeverity;
62
83
  limit: number;
84
+ fetchIntervalSeconds?: number;
63
85
  }
64
86
 
65
87
  export interface CustomJsonConfig {
@@ -74,6 +96,7 @@ export interface CustomJsonConfig {
74
96
  dateField?: string;
75
97
  idField?: string;
76
98
  maxItems: number;
99
+ fetchIntervalSeconds?: number;
77
100
  }
78
101
 
79
102
  export interface GitHubActivityConfig {
@@ -87,10 +110,12 @@ export interface GitHubActivityConfig {
87
110
  maxItems: number;
88
111
  sinceHours: number;
89
112
  tokenEnvVar: string;
113
+ fetchIntervalSeconds?: number;
90
114
  }
91
115
 
92
116
  export interface Config {
93
117
  feeds: FeedConfig[];
118
+ rssFetchIntervalSeconds: number;
94
119
  limit: number;
95
120
  mode: Mode;
96
121
  target: Target;
@@ -104,6 +129,7 @@ export interface Config {
104
129
  earthquakes: EarthquakeConfig;
105
130
  weatherAlerts: WeatherAlertsConfig;
106
131
  customJson: CustomJsonConfig;
132
+ customJsonSources?: CustomJsonConfig[];
107
133
  githubActivity: GitHubActivityConfig;
108
134
  }
109
135
 
@@ -111,6 +137,8 @@ export interface CliOverrides extends Partial<Config> {
111
137
  dryRun?: boolean;
112
138
  interactive?: boolean;
113
139
  uninstall?: boolean;
140
+ clearCache?: boolean;
141
+ triggerSchedulerNow?: boolean;
114
142
  createNewConfig?: boolean;
115
143
  installScheduler?: boolean;
116
144
  uninstallScheduler?: boolean;
@@ -120,14 +148,6 @@ export interface CliOverrides extends Partial<Config> {
120
148
  staticPackPath?: string;
121
149
  }
122
150
 
123
- export interface GitHubModelsResponse {
124
- choices?: Array<{
125
- message?: {
126
- content?: string;
127
- };
128
- }>;
129
- }
130
-
131
151
  export interface ArticleItem {
132
152
  type: 'article';
133
153
  id: string;
@@ -139,6 +159,10 @@ export interface ArticleItem {
139
159
  time?: string;
140
160
  content?: string;
141
161
  articleContent?: string;
162
+ /** Source-specific metadata for suffix display (e.g. HN score, commit delta) */
163
+ metadata?: Record<string, string | undefined>;
164
+ /** When true, the article's displayPhrase is final and should not be rewritten by AI models */
165
+ skipModelRewrite?: boolean;
142
166
  }
143
167
 
144
168
  export interface StockItem {
package/src/core/utils.ts CHANGED
@@ -150,6 +150,11 @@ export function stripHtml(input?: string): string | undefined {
150
150
  return cleaned || undefined;
151
151
  }
152
152
 
153
+ /** Convert hours to milliseconds. */
154
+ export function hoursToMs(hours: number): number {
155
+ return hours * 3_600_000;
156
+ }
157
+
153
158
  export function relativeTime(input?: string): string | undefined {
154
159
  if (!input) {
155
160
  return undefined;
@@ -261,13 +266,13 @@ export async function fetchJson<T>(url: string, headers?: Record<string, string>
261
266
  interface ZippopotamResponse {
262
267
  country?: string;
263
268
  'post code'?: string;
264
- places?: Array<{
269
+ places?: {
265
270
  latitude?: string;
266
271
  longitude?: string;
267
272
  'place name'?: string;
268
273
  state?: string;
269
274
  'state abbreviation'?: string;
270
- }>;
275
+ }[];
271
276
  }
272
277
 
273
278
  const zipLocationCache = new Map<string, Promise<ZipLocation>>();
@@ -310,3 +315,23 @@ export async function fetchUsZipLocation(zipCode: string): Promise<ZipLocation>
310
315
  zipLocationCache.set(normalizedZip, lookupPromise);
311
316
  return lookupPromise;
312
317
  }
318
+
319
+ interface IpGeoResponse {
320
+ status?: string;
321
+ zip?: string;
322
+ city?: string;
323
+ regionName?: string;
324
+ }
325
+
326
+ /** Best-effort ZIP detection via IP geolocation. Returns undefined on failure. */
327
+ export async function detectZipFromIp(): Promise<string | undefined> {
328
+ try {
329
+ const geo = await fetchJson<IpGeoResponse>('http://ip-api.com/json/?fields=status,zip,city,regionName');
330
+ if (geo.status === 'success' && geo.zip && isValidUsZipCode(geo.zip)) {
331
+ return geo.zip;
332
+ }
333
+ } catch {
334
+ // Best-effort — silently fail
335
+ }
336
+ return undefined;
337
+ }
@@ -1,4 +1,4 @@
1
- import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
1
+ import type { ArticleItem, Config, CustomJsonConfig, PhraseSource } from '../core/types.js';
2
2
  import { fetchJson, logInfo, relativeTime, stripHtml, truncate } from '../core/utils.js';
3
3
 
4
4
  type PathSegment = number | string;
@@ -125,33 +125,29 @@ function resolveItems(payload: unknown, path?: string): unknown[] {
125
125
  throw new Error(`Custom JSON items path did not resolve to an array${path?.trim() ? `: ${path}` : ''}`);
126
126
  }
127
127
 
128
- export async function fetchCustomJsonArticles(config: Config): Promise<ArticleItem[]> {
129
- if (!config.customJson.enabled) {
130
- return [];
131
- }
132
-
133
- logInfo(config, `Fetching custom JSON items from ${config.customJson.url}`);
134
- const payload = await fetchJson<unknown>(config.customJson.url);
135
- const items = resolveItems(payload, config.customJson.itemsPath).slice(0, config.customJson.maxItems);
136
- const defaultSource = config.customJson.sourceLabel?.trim() || buildDefaultSourceLabel(config.customJson.url);
128
+ async function fetchSingleCustomJsonSource(sourceConfig: CustomJsonConfig, config: Config): Promise<ArticleItem[]> {
129
+ logInfo(config, `Fetching custom JSON items from ${sourceConfig.url}`);
130
+ const payload = await fetchJson<unknown>(sourceConfig.url);
131
+ const items = resolveItems(payload, sourceConfig.itemsPath).slice(0, sourceConfig.maxItems);
132
+ const defaultSource = sourceConfig.sourceLabel?.trim() || buildDefaultSourceLabel(sourceConfig.url);
137
133
 
138
134
  return items.flatMap((item, index) => {
139
135
  if (!item || typeof item !== 'object') {
140
136
  return [];
141
137
  }
142
138
 
143
- const title = readStringValue(item, config.customJson.titleField);
139
+ const title = readStringValue(item, sourceConfig.titleField);
144
140
  if (!title) {
145
141
  return [];
146
142
  }
147
143
 
148
- const content = stripHtml(readStringValue(item, config.customJson.contentField));
149
- const datetime = toIsoDate(getPathValue(item, config.customJson.dateField));
150
- const link = readStringValue(item, config.customJson.linkField);
151
- const source = readStringValue(item, config.customJson.sourceField) ?? defaultSource;
152
- const id = readStringValue(item, config.customJson.idField)
144
+ const content = stripHtml(readStringValue(item, sourceConfig.contentField));
145
+ const datetime = toIsoDate(getPathValue(item, sourceConfig.dateField));
146
+ const link = readStringValue(item, sourceConfig.linkField);
147
+ const source = readStringValue(item, sourceConfig.sourceField) ?? defaultSource;
148
+ const id = readStringValue(item, sourceConfig.idField)
153
149
  ?? link
154
- ?? `${config.customJson.url}#${index}:${title}`;
150
+ ?? `${sourceConfig.url}#${index}:${title}`;
155
151
 
156
152
  return [{
157
153
  type: 'article' as const,
@@ -167,8 +163,22 @@ export async function fetchCustomJsonArticles(config: Config): Promise<ArticleIt
167
163
  });
168
164
  }
169
165
 
166
+ export async function fetchCustomJsonArticles(config: Config): Promise<ArticleItem[]> {
167
+ const allSources: CustomJsonConfig[] = [
168
+ ...(config.customJson.enabled ? [config.customJson] : []),
169
+ ...(config.customJsonSources ?? []).filter(s => s.enabled),
170
+ ];
171
+
172
+ if (allSources.length === 0) {
173
+ return [];
174
+ }
175
+
176
+ const results = await Promise.all(allSources.map(source => fetchSingleCustomJsonSource(source, config)));
177
+ return results.flat();
178
+ }
179
+
170
180
  export const customJsonSource: PhraseSource = {
171
181
  type: 'custom-json',
172
- isEnabled: config => config.customJson.enabled,
182
+ isEnabled: config => config.customJson.enabled || (config.customJsonSources ?? []).some(s => s.enabled),
173
183
  fetch: fetchCustomJsonArticles,
174
184
  };
@@ -1,5 +1,5 @@
1
- import type { ArticleItem, PhraseSource } from '../core/types.js';
2
- import { fetchJson, fetchUsZipLocation, logInfo, relativeTime } from '../core/utils.js';
1
+ import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
2
+ import { fetchJson, fetchUsZipLocation, hoursToMs, logInfo, relativeTime } from '../core/utils.js';
3
3
 
4
4
  interface UsgsFeature {
5
5
  id: string;
@@ -38,12 +38,12 @@ function buildEarthquakeContent(feature: UsgsFeature): string | undefined {
38
38
  return parts.length > 0 ? parts.join(' • ') : undefined;
39
39
  }
40
40
 
41
- export async function fetchEarthquakeArticles(config: import('../core/types.js').Config): Promise<ArticleItem[]> {
41
+ export async function fetchEarthquakeArticles(config: Config): Promise<ArticleItem[]> {
42
42
  if (!config.earthquakes.enabled) {
43
43
  return [];
44
44
  }
45
45
 
46
- const startTime = new Date(Date.now() - config.earthquakes.windowHours * 60 * 60 * 1000).toISOString();
46
+ const startTime = new Date(Date.now() - hoursToMs(config.earthquakes.windowHours)).toISOString();
47
47
  const params = new URLSearchParams({
48
48
  format: 'geojson',
49
49
  orderby: config.earthquakes.orderBy,