@@ -13,7 +13,7 @@ import fs from 'node:fs';
1313import path from 'node:path' ;
1414import os from 'node:os' ;
1515import { fileURLToPath } from 'node:url' ;
16- import { loadCorpus , taskMetadataForRead } from '@reneza/ats-core' ;
16+ import { loadCorpus , taskMetadataForRead , LINK_TYPES } from '@reneza/ats-core' ;
1717import { findSimilar } from '../../packages/adapter-ticktick/embedding.js' ;
1818import { loadState , benchReason , driftPenalty , recencyScore , impressionPenalty } from './state.mjs' ;
1919import { hasGoal } from './format.mjs' ;
@@ -97,11 +97,65 @@ function draftIntents(tasks) {
9797 } ) ;
9898}
9999
100+ // Shared headless-Claude call; returns stdout (or '' on error). Web search is
101+ // available under bypassPermissions, so a prompt may instruct the model to use it.
102+ function callClaude ( prompt , { timeout = 90000 , model = MODEL } = { } ) {
103+ return new Promise ( ( resolve ) => {
104+ const env = { ...process . env , HOME : '/home/debian' } ;
105+ delete env . CLAUDECODE ; delete env . CLAUDE_CODE_ENTRYPOINT ;
106+ const child = execFile ( '/home/debian/.local/bin/claude' , [ '-p' , '--model' , model , '--permission-mode' , 'bypassPermissions' ] ,
107+ { env, cwd : '/home/debian/claude' , timeout, maxBuffer : 4 * 1024 * 1024 } ,
108+ ( err , stdout ) => resolve ( err ? '' : String ( stdout ) ) ) ;
109+ child . stdin . end ( prompt ) ;
110+ } ) ;
111+ }
112+ // Research can hallucinate URLs on a small model — default to the batch model but
113+ // allow bumping (OPERATOR_RESEARCH_MODEL=sonnet) for better-grounded web results.
114+ const RESEARCH_MODEL = process . env . OPERATOR_RESEARCH_MODEL || MODEL ;
115+
116+ // Classify each semantically-close pair into a typed ATS relationship + the move
117+ // to make, so a relate card says "B is a subtask of A -> link as parent" instead
118+ // of a bare "link these two". One bounded call for the whole batch. Fails soft.
119+ async function classifyRelations ( pairs ) {
120+ if ( pairs . length === 0 ) return { } ;
121+ const payload = pairs . map ( ( p ) => ( { id : p . id , a : p . t . title , b : p . nt . title ,
122+ an : String ( p . t . content || '' ) . replace ( / \s + / g, ' ' ) . slice ( 0 , 160 ) , bn : String ( p . nt . content || '' ) . replace ( / \s + / g, ' ' ) . slice ( 0 , 160 ) } ) ) ;
123+ const prompt = [
124+ 'For each pair of tasks A and B, decide how they relate (direction is A -> B). Pick ONE type:' ,
125+ '- depends-on: A cannot proceed until B is done' ,
126+ '- blocks: A must be done before B can proceed' ,
127+ '- parent: B is a subtask / part of A' ,
128+ '- supports: both advance the same goal, neither blocks the other' ,
129+ '- supersedes: A and B are near-duplicates; A is the keeper, B is redundant' ,
130+ '- related: loosely connected, just cross-link' ,
131+ 'Return ONLY a JSON array, one object per pair: {id, type, move, why}.' ,
132+ 'move = imperative <=7 words (e.g. "link as depends-on", "mark B a subtask of A"). why = <=14 words.' ,
133+ 'Pairs:' , JSON . stringify ( payload ) ,
134+ ] . join ( '\n' ) ;
135+ const out = { } ;
136+ try { const m = ( await callClaude ( prompt , { timeout : 60000 } ) ) . match ( / \[ [ \s \S ] * \] / ) ; for ( const r of ( m ? JSON . parse ( m [ 0 ] ) : [ ] ) ) if ( r && r . id ) out [ r . id ] = r ; } catch { /* fail soft */ }
137+ return out ;
138+ }
139+
140+ // Research a task with web search; returns { refs:[{title,url}], nextStep } or null.
141+ // Scoped to research-y tasks and gated to the cron build (slow), never the hot path.
142+ async function researchTask ( task ) {
143+ const prompt = [
144+ 'You research a task using web search. Find 2-3 high-quality, current references' ,
145+ '(official docs, credible comparisons or guides) that would help do this task.' ,
146+ `Task: ${ JSON . stringify ( task . title ) } ` ,
147+ `Notes: ${ JSON . stringify ( String ( task . content || '' ) . replace ( / \s + / g, ' ' ) . slice ( 0 , 240 ) ) } ` ,
148+ 'Return ONLY JSON: {"refs":[{"title":"...","url":"..."}],"nextStep":"<one concrete next action, <=12 words>"}. No prose.' ,
149+ ] . join ( '\n' ) ;
150+ try { const m = ( await callClaude ( prompt , { timeout : 120000 , model : RESEARCH_MODEL } ) ) . match ( / \{ [ \s \S ] * \} / ) ; const o = m ? JSON . parse ( m [ 0 ] ) : null ; if ( o && Array . isArray ( o . refs ) ) return o ; } catch { /* fail soft */ }
151+ return null ;
152+ }
153+
100154// Round-robin across kinds (highest-scored first within each) so a batch always
101155// carries a MIX — not just goal cards. `relate` leads each round because it's the
102156// underrepresented axis ("which tasks are similar, link them?"); intent/next follow.
103157function pickDiverse ( cards , limit ) {
104- const order = [ 'relate' , 'intent' , 'next' ] ;
158+ const order = [ 'research' , ' relate', 'intent' , 'next' ] ;
105159 const buckets = new Map ( order . map ( ( k ) => [ k , [ ] ] ) ) ;
106160 const extra = [ ] ;
107161 for ( const c of cards ) ( buckets . get ( c . kind ) || extra ) . push ( c ) ;
@@ -117,7 +171,7 @@ function pickDiverse(cards, limit) {
117171 return out ;
118172}
119173
120- export async function buildBatch ( adapter , { existingIds = new Set ( ) , dismissed : dismissedMap = activeDismissed ( ) , limit = BATCH } = { } ) {
174+ export async function buildBatch ( adapter , { existingIds = new Set ( ) , dismissed : dismissedMap = activeDismissed ( ) , limit = BATCH , allowResearch = false } = { } ) {
121175 const now = Date . now ( ) ;
122176 const { corpus } = await loadCorpus ( adapter , { cache : true } ) ;
123177 const notes = await noteProjectSet ( adapter ) ;
@@ -217,6 +271,7 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed:
217271 // already full of intent/next, so a total-count cap would skip relate entirely
218272 // and the queue drifts to all-goal. Generate a pool; pickDiverse selects the mix.
219273 const pairSeen = new Set ( ) ;
274+ const relatePairs = [ ] ;
220275 let relateCount = 0 ;
221276 const relateTarget = Math . max ( limit , 10 ) ;
222277 for ( const { t, s } of ranked ) {
@@ -236,26 +291,75 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed:
236291 const id = `relate:${ t . id } :${ nid } ` ;
237292 if ( ! fresh ( id ) ) continue ;
238293 pairSeen . add ( key ) ;
239- cards . push ( { id, kind : 'relate' , score : s ,
240- items : [ { adapter : 'ticktick' , title : t . title } , { adapter : 'ticktick' , title : nt . title } ] ,
241- action : 'Link these two tasks' ,
242- back : { heading : 'Why' , body : `Semantically close (${ Math . round ( sc * 100 ) } %) but not linked. Approving files a Related link.` } ,
243- exec : { type : 'relate' , source : { projectId : t . projectId , taskId : t . id } , target : { projectId : nt . projectId , taskId : nid } , targetTitle : nt . title } } ) ;
294+ relatePairs . push ( { id, t, nt, sim : sc , s } ) ;
244295 relateCount += 1 ;
245296 break ;
246297 }
247298 }
248299
300+ // Classify the pairs into a typed ATS relationship + the move to make, then emit
301+ // the cards. One bounded LLM call for the whole batch (fails soft to 'related').
302+ const relations = await classifyRelations ( relatePairs ) ;
303+ const shortT = ( s ) => ( s . length > 26 ? `${ s . slice ( 0 , 25 ) } …` : s ) ;
304+ const phrase = {
305+ 'depends-on' : ( b ) => `Depends on “${ b } ”` ,
306+ blocks : ( b ) => `Blocks “${ b } ”` ,
307+ parent : ( b ) => `“${ b } ” is a subtask of this` ,
308+ supports : ( b ) => `Supports “${ b } ”` ,
309+ supersedes : ( b ) => `Duplicate of “${ b } ” — keep this` ,
310+ related : ( b ) => `Link to “${ b } ”` ,
311+ } ;
312+ for ( const p of relatePairs ) {
313+ const c = relations [ p . id ] || { } ;
314+ const type = LINK_TYPES . includes ( c . type ) ? c . type : 'related' ;
315+ const action = ( phrase [ type ] || phrase . related ) ( shortT ( p . nt . title ) ) ;
316+ cards . push ( { id : p . id , kind : 'relate' , score : p . s ,
317+ items : [ { adapter : 'ticktick' , title : p . t . title } , { adapter : 'ticktick' , title : p . nt . title } ] ,
318+ action,
319+ back : { heading : type === 'related' ? 'Why' : `Suggested: ${ type } ` , body : String ( c . why || '' ) . trim ( ) || `Semantically close (${ Math . round ( p . sim * 100 ) } %) but not linked yet.` } ,
320+ exec : { type : 'relate' , source : { projectId : p . t . projectId , taskId : p . t . id } , target : { projectId : p . nt . projectId , taskId : p . nt . id } , targetTitle : p . nt . title , relType : type , relDesc : String ( c . why || '' ) . trim ( ) } } ) ;
321+ }
322+
323+ // Stage 3 (cron only — slow web search): for research-y tasks with no references
324+ // yet, look up a few sources and suggest adding them. Gated to keep it off the
325+ // interactive refill path; bounded by OPERATOR_RESEARCH_BUDGET.
326+ if ( allowResearch ) {
327+ const RESEARCH_BUDGET = Number ( process . env . OPERATOR_RESEARCH_BUDGET || 2 ) ;
328+ const researchRe = / \b ( e v a l u a t e | a s s e s s | c o m p a r e | c o m p a r i s o n | v e r s u s | v s | r e s e a r c h | i n v e s t i g a t e | e x p l o r e | o p t i o n s | a l t e r n a t i v e s | b e s t | w h i c h | h o w t o | h o w d o | l e a r n | s t u d y | b e n c h m a r k | s h o r t l i s t | p r o s a n d c o n s | d e c i d e b e t w e e n | t o o l [ s ] ? f o r ) \b / i;
329+ const researchy = ranked
330+ . filter ( ( { t } ) => ! usedTask . has ( t . id ) && fresh ( `research:${ t . id } ` )
331+ && researchRe . test ( `${ t . title } ${ String ( t . content || '' ) . slice ( 0 , 200 ) } ` )
332+ && ! ( ( meta . get ( t . id ) ?. references || [ ] ) . length ) )
333+ . slice ( 0 , RESEARCH_BUDGET ) ;
334+ for ( const { t, s } of researchy ) {
335+ const r = await researchTask ( t ) ;
336+ const refs = ( r ?. refs || [ ] ) . filter ( ( x ) => x && x . url ) . slice ( 0 , 3 ) ;
337+ if ( ! refs . length ) continue ;
338+ cards . push ( { id : `research:${ t . id } ` , kind : 'research' , score : s + 0.2 ,
339+ items : [ { adapter : 'ticktick' , title : t . title } ] ,
340+ action : r . nextStep ? `Research: ${ r . nextStep } ` : `Add ${ refs . length } researched references` ,
341+ back : { heading : `Found ${ refs . length } references (web)` , body : refs . map ( ( x ) => x . title ) . join ( ' · ' ) } ,
342+ exec : { type : 'research' , source : { projectId : t . projectId , taskId : t . id } , refs, nextStep : r . nextStep || '' } } ) ;
343+ usedTask . add ( t . id ) ;
344+ }
345+ }
346+
249347 return pickDiverse ( cards , limit ) ;
250348}
251349
252- // CLI: top the queue up to BATCH, appending fresh cards.
350+ // CLI (cron): top the queue up to BATCH, and — since this is the only path that
351+ // runs the slow web-search stage — also inject a research card when the queue has
352+ // none, even if it's otherwise full. allowResearch is on here and ONLY here.
253353if ( process . argv [ 1 ] && fileURLToPath ( import . meta. url ) === path . resolve ( process . argv [ 1 ] ) ) {
254354 const mod = await import ( process . env . ATS_ADAPTER || '@reneza/ats-adapter-ticktick' ) ;
255355 const adapter = mod . default || mod ;
256356 const queue = loadQueue ( ) ;
257- if ( queue . length >= BATCH ) { console . log ( `queue already at ${ queue . length } ; nothing to do` ) ; process . exit ( 0 ) ; }
258- const batch = await buildBatch ( adapter , { existingIds : new Set ( queue . map ( ( c ) => c . id ) ) , limit : BATCH - queue . length } ) ;
259- saveQueue ( [ ...queue , ...batch ] ) ;
260- console . log ( `added ${ batch . length } (${ batch . map ( ( c ) => c . kind ) . join ( ',' ) } ); queue now ${ queue . length + batch . length } ` ) ;
357+ const need = Math . max ( 0 , BATCH - queue . length ) ;
358+ const hasResearch = queue . some ( ( c ) => c . kind === 'research' ) ;
359+ if ( need === 0 && hasResearch ) { console . log ( `queue at ${ queue . length } with research; nothing to do` ) ; process . exit ( 0 ) ; }
360+ const limit = Math . max ( need , hasResearch ? 0 : 3 ) ; // build a few even when full, so a research card can surface
361+ const batch = await buildBatch ( adapter , { existingIds : new Set ( queue . map ( ( c ) => c . id ) ) , limit, allowResearch : true } ) ;
362+ const merged = [ ...queue , ...batch . filter ( ( b ) => ! queue . some ( ( c ) => c . id === b . id ) ) ] ;
363+ saveQueue ( merged ) ;
364+ console . log ( `added ${ batch . length } (${ batch . map ( ( c ) => c . kind ) . join ( ',' ) } ); queue now ${ merged . length } ` ) ;
261365}
0 commit comments