@gobing-ai/knowledge-kit 0.0.11 → 0.0.12

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 (42) hide show
  1. package/dist/index.js +363 -138
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/src/storm.ts +99 -50
  4. package/plugins/generations/core-facts-gen/package.json +17 -0
  5. package/plugins/generations/core-facts-gen/plugin.json +7 -0
  6. package/plugins/generations/core-facts-gen/src/index.ts +116 -0
  7. package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
  8. package/plugins/generations/daily-article-gen/package.json +17 -0
  9. package/plugins/generations/daily-article-gen/plugin.json +7 -0
  10. package/plugins/generations/daily-article-gen/src/index.ts +91 -0
  11. package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
  12. package/plugins/generations/dailynews-gen/src/index.ts +11 -0
  13. package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
  14. package/plugins/generations/episode-plan-gen/package.json +17 -0
  15. package/plugins/generations/episode-plan-gen/plugin.json +7 -0
  16. package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
  17. package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
  18. package/plugins/generations/voice-gen/src/index.ts +102 -11
  19. package/plugins/generations/voice-gen/src/qc.ts +154 -9
  20. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  21. package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
  22. package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
  23. package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
  24. package/plugins/ingestions/horizon-ingest/package.json +17 -0
  25. package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
  26. package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
  27. package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
  28. package/plugins/ingestions/last30days-ingest/package.json +17 -0
  29. package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
  30. package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
  31. package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
  32. package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
  33. package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
  34. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
  35. package/plugins/publishings/podcast-pub/package.json +17 -0
  36. package/plugins/publishings/podcast-pub/plugin.json +7 -0
  37. package/plugins/publishings/podcast-pub/src/index.ts +538 -0
  38. package/plugins/publishings/podcast-pub/src/map.ts +165 -0
  39. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
  41. package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
  42. package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -5,7 +5,7 @@ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
6
  import { concatWavs } from './concat';
7
7
  import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
8
- import { auditVoiceSegments } from './qc';
8
+ import { auditVoiceSegments, detectLoudnessDip, TRANSCRIPTION_FIDELITY_FLOOR, transcriptionFidelity } from './qc';
9
9
  import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
10
10
  import {
11
11
  mergeVoiceScripts,
@@ -92,6 +92,9 @@ export async function processGeneratorIO(
92
92
  speaker?: string;
93
93
  emotion?: string;
94
94
  duration: number;
95
+ verifyRetries?: number;
96
+ transcription?: string;
97
+ loudnessDip?: boolean;
95
98
  }> = [];
96
99
  let totalDuration = 0;
97
100
 
@@ -115,7 +118,8 @@ export async function processGeneratorIO(
115
118
 
116
119
  const resolvedProfile = await client.resolveProfile(profileTarget);
117
120
 
118
- const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
121
+ const engine =
122
+ segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine ?? process.env.VOICEBOX_ENGINE;
119
123
  const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
120
124
  const rawInstruct = segment.instruct ?? speakerConfig?.instruct;
121
125
  const rawEmotion = segment.emotion ?? speakerConfig?.emotion ?? combinedScript.default_emotion;
@@ -170,28 +174,115 @@ export async function processGeneratorIO(
170
174
  effects_chain,
171
175
  };
172
176
 
173
- const { id } = await client.generate(generateBody);
174
- const history = await client.waitUntilDone(id);
175
- if (history.status === 'failed') {
176
- throw new Error(
177
- `Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
178
- );
177
+ // Timeout retry: a hung/timeout voicebox generation must not kill a multi-hour
178
+ // run (dogfood daily-ai-news-20260903: segment 108/132 timed out at 600s and
179
+ // failed the whole generate state after 140 minutes).
180
+ //
181
+ // Verify retry: qwen with cloned profiles occasionally emits contiguous loop
182
+ // garbage (fidelity 0.00 vs >=0.23 zh ASR floor on real audio — dogfood
183
+ // 2026-09-03). The server's runaway detector only catches gap-shaped failures,
184
+ // so verify per segment via transcription fidelity and regenerate failures with
185
+ // a varied seed and halved chunk size (mirrors the server's own retry policy).
186
+ let wavBytes: Uint8Array | undefined;
187
+ let segDuration = 0;
188
+ let generationId = '';
189
+ let transcription: string | undefined;
190
+ let verifyRetries = 0;
191
+ let loudnessDip = false;
192
+ let lastError: unknown;
193
+ let attemptBody = generateBody;
194
+ for (let verifyAttempt = 0; verifyAttempt <= 2 && wavBytes === undefined; verifyAttempt++) {
195
+ if (verifyAttempt > 0) {
196
+ verifyRetries = verifyAttempt;
197
+ const chunkChars = Math.max(VOICEBOX_CHUNK_CHARS_MIN, Math.floor(attemptBody.max_chunk_chars / 2));
198
+ attemptBody = {
199
+ ...generateBody,
200
+ seed: Math.floor(Math.random() * 2 ** 31),
201
+ max_chunk_chars: chunkChars,
202
+ };
203
+ echoError(
204
+ `voice-gen: regenerating segment with seed ${attemptBody.seed} and max_chunk_chars ${chunkChars}`,
205
+ );
206
+ }
207
+ let attemptBytes: Uint8Array | undefined;
208
+ for (let attempt = 1; attempt <= 2 && attemptBytes === undefined; attempt++) {
209
+ try {
210
+ const { id } = await client.generate(attemptBody);
211
+ generationId = id;
212
+ // Long qwen segments synthesize slower than the fixed 600s deadline
213
+ // (live 2026-09-04: 171s-audio segment took >600s twice, both completed
214
+ // server-side). Scale the poll deadline with text length: 600s base
215
+ // (or VOICEBOX_TIMEOUT_MS) + 60s per 100 chars, ~2x observed worst case.
216
+ const envTimeout = process.env.VOICEBOX_TIMEOUT_MS
217
+ ? parseInt(process.env.VOICEBOX_TIMEOUT_MS, 10)
218
+ : 600000;
219
+ const waitTimeout = envTimeout + Math.ceil(segment.text.length / 100) * 60_000;
220
+ const history = await client.waitUntilDone(id, waitTimeout);
221
+ if (history.status === 'failed') {
222
+ throw new Error(
223
+ `Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
224
+ );
225
+ }
226
+ attemptBytes = await client.downloadAudio(id);
227
+ segDuration = history.duration ?? 0;
228
+ } catch (err: unknown) {
229
+ lastError = err;
230
+ if (attempt === 1) {
231
+ echoError(
232
+ `voice-gen: segment failed (${err instanceof Error ? err.message : String(err)}); retrying once`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+ if (attemptBytes === undefined) {
238
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
239
+ }
240
+ if (typeof client.transcribe === 'function') {
241
+ try {
242
+ const trans = await client.transcribe(attemptBytes, language);
243
+ const fidelity = transcriptionFidelity(segment.text, trans.text);
244
+ if (fidelity < TRANSCRIPTION_FIDELITY_FLOOR && verifyAttempt < 2) {
245
+ echoError(
246
+ `voice-gen: transcription fidelity ${fidelity.toFixed(2)} below ${TRANSCRIPTION_FIDELITY_FLOOR} (looped/garbage audio): "${trans.text.slice(0, 80)}..."`,
247
+ );
248
+ continue;
249
+ }
250
+ transcription = trans.text;
251
+ } catch {
252
+ // Transcription unavailable — keep audio; the QC audit backstop decides.
253
+ }
254
+ }
255
+ // Voice dent gate: Qwen3-TTS stochastically collapses loudness mid-utterance on
256
+ // long text; a dip that survives to the last verify attempt is recorded for QC
257
+ // (dogfood 2026-09-08 A/B: same text regenerated clean, so retry usually clears).
258
+ const dip = detectLoudnessDip(attemptBytes);
259
+ loudnessDip = dip.dip;
260
+ if (dip.dip && verifyAttempt < 2) {
261
+ echoError(`voice-gen: loudness dip detected at ${dip.dipAtSec ?? '?'}s — regenerating segment`);
262
+ continue;
263
+ }
264
+ wavBytes = attemptBytes;
265
+ }
266
+ if (wavBytes === undefined) {
267
+ // Both network attempts and all verify attempts exhausted without audio.
268
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'no audio'));
179
269
  }
180
270
 
181
- const wavBytes = await client.downloadAudio(id);
182
271
  const gap = segment.gap_ms ?? 0;
183
272
 
184
273
  segmentWavs.push(wavBytes);
185
274
  segmentGaps.push(gap);
186
275
 
187
- const segDuration = history.duration ?? 0;
188
276
  totalDuration += segDuration + gap / 1000;
189
277
  segmentMetadata.push({
190
- generationId: id,
278
+ generationId,
191
279
  profile: resolvedProfile.name,
192
280
  speaker: segment.speaker,
193
281
  emotion: rawEmotion,
194
282
  duration: segDuration,
283
+ verifyRetries,
284
+ transcription,
285
+ loudnessDip,
195
286
  });
196
287
  }
197
288
 
@@ -1,3 +1,4 @@
1
+ import { type ParsedWav, parseWav } from './concat';
1
2
  import type { VoiceboxClient } from './voicebox-client';
2
3
  import type { VoiceScript, VoiceSegment } from './voicescript';
3
4
 
@@ -10,6 +11,8 @@ export interface SegmentQualityAudit {
10
11
  transcription?: string;
11
12
  repetitionDetected: boolean;
12
13
  durationAnomaly: boolean;
14
+ /** True when a loudness dip (voice dent) was detected in the final audio and survived regeneration. */
15
+ loudnessDip?: boolean;
13
16
  issues: string[];
14
17
  passed: boolean;
15
18
  }
@@ -50,6 +53,109 @@ export function detectRepetitions(text: string): boolean {
50
53
  return false;
51
54
  }
52
55
 
56
+ /** Below this character-bigram coverage, audio is treated as looped/garbage (dogfood 2026-09-03). */
57
+ export const TRANSCRIPTION_FIDELITY_FLOOR = 0.15;
58
+
59
+ /**
60
+ * Character-bigram coverage of the source text by the transcription. Whisper zh ASR is
61
+ * lossy on good audio (0.23–0.8 across dogfood runs); looped/garbage TTS audio scores ≤0.05.
62
+ */
63
+ export function transcriptionFidelity(source: string, transcription: string): number {
64
+ const bigrams = (t: string): Set<string> => {
65
+ const clean = t.replace(/\s+/g, '');
66
+ return new Set(Array.from({ length: Math.max(0, clean.length - 1) }, (_, i) => clean.slice(i, i + 2)));
67
+ };
68
+ const a = bigrams(source);
69
+ const b = bigrams(transcription);
70
+ if (a.size === 0) return 1;
71
+ let inter = 0;
72
+ for (const g of a) if (b.has(g)) inter++;
73
+ return inter / a.size;
74
+ }
75
+
76
+ /**
77
+ * A ~1s window whose RMS sits below this fraction of its louder neighbour is a dent.
78
+ * Calibrated on dogfood 2026-09-08: audible Qwen3-TTS collapses measured 0.24–0.37 of
79
+ * the louder neighbour (rms 0.033–0.048); normal quieter phrases sit ≥0.45.
80
+ */
81
+ export const DIP_RATIO = 0.4;
82
+
83
+ /** A window below this RMS is a real pause (silence), not a dent — dents are quiet-but-not-silent. */
84
+ export const DIP_SILENCE_FLOOR = 0.008;
85
+
86
+ /** Windows above this absolute RMS are normal quieter narration, not collapses (file p25 ≈ 0.08). */
87
+ export const DIP_ABS_CEILING = 0.06;
88
+
89
+ /** Neighbour windows below this RMS carry no speech signal to compare against. */
90
+ export const DIP_NEIGHBOR_FLOOR = 0.02;
91
+
92
+ /**
93
+ * Detect a loudness dip (voice dent): a ~1s window of quiet-but-not-silent audio
94
+ * sandwiched between louder speech on both sides. Qwen3-TTS (AR clone, MLX) stochastically
95
+ * collapses loudness mid-utterance on long text (dogfood 2026-09-08 A/B: same text regenerated
96
+ * clean; official docs list "loudness drops" among AR long-form instabilities). The detector
97
+ * returns the first dip position; voice-gen regenerates on it (stochastic → retry usually clears).
98
+ * Returns null when the wav is too short or unparseable.
99
+ */
100
+ export function detectLoudnessDip(wav: Uint8Array): { dip: boolean; dipAtSec: number | null } {
101
+ let parsed: ParsedWav;
102
+ try {
103
+ parsed = parseWav(wav);
104
+ } catch {
105
+ return { dip: false, dipAtSec: null };
106
+ }
107
+ if (parsed.bitsPerSample !== 16 && parsed.bitsPerSample !== 8) return { dip: false, dipAtSec: null };
108
+ const rate = parsed.sampleRate;
109
+ if (rate <= 0) return { dip: false, dipAtSec: null };
110
+ const data = parsed.dataBytes;
111
+ const bytesPerSample = parsed.bitsPerSample === 8 ? 1 : 2;
112
+ const frameBytes = parsed.numChannels * bytesPerSample;
113
+ const totalFrames = Math.floor(data.length / frameBytes);
114
+ const windowFrames = rate; // 1s windows
115
+ if (totalFrames < windowFrames * 5) return { dip: false, dipAtSec: null };
116
+
117
+ const sampleAt = (frame: number, channel = 0): number => {
118
+ const off = (frame * parsed.numChannels + channel) * bytesPerSample;
119
+ if (bytesPerSample === 1) return ((data[off] ?? 0) - 128) / 128;
120
+ let s = (data[off] ?? 0) | ((data[off + 1] ?? 0) << 8);
121
+ if (s >= 0x8000) s -= 0x10000;
122
+ return s / 32768;
123
+ };
124
+
125
+ // Non-overlapping 1s windows (step = window): a dent spans ~1s → exactly one window,
126
+ // with loud speech before and after. Frame-stepped windows would smear a 1s dent into a
127
+ // 24000-index plateau with equal neighbours, invisible to any adjacent comparison.
128
+ const nWins = Math.floor(totalFrames / windowFrames);
129
+ if (nWins < 7) return { dip: false, dipAtSec: null };
130
+ const windowRms: number[] = [];
131
+ for (let w = 0; w < nWins; w++) {
132
+ const base = w * windowFrames;
133
+ let sum = 0;
134
+ for (let f = base; f < base + windowFrames; f++) {
135
+ const v = sampleAt(f);
136
+ sum += v * v;
137
+ }
138
+ windowRms.push(Math.sqrt(sum / windowFrames));
139
+ }
140
+
141
+ // A dent = one ~1s window of quiet-but-not-silent audio (rms 0.008..0.06) that is
142
+ // notably quieter than BOTH its immediate 1s neighbours (ratio < 0.4) — an isolated
143
+ // loudness collapse amid otherwise steady speech. Non-overlapping windows keep
144
+ // neighbour semantics exact (a frame-stepped window would smear a 1s dent into a
145
+ // many-index plateau with equal neighbours). Sentence pauses are excluded by the
146
+ // silence floor; normal quieter phrases by the absolute ceiling (calibrated on
147
+ // dogfood 2026-09-08 prod audio: audible dents 0.047/0.042/0.048 vs normal p25 0.080).
148
+ for (let j = 1; j < windowRms.length - 1; j++) {
149
+ const center = windowRms[j] ?? 0;
150
+ if (center <= DIP_SILENCE_FLOOR) continue; // real pause, not a dent
151
+ if (center >= DIP_ABS_CEILING) continue; // normal quieter narration
152
+ const nbr = Math.max(windowRms[j - 1] ?? 0, windowRms[j + 1] ?? 0);
153
+ if (nbr <= DIP_NEIGHBOR_FLOOR) continue;
154
+ if (center < nbr * DIP_RATIO) return { dip: true, dipAtSec: j };
155
+ }
156
+ return { dip: false, dipAtSec: null };
157
+ }
158
+
53
159
  /**
54
160
  * Compute expected speech duration range given text and language.
55
161
  */
@@ -76,7 +182,14 @@ export function computeExpectedDurationRange(text: string, language = 'zh'): [nu
76
182
  export async function auditVoiceSegments(
77
183
  script: VoiceScript,
78
184
  segmentWavs: Uint8Array[],
79
- segmentMetadata: Array<{ generationId: string; profile: string; duration: number }>,
185
+ segmentMetadata: Array<{
186
+ generationId: string;
187
+ profile: string;
188
+ duration: number;
189
+ transcription?: string;
190
+ verifyRetries?: number;
191
+ loudnessDip?: boolean;
192
+ }>,
80
193
  client?: VoiceboxClient,
81
194
  ): Promise<VoiceQualityReport> {
82
195
  const audits: SegmentQualityAudit[] = [];
@@ -109,22 +222,53 @@ export async function auditVoiceSegments(
109
222
  let transcriptionText: string | undefined;
110
223
  let repetitionDetected = false;
111
224
 
112
- if (client && wavBytes && typeof client.transcribe === 'function') {
225
+ if (meta && typeof meta.transcription === 'string') {
226
+ // Precomputed during generation (verify step) — reuse instead of re-transcribing.
227
+ transcriptionText = meta.transcription;
228
+ } else if (client && wavBytes && typeof client.transcribe === 'function') {
113
229
  try {
114
230
  const trans = await client.transcribe(wavBytes, lang);
115
231
  transcriptionText = trans.text;
116
- if (detectRepetitions(transcriptionText)) {
117
- repetitionDetected = true;
118
- issues.push(
119
- `Repetition hallucination detected in audio transcription: "${transcriptionText.slice(0, 80)}..."`,
120
- );
121
- }
122
232
  } catch {
123
233
  // Transcription service unavailable or optional
124
234
  }
125
235
  }
236
+ if (transcriptionText !== undefined && detectRepetitions(transcriptionText)) {
237
+ repetitionDetected = true;
238
+ // Whisper loops over trailing gap/silence and emits phantom repetitions whose
239
+ // audio duration stays normal; a real TTS loop always inflates duration.
240
+ // Fail only when the duration channel corroborates (dogfood 2026-09-03:
241
+ // 15/22 segments flagged with in-range durations = ASR artifacts).
242
+ if (durationAnomaly) {
243
+ issues.push(
244
+ `Repetition hallucination corroborated by duration anomaly: "${transcriptionText.slice(0, 80)}..."`,
245
+ );
246
+ }
247
+ }
248
+
249
+ let lowFidelity = false;
250
+ if (transcriptionText !== undefined) {
251
+ const fidelity = transcriptionFidelity(segment.text, transcriptionText);
252
+ if (fidelity < TRANSCRIPTION_FIDELITY_FLOOR) {
253
+ lowFidelity = true;
254
+ issues.push(
255
+ `Transcription fidelity ${fidelity.toFixed(2)} below ${TRANSCRIPTION_FIDELITY_FLOOR} — audio diverges from source text: "${transcriptionText.slice(0, 80)}..."`,
256
+ );
257
+ }
258
+ }
259
+
260
+ // Loudness dip (voice dent) that survived regeneration — record, don't fail the
261
+ // pipeline: the verify loop already regenerated on it, and a stubborn single dent
262
+ // is better surfaced than a whole-episode failure (dogfood 2026-09-08).
263
+ let loudnessDip = false;
264
+ if (meta?.loudnessDip === true) {
265
+ loudnessDip = true;
266
+ issues.push(
267
+ `Loudness dip persisted after regeneration (voice-gen auto-retried with varied seed; verifyRetries=${meta.verifyRetries ?? 0})`,
268
+ );
269
+ }
126
270
 
127
- const passed = !durationAnomaly && !repetitionDetected;
271
+ const passed = !durationAnomaly && !(repetitionDetected && durationAnomaly) && !lowFidelity;
128
272
  if (!passed) {
129
273
  criticalIssues.push(`Segment ${i + 1} (${meta?.profile || 'Unknown'}): ${issues.join('; ')}`);
130
274
  }
@@ -138,6 +282,7 @@ export async function auditVoiceSegments(
138
282
  transcription: transcriptionText,
139
283
  repetitionDetected,
140
284
  durationAnomaly,
285
+ loudnessDip,
141
286
  issues,
142
287
  passed,
143
288
  });
@@ -3,5 +3,5 @@
3
3
  "kind": "ingestion",
4
4
  "entry": "./src/index.ts",
5
5
  "version": "1.0.0",
6
- "description": "Ingests curated AI news items from aihot.virxact.com into Doc[]"
6
+ "description": "Ingests fresh AI news from an RSS feed (default TechCrunch AI) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
7
7
  }
@@ -6,9 +6,11 @@ import { echoError } from '@gobing-ai/ts-utils';
6
6
  import { z } from 'zod';
7
7
  import { type AihotDeps, createAihotClient } from './client';
8
8
  import { mapAihotItemsToDocs } from './mapper';
9
+ import { AIHOT_FEED_URL_DEFAULT, fetchRssDocs, type RssFetchLike } from './rss';
9
10
 
10
11
  export * from './client';
11
12
  export * from './mapper';
13
+ export * from './rss';
12
14
 
13
15
  export interface IngestionOptions {
14
16
  out: string;
@@ -38,8 +40,26 @@ function parseLimit(value: string): number {
38
40
  export async function processIngestionIO(
39
41
  options: IngestionOptions,
40
42
  depsOverride?: Partial<AihotDeps>,
43
+ rssFetchLike: RssFetchLike = fetch,
41
44
  ): Promise<IngestionResult> {
42
45
  const fs = createNodeFileSystem();
46
+
47
+ // RSS is the default source (2026-09-08 dogfood: the aihot.virxact.com API served a
48
+ // 2020–2025 archive — every item failed the 7-day freshness gate; see rss.ts). The API
49
+ // path stays reachable with AIHOT_SOURCE=api for when the CMS carries live curated items.
50
+ const source = process.env.AIHOT_SOURCE?.trim() || 'rss';
51
+ if (source === 'api') {
52
+ return processApiSource(options, fs, depsOverride);
53
+ }
54
+ return processRssSource(options, fs, rssFetchLike);
55
+ }
56
+
57
+ /** Legacy aihot.virxact.com API source (cursor/stateFile contract preserved). */
58
+ async function processApiSource(
59
+ options: IngestionOptions,
60
+ fs: ReturnType<typeof createNodeFileSystem>,
61
+ depsOverride?: Partial<AihotDeps>,
62
+ ): Promise<IngestionResult> {
43
63
  const client = createAihotClient(depsOverride);
44
64
 
45
65
  let effectiveCursor = options.cursor?.trim();
@@ -68,30 +88,69 @@ export async function processIngestionIO(
68
88
  }
69
89
 
70
90
  const docs = DocListSchema.parse(mapAihotItemsToDocs(items));
71
-
72
- const outDir = dirname(options.out);
73
- if (outDir && outDir !== '.') {
74
- await fs.ensureDir(outDir);
75
- }
76
- await atomicWriteJson(options.out, docs, fs);
91
+ await writeOutput(fs, options, docs);
77
92
 
78
93
  if (options.stateFile) {
79
- const stateDir = dirname(options.stateFile);
80
- if (stateDir && stateDir !== '.') {
81
- await fs.ensureDir(stateDir);
82
- }
83
- const statePayload = {
94
+ await writeState(fs, options.stateFile, {
84
95
  cursor,
85
96
  asOf,
86
97
  lastSyncAt: new Date().toISOString(),
87
98
  itemCount: docs.length,
88
- };
89
- await atomicWriteJson(options.stateFile, statePayload, fs);
99
+ });
90
100
  }
91
101
 
92
102
  return { docs, cursor, asOf };
93
103
  }
94
104
 
105
+ /** RSS feed source (default): stateless fetch, no cursor (feed order is newest-first). */
106
+ async function processRssSource(
107
+ options: IngestionOptions,
108
+ fs: ReturnType<typeof createNodeFileSystem>,
109
+ rssFetchLike: RssFetchLike,
110
+ ): Promise<IngestionResult> {
111
+ const feedUrl = process.env.AIHOT_FEED_URL?.trim() || AIHOT_FEED_URL_DEFAULT;
112
+ const limit = options.limit ?? 10;
113
+ const { docs, asOf } = await fetchRssDocs(feedUrl, limit, rssFetchLike);
114
+ const validated = DocListSchema.parse(docs);
115
+ await writeOutput(fs, options, validated);
116
+
117
+ if (options.stateFile) {
118
+ await writeState(fs, options.stateFile, {
119
+ source: 'rss',
120
+ feedUrl,
121
+ asOf,
122
+ lastSyncAt: new Date().toISOString(),
123
+ itemCount: validated.length,
124
+ });
125
+ }
126
+
127
+ return { docs: validated, cursor: '', asOf };
128
+ }
129
+
130
+ async function writeOutput(
131
+ fs: ReturnType<typeof createNodeFileSystem>,
132
+ options: IngestionOptions,
133
+ docs: Doc[],
134
+ ): Promise<void> {
135
+ const outDir = dirname(options.out);
136
+ if (outDir && outDir !== '.') {
137
+ await fs.ensureDir(outDir);
138
+ }
139
+ await atomicWriteJson(options.out, docs, fs);
140
+ }
141
+
142
+ async function writeState(
143
+ fs: ReturnType<typeof createNodeFileSystem>,
144
+ stateFile: string,
145
+ payload: Record<string, unknown>,
146
+ ): Promise<void> {
147
+ const stateDir = dirname(stateFile);
148
+ if (stateDir && stateDir !== '.') {
149
+ await fs.ensureDir(stateDir);
150
+ }
151
+ await atomicWriteJson(stateFile, payload, fs);
152
+ }
153
+
95
154
  export async function main(depsOverride?: Partial<AihotDeps>): Promise<number> {
96
155
  let values: { in?: string; out?: string; limit?: string; cursor?: string; 'state-file'?: string };
97
156
  try {
@@ -16,6 +16,7 @@ export function mapAihotItemToDoc(item: AihotItem): Doc {
16
16
  const body = `${item.summary?.trim() || item.title.trim()}\n`;
17
17
 
18
18
  const metadata: Record<string, unknown> = {
19
+ source: 'aihot',
19
20
  sourceName: item.source.name,
20
21
  publishedAt: item.publishedAt,
21
22
  discoveredAt: item.discoveredAt,
@@ -0,0 +1,151 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Doc } from '@gobing-ai/kk-core';
3
+
4
+ /**
5
+ * RSS/Atom feed fetch + item → Doc mapping for the aihot-ingest RSS source.
6
+ *
7
+ * Why RSS (2026-09-08, dogfood): aihot.virxact.com's API served a 2020–2025 blog
8
+ * archive — all 10 items were dropped every run by the workflow's 7-day freshness
9
+ * gate, so the "curated" aihot source contributed zero usable items while carrying
10
+ * the highest selection tier (SOURCE_BASE aihot=30, importance 4). The RSS source
11
+ * restores aihot as a genuinely fresh feed; the API path stays selectable via
12
+ * AIHOT_SOURCE=api for when the CMS has live curated content.
13
+ *
14
+ * Keeps metadata.source='aihot' so planEpisode's aihot tier (and importance base 4)
15
+ * still applies to feed items.
16
+ */
17
+
18
+ /** Default fresh AI-news feed (RSS 2.0, high volume, reachable without auth). */
19
+ export const AIHOT_FEED_URL_DEFAULT = 'https://techcrunch.com/category/artificial-intelligence/feed/';
20
+
21
+ export type RssFetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
22
+
23
+ export interface RssEntry {
24
+ title: string;
25
+ link: string;
26
+ publishedAt: string | null; // ISO-8601 or null
27
+ summary: string;
28
+ category: string | null;
29
+ }
30
+
31
+ /** Strip XML/HTML tags and normalize common entities to plain text. */
32
+ function stripMarkup(raw: string): string {
33
+ return raw
34
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
35
+ .replace(/<[^>]+>/g, ' ')
36
+ .replace(/&nbsp;/g, ' ')
37
+ .replace(/&amp;/g, '&')
38
+ .replace(/&lt;/g, '<')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/&#39;|&apos;/g, "'")
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ }
45
+
46
+ /** RSS <pubDate> (RFC 822) or Atom <published>/<updated> (ISO-8601) → ISO-8601. */
47
+ function toIsoDate(raw: string | undefined): string | null {
48
+ if (!raw?.trim()) return null;
49
+ const t = Date.parse(raw.trim().replace('UT', 'GMT'));
50
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
51
+ }
52
+
53
+ /** Extract a single sub-element's text from an element body. */
54
+ function subElement(body: string, tag: string): string | undefined {
55
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
56
+ const m = body.match(re);
57
+ return m?.[1];
58
+ }
59
+
60
+ /** Extract a link: RSS <link>text</link> or Atom <link href="..."/> (first match wins). */
61
+ function subLink(body: string): string | undefined {
62
+ const text = subElement(body, 'link');
63
+ if (text?.trim()) return text.trim();
64
+ const m = body.match(/<link[^>]*\bhref\s*=\s*"([^"]+)"/i);
65
+ return m?.[1];
66
+ }
67
+
68
+ /** Block-split RSS <item>… or Atom <entry>… elements and map each to an RssEntry. */
69
+ export function parseFeed(xml: string): RssEntry[] {
70
+ const blocks: string[] = [];
71
+ const itemRe = /<item[\s>][\s\S]*?<\/item>/gi;
72
+ const entryRe = /<entry[\s>][\s\S]*?<\/entry>/gi;
73
+ let m = itemRe.exec(xml);
74
+ while (m !== null) {
75
+ blocks.push(m[0]);
76
+ m = itemRe.exec(xml);
77
+ }
78
+ if (blocks.length === 0) {
79
+ m = entryRe.exec(xml);
80
+ while (m !== null) {
81
+ blocks.push(m[0]);
82
+ m = entryRe.exec(xml);
83
+ }
84
+ }
85
+ return blocks.map((b) => {
86
+ const title = stripMarkup(subElement(b, 'title') ?? '');
87
+ const link = subLink(b) ?? '';
88
+ const category = stripMarkup(subElement(b, 'category') ?? '') || null;
89
+ const pubRaw = subElement(b, 'pubDate') ?? subElement(b, 'published') ?? subElement(b, 'updated');
90
+ const summary = stripMarkup(
91
+ subElement(b, 'content:encoded') ?? subElement(b, 'description') ?? subElement(b, 'summary') ?? title,
92
+ );
93
+ return { title, link, publishedAt: toIsoDate(pubRaw), summary, category };
94
+ });
95
+ }
96
+
97
+ export function computeRssDocId(seed: string): string {
98
+ return createHash('sha256').update(seed).digest('hex').slice(0, 16);
99
+ }
100
+
101
+ /** Map a feed entry to the kk Doc shape; source:'aihot' keeps the aihot plan tier. */
102
+ export function mapRssEntryToDoc(entry: RssEntry, feedUrl: string): Doc {
103
+ const id = entry.link ? computeRssDocId(entry.link) : computeRssDocId(`${entry.title}${entry.summary}`);
104
+ let sourceName = 'aihot-feed';
105
+ try {
106
+ sourceName = new URL(feedUrl).hostname;
107
+ } catch {
108
+ // keep 'aihot-feed' fallback
109
+ }
110
+ return {
111
+ id,
112
+ title: entry.title || 'Untitled AI News',
113
+ sourceUri: entry.link || feedUrl,
114
+ body: `${entry.title}\n\n${entry.summary}\n`,
115
+ mediaType: 'text/markdown',
116
+ metadata: {
117
+ source: 'aihot',
118
+ sourceName,
119
+ publishedAt: entry.publishedAt ?? undefined,
120
+ discoveredAt: new Date().toISOString(),
121
+ originalUrl: entry.link || undefined,
122
+ category: entry.category ?? undefined,
123
+ },
124
+ };
125
+ }
126
+
127
+ export async function fetchRssDocs(
128
+ feedUrl: string,
129
+ limit: number,
130
+ fetchLike: RssFetchLike = fetch,
131
+ ): Promise<{ docs: Doc[]; asOf: string }> {
132
+ const res = await fetchLike(feedUrl, {
133
+ headers: { 'user-agent': 'aihot-ingest/1.0.0 (RSS source)' },
134
+ });
135
+ if (!res.ok) {
136
+ throw new Error(`aihot-ingest RSS fetch failed: HTTP ${res.status} from ${feedUrl}`);
137
+ }
138
+ const xml = await res.text();
139
+ const entries = parseFeed(xml);
140
+ // Feed order is newest-first for RSS; atom may vary — prefer publication order.
141
+ const seen = new Set<string>();
142
+ const docs: Doc[] = [];
143
+ for (const entry of entries) {
144
+ if (docs.length >= limit) break;
145
+ const key = entry.link || entry.title;
146
+ if (seen.has(key)) continue;
147
+ seen.add(key);
148
+ docs.push(mapRssEntryToDoc(entry, feedUrl));
149
+ }
150
+ return { docs, asOf: new Date().toISOString() };
151
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/horizon-ingest",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }