@@ -652,6 +652,16 @@ enum Commands {
652652 } ,
653653 /// List your stored user preferences.
654654 Preferences ,
655+ /// Distil this project's recurring decisions and constraints into durable
656+ /// semantic/procedural facts (Pillar C). MANUAL and opt-in — it makes ONE
657+ /// direct Haiku API call per run (needs ANTHROPIC_API_KEY; ~1c/run) and is
658+ /// never wired to a hook, so it can't spend automatically. Facts are stored
659+ /// as events in a per-project "conventions" task and surface in ask/recall.
660+ Consolidate {
661+ /// Maximum number of facts to produce.
662+ #[ arg( long, default_value_t = 8 ) ]
663+ max_facts : usize ,
664+ } ,
655665 /// Render and print the resume pack for a task.
656666 Pack {
657667 /// Task id (e.g. tj-7f3a).
@@ -1284,6 +1294,9 @@ fn main() -> Result<()> {
12841294 }
12851295 }
12861296 }
1297+ Commands :: Consolidate { max_facts } => {
1298+ run_consolidate ( max_facts) ?;
1299+ }
12871300 Commands :: Event {
12881301 task_id,
12891302 r#type,
@@ -3904,6 +3917,112 @@ fn emit_session_context(ctx: &str) {
39043917 println ! ( "{env}" ) ;
39053918}
39063919
3920+ const CONSOLIDATE_TASK_TITLE : & str = "Project conventions (consolidated)" ;
3921+
3922+ /// Manual consolidation: read this project's recurring decisions/constraints,
3923+ /// distil them into durable facts via one direct Haiku API call, and store the
3924+ /// facts as events in a per-project conventions task. Skips cleanly (no spend)
3925+ /// when ANTHROPIC_API_KEY is absent.
3926+ fn run_consolidate ( max_facts : usize ) -> anyhow:: Result < ( ) > {
3927+ let cwd = std:: env:: current_dir ( ) ?;
3928+ let project_hash = tj_core:: project_hash:: from_path ( & cwd) ?;
3929+ let events_path = tj_core:: paths:: events_dir ( ) ?. join ( format ! ( "{project_hash}.jsonl" ) ) ;
3930+ let state_path = tj_core:: paths:: state_dir ( ) ?. join ( format ! ( "{project_hash}.sqlite" ) ) ;
3931+ if !events_path. exists ( ) {
3932+ anyhow:: bail!( "no events file at {events_path:?}" ) ;
3933+ }
3934+ let conn = tj_core:: db:: open ( & state_path) ?;
3935+ tj_core:: db:: ingest_new_events ( & conn, & events_path, & project_hash) ?;
3936+
3937+ let sources = tj_core:: db:: high_signal_events ( & conn, 200 ) ?;
3938+ if sources. is_empty ( ) {
3939+ println ! ( "nothing to consolidate — no decisions/constraints/rejections recorded yet" ) ;
3940+ return Ok ( ( ) ) ;
3941+ }
3942+ let texts: Vec < String > = sources. iter ( ) . map ( |( _, t) | t. clone ( ) ) . collect ( ) ;
3943+ let source_ids: Vec < String > = sources. iter ( ) . map ( |( id, _) | id. clone ( ) ) . collect ( ) ;
3944+
3945+ let consolidator = match tj_core:: consolidate:: Consolidator :: from_env ( max_facts) {
3946+ Ok ( c) => c,
3947+ Err ( e) => {
3948+ println ! ( "skipped: {e}. Set ANTHROPIC_API_KEY to enable consolidation (~1c/run)." ) ;
3949+ return Ok ( ( ) ) ;
3950+ }
3951+ } ;
3952+ eprintln ! (
3953+ "consolidating {} high-signal event(s) via {} …" ,
3954+ texts. len( ) ,
3955+ consolidator. model
3956+ ) ;
3957+ let facts = consolidator. consolidate ( & texts) ?;
3958+ if facts. is_empty ( ) {
3959+ println ! ( "no durable facts found" ) ;
3960+ return Ok ( ( ) ) ;
3961+ }
3962+
3963+ // Reuse the per-project conventions task, or create it.
3964+ let task_id = match tj_core:: db:: find_task_by_title ( & conn, CONSOLIDATE_TASK_TITLE ) ? {
3965+ Some ( id) => id,
3966+ None => {
3967+ let id = tj_core:: new_task_id ( ) ;
3968+ let mut ev = tj_core:: event:: Event :: new (
3969+ id. clone ( ) ,
3970+ tj_core:: event:: EventType :: Open ,
3971+ tj_core:: event:: Author :: User ,
3972+ tj_core:: event:: Source :: Cli ,
3973+ CONSOLIDATE_TASK_TITLE . to_string ( ) ,
3974+ ) ;
3975+ ev. meta = serde_json:: json!( { "title" : CONSOLIDATE_TASK_TITLE } ) ;
3976+ let mut w = tj_core:: storage:: JsonlWriter :: open ( & events_path) ?;
3977+ w. append ( & ev) ?;
3978+ w. flush_durable ( ) ?;
3979+ tj_core:: db:: ingest_new_events ( & conn, & events_path, & project_hash) ?;
3980+ id
3981+ }
3982+ } ;
3983+
3984+ // De-dup against facts already stored in the conventions task.
3985+ let existing: std:: collections:: HashSet < String > =
3986+ tj_core:: db:: task_event_texts ( & conn, & task_id) ?
3987+ . into_iter ( )
3988+ . collect ( ) ;
3989+
3990+ let mut writer = tj_core:: storage:: JsonlWriter :: open ( & events_path) ?;
3991+ let mut written = 0usize ;
3992+ for f in & facts {
3993+ if existing. contains ( & f. text ) {
3994+ continue ;
3995+ }
3996+ let mut ev = tj_core:: event:: Event :: new (
3997+ task_id. clone ( ) ,
3998+ tj_core:: event:: EventType :: Finding ,
3999+ tj_core:: event:: Author :: Agent ,
4000+ tj_core:: event:: Source :: Cli ,
4001+ f. text . clone ( ) ,
4002+ ) ;
4003+ ev. meta = serde_json:: json!( {
4004+ "memory_tier" : f. tier,
4005+ "consolidated" : true ,
4006+ "derived_from" : source_ids,
4007+ } ) ;
4008+ writer. append ( & ev) ?;
4009+ written += 1 ;
4010+ }
4011+ writer. flush_durable ( ) ?;
4012+
4013+ // Index the new facts and push them to the global recall index.
4014+ tj_core:: db:: ingest_new_events ( & conn, & events_path, & project_hash) ?;
4015+ let embedder = tj_core:: embed:: default_embedder ( ) ;
4016+ let now = chrono:: Utc :: now ( ) . to_rfc3339 ( ) ;
4017+ tj_core:: db:: embed_pending ( & conn, & project_hash, embedder. as_ref ( ) , & now, 512 ) ?;
4018+ sync_global_memory ( & conn, & project_hash) ;
4019+
4020+ println ! (
4021+ "consolidated {written} new fact(s) into task {task_id} (\" {CONSOLIDATE_TASK_TITLE}\" )"
4022+ ) ;
4023+ Ok ( ( ) )
4024+ }
4025+
39074026fn auto_open_task_from_prompt (
39084027 events_path : & std:: path:: Path ,
39094028 project_hash : & str ,
0 commit comments