
    `gj$                       d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	m
Z
 ddlmZmZmZmZ ddlmZ ddlmZ ddlmZ  ej.                  e      Z e       j5                         d	z  Zed
z  Z ej:                         ZdZ e h d      Z!dZ"dZ#dZ$ddZ%ddZ&ddZ'd dZ(d!dZ)d!dZ*	 	 	 	 	 	 	 	 	 	 	 	 d"dZ+d#dZ,d$dZ-d%dZ.ddd&dZ/d'dZ0y)(u  Suggested cron jobs — proposed automations the user accepts with one tap.

A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
user, who accepts it (creates the real cron job) or dismisses it (latched so
it is never re-offered). This is the single surface every automation proposal
flows through, regardless of where it came from:

  * ``catalog``  — a curated starter automation (daily briefing, important-mail
                   monitor, weekly digest, ...).
  * ``blueprint``   — the user installed a skill that carries a ``blueprint:`` block
                   (see ``tools/blueprints.py``); installing it registers a
                   suggestion instead of auto-scheduling.
  * ``usage``    — the background self-improvement review noticed a recurring
                   ask that a scheduled job would serve.
  * ``integration`` — the user connected an account (Gmail, GitHub, ...) and
                   the obvious automations for that surface are offered.

Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
the stored ``job_spec`` — there is NO second job engine. Suggestions never
auto-create jobs; acceptance is always explicit (consent-first). Dismissed
suggestions latch by a stable ``dedup_key`` so the same proposal is not
re-offered after the user says no.

Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
writes, an in-process lock, and 0600 perms.
    )annotationsN)Path)AnyDictListOptional)get_hermes_home)now)atomic_replacecronzsuggestions.json   >   usagecatalog	blueprintintegrationpendingaccepted	dismissedc                P    	 t        j                  | d       y # t        $ r Y y w xY w)Ni  )oschmodOSError)paths    C/root/.hermes/venv/lib/python3.12/site-packages/cron/suggestions.py_secure_filer   A   s'    
u s    	%%c                 2    t         j                  dd       y )NT)parentsexist_ok)CRON_DIRmkdir     r   _ensure_dirr#   H   s    NN4$N/r"   c                    t         j                         sdg iS 	 t        t         dd      5 } t        j                  |       }d d d        t        t              r!t        |j                  d      t              r|S t        |t              rd|iS t        j                  d       dg iS # 1 sw Y   gxY w# t        j
                  t        f$ r$}t        j                  d|       dg icY d }~S d }~ww xY w)Nsuggestionsrutf-8encodingz0suggestions.json unreadable (%s); starting emptyz*suggestions.json malformed; starting empty)SUGGESTIONS_FILEexistsopenjsonloadJSONDecodeErrorr   loggerwarning
isinstancedictgetlist)fdataes      r   	_load_rawr9   L   s    ""$r""#"C': 	 a99Q<D	 
 $*TXXm-Dd"K$t$$
NN?@2	  	   '* #I1Mr""#s4   B4 B(B4 (B1-B4 4C1C,&C1,C1c                D   t                t        j                  t        t        j
                        dd      \  }}	 t        j                  |dd      5 }t        j                  | t               j                         d|d	       |j                          t        j                  |j                                d d d        t        |t               t!        t               y # 1 sw Y   )xY w# t"        $ r' 	 t        j$                  |        # t&        $ r Y  w xY ww xY w)
Nz.tmpz.sugg_)dirsuffixprefixwr'   r(   )r%   
updated_at   )indent)r#   tempfilemkstempstrr*   parentr   fdopenr-   dump_hermes_now	isoformatflushfsyncfilenor   r   BaseExceptionunlinkr   )r%   fdtmp_pathr6   s       r   	_save_rawrQ   ]   s    M##,<,C,C(DV\deLBYYr31 	!QII +;=;R;R;TU
 GGIHHQXXZ 	! 	x!12%&	! 	!  	IIh 	  		sH   C/ A%C#;'C/ #C,(C/ /	D9DD	DDDDc                 6    t               j                  dg       S )z+Return all suggestion records (any status).r%   )r9   r4   r!   r"   r   load_suggestionsrS   s   s    ;??="--r"   c                 n    t               D  cg c]  } | j                  d      t        k(  s|  c} S c c} w )z<Return pending suggestions in creation order (oldest first).status)rS   r4   _STATUS_PENDING)ss    r   list_pendingrX   x   s(    ')P!QUU8_-OAPPPs   22c           
     R   |t         vrt        d|      | j                         r|j                         st        d      t        5  t	               j                  dg       }|D ]`  }|j                  d      |k(  s|j                  d      t        t        fv r
 ddd       y|j                  d      t        k(  sX ddd       y t        d |D              }|t        k\  r%t        j                  dt        |        	 ddd       yt        j                         j                  dd	 | j                         |j                         |||j                         t        t!               j#                         d
}|j%                  |       t'        |       |cddd       S # 1 sw Y   yxY w)u  Register a pending suggestion. Returns the record, or None if skipped.

    Skipped when: the source is unknown, the same ``dedup_key`` was already
    dismissed or accepted (never re-offer), an identical pending suggestion
    exists, or the pending list is full (``MAX_PENDING``).

    ``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting
    the suggestion passes it straight through, so there is no second schema to
    keep in sync.
    zunknown suggestion source: z title and dedup_key are requiredr%   	dedup_keyrU   Nc              3  R   K   | ]  }|j                  d       t        k(  sd ! yw)rU      N)r4   rV   ).0rW   s     r   	<genexpr>z!add_suggestion.<locals>.<genexpr>   s     Y!aeeHo6XAYs   ''z)Suggestion backlog full (%d); dropping %r   )idtitledescriptionsourcejob_specrZ   rU   
created_at)VALID_SOURCES
ValueErrorstrip_suggestions_lockr9   r4   _STATUS_DISMISSED_STATUS_ACCEPTEDrV   sumMAX_PENDINGr0   infouuiduuid4hexrH   rI   appendrQ   )	ra   rb   rc   rd   rZ   r%   existingpending_countrecords	            r   add_suggestionrv   }   sw   $ ]"6vjABB;;=	 1;<<	 koomR8 $ 	 H||K(I5<<).?AQ-RR  <<)_< 
	  Y{YYK'KKC[RWX $ **,""3B'[[]&,,. "*%%-113	
 	6"+;  s+   4F=F$F=F8FBFF&c                   t               }|D ]  }|j                  d      | k(  s|c S  | j                         rP|D cg c]  }|j                  d      t        k(  s| }}t	        |       dz
  }d|cxk  rt        |      k  r||   S  |D ]7  }|j                  dd      j                         | j                         k(  s5|c S  yc c}w )zDResolve a suggestion by id, 1-based pending index, or title (exact).r`   rU   r\   r   ra    N)rS   r4   isdigitrV   intlenlower)refr%   rW   r   idxs        r   get_suggestionr      s    "$K 55;#H {{})PQUU8_-O1PP#hl"c'l"3< #  55"##%4H  Qs   CCc                   t         5  t               j                  dg       }d}|D ]:  }|j                  d      | k(  s||d<   t               j	                         |d<   d} n |rt        |       |cd d d        S # 1 sw Y   y xY w)Nr%   Fr`   rU   resolved_atT)ri   r9   r4   rH   rI   rQ   )suggestion_idrU   r%   changedrW   s        r   _set_statusr      s    	 koomR8 	AuuT{m+$(#.=#:#:#<- 	 k"  s   6A;3A;;Bc                D    t        |       }|syt        |d   t              S )uF   Dismiss a suggestion (latched — never re-offered for its dedup_key).Fr`   )r   r   rj   )r}   rW   s     r   dismiss_suggestionr      s$    sAqw 122r"   )originc                   t        |       }|r|j                  d      t        k7  ryddlm} t        |j                  d      xs i       }|	d|vr||d<    |di |}t        |d   t               |S )	a]  Accept a suggestion: create the real cron job from its ``job_spec``.

    Returns the created cron job dict, or None if the suggestion isn't found /
    not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
    an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
    the chat where the user accepted.
    rU   Nr   )
create_jobrd   r   r`   r!   )r   r4   rV   	cron.jobsr   r3   r   rk   )r}   r   rW   r   specjobs         r   accept_suggestionr      su     	sAh?2$j!'R(Dhd2X

t
C$)*Jr"   c                    t         5  t               j                  dg       } | D cg c]  }|j                  d      t        k7  s| }}t	        |       t	        |      z
  }|rt        |       |cddd       S c c}w # 1 sw Y   yxY w)aq  Drop accepted/dismissed records from disk. Returns the count removed.

    Pending suggestions and the dedup memory of dismissed ones are the only
    things that matter long-term, but dismissed records must be RETAINED for
    their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
    records, which have served their purpose once the job exists.
    r%   rU   N)ri   r9   r4   rk   r{   rQ   )r%   rW   keptremoveds       r   clear_resolvedr      sy     
 koomR8&Na!%%/=M*MNNk"SY.dO N s"   A>A9A9'A>9A>>B)r   r   returnNone)r   r   )r   Dict[str, Any])r%   List[Dict[str, Any]]r   r   )r   r   )ra   rD   rb   rD   rc   rD   rd   r   rZ   rD   r   Optional[Dict[str, Any]])r}   rD   r   r   )r   rD   rU   rD   r   bool)r}   rD   r   r   )r}   rD   r   r   r   r   )r   rz   )1__doc__
__future__r   r-   loggingr   rB   	threadingro   pathlibr   typingr   r   r   r   hermes_constantsr	   hermes_timer
   rH   utilsr   	getLogger__name__r0   resolver   r*   Lockri   rm   	frozensetrf   rV   rk   rj   r   r#   r9   rQ   rS   rX   rv   r   r   r   r   r   r!   r"   r   <module>r      s  6 #   	     , , , *  			8	$
 $$&/00  #INN$  JK  0",.
Q
44 4 	4
 4 4 4n(3 GK .r"   