thinking-phrases 1.0.1

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 (44) hide show
  1. package/README.md +370 -0
  2. package/bin/thinking-phrases.ts +8 -0
  3. package/configs/hn-top.config.json +83 -0
  4. package/launchd/com.austenstone.thinking-phrases.rss.plist +29 -0
  5. package/launchd/rss-update.error.log +27 -0
  6. package/launchd/rss-update.log +0 -0
  7. package/package.json +66 -0
  8. package/scripts/build.ts +96 -0
  9. package/scripts/install-rss-updater.zsh +69 -0
  10. package/scripts/run-rss-update.zsh +16 -0
  11. package/scripts/uninstall-rss-updater.zsh +11 -0
  12. package/scripts/update-rss-settings.ts +7 -0
  13. package/src/core/config.ts +704 -0
  14. package/src/core/githubModels.ts +208 -0
  15. package/src/core/interactive.ts +1053 -0
  16. package/src/core/presets.ts +77 -0
  17. package/src/core/runner.ts +375 -0
  18. package/src/core/scheduler.ts +84 -0
  19. package/src/core/sourceCatalog.ts +18 -0
  20. package/src/core/staticPacks.ts +66 -0
  21. package/src/core/types.ts +177 -0
  22. package/src/core/utils.ts +312 -0
  23. package/src/sinks/vscodeSettings.ts +44 -0
  24. package/src/sources/customJson.ts +174 -0
  25. package/src/sources/earthquakes.ts +100 -0
  26. package/src/sources/githubActivity.ts +598 -0
  27. package/src/sources/hackerNews.ts +75 -0
  28. package/src/sources/rss.ts +256 -0
  29. package/src/sources/stocks.ts +120 -0
  30. package/src/sources/weatherAlerts.ts +111 -0
  31. package/tips/dwyl-quotes.json +1616 -0
  32. package/tips/javascript-tips.json +102 -0
  33. package/tips/league-loading-screen-tips.json +102 -0
  34. package/tips/ruby-tips.json +110 -0
  35. package/tips/typescript-tips.json +126 -0
  36. package/tips/vscode/copilot.json +77 -0
  37. package/tips/vscode/debugging.json +37 -0
  38. package/tips/vscode/editor.json +52 -0
  39. package/tips/vscode/funny.json +42 -0
  40. package/tips/vscode/git.json +42 -0
  41. package/tips/vscode/shortcuts.json +127 -0
  42. package/tips/vscode/terminal.json +37 -0
  43. package/tips/wow-loading-screen-tips.json +111 -0
  44. package/tsconfig.json +15 -0
@@ -0,0 +1,208 @@
1
+ import ModelClient, { isUnexpected } from '@azure-rest/ai-inference';
2
+ import { AzureKeyCredential } from '@azure/core-auth';
3
+ import { execFileSync } from 'node:child_process';
4
+ import type { ArticleItem, Config, GitHubModelsConfig, GitHubModelsResponse } from './types.js';
5
+ import { decodeHtmlEntities, dedupePhrases, logDebug, singleLine } from './utils.js';
6
+
7
+ interface BuildModelArticlePhrasesOptions {
8
+ onProgress?: (message: string) => void;
9
+ }
10
+
11
+ const GITHUB_MODELS_ENDPOINT = 'https://models.github.ai/inference';
12
+
13
+ function getGitHubModelsToken(config: GitHubModelsConfig): string | undefined {
14
+ const envToken = process.env[config.tokenEnvVar] ?? process.env.GITHUB_TOKEN;
15
+ if (envToken && !envToken.includes('replace_me')) {
16
+ return envToken;
17
+ }
18
+
19
+ try {
20
+ const token = execFileSync('gh', ['auth', 'token'], {
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ }).trim();
24
+
25
+ return token || undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ function extractModelPhrases(input: string): string[] {
32
+ const candidate = (input.match(/```(?:json)?\s*([\s\S]*?)```/u)?.[1] ?? input).trim();
33
+
34
+ try {
35
+ const parsed = JSON.parse(candidate) as unknown;
36
+
37
+ if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) {
38
+ return parsed;
39
+ }
40
+
41
+ if (
42
+ parsed &&
43
+ typeof parsed === 'object' &&
44
+ 'phrases' in parsed &&
45
+ Array.isArray((parsed as { phrases?: unknown }).phrases)
46
+ ) {
47
+ return (parsed as { phrases: unknown[] }).phrases.filter((item): item is string => typeof item === 'string');
48
+ }
49
+
50
+ if (
51
+ parsed &&
52
+ typeof parsed === 'object' &&
53
+ 'phrasesByItem' in parsed &&
54
+ Array.isArray((parsed as { phrasesByItem?: unknown }).phrasesByItem)
55
+ ) {
56
+ return (parsed as { phrasesByItem: unknown[] }).phrasesByItem.flatMap(item =>
57
+ Array.isArray(item) ? item.filter((value): value is string => typeof value === 'string') : [],
58
+ );
59
+ }
60
+ } catch {
61
+ // Fall back to line parsing below.
62
+ }
63
+
64
+ return candidate
65
+ .split(/\r?\n/u)
66
+ .map(line => line.trim())
67
+ .filter(Boolean)
68
+ .map(line => line.replace(/^[\[\]",*-•\s]+/gu, '').trim())
69
+ .filter(Boolean);
70
+ }
71
+
72
+ async function runGitHubModelsPrompt(config: GitHubModelsConfig, content: string): Promise<string> {
73
+ const token = getGitHubModelsToken(config);
74
+ if (!token) {
75
+ throw new Error(
76
+ `Missing GitHub Models token. Set ${config.tokenEnvVar}, set GITHUB_TOKEN, or sign in with GitHub CLI via \`gh auth login\`.`,
77
+ );
78
+ }
79
+
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
+ },
89
+ });
90
+
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
+ }
95
+
96
+ const text = (response.body as GitHubModelsResponse).choices?.[0]?.message?.content?.trim();
97
+ if (!text) {
98
+ throw new Error('GitHub Models response did not include content.');
99
+ }
100
+
101
+ return text;
102
+ }
103
+
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
+ }
137
+
138
+ currentChunk.push(article);
139
+ currentCharacters += articleCharacters;
140
+
141
+ if (articleCharacters > maxCharactersPerChunk) {
142
+ flushCurrentChunk();
143
+ }
144
+ }
145
+
146
+ flushCurrentChunk();
147
+
148
+ return chunks;
149
+ }
150
+
151
+ export async function buildModelArticlePhrases(
152
+ articles: ArticleItem[],
153
+ config: Config,
154
+ options: BuildModelArticlePhrasesOptions = {},
155
+ ): 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;
203
+ }
204
+
205
+ return dedupePhrases(
206
+ settledChunkResults.flatMap(result => (result.status === 'fulfilled' ? result.value : [])),
207
+ );
208
+ }