@@ -30,12 +30,15 @@ export type RunEvent =
3030 | { k : 'tool' ; name : string ; args : Json ; result : Json }
3131 | { k : 'input' ; prompt : string ; answer : string }
3232 | { k : 'fetch' ; url : string ; status : number ; ok : boolean ; simulated : boolean }
33+ | { k : 'signal' ; phase : 'waiting' | 'receiving' | 'received' | 'timeout' | 'poll-empty' ; names : string [ ] ; timeoutMs ?: number | null ; result ?: Json }
34+ | { k : 'op' ; op : string ; label : string ; data ?: Json }
35+ | { k : 'dom' ; html : string ; ops : number }
3336 | { k : 'result' ; value : Json }
3437 | { k : 'error' ; text : string }
3538 | { k : 'done' }
3639 | { k : 'note' ; text : string } ;
3740
38- const KINDS = new Set ( [ 'log' , 'prompt' , 'tool' , 'input' , 'fetch' , 'result' , 'error' , 'done' , 'note' ] ) ;
41+ const KINDS = new Set ( [ 'log' , 'prompt' , 'tool' , 'input' , 'fetch' , 'signal' , 'op' , 'dom' , ' result', 'error' , 'done' , 'note' ] ) ;
3942
4043/** Journaled console lines (one JSON event per line) → renderable feed. */
4144export function parseRunFeed ( lines : string [ ] ) : RunEvent [ ] {
@@ -157,7 +160,9 @@ export async function decidePrompt(payload: { text: string; opts?: unknown }): P
157160 }
158161 return JSON . stringify ( { reply : String ( message . content ?? '' ) } satisfies ToolLoopDecision ) ;
159162 }
160- if ( ! key ) return OFFLINE_REPLY ;
163+ // Honor format:"json" in the offline stand-in: the harness parses the
164+ // reply, so hand it a valid JSON string literal instead of bare prose.
165+ if ( ! key ) return opts . format === 'json' ? JSON . stringify ( OFFLINE_REPLY ) : OFFLINE_REPLY ;
161166 const message = await chatCompletion ( key , {
162167 model : getOpenRouterModel ( ) ,
163168 messages : [ ...systemMessages ( opts ) , { role : 'user' , content : payload . text } ] ,
@@ -171,9 +176,11 @@ const asObj = (kwargs: Json): Record<string, Json> =>
171176 kwargs && typeof kwargs === 'object' && ! Array . isArray ( kwargs ) ? kwargs : { } ;
172177
173178/**
174- * The registry behind `chidori.tool()` calls in docs examples. Small on
175- * purpose: docs search (both spellings the docs use), plus the playground's
176- * deterministic calculator.
179+ * The registry behind `chidori.tool()` calls in docs examples: docs search
180+ * (both spellings the docs use), the playground's deterministic calculator,
181+ * and the Hacker News research tools the usability-review walkthroughs use
182+ * (Algolia's API is CORS-enabled, so they work live from the browser; when
183+ * the network is unavailable they degrade to a labelled simulated result).
177184 */
178185export function makeDocsTools (
179186 getIndex : ( ) => DocsIndex | null ,
@@ -187,13 +194,53 @@ export function makeDocsTools(
187194 ...( index ? { } : { note : 'docs index not loaded' } ) ,
188195 } ;
189196 } ;
197+ const hnFetch = async ( url : string ) : Promise < Json > => {
198+ const res = await fetchWithSimulatedFallback ( url ) ;
199+ return ( await res . json ( ) ) as Json ;
200+ } ;
190201 return {
191202 docs_search : search ,
192203 search_docs : search ,
193204 calculate : ( kwargs ) => {
194205 const expression = String ( asObj ( kwargs ) . expression ?? '' ) ;
195206 return { expression, value : evaluateExpression ( expression ) } ;
196207 } ,
208+ hn_search : async ( kwargs ) => {
209+ const args = asObj ( kwargs ) ;
210+ const endpoint = args . sortBy === 'date' ? 'search_by_date' : 'search' ;
211+ const data = asObj (
212+ await hnFetch (
213+ `https://hn.algolia.com/api/v1/${ endpoint } ?tags=story&hitsPerPage=8&query=${ encodeURIComponent ( String ( args . query ?? '' ) ) } ` ,
214+ ) ,
215+ ) ;
216+ if ( data . __simulated ) return { query : args . query ?? '' , hits : [ ] , note : 'offline — simulated empty result' } as Json ;
217+ const hits = ( Array . isArray ( data . hits ) ? data . hits : [ ] ) . map ( ( h ) => {
218+ const hit = asObj ( h ) ;
219+ return {
220+ objectID : hit . objectID ?? null ,
221+ title : hit . title ?? null ,
222+ url : hit . url ?? null ,
223+ points : hit . points ?? null ,
224+ numComments : hit . num_comments ?? null ,
225+ createdAt : hit . created_at ?? null ,
226+ } ;
227+ } ) ;
228+ return { query : args . query ?? '' , hits } as Json ;
229+ } ,
230+ hn_thread : async ( kwargs ) => {
231+ const args = asObj ( kwargs ) ;
232+ const data = asObj ( await hnFetch ( `https://hn.algolia.com/api/v1/items/${ encodeURIComponent ( String ( args . objectID ?? '' ) ) } ` ) ) ;
233+ if ( data . __simulated ) return { objectID : args . objectID ?? '' , note : 'offline — simulated empty thread' , comments : [ ] } as Json ;
234+ const strip = ( html : unknown ) => String ( html ?? '' ) . replace ( / < [ ^ > ] + > / g, ' ' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
235+ const comments = ( Array . isArray ( data . children ) ? data . children : [ ] )
236+ . slice ( 0 , 12 )
237+ . map ( ( c ) => {
238+ const comment = asObj ( c ) ;
239+ return { author : comment . author ?? null , text : strip ( comment . text ) . slice ( 0 , 600 ) } ;
240+ } )
241+ . filter ( ( c ) => c . text ) ;
242+ return { objectID : args . objectID ?? '' , title : data . title ?? null , points : data . points ?? null , comments } as Json ;
243+ } ,
197244 } ;
198245}
199246
0 commit comments