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
@@ -28,35 +28,50 @@ export function resolveConfigPath(configPath?: string): string {
28
28
 
29
29
  export const DEFAULT_CONFIG: Config = {
30
30
  feeds: [],
31
- limit: 25,
31
+ rssFetchIntervalSeconds: 21600,
32
+ limit: 100,
32
33
  mode: 'replace',
33
34
  target: 'auto',
34
35
  phraseFormatting: {
35
36
  includeSource: true,
36
37
  includeTime: true,
37
38
  maxLength: 140,
39
+ templates: {
40
+ article: '%title% — %source% (%time%)',
41
+ hackerNews: '%title% — HN %score% (%time%)',
42
+ stock: '%symbol% %price% %change% %market%',
43
+ githubCommit: '%headline% — %repo% %delta% @%author% (%time%)',
44
+ githubFeed: '%action% — @%handle% (%time%)',
45
+ },
38
46
  },
39
47
  githubModels: {
40
48
  enabled: false,
41
- model: 'openai/gpt-4.1',
49
+ endpoint: 'https://models.github.ai/inference',
50
+ model: 'openai/gpt-4.1-mini',
42
51
  tokenEnvVar: 'GITHUB_MODELS_TOKEN',
43
52
  maxInputItems: 10,
44
- maxTokens: 300,
53
+ maxInputTokens: 16000,
54
+ maxTokens: 500,
55
+ maxConcurrency: 1,
45
56
  maxPhrasesPerArticle: 2,
46
57
  temperature: 0.2,
47
58
  fetchArticleContent: true,
48
59
  maxArticleContentLength: 6000,
60
+ cacheTtlSeconds: 604800,
49
61
  },
50
62
  stockQuotes: {
51
63
  enabled: false,
52
64
  symbols: ['MSFT', 'NVDA', 'TSLA', 'AMZN', 'GOOGL', 'AMD'],
53
65
  includeMarketState: true,
66
+ showClosed: false,
67
+ fetchIntervalSeconds: 60,
54
68
  },
55
69
  hackerNews: {
56
70
  enabled: false,
57
71
  feed: 'top',
58
72
  maxItems: 10,
59
73
  minScore: 50,
74
+ fetchIntervalSeconds: 300,
60
75
  },
61
76
  earthquakes: {
62
77
  enabled: false,
@@ -66,6 +81,7 @@ export const DEFAULT_CONFIG: Config = {
66
81
  limit: 10,
67
82
  radiusKm: 500,
68
83
  orderBy: 'time',
84
+ fetchIntervalSeconds: 1800,
69
85
  },
70
86
  weatherAlerts: {
71
87
  enabled: false,
@@ -73,6 +89,7 @@ export const DEFAULT_CONFIG: Config = {
73
89
  area: '',
74
90
  minimumSeverity: 'moderate',
75
91
  limit: 10,
92
+ fetchIntervalSeconds: 1800,
76
93
  },
77
94
  customJson: {
78
95
  enabled: false,
@@ -86,6 +103,7 @@ export const DEFAULT_CONFIG: Config = {
86
103
  dateField: 'publishedAt',
87
104
  idField: 'id',
88
105
  maxItems: 10,
106
+ fetchIntervalSeconds: 3600,
89
107
  },
90
108
  githubActivity: {
91
109
  enabled: false,
@@ -98,6 +116,7 @@ export const DEFAULT_CONFIG: Config = {
98
116
  maxItems: 10,
99
117
  sinceHours: 24,
100
118
  tokenEnvVar: 'GITHUB_TOKEN',
119
+ fetchIntervalSeconds: 300,
101
120
  },
102
121
  };
103
122
 
@@ -272,6 +291,12 @@ export function parseArgs(argv: string[]): CliOverrides {
272
291
  index += 1;
273
292
  }
274
293
  break;
294
+ case '--models-max-input-tokens':
295
+ if (next) {
296
+ setModels({ maxInputTokens: Number(next) });
297
+ index += 1;
298
+ }
299
+ break;
275
300
  case '--models-max-tokens':
276
301
  if (next) {
277
302
  setModels({ maxTokens: Number(next) });
@@ -290,6 +315,18 @@ export function parseArgs(argv: string[]): CliOverrides {
290
315
  index += 1;
291
316
  }
292
317
  break;
318
+ case '--models-endpoint':
319
+ if (next) {
320
+ setModels({ endpoint: next });
321
+ index += 1;
322
+ }
323
+ break;
324
+ case '--models-max-concurrency':
325
+ if (next) {
326
+ setModels({ maxConcurrency: Number(next) });
327
+ index += 1;
328
+ }
329
+ break;
293
330
  case '--fetch-article-content':
294
331
  setModels({ fetchArticleContent: true });
295
332
  break;
@@ -566,6 +603,7 @@ export function mergeConfig(base: Config, fileConfig: Partial<Config>, argConfig
566
603
  verbose: argConfig.verbose ?? fileConfig.verbose ?? base.verbose,
567
604
  debug: argConfig.debug ?? fileConfig.debug ?? base.debug,
568
605
  feeds: argConfig.feeds ?? fileConfig.feeds ?? base.feeds,
606
+ rssFetchIntervalSeconds: argConfig.rssFetchIntervalSeconds ?? fileConfig.rssFetchIntervalSeconds ?? base.rssFetchIntervalSeconds,
569
607
  phraseFormatting: {
570
608
  ...base.phraseFormatting,
571
609
  ...(fileConfig.phraseFormatting ?? {}),
@@ -601,6 +639,7 @@ export function mergeConfig(base: Config, fileConfig: Partial<Config>, argConfig
601
639
  ...(fileConfig.customJson ?? {}),
602
640
  ...(argConfig.customJson ?? {}),
603
641
  },
642
+ customJsonSources: argConfig.customJsonSources ?? fileConfig.customJsonSources ?? base.customJsonSources,
604
643
  githubActivity: {
605
644
  ...base.githubActivity,
606
645
  ...(fileConfig.githubActivity ?? {}),
@@ -617,6 +656,7 @@ export function validateConfig(config: Config): void {
617
656
  && !config.earthquakes.enabled
618
657
  && !config.weatherAlerts.enabled
619
658
  && !config.customJson.enabled
659
+ && !(config.customJsonSources ?? []).some(s => s.enabled)
620
660
  && !config.githubActivity.enabled
621
661
  ) {
622
662
  throw new Error('Configure at least one source before running dynamic phrases.');
@@ -647,6 +687,14 @@ export function validateConfig(config: Config): void {
647
687
  throw new Error(`githubModels.temperature must be between 0 and 1. Received: ${config.githubModels.temperature}`);
648
688
  }
649
689
 
690
+ if (!Number.isFinite(config.githubModels.maxConcurrency) || config.githubModels.maxConcurrency < 1) {
691
+ throw new Error(`githubModels.maxConcurrency must be at least 1. Received: ${config.githubModels.maxConcurrency}`);
692
+ }
693
+
694
+ if (config.githubModels.endpoint && !/^https?:\/\//u.test(config.githubModels.endpoint)) {
695
+ throw new Error(`githubModels.endpoint must be a valid HTTP(S) URL. Received: ${config.githubModels.endpoint}`);
696
+ }
697
+
650
698
  const invalidFeed = config.feeds.find(feed => !feed.url.trim());
651
699
  if (invalidFeed) {
652
700
  throw new Error('Every feed entry must include a non-empty url.');
@@ -682,6 +730,20 @@ export function validateConfig(config: Config): void {
682
730
  }
683
731
  }
684
732
 
733
+ for (const [index, source] of (config.customJsonSources ?? []).entries()) {
734
+ if (!source.enabled) {
735
+ continue;
736
+ }
737
+
738
+ if (!source.url.trim()) {
739
+ throw new Error(`customJsonSources[${index}].url must be set when enabled.`);
740
+ }
741
+
742
+ if (!source.titleField.trim()) {
743
+ throw new Error(`customJsonSources[${index}].titleField must be set when enabled.`);
744
+ }
745
+ }
746
+
685
747
  if (config.githubActivity.enabled) {
686
748
  if (config.githubActivity.mode === 'repo-commits' && !config.githubActivity.repo?.trim()) {
687
749
  throw new Error('githubActivity.repo must be set when githubActivity.mode is repo-commits.');
@@ -1,14 +1,114 @@
1
- import ModelClient, { isUnexpected } from '@azure-rest/ai-inference';
2
- import { AzureKeyCredential } from '@azure/core-auth';
1
+ import OpenAI from 'openai';
3
2
  import { execFileSync } from 'node:child_process';
4
- import type { ArticleItem, Config, GitHubModelsConfig, GitHubModelsResponse } from './types.js';
3
+ import type { ArticleItem, Config, GitHubModelsConfig } from './types.js';
5
4
  import { decodeHtmlEntities, dedupePhrases, logDebug, singleLine } from './utils.js';
5
+ import { appendSourceSuffix } from './phraseFormats.js';
6
6
 
7
7
  interface BuildModelArticlePhrasesOptions {
8
8
  onProgress?: (message: string) => void;
9
+ /** Called with each batch of phrases as they're generated */
10
+ onPhrases?: (phrases: string[]) => void;
11
+ /** Source type for prompt selection (rss, hacker-news, github-activity, etc.) */
12
+ sourceType?: string;
9
13
  }
10
14
 
11
- const GITHUB_MODELS_ENDPOINT = 'https://models.github.ai/inference';
15
+ const PROMPT_PREAMBLE = [
16
+ 'IMPORTANT: Your ENTIRE response must be valid JSON and nothing else. No markdown, no explanation, no code fences.',
17
+ 'Return exactly this shape: {"phrases":["phrase1","phrase2"]}.',
18
+ 'Each phrase: factual, concrete, max maxLength chars. You may emit up to maxPhrasesPerArticle phrases per item.',
19
+ 'NEVER include the source name, author, date, or time — those are appended automatically.',
20
+ 'NEVER restate the title verbatim. The reader already saw the headline — give them the insight behind it.',
21
+ 'Each phrase is displayed INDEPENDENTLY — never start with "It", "This", "The project", "The tool", or any pronoun that refers to something the reader hasn\'t seen.',
22
+ 'Every phrase must be self-contained and make sense on its own without context from other phrases.',
23
+ ].join(' ');
24
+
25
+ export const DEFAULT_SOURCE_PROMPTS: Record<string, string> = {
26
+ 'rss': [
27
+ PROMPT_PREAMBLE,
28
+ 'You are extracting insights from blog posts and news articles (RSS/Atom feeds).',
29
+ 'You receive the article title AND the full article body text.',
30
+ 'Your job: find the single most valuable, concrete takeaway buried in the article that the reader would NOT get from the title alone.',
31
+ 'Prioritize: specific numbers, benchmarks, percentages, technical details, surprising findings, release dates, breaking changes, or "how it works" explanations.',
32
+ 'BAD: "GitHub released a new feature for code review" (just restates the headline).',
33
+ 'GOOD: "Copilot code review now uses multi-line comments that reduced cognitive load by 15% in A/B testing".',
34
+ 'BAD: "The article discusses improvements to Docker performance".',
35
+ 'GOOD: "Docker BuildKit v0.17 parallelizes dependency resolution, cutting cold builds from 4m to 90s on large monorepos".',
36
+ 'If the article body has real data, use it. If it is too thin, extract the most specific claim from the title and sharpen it.',
37
+ ].join(' '),
38
+ 'hacker-news': [
39
+ PROMPT_PREAMBLE,
40
+ 'You are extracting insights from Hacker News posts. You may receive:',
41
+ '(a) The HN title + the full linked article body (most common — link posts)',
42
+ '(b) The HN title + the self-post text (Ask HN, Show HN)',
43
+ '(c) The HN title + both the self-post text AND fetched article body',
44
+ 'Your job: extract the ONE technical insight, surprising fact, or concrete detail that makes this post worth reading.',
45
+ 'The reader has 3 seconds of glance time. Make it count with a real fact, not a summary.',
46
+ 'BAD: "A database was built in a spreadsheet" (just restates the HN title).',
47
+ 'GOOD: "The spreadsheet-database uses SQLite compiled to WASM, handling 10k rows with indexed queries under 50ms".',
48
+ 'BAD: "The author discusses their experience with Rust".',
49
+ 'GOOD: "Switching from Go to Rust cut their p99 latency from 12ms to 800μs by eliminating GC pauses".',
50
+ 'For Show HN posts: what does it actually do and what makes it technically interesting?',
51
+ 'For Ask HN posts: what is the most insightful or surprising answer/claim?',
52
+ ].join(' '),
53
+ 'github-activity': [
54
+ PROMPT_PREAMBLE,
55
+ 'You are summarizing GitHub commits. You receive the commit message AND the full diff (added/removed lines).',
56
+ 'Your job: explain the PURPOSE and IMPACT of the change in plain language. What is different for users or developers AFTER this commit?',
57
+ 'Read the diff carefully — the commit message often undersells the change. The diff tells the real story.',
58
+ 'BAD: "Fixed a null check in the settings handler" (says what, not why).',
59
+ 'GOOD: "Settings panel no longer crashes when opening a workspace with a corrupted .vscode/settings.json".',
60
+ 'BAD: "Refactored the entrypoint module".',
61
+ 'GOOD: "DevTools now loads 40% faster after the entrypoint was split into lazy-loaded chunks".',
62
+ 'For performance changes: include the before/after numbers if visible in the diff.',
63
+ 'For bug fixes: describe the user-visible symptom that was fixed.',
64
+ 'For new features: describe what users can now do that they couldn\'t before.',
65
+ 'NEVER mention file paths, line counts, or SHA hashes — those appear in the metadata suffix.',
66
+ ].join(' '),
67
+ 'earthquakes': [
68
+ PROMPT_PREAMBLE,
69
+ 'You are summarizing USGS earthquake data. You receive magnitude, location, significance score, alert level, and tsunami status.',
70
+ 'Keep it concise and factual. The magnitude and location are already in the title — add context that helps the reader understand the severity.',
71
+ 'If significance is high (>500) or an alert level is set, emphasize that.',
72
+ 'If a tsunami bulletin was issued, lead with that.',
73
+ 'BAD: "An earthquake happened near Ridgecrest" (obvious from the title).',
74
+ 'GOOD: "Significance 680 with yellow alert — strongest quake in the region since the 2019 Ridgecrest sequence".',
75
+ 'If the data is sparse (just magnitude + location with no alert), a clean one-liner with the depth or felt radius is fine.',
76
+ ].join(' '),
77
+ 'custom-json': [
78
+ PROMPT_PREAMBLE,
79
+ 'You are summarizing items from a custom JSON API. The data structure varies.',
80
+ 'Extract the most concrete, specific, and informative detail from each item.',
81
+ 'Focus on facts the reader can learn in a glance: numbers, names, outcomes, technical details.',
82
+ 'BAD: "An interesting article about cloud computing".',
83
+ 'GOOD: "AWS Lambda now supports 10GB memory functions, enabling in-memory ML inference without containers".',
84
+ ].join(' '),
85
+ };
86
+
87
+ const DEFAULT_FALLBACK_PROMPT = [
88
+ PROMPT_PREAMBLE,
89
+ 'Extract the single most valuable, concrete takeaway from each item.',
90
+ 'Prioritize: specific numbers, technical details, surprising findings, or "what changed and why it matters".',
91
+ 'The reader has 3 seconds. Give them a real insight, not a headline restatement.',
92
+ ].join(' ');
93
+
94
+ /**
95
+ * Resolve the prompt for a given source type.
96
+ * Priority: config per-source prompt > config systemPrompt > built-in per-source default > built-in fallback.
97
+ */
98
+ export function resolvePrompt(config: GitHubModelsConfig, sourceType?: string): string {
99
+ if (sourceType && config.prompts?.[sourceType]) {
100
+ return config.prompts[sourceType];
101
+ }
102
+ if (config.systemPrompt) {
103
+ return config.systemPrompt;
104
+ }
105
+ if (sourceType && DEFAULT_SOURCE_PROMPTS[sourceType]) {
106
+ return DEFAULT_SOURCE_PROMPTS[sourceType];
107
+ }
108
+ return DEFAULT_FALLBACK_PROMPT;
109
+ }
110
+
111
+ const DEFAULT_ENDPOINT = 'https://models.github.ai/inference';
12
112
 
13
113
  function getGitHubModelsToken(config: GitHubModelsConfig): string | undefined {
14
114
  const envToken = process.env[config.tokenEnvVar] ?? process.env.GITHUB_TOKEN;
@@ -20,6 +120,13 @@ function getGitHubModelsToken(config: GitHubModelsConfig): string | undefined {
20
120
  const token = execFileSync('gh', ['auth', 'token'], {
21
121
  encoding: 'utf8',
22
122
  stdio: ['ignore', 'pipe', 'ignore'],
123
+ env: {
124
+ ...process.env,
125
+ GITHUB_TOKEN: '',
126
+ GH_TOKEN: '',
127
+ GITHUB_ENTERPRISE_TOKEN: '',
128
+ GH_ENTERPRISE_TOKEN: '',
129
+ },
23
130
  }).trim();
24
131
 
25
132
  return token || undefined;
@@ -28,7 +135,7 @@ function getGitHubModelsToken(config: GitHubModelsConfig): string | undefined {
28
135
  }
29
136
  }
30
137
 
31
- function extractModelPhrases(input: string): string[] {
138
+ export function extractModelPhrases(input: string): string[] {
32
139
  const candidate = (input.match(/```(?:json)?\s*([\s\S]*?)```/u)?.[1] ?? input).trim();
33
140
 
34
141
  try {
@@ -65,7 +172,7 @@ function extractModelPhrases(input: string): string[] {
65
172
  .split(/\r?\n/u)
66
173
  .map(line => line.trim())
67
174
  .filter(Boolean)
68
- .map(line => line.replace(/^[\[\]",*-•\s]+/gu, '').trim())
175
+ .map(line => line.replace(/^[[\]",*\-•\s]+/gu, '').trim())
69
176
  .filter(Boolean);
70
177
  }
71
178
 
@@ -77,75 +184,64 @@ async function runGitHubModelsPrompt(config: GitHubModelsConfig, content: string
77
184
  );
78
185
  }
79
186
 
80
- const client = ModelClient(GITHUB_MODELS_ENDPOINT, new AzureKeyCredential(token));
81
- const response = await client.path('/chat/completions').post({
82
- body: {
83
- model: config.model,
84
- messages: [{ role: 'user', content }],
85
- temperature: config.temperature,
86
- max_tokens: config.maxTokens,
87
- response_format: { type: 'json_object' },
88
- },
187
+ const client = new OpenAI({
188
+ baseURL: config.endpoint || DEFAULT_ENDPOINT,
189
+ apiKey: token,
89
190
  });
90
191
 
91
- if (isUnexpected(response)) {
92
- const errorBody = response.body as { error?: { message?: string } };
93
- throw new Error(errorBody.error?.message ?? 'GitHub Models request failed.');
94
- }
192
+ // Reasoning models (o1, o3, o4, gpt-5) don't support temperature or response_format
193
+ const isReasoningModel = /(?:^|\/)(?:o[1-4]|gpt-5)/iu.test(config.model);
194
+
195
+ // Reasoning models need more tokens because thinking tokens count against the budget
196
+ const maxTokens = isReasoningModel ? Math.max(config.maxTokens * 4, 2000) : config.maxTokens;
95
197
 
96
- const text = (response.body as GitHubModelsResponse).choices?.[0]?.message?.content?.trim();
198
+ const completion = await client.chat.completions.create({
199
+ model: config.model,
200
+ messages: [{ role: 'user', content }],
201
+ ...(!isReasoningModel && config.temperature !== 1 ? { temperature: config.temperature } : {}),
202
+ max_completion_tokens: maxTokens,
203
+ ...(!isReasoningModel ? { response_format: { type: 'json_object' } } : {}),
204
+ });
205
+
206
+ const text = completion.choices?.[0]?.message?.content?.trim();
97
207
  if (!text) {
98
- throw new Error('GitHub Models response did not include content.');
208
+ const reason = completion.choices?.[0]?.finish_reason;
209
+ throw new Error(`GitHub Models returned empty content (finish_reason: ${reason ?? 'unknown'})`);
99
210
  }
100
211
 
101
212
  return text;
102
213
  }
103
214
 
104
- function chunkArticles(articles: ArticleItem[], config: GitHubModelsConfig): ArticleItem[][] {
105
- const chunks: ArticleItem[][] = [];
106
- const estimatedPerChunk = Math.max(1, Math.floor(config.maxTokens / Math.max(80, config.maxPhrasesPerArticle * 80)));
107
- const defaultChunkSize = Math.max(1, Math.min(config.maxInputItems, estimatedPerChunk));
108
- const maxCharactersPerChunk = 24_000;
109
-
110
- let currentChunk: ArticleItem[] = [];
111
- let currentCharacters = 0;
112
-
113
- const estimateArticleCharacters = (article: ArticleItem): number => {
114
- return [article.title, article.source, article.time, article.articleContent, article.content, article.link]
115
- .filter(Boolean)
116
- .join(' ')
117
- .length;
118
- };
119
-
120
- const flushCurrentChunk = (): void => {
121
- if (currentChunk.length > 0) {
122
- chunks.push(currentChunk);
123
- currentChunk = [];
124
- currentCharacters = 0;
125
- }
126
- };
127
-
128
- for (const article of articles) {
129
- const articleCharacters = estimateArticleCharacters(article);
130
-
131
- if (
132
- currentChunk.length > 0
133
- && (currentChunk.length >= defaultChunkSize || currentCharacters + articleCharacters > maxCharactersPerChunk)
134
- ) {
135
- flushCurrentChunk();
136
- }
215
+ async function summarizeArticle(article: ArticleItem, config: Config, sourceType?: string): Promise<string[]> {
216
+ const instruction = resolvePrompt(config.githubModels, sourceType);
217
+ const payload = JSON.stringify({
218
+ instruction,
219
+ maxLength: config.phraseFormatting.maxLength,
220
+ maxPhrasesPerArticle: config.githubModels.maxPhrasesPerArticle,
221
+ items: [{
222
+ title: article.title ?? '',
223
+ source: article.source ?? '',
224
+ time: article.time ?? '',
225
+ content: (article.articleContent ?? article.content ?? '').slice(0, config.githubModels.maxArticleContentLength),
226
+ link: article.link ?? '',
227
+ }],
228
+ });
137
229
 
138
- currentChunk.push(article);
139
- currentCharacters += articleCharacters;
230
+ logDebug(config, `Sending "${article.title}" (${payload.length} chars) to GitHub Models`);
231
+ const responseText = await runGitHubModelsPrompt(config.githubModels, payload);
232
+ logDebug(config, `Response: ${singleLine(responseText, 220)}`);
140
233
 
141
- if (articleCharacters > maxCharactersPerChunk) {
142
- flushCurrentChunk();
143
- }
144
- }
234
+ return extractModelPhrases(responseText)
235
+ .map(phrase => singleLine(decodeHtmlEntities(phrase), config.phraseFormatting.maxLength))
236
+ .filter(Boolean)
237
+ .map(phrase => appendSourceSuffix(phrase, article.source, article.time, article.metadata));
238
+ }
145
239
 
146
- flushCurrentChunk();
240
+ const DELAY_BETWEEN_REQUESTS_MS = 2000;
241
+ const MAX_RETRIES = 3;
147
242
 
148
- return chunks;
243
+ async function sleep(ms: number): Promise<void> {
244
+ return new Promise(resolve => setTimeout(resolve, ms));
149
245
  }
150
246
 
151
247
  export async function buildModelArticlePhrases(
@@ -153,56 +249,48 @@ export async function buildModelArticlePhrases(
153
249
  config: Config,
154
250
  options: BuildModelArticlePhrasesOptions = {},
155
251
  ): Promise<string[]> {
156
- const chunks = chunkArticles(articles, config.githubModels);
157
- let completedChunks = 0;
158
-
159
- options.onProgress?.(`Generating phrases with GitHub Models (${chunks.length} batch${chunks.length === 1 ? '' : 'es'} in parallel)`);
160
-
161
- const settledChunkResults = await Promise.allSettled(
162
- chunks.map(async (chunk, index) => {
163
- const payload = JSON.stringify({
164
- instruction: config.githubModels.systemPrompt ?? [
165
- 'Create concise VS Code thinking phrases from these normalized content items.',
166
- 'Return JSON only in this shape: {"phrases":["..."]}.',
167
- 'Each phrase must be factual, concrete, and at most maxLength characters.',
168
- 'You may emit multiple phrases for one item when it has multiple distinct takeaways.',
169
- 'Return at most maxPhrasesPerArticle phrases per item.',
170
- 'Prefer concrete details like numbers, locations, dates, features, examples, or outcomes.',
171
- 'Avoid vague rewrites of the headline.',
172
- ].join(' '),
173
- maxLength: config.phraseFormatting.maxLength,
174
- maxPhrasesPerArticle: config.githubModels.maxPhrasesPerArticle,
175
- items: chunk.map(article => ({
176
- title: article.title ?? '',
177
- source: article.source ?? '',
178
- time: article.time ?? '',
179
- content: article.articleContent ?? article.content ?? '',
180
- link: article.link ?? '',
181
- })),
182
- });
183
-
184
- logDebug(config, `Sending ${chunk.length} items to GitHub Models for chunk ${index + 1}/${chunks.length}`);
185
- const responseText = await runGitHubModelsPrompt(config.githubModels, payload);
186
- logDebug(config, `Model response preview: ${singleLine(responseText, 220)}`);
187
-
188
- completedChunks += 1;
189
- options.onProgress?.(`Generated GitHub Models phrases (${completedChunks}/${chunks.length})`);
190
-
191
- return extractModelPhrases(responseText)
192
- .map(phrase => singleLine(decodeHtmlEntities(phrase), config.phraseFormatting.maxLength))
193
- .filter(Boolean);
194
- }),
195
- );
196
-
197
- const rejectedChunk = settledChunkResults.find(
198
- (result): result is PromiseRejectedResult => result.status === 'rejected',
199
- );
200
-
201
- if (rejectedChunk) {
202
- throw rejectedChunk.reason;
252
+ options.onProgress?.(`Generating phrases with GitHub Models (${articles.length} article${articles.length === 1 ? '' : 's'})`);
253
+
254
+ const allPhrases: string[] = [];
255
+ let lastError: unknown;
256
+
257
+ for (let i = 0; i < articles.length; i++) {
258
+ const article = articles[i];
259
+ const title = article.title ?? 'untitled';
260
+ options.onProgress?.(`Summarizing (${i + 1}/${articles.length}): ${title.length > 60 ? title.slice(0, 60) + '…' : title}`);
261
+ let phrases: string[] | undefined;
262
+
263
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
264
+ try {
265
+ phrases = await summarizeArticle(article, config, options.sourceType);
266
+ break;
267
+ } catch (error: unknown) {
268
+ const is429 = error instanceof Error && error.message.includes('429');
269
+ if (is429 && attempt < MAX_RETRIES) {
270
+ const backoff = attempt * 5000;
271
+ options.onProgress?.(`Rate limited — waiting ${backoff / 1000}s before retry (${attempt}/${MAX_RETRIES})`);
272
+ await sleep(backoff);
273
+ continue;
274
+ }
275
+ lastError = error;
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ logDebug(config, `Failed "${article.title}": ${message}`);
278
+ options.onProgress?.(`GitHub Models failed (${i + 1}/${articles.length}): ${message}`);
279
+ break;
280
+ }
281
+ }
282
+
283
+ if (phrases && phrases.length > 0) {
284
+ allPhrases.push(...phrases);
285
+ options.onPhrases?.(phrases);
286
+ }
287
+
288
+ options.onProgress?.(`Generated phrases (${i + 1}/${articles.length})`);
289
+ }
290
+
291
+ if (allPhrases.length === 0 && lastError) {
292
+ throw lastError;
203
293
  }
204
294
 
205
- return dedupePhrases(
206
- settledChunkResults.flatMap(result => (result.status === 'fulfilled' ? result.value : [])),
207
- );
295
+ return dedupePhrases(allPhrases);
208
296
  }